Skip to content
This repository was archived by the owner on Jun 14, 2026. It is now read-only.

Commit 08fc6c1

Browse files
committed
feat(agent): message-log pause/resume + run handle
1 parent 1adfadb commit 08fc6c1

9 files changed

Lines changed: 932 additions & 301 deletions

File tree

packages/agent/__tests__/agent.test.ts

Lines changed: 156 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type Context,
44
createAssistantMessageEventStream,
55
type ModelDescriptor,
6+
type ToolCallContent,
67
} from 'agentic-kit';
78
import {
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

2321
describe('@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(/no tool calls awaiting a decision/);
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

Comments
 (0)