chore(a2a-server): refactor a2a-server src directory (#7593)

This commit is contained in:
Sam McCauley
2025-09-03 10:24:48 -04:00
committed by GitHub
parent 1ca388019f
commit ab751618f4
17 changed files with 253 additions and 229 deletions
+625
View File
@@ -0,0 +1,625 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@google/gemini-cli-core';
import {
GeminiEventType,
ApprovalMode,
type ToolCallConfirmationDetails,
} from '@google/gemini-cli-core';
import type {
TaskStatusUpdateEvent,
SendStreamingMessageSuccessResponse,
} from '@a2a-js/sdk';
import type 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 } from './app.js';
import {
assertUniqueFinalEventIsLast,
assertTaskCreationAndWorkingStatus,
createStreamMessageRequest,
createMockConfig,
} from '../utils/testing_utils.js';
import { MockTool } from '@google/gemini-cli-core';
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(ApprovalMode.DEFAULT);
const getApprovalModeSpy = 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,
});
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(),
})),
};
});
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();
});
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: 'validating',
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 each 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: 'validating',
request: { callId: 'test-call-id-1' },
},
},
]);
const toolCallValidateEvent2 = events[4].result as TaskStatusUpdateEvent;
expect(toolCallValidateEvent2.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
expect(toolCallValidateEvent2.status.message?.parts).toMatchObject([
{
data: {
status: 'validating',
request: { callId: 'test-call-id-2' },
},
},
]);
// State Update: Set each tool call to awaiting
const toolCallAwaitEvent1 = events[5].result as TaskStatusUpdateEvent;
expect(toolCallAwaitEvent1.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-confirmation',
});
expect(toolCallAwaitEvent1.status.message?.parts).toMatchObject([
{
data: {
status: 'awaiting_approval',
request: { callId: 'test-call-id-1' },
},
},
]);
const toolCallAwaitEvent2 = events[6].result as TaskStatusUpdateEvent;
expect(toolCallAwaitEvent2.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-confirmation',
});
expect(toolCallAwaitEvent2.status.message?.parts).toMatchObject([
{
data: {
status: 'awaiting_approval',
request: { callId: 'test-call-id-2' },
},
},
]);
assertUniqueFinalEventIsLast(events);
expect(events.length).toBe(8);
});
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 (validating)
const validatingEvent = events[3].result as TaskStatusUpdateEvent;
expect(validatingEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
expect(validatingEvent.status.message?.parts).toMatchObject([
{
data: {
status: 'validating',
request: { callId: 'test-call-id-no-approval' },
},
},
]);
// Status update: tool-call-update (scheduled)
const scheduledEvent = events[4].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-no-approval' },
},
},
]);
// Status update: tool-call-update (executing)
const executingEvent = events[5].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[6].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[7].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[8].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(10);
});
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 (validating)
const validatingEvent = events[3].result as TaskStatusUpdateEvent;
expect(validatingEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'tool-call-update',
});
expect(validatingEvent.status.message?.parts).toMatchObject([
{
data: {
status: 'validating',
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 (executing)
const executingEvent = events[5].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[6].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[7].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[8].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(10);
});
});
+200
View File
@@ -0,0 +1,200 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import express from 'express';
import type { AgentCard } from '@a2a-js/sdk';
import type { TaskStore } from '@a2a-js/sdk/server';
import { DefaultRequestHandler, InMemoryTaskStore } from '@a2a-js/sdk/server';
import { A2AExpressApp } from '@a2a-js/sdk/server/express'; // Import server components
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../utils/logger.js';
import type { AgentSettings } from '../types.js';
import { GCSTaskStore, NoOpTaskStore } from '../persistence/gcs.js';
import { CoderAgentExecutor } from '../agent/executor.js';
import { requestStorage } from './requestStorage.js';
const coderAgentCard: AgentCard = {
name: 'Gemini SDLC Agent',
description:
'An agent that generates code based on natural language instructions and streams file outputs.',
url: 'http://localhost:41242/',
provider: {
organization: 'Google',
url: 'https://google.com',
},
protocolVersion: '0.3.0',
version: '0.0.2', // Incremented version
capabilities: {
streaming: true,
pushNotifications: false,
stateTransitionHistory: true,
},
securitySchemes: undefined,
security: undefined,
defaultInputModes: ['text'],
defaultOutputModes: ['text'],
skills: [
{
id: 'code_generation',
name: 'Code Generation',
description:
'Generates code snippets or complete files based on user requests, streaming the results.',
tags: ['code', 'development', 'programming'],
examples: [
'Write a python function to calculate fibonacci numbers.',
'Create an HTML file with a basic button that alerts "Hello!" when clicked.',
],
inputModes: ['text'],
outputModes: ['text'],
},
],
supportsAuthenticatedExtendedCard: false,
};
export function updateCoderAgentCardUrl(port: number) {
coderAgentCard.url = `http://localhost:${port}/`;
}
export async function createApp() {
try {
// loadEnvironment() is called within getConfig now
const bucketName = process.env['GCS_BUCKET_NAME'];
let taskStoreForExecutor: TaskStore;
let taskStoreForHandler: TaskStore;
if (bucketName) {
logger.info(`Using GCSTaskStore with bucket: ${bucketName}`);
const gcsTaskStore = new GCSTaskStore(bucketName);
taskStoreForExecutor = gcsTaskStore;
taskStoreForHandler = new NoOpTaskStore(gcsTaskStore);
} else {
logger.info('Using InMemoryTaskStore');
const inMemoryTaskStore = new InMemoryTaskStore();
taskStoreForExecutor = inMemoryTaskStore;
taskStoreForHandler = inMemoryTaskStore;
}
const agentExecutor = new CoderAgentExecutor(taskStoreForExecutor);
const requestHandler = new DefaultRequestHandler(
coderAgentCard,
taskStoreForHandler,
agentExecutor,
);
let expressApp = express();
expressApp.use((req, res, next) => {
requestStorage.run({ req }, next);
});
const appBuilder = new A2AExpressApp(requestHandler);
expressApp = appBuilder.setupRoutes(expressApp, '');
expressApp.use(express.json());
expressApp.post('/tasks', async (req, res) => {
try {
const taskId = uuidv4();
const agentSettings = req.body.agentSettings as
| AgentSettings
| undefined;
const contextId = req.body.contextId || uuidv4();
const wrapper = await agentExecutor.createTask(
taskId,
contextId,
agentSettings,
);
await taskStoreForExecutor.save(wrapper.toSDKTask());
res.status(201).json(wrapper.id);
} catch (error) {
logger.error('[CoreAgent] Error creating task:', error);
const errorMessage =
error instanceof Error
? error.message
: 'Unknown error creating task';
res.status(500).send({ error: errorMessage });
}
});
expressApp.get('/tasks/metadata', async (req, res) => {
// This endpoint is only meaningful if the task store is in-memory.
if (!(taskStoreForExecutor instanceof InMemoryTaskStore)) {
res.status(501).send({
error:
'Listing all task metadata is only supported when using InMemoryTaskStore.',
});
}
try {
const wrappers = agentExecutor.getAllTasks();
if (wrappers && wrappers.length > 0) {
const tasksMetadata = await Promise.all(
wrappers.map((wrapper) => wrapper.task.getMetadata()),
);
res.status(200).json(tasksMetadata);
} else {
res.status(204).send();
}
} catch (error) {
logger.error('[CoreAgent] Error getting all task metadata:', error);
const errorMessage =
error instanceof Error
? error.message
: 'Unknown error getting task metadata';
res.status(500).send({ error: errorMessage });
}
});
expressApp.get('/tasks/:taskId/metadata', async (req, res) => {
const taskId = req.params.taskId;
let wrapper = agentExecutor.getTask(taskId);
if (!wrapper) {
const sdkTask = await taskStoreForExecutor.load(taskId);
if (sdkTask) {
wrapper = await agentExecutor.reconstruct(sdkTask);
}
}
if (!wrapper) {
res.status(404).send({ error: 'Task not found' });
return;
}
res.json({ metadata: await wrapper.task.getMetadata() });
});
return expressApp;
} catch (error) {
logger.error('[CoreAgent] Error during startup:', error);
process.exit(1);
}
}
export async function main() {
try {
const expressApp = await createApp();
const port = process.env['CODER_AGENT_PORT'] || 0;
const server = expressApp.listen(port, () => {
const address = server.address();
let actualPort;
if (process.env['CODER_AGENT_PORT']) {
actualPort = process.env['CODER_AGENT_PORT'];
} else if (address && typeof address !== 'string') {
actualPort = address.port;
} else {
throw new Error('[Core Agent] Could not find port number.');
}
updateCoderAgentCardUrl(Number(actualPort));
logger.info(
`[CoreAgent] Agent Server started on http://localhost:${actualPort}`,
);
logger.info(
`[CoreAgent] Agent Card: http://localhost:${actualPort}/.well-known/agent-card.json`,
);
logger.info('[CoreAgent] Press Ctrl+C to stop the server');
});
} catch (error) {
logger.error('[CoreAgent] Error during startup:', error);
process.exit(1);
}
}
@@ -0,0 +1,149 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import type express from 'express';
import { createApp, updateCoderAgentCardUrl } from './app.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import type { Server } from 'node:http';
import type { TaskMetadata } from '../types.js';
import type { AddressInfo } from 'node:net';
// Mock the logger to avoid polluting test output
// Comment out to help debug
vi.mock('../utils/logger.js', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
// Mock Task.create to avoid its complex setup
vi.mock('../agent/task.js', () => {
class MockTask {
id: string;
contextId: string;
taskState = 'submitted';
config = {
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ model: 'gemini-pro' }),
};
geminiClient = {
initialize: vi.fn().mockResolvedValue(undefined),
};
constructor(id: string, contextId: string) {
this.id = id;
this.contextId = contextId;
}
static create = vi
.fn()
.mockImplementation((id, contextId) =>
Promise.resolve(new MockTask(id, contextId)),
);
getMetadata = vi.fn().mockImplementation(async () => ({
id: this.id,
contextId: this.contextId,
taskState: this.taskState,
model: 'gemini-pro',
mcpServers: [],
availableTools: [],
}));
}
return { Task: MockTask };
});
describe('Agent Server Endpoints', () => {
let app: express.Express;
let server: Server;
let testWorkspace: string;
const createTask = (contextId: string) =>
request(app)
.post('/tasks')
.send({
contextId,
agentSettings: {
kind: 'agent-settings',
workspacePath: testWorkspace,
},
})
.set('Content-Type', 'application/json');
beforeAll(async () => {
// Create a unique temporary directory for the workspace to avoid conflicts
testWorkspace = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-agent-test-'),
);
app = await createApp();
await new Promise<void>((resolve) => {
server = app.listen(0, () => {
const port = (server.address() as AddressInfo).port;
updateCoderAgentCardUrl(port);
resolve();
});
});
});
afterAll(async () => {
if (server) {
await new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) return reject(err);
resolve();
});
});
}
if (testWorkspace) {
try {
fs.rmSync(testWorkspace, { recursive: true, force: true });
} catch (e) {
console.warn(`Could not remove temp dir '${testWorkspace}':`, e);
}
}
});
it('should create a new task via POST /tasks', async () => {
const response = await createTask('test-context');
expect(response.status).toBe(201);
expect(response.body).toBeTypeOf('string'); // Should return the task ID
}, 7000);
it('should get metadata for a specific task via GET /tasks/:taskId/metadata', async () => {
const createResponse = await createTask('test-context-2');
const taskId = createResponse.body;
const response = await request(app).get(`/tasks/${taskId}/metadata`);
expect(response.status).toBe(200);
expect(response.body.metadata.id).toBe(taskId);
}, 6000);
it('should get metadata for all tasks via GET /tasks/metadata', async () => {
const createResponse = await createTask('test-context-3');
const taskId = createResponse.body;
const response = await request(app).get('/tasks/metadata');
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
const taskMetadata = response.body.find(
(m: TaskMetadata) => m.id === taskId,
);
expect(taskMetadata).toBeDefined();
});
it('should return 404 for a non-existent task', async () => {
const response = await request(app).get('/tasks/fake-task/metadata');
expect(response.status).toBe(404);
});
it('should return agent metadata via GET /.well-known/agent-card.json', async () => {
const response = await request(app).get('/.well-known/agent-card.json');
const port = (server.address() as AddressInfo).port;
expect(response.status).toBe(200);
expect(response.body.name).toBe('Gemini SDLC Agent');
expect(response.body.url).toBe(`http://localhost:${port}/`);
});
});
@@ -0,0 +1,10 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type express from 'express';
import { AsyncLocalStorage } from 'node:async_hooks';
export const requestStorage = new AsyncLocalStorage<{ req: express.Request }>();
+33
View File
@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as url from 'node:url';
import * as path from 'node:path';
import { logger } from '../utils/logger.js';
import { main } from './app.js';
// Check if the module is the main script being run. path.resolve() creates a
// canonical, absolute path, which avoids cross-platform issues.
const isMainModule =
path.resolve(process.argv[1]) ===
path.resolve(url.fileURLToPath(import.meta.url));
process.on('uncaughtException', (error) => {
logger.error('Unhandled exception:', error);
process.exit(1);
});
if (
import.meta.url.startsWith('file:') &&
isMainModule &&
process.env['NODE_ENV'] !== 'test'
) {
main().catch((error) => {
logger.error('[CoreAgent] Unhandled error in main:', error);
process.exit(1);
});
}