33 type Context ,
44 createAssistantMessageEventStream ,
55 type ModelDescriptor ,
6+ type ToolCallContent ,
67} from 'agentic-kit' ;
78import {
89 createScriptedProvider ,
@@ -15,9 +16,6 @@ import {
1516 type AgentEvent ,
1617 type AgentTool ,
1718 DecisionValidationError ,
18- MemoryRunStore ,
19- RunNotFoundError ,
20- ToolNotRegisteredError ,
2119} from '../src' ;
2220
2321describe ( '@agentic-kit/agent' , ( ) => {
@@ -229,51 +227,53 @@ describe('@agentic-kit/agent — pausable tools', () => {
229227 } ) ;
230228 }
231229
232- it ( 'pauses on a decision-bearing tool, persists the run, and emits tool_decision_pending' , async ( ) => {
230+ function attachDecision ( agent : Agent , toolCallId : string , decision : unknown ) : void {
231+ const messages = agent . state . messages ;
232+ const last = messages [ messages . length - 1 ] as AssistantMessage ;
233+ const updatedContent = last . content . map ( ( block ) =>
234+ block . type === 'toolCall' && block . id === toolCallId
235+ ? ( { ...block , decision } as ToolCallContent )
236+ : block
237+ ) ;
238+ const updated : AssistantMessage = { ...last , content : updatedContent } ;
239+ agent . replaceMessages ( [ ...messages . slice ( 0 , - 1 ) , updated ] ) ;
240+ }
241+
242+ it ( 'pauses on a decision-bearing tool and emits tool_decision_pending without runId' , async ( ) => {
233243 const provider = createScriptedProvider ( { responses : [ pauseResponse ( ) ] } ) ;
234- const runStore = new MemoryRunStore ( ) ;
235- const saveSpy = jest . spyOn ( runStore , 'save' ) ;
236244 const execute = jest . fn ( ) ;
237245 const events : AgentEvent [ ] = [ ] ;
238246
239247 const agent = new Agent ( {
240248 initialState : { model : makeFakeModel ( ) } ,
241249 streamFn : provider . stream ,
242- runStore,
243250 } ) ;
244251 agent . subscribe ( ( event ) => events . push ( event ) ) ;
245252 agent . setTools ( [ makeApprovalTool ( execute ) ] ) ;
246253
247254 await agent . prompt ( 'approve thing' ) ;
248255
249256 expect ( execute ) . not . toHaveBeenCalled ( ) ;
250- expect ( saveSpy ) . toHaveBeenCalledTimes ( 1 ) ;
257+ expect ( agent . state . isStreaming ) . toBe ( false ) ;
258+ expect ( events . some ( ( e ) => e . type === 'agent_end' ) ) . toBe ( false ) ;
251259
252260 const pendingEvent = events . find ( ( e ) => e . type === 'tool_decision_pending' ) ;
253- expect ( pendingEvent ) . toMatchObject ( {
261+ expect ( pendingEvent ) . toEqual ( {
254262 type : 'tool_decision_pending' ,
255263 toolCallId : 'tool_1' ,
256264 toolName : 'approve' ,
257265 input : { target : 'thing' } ,
258266 schema : expect . objectContaining ( { type : 'object' } ) ,
259267 } ) ;
268+ expect ( pendingEvent ) . not . toHaveProperty ( 'runId' ) ;
260269
261- const runId = ( pendingEvent as { runId : string } ) . runId ;
262- expect ( runId ) . toBeTruthy ( ) ;
263- expect ( agent . pendingRunId ) . toBe ( runId ) ;
264- expect ( agent . state . isStreaming ) . toBe ( false ) ;
265-
266- expect ( events . some ( ( e ) => e . type === 'agent_end' ) ) . toBe ( false ) ;
267-
268- const stored = await runStore . load ( runId ) ;
269- expect ( stored ) . toMatchObject ( {
270- id : runId ,
271- pending : { toolCallId : 'tool_1' , toolName : 'approve' , input : { target : 'thing' } } ,
272- } ) ;
273- expect ( stored ?. tools [ 0 ] ) . not . toHaveProperty ( 'execute' ) ;
270+ const lastMessage = agent . state . messages . at ( - 1 ) ;
271+ expect ( lastMessage ) . toMatchObject ( { role : 'assistant' , stopReason : 'toolUse' } ) ;
272+ const toolResults = agent . state . messages . filter ( ( m ) => m . role === 'toolResult' ) ;
273+ expect ( toolResults ) . toHaveLength ( 0 ) ;
274274 } ) ;
275275
276- it ( 'resume invokes execute with the decision argument and continues the loop' , async ( ) => {
276+ it ( 'continue() invokes execute with the decision attached to the tool call and continues the loop' , async ( ) => {
277277 const provider = createScriptedProvider ( { responses : [ pauseResponse ( ) , finalResponse ( ) ] } ) ;
278278 const execute = jest . fn (
279279 async ( _id : string , _params : Record < string , unknown > , decision : unknown ) => ( {
@@ -290,14 +290,13 @@ describe('@agentic-kit/agent — pausable tools', () => {
290290 agent . setTools ( [ makeApprovalTool ( execute ) ] ) ;
291291
292292 await agent . prompt ( 'approve thing' ) ;
293- const runId = agent . pendingRunId ! ;
294- expect ( runId ) . toBeTruthy ( ) ;
295293
296- await agent . resume ( runId , { approved : true } ) ;
294+ attachDecision ( agent , 'tool_1' , { approved : true } ) ;
295+
296+ await agent . continue ( ) ;
297297
298298 expect ( execute ) . toHaveBeenCalledTimes ( 1 ) ;
299299 expect ( execute . mock . calls [ 0 ] ?. [ 2 ] ) . toEqual ( { approved : true } ) ;
300- expect ( agent . pendingRunId ) . toBeUndefined ( ) ;
301300
302301 expect ( agent . state . messages . at ( - 1 ) ) . toMatchObject ( {
303302 role : 'assistant' ,
@@ -306,89 +305,178 @@ describe('@agentic-kit/agent — pausable tools', () => {
306305 expect ( events . some ( ( e ) => e . type === 'agent_end' ) ) . toBe ( true ) ;
307306 } ) ;
308307
309- it ( 'rejects a malformed decision and leaves the run resumable ' , async ( ) => {
308+ it ( 'continue() throws DecisionValidationError synchronously on a malformed decision ' , async ( ) => {
310309 const provider = createScriptedProvider ( { responses : [ pauseResponse ( ) , finalResponse ( ) ] } ) ;
311- const runStore = new MemoryRunStore ( ) ;
312- const execute = jest . fn (
313- async ( _id : string , _params : Record < string , unknown > , decision : unknown ) => ( {
314- content : [ { type : 'text' as const , text : `decision=${ JSON . stringify ( decision ) } ` } ] ,
315- } )
316- ) ;
310+ const execute = jest . fn ( async ( ) => ( {
311+ content : [ { type : 'text' as const , text : 'ok' } ] ,
312+ } ) ) ;
317313
318314 const agent = new Agent ( {
319315 initialState : { model : makeFakeModel ( ) } ,
320316 streamFn : provider . stream ,
321- runStore,
322317 } ) ;
323318 agent . setTools ( [ makeApprovalTool ( execute ) ] ) ;
324319
325320 await agent . prompt ( 'approve thing' ) ;
326- const runId = agent . pendingRunId ! ;
327321
328- await expect ( agent . resume ( runId , { approved : 'yes' } ) ) . rejects . toBeInstanceOf (
329- DecisionValidationError
330- ) ;
322+ attachDecision ( agent , 'tool_1' , { approved : 'yes' } ) ;
323+
324+ expect ( ( ) => agent . continue ( ) ) . toThrow ( DecisionValidationError ) ;
331325 expect ( execute ) . not . toHaveBeenCalled ( ) ;
332- expect ( agent . pendingRunId ) . toBe ( runId ) ;
333- expect ( await runStore . load ( runId ) ) . toBeDefined ( ) ;
326+ const toolResults = agent . state . messages . filter ( ( m ) => m . role === 'toolResult' ) ;
327+ expect ( toolResults ) . toHaveLength ( 0 ) ;
334328
335- await agent . resume ( runId , { approved : true } ) ;
329+ attachDecision ( agent , 'tool_1' , { approved : true } ) ;
330+ await agent . continue ( ) ;
336331
337332 expect ( execute ) . toHaveBeenCalledTimes ( 1 ) ;
338- expect ( agent . pendingRunId ) . toBeUndefined ( ) ;
339- expect ( await runStore . load ( runId ) ) . toBeUndefined ( ) ;
340333 } ) ;
341334
342- it ( 'throws RunNotFoundError when resuming an unknown run' , async ( ) => {
335+ it ( 'continue() rejects when the trailing assistant has tool calls but no decisions attached' , async ( ) => {
336+ const provider = createScriptedProvider ( { responses : [ pauseResponse ( ) ] } ) ;
337+
343338 const agent = new Agent ( {
344339 initialState : { model : makeFakeModel ( ) } ,
345- streamFn : createScriptedProvider ( { responses : [ ] } ) . stream ,
340+ streamFn : provider . stream ,
346341 } ) ;
342+ agent . setTools ( [ makeApprovalTool ( jest . fn ( ) ) ] ) ;
347343
348- await expect ( agent . resume ( 'does-not-exist' , { approved : true } ) ) . rejects . toBeInstanceOf (
349- RunNotFoundError
350- ) ;
344+ await agent . prompt ( 'approve thing' ) ;
345+
346+ expect ( ( ) => agent . continue ( ) ) . toThrow ( / n o t o o l c a l l s a w a i t i n g a d e c i s i o n / ) ;
351347 } ) ;
352348
353- it ( 'cleans up the persisted run when abort() is called while paused' , async ( ) => {
349+ it ( 'abort() while paused stops further work without throwing ' , async ( ) => {
354350 const provider = createScriptedProvider ( { responses : [ pauseResponse ( ) ] } ) ;
355- const runStore = new MemoryRunStore ( ) ;
356351
357352 const agent = new Agent ( {
358353 initialState : { model : makeFakeModel ( ) } ,
359354 streamFn : provider . stream ,
360- runStore,
361355 } ) ;
362356 agent . setTools ( [ makeApprovalTool ( jest . fn ( ) ) ] ) ;
363357
364358 await agent . prompt ( 'approve thing' ) ;
365- const runId = agent . pendingRunId ! ;
366- expect ( await runStore . load ( runId ) ) . toBeDefined ( ) ;
367359
368- agent . abort ( ) ;
369- await new Promise ( ( resolve ) => setImmediate ( resolve ) ) ;
360+ expect ( ( ) => agent . abort ( ) ) . not . toThrow ( ) ;
361+ expect ( agent . state . isStreaming ) . toBe ( false ) ;
362+ } ) ;
363+
364+ it ( 'flushes prior tool results before the args-validation error tool_result on a mixed batch' , async ( ) => {
365+ const provider = createScriptedProvider ( {
366+ responses : [
367+ makeFakeAssistantMessage ( {
368+ stopReason : 'toolUse' ,
369+ content : [
370+ { type : 'toolCall' , id : 'tool_regular' , name : 'echo' , arguments : { text : 'first' } } ,
371+ { type : 'toolCall' , id : 'tool_approve' , name : 'approve' , arguments : { } } ,
372+ ] ,
373+ } ) ,
374+ makeFakeAssistantMessage ( {
375+ stopReason : 'stop' ,
376+ content : [ { type : 'text' , text : 'recovered' } ] ,
377+ } ) ,
378+ ] ,
379+ } ) ;
380+
381+ const regularExecute = jest . fn ( async ( ) => ( {
382+ content : [ { type : 'text' as const , text : 'first-result' } ] ,
383+ } ) ) ;
384+ const approveExecute = jest . fn ( async ( ) => ( {
385+ content : [ { type : 'text' as const , text : 'should not run' } ] ,
386+ } ) ) ;
370387
371- expect ( agent . pendingRunId ) . toBeUndefined ( ) ;
372- expect ( await runStore . load ( runId ) ) . toBeUndefined ( ) ;
388+ const agent = new Agent ( {
389+ initialState : { model : makeFakeModel ( ) } ,
390+ streamFn : provider . stream ,
391+ } ) ;
392+ agent . setTools ( [
393+ {
394+ name : 'echo' ,
395+ label : 'Echo' ,
396+ description : 'Echo text' ,
397+ parameters : {
398+ type : 'object' ,
399+ properties : { text : { type : 'string' } } ,
400+ required : [ 'text' ] ,
401+ } ,
402+ execute : regularExecute ,
403+ } ,
404+ makeApprovalTool ( approveExecute ) ,
405+ ] ) ;
406+
407+ await agent . prompt ( 'go' ) ;
408+
409+ expect ( regularExecute ) . toHaveBeenCalledTimes ( 1 ) ;
410+ expect ( approveExecute ) . not . toHaveBeenCalled ( ) ;
411+
412+ const messages = agent . state . messages ;
413+ expect ( messages [ 1 ] ) . toMatchObject ( { role : 'assistant' , stopReason : 'toolUse' } ) ;
414+ expect ( messages [ 2 ] ) . toMatchObject ( {
415+ role : 'toolResult' ,
416+ toolCallId : 'tool_regular' ,
417+ toolName : 'echo' ,
418+ content : [ { type : 'text' , text : 'first-result' } ] ,
419+ } ) ;
420+ expect ( messages [ 3 ] ) . toMatchObject ( {
421+ role : 'toolResult' ,
422+ toolCallId : 'tool_approve' ,
423+ toolName : 'approve' ,
424+ isError : true ,
425+ } ) ;
426+ expect ( messages [ 3 ] . content [ 0 ] ) . toMatchObject ( {
427+ type : 'text' ,
428+ text : expect . stringContaining ( 'Tool argument validation failed' ) ,
429+ } ) ;
430+ expect ( messages [ 4 ] ) . toMatchObject ( {
431+ role : 'assistant' ,
432+ content : [ { type : 'text' , text : 'recovered' } ] ,
433+ } ) ;
373434 } ) ;
374435
375- it ( 'throws ToolNotRegisteredError when resuming after the tool has been removed' , async ( ) => {
376- const provider = createScriptedProvider ( { responses : [ pauseResponse ( ) , finalResponse ( ) ] } ) ;
377- const tool = makeApprovalTool ( jest . fn ( ) ) ;
436+ it ( 'regression: a tool without a decision schema runs without pausing' , async ( ) => {
437+ const provider = createScriptedProvider ( {
438+ responses : [
439+ makeFakeAssistantMessage ( {
440+ stopReason : 'toolUse' ,
441+ content : [
442+ { type : 'toolCall' , id : 'tool_1' , name : 'echo' , arguments : { text : 'hi' } } ,
443+ ] ,
444+ } ) ,
445+ makeFakeAssistantMessage ( {
446+ stopReason : 'stop' ,
447+ content : [ { type : 'text' , text : 'done' } ] ,
448+ } ) ,
449+ ] ,
450+ } ) ;
451+ const execute = jest . fn ( async ( ) => ( {
452+ content : [ { type : 'text' as const , text : 'hi' } ] ,
453+ } ) ) ;
378454
379455 const agent = new Agent ( {
380456 initialState : { model : makeFakeModel ( ) } ,
381457 streamFn : provider . stream ,
382458 } ) ;
383- agent . setTools ( [ tool ] ) ;
459+ agent . setTools ( [
460+ {
461+ name : 'echo' ,
462+ label : 'Echo' ,
463+ description : 'Echo text' ,
464+ parameters : {
465+ type : 'object' ,
466+ properties : { text : { type : 'string' } } ,
467+ required : [ 'text' ] ,
468+ } ,
469+ execute,
470+ } ,
471+ ] ) ;
384472
385- await agent . prompt ( 'approve thing' ) ;
386- const runId = agent . pendingRunId ! ;
473+ const events : AgentEvent [ ] = [ ] ;
474+ agent . subscribe ( ( e ) => events . push ( e ) ) ;
387475
388- agent . setTools ( [ ] ) ;
476+ await agent . prompt ( 'go' ) ;
389477
390- await expect ( agent . resume ( runId , { approved : true } ) ) . rejects . toBeInstanceOf (
391- ToolNotRegisteredError
392- ) ;
478+ expect ( execute ) . toHaveBeenCalledTimes ( 1 ) ;
479+ expect ( events . some ( ( e ) => e . type === 'tool_decision_pending' ) ) . toBe ( false ) ;
480+ expect ( events . some ( ( e ) => e . type === 'agent_end' ) ) . toBe ( true ) ;
393481 } ) ;
394482} ) ;
0 commit comments