/** * Targeted tests for abort signal propagation fixes (#99, #100, #101). * * - #99: Per-call abortSignal must reach tool execution context * - #100: Abort path in executeQueue must skip blocked tasks and emit events * - #101: Gemini adapter must forward abortSignal to the SDK */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { AgentRunner } from '../src/agent/runner.js' import { ToolRegistry, defineTool } from '../src/tool/framework.js' import { ToolExecutor } from '../src/tool/executor.js' import { TaskQueue } from '../src/task/queue.js' import { createTask } from '../src/task/task.js' import { z } from 'zod' import type { LLMAdapter, LLMMessage, ToolUseContext } from '../src/types.js' // --------------------------------------------------------------------------- // #99 — Per-call abortSignal propagated to tool context // --------------------------------------------------------------------------- describe('Per-call abortSignal reaches tool context (#99)', () => { it('tool receives per-call abortSignal, not static runner signal', async () => { // Track the abortSignal passed to the tool let receivedSignal: AbortSignal | undefined const spy = defineTool({ name: 'spy', description: 'Captures the abort signal from context.', inputSchema: z.object({}), execute: async (_input, context) => { receivedSignal = context.abortSignal return { data: 'ok', isError: false } }, }) const registry = new ToolRegistry() registry.register(spy) const executor = new ToolExecutor(registry) // Adapter returns one tool_use then end_turn const adapter: LLMAdapter = { name: 'mock', chat: vi.fn() .mockResolvedValueOnce({ id: '1', content: [{ type: 'tool_use', id: 'call-1', name: 'spy', input: {} }], model: 'mock', stop_reason: 'tool_use', usage: { input_tokens: 0, output_tokens: 0 }, }) .mockResolvedValueOnce({ id: '2', content: [{ type: 'text', text: 'done' }], model: 'mock', stop_reason: 'end_turn', usage: { input_tokens: 0, output_tokens: 0 }, }), async *stream() { /* unused */ }, } const perCallController = new AbortController() // Runner created WITHOUT a static abortSignal const runner = new AgentRunner(adapter, registry, executor, { model: 'mock', agentName: 'test', }) const messages: LLMMessage[] = [ { role: 'user', content: [{ type: 'text', text: 'go' }] }, ] await runner.run(messages, { abortSignal: perCallController.signal }) // The tool must have received the per-call signal, not undefined expect(receivedSignal).toBe(perCallController.signal) }) it('tool receives static signal when no per-call signal is provided', async () => { let receivedSignal: AbortSignal | undefined const spy = defineTool({ name: 'spy', description: 'Captures the abort signal from context.', inputSchema: z.object({}), execute: async (_input, context) => { receivedSignal = context.abortSignal return { data: 'ok', isError: false } }, }) const registry = new ToolRegistry() registry.register(spy) const executor = new ToolExecutor(registry) const staticController = new AbortController() const adapter: LLMAdapter = { name: 'mock', chat: vi.fn() .mockResolvedValueOnce({ id: '1', content: [{ type: 'tool_use', id: 'call-1', name: 'spy', input: {} }], model: 'mock', stop_reason: 'tool_use', usage: { input_tokens: 0, output_tokens: 0 }, }) .mockResolvedValueOnce({ id: '2', content: [{ type: 'text', text: 'done' }], model: 'mock', stop_reason: 'end_turn', usage: { input_tokens: 0, output_tokens: 0 }, }), async *stream() { /* unused */ }, } // Runner created WITH a static abortSignal, no per-call signal const runner = new AgentRunner(adapter, registry, executor, { model: 'mock', agentName: 'test', abortSignal: staticController.signal, }) const messages: LLMMessage[] = [ { role: 'user', content: [{ type: 'text', text: 'go' }] }, ] await runner.run(messages) expect(receivedSignal).toBe(staticController.signal) }) }) // --------------------------------------------------------------------------- // #100 — Abort path skips blocked tasks and emits events // --------------------------------------------------------------------------- describe('Abort path skips blocked tasks and emits events (#100)', () => { function task(id: string, opts: { dependsOn?: string[]; assignee?: string } = {}) { const t = createTask({ title: id, description: `task ${id}`, assignee: opts.assignee }) return { ...t, id, dependsOn: opts.dependsOn } as ReturnType } it('skipRemaining transitions blocked tasks to skipped', () => { const q = new TaskQueue() q.add(task('a')) q.add(task('b', { dependsOn: ['a'] })) // 'b' should be blocked because it depends on 'a' expect(q.getByStatus('blocked').length).toBe(1) q.skipRemaining('Skipped: run aborted.') // Both tasks should be skipped — including the blocked one const all = q.list() expect(all.every(t => t.status === 'skipped')).toBe(true) expect(q.getByStatus('blocked').length).toBe(0) }) it('skipRemaining emits task:skipped for every non-terminal task', () => { const q = new TaskQueue() q.add(task('a')) q.add(task('b', { dependsOn: ['a'] })) const handler = vi.fn() q.on('task:skipped', handler) q.skipRemaining('Skipped: run aborted.') // Both pending 'a' and blocked 'b' must trigger events expect(handler).toHaveBeenCalledTimes(2) const ids = handler.mock.calls.map((c: any[]) => c[0].id) expect(ids).toContain('a') expect(ids).toContain('b') }) it('skipRemaining fires all:complete after skipping', () => { const q = new TaskQueue() q.add(task('a')) q.add(task('b', { dependsOn: ['a'] })) const completeHandler = vi.fn() q.on('all:complete', completeHandler) q.skipRemaining('Skipped: run aborted.') expect(completeHandler).toHaveBeenCalledTimes(1) expect(q.isComplete()).toBe(true) }) }) // --------------------------------------------------------------------------- // #101 — Gemini adapter forwards abortSignal to SDK config // --------------------------------------------------------------------------- const mockGenerateContent = vi.hoisted(() => vi.fn()) const mockGenerateContentStream = vi.hoisted(() => vi.fn()) const GoogleGenAIMock = vi.hoisted(() => vi.fn(() => ({ models: { generateContent: mockGenerateContent, generateContentStream: mockGenerateContentStream, }, })), ) vi.mock('@google/genai', () => ({ GoogleGenAI: GoogleGenAIMock, FunctionCallingConfigMode: { AUTO: 'AUTO' }, })) import { GeminiAdapter } from '../src/llm/gemini.js' describe('Gemini adapter forwards abortSignal (#101)', () => { let adapter: GeminiAdapter function makeGeminiResponse(parts: Array>) { return { candidates: [{ content: { parts }, finishReason: 'STOP', }], usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 }, } } async function* asyncGen(items: T[]): AsyncGenerator { for (const item of items) yield item } beforeEach(() => { vi.clearAllMocks() adapter = new GeminiAdapter('test-key') }) it('chat() passes abortSignal in config', async () => { mockGenerateContent.mockResolvedValue(makeGeminiResponse([{ text: 'hi' }])) const controller = new AbortController() await adapter.chat( [{ role: 'user', content: [{ type: 'text' as const, text: 'hello' }] }], { model: 'gemini-2.5-flash', abortSignal: controller.signal }, ) const callArgs = mockGenerateContent.mock.calls[0][0] expect(callArgs.config.abortSignal).toBe(controller.signal) }) it('chat() does not include abortSignal when not provided', async () => { mockGenerateContent.mockResolvedValue(makeGeminiResponse([{ text: 'hi' }])) await adapter.chat( [{ role: 'user', content: [{ type: 'text' as const, text: 'hello' }] }], { model: 'gemini-2.5-flash' }, ) const callArgs = mockGenerateContent.mock.calls[0][0] expect(callArgs.config.abortSignal).toBeUndefined() }) it('stream() passes abortSignal in config', async () => { const chunk = makeGeminiResponse([{ text: 'hi' }]) mockGenerateContentStream.mockResolvedValue(asyncGen([chunk])) const controller = new AbortController() const events: unknown[] = [] for await (const e of adapter.stream( [{ role: 'user', content: [{ type: 'text' as const, text: 'hello' }] }], { model: 'gemini-2.5-flash', abortSignal: controller.signal }, )) { events.push(e) } const callArgs = mockGenerateContentStream.mock.calls[0][0] expect(callArgs.config.abortSignal).toBe(controller.signal) }) })