From 30d09baa88452ba54bdcea38b28563b179387b7a Mon Sep 17 00:00:00 2001 From: neubig <398875+neubig@users.noreply.github.com> Date: Sat, 23 May 2026 08:14:06 -0400 Subject: [PATCH] Add SDK v1.23 API parity clients --- src/__tests__/api-clients.test.ts | 250 ++++++++++++++++++++++- src/__tests__/workspace-session.test.ts | 14 ++ src/client/agent-server-compatibility.ts | 15 ++ src/client/cloud-proxy-client.ts | 40 ++++ src/client/conversation-client.ts | 139 ++++++++++++- src/client/file-client.ts | 33 +++ src/client/hooks-client.ts | 38 ++++ src/client/mcp-client.ts | 40 ++++ src/client/settings-client.ts | 9 + src/clients.ts | 8 +- src/conversation/conversation-manager.ts | 16 ++ src/conversation/local-conversation.ts | 5 +- src/conversation/remote-conversation.ts | 17 +- src/index.ts | 31 +++ src/models/api.ts | 64 +++++- src/models/conversation.ts | 43 +++- src/types/base.ts | 3 +- src/workspace/remote-workspace.ts | 7 + 18 files changed, 751 insertions(+), 21 deletions(-) create mode 100644 src/client/cloud-proxy-client.ts create mode 100644 src/client/hooks-client.ts create mode 100644 src/client/mcp-client.ts diff --git a/src/__tests__/api-clients.test.ts b/src/__tests__/api-clients.test.ts index 31e2368..2390736 100644 --- a/src/__tests__/api-clients.test.ts +++ b/src/__tests__/api-clients.test.ts @@ -1,5 +1,6 @@ import { Agent, + ConversationExecutionStatus, ConversationManager, HttpClient, HttpError, @@ -13,10 +14,13 @@ import { ApiKeysClient, BashClient, clearAgentServerInfoCache, + CloudProxyClient, compareAgentServerVersions, ConversationClient, FileClient, + HooksClient, isAgentServerVersionError, + MCPClient, ProfilesClient, SecurityClient, ServerClient, @@ -47,10 +51,14 @@ describe('Auxiliary API clients', () => { expect(manager.profiles.host).toBe('http://example.com'); expect(manager.profiles.apiKey).toBe('secret'); expect(manager.files).toBeInstanceOf(FileClient); + expect(manager.workspaces).toBeInstanceOf(WorkspacesClient); expect(manager.security).toBeInstanceOf(SecurityClient); expect(manager.apiKeys).toBeInstanceOf(ApiKeysClient); expect(manager.session).toBeInstanceOf(SessionClient); expect(manager.shared).toBeInstanceOf(SharedClient); + expect(manager.hooks).toBeInstanceOf(HooksClient); + expect(manager.mcp).toBeInstanceOf(MCPClient); + expect(manager.cloudProxy).toBeInstanceOf(CloudProxyClient); }); it('Workspace exposes bash namespace', () => { @@ -679,6 +687,31 @@ describe('Auxiliary API clients', () => { ); }); + it('RemoteConversation.setConfirmationPolicy wraps the SDK v1.23.0 request body', async () => { + global.fetch = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) as typeof fetch; + + const agent = new Agent({ llm: { model: 'gpt-4o', api_key: 'k' } }); + const workspace = new RemoteWorkspace({ host: 'http://example.com', workingDir: '/tmp' }); + const conversation = new RemoteConversation(agent, workspace, { + conversationId: 'conv-123', + }); + + await conversation.setConfirmationPolicy({ kind: 'NeverConfirm' }); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/api/conversations/conv-123/confirmation_policy', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ policy: { kind: 'NeverConfirm' } }), + }) + ); + }); + it('HttpClient can parse blob responses when requested', async () => { global.fetch = jest.fn().mockResolvedValue( new Response(new Blob(['zip-data']), { @@ -807,14 +840,16 @@ describe('Auxiliary API clients', () => { { agent_settings: {}, conversation_settings: { max_iterations: 50 } }, { secrets: [{ name: 'TOKEN', description: 'token' }] }, { name: 'TOKEN', description: 'token' }, + 'plain-secret', { deleted: true }, ]; global.fetch = jest.fn().mockImplementation(() => { const body = responses.shift(); + const isText = typeof body === 'string'; return Promise.resolve( - new Response(JSON.stringify(body), { + new Response(isText ? body : JSON.stringify(body), { status: 200, - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': isText ? 'text/plain' : 'application/json' }, }) ); }) as typeof fetch; @@ -824,6 +859,7 @@ describe('Auxiliary API clients', () => { await client.updateSettings({ conversation_settings_diff: { max_iterations: 50 } }); await client.listSecrets(); await client.upsertSecret({ name: 'TOKEN', value: 'secret', description: 'token' }); + await expect(client.getSecret('TOKEN')).resolves.toBe('plain-secret'); await client.deleteSecret('TOKEN/with slash'); expect(global.fetch).toHaveBeenNthCalledWith( @@ -854,6 +890,11 @@ describe('Auxiliary API clients', () => { ); expect(global.fetch).toHaveBeenNthCalledWith( 5, + 'http://example.com/api/settings/secrets/TOKEN', + expect.objectContaining({ method: 'GET' }) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 6, 'http://example.com/api/settings/secrets/TOKEN%2Fwith%20slash', expect.objectContaining({ method: 'DELETE' }) ); @@ -878,12 +919,21 @@ describe('Auxiliary API clients', () => { } ) ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) .mockResolvedValueOnce(new Response(binary, { status: 200 })) .mockResolvedValueOnce(new Response(new Blob(['trajectory']), { status: 200 })); const client = new FileClient({ host: 'http://example.com' }); await expect(client.getHome()).resolves.toEqual({ home: '/workspace' }); await client.searchSubdirectories('/workspace', { limit: 10, pageId: 'p1' }); + await expect(client.uploadTextFile('hello', '/workspace/hello.txt')).resolves.toEqual({ + success: true, + }); await expect(client.downloadTextFile('/workspace/README.md')).resolves.toBe('hello'); await expect(client.downloadTrajectory('conv 1')).resolves.toBeInstanceOf(Blob); @@ -894,11 +944,18 @@ describe('Auxiliary API clients', () => { ); expect(global.fetch).toHaveBeenNthCalledWith( 3, + 'http://example.com/api/file/upload?path=%2Fworkspace%2Fhello.txt', + expect.objectContaining({ method: 'POST', body: expect.any(FormData) }) + ); + const uploadInit = (global.fetch as jest.Mock).mock.calls[2][1]; + expect(uploadInit.headers['Content-Type']).toBeUndefined(); + expect(global.fetch).toHaveBeenNthCalledWith( + 4, 'http://example.com/api/file/download?path=%2Fworkspace%2FREADME.md', expect.objectContaining({ method: 'GET' }) ); expect(global.fetch).toHaveBeenNthCalledWith( - 4, + 5, 'http://example.com/api/file/download-trajectory/conv%201', expect.objectContaining({ method: 'GET' }) ); @@ -954,6 +1011,193 @@ describe('Auxiliary API clients', () => { ); }); + it('ConversationClient wraps SDK v1.23.0 conversation endpoints', async () => { + const event = { + id: 'event-1', + kind: 'MessageEvent', + timestamp: '2026-05-23T12:00:00Z', + source: 'agent', + }; + const responses = [ + 2, + { items: [event], next_page_id: null }, + event, + 4, + { response: 'done' }, + { success: true }, + { success: true }, + { success: true }, + { success: true }, + { id: 'fork-1' }, + ]; + global.fetch = jest.fn().mockImplementation(() => { + const body = responses.shift(); + return Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + }) as typeof fetch; + + const client = new ConversationClient({ host: 'http://example.com' }); + await expect( + client.countConversations({ status: ConversationExecutionStatus.IDLE }) + ).resolves.toBe(2); + await expect(client.searchEvents('c1', { kind: 'MessageEvent', limit: 5 })).resolves.toEqual({ + items: [event], + next_page_id: null, + }); + await expect(client.getEvent('c1', 'event-1')).resolves.toEqual(event); + await expect(client.getEventCount('c1', { source: 'agent' })).resolves.toBe(4); + await expect(client.getAgentFinalResponse('c1')).resolves.toEqual({ response: 'done' }); + await client.setConfirmationPolicy('c1', { kind: 'NeverConfirm' }); + await client.condenseConversation('c1'); + await client.setSecurityAnalyzer('c1', { kind: 'LLMSecurityAnalyzer' }); + await client.updateSecrets('c1', { + secrets: { TOKEN: { kind: 'StaticSecret', value: 'secret' } }, + }); + await client.forkConversation('c1', { title: 'Fork' }, { includeSkills: true }); + + expect(global.fetch).toHaveBeenNthCalledWith( + 1, + 'http://example.com/api/conversations/count?status=idle', + expect.objectContaining({ method: 'GET' }) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 2, + 'http://example.com/api/conversations/c1/events/search?kind=MessageEvent&limit=5', + expect.objectContaining({ method: 'GET' }) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 5, + 'http://example.com/api/conversations/c1/agent_final_response', + expect.objectContaining({ method: 'GET' }) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 6, + 'http://example.com/api/conversations/c1/confirmation_policy', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ policy: { kind: 'NeverConfirm' } }), + }) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 8, + 'http://example.com/api/conversations/c1/security_analyzer', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ security_analyzer: { kind: 'LLMSecurityAnalyzer' } }), + }) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 10, + 'http://example.com/api/conversations/c1/fork?include_skills=true', + expect.objectContaining({ method: 'POST', body: JSON.stringify({ title: 'Fork' }) }) + ); + }); + + it('Hooks MCP and CloudProxy clients wrap SDK v1.23.0 endpoints', async () => { + const serverInfo = { version: '1.23.0', uptime: 1, idle_time: 0 }; + const responses = [ + serverInfo, + { hook_config: null }, + serverInfo, + { ok: true, tools: ['ping'] }, + serverInfo, + { proxied: true }, + ]; + global.fetch = jest.fn().mockImplementation(() => { + const body = responses.shift(); + return Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + }) as typeof fetch; + + const options = { host: 'http://example.com', apiKey: 'secret' }; + await expect( + new HooksClient(options).loadHooks({ project_dir: '/workspace' }) + ).resolves.toEqual({ + hook_config: null, + }); + await expect( + new MCPClient(options).testServer({ + server: { type: 'stdio', command: 'node', args: ['server.js'] }, + timeout: 10, + }) + ).resolves.toEqual({ ok: true, tools: ['ping'] }); + await expect( + new CloudProxyClient(options).forward({ + host: 'https://app.all-hands.dev', + path: '/api/organizations', + method: 'GET', + }) + ).resolves.toEqual({ proxied: true }); + + expect(global.fetch).toHaveBeenNthCalledWith( + 1, + 'http://example.com/server_info', + expect.objectContaining({ method: 'GET' }) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 2, + 'http://example.com/api/hooks', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ project_dir: '/workspace' }), + headers: expect.objectContaining({ 'X-Session-API-Key': 'secret' }), + }) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 4, + 'http://example.com/api/mcp/test', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + server: { type: 'stdio', command: 'node', args: ['server.js'] }, + timeout: 10, + }), + }) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 6, + 'http://example.com/api/cloud-proxy', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + host: 'https://app.all-hands.dev', + path: '/api/organizations', + method: 'GET', + }), + }) + ); + }); + + it('new SDK v1.23.0 clients throw AgentServerVersionError for old servers', async () => { + global.fetch = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ version: '1.22.1', uptime: 1, idle_time: 0 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) as typeof fetch; + + await expect( + new MCPClient({ host: 'http://example.com' }).testServer({ + server: { type: 'stdio', command: 'node' }, + }) + ).rejects.toMatchObject({ + code: 'AGENT_SERVER_VERSION_TOO_OLD', + feature: 'mcp-test', + requiredVersion: '1.23.0', + actualVersion: '1.22.1', + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + it('Security ApiKeys Session and Shared clients wrap app endpoints', async () => { const responses = [ { policy: 'default' }, diff --git a/src/__tests__/workspace-session.test.ts b/src/__tests__/workspace-session.test.ts index fd8cc9f..32fc03f 100644 --- a/src/__tests__/workspace-session.test.ts +++ b/src/__tests__/workspace-session.test.ts @@ -85,4 +85,18 @@ describe('RemoteWorkspace.startWorkspaceSession', () => { expect(baseUrl).toBe('https://agent.example.com/api/conversations/cid-only/workspace/'); }); + + it('deletes the workspace session cookie with credentials included', async () => { + const fetchMock = jest.fn().mockResolvedValue(noContentResponse()) as jest.Mock; + global.fetch = fetchMock as typeof fetch; + + const workspace = makeWorkspace(); + await workspace.deleteWorkspaceSession(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(new URL(url as string).pathname).toBe('/api/auth/workspace-session'); + expect((init as RequestInit).method).toBe('DELETE'); + expect((init as RequestInit).credentials).toBe('include'); + }); }); diff --git a/src/client/agent-server-compatibility.ts b/src/client/agent-server-compatibility.ts index 0148614..e50e3f6 100644 --- a/src/client/agent-server-compatibility.ts +++ b/src/client/agent-server-compatibility.ts @@ -15,6 +15,21 @@ export const AgentServerFeatureRequirements = { displayName: 'Workspaces', minVersion: '1.23.0', }, + hooks: { + feature: 'hooks', + displayName: 'Hooks API', + minVersion: '1.23.0', + }, + mcpTest: { + feature: 'mcp-test', + displayName: 'MCP test API', + minVersion: '1.23.0', + }, + cloudProxy: { + feature: 'cloud-proxy', + displayName: 'Cloud proxy', + minVersion: '1.23.0', + }, } as const satisfies Record; export class AgentServerVersionError extends Error { diff --git a/src/client/cloud-proxy-client.ts b/src/client/cloud-proxy-client.ts new file mode 100644 index 0000000..e5268bb --- /dev/null +++ b/src/client/cloud-proxy-client.ts @@ -0,0 +1,40 @@ +import { + AgentServerFeatureRequirements, + assertAgentServerSupports, +} from './agent-server-compatibility'; +import { HttpClient } from './http-client'; +import type { CloudProxyRequest } from '../models/api'; + +export interface CloudProxyClientOptions { + host: string; + apiKey?: string; + timeout?: number; +} + +export class CloudProxyClient { + public readonly host: string; + public readonly apiKey?: string; + private readonly client: HttpClient; + + constructor(options: CloudProxyClientOptions) { + this.host = options.host.replace(/\/$/, ''); + this.apiKey = options.apiKey; + this.client = new HttpClient({ + baseUrl: this.host, + apiKey: this.apiKey, + timeout: options.timeout || 60000, + }); + } + + async forward(request: CloudProxyRequest): Promise { + await assertAgentServerSupports(this.client, AgentServerFeatureRequirements.cloudProxy); + const response = await this.client.post('/api/cloud-proxy', request, { + timeout: (request.timeout_seconds ?? 15) * 1000 + 5000, + }); + return response.data; + } + + close(): void { + this.client.close(); + } +} diff --git a/src/client/conversation-client.ts b/src/client/conversation-client.ts index 1cf1a5d..ca37fd5 100644 --- a/src/client/conversation-client.ts +++ b/src/client/conversation-client.ts @@ -1,13 +1,23 @@ -import { HttpClient } from './http-client'; -import { LLM, Success } from '../types/base'; +import { HttpClient, HttpError } from './http-client'; +import { ConversationExecutionStatus, LLM, Success } from '../types/base'; import type { + AgentResponseResult, AskAgentResponse, ConfirmationResponseRequest, + ConversationEvent, + ConversationEventCountOptions, + ConversationEventPage, + ConversationEventSearchOptions, ConversationInfo, ConversationSearchRequest, ConversationSearchResponse, + ForkConversationRequest, + SetConfirmationPolicyRequest, + SetSecurityAnalyzerRequest, UpdateConversationRequest, + UpdateSecretsRequest, } from '../models/conversation'; +import type { ConfirmationPolicyBase } from '../types/base'; export interface ConversationClientOptions { host: string; @@ -55,6 +65,15 @@ export class ConversationClient { return response.data; } + async countConversations( + options: { status?: ConversationExecutionStatus } = {} + ): Promise { + const response = await this.client.get('/api/conversations/count', { + params: options as Record, + }); + return response.data; + } + async getConversations( conversationIds: string[] ): Promise> { @@ -71,6 +90,42 @@ export class ConversationClient { return response.data; } + async searchEvents( + conversationId: string, + options: ConversationEventSearchOptions = {} + ): Promise { + const response = await this.client.get( + `/api/conversations/${conversationId}/events/search`, + { params: options as Record } + ); + return response.data; + } + + async getEvent(conversationId: string, eventId: string): Promise { + const response = await this.client.get( + `/api/conversations/${conversationId}/events/${eventId}` + ); + return response.data; + } + + async getEvents( + conversationId: string, + eventIds: string[] + ): Promise> { + return Promise.all( + eventIds.map(async (eventId) => { + try { + return await this.getEvent(conversationId, eventId); + } catch (error) { + if (error instanceof HttpError && error.status === 404) { + return null; + } + throw error; + } + }) + ); + } + async sendEvent( conversationId: string, event: object, @@ -114,9 +169,13 @@ export class ConversationClient { return response.data; } - async getEventCount(conversationId: string): Promise { + async getEventCount( + conversationId: string, + options: ConversationEventCountOptions = {} + ): Promise { const response = await this.client.get( - `/api/conversations/${conversationId}/events/count` + `/api/conversations/${conversationId}/events/count`, + { params: options as Record } ); return response.data; } @@ -132,6 +191,74 @@ export class ConversationClient { return response.data; } + async getAgentFinalResponse(conversationId: string): Promise { + const response = await this.client.get( + `/api/conversations/${conversationId}/agent_final_response` + ); + return response.data; + } + + async setConfirmationPolicy( + conversationId: string, + policyOrRequest: ConfirmationPolicyBase | SetConfirmationPolicyRequest + ): Promise { + const request = + 'policy' in policyOrRequest ? policyOrRequest : ({ policy: policyOrRequest } as const); + const response = await this.client.post( + `/api/conversations/${conversationId}/confirmation_policy`, + request + ); + return response.data; + } + + async condenseConversation(conversationId: string): Promise { + const response = await this.client.post( + `/api/conversations/${conversationId}/condense`, + {} + ); + return response.data; + } + + async setSecurityAnalyzer( + conversationId: string, + securityAnalyzerOrRequest: unknown | SetSecurityAnalyzerRequest | null + ): Promise { + const request = isSetSecurityAnalyzerRequest(securityAnalyzerOrRequest) + ? securityAnalyzerOrRequest + : { security_analyzer: securityAnalyzerOrRequest }; + const response = await this.client.post( + `/api/conversations/${conversationId}/security_analyzer`, + request + ); + return response.data; + } + + async updateSecrets(conversationId: string, request: UpdateSecretsRequest): Promise { + const response = await this.client.post( + `/api/conversations/${conversationId}/secrets`, + request + ); + return response.data; + } + + async forkConversation( + conversationId: string, + request: ForkConversationRequest = {}, + options: { includeSkills?: boolean } = {} + ): Promise { + const response = await this.client.post( + `/api/conversations/${conversationId}/fork`, + request, + { + params: + options.includeSkills === undefined + ? undefined + : { include_skills: options.includeSkills }, + } + ); + return response.data; + } + async switchProfile(conversationId: string, profileName: string): Promise { await this.client.post(`/api/conversations/${conversationId}/switch_profile`, { profile_name: profileName, @@ -161,3 +288,7 @@ export class ConversationClient { this.client.close(); } } + +function isSetSecurityAnalyzerRequest(value: unknown): value is SetSecurityAnalyzerRequest { + return typeof value === 'object' && value !== null && 'security_analyzer' in value; +} diff --git a/src/client/file-client.ts b/src/client/file-client.ts index f267ebf..3686af0 100644 --- a/src/client/file-client.ts +++ b/src/client/file-client.ts @@ -4,6 +4,7 @@ import type { FileSearchSubdirsOptions, FileSubdirectoryPage, } from '../models/api'; +import type { Success } from '../types/base'; export interface FileClientOptions { host: string; @@ -11,6 +12,8 @@ export interface FileClientOptions { timeout?: number; } +export type FileUploadContent = string | Blob | File; + export class FileClient { public readonly host: string; public readonly apiKey?: string; @@ -57,6 +60,36 @@ export class FileClient { return new TextDecoder().decode(await this.downloadFile(path)); } + async uploadFile( + content: FileUploadContent, + destinationPath: string, + fileName?: string + ): Promise { + const formData = new FormData(); + const fileConstructor = typeof File === 'undefined' ? undefined : File; + + if (fileConstructor && content instanceof fileConstructor) { + formData.append('file', content, fileName || content.name); + } else if (content instanceof Blob) { + formData.append('file', content, fileName || 'blob-file'); + } else { + formData.append( + 'file', + new Blob([content], { type: 'text/plain' }), + fileName || 'text-file.txt' + ); + } + + const response = await this.client.post('/api/file/upload', formData, { + params: { path: destinationPath }, + }); + return response.data; + } + + async uploadTextFile(text: string, destinationPath: string, fileName?: string): Promise { + return this.uploadFile(text, destinationPath, fileName); + } + async downloadTrajectory(conversationId: string): Promise { const response = await this.client.get( `/api/file/download-trajectory/${encodeURIComponent(conversationId)}`, diff --git a/src/client/hooks-client.ts b/src/client/hooks-client.ts new file mode 100644 index 0000000..2e85c37 --- /dev/null +++ b/src/client/hooks-client.ts @@ -0,0 +1,38 @@ +import { + AgentServerFeatureRequirements, + assertAgentServerSupports, +} from './agent-server-compatibility'; +import { HttpClient } from './http-client'; +import type { HooksRequest, HooksResponse } from '../models/api'; + +export interface HooksClientOptions { + host: string; + apiKey?: string; + timeout?: number; +} + +export class HooksClient { + public readonly host: string; + public readonly apiKey?: string; + private readonly client: HttpClient; + + constructor(options: HooksClientOptions) { + this.host = options.host.replace(/\/$/, ''); + this.apiKey = options.apiKey; + this.client = new HttpClient({ + baseUrl: this.host, + apiKey: this.apiKey, + timeout: options.timeout || 60000, + }); + } + + async loadHooks(request: HooksRequest = {}): Promise { + await assertAgentServerSupports(this.client, AgentServerFeatureRequirements.hooks); + const response = await this.client.post('/api/hooks', request); + return response.data; + } + + close(): void { + this.client.close(); + } +} diff --git a/src/client/mcp-client.ts b/src/client/mcp-client.ts new file mode 100644 index 0000000..eebd1e6 --- /dev/null +++ b/src/client/mcp-client.ts @@ -0,0 +1,40 @@ +import { + AgentServerFeatureRequirements, + assertAgentServerSupports, +} from './agent-server-compatibility'; +import { HttpClient } from './http-client'; +import type { MCPTestRequest, MCPTestResponse } from '../models/api'; + +export interface MCPClientOptions { + host: string; + apiKey?: string; + timeout?: number; +} + +export class MCPClient { + public readonly host: string; + public readonly apiKey?: string; + private readonly client: HttpClient; + + constructor(options: MCPClientOptions) { + this.host = options.host.replace(/\/$/, ''); + this.apiKey = options.apiKey; + this.client = new HttpClient({ + baseUrl: this.host, + apiKey: this.apiKey, + timeout: options.timeout || 60000, + }); + } + + async testServer(request: MCPTestRequest): Promise { + await assertAgentServerSupports(this.client, AgentServerFeatureRequirements.mcpTest); + const response = await this.client.post('/api/mcp/test', request, { + timeout: (request.timeout ?? 15) * 1000 + 5000, + }); + return response.data; + } + + close(): void { + this.client.close(); + } +} diff --git a/src/client/settings-client.ts b/src/client/settings-client.ts index c69ce02..2ad1ac4 100644 --- a/src/client/settings-client.ts +++ b/src/client/settings-client.ts @@ -9,6 +9,7 @@ import type { SettingsApiResponse, SettingsSchema, SettingsUpdateRequest, + SecretValueResponse, SecretsListResponse, UpsertSecretRequest, UpsertSecretResponse, @@ -72,6 +73,14 @@ export class SettingsClient { return response.data; } + async getSecret(name: string): Promise { + const response = await this.client.get( + `/api/settings/secrets/${encodeURIComponent(name)}`, + { responseType: 'text' } + ); + return response.data; + } + async deleteSecret(name: string): Promise { const response = await this.client.delete( `/api/settings/secrets/${encodeURIComponent(name)}` diff --git a/src/clients.ts b/src/clients.ts index de21a6a..bbb6e67 100644 --- a/src/clients.ts +++ b/src/clients.ts @@ -1,8 +1,11 @@ export { ServerClient } from './client/server-client'; export { BashClient } from './client/bash-client'; +export { CloudProxyClient } from './client/cloud-proxy-client'; export { ConversationClient } from './client/conversation-client'; export { FileClient } from './client/file-client'; +export { HooksClient } from './client/hooks-client'; export { LLMMetadataClient } from './client/llm-client'; +export { MCPClient } from './client/mcp-client'; export { ProfilesClient } from './client/profiles-client'; export { SettingsClient } from './client/settings-client'; export { SkillsClient } from './client/skills-client'; @@ -27,13 +30,16 @@ export { export type { ServerClientOptions } from './client/server-client'; export type { BashClientOptions } from './client/bash-client'; +export type { CloudProxyClientOptions } from './client/cloud-proxy-client'; export type { ConversationClientOptions, CreateConversationPayload, SendConversationEventOptions, } from './client/conversation-client'; -export type { FileClientOptions } from './client/file-client'; +export type { FileClientOptions, FileUploadContent } from './client/file-client'; +export type { HooksClientOptions } from './client/hooks-client'; export type { LLMMetadataClientOptions } from './client/llm-client'; +export type { MCPClientOptions } from './client/mcp-client'; export type { ProfilesClientOptions, GetProfileOptions } from './client/profiles-client'; export type { SettingsClientOptions, diff --git a/src/conversation/conversation-manager.ts b/src/conversation/conversation-manager.ts index 316692d..b1dac5b 100644 --- a/src/conversation/conversation-manager.ts +++ b/src/conversation/conversation-manager.ts @@ -4,9 +4,12 @@ import { HttpClient } from '../client/http-client'; import { ApiKeysClient } from '../client/api-keys-client'; +import { CloudProxyClient } from '../client/cloud-proxy-client'; import { DesktopClient } from '../client/desktop-client'; import { FileClient } from '../client/file-client'; +import { HooksClient } from '../client/hooks-client'; import { LLMMetadataClient } from '../client/llm-client'; +import { MCPClient } from '../client/mcp-client'; import { ProfilesClient } from '../client/profiles-client'; import { ServerClient } from '../client/server-client'; import { SecurityClient } from '../client/security-client'; @@ -16,6 +19,7 @@ import { SharedClient } from '../client/shared-client'; import { SkillsClient } from '../client/skills-client'; import { ToolClient } from '../client/tool-client'; import { VSCodeClient } from '../client/vscode-client'; +import { WorkspacesClient } from '../client/workspaces-client'; import { RemoteConversation } from './remote-conversation'; import { RemoteWorkspace } from '../workspace/remote-workspace'; import { @@ -86,10 +90,14 @@ export class ConversationManager { public readonly vscode: VSCodeClient; public readonly desktop: DesktopClient; public readonly files: FileClient; + public readonly workspaces: WorkspacesClient; public readonly security: SecurityClient; public readonly apiKeys: ApiKeysClient; public readonly session: SessionClient; public readonly shared: SharedClient; + public readonly hooks: HooksClient; + public readonly mcp: MCPClient; + public readonly cloudProxy: CloudProxyClient; public readonly acp: ACPConversationNamespace; constructor(options: ConversationManagerOptions) { @@ -116,10 +124,14 @@ export class ConversationManager { this.vscode = new VSCodeClient(clientOptions); this.desktop = new DesktopClient(clientOptions); this.files = new FileClient(clientOptions); + this.workspaces = new WorkspacesClient(clientOptions); this.security = new SecurityClient(clientOptions); this.apiKeys = new ApiKeysClient(clientOptions); this.session = new SessionClient(clientOptions); this.shared = new SharedClient(clientOptions); + this.hooks = new HooksClient(clientOptions); + this.mcp = new MCPClient(clientOptions); + this.cloudProxy = new CloudProxyClient(clientOptions); this.acp = new ACPConversationNamespace(this); } @@ -393,10 +405,14 @@ export class ConversationManager { this.vscode.close(); this.desktop.close(); this.files.close(); + this.workspaces.close(); this.security.close(); this.apiKeys.close(); this.session.close(); this.shared.close(); + this.hooks.close(); + this.mcp.close(); + this.cloudProxy.close(); this.client.close(); } } diff --git a/src/conversation/local-conversation.ts b/src/conversation/local-conversation.ts index 9b9be57..1d566b9 100644 --- a/src/conversation/local-conversation.ts +++ b/src/conversation/local-conversation.ts @@ -705,9 +705,10 @@ export class LocalConversation implements IConversation { this._state.confirmationPolicy = policy as ConfirmationPolicy; } else { // Create a simple wrapper + const policyType = (policy as ConfirmationPolicyBase).type ?? policy.kind ?? 'never'; this._state.confirmationPolicy = { - type: (policy as ConfirmationPolicyBase).type, - requiresConfirmation: () => (policy as ConfirmationPolicyBase).type === 'always', + type: policyType, + requiresConfirmation: () => policyType === 'always' || policyType === 'AlwaysConfirm', }; } } diff --git a/src/conversation/remote-conversation.ts b/src/conversation/remote-conversation.ts index cf8195c..6468bc3 100644 --- a/src/conversation/remote-conversation.ts +++ b/src/conversation/remote-conversation.ts @@ -232,7 +232,7 @@ export class RemoteConversation implements IConversation { } async setConfirmationPolicy(policy: ConfirmationPolicyBase): Promise { - await this.client.post(`/api/conversations/${this.id}/confirmation_policy`, policy); + await this.client.post(`/api/conversations/${this.id}/confirmation_policy`, { policy }); } async sendConfirmationResponse(accept: boolean, reason?: string): Promise { @@ -304,10 +304,19 @@ export class RemoteConversation implements IConversation { /** * Fork the current conversation and return a new RemoteConversation instance. */ - async fork(request: ForkConversationRequest = {}): Promise { + async fork( + request: ForkConversationRequest = {}, + options: { includeSkills?: boolean } = {} + ): Promise { const response = await this.client.post( `/api/conversations/${this.id}/fork`, - request + request, + { + params: + options.includeSkills === undefined + ? undefined + : { include_skills: options.includeSkills }, + } ); const forkWorkspace = new RemoteWorkspace({ @@ -346,7 +355,7 @@ export class RemoteConversation implements IConversation { * Set the security analyzer for the conversation. * The security analyzer evaluates action risks. */ - async setSecurityAnalyzer(securityAnalyzer: any | null): Promise { + async setSecurityAnalyzer(securityAnalyzer: unknown | null): Promise { const request: SetSecurityAnalyzerRequest = { security_analyzer: securityAnalyzer }; await this.client.post(`/api/conversations/${this.id}/security_analyzer`, request); } diff --git a/src/index.ts b/src/index.ts index f6ff8f5..ac07363 100644 --- a/src/index.ts +++ b/src/index.ts @@ -140,6 +140,9 @@ export type { BashWebSocketClientOptions } from './events/bash-websocket-client' // HTTP client export { HttpClient, HttpError } from './client/http-client'; +export { CloudProxyClient } from './client/cloud-proxy-client'; +export { HooksClient } from './client/hooks-client'; +export { MCPClient } from './client/mcp-client'; export { WorkspacesClient } from './client/workspaces-client'; export { AGENT_SERVER_VERSION_ERROR_CODE, @@ -238,12 +241,20 @@ export type { AskAgentRequest, AskAgentResponse, SetSecurityAnalyzerRequest, + SetConfirmationPolicyRequest, + ConversationEventSearchOptions, + ConversationEventCountOptions, ForkConversationRequest, AgentResponseResult, + ConversationEvent as ConversationApiEvent, + ConversationEventPage, } from './models/conversation'; // Client options export type { HttpClientOptions, RequestOptions, HttpResponse } from './client/http-client'; +export type { CloudProxyClientOptions } from './client/cloud-proxy-client'; +export type { HooksClientOptions } from './client/hooks-client'; +export type { MCPClientOptions } from './client/mcp-client'; export type { DeleteWorkspaceResponse, WorkspacesClientOptions, @@ -296,10 +307,24 @@ export type { UpsertSecretRequest, UpsertSecretResponse, DeleteSecretResponse, + SecretValueResponse, FileSubdirectoryEntry, FileSubdirectoryPage, FileHomeResponse, FileSearchSubdirsOptions, + CloudProxyRequest, + CloudProxyResponse, + HooksRequest, + HooksResponse, + StdioMCPServerSpec, + RemoteMCPServerType, + RemoteMCPServerSpec, + MCPServerSpec, + MCPTestRequest, + MCPTestSuccess, + MCPTestFailureKind, + MCPTestFailure, + MCPTestResponse, SecuritySettings, SecurityTraceResponse, ApiKey, @@ -369,6 +394,9 @@ import { RemoteEventsList } from './events/remote-events-list'; import { WebSocketCallbackClient } from './events/websocket-client'; import { BashWebSocketClient } from './events/bash-websocket-client'; import { HttpClient, HttpError } from './client/http-client'; +import { CloudProxyClient } from './client/cloud-proxy-client'; +import { HooksClient } from './client/hooks-client'; +import { MCPClient } from './client/mcp-client'; import { WorkspacesClient } from './client/workspaces-client'; import { AGENT_SERVER_VERSION_ERROR_CODE, @@ -421,6 +449,9 @@ export default { BashWebSocketClient, HttpClient, HttpError, + CloudProxyClient, + HooksClient, + MCPClient, WorkspacesClient, AGENT_SERVER_VERSION_ERROR_CODE, AgentServerFeatureRequirements, diff --git a/src/models/api.ts b/src/models/api.ts index 7777fbf..08f0d60 100644 --- a/src/models/api.ts +++ b/src/models/api.ts @@ -2,7 +2,8 @@ * Models for auxiliary Agent Server APIs. */ -import { LLM } from '../types/base'; +import type { HookConfig } from '../hooks'; +import type { LLM } from '../types/base'; export interface AliveStatus { status: string; @@ -227,6 +228,8 @@ export interface DeleteSecretResponse { deleted: boolean; } +export type SecretValueResponse = string; + export interface FileSubdirectoryEntry { name: string; path: string; @@ -246,6 +249,65 @@ export interface FileSearchSubdirsOptions { limit?: number; } +export interface CloudProxyRequest { + host: string; + path: string; + method?: string; + headers?: Record; + body?: unknown; + timeout_seconds?: number; +} + +export type CloudProxyResponse = unknown; + +export interface HooksRequest { + project_dir?: string | null; +} + +export interface HooksResponse { + hook_config?: HookConfig | null; +} + +export interface StdioMCPServerSpec { + type: 'stdio'; + command: string; + args?: string[]; + env?: Record; + cwd?: string | null; +} + +export type RemoteMCPServerType = 'http' | 'shttp' | 'streamable-http' | 'sse'; + +export interface RemoteMCPServerSpec { + type: RemoteMCPServerType; + url: string; + headers?: Record; + api_key?: string | null; +} + +export type MCPServerSpec = StdioMCPServerSpec | RemoteMCPServerSpec; + +export interface MCPTestRequest { + server: MCPServerSpec; + name?: string; + timeout?: number; +} + +export interface MCPTestSuccess { + ok: true; + tools: string[]; +} + +export type MCPTestFailureKind = 'timeout' | 'connection' | 'unknown'; + +export interface MCPTestFailure { + ok: false; + error: string; + error_kind: MCPTestFailureKind; +} + +export type MCPTestResponse = MCPTestSuccess | MCPTestFailure; + export interface SecuritySettings { RISK_SEVERITY: number; [key: string]: unknown; diff --git a/src/models/conversation.ts b/src/models/conversation.ts index f134008..d201111 100644 --- a/src/models/conversation.ts +++ b/src/models/conversation.ts @@ -9,6 +9,8 @@ import { ConfirmationPolicyBase, ConversationStats, AgentBase, + Event, + EventPage, Message, } from '../types/base'; import type { HookConfig } from '../hooks'; @@ -114,15 +116,23 @@ export interface UpdateConversationRequest { export interface StaticSecret { kind: 'StaticSecret'; - value: string; - description?: string; + value?: string | null; + description?: string | null; } export interface LookupSecret { kind: 'LookupSecret'; - source: string; - key: string; - description?: string; + url: string; + headers?: Record; + description?: string | null; + /** + * @deprecated v1.23.0 agent servers use `url` and optional `headers`. + */ + source?: string; + /** + * @deprecated v1.23.0 agent servers use `url` and optional `headers`. + */ + key?: string; } export type SecretObject = StaticSecret | LookupSecret; @@ -151,6 +161,26 @@ export interface SetSecurityAnalyzerRequest { security_analyzer: unknown | null; } +export interface SetConfirmationPolicyRequest { + policy: ConfirmationPolicyBase; +} + +export interface ConversationEventSearchOptions { + page_id?: string; + limit?: number; + kind?: string; + source?: string; + body?: string; + sort_order?: 'TIMESTAMP' | 'TIMESTAMP_DESC'; + timestamp__gte?: string; + timestamp__lt?: string; +} + +export type ConversationEventCountOptions = Omit< + ConversationEventSearchOptions, + 'page_id' | 'limit' | 'sort_order' +>; + export interface ConversationSearchResponse { items: ConversationInfo[]; next_page_id?: string; @@ -173,3 +203,6 @@ export interface ForkConversationRequest { export interface AgentResponseResult { response: string; } + +export type ConversationEvent = Event; +export type ConversationEventPage = EventPage; diff --git a/src/types/base.ts b/src/types/base.ts index f1c9c46..0269454 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -129,7 +129,8 @@ export interface ConversationStats { } export interface ConfirmationPolicyBase { - type: string; + kind?: string; + type?: string; [key: string]: unknown; } diff --git a/src/workspace/remote-workspace.ts b/src/workspace/remote-workspace.ts index 277542a..8f7db72 100644 --- a/src/workspace/remote-workspace.ts +++ b/src/workspace/remote-workspace.ts @@ -93,6 +93,13 @@ export class RemoteWorkspace implements IWorkspace { return `${this.host}/api/conversations/${conversationId}/workspace/`; } + async deleteWorkspaceSession(): Promise { + await this.client.delete('/api/auth/workspace-session', { + credentials: 'include', + acceptableStatusCodes: new Set([204]), + }); + } + async executeCommand( command: string, cwd?: string,