@@ -10,7 +10,15 @@ import {
1010 makeFakeModel ,
1111} from '@test/index' ;
1212
13- import { Agent } from '../src' ;
13+ import {
14+ Agent ,
15+ type AgentEvent ,
16+ type AgentTool ,
17+ DecisionValidationError ,
18+ MemoryRunStore ,
19+ RunNotFoundError ,
20+ ToolNotRegisteredError ,
21+ } from '../src' ;
1422
1523describe ( '@agentic-kit/agent' , ( ) => {
1624 it ( 'runs a minimal sequential tool loop' , async ( ) => {
@@ -47,7 +55,7 @@ describe('@agentic-kit/agent', () => {
4755 } ,
4856 required : [ 'text' ] ,
4957 } ,
50- execute : async ( _toolCallId , params ) => ( {
58+ execute : async ( _toolCallId , params , _decision ) => ( {
5159 content : [ { type : 'text' , text : String ( params . text ) } ] ,
5260 } ) ,
5361 } ,
@@ -184,3 +192,203 @@ function makeUsage() {
184192 cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 , total : 0 } ,
185193 } ;
186194}
195+
196+ describe ( '@agentic-kit/agent — pausable tools' , ( ) => {
197+ function makeApprovalTool ( execute : AgentTool [ 'execute' ] ) : AgentTool {
198+ return {
199+ name : 'approve' ,
200+ label : 'Approve' ,
201+ description : 'Tool that requires explicit approval' ,
202+ parameters : {
203+ type : 'object' ,
204+ properties : { target : { type : 'string' } } ,
205+ required : [ 'target' ] ,
206+ } ,
207+ decision : {
208+ type : 'object' ,
209+ properties : { approved : { type : 'boolean' } } ,
210+ required : [ 'approved' ] ,
211+ } ,
212+ execute,
213+ } ;
214+ }
215+
216+ function pauseResponse ( ) {
217+ return makeFakeAssistantMessage ( {
218+ stopReason : 'toolUse' ,
219+ content : [
220+ { type : 'toolCall' , id : 'tool_1' , name : 'approve' , arguments : { target : 'thing' } } ,
221+ ] ,
222+ } ) ;
223+ }
224+
225+ function finalResponse ( ) {
226+ return makeFakeAssistantMessage ( {
227+ stopReason : 'stop' ,
228+ content : [ { type : 'text' , text : 'finalized' } ] ,
229+ } ) ;
230+ }
231+
232+ it ( 'pauses on a decision-bearing tool, persists the run, and emits tool_decision_pending' , async ( ) => {
233+ const provider = createScriptedProvider ( { responses : [ pauseResponse ( ) ] } ) ;
234+ const runStore = new MemoryRunStore ( ) ;
235+ const saveSpy = jest . spyOn ( runStore , 'save' ) ;
236+ const execute = jest . fn ( ) ;
237+ const events : AgentEvent [ ] = [ ] ;
238+
239+ const agent = new Agent ( {
240+ initialState : { model : makeFakeModel ( ) } ,
241+ streamFn : provider . stream ,
242+ runStore,
243+ } ) ;
244+ agent . subscribe ( ( event ) => events . push ( event ) ) ;
245+ agent . setTools ( [ makeApprovalTool ( execute ) ] ) ;
246+
247+ await agent . prompt ( 'approve thing' ) ;
248+
249+ expect ( execute ) . not . toHaveBeenCalled ( ) ;
250+ expect ( saveSpy ) . toHaveBeenCalledTimes ( 1 ) ;
251+
252+ const pendingEvent = events . find ( ( e ) => e . type === 'tool_decision_pending' ) ;
253+ expect ( pendingEvent ) . toMatchObject ( {
254+ type : 'tool_decision_pending' ,
255+ toolCallId : 'tool_1' ,
256+ toolName : 'approve' ,
257+ input : { target : 'thing' } ,
258+ schema : expect . objectContaining ( { type : 'object' } ) ,
259+ } ) ;
260+
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' ) ;
274+ } ) ;
275+
276+ it ( 'resume invokes execute with the decision argument and continues the loop' , async ( ) => {
277+ const provider = createScriptedProvider ( { responses : [ pauseResponse ( ) , finalResponse ( ) ] } ) ;
278+ const execute = jest . fn (
279+ async ( _id : string , _params : Record < string , unknown > , decision : unknown ) => ( {
280+ content : [ { type : 'text' as const , text : `decision=${ JSON . stringify ( decision ) } ` } ] ,
281+ } )
282+ ) ;
283+ const events : AgentEvent [ ] = [ ] ;
284+
285+ const agent = new Agent ( {
286+ initialState : { model : makeFakeModel ( ) } ,
287+ streamFn : provider . stream ,
288+ } ) ;
289+ agent . subscribe ( ( event ) => events . push ( event ) ) ;
290+ agent . setTools ( [ makeApprovalTool ( execute ) ] ) ;
291+
292+ await agent . prompt ( 'approve thing' ) ;
293+ const runId = agent . pendingRunId ! ;
294+ expect ( runId ) . toBeTruthy ( ) ;
295+
296+ await agent . resume ( runId , { approved : true } ) ;
297+
298+ expect ( execute ) . toHaveBeenCalledTimes ( 1 ) ;
299+ expect ( execute . mock . calls [ 0 ] ?. [ 2 ] ) . toEqual ( { approved : true } ) ;
300+ expect ( agent . pendingRunId ) . toBeUndefined ( ) ;
301+
302+ expect ( agent . state . messages . at ( - 1 ) ) . toMatchObject ( {
303+ role : 'assistant' ,
304+ content : [ { type : 'text' , text : 'finalized' } ] ,
305+ } ) ;
306+ expect ( events . some ( ( e ) => e . type === 'agent_end' ) ) . toBe ( true ) ;
307+ } ) ;
308+
309+ it ( 'rejects a malformed decision and leaves the run resumable' , async ( ) => {
310+ 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+ ) ;
317+
318+ const agent = new Agent ( {
319+ initialState : { model : makeFakeModel ( ) } ,
320+ streamFn : provider . stream ,
321+ runStore,
322+ } ) ;
323+ agent . setTools ( [ makeApprovalTool ( execute ) ] ) ;
324+
325+ await agent . prompt ( 'approve thing' ) ;
326+ const runId = agent . pendingRunId ! ;
327+
328+ await expect ( agent . resume ( runId , { approved : 'yes' } ) ) . rejects . toBeInstanceOf (
329+ DecisionValidationError
330+ ) ;
331+ expect ( execute ) . not . toHaveBeenCalled ( ) ;
332+ expect ( agent . pendingRunId ) . toBe ( runId ) ;
333+ expect ( await runStore . load ( runId ) ) . toBeDefined ( ) ;
334+
335+ await agent . resume ( runId , { approved : true } ) ;
336+
337+ expect ( execute ) . toHaveBeenCalledTimes ( 1 ) ;
338+ expect ( agent . pendingRunId ) . toBeUndefined ( ) ;
339+ expect ( await runStore . load ( runId ) ) . toBeUndefined ( ) ;
340+ } ) ;
341+
342+ it ( 'throws RunNotFoundError when resuming an unknown run' , async ( ) => {
343+ const agent = new Agent ( {
344+ initialState : { model : makeFakeModel ( ) } ,
345+ streamFn : createScriptedProvider ( { responses : [ ] } ) . stream ,
346+ } ) ;
347+
348+ await expect ( agent . resume ( 'does-not-exist' , { approved : true } ) ) . rejects . toBeInstanceOf (
349+ RunNotFoundError
350+ ) ;
351+ } ) ;
352+
353+ it ( 'cleans up the persisted run when abort() is called while paused' , async ( ) => {
354+ const provider = createScriptedProvider ( { responses : [ pauseResponse ( ) ] } ) ;
355+ const runStore = new MemoryRunStore ( ) ;
356+
357+ const agent = new Agent ( {
358+ initialState : { model : makeFakeModel ( ) } ,
359+ streamFn : provider . stream ,
360+ runStore,
361+ } ) ;
362+ agent . setTools ( [ makeApprovalTool ( jest . fn ( ) ) ] ) ;
363+
364+ await agent . prompt ( 'approve thing' ) ;
365+ const runId = agent . pendingRunId ! ;
366+ expect ( await runStore . load ( runId ) ) . toBeDefined ( ) ;
367+
368+ agent . abort ( ) ;
369+ await new Promise ( ( resolve ) => setImmediate ( resolve ) ) ;
370+
371+ expect ( agent . pendingRunId ) . toBeUndefined ( ) ;
372+ expect ( await runStore . load ( runId ) ) . toBeUndefined ( ) ;
373+ } ) ;
374+
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 ( ) ) ;
378+
379+ const agent = new Agent ( {
380+ initialState : { model : makeFakeModel ( ) } ,
381+ streamFn : provider . stream ,
382+ } ) ;
383+ agent . setTools ( [ tool ] ) ;
384+
385+ await agent . prompt ( 'approve thing' ) ;
386+ const runId = agent . pendingRunId ! ;
387+
388+ agent . setTools ( [ ] ) ;
389+
390+ await expect ( agent . resume ( runId , { approved : true } ) ) . rejects . toBeInstanceOf (
391+ ToolNotRegisteredError
392+ ) ;
393+ } ) ;
394+ } ) ;
0 commit comments