Download packages/a2a-server/src/http/app.test.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 40.1 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/a2a-server/src/http/app.test.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/a2a-server/src/http/app.test.ts
-
curl -L -o app.test.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/a2a-server/src/http/app.test.ts
40.1 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import { | |
| GeminiEventType, | |
| ApprovalMode, | |
| type Config, | |
| type ToolCallConfirmationDetails, | |
| } from '@google/gemini-cli-core'; | |
| import type { | |
| TaskStatusUpdateEvent, | |
| SendStreamingMessageSuccessResponse, | |
| } from '@a2a-js/sdk'; | |
| import express from 'express'; | |
| import type { Server } from 'node:http'; | |
| import request from 'supertest'; | |
| import { | |
| afterAll, | |
| afterEach, | |
| beforeEach, | |
| beforeAll, | |
| describe, | |
| expect, | |
| it, | |
| vi, | |
| } from 'vitest'; | |
| import { createApp, main } from './app.js'; | |
| import { commandRegistry } from '../commands/command-registry.js'; | |
| import { | |
| assertUniqueFinalEventIsLast, | |
| assertTaskCreationAndWorkingStatus, | |
| createStreamMessageRequest, | |
| createMockConfig, | |
| } from '../utils/testing_utils.js'; | |
| // Import MockTool from specific path to avoid vitest dependency in main core bundle | |
| import { MockTool } from '@google/gemini-cli-core/src/test-utils/mock-tool.js'; | |
| import type { Command, CommandContext } from '../commands/types.js'; | |
| const mockToolConfirmationFn = async () => | |
| ({}) as unknown as ToolCallConfirmationDetails; | |
| const streamToSSEEvents = ( | |
| stream: string, | |
| ): SendStreamingMessageSuccessResponse[] => | |
| stream | |
| .split('\n\n') | |
| .filter(Boolean) // Remove empty strings from trailing newlines | |
| .map((chunk) => { | |
| const dataLine = chunk | |
| .split('\n') | |
| .find((line) => line.startsWith('data: ')); | |
| if (!dataLine) { | |
| throw new Error(`Invalid SSE chunk found: "${chunk}"`); | |
| } | |
| return JSON.parse(dataLine.substring(6)); | |
| }); | |
| // Mock the logger to avoid polluting test output | |
| // Comment out to debug tests | |
| vi.mock('../utils/logger.js', () => ({ | |
| logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, | |
| })); | |
| let config: Config; | |
| const getToolRegistrySpy = vi.fn().mockReturnValue({ | |
| getTool: vi.fn(), | |
| getAllToolNames: vi.fn().mockReturnValue([]), | |
| getAllTools: vi.fn().mockReturnValue([]), | |
| getToolsByServer: vi.fn().mockReturnValue([]), | |
| }); | |
| const getApprovalModeSpy = vi.fn(); | |
| const getShellExecutionConfigSpy = vi.fn(); | |
| const getExtensionsSpy = vi.fn(); | |
| vi.mock('../config/config.js', async () => { | |
| const actual = await vi.importActual('../config/config.js'); | |
| return { | |
| ...actual, | |
| loadConfig: vi.fn().mockImplementation(async () => { | |
| const mockConfig = createMockConfig({ | |
| getToolRegistry: getToolRegistrySpy, | |
| getApprovalMode: getApprovalModeSpy, | |
| getShellExecutionConfig: getShellExecutionConfigSpy, | |
| getExtensions: getExtensionsSpy, | |
| }); | |
| config = mockConfig as Config; | |
| return config; | |
| }), | |
| }; | |
| }); | |
| // Mock the GeminiClient to avoid actual API calls | |
| const sendMessageStreamSpy = vi.fn(); | |
| vi.mock('@google/gemini-cli-core', async () => { | |
| const actual = await vi.importActual('@google/gemini-cli-core'); | |
| return { | |
| ...actual, | |
| GeminiClient: vi.fn().mockImplementation(() => ({ | |
| sendMessageStream: sendMessageStreamSpy, | |
| getUserTier: vi.fn().mockReturnValue('free'), | |
| initialize: vi.fn(), | |
| })), | |
| performRestore: vi.fn(), | |
| }; | |
| }); | |
| describe('E2E Tests', () => { | |
| let app: express.Express; | |
| let server: Server; | |
| beforeAll(async () => { | |
| app = await createApp(); | |
| server = app.listen(0); // Listen on a random available port | |
| }); | |
| beforeEach(() => { | |
| getApprovalModeSpy.mockReturnValue(ApprovalMode.DEFAULT); | |
| }); | |
| afterAll( | |
| () => | |
| new Promise<void>((resolve) => { | |
| server.close(() => { | |
| resolve(); | |
| }); | |
| }), | |
| ); | |
| afterEach(() => { | |
| vi.clearAllMocks(); | |
| vi.unstubAllEnvs(); | |
| }); | |
| it('should create a new task and stream status updates (text-content) via POST /', async () => { | |
| sendMessageStreamSpy.mockImplementation(async function* () { | |
| yield* [{ type: 'content', value: 'Hello how are you?' }]; | |
| }); | |
| const agent = request.agent(app); | |
| const res = await agent | |
| .post('/') | |
| .send(createStreamMessageRequest('hello', 'a2a-test-message')) | |
| .set('Content-Type', 'application/json') | |
| .expect(200); | |
| const events = streamToSSEEvents(res.text); | |
| assertTaskCreationAndWorkingStatus(events); | |
| // Status update: text-content | |
| const textContentEvent = events[2].result as TaskStatusUpdateEvent; | |
| expect(textContentEvent.kind).toBe('status-update'); | |
| expect(textContentEvent.status.state).toBe('working'); | |
| expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'text-content', | |
| }); | |
| expect(textContentEvent.status.message?.parts).toMatchObject([ | |
| { kind: 'text', text: 'Hello how are you?' }, | |
| ]); | |
| // Status update: input-required (final) | |
| const finalEvent = events[3].result as TaskStatusUpdateEvent; | |
| expect(finalEvent.kind).toBe('status-update'); | |
| expect(finalEvent.status?.state).toBe('input-required'); | |
| expect(finalEvent.final).toBe(true); | |
| assertUniqueFinalEventIsLast(events); | |
| expect(events.length).toBe(4); | |
| }); | |
| it('should create a new task, schedule a tool call, and wait for approval', async () => { | |
| // First call yields the tool request | |
| sendMessageStreamSpy.mockImplementationOnce(async function* () { | |
| yield* [ | |
| { | |
| type: GeminiEventType.ToolCallRequest, | |
| value: { | |
| callId: 'test-call-id', | |
| name: 'test-tool', | |
| args: {}, | |
| }, | |
| }, | |
| ]; | |
| }); | |
| // Subsequent calls yield nothing | |
| sendMessageStreamSpy.mockImplementation(async function* () { | |
| yield* []; | |
| }); | |
| const mockTool = new MockTool({ | |
| name: 'test-tool', | |
| shouldConfirmExecute: vi.fn(mockToolConfirmationFn), | |
| }); | |
| getToolRegistrySpy.mockReturnValue({ | |
| getAllTools: vi.fn().mockReturnValue([mockTool]), | |
| getToolsByServer: vi.fn().mockReturnValue([]), | |
| getTool: vi.fn().mockReturnValue(mockTool), | |
| }); | |
| const agent = request.agent(app); | |
| const res = await agent | |
| .post('/') | |
| .send(createStreamMessageRequest('run a tool', 'a2a-tool-test-message')) | |
| .set('Content-Type', 'application/json') | |
| .expect(200); | |
| const events = streamToSSEEvents(res.text); | |
| assertTaskCreationAndWorkingStatus(events); | |
| // Status update: working | |
| const workingEvent2 = events[2].result as TaskStatusUpdateEvent; | |
| expect(workingEvent2.kind).toBe('status-update'); | |
| expect(workingEvent2.status.state).toBe('working'); | |
| expect(workingEvent2.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'state-change', | |
| }); | |
| // Status update: tool-call-update | |
| const toolCallUpdateEvent = events[3].result as TaskStatusUpdateEvent; | |
| expect(toolCallUpdateEvent.kind).toBe('status-update'); | |
| expect(toolCallUpdateEvent.status.state).toBe('working'); | |
| expect(toolCallUpdateEvent.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'tool-call-update', | |
| }); | |
| expect(toolCallUpdateEvent.status.message?.parts).toMatchObject([ | |
| { | |
| data: { | |
| status: 'scheduled', | |
| request: { callId: 'test-call-id' }, | |
| }, | |
| }, | |
| ]); | |
| // State update: awaiting_approval update | |
| const toolCallConfirmationEvent = events[4].result as TaskStatusUpdateEvent; | |
| expect(toolCallConfirmationEvent.kind).toBe('status-update'); | |
| expect(toolCallConfirmationEvent.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'tool-call-confirmation', | |
| }); | |
| expect(toolCallConfirmationEvent.status.message?.parts).toMatchObject([ | |
| { | |
| data: { | |
| status: 'awaiting_approval', | |
| request: { callId: 'test-call-id' }, | |
| }, | |
| }, | |
| ]); | |
| expect(toolCallConfirmationEvent.status?.state).toBe('working'); | |
| assertUniqueFinalEventIsLast(events); | |
| expect(events.length).toBe(6); | |
| }); | |
| it('should handle multiple tool calls in a single turn', async () => { | |
| // First call yields the tool request | |
| sendMessageStreamSpy.mockImplementationOnce(async function* () { | |
| yield* [ | |
| { | |
| type: GeminiEventType.ToolCallRequest, | |
| value: { | |
| callId: 'test-call-id-1', | |
| name: 'test-tool-1', | |
| args: {}, | |
| }, | |
| }, | |
| { | |
| type: GeminiEventType.ToolCallRequest, | |
| value: { | |
| callId: 'test-call-id-2', | |
| name: 'test-tool-2', | |
| args: {}, | |
| }, | |
| }, | |
| ]; | |
| }); | |
| // Subsequent calls yield nothing | |
| sendMessageStreamSpy.mockImplementation(async function* () { | |
| yield* []; | |
| }); | |
| const mockTool1 = new MockTool({ | |
| name: 'test-tool-1', | |
| displayName: 'Test Tool 1', | |
| shouldConfirmExecute: vi.fn(mockToolConfirmationFn), | |
| }); | |
| const mockTool2 = new MockTool({ | |
| name: 'test-tool-2', | |
| displayName: 'Test Tool 2', | |
| shouldConfirmExecute: vi.fn(mockToolConfirmationFn), | |
| }); | |
| getToolRegistrySpy.mockReturnValue({ | |
| getAllTools: vi.fn().mockReturnValue([mockTool1, mockTool2]), | |
| getToolsByServer: vi.fn().mockReturnValue([]), | |
| getTool: vi.fn().mockImplementation((name: string) => { | |
| if (name === 'test-tool-1') return mockTool1; | |
| if (name === 'test-tool-2') return mockTool2; | |
| return undefined; | |
| }), | |
| }); | |
| const agent = request.agent(app); | |
| const res = await agent | |
| .post('/') | |
| .send( | |
| createStreamMessageRequest( | |
| 'run two tools', | |
| 'a2a-multi-tool-test-message', | |
| ), | |
| ) | |
| .set('Content-Type', 'application/json') | |
| .expect(200); | |
| const events = streamToSSEEvents(res.text); | |
| assertTaskCreationAndWorkingStatus(events); | |
| // Second working update | |
| const workingEvent = events[2].result as TaskStatusUpdateEvent; | |
| expect(workingEvent.kind).toBe('status-update'); | |
| expect(workingEvent.status.state).toBe('working'); | |
| // State Update: Validate the first tool call | |
| const toolCallValidateEvent1 = events[3].result as TaskStatusUpdateEvent; | |
| expect(toolCallValidateEvent1.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'tool-call-update', | |
| }); | |
| expect(toolCallValidateEvent1.status.message?.parts).toMatchObject([ | |
| { | |
| data: { | |
| status: 'scheduled', | |
| request: { callId: 'test-call-id-1' }, | |
| }, | |
| }, | |
| ]); | |
| // --- Assert the event stream --- | |
| // 1. Initial "submitted" status. | |
| expect((events[0].result as TaskStatusUpdateEvent).status.state).toBe( | |
| 'submitted', | |
| ); | |
| // 2. "working" status after receiving the user prompt. | |
| expect((events[1].result as TaskStatusUpdateEvent).status.state).toBe( | |
| 'working', | |
| ); | |
| // 3. A "state-change" event from the agent. | |
| expect(events[2].result.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'state-change', | |
| }); | |
| // 4. Tool 1 is scheduled. | |
| const toolCallUpdate1 = events[3].result as TaskStatusUpdateEvent; | |
| expect(toolCallUpdate1.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'tool-call-update', | |
| }); | |
| expect(toolCallUpdate1.status.message?.parts).toMatchObject([ | |
| { | |
| data: { | |
| request: { callId: 'test-call-id-1' }, | |
| status: 'scheduled', | |
| }, | |
| }, | |
| ]); | |
| // 5. Tool 2 is scheduled. | |
| const toolCallUpdate2 = events[4].result as TaskStatusUpdateEvent; | |
| expect(toolCallUpdate2.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'tool-call-update', | |
| }); | |
| expect(toolCallUpdate2.status.message?.parts).toMatchObject([ | |
| { | |
| data: { | |
| request: { callId: 'test-call-id-2' }, | |
| status: 'scheduled', | |
| }, | |
| }, | |
| ]); | |
| // 6. Tool 1 is awaiting approval. | |
| const toolCallAwaitEvent1 = events[5].result as TaskStatusUpdateEvent; | |
| expect(toolCallAwaitEvent1.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'tool-call-confirmation', | |
| }); | |
| expect(toolCallAwaitEvent1.status.message?.parts).toMatchObject([ | |
| { | |
| data: { | |
| request: { callId: 'test-call-id-1' }, | |
| status: 'awaiting_approval', | |
| }, | |
| }, | |
| ]); | |
| // 7. Tool 2 is awaiting approval. | |
| const toolCallAwaitEvent2 = events[6].result as TaskStatusUpdateEvent; | |
| expect(toolCallAwaitEvent2.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'tool-call-confirmation', | |
| }); | |
| expect(toolCallAwaitEvent2.status.message?.parts).toMatchObject([ | |
| { | |
| data: { | |
| request: { callId: 'test-call-id-2' }, | |
| status: 'awaiting_approval', | |
| }, | |
| }, | |
| ]); | |
| // 8. The final event is "input-required". | |
| const finalEvent = events[7].result as TaskStatusUpdateEvent; | |
| expect(finalEvent.final).toBe(true); | |
| expect(finalEvent.status.state).toBe('input-required'); | |
| // The scheduler now waits for approval, so no more events are sent. | |
| assertUniqueFinalEventIsLast(events); | |
| expect(events.length).toBe(8); | |
| }); | |
| it('should handle multiple tool calls sequentially in YOLO mode', async () => { | |
| // Set YOLO mode to auto-approve tools and test sequential execution. | |
| getApprovalModeSpy.mockReturnValue(ApprovalMode.YOLO); | |
| // First call yields the tool request | |
| sendMessageStreamSpy.mockImplementationOnce(async function* () { | |
| yield* [ | |
| { | |
| type: GeminiEventType.ToolCallRequest, | |
| value: { | |
| callId: 'test-call-id-1', | |
| name: 'test-tool-1', | |
| args: {}, | |
| }, | |
| }, | |
| { | |
| type: GeminiEventType.ToolCallRequest, | |
| value: { | |
| callId: 'test-call-id-2', | |
| name: 'test-tool-2', | |
| args: {}, | |
| }, | |
| }, | |
| ]; | |
| }); | |
| // Subsequent calls yield nothing, as the tools will "succeed". | |
| sendMessageStreamSpy.mockImplementation(async function* () { | |
| yield* [{ type: 'content', value: 'All tools executed.' }]; | |
| }); | |
| const mockTool1 = new MockTool({ | |
| name: 'test-tool-1', | |
| displayName: 'Test Tool 1', | |
| shouldConfirmExecute: vi.fn(mockToolConfirmationFn), | |
| execute: vi | |
| .fn() | |
| .mockResolvedValue({ llmContent: 'tool 1 done', returnDisplay: '' }), | |
| }); | |
| const mockTool2 = new MockTool({ | |
| name: 'test-tool-2', | |
| displayName: 'Test Tool 2', | |
| shouldConfirmExecute: vi.fn(mockToolConfirmationFn), | |
| execute: vi | |
| .fn() | |
| .mockResolvedValue({ llmContent: 'tool 2 done', returnDisplay: '' }), | |
| }); | |
| getToolRegistrySpy.mockReturnValue({ | |
| getAllTools: vi.fn().mockReturnValue([mockTool1, mockTool2]), | |
| getToolsByServer: vi.fn().mockReturnValue([]), | |
| getTool: vi.fn().mockImplementation((name: string) => { | |
| if (name === 'test-tool-1') return mockTool1; | |
| if (name === 'test-tool-2') return mockTool2; | |
| return undefined; | |
| }), | |
| }); | |
| const agent = request.agent(app); | |
| const res = await agent | |
| .post('/') | |
| .send( | |
| createStreamMessageRequest( | |
| 'run two tools', | |
| 'a2a-multi-tool-test-message', | |
| ), | |
| ) | |
| .set('Content-Type', 'application/json') | |
| .expect(200); | |
| const events = streamToSSEEvents(res.text); | |
| assertTaskCreationAndWorkingStatus(events); | |
| // --- Assert the sequential execution flow --- | |
| const eventStream = events.slice(2).map((e) => { | |
| const update = e.result as TaskStatusUpdateEvent; | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| const agentData = update.metadata?.['coderAgent'] as any; | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| const toolData = update.status.message?.parts[0] as any; | |
| if (!toolData) { | |
| return { kind: agentData.kind }; | |
| } | |
| return { | |
| kind: agentData.kind, | |
| status: toolData.data?.status, | |
| callId: toolData.data?.request.callId, | |
| }; | |
| }); | |
| const expectedFlow = [ | |
| // Initial state change | |
| { kind: 'state-change', status: undefined, callId: undefined }, | |
| // Tool 1 Lifecycle | |
| { | |
| kind: 'tool-call-update', | |
| status: 'scheduled', | |
| callId: 'test-call-id-1', | |
| }, | |
| { | |
| kind: 'tool-call-update', | |
| status: 'scheduled', | |
| callId: 'test-call-id-1', | |
| }, | |
| { | |
| kind: 'tool-call-update', | |
| status: 'executing', | |
| callId: 'test-call-id-1', | |
| }, | |
| { | |
| kind: 'tool-call-update', | |
| status: 'success', | |
| callId: 'test-call-id-1', | |
| }, | |
| // Tool 2 Lifecycle | |
| { | |
| kind: 'tool-call-update', | |
| status: 'scheduled', | |
| callId: 'test-call-id-2', | |
| }, | |
| { | |
| kind: 'tool-call-update', | |
| status: 'scheduled', | |
| callId: 'test-call-id-2', | |
| }, | |
| { | |
| kind: 'tool-call-update', | |
| status: 'executing', | |
| callId: 'test-call-id-2', | |
| }, | |
| { | |
| kind: 'tool-call-update', | |
| status: 'success', | |
| callId: 'test-call-id-2', | |
| }, | |
| // Final updates | |
| { kind: 'state-change', status: undefined, callId: undefined }, | |
| { kind: 'text-content', status: undefined, callId: undefined }, | |
| ]; | |
| // Use `toContainEqual` for flexibility if other events are interspersed. | |
| expect(eventStream).toEqual(expect.arrayContaining(expectedFlow)); | |
| assertUniqueFinalEventIsLast(events); | |
| }); | |
| it('should handle tool calls that do not require approval', async () => { | |
| // First call yields the tool request | |
| sendMessageStreamSpy.mockImplementationOnce(async function* () { | |
| yield* [ | |
| { | |
| type: GeminiEventType.ToolCallRequest, | |
| value: { | |
| callId: 'test-call-id-no-approval', | |
| name: 'test-tool-no-approval', | |
| args: {}, | |
| }, | |
| }, | |
| ]; | |
| }); | |
| // Second call, after the tool runs, yields the final text | |
| sendMessageStreamSpy.mockImplementationOnce(async function* () { | |
| yield* [{ type: 'content', value: 'Tool executed successfully.' }]; | |
| }); | |
| const mockTool = new MockTool({ | |
| name: 'test-tool-no-approval', | |
| displayName: 'Test Tool No Approval', | |
| execute: vi.fn().mockResolvedValue({ | |
| llmContent: 'Tool executed successfully.', | |
| returnDisplay: 'Tool executed successfully.', | |
| }), | |
| }); | |
| getToolRegistrySpy.mockReturnValue({ | |
| getAllTools: vi.fn().mockReturnValue([mockTool]), | |
| getToolsByServer: vi.fn().mockReturnValue([]), | |
| getTool: vi.fn().mockReturnValue(mockTool), | |
| }); | |
| const agent = request.agent(app); | |
| const res = await agent | |
| .post('/') | |
| .send( | |
| createStreamMessageRequest( | |
| 'run a tool without approval', | |
| 'a2a-no-approval-test-message', | |
| ), | |
| ) | |
| .set('Content-Type', 'application/json') | |
| .expect(200); | |
| const events = streamToSSEEvents(res.text); | |
| assertTaskCreationAndWorkingStatus(events); | |
| // Status update: working | |
| const workingEvent2 = events[2].result as TaskStatusUpdateEvent; | |
| expect(workingEvent2.kind).toBe('status-update'); | |
| expect(workingEvent2.status.state).toBe('working'); | |
| // Status update: tool-call-update (scheduled) | |
| const scheduledEvent1 = events[3].result as TaskStatusUpdateEvent; | |
| expect(scheduledEvent1.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'tool-call-update', | |
| }); | |
| expect(scheduledEvent1.status.message?.parts).toMatchObject([ | |
| { | |
| data: { | |
| status: 'scheduled', | |
| request: { callId: 'test-call-id-no-approval' }, | |
| }, | |
| }, | |
| ]); | |
| // Status update: tool-call-update (scheduled) | |
| const scheduledEvent2 = events[4].result as TaskStatusUpdateEvent; | |
| expect(scheduledEvent2.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'tool-call-update', | |
| }); | |
| expect(scheduledEvent2.status.message?.parts).toMatchObject([ | |
| { | |
| data: { | |
| status: 'scheduled', | |
| request: { callId: 'test-call-id-no-approval' }, | |
| }, | |
| }, | |
| ]); | |
| // Status update: tool-call-update (scheduled) | |
| const scheduledEvent3 = events[5].result as TaskStatusUpdateEvent; | |
| expect(scheduledEvent3.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'tool-call-update', | |
| }); | |
| expect(scheduledEvent3.status.message?.parts).toMatchObject([ | |
| { | |
| data: { | |
| status: 'scheduled', | |
| request: { callId: 'test-call-id-no-approval' }, | |
| }, | |
| }, | |
| ]); | |
| // Status update: tool-call-update (executing) | |
| const executingEvent = events[6].result as TaskStatusUpdateEvent; | |
| expect(executingEvent.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'tool-call-update', | |
| }); | |
| expect(executingEvent.status.message?.parts).toMatchObject([ | |
| { | |
| data: { | |
| status: 'executing', | |
| request: { callId: 'test-call-id-no-approval' }, | |
| }, | |
| }, | |
| ]); | |
| // Status update: tool-call-update (success) | |
| const successEvent = events[7].result as TaskStatusUpdateEvent; | |
| expect(successEvent.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'tool-call-update', | |
| }); | |
| expect(successEvent.status.message?.parts).toMatchObject([ | |
| { | |
| data: { | |
| status: 'success', | |
| request: { callId: 'test-call-id-no-approval' }, | |
| }, | |
| }, | |
| ]); | |
| // Status update: working (before sending tool result to LLM) | |
| const workingEvent3 = events[8].result as TaskStatusUpdateEvent; | |
| expect(workingEvent3.kind).toBe('status-update'); | |
| expect(workingEvent3.status.state).toBe('working'); | |
| // Status update: text-content (final LLM response) | |
| const textContentEvent = events[9].result as TaskStatusUpdateEvent; | |
| expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'text-content', | |
| }); | |
| expect(textContentEvent.status.message?.parts).toMatchObject([ | |
| { text: 'Tool executed successfully.' }, | |
| ]); | |
| assertUniqueFinalEventIsLast(events); | |
| expect(events.length).toBe(11); | |
| }); | |
| it('should bypass tool approval in YOLO mode', async () => { | |
| // First call yields the tool request | |
| sendMessageStreamSpy.mockImplementationOnce(async function* () { | |
| yield* [ | |
| { | |
| type: GeminiEventType.ToolCallRequest, | |
| value: { | |
| callId: 'test-call-id-yolo', | |
| name: 'test-tool-yolo', | |
| args: {}, | |
| }, | |
| }, | |
| ]; | |
| }); | |
| // Second call, after the tool runs, yields the final text | |
| sendMessageStreamSpy.mockImplementationOnce(async function* () { | |
| yield* [{ type: 'content', value: 'Tool executed successfully.' }]; | |
| }); | |
| // Set approval mode to yolo | |
| getApprovalModeSpy.mockReturnValue(ApprovalMode.YOLO); | |
| const mockTool = new MockTool({ | |
| name: 'test-tool-yolo', | |
| displayName: 'Test Tool YOLO', | |
| execute: vi.fn().mockResolvedValue({ | |
| llmContent: 'Tool executed successfully.', | |
| returnDisplay: 'Tool executed successfully.', | |
| }), | |
| }); | |
| getToolRegistrySpy.mockReturnValue({ | |
| getAllTools: vi.fn().mockReturnValue([mockTool]), | |
| getToolsByServer: vi.fn().mockReturnValue([]), | |
| getTool: vi.fn().mockReturnValue(mockTool), | |
| }); | |
| const agent = request.agent(app); | |
| const res = await agent | |
| .post('/') | |
| .send( | |
| createStreamMessageRequest( | |
| 'run a tool in yolo mode', | |
| 'a2a-yolo-mode-test-message', | |
| ), | |
| ) | |
| .set('Content-Type', 'application/json') | |
| .expect(200); | |
| const events = streamToSSEEvents(res.text); | |
| assertTaskCreationAndWorkingStatus(events); | |
| // Status update: working | |
| const workingEvent2 = events[2].result as TaskStatusUpdateEvent; | |
| expect(workingEvent2.kind).toBe('status-update'); | |
| expect(workingEvent2.status.state).toBe('working'); | |
| // Status update: tool-call-update (scheduled) | |
| const scheduledEvent = events[3].result as TaskStatusUpdateEvent; | |
| expect(scheduledEvent.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'tool-call-update', | |
| }); | |
| expect(scheduledEvent.status.message?.parts).toMatchObject([ | |
| { | |
| data: { | |
| status: 'scheduled', | |
| request: { callId: 'test-call-id-yolo' }, | |
| }, | |
| }, | |
| ]); | |
| // Status update: tool-call-update (scheduled) | |
| const awaitingEvent = events[4].result as TaskStatusUpdateEvent; | |
| expect(awaitingEvent.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'tool-call-update', | |
| }); | |
| expect(awaitingEvent.status.message?.parts).toMatchObject([ | |
| { | |
| data: { | |
| status: 'scheduled', | |
| request: { callId: 'test-call-id-yolo' }, | |
| }, | |
| }, | |
| ]); | |
| // Status update: tool-call-update (scheduled) | |
| const scheduledEvent3 = events[5].result as TaskStatusUpdateEvent; | |
| expect(scheduledEvent3.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'tool-call-update', | |
| }); | |
| expect(scheduledEvent3.status.message?.parts).toMatchObject([ | |
| { | |
| data: { | |
| status: 'scheduled', | |
| request: { callId: 'test-call-id-yolo' }, | |
| }, | |
| }, | |
| ]); | |
| // Status update: tool-call-update (executing) | |
| const executingEvent = events[6].result as TaskStatusUpdateEvent; | |
| expect(executingEvent.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'tool-call-update', | |
| }); | |
| expect(executingEvent.status.message?.parts).toMatchObject([ | |
| { | |
| data: { | |
| status: 'executing', | |
| request: { callId: 'test-call-id-yolo' }, | |
| }, | |
| }, | |
| ]); | |
| // Status update: tool-call-update (success) | |
| const successEvent = events[7].result as TaskStatusUpdateEvent; | |
| expect(successEvent.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'tool-call-update', | |
| }); | |
| expect(successEvent.status.message?.parts).toMatchObject([ | |
| { | |
| data: { | |
| status: 'success', | |
| request: { callId: 'test-call-id-yolo' }, | |
| }, | |
| }, | |
| ]); | |
| // Status update: working (before sending tool result to LLM) | |
| const workingEvent3 = events[8].result as TaskStatusUpdateEvent; | |
| expect(workingEvent3.kind).toBe('status-update'); | |
| expect(workingEvent3.status.state).toBe('working'); | |
| // Status update: text-content (final LLM response) | |
| const textContentEvent = events[9].result as TaskStatusUpdateEvent; | |
| expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({ | |
| kind: 'text-content', | |
| }); | |
| expect(textContentEvent.status.message?.parts).toMatchObject([ | |
| { text: 'Tool executed successfully.' }, | |
| ]); | |
| assertUniqueFinalEventIsLast(events); | |
| expect(events.length).toBe(11); | |
| }); | |
| it('should include traceId in status updates when available', async () => { | |
| const traceId = 'test-trace-id'; | |
| sendMessageStreamSpy.mockImplementation(async function* () { | |
| yield* [ | |
| { type: 'content', value: 'Hello', traceId }, | |
| { type: 'thought', value: { subject: 'Thinking...' }, traceId }, | |
| ]; | |
| }); | |
| const agent = request.agent(app); | |
| const res = await agent | |
| .post('/') | |
| .send(createStreamMessageRequest('hello', 'a2a-trace-id-test')) | |
| .set('Content-Type', 'application/json') | |
| .expect(200); | |
| const events = streamToSSEEvents(res.text); | |
| // The first two events are task-creation and working status | |
| const textContentEvent = events[2].result as TaskStatusUpdateEvent; | |
| expect(textContentEvent.kind).toBe('status-update'); | |
| expect(textContentEvent.metadata?.['traceId']).toBe(traceId); | |
| const thoughtEvent = events[3].result as TaskStatusUpdateEvent; | |
| expect(thoughtEvent.kind).toBe('status-update'); | |
| expect(thoughtEvent.metadata?.['traceId']).toBe(traceId); | |
| }); | |
| describe('/listCommands', () => { | |
| it('should return a list of top-level commands', async () => { | |
| const mockCommands = [ | |
| { | |
| name: 'test-command', | |
| description: 'A test command', | |
| topLevel: true, | |
| arguments: [{ name: 'arg1', description: 'Argument 1' }], | |
| subCommands: [ | |
| { | |
| name: 'sub-command', | |
| description: 'A sub command', | |
| topLevel: false, | |
| execute: vi.fn(), | |
| }, | |
| ], | |
| execute: vi.fn(), | |
| }, | |
| { | |
| name: 'another-command', | |
| description: 'Another test command', | |
| topLevel: true, | |
| execute: vi.fn(), | |
| }, | |
| { | |
| name: 'not-top-level', | |
| description: 'Not a top level command', | |
| topLevel: false, | |
| execute: vi.fn(), | |
| }, | |
| ]; | |
| const getAllCommandsSpy = vi | |
| .spyOn(commandRegistry, 'getAllCommands') | |
| .mockReturnValue(mockCommands); | |
| const agent = request.agent(app); | |
| const res = await agent.get('/listCommands').expect(200); | |
| expect(res.body).toEqual({ | |
| commands: [ | |
| { | |
| name: 'test-command', | |
| description: 'A test command', | |
| arguments: [{ name: 'arg1', description: 'Argument 1' }], | |
| subCommands: [ | |
| { | |
| name: 'sub-command', | |
| description: 'A sub command', | |
| arguments: [], | |
| subCommands: [], | |
| }, | |
| ], | |
| }, | |
| { | |
| name: 'another-command', | |
| description: 'Another test command', | |
| arguments: [], | |
| subCommands: [], | |
| }, | |
| ], | |
| }); | |
| expect(getAllCommandsSpy).toHaveBeenCalledOnce(); | |
| getAllCommandsSpy.mockRestore(); | |
| }); | |
| it('should handle cyclic commands gracefully', async () => { | |
| const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); | |
| const cyclicCommand: Command = { | |
| name: 'cyclic-command', | |
| description: 'A cyclic command', | |
| topLevel: true, | |
| execute: vi.fn(), | |
| subCommands: [], | |
| }; | |
| cyclicCommand.subCommands?.push(cyclicCommand); // Create cycle | |
| const getAllCommandsSpy = vi | |
| .spyOn(commandRegistry, 'getAllCommands') | |
| .mockReturnValue([cyclicCommand]); | |
| const agent = request.agent(app); | |
| const res = await agent.get('/listCommands').expect(200); | |
| expect(res.body.commands[0].name).toBe('cyclic-command'); | |
| expect(res.body.commands[0].subCommands).toEqual([]); | |
| expect(warnSpy).toHaveBeenCalledWith( | |
| 'Command cyclic-command already inserted in the response, skipping', | |
| ); | |
| getAllCommandsSpy.mockRestore(); | |
| warnSpy.mockRestore(); | |
| }); | |
| }); | |
| describe('/executeCommand', () => { | |
| const mockExtensions = [{ name: 'test-extension', version: '0.0.1' }]; | |
| beforeEach(() => { | |
| getExtensionsSpy.mockReturnValue(mockExtensions); | |
| }); | |
| afterEach(() => { | |
| getExtensionsSpy.mockClear(); | |
| }); | |
| it('should return extensions for valid command', async () => { | |
| const mockExtensionsCommand = { | |
| name: 'extensions list', | |
| description: 'a mock command', | |
| execute: vi.fn(async (context: CommandContext) => { | |
| // Simulate the actual command's behavior | |
| const extensions = context.config.getExtensions(); | |
| return { name: 'extensions list', data: extensions }; | |
| }), | |
| }; | |
| vi.spyOn(commandRegistry, 'get').mockReturnValue(mockExtensionsCommand); | |
| const agent = request.agent(app); | |
| const res = await agent | |
| .post('/executeCommand') | |
| .send({ command: 'extensions list', args: [] }) | |
| .set('Content-Type', 'application/json') | |
| .expect(200); | |
| expect(res.body).toEqual({ | |
| name: 'extensions list', | |
| data: mockExtensions, | |
| }); | |
| expect(getExtensionsSpy).toHaveBeenCalled(); | |
| }); | |
| it('should return 404 for invalid command', async () => { | |
| vi.spyOn(commandRegistry, 'get').mockReturnValue(undefined); | |
| const agent = request.agent(app); | |
| const res = await agent | |
| .post('/executeCommand') | |
| .send({ command: 'invalid command' }) | |
| .set('Content-Type', 'application/json') | |
| .expect(404); | |
| expect(res.body.error).toBe('Command not found: invalid command'); | |
| expect(getExtensionsSpy).not.toHaveBeenCalled(); | |
| }); | |
| it('should return 400 for missing command', async () => { | |
| const agent = request.agent(app); | |
| await agent | |
| .post('/executeCommand') | |
| .send({ args: [] }) | |
| .set('Content-Type', 'application/json') | |
| .expect(400); | |
| expect(getExtensionsSpy).not.toHaveBeenCalled(); | |
| }); | |
| it('should return 400 if args is not an array', async () => { | |
| const agent = request.agent(app); | |
| const res = await agent | |
| .post('/executeCommand') | |
| .send({ command: 'extensions.list', args: 'not-an-array' }) | |
| .set('Content-Type', 'application/json') | |
| .expect(400); | |
| expect(res.body.error).toBe('"args" field must be an array.'); | |
| expect(getExtensionsSpy).not.toHaveBeenCalled(); | |
| }); | |
| it('should execute a command that does not require a workspace when CODER_AGENT_WORKSPACE_PATH is not set', async () => { | |
| const mockCommand = { | |
| name: 'test-command', | |
| description: 'a mock command', | |
| execute: vi | |
| .fn() | |
| .mockResolvedValue({ name: 'test-command', data: 'success' }), | |
| }; | |
| vi.spyOn(commandRegistry, 'get').mockReturnValue(mockCommand); | |
| vi.stubEnv('CODER_AGENT_WORKSPACE_PATH', ''); | |
| const response = await request(app) | |
| .post('/executeCommand') | |
| .send({ command: 'test-command', args: [] }); | |
| expect(response.status).toBe(200); | |
| expect(response.body.data).toBe('success'); | |
| }); | |
| it('should return 400 for a command that requires a workspace when CODER_AGENT_WORKSPACE_PATH is not set', async () => { | |
| const mockWorkspaceCommand = { | |
| name: 'workspace-command', | |
| description: 'A command that requires a workspace', | |
| requiresWorkspace: true, | |
| execute: vi | |
| .fn() | |
| .mockResolvedValue({ name: 'workspace-command', data: 'success' }), | |
| }; | |
| vi.spyOn(commandRegistry, 'get').mockReturnValue(mockWorkspaceCommand); | |
| vi.stubEnv('CODER_AGENT_WORKSPACE_PATH', ''); | |
| const response = await request(app) | |
| .post('/executeCommand') | |
| .send({ command: 'workspace-command', args: [] }); | |
| expect(response.status).toBe(400); | |
| expect(response.body.error).toBe( | |
| 'Command "workspace-command" requires a workspace, but CODER_AGENT_WORKSPACE_PATH is not set.', | |
| ); | |
| }); | |
| it('should execute a command that requires a workspace when CODER_AGENT_WORKSPACE_PATH is set', async () => { | |
| const mockWorkspaceCommand = { | |
| name: 'workspace-command', | |
| description: 'A command that requires a workspace', | |
| requiresWorkspace: true, | |
| execute: vi | |
| .fn() | |
| .mockResolvedValue({ name: 'workspace-command', data: 'success' }), | |
| }; | |
| vi.spyOn(commandRegistry, 'get').mockReturnValue(mockWorkspaceCommand); | |
| vi.stubEnv('CODER_AGENT_WORKSPACE_PATH', '/tmp/test-workspace'); | |
| const response = await request(app) | |
| .post('/executeCommand') | |
| .send({ command: 'workspace-command', args: [] }); | |
| expect(response.status).toBe(200); | |
| expect(response.body.data).toBe('success'); | |
| }); | |
| it('should include agentExecutor in context', async () => { | |
| const mockCommand = { | |
| name: 'context-check-command', | |
| description: 'checks context', | |
| execute: vi.fn(async (context: CommandContext) => { | |
| if (!context.agentExecutor) { | |
| throw new Error('agentExecutor missing'); | |
| } | |
| return { name: 'context-check-command', data: 'success' }; | |
| }), | |
| }; | |
| vi.spyOn(commandRegistry, 'get').mockReturnValue(mockCommand); | |
| const agent = request.agent(app); | |
| const res = await agent | |
| .post('/executeCommand') | |
| .send({ command: 'context-check-command', args: [] }) | |
| .set('Content-Type', 'application/json') | |
| .expect(200); | |
| expect(res.body.data).toBe('success'); | |
| }); | |
| describe('/executeCommand streaming', () => { | |
| it('should execute a streaming command and stream back events', (done: ( | |
| err?: unknown, | |
| ) => void) => { | |
| const executeSpy = vi.fn(async (context: CommandContext) => { | |
| context.eventBus?.publish({ | |
| kind: 'status-update', | |
| status: { state: 'working' }, | |
| taskId: 'test-task', | |
| contextId: 'test-context', | |
| final: false, | |
| }); | |
| context.eventBus?.publish({ | |
| kind: 'status-update', | |
| status: { state: 'completed' }, | |
| taskId: 'test-task', | |
| contextId: 'test-context', | |
| final: true, | |
| }); | |
| return { name: 'stream-test', data: 'done' }; | |
| }); | |
| const mockStreamCommand = { | |
| name: 'stream-test', | |
| description: 'A test streaming command', | |
| streaming: true, | |
| execute: executeSpy, | |
| }; | |
| vi.spyOn(commandRegistry, 'get').mockReturnValue(mockStreamCommand); | |
| const agent = request.agent(app); | |
| agent | |
| .post('/executeCommand') | |
| .send({ command: 'stream-test', args: [] }) | |
| .set('Content-Type', 'application/json') | |
| .set('Accept', 'text/event-stream') | |
| .on('response', (res) => { | |
| let data = ''; | |
| res.on('data', (chunk: Buffer) => { | |
| data += chunk.toString(); | |
| }); | |
| res.on('end', () => { | |
| try { | |
| const events = streamToSSEEvents(data); | |
| expect(events.length).toBe(2); | |
| expect(events[0].result).toEqual({ | |
| kind: 'status-update', | |
| status: { state: 'working' }, | |
| taskId: 'test-task', | |
| contextId: 'test-context', | |
| final: false, | |
| }); | |
| expect(events[1].result).toEqual({ | |
| kind: 'status-update', | |
| status: { state: 'completed' }, | |
| taskId: 'test-task', | |
| contextId: 'test-context', | |
| final: true, | |
| }); | |
| expect(executeSpy).toHaveBeenCalled(); | |
| done(); | |
| } catch (e) { | |
| done(e); | |
| } | |
| }); | |
| }) | |
| .end(); | |
| }); | |
| it('should handle non-streaming commands gracefully', async () => { | |
| const mockNonStreamCommand = { | |
| name: 'non-stream-test', | |
| description: 'A test non-streaming command', | |
| execute: vi | |
| .fn() | |
| .mockResolvedValue({ name: 'non-stream-test', data: 'done' }), | |
| }; | |
| vi.spyOn(commandRegistry, 'get').mockReturnValue(mockNonStreamCommand); | |
| const agent = request.agent(app); | |
| const res = await agent | |
| .post('/executeCommand') | |
| .send({ command: 'non-stream-test', args: [] }) | |
| .set('Content-Type', 'application/json') | |
| .expect(200); | |
| expect(res.body).toEqual({ name: 'non-stream-test', data: 'done' }); | |
| }); | |
| }); | |
| }); | |
| describe('main', () => { | |
| it('should listen on localhost only', async () => { | |
| const listenSpy = vi | |
| .spyOn(express.application, 'listen') | |
| .mockImplementation((...args: unknown[]) => { | |
| // Trigger the callback passed to listen | |
| const callback = args.find( | |
| (arg): arg is () => void => typeof arg === 'function', | |
| ); | |
| if (callback) { | |
| callback(); | |
| } | |
| return { | |
| address: () => ({ port: 1234 }), | |
| on: vi.fn(), | |
| once: vi.fn(), | |
| emit: vi.fn(), | |
| } as unknown as Server; | |
| }); | |
| // Avoid process.exit if possible, or mock it if main might fail | |
| const exitSpy = vi | |
| .spyOn(process, 'exit') | |
| .mockImplementation(() => undefined as never); | |
| await main(); | |
| expect(listenSpy).toHaveBeenCalledWith( | |
| expect.any(Number), | |
| 'localhost', | |
| expect.any(Function), | |
| ); | |
| listenSpy.mockRestore(); | |
| exitSpy.mockRestore(); | |
| }); | |
| }); | |
| }); | |