Files
gemini-cli/packages/core/src/agents/local-session-invocation.test.ts
T
2026-05-18 19:30:21 +00:00

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',
}),
);
});
});
});