From 255df0c31fb9830ab3503c8e3dd60b9a2bf0f5e9 Mon Sep 17 00:00:00 2001 From: neubig <398875+neubig@users.noreply.github.com> Date: Sat, 23 May 2026 06:43:04 -0400 Subject: [PATCH 1/3] Add agent-server version errors for workspaces --- src/__tests__/api-clients.test.ts | 113 +++++++++++++++++ src/client/agent-server-compatibility.ts | 149 +++++++++++++++++++++++ src/client/workspaces-client.ts | 95 +++++++++++++++ src/clients.ts | 19 +++ src/index.ts | 39 ++++++ 5 files changed, 415 insertions(+) create mode 100644 src/client/agent-server-compatibility.ts create mode 100644 src/client/workspaces-client.ts diff --git a/src/__tests__/api-clients.test.ts b/src/__tests__/api-clients.test.ts index edf5efb..c570862 100644 --- a/src/__tests__/api-clients.test.ts +++ b/src/__tests__/api-clients.test.ts @@ -8,10 +8,14 @@ import { Workspace, } from '../index'; import { + AgentServerVersionError, ApiKeysClient, BashClient, + clearAgentServerInfoCache, + compareAgentServerVersions, ConversationClient, FileClient, + isAgentServerVersionError, ProfilesClient, SecurityClient, ServerClient, @@ -19,6 +23,7 @@ import { SettingsClient, SharedClient, SkillsClient, + WorkspacesClient, } from '../clients'; const originalFetch = global.fetch; @@ -26,6 +31,7 @@ const originalFetch = global.fetch; describe('Auxiliary API clients', () => { afterEach(() => { global.fetch = originalFetch; + clearAgentServerInfoCache(); jest.restoreAllMocks(); }); @@ -220,6 +226,113 @@ describe('Auxiliary API clients', () => { ); }); + it('WorkspacesClient checks agent-server version before listing workspaces', async () => { + const responses = [ + { version: '1.23.0', uptime: 1, idle_time: 0 }, + { + workspaces: [ + { + id: '/repo', + name: 'repo', + path: '/repo', + parentPath: '/home', + }, + ], + workspaceParents: [{ id: '/home', name: 'home', path: '/home' }], + }, + { + workspaces: [ + { + id: '/repo', + name: 'repo', + path: '/repo', + parentPath: '/home', + }, + { + id: '/repo2', + name: 'repo2', + path: '/repo2', + parentPath: '/home', + }, + ], + workspaceParents: [{ id: '/home', name: 'home', path: '/home' }], + }, + ]; + 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 WorkspacesClient({ host: 'http://example.com' }); + const listed = await client.listWorkspaces(); + const updated = await client.addWorkspaces([ + { id: '/repo2', name: 'repo2', path: '/repo2', parentPath: '/home' }, + ]); + + expect(listed.workspaces).toHaveLength(1); + expect(updated.workspaces).toHaveLength(2); + expect(global.fetch).toHaveBeenNthCalledWith( + 1, + 'http://example.com/server_info', + expect.objectContaining({ method: 'GET' }) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 2, + 'http://example.com/api/workspaces', + expect.objectContaining({ method: 'GET' }) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 3, + 'http://example.com/api/workspaces', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + workspaces: [{ id: '/repo2', name: 'repo2', path: '/repo2', parentPath: '/home' }], + }), + }) + ); + }); + + it('WorkspacesClient throws AgentServerVersionError for old agent 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; + + const client = new WorkspacesClient({ host: 'http://example.com' }); + + await expect(client.listWorkspaces()).rejects.toMatchObject({ + code: 'AGENT_SERVER_VERSION_TOO_OLD', + feature: 'workspaces', + requiredVersion: '1.23.0', + actualVersion: '1.22.1', + }); + + try { + await client.listWorkspaces(); + } catch (error) { + expect(error).toBeInstanceOf(AgentServerVersionError); + expect(isAgentServerVersionError(error)).toBe(true); + } + + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('compares agent-server semantic versions', () => { + expect(compareAgentServerVersions('1.23.0', '1.23.0')).toBe(0); + expect(compareAgentServerVersions('v1.24.0+build.1', '1.23.0')).toBe(1); + expect(compareAgentServerVersions('1.22.9', '1.23.0')).toBe(-1); + expect(compareAgentServerVersions('1.23.0-rc.1', '1.23.0')).toBe(-1); + expect(compareAgentServerVersions('not-a-version', '1.23.0')).toBeNull(); + }); + it('BashClient.startCommand normalizes string requests', async () => { global.fetch = jest.fn().mockResolvedValue( new Response( diff --git a/src/client/agent-server-compatibility.ts b/src/client/agent-server-compatibility.ts new file mode 100644 index 0000000..0148614 --- /dev/null +++ b/src/client/agent-server-compatibility.ts @@ -0,0 +1,149 @@ +import { HttpClient } from './http-client'; +import type { ServerInfo } from '../types/base'; + +export const AGENT_SERVER_VERSION_ERROR_CODE = 'AGENT_SERVER_VERSION_TOO_OLD'; + +export interface AgentServerFeatureRequirement { + feature: string; + displayName: string; + minVersion: string; +} + +export const AgentServerFeatureRequirements = { + workspaces: { + feature: 'workspaces', + displayName: 'Workspaces', + minVersion: '1.23.0', + }, +} as const satisfies Record; + +export class AgentServerVersionError extends Error { + public readonly code = AGENT_SERVER_VERSION_ERROR_CODE; + public readonly feature: string; + public readonly displayName: string; + public readonly requiredVersion: string; + public readonly actualVersion: string; + + constructor({ + requirement, + actualVersion, + }: { + requirement: AgentServerFeatureRequirement; + actualVersion: string; + }) { + super( + `${requirement.displayName} requires agent-server ${requirement.minVersion} or newer; this backend is running ${actualVersion}. Please upgrade your agent-server backend.` + ); + this.name = 'AgentServerVersionError'; + this.feature = requirement.feature; + this.displayName = requirement.displayName; + this.requiredVersion = requirement.minVersion; + this.actualVersion = actualVersion; + + Object.setPrototypeOf(this, AgentServerVersionError.prototype); + } +} + +interface ParsedVersion { + major: number; + minor: number; + patch: number; + prerelease?: string; +} + +let serverInfoCache = new WeakMap>(); + +export function isAgentServerVersionError(error: unknown): error is AgentServerVersionError { + return ( + error instanceof AgentServerVersionError || + (typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === AGENT_SERVER_VERSION_ERROR_CODE) + ); +} + +export function clearAgentServerInfoCache(client?: HttpClient): void { + if (client) { + serverInfoCache.delete(client); + return; + } + serverInfoCache = new WeakMap>(); +} + +export async function getCachedAgentServerInfo(client: HttpClient): Promise { + const cached = serverInfoCache.get(client); + if (cached) { + return cached; + } + + const request = client.get('/server_info').then((response) => response.data); + serverInfoCache.set(client, request); + return request; +} + +export async function assertAgentServerSupports( + client: HttpClient, + requirement: AgentServerFeatureRequirement +): Promise { + const serverInfo = await getCachedAgentServerInfo(client); + const actualVersion = + typeof serverInfo.version === 'string' && serverInfo.version.trim() + ? serverInfo.version.trim() + : 'unknown'; + const comparison = compareAgentServerVersions(actualVersion, requirement.minVersion); + + if (comparison === null || comparison < 0) { + throw new AgentServerVersionError({ requirement, actualVersion }); + } + + return serverInfo; +} + +export function compareAgentServerVersions(actual: string, required: string): number | null { + const parsedActual = parseAgentServerVersion(actual); + const parsedRequired = parseAgentServerVersion(required); + + if (!parsedActual || !parsedRequired) { + return null; + } + + for (const key of ['major', 'minor', 'patch'] as const) { + if (parsedActual[key] > parsedRequired[key]) { + return 1; + } + if (parsedActual[key] < parsedRequired[key]) { + return -1; + } + } + + if (parsedActual.prerelease && !parsedRequired.prerelease) { + return -1; + } + if (!parsedActual.prerelease && parsedRequired.prerelease) { + return 1; + } + if (parsedActual.prerelease && parsedRequired.prerelease) { + return parsedActual.prerelease.localeCompare(parsedRequired.prerelease); + } + + return 0; +} + +function parseAgentServerVersion(version: string): ParsedVersion | null { + const trimmed = version.trim().replace(/^v/, ''); + const [withoutBuild] = trimmed.split('+'); + const [core, prerelease] = withoutBuild.split('-', 2); + const parts = core.split('.'); + + if (parts.length !== 3) { + return null; + } + + const [major, minor, patch] = parts.map((part) => Number(part)); + if (![major, minor, patch].every((part) => Number.isInteger(part) && part >= 0)) { + return null; + } + + return { major, minor, patch, prerelease }; +} diff --git a/src/client/workspaces-client.ts b/src/client/workspaces-client.ts new file mode 100644 index 0000000..de72b6a --- /dev/null +++ b/src/client/workspaces-client.ts @@ -0,0 +1,95 @@ +import { + AgentServerFeatureRequirements, + assertAgentServerSupports, +} from './agent-server-compatibility'; +import { HttpClient } from './http-client'; + +export interface WorkspacesClientOptions { + host: string; + apiKey?: string; + timeout?: number; +} + +export interface WorkspaceItem { + id: string; + name: string; + path: string; + parentPath?: string | null; +} + +export interface WorkspaceParentItem { + id: string; + name: string; + path: string; +} + +export interface WorkspacesListResponse { + workspaces: WorkspaceItem[]; + workspaceParents: WorkspaceParentItem[]; +} + +export interface DeleteWorkspaceResponse { + deleted: boolean; +} + +export class WorkspacesClient { + public readonly host: string; + public readonly apiKey?: string; + private readonly client: HttpClient; + + constructor(options: WorkspacesClientOptions) { + this.host = options.host.replace(/\/$/, ''); + this.apiKey = options.apiKey; + this.client = new HttpClient({ + baseUrl: this.host, + apiKey: this.apiKey, + timeout: options.timeout || 60000, + }); + } + + async listWorkspaces(): Promise { + await this.ensureWorkspacesSupported(); + const response = await this.client.get('/api/workspaces'); + return response.data; + } + + async addWorkspaces(workspaces: WorkspaceItem[]): Promise { + await this.ensureWorkspacesSupported(); + const response = await this.client.post('/api/workspaces', { + workspaces, + }); + return response.data; + } + + async deleteWorkspace(path: string): Promise { + await this.ensureWorkspacesSupported(); + const response = await this.client.delete('/api/workspaces', { + params: { path }, + }); + return response.data; + } + + async addWorkspaceParents(parents: WorkspaceParentItem[]): Promise { + await this.ensureWorkspacesSupported(); + const response = await this.client.post('/api/workspaces/parents', { + parents, + }); + return response.data; + } + + async deleteWorkspaceParent(path: string): Promise { + await this.ensureWorkspacesSupported(); + const response = await this.client.delete('/api/workspaces/parents', { + params: { path }, + }); + return response.data; + } + + close(): void { + this.client.close(); + } + + private async ensureWorkspacesSupported(): Promise { + await assertAgentServerSupports(this.client, AgentServerFeatureRequirements.workspaces); + } +} diff --git a/src/clients.ts b/src/clients.ts index 88f24ea..de21a6a 100644 --- a/src/clients.ts +++ b/src/clients.ts @@ -13,6 +13,17 @@ export { SecurityClient } from './client/security-client'; export { ApiKeysClient } from './client/api-keys-client'; export { SessionClient } from './client/session-client'; export { SharedClient } from './client/shared-client'; +export { WorkspacesClient } from './client/workspaces-client'; +export { + AGENT_SERVER_VERSION_ERROR_CODE, + AgentServerFeatureRequirements, + AgentServerVersionError, + assertAgentServerSupports, + clearAgentServerInfoCache, + compareAgentServerVersions, + getCachedAgentServerInfo, + isAgentServerVersionError, +} from './client/agent-server-compatibility'; export type { ServerClientOptions } from './client/server-client'; export type { BashClientOptions } from './client/bash-client'; @@ -41,3 +52,11 @@ export type { SecurityClientOptions } from './client/security-client'; export type { ApiKeysClientOptions } from './client/api-keys-client'; export type { SessionClientOptions } from './client/session-client'; export type { SharedClientOptions, SharedEventSearchOptions } from './client/shared-client'; +export type { + DeleteWorkspaceResponse, + WorkspacesClientOptions, + WorkspacesListResponse, + WorkspaceItem, + WorkspaceParentItem, +} from './client/workspaces-client'; +export type { AgentServerFeatureRequirement } from './client/agent-server-compatibility'; diff --git a/src/index.ts b/src/index.ts index a7a91f4..cc31c51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -140,6 +140,17 @@ export type { BashWebSocketClientOptions } from './events/bash-websocket-client' // HTTP client export { HttpClient, HttpError } from './client/http-client'; +export { WorkspacesClient } from './client/workspaces-client'; +export { + AGENT_SERVER_VERSION_ERROR_CODE, + AgentServerFeatureRequirements, + AgentServerVersionError, + assertAgentServerSupports, + clearAgentServerInfoCache, + compareAgentServerVersions, + getCachedAgentServerInfo, + isAgentServerVersionError, +} from './client/agent-server-compatibility'; // Types and interfaces export type { @@ -233,6 +244,14 @@ export type { // Client options export type { HttpClientOptions, RequestOptions, HttpResponse } from './client/http-client'; +export type { + DeleteWorkspaceResponse, + WorkspacesClientOptions, + WorkspacesListResponse, + WorkspaceItem, + WorkspaceParentItem, +} from './client/workspaces-client'; +export type { AgentServerFeatureRequirement } from './client/agent-server-compatibility'; export type { AliveStatus, @@ -350,6 +369,17 @@ 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 { WorkspacesClient } from './client/workspaces-client'; +import { + AGENT_SERVER_VERSION_ERROR_CODE, + AgentServerFeatureRequirements, + AgentServerVersionError, + assertAgentServerSupports, + clearAgentServerInfoCache, + compareAgentServerVersions, + getCachedAgentServerInfo, + isAgentServerVersionError, +} from './client/agent-server-compatibility'; import { EventSortOrder, AgentExecutionStatus, ConversationExecutionStatus } from './types/base'; import { ConversationSortOrder } from './models/conversation'; import { Agent } from './agent/agent'; @@ -391,6 +421,15 @@ export default { BashWebSocketClient, HttpClient, HttpError, + WorkspacesClient, + AGENT_SERVER_VERSION_ERROR_CODE, + AgentServerFeatureRequirements, + AgentServerVersionError, + assertAgentServerSupports, + clearAgentServerInfoCache, + compareAgentServerVersions, + getCachedAgentServerInfo, + isAgentServerVersionError, EventSortOrder, ConversationSortOrder, AgentExecutionStatus, From c8e6bb9ff92040d13a9b72c39d9e21f54f460b15 Mon Sep 17 00:00:00 2001 From: neubig <398875+neubig@users.noreply.github.com> Date: Sat, 23 May 2026 06:53:51 -0400 Subject: [PATCH 2/3] Add typed helpers for events and LLM switching --- src/__tests__/api-clients.test.ts | 44 +++++++++++++++++++++++++++++++ src/client/conversation-client.ts | 4 +++ src/events/remote-events-list.ts | 8 ++++-- src/index.ts | 2 +- 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/__tests__/api-clients.test.ts b/src/__tests__/api-clients.test.ts index c570862..31e2368 100644 --- a/src/__tests__/api-clients.test.ts +++ b/src/__tests__/api-clients.test.ts @@ -4,6 +4,7 @@ import { HttpClient, HttpError, RemoteConversation, + RemoteEventsList, RemoteWorkspace, Workspace, } from '../index'; @@ -79,6 +80,49 @@ describe('Auxiliary API clients', () => { expect(ready.message).toBe('Booting'); }); + it('RemoteEventsList can be constructed from client options', async () => { + global.fetch = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ items: [], next_page_id: null }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) as typeof fetch; + + const events = new RemoteEventsList({ baseUrl: 'http://example.com', apiKey: 'secret' }, 'c1'); + const page = await events.search({ limit: 25 }); + + expect(page.items).toEqual([]); + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/api/conversations/c1/events/search?limit=25', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'X-Session-API-Key': 'secret', + }), + }) + ); + }); + + it('ConversationClient.switchLLM posts an explicit LLM config', async () => { + global.fetch = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) as typeof fetch; + + const client = new ConversationClient({ host: 'http://example.com' }); + await client.switchLLM('c1', { model: 'gpt-4o', api_key: 'encrypted' }); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/api/conversations/c1/switch_llm', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ llm: { model: 'gpt-4o', api_key: 'encrypted' } }), + }) + ); + }); + it('SkillsClient.syncSkills posts to the sync endpoint', async () => { global.fetch = jest.fn().mockResolvedValue( new Response(JSON.stringify({ status: 'success', message: 'ok' }), { diff --git a/src/client/conversation-client.ts b/src/client/conversation-client.ts index e3ea0a7..a9e69f7 100644 --- a/src/client/conversation-client.ts +++ b/src/client/conversation-client.ts @@ -138,6 +138,10 @@ export class ConversationClient { }); } + async switchLLM(conversationId: string, llm: unknown): Promise { + await this.client.post(`/api/conversations/${conversationId}/switch_llm`, { llm }); + } + async deleteConversation(conversationId: string): Promise { await this.client.delete(`/api/conversations/${conversationId}`); } diff --git a/src/events/remote-events-list.ts b/src/events/remote-events-list.ts index 8934ef9..e3a672d 100644 --- a/src/events/remote-events-list.ts +++ b/src/events/remote-events-list.ts @@ -7,6 +7,7 @@ */ import { HttpClient, HttpError } from '../client/http-client'; +import type { HttpClientOptions } from '../client/http-client'; import { Event, ConversationCallbackType } from '../types/base'; import { EventPage } from '../types/base'; @@ -32,14 +33,17 @@ export interface EventSearchOptions { timestamp__lt?: string; } +export type RemoteEventsListOptions = HttpClientOptions; + export class RemoteEventsList { private client: HttpClient; private conversationId: string; private cachedEvents: Event[] = []; private cachedEventIds = new Set(); - constructor(client: HttpClient, conversationId: string) { - this.client = client; + constructor(clientOrOptions: HttpClient | RemoteEventsListOptions, conversationId: string) { + this.client = + clientOrOptions instanceof HttpClient ? clientOrOptions : new HttpClient(clientOrOptions); this.conversationId = conversationId; } diff --git a/src/index.ts b/src/index.ts index cc31c51..f6ff8f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,7 @@ export { LocalWorkspace } from './workspace/local-workspace'; export { Workspace, createWorkspace, createWorkspaceAuto } from './workspace/workspace'; export { RemoteState } from './conversation/remote-state'; export { RemoteEventsList } from './events/remote-events-list'; -export type { EventSearchOptions } from './events/remote-events-list'; +export type { EventSearchOptions, RemoteEventsListOptions } from './events/remote-events-list'; // Stuck Detection export { StuckDetector, DEFAULT_STUCK_THRESHOLDS } from './conversation/stuck-detector'; From f273361236b5c7bdef44d02cc4b020614364126b Mon Sep 17 00:00:00 2001 From: neubig <398875+neubig@users.noreply.github.com> Date: Sat, 23 May 2026 07:27:25 -0400 Subject: [PATCH 3/3] Type switch LLM payload --- src/client/conversation-client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/conversation-client.ts b/src/client/conversation-client.ts index a9e69f7..1cf1a5d 100644 --- a/src/client/conversation-client.ts +++ b/src/client/conversation-client.ts @@ -1,5 +1,5 @@ import { HttpClient } from './http-client'; -import { Success } from '../types/base'; +import { LLM, Success } from '../types/base'; import type { AskAgentResponse, ConfirmationResponseRequest, @@ -138,7 +138,7 @@ export class ConversationClient { }); } - async switchLLM(conversationId: string, llm: unknown): Promise { + async switchLLM(conversationId: string, llm: LLM): Promise { await this.client.post(`/api/conversations/${conversationId}/switch_llm`, { llm }); }