Adds listCommands endpoint to a2a server (#12604)

Co-authored-by: Juanda <jdgarrido@google.com>
Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
This commit is contained in:
cocosheng-g
2025-11-07 10:10:29 -05:00
committed by GitHub
parent cd27cae848
commit 69339f08a6
9 changed files with 399 additions and 73 deletions
+104 -1
View File
@@ -28,6 +28,7 @@ import {
vi,
} from 'vitest';
import { createApp } from './app.js';
import { commandRegistry } from '../commands/command-registry.js';
import {
assertUniqueFinalEventIsLast,
assertTaskCreationAndWorkingStatus,
@@ -35,6 +36,7 @@ import {
createMockConfig,
} from '../utils/testing_utils.js';
import { MockTool } from '@google/gemini-cli-core';
import type { Command } from '../commands/types.js';
const mockToolConfirmationFn = async () =>
({}) as unknown as ToolCallConfirmationDetails;
@@ -827,6 +829,104 @@ describe('E2E Tests', () => {
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' }];
@@ -846,7 +946,10 @@ describe('E2E Tests', () => {
.set('Content-Type', 'application/json')
.expect(200);
expect(res.body).toEqual(mockExtensions);
expect(res.body).toEqual({
name: 'extensions list',
data: mockExtensions,
});
expect(getExtensionsSpy).toHaveBeenCalled();
});
+50
View File
@@ -21,6 +21,14 @@ import { loadSettings } from '../config/settings.js';
import { loadExtensions } from '../config/extension.js';
import { commandRegistry } from '../commands/command-registry.js';
import { SimpleExtensionLoader } from '@google/gemini-cli-core';
import type { Command, CommandArgument } from '../commands/types.js';
type CommandResponse = {
name: string;
description: string;
arguments: CommandArgument[];
subCommands: CommandResponse[];
};
const coderAgentCard: AgentCard = {
name: 'Gemini SDLC Agent',
@@ -167,6 +175,48 @@ export async function createApp() {
}
});
expressApp.get('/listCommands', (req, res) => {
try {
const transformCommand = (
command: Command,
visited: string[],
): CommandResponse | undefined => {
const commandName = command.name;
if (visited.includes(commandName)) {
console.warn(
`Command ${commandName} already inserted in the response, skipping`,
);
return undefined;
}
return {
name: command.name,
description: command.description,
arguments: command.arguments ?? [],
subCommands: (command.subCommands ?? [])
.map((subCommand) =>
transformCommand(subCommand, visited.concat(commandName)),
)
.filter(
(subCommand): subCommand is CommandResponse => !!subCommand,
),
};
};
const commands = commandRegistry
.getAllCommands()
.filter((command) => command.topLevel)
.map((command) => transformCommand(command, []));
return res.status(200).json({ commands });
} catch (e) {
logger.error('Error executing /listCommands:', e);
const errorMessage =
e instanceof Error ? e.message : 'Unknown error listing commands';
return res.status(500).json({ 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)) {