Skip to content
This repository was archived by the owner on Sep 7, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions src/__tests__/api-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
SettingsClient,
SharedClient,
SkillsClient,
SubAgentsClient,
WorkspacesClient,
} from '../clients';
import * as http from 'node:http';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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: [
Expand Down
65 changes: 65 additions & 0 deletions src/__tests__/integration/deterministic-api.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { getServerTestConfig } from './test-config';
import {
deleteWorkspaceFile,
readWorkspaceFile,
removeWorkspacePath,
sleep,
uniqueDirName,
uniqueFileName,
workspaceFileExists,
writeWorkspaceFile,
} from './test-utils';

const config = getServerTestConfig();
Expand Down Expand Up @@ -56,6 +58,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();
Expand All @@ -70,6 +77,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 {
Expand All @@ -83,6 +97,57 @@ 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 {
// 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
);

it(
'supports deterministic conversation, event, fork, and ACP endpoints',
async () => {
Expand Down
16 changes: 16 additions & 0 deletions src/__tests__/integration/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down
45 changes: 45 additions & 0 deletions src/client/sub-agents-client.ts
Original file line number Diff line number Diff line change
@@ -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<SubAgentsResponse> {
const response = await this.client.post<SubAgentsResponse>('/api/sub-agents', request);
return response.data;
}

close(): void {
this.client.close();
}
}
2 changes: 2 additions & 0 deletions src/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down
4 changes: 4 additions & 0 deletions src/conversation/conversation-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,10 @@ export type {
RefreshSkillResponse,
MarketplaceSkill,
MarketplaceResponse,
SubAgentLevel,
SubAgentsRequest,
SubAgentInfo,
SubAgentsResponse,
MarketplacePlugin,
MarketplaceCatalogResponse,
PluginsRequest,
Expand Down
Loading
Loading