mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-12 02:50:41 -07:00
818 lines
25 KiB
TypeScript
818 lines
25 KiB
TypeScript
/**
|
|
* @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',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
});
|