From f58e670e55da77c8cdd9356b07c7434e3cbd55a4 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 6 Aug 2026 08:21:33 -0700 Subject: [PATCH 1/2] Feat: serve detailed app metadata from /list-apps in the dev server The adk-web bundle renders an agent picker from `/list-apps?detailed=true`. The Python dev server serves that form; the JS dev server did not, so the picker had no descriptions. `AgentLoader.listAgentsDetailed()` returns one `AppInfo` per discovered agent, sorted by name. An agent that fails to load is logged and skipped. The route keeps its current array response for every other query value. --- dev/src/server/adk_api_server.ts | 21 +++++- dev/src/utils/agent_loader.ts | 49 ++++++++++++++ dev/test/server/adk_api_server_test.ts | 69 ++++++++++++++++++++ dev/test/utils/agent_loader_test.ts | 88 ++++++++++++++++++++++++++ 4 files changed, 226 insertions(+), 1 deletion(-) diff --git a/dev/src/server/adk_api_server.ts b/dev/src/server/adk_api_server.ts index a52ad63ab..693c4d470 100644 --- a/dev/src/server/adk_api_server.ts +++ b/dev/src/server/adk_api_server.ts @@ -33,7 +33,7 @@ import express, {Request, Response} from 'express'; import * as http from 'node:http'; import * as path from 'node:path'; -import {AgentFileOptions, AgentLoader} from '../utils/agent_loader.js'; +import {AgentFileOptions, AgentLoader, AppInfo} from '../utils/agent_loader.js'; import {AdkLogger} from '../utils/logger.js'; import { ApiServerSpanExporter, @@ -50,6 +50,11 @@ import {getAgentGraphAsDot} from './agent_graph.js'; */ export const A2A_AUTH_TOKEN_ENV_VAR = 'ADK_A2A_AUTH_TOKEN'; +/** Response body of `GET /list-apps?detailed=true`. */ +export interface ListAppsResponse { + apps: AppInfo[]; +} + interface ServerOptions { agentsDir?: string; host?: string; @@ -243,6 +248,20 @@ export class AdkApiServer { app.get('/list-apps', async (req: Request, res: Response) => { try { + // Anything other than these two values falls back to the legacy array + // response, so an unrecognised value never changes the response shape. + const detailedParam = req.query['detailed']; + const detailed = detailedParam === 'true' || detailedParam === '1'; + + if (detailed) { + const response: ListAppsResponse = { + apps: await this.agentLoader.listAgentsDetailed(), + }; + res.json(response); + + return; + } + const apps = await this.agentLoader.listAgents(); res.json(apps); } catch (e: unknown) { diff --git a/dev/src/utils/agent_loader.ts b/dev/src/utils/agent_loader.ts index d5097c482..c9e4a74ee 100644 --- a/dev/src/utils/agent_loader.ts +++ b/dev/src/utils/agent_loader.ts @@ -74,6 +74,23 @@ export interface AgentFileOptions { moduleType?: FileModuleType; } +/** + * Metadata for a single app, returned by the detailed form of `/list-apps`. + * + * The field names are camelCase to match the JSON that the Python dev server + * emits (`AppInfo` in adk-python `src/google/adk/cli/api_server.py`), because + * the same `adk-web` front-end bundle consumes both servers. + */ +export interface AppInfo { + /** Directory or file name that the agent was discovered under. */ + name: string; + /** `name` of the loaded root agent, which need not match `name`. */ + rootAgentName: string; + description: string; + language: 'typescript'; + isComputerUse: boolean; +} + /** * Default options for loading an agent file. * @@ -458,6 +475,38 @@ export class AgentLoader { return appNames.sort(); } + /** + * Lists every discovered agent with its metadata, sorted by name. + * + * An agent that fails to load is logged and omitted, so one broken agent + * cannot fail the whole listing. + */ + async listAgentsDetailed(): Promise { + const names = await this.listAgents(); + + const appsInfo: AppInfo[] = []; + for (const name of names) { + try { + const loaded = await this.preloadedAgents[name].load(); + const agent = isApp(loaded) ? loaded.rootAgent : loaded; + + appsInfo.push({ + name, + rootAgentName: agent.name, + description: agent.description ?? '', + language: 'typescript', + // adk-js has no computer-use toolset to detect, so this is always + // false. The field stays for parity with the Python dev server. + isComputerUse: false, + }); + } catch (e: unknown) { + logger.error(`Failed to load agent '${name}': ${e}`); + } + } + + return appsInfo; + } + async getAgentFile(agentName: string): Promise { await this.preloadAgents(); diff --git a/dev/test/server/adk_api_server_test.ts b/dev/test/server/adk_api_server_test.ts index d6db712d2..0d5f39d66 100644 --- a/dev/test/server/adk_api_server_test.ts +++ b/dev/test/server/adk_api_server_test.ts @@ -28,6 +28,7 @@ import {z} from 'zod'; import { A2A_AUTH_TOKEN_ENV_VAR, AdkApiServer, + ListAppsResponse, } from '../../src/server/adk_api_server.js'; import {AgentLoader} from '../../src/utils/agent_loader.js'; @@ -224,6 +225,16 @@ describe('AdkWebServer', () => { beforeEach(async () => { agentLoader = { listAgents: () => Promise.resolve(['testApp']), + listAgentsDetailed: () => + Promise.resolve([ + { + name: 'testApp', + rootAgentName: 'testAgent', + description: 'test agent', + language: 'typescript', + isComputerUse: false, + }, + ]), getAgentFile: () => Promise.resolve({ load() { @@ -813,6 +824,64 @@ describe('AdkWebServer', () => { agentLoader.listAgents = originalListAgents; } }); + + const detailedEnvelope: ListAppsResponse = { + apps: [ + { + name: 'testApp', + rootAgentName: 'testAgent', + description: 'test agent', + language: 'typescript', + isComputerUse: false, + }, + ], + }; + + it('returns detailed app info when detailed=true', async () => { + const response = await client.get( + '/list-apps?detailed=true', + ); + + expect(response.status).toBe(200); + expect(response.data).toEqual(detailedEnvelope); + }); + + it('accepts detailed=1', async () => { + const response = await client.get( + '/list-apps?detailed=1', + ); + + expect(response.status).toBe(200); + expect(response.data).toEqual(detailedEnvelope); + }); + + it('returns the plain array when detailed=false', async () => { + const response = await client.get('/list-apps?detailed=false'); + + expect(response.status).toBe(200); + expect(response.data).toEqual(['testApp']); + }); + + it('returns the plain array for an unrecognised detailed value', async () => { + const response = await client.get('/list-apps?detailed=yes'); + + expect(response.status).toBe(200); + expect(response.data).toEqual(['testApp']); + }); + + it('returns 500 if listAgentsDetailed fails', async () => { + const originalListAgentsDetailed = agentLoader.listAgentsDetailed; + agentLoader.listAgentsDetailed = () => + Promise.reject(new Error('List failed')); + + try { + await expect( + client.get('/list-apps?detailed=true'), + ).rejects.toMatchObject({response: {status: 500}}); + } finally { + agentLoader.listAgentsDetailed = originalListAgentsDetailed; + } + }); }); describe('Debug UI', () => { diff --git a/dev/test/utils/agent_loader_test.ts b/dev/test/utils/agent_loader_test.ts index 2285df895..077cdc2bd 100644 --- a/dev/test/utils/agent_loader_test.ts +++ b/dev/test/utils/agent_loader_test.ts @@ -27,6 +27,7 @@ import { replaceDirnamePlugin, } from '../../src/utils/agent_loader.js'; import * as fileUtils from '../../src/utils/file_utils.js'; +import {AdkLogger} from '../../src/utils/logger.js'; vi.mock('../../src/utils/file_utils.js', () => ({ createTempDir: vi.fn(), @@ -139,6 +140,17 @@ const agent = new FakeAgentForApp('agent_for_app_default'); export default new App({ name: 'test_app_default', rootAgent: agent }); `; +const agentWithDescriptionContent = ` +const {BaseAgent} = require('@google/adk'); + +class FakeDescribedAgent extends BaseAgent { + constructor(name, description) { + super({ name, description }); + } +} +exports.rootAgent = new FakeDescribedAgent('described_root', 'A described agent'); +`; + describe('AgentLoader', () => { let tempAgentsDir: string; let tempLoaderDir: string; @@ -788,6 +800,82 @@ describe('AgentLoader', () => { await loader.disposeAll(); }); + it('lists agents with detailed metadata', async () => { + await fs.writeFile( + path.join(tempAgentsDir, 'described_agent.js'), + agentWithDescriptionContent, + ); + + const loader = new AgentLoader(tempAgentsDir); + const result = await loader.listAgentsDetailed(); + + expect(result).toContainEqual({ + name: 'described_agent', + rootAgentName: 'described_root', + description: 'A described agent', + language: 'typescript', + isComputerUse: false, + }); + + await loader.disposeAll(); + }); + + it('defaults description to an empty string', async () => { + const loader = new AgentLoader(tempAgentsDir); + const result = await loader.listAgentsDetailed(); + + expect(result.find((a) => a.name === 'agent1')?.description).toBe(''); + + await loader.disposeAll(); + }); + + it('sorts detailed entries by name', async () => { + const loader = new AgentLoader(tempAgentsDir); + const result = await loader.listAgentsDetailed(); + + expect(result.map((a) => a.name)).toEqual(['agent1', 'agent2', 'agent3']); + + await loader.disposeAll(); + }); + + it('unwraps the root agent of an App', async () => { + const appDir = path.join(tempAgentsDir, 'my_service'); + await fs.mkdir(appDir, {recursive: true}); + await fs.writeFile(path.join(appDir, 'app.js'), appJsContent); + + const loader = new AgentLoader(tempAgentsDir); + const result = await loader.listAgentsDetailed(); + + expect(result.find((a) => a.name === 'my_service')?.rootAgentName).toBe( + 'agent_for_app', + ); + + await loader.disposeAll(); + }); + + it('skips and logs agents that fail to load', async () => { + const loader = new AgentLoader(tempAgentsDir); + await loader.preloadAgents(); + + const agent2File = await loader.getAgentFile('agent2'); + const loadSpy = vi + .spyOn(agent2File, 'load') + .mockRejectedValue(new Error('boom')); + const errorSpy = vi + .spyOn(AdkLogger.prototype, 'error') + .mockImplementation(() => {}); + + const result = await loader.listAgentsDetailed(); + + expect(result.map((a) => a.name)).toEqual(['agent1', 'agent3']); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(String(errorSpy.mock.calls[0][0])).toContain('agent2'); + + loadSpy.mockRestore(); + errorSpy.mockRestore(); + await loader.disposeAll(); + }); + it('resets preload cache when invalidateAll is called (simulates file-change reload)', async () => { const loader = new AgentLoader(tempAgentsDir); From 2509533a9c70912a00dcf1c93bc2e9e36fda6001 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 6 Aug 2026 08:30:21 -0700 Subject: [PATCH 2/2] Test: restore the AdkLogger prototype spy on the failure path A failing assertion left the spy installed, which silenced error logging for every later test in the file. --- dev/test/utils/agent_loader_test.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/dev/test/utils/agent_loader_test.ts b/dev/test/utils/agent_loader_test.ts index 077cdc2bd..7657886b9 100644 --- a/dev/test/utils/agent_loader_test.ts +++ b/dev/test/utils/agent_loader_test.ts @@ -865,15 +865,17 @@ describe('AgentLoader', () => { .spyOn(AdkLogger.prototype, 'error') .mockImplementation(() => {}); - const result = await loader.listAgentsDetailed(); - - expect(result.map((a) => a.name)).toEqual(['agent1', 'agent3']); - expect(errorSpy).toHaveBeenCalledTimes(1); - expect(String(errorSpy.mock.calls[0][0])).toContain('agent2'); - - loadSpy.mockRestore(); - errorSpy.mockRestore(); - await loader.disposeAll(); + try { + const result = await loader.listAgentsDetailed(); + + expect(result.map((a) => a.name)).toEqual(['agent1', 'agent3']); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(String(errorSpy.mock.calls[0][0])).toContain('agent2'); + } finally { + loadSpy.mockRestore(); + errorSpy.mockRestore(); + await loader.disposeAll(); + } }); it('resets preload cache when invalidateAll is called (simulates file-change reload)', async () => {