From 5ba0f554f71bc7923a9dc5c9b6d85352cc0d5e85 Mon Sep 17 00:00:00 2001 From: VascoSch92 Date: Thu, 2 Jul 2026 23:25:45 +0200 Subject: [PATCH 1/2] feat(client): add SubAgentsClient for POST /api/sub-agents Add a client for the agent-server's read-only POST /api/sub-agents endpoint, which lists the file-based and built-in sub-agents (delegate agents) available to a workspace. The endpoint mirrors POST /api/skills and was missing from the TypeScript client. - SubAgentsClient.getSubAgents(request) posts to /api/sub-agents - SubAgentsRequest / SubAgentInfo / SubAgentsResponse types mirror the server's lossless AgentDefinition view (all frontmatter fields plus level, source, is_builtin, and the inline system_prompt) - Expose manager.subAgents on ConversationManager; export the client and types from clients.ts and index.ts - Unit tests for request/response mapping and the default empty body - Integration test that discovers a project-level sub-agent end-to-end --- src/__tests__/api-clients.test.ts | 106 ++++++++++++++++++ .../deterministic-api.integration.test.ts | 58 ++++++++++ src/client/sub-agents-client.ts | 45 ++++++++ src/clients.ts | 2 + src/conversation/conversation-manager.ts | 4 + src/index.ts | 4 + src/models/api.ts | 53 +++++++++ 7 files changed, 272 insertions(+) create mode 100644 src/client/sub-agents-client.ts diff --git a/src/__tests__/api-clients.test.ts b/src/__tests__/api-clients.test.ts index cc315bb..634dfcd 100644 --- a/src/__tests__/api-clients.test.ts +++ b/src/__tests__/api-clients.test.ts @@ -28,6 +28,7 @@ import { SettingsClient, SharedClient, SkillsClient, + SubAgentsClient, WorkspacesClient, } from '../clients'; import * as http from 'node:http'; @@ -85,6 +86,7 @@ describe('Auxiliary API clients', () => { expect(manager.server).toBeInstanceOf(ServerClient); expect(manager.skills).toBeInstanceOf(SkillsClient); + expect(manager.subAgents).toBeInstanceOf(SubAgentsClient); expect(manager.profiles).toBeInstanceOf(ProfilesClient); expect(manager.agentProfiles).toBeInstanceOf(AgentProfilesClient); expect(manager.metaProfiles).toBeInstanceOf(MetaProfilesClient); @@ -593,6 +595,110 @@ describe('Auxiliary API clients', () => { ); }); + it('SubAgentsClient.getSubAgents POSTs the request to /api/sub-agents', async () => { + const payload = { + agents: [ + { + name: 'general-purpose', + description: 'A general-purpose delegate agent', + model: 'inherit', + color: null, + tools: ['bash', 'str_replace_editor'], + skills: [], + system_prompt: 'You are a helpful sub-agent.', + when_to_use_examples: ['Use for broad research tasks'], + permission_mode: null, + max_iteration_per_run: null, + max_budget_per_run: null, + mcp_servers: null, + profile_store_dir: null, + hooks: null, + condenser: null, + metadata: {}, + level: 'builtin', + source: null, + is_builtin: true, + }, + { + name: 'code-explorer', + description: 'Explores a codebase', + model: 'inherit', + color: 'blue', + tools: ['bash'], + skills: ['grep'], + system_prompt: 'Explore the repository.', + when_to_use_examples: [], + permission_mode: 'confirm_risky', + max_iteration_per_run: 25, + max_budget_per_run: 1.5, + mcp_servers: { fetch: { command: 'uvx', args: ['mcp-server-fetch'] } }, + profile_store_dir: null, + hooks: null, + condenser: null, + metadata: { team: 'core' }, + level: 'project', + source: '/workspace/.openhands/agents/code-explorer.md', + is_builtin: false, + }, + ], + }; + global.fetch = jest.fn().mockResolvedValue( + new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) as typeof fetch; + + const client = new SubAgentsClient({ host: 'http://example.com', apiKey: 'secret' }); + const request = { + load_user: true, + load_project: true, + load_builtin: false, + project_dir: '/workspace', + }; + const response = await client.getSubAgents(request); + + expect(response.agents).toHaveLength(2); + expect(response.agents[0].name).toBe('general-purpose'); + expect(response.agents[0].is_builtin).toBe(true); + expect(response.agents[0].level).toBe('builtin'); + expect(response.agents[1].name).toBe('code-explorer'); + expect(response.agents[1].level).toBe('project'); + expect(response.agents[1].mcp_servers).toEqual({ + fetch: { command: 'uvx', args: ['mcp-server-fetch'] }, + }); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/api/sub-agents', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(request), + headers: expect.objectContaining({ 'X-Session-API-Key': 'secret' }), + }) + ); + }); + + it('SubAgentsClient.getSubAgents defaults to an empty request body', async () => { + global.fetch = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ agents: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) as typeof fetch; + + const client = new SubAgentsClient({ host: 'http://example.com' }); + const response = await client.getSubAgents(); + + expect(response.agents).toEqual([]); + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/api/sub-agents', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({}), + }) + ); + }); + it('PluginsClient.getPluginsMarketplace fetches the plugins marketplace', async () => { const payload = { plugins: [ diff --git a/src/__tests__/integration/deterministic-api.integration.test.ts b/src/__tests__/integration/deterministic-api.integration.test.ts index 149f44c..c0ebf78 100644 --- a/src/__tests__/integration/deterministic-api.integration.test.ts +++ b/src/__tests__/integration/deterministic-api.integration.test.ts @@ -8,6 +8,7 @@ import { uniqueDirName, uniqueFileName, workspaceFileExists, + writeWorkspaceFile, } from './test-utils'; const config = getServerTestConfig(); @@ -56,6 +57,11 @@ describe('Deterministic API Integration Tests', () => { load_project: false, load_org: false, }); + const subAgents = await manager.subAgents.getSubAgents({ + load_user: false, + load_project: false, + load_builtin: true, + }); const vscodeStatus = await manager.vscode.getStatus(); expect(root).toBeDefined(); @@ -70,6 +76,13 @@ describe('Deterministic API Integration Tests', () => { expect(conversationSchema.model_name).toBeTruthy(); expect(Array.isArray(tools)).toBe(true); expect(Array.isArray(skills.skills)).toBe(true); + // `load_builtin` discovery is best-effort and can be empty (e.g. the + // frozen agent-server image ships no built-in agent files), so assert the + // contract shape rather than a specific count. Any agent that does come + // back from a builtin-only request must be flagged `is_builtin`. A + // dedicated test below exercises project-level discovery end-to-end. + expect(Array.isArray(subAgents.agents)).toBe(true); + expect(subAgents.agents.every((agent) => agent.is_builtin)).toBe(true); expect(typeof vscodeStatus.enabled).toBe('boolean'); try { @@ -83,6 +96,51 @@ describe('Deterministic API Integration Tests', () => { config.testTimeout ); + it( + 'discovers a project-level sub-agent from the workspace', + async () => { + // Drop a file-based agent into the workspace and confirm the read-only + // POST /api/sub-agents endpoint discovers it losslessly. + const agentName = uniqueDirName('it-sub-agent'); + const agentPath = `.openhands/agents/${agentName}.md`; + writeWorkspaceFile( + agentPath, + [ + '---', + `name: ${agentName}`, + 'description: Integration-test project sub-agent.', + 'model: inherit', + 'tools:', + ' - bash', + '---', + 'You are an integration-test project sub-agent.', + ].join('\n') + ); + + try { + const response = await manager.subAgents.getSubAgents({ + load_user: false, + load_project: true, + load_builtin: false, + project_dir: config.agentWorkspaceDir, + }); + + expect(Array.isArray(response.agents)).toBe(true); + const discovered = response.agents.find((agent) => agent.name === agentName); + expect(discovered).toBeDefined(); + expect(discovered?.level).toBe('project'); + expect(discovered?.is_builtin).toBe(false); + expect(discovered?.description).toBe('Integration-test project sub-agent.'); + expect(discovered?.tools).toEqual(['bash']); + expect(discovered?.system_prompt).toContain('integration-test project sub-agent'); + expect(discovered?.source).toContain(agentPath); + } finally { + deleteWorkspaceFile(agentPath); + } + }, + config.testTimeout + ); + it( 'supports deterministic conversation, event, fork, and ACP endpoints', async () => { diff --git a/src/client/sub-agents-client.ts b/src/client/sub-agents-client.ts new file mode 100644 index 0000000..3cac90a --- /dev/null +++ b/src/client/sub-agents-client.ts @@ -0,0 +1,45 @@ +import { HttpClient } from './http-client'; +import type { SubAgentsRequest, SubAgentsResponse } from '../models/api'; + +export interface SubAgentsClientOptions { + host: string; + apiKey?: string; + timeout?: number; +} + +/** + * Client for the agent-server's sub-agents catalog. + * + * A single read endpoint (`POST /api/sub-agents`) listing the file-based and + * built-in sub-agents available to a workspace. Read-only: it discovers the + * catalog, it does not mutate it. + */ +export class SubAgentsClient { + public readonly host: string; + public readonly apiKey?: string; + private readonly client: HttpClient; + + constructor(options: SubAgentsClientOptions) { + this.host = options.host.replace(/\/$/, ''); + this.apiKey = options.apiKey; + this.client = new HttpClient({ + baseUrl: this.host, + apiKey: this.apiKey, + timeout: options.timeout || 60000, + }); + } + + /** + * List the file-based and built-in sub-agents for the workspace. + * + * Merged first-wins by name with precedence project > user > builtin. + */ + async getSubAgents(request: SubAgentsRequest = {}): Promise { + const response = await this.client.post('/api/sub-agents', request); + return response.data; + } + + close(): void { + this.client.close(); + } +} diff --git a/src/clients.ts b/src/clients.ts index 6dd1d2f..339a2e9 100644 --- a/src/clients.ts +++ b/src/clients.ts @@ -10,6 +10,7 @@ export { ProfilesClient } from './client/profiles-client'; export { MetaProfilesClient } from './client/meta-profiles-client'; export { SettingsClient } from './client/settings-client'; export { SkillsClient } from './client/skills-client'; +export { SubAgentsClient } from './client/sub-agents-client'; export { PluginsClient } from './client/plugins-client'; export { ToolClient } from './client/tool-client'; export { VSCodeClient } from './client/vscode-client'; @@ -58,6 +59,7 @@ export type { LLMProfileMutationResponse, } from './client/settings-client'; export type { SkillsClientOptions } from './client/skills-client'; +export type { SubAgentsClientOptions } from './client/sub-agents-client'; export type { PluginsClientOptions } from './client/plugins-client'; export type { ToolClientOptions } from './client/tool-client'; export type { VSCodeClientOptions, GetVSCodeUrlOptions } from './client/vscode-client'; diff --git a/src/conversation/conversation-manager.ts b/src/conversation/conversation-manager.ts index 276ccef..c778326 100644 --- a/src/conversation/conversation-manager.ts +++ b/src/conversation/conversation-manager.ts @@ -15,6 +15,7 @@ import { ServerClient } from '../client/server-client'; import { SettingsClient } from '../client/settings-client'; import { SharedClient } from '../client/shared-client'; import { SkillsClient } from '../client/skills-client'; +import { SubAgentsClient } from '../client/sub-agents-client'; import { ToolClient } from '../client/tool-client'; import { VSCodeClient } from '../client/vscode-client'; import { WorkspacesClient } from '../client/workspaces-client'; @@ -86,6 +87,7 @@ export class ConversationManager { public readonly metaProfiles: MetaProfilesClient; public readonly settings: SettingsClient; public readonly skills: SkillsClient; + public readonly subAgents: SubAgentsClient; public readonly tools: ToolClient; public readonly vscode: VSCodeClient; public readonly desktop: DesktopClient; @@ -118,6 +120,7 @@ export class ConversationManager { this.metaProfiles = new MetaProfilesClient(clientOptions); this.settings = new SettingsClient(clientOptions); this.skills = new SkillsClient(clientOptions); + this.subAgents = new SubAgentsClient(clientOptions); this.tools = new ToolClient(clientOptions); this.vscode = new VSCodeClient(clientOptions); this.desktop = new DesktopClient(clientOptions); @@ -401,6 +404,7 @@ export class ConversationManager { this.metaProfiles.close(); this.settings.close(); this.skills.close(); + this.subAgents.close(); this.tools.close(); this.vscode.close(); this.desktop.close(); diff --git a/src/index.ts b/src/index.ts index a350e73..68b654b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -314,6 +314,10 @@ export type { RefreshSkillResponse, MarketplaceSkill, MarketplaceResponse, + SubAgentLevel, + SubAgentsRequest, + SubAgentInfo, + SubAgentsResponse, MarketplacePlugin, MarketplaceCatalogResponse, PluginsRequest, diff --git a/src/models/api.ts b/src/models/api.ts index c98357f..15e4bfd 100644 --- a/src/models/api.ts +++ b/src/models/api.ts @@ -160,6 +160,59 @@ export interface MarketplaceResponse { skills: MarketplaceSkill[]; } +/** + * Sub-agents: the catalog of file-based and built-in delegate agents available + * to a workspace, served by the agent-server's read-only `POST /api/sub-agents` + * (mirrors `POST /api/skills`). "Sub-agents" are the delegate agents distinct + * from the top-level agent and from `agent-profiles`. + */ + +/** Scope where a sub-agent was discovered (server `AgentDefinitionLevel`). */ +export type SubAgentLevel = 'project' | 'user' | 'builtin' | 'plugin' | 'programmatic'; + +export interface SubAgentsRequest { + /** Load user agents from `~/.agents/agents` and `~/.openhands/agents`. */ + load_user?: boolean; + /** Load project agents from the workspace. */ + load_project?: boolean; + /** Load SDK built-in agents (general-purpose, code-explorer, ...). */ + load_builtin?: boolean; + /** Workspace directory path for project agents. */ + project_dir?: string | null; +} + +/** + * Lossless view of a server `AgentDefinition`: every frontmatter field plus the + * discovered `level`/`source`, an `is_builtin` flag, and the inline + * `system_prompt` (Markdown body) so a detail view needs no extra fetch. + */ +export interface SubAgentInfo { + name: string; + description: string; + model: string; + color: string | null; + tools: string[]; + skills: string[]; + system_prompt: string; + when_to_use_examples: string[]; + permission_mode: string | null; + max_iteration_per_run: number | null; + max_budget_per_run: number | null; + mcp_servers: Record | null; + profile_store_dir: string | null; + hooks: HookConfig | null; + /** Context condenser spec (opaque discriminated union), or null for default. */ + condenser: unknown; + metadata: Record; + level: SubAgentLevel | null; + source: string | null; + is_builtin: boolean; +} + +export interface SubAgentsResponse { + agents: SubAgentInfo[]; +} + export interface MarketplacePlugin { name: string; description: string | null; From cd1c81d8aa4627fc507cb6351cb37f3e5550f2e1 Mon Sep 17 00:00:00 2001 From: VascoSch92 Date: Fri, 3 Jul 2026 03:20:32 +0200 Subject: [PATCH 2/2] test(integration): clean up .openhands dir left by sub-agent discovery test The project-level sub-agent discovery test writes `.openhands/agents/.md` into the workspace from the host (the test runner). Its cleanup only unlinked the agent file, leaving the `.openhands/` directory behind, owned by the host user. The agent-server container runs as a different user, so a later test (profile round-trip) failed when the server tried to chmod `workspace/.openhands` during profile activation: Failed to set permissions on workspace/.openhands: [Errno 1] Operation not permitted -> 500 Failed to activate profile Add a `removeWorkspacePath` helper and use it to remove the whole `.openhands` tree the test creates, restoring the workspace so the server can create and own `.openhands` itself. --- .../deterministic-api.integration.test.ts | 9 ++++++++- src/__tests__/integration/test-utils.ts | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/__tests__/integration/deterministic-api.integration.test.ts b/src/__tests__/integration/deterministic-api.integration.test.ts index c0ebf78..549f890 100644 --- a/src/__tests__/integration/deterministic-api.integration.test.ts +++ b/src/__tests__/integration/deterministic-api.integration.test.ts @@ -4,6 +4,7 @@ import { getServerTestConfig } from './test-config'; import { deleteWorkspaceFile, readWorkspaceFile, + removeWorkspacePath, sleep, uniqueDirName, uniqueFileName, @@ -135,7 +136,13 @@ describe('Deterministic API Integration Tests', () => { expect(discovered?.system_prompt).toContain('integration-test project sub-agent'); expect(discovered?.source).toContain(agentPath); } finally { - deleteWorkspaceFile(agentPath); + // Remove the entire `.openhands` tree this test created, not just the + // agent file. Writing it from the host leaves the directory owned by + // the test-runner user; if left behind, the agent-server container + // (which runs as a different user) later fails to chmod + // `workspace/.openhands` during profile activation ("Operation not + // permitted" -> 500 Failed to activate profile). + removeWorkspacePath('.openhands'); } }, config.testTimeout diff --git a/src/__tests__/integration/test-utils.ts b/src/__tests__/integration/test-utils.ts index 37430b5..36cf63c 100644 --- a/src/__tests__/integration/test-utils.ts +++ b/src/__tests__/integration/test-utils.ts @@ -75,6 +75,22 @@ export function deleteWorkspaceFile(relativePath: string): void { } } +/** + * Recursively remove a file or directory from the host workspace directory. + * + * Writing into the workspace from the host (the test runner) creates paths + * owned by the host user, whereas the agent-server container runs as a + * different user. If a test leaves a host-owned directory such as `.openhands` + * behind, the server later fails to chmod it (e.g. profile activation errors + * with "Operation not permitted"). Use this to fully clean up anything a test + * writes into the workspace so it doesn't poison later tests. + */ +export function removeWorkspacePath(relativePath: string): void { + const config = getServerTestConfig(); + const fullPath = path.join(config.hostWorkspaceDir, relativePath); + fs.rmSync(fullPath, { recursive: true, force: true }); +} + /** * Clean the workspace directory (remove all files) */