From 69339f08a6b9eeb4dc1532fe8243b958f8e249d2 Mon Sep 17 00:00:00 2001 From: cocosheng-g Date: Fri, 7 Nov 2025 10:10:29 -0500 Subject: [PATCH] Adds listCommands endpoint to a2a server (#12604) Co-authored-by: Juanda Co-authored-by: Shreya Keshive --- .../src/commands/command-registry.test.ts | 84 ++++++++++++-- .../src/commands/command-registry.ts | 26 +++-- .../src/commands/extensions.test.ts | 87 +++++++++++++++ .../a2a-server/src/commands/extensions.ts | 37 ++++++ .../src/commands/list-extensions.test.ts | 39 ------- .../src/commands/list-extensions.ts | 16 --- packages/a2a-server/src/commands/types.ts | 28 +++++ packages/a2a-server/src/http/app.test.ts | 105 +++++++++++++++++- packages/a2a-server/src/http/app.ts | 50 +++++++++ 9 files changed, 399 insertions(+), 73 deletions(-) create mode 100644 packages/a2a-server/src/commands/extensions.test.ts create mode 100644 packages/a2a-server/src/commands/extensions.ts delete mode 100644 packages/a2a-server/src/commands/list-extensions.test.ts delete mode 100644 packages/a2a-server/src/commands/list-extensions.ts create mode 100644 packages/a2a-server/src/commands/types.ts diff --git a/packages/a2a-server/src/commands/command-registry.test.ts b/packages/a2a-server/src/commands/command-registry.test.ts index 2bcd0c4428..70e32cc4fc 100644 --- a/packages/a2a-server/src/commands/command-registry.test.ts +++ b/packages/a2a-server/src/commands/command-registry.test.ts @@ -5,27 +5,44 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Command } from './types.js'; describe('CommandRegistry', () => { - const mockListExtensionsCommandInstance = { - names: ['extensions', 'extensions list'], + const mockListExtensionsCommandInstance: Command = { + name: 'extensions list', + description: 'Lists all installed extensions.', execute: vi.fn(), }; const mockListExtensionsCommand = vi.fn( () => mockListExtensionsCommandInstance, ); + const mockExtensionsCommandInstance: Command = { + name: 'extensions', + description: 'Manage extensions.', + execute: vi.fn(), + subCommands: [mockListExtensionsCommandInstance], + }; + const mockExtensionsCommand = vi.fn(() => mockExtensionsCommandInstance); + beforeEach(async () => { vi.resetModules(); - vi.doMock('./list-extensions', () => ({ + vi.doMock('./extensions.js', () => ({ + ExtensionsCommand: mockExtensionsCommand, ListExtensionsCommand: mockListExtensionsCommand, })); }); - it('should register ListExtensionsCommand on initialization', async () => { + it('should register ExtensionsCommand on initialization', async () => { const { commandRegistry } = await import('./command-registry.js'); - expect(mockListExtensionsCommand).toHaveBeenCalled(); + expect(mockExtensionsCommand).toHaveBeenCalled(); const command = commandRegistry.get('extensions'); + expect(command).toBe(mockExtensionsCommandInstance); + }); + + it('should register sub commands on initialization', async () => { + const { commandRegistry } = await import('./command-registry.js'); + const command = commandRegistry.get('extensions list'); expect(command).toBe(mockListExtensionsCommandInstance); }); @@ -37,12 +54,65 @@ describe('CommandRegistry', () => { it('register() should register a new command', async () => { const { commandRegistry } = await import('./command-registry.js'); - const mockCommand = { - names: ['test-command'], + const mockCommand: Command = { + name: 'test-command', + description: '', execute: vi.fn(), }; commandRegistry.register(mockCommand); const command = commandRegistry.get('test-command'); expect(command).toBe(mockCommand); }); + + it('register() should register a nested command', async () => { + const { commandRegistry } = await import('./command-registry.js'); + const mockSubSubCommand: Command = { + name: 'test-command-sub-sub', + description: '', + execute: vi.fn(), + }; + const mockSubCommand: Command = { + name: 'test-command-sub', + description: '', + execute: vi.fn(), + subCommands: [mockSubSubCommand], + }; + const mockCommand: Command = { + name: 'test-command', + description: '', + execute: vi.fn(), + subCommands: [mockSubCommand], + }; + commandRegistry.register(mockCommand); + + const command = commandRegistry.get('test-command'); + const subCommand = commandRegistry.get('test-command-sub'); + const subSubCommand = commandRegistry.get('test-command-sub-sub'); + + expect(command).toBe(mockCommand); + expect(subCommand).toBe(mockSubCommand); + expect(subSubCommand).toBe(mockSubSubCommand); + }); + + it('register() should not enter an infinite loop with a cyclic command', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { commandRegistry } = await import('./command-registry.js'); + const mockCommand: Command = { + name: 'cyclic-command', + description: '', + subCommands: [], + execute: vi.fn(), + }; + + mockCommand.subCommands?.push(mockCommand); // Create cycle + + commandRegistry.register(mockCommand); + + expect(commandRegistry.get('cyclic-command')).toBe(mockCommand); + expect(warnSpy).toHaveBeenCalledWith( + 'Command cyclic-command already registered. Skipping.', + ); + // If the test finishes, it means we didn't get into an infinite loop. + warnSpy.mockRestore(); + }); }); diff --git a/packages/a2a-server/src/commands/command-registry.ts b/packages/a2a-server/src/commands/command-registry.ts index 3d82bfd45d..658193a603 100644 --- a/packages/a2a-server/src/commands/command-registry.ts +++ b/packages/a2a-server/src/commands/command-registry.ts @@ -4,30 +4,36 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ListExtensionsCommand } from './list-extensions.js'; -import type { Config } from '@google/gemini-cli-core'; - -export interface Command { - readonly names: string[]; - execute(config: Config, args: string[]): Promise; -} +import { ExtensionsCommand } from './extensions.js'; +import type { Command } from './types.js'; class CommandRegistry { private readonly commands = new Map(); constructor() { - this.register(new ListExtensionsCommand()); + this.register(new ExtensionsCommand()); } register(command: Command) { - for (const name of command.names) { - this.commands.set(name, command); + if (this.commands.has(command.name)) { + console.warn(`Command ${command.name} already registered. Skipping.`); + return; + } + + this.commands.set(command.name, command); + + for (const subCommand of command.subCommands ?? []) { + this.register(subCommand); } } get(commandName: string): Command | undefined { return this.commands.get(commandName); } + + getAllCommands(): Command[] { + return [...this.commands.values()]; + } } export const commandRegistry = new CommandRegistry(); diff --git a/packages/a2a-server/src/commands/extensions.test.ts b/packages/a2a-server/src/commands/extensions.test.ts new file mode 100644 index 0000000000..ffccb017c9 --- /dev/null +++ b/packages/a2a-server/src/commands/extensions.test.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ExtensionsCommand, ListExtensionsCommand } from './extensions.js'; +import type { Config } from '@google/gemini-cli-core'; + +const mockListExtensions = vi.hoisted(() => vi.fn()); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + + return { + ...original, + listExtensions: mockListExtensions, + }; +}); + +describe('ExtensionsCommand', () => { + it('should have the correct name', () => { + const command = new ExtensionsCommand(); + expect(command.name).toEqual('extensions'); + }); + + it('should have the correct description', () => { + const command = new ExtensionsCommand(); + expect(command.description).toEqual('Manage extensions.'); + }); + + it('should have "extensions list" as a subcommand', () => { + const command = new ExtensionsCommand(); + expect(command.subCommands.map((c) => c.name)).toContain('extensions list'); + }); + + it('should be a top-level command', () => { + const command = new ExtensionsCommand(); + expect(command.topLevel).toBe(true); + }); + + it('should default to listing extensions', async () => { + const command = new ExtensionsCommand(); + const mockConfig = {} as Config; + const mockExtensions = [{ name: 'ext1' }]; + mockListExtensions.mockReturnValue(mockExtensions); + + const result = await command.execute(mockConfig, []); + + expect(result).toEqual({ name: 'extensions list', data: mockExtensions }); + expect(mockListExtensions).toHaveBeenCalledWith(mockConfig); + }); +}); + +describe('ListExtensionsCommand', () => { + it('should have the correct name', () => { + const command = new ListExtensionsCommand(); + expect(command.name).toEqual('extensions list'); + }); + + it('should call listExtensions with the provided config', async () => { + const command = new ListExtensionsCommand(); + const mockConfig = {} as Config; + const mockExtensions = [{ name: 'ext1' }]; + mockListExtensions.mockReturnValue(mockExtensions); + + const result = await command.execute(mockConfig, []); + + expect(result).toEqual({ name: 'extensions list', data: mockExtensions }); + expect(mockListExtensions).toHaveBeenCalledWith(mockConfig); + }); + + it('should return a message when no extensions are installed', async () => { + const command = new ListExtensionsCommand(); + const mockConfig = {} as Config; + mockListExtensions.mockReturnValue([]); + + const result = await command.execute(mockConfig, []); + + expect(result).toEqual({ + name: 'extensions list', + data: 'No extensions installed.', + }); + expect(mockListExtensions).toHaveBeenCalledWith(mockConfig); + }); +}); diff --git a/packages/a2a-server/src/commands/extensions.ts b/packages/a2a-server/src/commands/extensions.ts new file mode 100644 index 0000000000..91893cd55b --- /dev/null +++ b/packages/a2a-server/src/commands/extensions.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { listExtensions, type Config } from '@google/gemini-cli-core'; +import type { Command, CommandExecutionResponse } from './types.js'; + +export class ExtensionsCommand implements Command { + readonly name = 'extensions'; + readonly description = 'Manage extensions.'; + readonly subCommands = [new ListExtensionsCommand()]; + readonly topLevel = true; + + async execute( + config: Config, + _: string[], + ): Promise { + return new ListExtensionsCommand().execute(config, _); + } +} + +export class ListExtensionsCommand implements Command { + readonly name = 'extensions list'; + readonly description = 'Lists all installed extensions.'; + + async execute( + config: Config, + _: string[], + ): Promise { + const extensions = listExtensions(config); + const data = extensions.length ? extensions : 'No extensions installed.'; + + return { name: this.name, data }; + } +} diff --git a/packages/a2a-server/src/commands/list-extensions.test.ts b/packages/a2a-server/src/commands/list-extensions.test.ts deleted file mode 100644 index 42c3560f92..0000000000 --- a/packages/a2a-server/src/commands/list-extensions.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi } from 'vitest'; -import { ListExtensionsCommand } from './list-extensions.js'; -import type { Config } from '@google/gemini-cli-core'; - -const mockListExtensions = vi.hoisted(() => vi.fn()); -vi.mock('@google/gemini-cli-core', async (importOriginal) => { - const original = - await importOriginal(); - - return { - ...original, - listExtensions: mockListExtensions, - }; -}); - -describe('ListExtensionsCommand', () => { - it('should have the correct names', () => { - const command = new ListExtensionsCommand(); - expect(command.names).toEqual(['extensions', 'extensions list']); - }); - - it('should call listExtensions with the provided config', async () => { - const command = new ListExtensionsCommand(); - const mockConfig = {} as Config; - const mockExtensions = [{ name: 'ext1' }]; - mockListExtensions.mockReturnValue(mockExtensions); - - const result = await command.execute(mockConfig, []); - - expect(result).toEqual(mockExtensions); - expect(mockListExtensions).toHaveBeenCalledWith(mockConfig); - }); -}); diff --git a/packages/a2a-server/src/commands/list-extensions.ts b/packages/a2a-server/src/commands/list-extensions.ts deleted file mode 100644 index fa2fe5d84e..0000000000 --- a/packages/a2a-server/src/commands/list-extensions.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { listExtensions, type Config } from '@google/gemini-cli-core'; -import type { Command } from './command-registry.js'; - -export class ListExtensionsCommand implements Command { - readonly names = ['extensions', 'extensions list']; - - async execute(config: Config, _: string[]): Promise { - return listExtensions(config); - } -} diff --git a/packages/a2a-server/src/commands/types.ts b/packages/a2a-server/src/commands/types.ts new file mode 100644 index 0000000000..ef6a876c5c --- /dev/null +++ b/packages/a2a-server/src/commands/types.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '@google/gemini-cli-core'; + +export interface CommandArgument { + readonly name: string; + readonly description: string; + readonly isRequired?: boolean; +} + +export interface Command { + readonly name: string; + readonly description: string; + readonly arguments?: CommandArgument[]; + readonly subCommands?: Command[]; + readonly topLevel?: boolean; + + execute(config: Config, args: string[]): Promise; +} + +export interface CommandExecutionResponse { + readonly name: string; + readonly data: unknown; +} diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts index 15b386bd3d..57269feeba 100644 --- a/packages/a2a-server/src/http/app.test.ts +++ b/packages/a2a-server/src/http/app.test.ts @@ -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(); }); diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index 89bfa2cf25..81d57e4abd 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -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)) {