Download packages/core/src/agents/local-session-invocation.test.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 25.4 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/agents/local-session-invocation.test.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/core/src/agents/local-session-invocation.test.ts
-
curl -L -o local-session-invocation.test.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/agents/local-session-invocation.test.ts
25.4 kB
| /** | |
| * @license | |
| * Copyright 2026 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import { describe, it, expect, vi, beforeEach } from 'vitest'; | |
| import { | |
| AgentTerminateMode, | |
| SubagentActivityErrorType, | |
| SUBAGENT_REJECTED_ERROR_PREFIX, | |
| SUBAGENT_CANCELLED_ERROR_MESSAGE, | |
| type SubagentProgress, | |
| type LocalAgentDefinition, | |
| type AgentInputs, | |
| type SubagentActivityEvent, | |
| } from './types.js'; | |
| import { LocalSessionInvocation } from './local-session-invocation.js'; | |
| import { LocalSubagentSession } from './local-subagent-protocol.js'; | |
| import { makeFakeConfig } from '../test-utils/config.js'; | |
| import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; | |
| import { MessageBusType } from '../confirmation-bus/types.js'; | |
| import type { AgentLoopContext } from '../config/agent-loop-context.js'; | |
| import type { MessageBus } from '../confirmation-bus/message-bus.js'; | |
| vi.mock('./local-subagent-protocol.js'); | |
| const MockLocalSubagentSession = vi.mocked(LocalSubagentSession); | |
| let capturedActivityCallback: | |
| | ((activity: SubagentActivityEvent) => void) | |
| | undefined; | |
| const testDefinition: LocalAgentDefinition = { | |
| kind: 'local', | |
| name: 'MockAgent', | |
| description: 'A mock agent for testing.', | |
| inputConfig: { | |
| inputSchema: { | |
| type: 'object', | |
| properties: { task: { type: 'string' } }, | |
| }, | |
| }, | |
| modelConfig: { model: 'test-model', generateContentConfig: {} }, | |
| runConfig: { maxTimeMinutes: 1 }, | |
| promptConfig: { systemPrompt: 'test' }, | |
| }; | |
| function setupMockSession(config: { | |
| output?: { result: string; terminate_reason: AgentTerminateMode }; | |
| error?: Error; | |
| }) { | |
| const mockSession = { | |
| send: vi.fn().mockResolvedValue({ streamId: 'stream-1' }), | |
| getResult: config.error | |
| ? vi.fn().mockRejectedValue(config.error) | |
| : vi.fn().mockResolvedValue( | |
| config.output ?? { | |
| result: 'done', | |
| terminate_reason: AgentTerminateMode.GOAL, | |
| }, | |
| ), | |
| abort: vi.fn(), | |
| subscribe: vi.fn().mockReturnValue(vi.fn()), | |
| }; | |
| MockLocalSubagentSession.mockImplementation( | |
| ( | |
| _def: LocalAgentDefinition, | |
| _ctx: AgentLoopContext, | |
| _bus: MessageBus, | |
| rawCallback?: (activity: SubagentActivityEvent) => void, | |
| ) => { | |
| capturedActivityCallback = rawCallback; | |
| return mockSession as unknown as LocalSubagentSession; | |
| }, | |
| ); | |
| return mockSession; | |
| } | |
| describe('LocalSessionInvocation', () => { | |
| let mockContext: AgentLoopContext; | |
| let mockMessageBus: MessageBus; | |
| beforeEach(() => { | |
| vi.clearAllMocks(); | |
| capturedActivityCallback = undefined; | |
| mockContext = makeFakeConfig() as unknown as AgentLoopContext; | |
| mockMessageBus = createMockMessageBus(); | |
| }); | |
| it('should pass the messageBus to the parent constructor', () => { | |
| setupMockSession({}); | |
| const params = { task: 'Analyze data' }; | |
| const invocation = new LocalSessionInvocation( | |
| testDefinition, | |
| mockContext, | |
| params, | |
| mockMessageBus, | |
| ); | |
| expect( | |
| (invocation as unknown as { messageBus: MessageBus }).messageBus, | |
| ).toBe(mockMessageBus); | |
| }); | |
| describe('getDescription', () => { | |
| it('should format the description with inputs', () => { | |
| setupMockSession({}); | |
| const params = { task: 'Analyze data', priority: 5 }; | |
| const invocation = new LocalSessionInvocation( | |
| testDefinition, | |
| mockContext, | |
| params, | |
| mockMessageBus, | |
| ); | |
| const description = invocation.getDescription(); | |
| expect(description).toBe( | |
| "Running subagent 'MockAgent' with inputs: { task: Analyze data, priority: 5 }", | |
| ); | |
| }); | |
| it('should not truncate long input values', () => { | |
| setupMockSession({}); | |
| const longTask = 'A'.repeat(100); | |
| const params = { task: longTask }; | |
| const invocation = new LocalSessionInvocation( | |
| testDefinition, | |
| mockContext, | |
| params, | |
| mockMessageBus, | |
| ); | |
| const description = invocation.getDescription(); | |
| expect(description).toBe( | |
| `Running subagent 'MockAgent' with inputs: { task: ${'A'.repeat(100)} }`, | |
| ); | |
| }); | |
| it('should not truncate the overall description', () => { | |
| setupMockSession({}); | |
| const longNameDef: LocalAgentDefinition = { | |
| ...testDefinition, | |
| name: 'VeryLongAgentNameThatTakesUpSpace', | |
| }; | |
| const params: AgentInputs = {}; | |
| for (let i = 0; i < 20; i++) { | |
| params[`input${i}`] = `value${i}`; | |
| } | |
| const invocation = new LocalSessionInvocation( | |
| longNameDef, | |
| mockContext, | |
| params, | |
| mockMessageBus, | |
| ); | |
| const description = invocation.getDescription(); | |
| expect(description.length).toBeGreaterThan(300); | |
| }); | |
| }); | |
| describe('execute', () => { | |
| it('should create session and run successfully', async () => { | |
| const mockOutput = { | |
| result: 'Analysis complete.', | |
| terminate_reason: AgentTerminateMode.GOAL, | |
| }; | |
| const mockSession = setupMockSession({ output: mockOutput }); | |
| const params = { query: 'Execute task' }; | |
| const signal = new AbortController().signal; | |
| const updateOutput = vi.fn(); | |
| const invocation = new LocalSessionInvocation( | |
| testDefinition, | |
| mockContext, | |
| params, | |
| mockMessageBus, | |
| ); | |
| const result = await invocation.execute({ | |
| abortSignal: signal, | |
| updateOutput, | |
| }); | |
| expect(MockLocalSubagentSession).toHaveBeenCalledWith( | |
| testDefinition, | |
| mockContext, | |
| mockMessageBus, | |
| expect.any(Function), | |
| ); | |
| expect(mockSession.send).toHaveBeenCalledWith({ | |
| message: { content: [{ type: 'text', text: 'Execute task' }] }, | |
| }); | |
| expect(result.llmContent).toEqual([ | |
| { | |
| text: expect.stringContaining( | |
| "Subagent 'MockAgent' finished.\nTermination Reason: GOAL\nResult:\nAnalysis complete.", | |
| ), | |
| }, | |
| ]); | |
| const display = result.returnDisplay as SubagentProgress; | |
| expect(display.isSubagentProgress).toBe(true); | |
| expect(display.state).toBe('completed'); | |
| expect(display.result).toBe('Analysis complete.'); | |
| expect(display.terminateReason).toBe(AgentTerminateMode.GOAL); | |
| }); | |
| it('should stream THOUGHT_CHUNK activity', async () => { | |
| const mockSession = setupMockSession({}); | |
| const params = { query: 'think' }; | |
| const signal = new AbortController().signal; | |
| const updateOutput = vi.fn(); | |
| const invocation = new LocalSessionInvocation( | |
| testDefinition, | |
| mockContext, | |
| params, | |
| mockMessageBus, | |
| ); | |
| const executePromise = invocation.execute({ | |
| abortSignal: signal, | |
| updateOutput, | |
| }); | |
| // Wait for send to be called so the activity callback is wired | |
| await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled()); | |
| // Emit a thought chunk via captured callback | |
| capturedActivityCallback!({ | |
| isSubagentActivityEvent: true, | |
| agentName: 'MockAgent', | |
| type: 'THOUGHT_CHUNK', | |
| data: { text: 'Analyzing...' }, | |
| }); | |
| await executePromise; | |
| // Find an updateOutput call containing the thought | |
| const progressCalls = updateOutput.mock.calls.map( | |
| (c) => c[0] as SubagentProgress, | |
| ); | |
| const hasThought = progressCalls.some( | |
| (p) => | |
| p.recentActivity && | |
| p.recentActivity.some( | |
| (a) => a.type === 'thought' && a.content === 'Analyzing...', | |
| ), | |
| ); | |
| expect(hasThought).toBe(true); | |
| }); | |
| it('should stream TOOL_CALL_START and TOOL_CALL_END', async () => { | |
| const mockSession = setupMockSession({}); | |
| const params = { query: 'run tool' }; | |
| const signal = new AbortController().signal; | |
| const updateOutput = vi.fn(); | |
| const invocation = new LocalSessionInvocation( | |
| testDefinition, | |
| mockContext, | |
| params, | |
| mockMessageBus, | |
| ); | |
| const executePromise = invocation.execute({ | |
| abortSignal: signal, | |
| updateOutput, | |
| }); | |
| await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled()); | |
| capturedActivityCallback!({ | |
| isSubagentActivityEvent: true, | |
| agentName: 'MockAgent', | |
| type: 'TOOL_CALL_START', | |
| data: { name: 'ls', args: {}, callId: 'call-123' }, | |
| }); | |
| capturedActivityCallback!({ | |
| isSubagentActivityEvent: true, | |
| agentName: 'MockAgent', | |
| type: 'TOOL_CALL_END', | |
| data: { name: 'ls', data: {}, id: 'call-123' }, | |
| }); | |
| await executePromise; | |
| const progressCalls = updateOutput.mock.calls.map( | |
| (c) => c[0] as SubagentProgress, | |
| ); | |
| // After TOOL_CALL_START, the immediate updateOutput call should show running | |
| const runningCalls = progressCalls.filter((p) => p.state === 'running'); | |
| // The first running call with a tool_call should show 'running' | |
| const firstToolCall = runningCalls.find((p) => | |
| p.recentActivity?.some( | |
| (a) => a.type === 'tool_call' && a.content === 'ls', | |
| ), | |
| ); | |
| expect(firstToolCall).toBeDefined(); | |
| // After TOOL_CALL_END, the tool should be completed | |
| const hasCompleted = progressCalls.some((p) => | |
| p.recentActivity?.some( | |
| (a) => | |
| a.type === 'tool_call' && | |
| a.content === 'ls' && | |
| a.status === 'completed', | |
| ), | |
| ); | |
| expect(hasCompleted).toBe(true); | |
| }); | |
| it('should handle ERROR activity', async () => { | |
| const mockSession = setupMockSession({}); | |
| const params = { query: 'fail' }; | |
| const signal = new AbortController().signal; | |
| const updateOutput = vi.fn(); | |
| const invocation = new LocalSessionInvocation( | |
| testDefinition, | |
| mockContext, | |
| params, | |
| mockMessageBus, | |
| ); | |
| const executePromise = invocation.execute({ | |
| abortSignal: signal, | |
| updateOutput, | |
| }); | |
| await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled()); | |
| capturedActivityCallback!({ | |
| isSubagentActivityEvent: true, | |
| agentName: 'MockAgent', | |
| type: 'ERROR', | |
| data: { error: 'Something broke' }, | |
| }); | |
| await executePromise; | |
| const progressCalls = updateOutput.mock.calls.map( | |
| (c) => c[0] as SubagentProgress, | |
| ); | |
| const hasError = progressCalls.some((p) => | |
| p.recentActivity?.some( | |
| (a) => | |
| a.type === 'thought' && | |
| a.content === 'Error: Something broke' && | |
| a.status === 'error', | |
| ), | |
| ); | |
| expect(hasError).toBe(true); | |
| }); | |
| it('should handle cancelled errors', async () => { | |
| const mockSession = setupMockSession({}); | |
| const params = { query: 'cancel' }; | |
| const signal = new AbortController().signal; | |
| const updateOutput = vi.fn(); | |
| const invocation = new LocalSessionInvocation( | |
| testDefinition, | |
| mockContext, | |
| params, | |
| mockMessageBus, | |
| ); | |
| const executePromise = invocation.execute({ | |
| abortSignal: signal, | |
| updateOutput, | |
| }); | |
| await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled()); | |
| capturedActivityCallback!({ | |
| isSubagentActivityEvent: true, | |
| agentName: 'MockAgent', | |
| type: 'ERROR', | |
| data: { | |
| error: SUBAGENT_CANCELLED_ERROR_MESSAGE, | |
| errorType: SubagentActivityErrorType.CANCELLED, | |
| }, | |
| }); | |
| await executePromise; | |
| const progressCalls = updateOutput.mock.calls.map( | |
| (c) => c[0] as SubagentProgress, | |
| ); | |
| const hasCancelled = progressCalls.some((p) => | |
| p.recentActivity?.some( | |
| (a) => a.type === 'thought' && a.status === 'cancelled', | |
| ), | |
| ); | |
| expect(hasCancelled).toBe(true); | |
| }); | |
| it('should handle rejected errors', async () => { | |
| const mockSession = setupMockSession({}); | |
| const params = { query: 'reject' }; | |
| const signal = new AbortController().signal; | |
| const updateOutput = vi.fn(); | |
| const invocation = new LocalSessionInvocation( | |
| testDefinition, | |
| mockContext, | |
| params, | |
| mockMessageBus, | |
| ); | |
| const executePromise = invocation.execute({ | |
| abortSignal: signal, | |
| updateOutput, | |
| }); | |
| await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled()); | |
| capturedActivityCallback!({ | |
| isSubagentActivityEvent: true, | |
| agentName: 'MockAgent', | |
| type: 'TOOL_CALL_START', | |
| data: { name: 'dangerous_tool', args: {}, callId: 'call-rej' }, | |
| }); | |
| capturedActivityCallback!({ | |
| isSubagentActivityEvent: true, | |
| agentName: 'MockAgent', | |
| type: 'ERROR', | |
| data: { | |
| name: 'dangerous_tool', | |
| error: `${SUBAGENT_REJECTED_ERROR_PREFIX} Rethink approach.`, | |
| errorType: SubagentActivityErrorType.REJECTED, | |
| callId: 'call-rej', | |
| }, | |
| }); | |
| await executePromise; | |
| const progressCalls = updateOutput.mock.calls.map( | |
| (c) => c[0] as SubagentProgress, | |
| ); | |
| // Tool call should be marked cancelled | |
| const hasToolCancelled = progressCalls.some((p) => | |
| p.recentActivity?.some( | |
| (a) => | |
| a.type === 'tool_call' && | |
| a.content === 'dangerous_tool' && | |
| a.status === 'cancelled', | |
| ), | |
| ); | |
| expect(hasToolCancelled).toBe(true); | |
| }); | |
| it('should trim recentActivity to MAX_RECENT_ACTIVITY', async () => { | |
| const mockSession = setupMockSession({}); | |
| const params = { query: 'trim' }; | |
| const signal = new AbortController().signal; | |
| const updateOutput = vi.fn(); | |
| const invocation = new LocalSessionInvocation( | |
| testDefinition, | |
| mockContext, | |
| params, | |
| mockMessageBus, | |
| ); | |
| const executePromise = invocation.execute({ | |
| abortSignal: signal, | |
| updateOutput, | |
| }); | |
| await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled()); | |
| // Emit 4+ activities to exceed MAX_RECENT_ACTIVITY (3) | |
| capturedActivityCallback!({ | |
| isSubagentActivityEvent: true, | |
| agentName: 'MockAgent', | |
| type: 'TOOL_CALL_START', | |
| data: { name: 'tool1', args: {} }, | |
| }); | |
| capturedActivityCallback!({ | |
| isSubagentActivityEvent: true, | |
| agentName: 'MockAgent', | |
| type: 'TOOL_CALL_START', | |
| data: { name: 'tool2', args: {} }, | |
| }); | |
| capturedActivityCallback!({ | |
| isSubagentActivityEvent: true, | |
| agentName: 'MockAgent', | |
| type: 'TOOL_CALL_START', | |
| data: { name: 'tool3', args: {} }, | |
| }); | |
| capturedActivityCallback!({ | |
| isSubagentActivityEvent: true, | |
| agentName: 'MockAgent', | |
| type: 'TOOL_CALL_START', | |
| data: { name: 'tool4', args: {} }, | |
| }); | |
| await executePromise; | |
| // After the 4th activity, the last updateOutput call before completion | |
| // should have only 3 items in recentActivity | |
| const progressCalls = updateOutput.mock.calls.map( | |
| (c) => c[0] as SubagentProgress, | |
| ); | |
| // Find the call right after the 4th activity (before completion) | |
| const afterFourthActivity = progressCalls.filter( | |
| (p) => p.state === 'running' && p.recentActivity.length > 0, | |
| ); | |
| const lastRunning = afterFourthActivity[afterFourthActivity.length - 1]; | |
| expect(lastRunning.recentActivity.length).toBeLessThanOrEqual(3); | |
| // Should contain tool4 (the latest) | |
| expect( | |
| lastRunning.recentActivity.some((a) => a.content === 'tool4'), | |
| ).toBe(true); | |
| // Should NOT contain tool1 (trimmed away) | |
| expect( | |
| lastRunning.recentActivity.some((a) => a.content === 'tool1'), | |
| ).toBe(false); | |
| }); | |
| it('should handle executor errors', async () => { | |
| const error = new Error('Model failed during execution.'); | |
| setupMockSession({ error }); | |
| const params = { query: 'fail hard' }; | |
| const signal = new AbortController().signal; | |
| const updateOutput = vi.fn(); | |
| const invocation = new LocalSessionInvocation( | |
| testDefinition, | |
| mockContext, | |
| params, | |
| mockMessageBus, | |
| ); | |
| const result = await invocation.execute({ | |
| abortSignal: signal, | |
| updateOutput, | |
| }); | |
| expect(result.llmContent).toBe( | |
| `Subagent 'MockAgent' failed. Error: ${error.message}`, | |
| ); | |
| const display = result.returnDisplay as SubagentProgress; | |
| expect(display.isSubagentProgress).toBe(true); | |
| expect(display.state).toBe('error'); | |
| expect(display.recentActivity).toContainEqual( | |
| expect.objectContaining({ | |
| type: 'thought', | |
| content: `Error: ${error.message}`, | |
| status: 'error', | |
| }), | |
| ); | |
| }); | |
| it('should handle abort', async () => { | |
| const mockOutput = { | |
| result: '', | |
| terminate_reason: AgentTerminateMode.ABORTED, | |
| }; | |
| setupMockSession({ output: mockOutput }); | |
| const params = { query: 'abort me' }; | |
| const signal = new AbortController().signal; | |
| const updateOutput = vi.fn(); | |
| const invocation = new LocalSessionInvocation( | |
| testDefinition, | |
| mockContext, | |
| params, | |
| mockMessageBus, | |
| ); | |
| await expect( | |
| invocation.execute({ abortSignal: signal, updateOutput }), | |
| ).rejects.toThrow('Operation cancelled by user'); | |
| // Verify cancelled state was published | |
| const progressCalls = updateOutput.mock.calls.map( | |
| (c) => c[0] as SubagentProgress, | |
| ); | |
| const hasCancelledState = progressCalls.some( | |
| (p) => p.state === 'cancelled', | |
| ); | |
| expect(hasCancelledState).toBe(true); | |
| }); | |
| it('should wire abort signal to session.abort', async () => { | |
| const mockSession = setupMockSession({}); | |
| const params = { query: 'abort wire' }; | |
| const controller = new AbortController(); | |
| const updateOutput = vi.fn(); | |
| const invocation = new LocalSessionInvocation( | |
| testDefinition, | |
| mockContext, | |
| params, | |
| mockMessageBus, | |
| ); | |
| const executePromise = invocation.execute({ | |
| abortSignal: controller.signal, | |
| updateOutput, | |
| }); | |
| // Trigger abort | |
| controller.abort(); | |
| // The execute should complete (getResult returned GOAL by default) | |
| await executePromise.catch(() => { | |
| /* abort may throw */ | |
| }); | |
| expect(mockSession.abort).toHaveBeenCalled(); | |
| }); | |
| it('should send non-query params as config update before query', async () => { | |
| const mockSession = setupMockSession({}); | |
| const params = { query: 'Do something', extra_config: 'value123' }; | |
| const signal = new AbortController().signal; | |
| const updateOutput = vi.fn(); | |
| const invocation = new LocalSessionInvocation( | |
| testDefinition, | |
| mockContext, | |
| params, | |
| mockMessageBus, | |
| ); | |
| await invocation.execute({ abortSignal: signal, updateOutput }); | |
| // First send: config update with non-query params | |
| expect(mockSession.send).toHaveBeenCalledWith({ | |
| update: { config: { extra_config: 'value123' } }, | |
| }); | |
| // Second send: message with query | |
| expect(mockSession.send).toHaveBeenCalledWith({ | |
| message: { content: [{ type: 'text', text: 'Do something' }] }, | |
| }); | |
| // Config update should come before message | |
| const sendCalls = mockSession.send.mock.calls; | |
| const configIdx = sendCalls.findIndex((c) => c[0]?.update?.config); | |
| const messageIdx = sendCalls.findIndex((c) => c[0]?.message); | |
| expect(configIdx).toBeLessThan(messageIdx); | |
| }); | |
| it('should publish SUBAGENT_ACTIVITY on messageBus', async () => { | |
| const mockSession = setupMockSession({}); | |
| const params = { query: 'publish test' }; | |
| const signal = new AbortController().signal; | |
| const updateOutput = vi.fn(); | |
| const invocation = new LocalSessionInvocation( | |
| testDefinition, | |
| mockContext, | |
| params, | |
| mockMessageBus, | |
| ); | |
| const executePromise = invocation.execute({ | |
| abortSignal: signal, | |
| updateOutput, | |
| }); | |
| await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled()); | |
| capturedActivityCallback!({ | |
| isSubagentActivityEvent: true, | |
| agentName: 'MockAgent', | |
| type: 'THOUGHT_CHUNK', | |
| data: { text: 'Thinking...' }, | |
| }); | |
| await executePromise; | |
| expect(mockMessageBus.publish).toHaveBeenCalledWith( | |
| expect.objectContaining({ | |
| type: MessageBusType.SUBAGENT_ACTIVITY, | |
| subagentName: 'MockAgent', | |
| activity: expect.objectContaining({ | |
| type: 'thought', | |
| content: 'Thinking...', | |
| }), | |
| }), | |
| ); | |
| }); | |
| it('should clean up abort listener in finally', async () => { | |
| setupMockSession({}); | |
| const params = { query: 'cleanup' }; | |
| const controller = new AbortController(); | |
| const removeEventListenerSpy = vi.spyOn( | |
| controller.signal, | |
| 'removeEventListener', | |
| ); | |
| const updateOutput = vi.fn(); | |
| const invocation = new LocalSessionInvocation( | |
| testDefinition, | |
| mockContext, | |
| params, | |
| mockMessageBus, | |
| ); | |
| await invocation.execute({ | |
| abortSignal: controller.signal, | |
| updateOutput, | |
| }); | |
| expect(removeEventListenerSpy).toHaveBeenCalledWith( | |
| 'abort', | |
| expect.any(Function), | |
| ); | |
| }); | |
| it('should unsubscribe parent observer in finally', async () => { | |
| const unsubscribeFn = vi.fn(); | |
| const mockSession = setupMockSession({}); | |
| mockSession.subscribe.mockReturnValue(unsubscribeFn); | |
| const params = { query: 'unsub test' }; | |
| const signal = new AbortController().signal; | |
| const updateOutput = vi.fn(); | |
| const onAgentEvent = vi.fn(); | |
| const invocation = new LocalSessionInvocation( | |
| testDefinition, | |
| mockContext, | |
| params, | |
| mockMessageBus, | |
| { onAgentEvent }, | |
| ); | |
| await invocation.execute({ abortSignal: signal, updateOutput }); | |
| expect(mockSession.subscribe).toHaveBeenCalledWith(onAgentEvent); | |
| expect(unsubscribeFn).toHaveBeenCalled(); | |
| }); | |
| it('should handle TOOL_CALL_END with error data', async () => { | |
| const mockSession = setupMockSession({}); | |
| const params = { query: 'tool error' }; | |
| const signal = new AbortController().signal; | |
| const updateOutput = vi.fn(); | |
| const invocation = new LocalSessionInvocation( | |
| testDefinition, | |
| mockContext, | |
| params, | |
| mockMessageBus, | |
| ); | |
| const executePromise = invocation.execute({ | |
| abortSignal: signal, | |
| updateOutput, | |
| }); | |
| await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled()); | |
| capturedActivityCallback!({ | |
| isSubagentActivityEvent: true, | |
| agentName: 'MockAgent', | |
| type: 'TOOL_CALL_START', | |
| data: { name: 'failing_tool', args: {}, callId: 'call-err' }, | |
| }); | |
| capturedActivityCallback!({ | |
| isSubagentActivityEvent: true, | |
| agentName: 'MockAgent', | |
| type: 'TOOL_CALL_END', | |
| data: { name: 'failing_tool', data: { isError: true }, id: 'call-err' }, | |
| }); | |
| await executePromise; | |
| const progressCalls = updateOutput.mock.calls.map( | |
| (c) => c[0] as SubagentProgress, | |
| ); | |
| const hasToolError = progressCalls.some((p) => | |
| p.recentActivity?.some( | |
| (a) => | |
| a.type === 'tool_call' && | |
| a.content === 'failing_tool' && | |
| a.status === 'error', | |
| ), | |
| ); | |
| expect(hasToolError).toBe(true); | |
| }); | |
| it('should mark running items as cancelled on abort', async () => { | |
| const abortError = new Error('Aborted'); | |
| abortError.name = 'AbortError'; | |
| const mockSession = setupMockSession({ error: abortError }); | |
| const params = { query: 'mark cancelled' }; | |
| const signal = new AbortController().signal; | |
| const updateOutput = vi.fn(); | |
| const invocation = new LocalSessionInvocation( | |
| testDefinition, | |
| mockContext, | |
| params, | |
| mockMessageBus, | |
| ); | |
| const executePromise = invocation.execute({ | |
| abortSignal: signal, | |
| updateOutput, | |
| }); | |
| await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled()); | |
| // Emit a running tool call before the abort | |
| capturedActivityCallback!({ | |
| isSubagentActivityEvent: true, | |
| agentName: 'MockAgent', | |
| type: 'TOOL_CALL_START', | |
| data: { name: 'running_tool', args: {} }, | |
| }); | |
| await expect(executePromise).rejects.toThrow('Aborted'); | |
| const progressCalls = updateOutput.mock.calls.map( | |
| (c) => c[0] as SubagentProgress, | |
| ); | |
| // The final progress should show the tool as cancelled | |
| const lastProgress = progressCalls[progressCalls.length - 1]; | |
| expect(lastProgress.state).toBe('cancelled'); | |
| expect(lastProgress.recentActivity).toContainEqual( | |
| expect.objectContaining({ | |
| type: 'tool_call', | |
| content: 'running_tool', | |
| status: 'cancelled', | |
| }), | |
| ); | |
| }); | |
| }); | |
| }); | |