|
2 | 2 | type AssistantMessage, |
3 | 3 | type Context, |
4 | 4 | createAssistantMessageEventStream, |
| 5 | + type Message, |
5 | 6 | type ModelDescriptor, |
6 | 7 | type ToolCallContent, |
7 | 8 | } from 'agentic-kit'; |
@@ -346,6 +347,41 @@ describe('@agentic-kit/agent — pausable tools', () => { |
346 | 347 | expect(() => agent.continue()).toThrow(/no tool calls awaiting a decision/); |
347 | 348 | }); |
348 | 349 |
|
| 350 | + it('continue() resumes from a non-trailing assistant when a later message was appended after the pause', async () => { |
| 351 | + const provider = createScriptedProvider({ responses: [pauseResponse(), finalResponse()] }); |
| 352 | + const execute = jest.fn( |
| 353 | + async (_id: string, _params: Record<string, unknown>, decision: unknown) => ({ |
| 354 | + content: [{ type: 'text' as const, text: `decision=${JSON.stringify(decision)}` }], |
| 355 | + }) |
| 356 | + ); |
| 357 | + |
| 358 | + const agent = new Agent({ |
| 359 | + initialState: { model: makeFakeModel() }, |
| 360 | + streamFn: provider.stream, |
| 361 | + }); |
| 362 | + agent.setTools([makeApprovalTool(execute)]); |
| 363 | + |
| 364 | + await agent.prompt('approve thing'); |
| 365 | + |
| 366 | + attachDecision(agent, 'tool_1', { approved: true }); |
| 367 | + |
| 368 | + const trailingNote: Message = { |
| 369 | + role: 'user', |
| 370 | + content: 'side note injected by an external queue while paused', |
| 371 | + timestamp: Date.now(), |
| 372 | + }; |
| 373 | + agent.replaceMessages([...agent.state.messages, trailingNote]); |
| 374 | + |
| 375 | + await agent.continue(); |
| 376 | + |
| 377 | + expect(execute).toHaveBeenCalledTimes(1); |
| 378 | + expect(execute.mock.calls[0]?.[2]).toEqual({ approved: true }); |
| 379 | + expect(agent.state.messages.at(-1)).toMatchObject({ |
| 380 | + role: 'assistant', |
| 381 | + content: [{ type: 'text', text: 'finalized' }], |
| 382 | + }); |
| 383 | + }); |
| 384 | + |
349 | 385 | it('abort() while paused stops further work without throwing', async () => { |
350 | 386 | const provider = createScriptedProvider({ responses: [pauseResponse()] }); |
351 | 387 |
|
@@ -480,3 +516,124 @@ describe('@agentic-kit/agent — pausable tools', () => { |
480 | 516 | expect(events.some((e) => e.type === 'agent_end')).toBe(true); |
481 | 517 | }); |
482 | 518 | }); |
| 519 | + |
| 520 | +describe('@agentic-kit/agent — maxSteps', () => { |
| 521 | + function makeEchoTool(): AgentTool { |
| 522 | + return { |
| 523 | + name: 'echo', |
| 524 | + label: 'Echo', |
| 525 | + description: 'Echo text', |
| 526 | + parameters: { |
| 527 | + type: 'object', |
| 528 | + properties: { text: { type: 'string' } }, |
| 529 | + required: ['text'], |
| 530 | + }, |
| 531 | + execute: async (_id, params) => ({ |
| 532 | + content: [{ type: 'text', text: String(params.text) }], |
| 533 | + }), |
| 534 | + }; |
| 535 | + } |
| 536 | + |
| 537 | + function toolThenText(toolText = 'one', finalText = 'done') { |
| 538 | + return [ |
| 539 | + makeFakeAssistantMessage({ |
| 540 | + stopReason: 'toolUse', |
| 541 | + content: [ |
| 542 | + { type: 'toolCall', id: 'tool_1', name: 'echo', arguments: { text: toolText } }, |
| 543 | + ], |
| 544 | + }), |
| 545 | + makeFakeAssistantMessage({ |
| 546 | + stopReason: 'stop', |
| 547 | + content: [{ type: 'text', text: finalText }], |
| 548 | + }), |
| 549 | + ]; |
| 550 | + } |
| 551 | + |
| 552 | + it('halts after the configured number of model calls and emits agent_end with stopReason=max_steps', async () => { |
| 553 | + const provider = createScriptedProvider({ responses: toolThenText() }); |
| 554 | + const agent = new Agent({ |
| 555 | + initialState: { model: makeFakeModel() }, |
| 556 | + streamFn: provider.stream, |
| 557 | + maxSteps: 1, |
| 558 | + }); |
| 559 | + agent.setTools([makeEchoTool()]); |
| 560 | + |
| 561 | + const events: AgentEvent[] = []; |
| 562 | + agent.subscribe((e) => events.push(e)); |
| 563 | + |
| 564 | + await agent.prompt('go'); |
| 565 | + |
| 566 | + expect(agent.state.stepCount).toBe(1); |
| 567 | + // Tool ran for the first turn, but no second model call. |
| 568 | + const toolResults = agent.state.messages.filter((m) => m.role === 'toolResult'); |
| 569 | + expect(toolResults).toHaveLength(1); |
| 570 | + const assistants = agent.state.messages.filter((m) => m.role === 'assistant'); |
| 571 | + expect(assistants).toHaveLength(1); |
| 572 | + |
| 573 | + const end = events.find((e) => e.type === 'agent_end'); |
| 574 | + expect(end).toMatchObject({ type: 'agent_end', stopReason: 'max_steps' }); |
| 575 | + }); |
| 576 | + |
| 577 | + it('does not enforce a cap when maxSteps is undefined (no behavior change)', async () => { |
| 578 | + const provider = createScriptedProvider({ responses: toolThenText() }); |
| 579 | + const agent = new Agent({ |
| 580 | + initialState: { model: makeFakeModel() }, |
| 581 | + streamFn: provider.stream, |
| 582 | + }); |
| 583 | + agent.setTools([makeEchoTool()]); |
| 584 | + |
| 585 | + const events: AgentEvent[] = []; |
| 586 | + agent.subscribe((e) => events.push(e)); |
| 587 | + |
| 588 | + await agent.prompt('go'); |
| 589 | + |
| 590 | + expect(agent.state.stepCount).toBe(2); |
| 591 | + expect(agent.state.messages.at(-1)).toMatchObject({ |
| 592 | + role: 'assistant', |
| 593 | + content: [{ type: 'text', text: 'done' }], |
| 594 | + }); |
| 595 | + const end = events.find((e) => e.type === 'agent_end'); |
| 596 | + expect(end).toMatchObject({ stopReason: 'completed' }); |
| 597 | + }); |
| 598 | + |
| 599 | + it('per-call maxSteps overrides the constructor default', async () => { |
| 600 | + const provider = createScriptedProvider({ responses: toolThenText() }); |
| 601 | + const agent = new Agent({ |
| 602 | + initialState: { model: makeFakeModel() }, |
| 603 | + streamFn: provider.stream, |
| 604 | + maxSteps: 1, // would cap; per-call override allows the second call |
| 605 | + }); |
| 606 | + agent.setTools([makeEchoTool()]); |
| 607 | + |
| 608 | + await agent.prompt('go', { maxSteps: 5 }); |
| 609 | + |
| 610 | + expect(agent.state.stepCount).toBe(2); |
| 611 | + expect(agent.state.messages.at(-1)).toMatchObject({ |
| 612 | + role: 'assistant', |
| 613 | + content: [{ type: 'text', text: 'done' }], |
| 614 | + }); |
| 615 | + }); |
| 616 | + |
| 617 | + it('prompt() resets stepCount; continue() preserves it across turns', async () => { |
| 618 | + // Two prompt rounds: first one consumes 2 steps; second prompt resets to 0. |
| 619 | + const responses = [ |
| 620 | + ...toolThenText('first', 'first-done'), |
| 621 | + makeFakeAssistantMessage({ |
| 622 | + stopReason: 'stop', |
| 623 | + content: [{ type: 'text', text: 'second-done' }], |
| 624 | + }), |
| 625 | + ]; |
| 626 | + const provider = createScriptedProvider({ responses }); |
| 627 | + const agent = new Agent({ |
| 628 | + initialState: { model: makeFakeModel() }, |
| 629 | + streamFn: provider.stream, |
| 630 | + }); |
| 631 | + agent.setTools([makeEchoTool()]); |
| 632 | + |
| 633 | + await agent.prompt('first'); |
| 634 | + expect(agent.state.stepCount).toBe(2); |
| 635 | + |
| 636 | + await agent.prompt('second'); |
| 637 | + expect(agent.state.stepCount).toBe(1); |
| 638 | + }); |
| 639 | +}); |
0 commit comments