Skip to content
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
157 changes: 157 additions & 0 deletions src/__tests__/api-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,35 @@ import {
HttpClient,
HttpError,
RemoteConversation,
RemoteEventsList,
RemoteWorkspace,
Workspace,
} from '../index';
import {
AgentServerVersionError,
ApiKeysClient,
BashClient,
clearAgentServerInfoCache,
compareAgentServerVersions,
ConversationClient,
FileClient,
isAgentServerVersionError,
ProfilesClient,
SecurityClient,
ServerClient,
SessionClient,
SettingsClient,
SharedClient,
SkillsClient,
WorkspacesClient,
} from '../clients';

const originalFetch = global.fetch;

describe('Auxiliary API clients', () => {
afterEach(() => {
global.fetch = originalFetch;
clearAgentServerInfoCache();
jest.restoreAllMocks();
});

Expand Down Expand Up @@ -73,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' }), {
Expand Down Expand Up @@ -220,6 +270,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(
Expand Down
149 changes: 149 additions & 0 deletions src/client/agent-server-compatibility.ts
Original file line number Diff line number Diff line change
@@ -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<string, AgentServerFeatureRequirement>;

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<HttpClient, Promise<ServerInfo>>();

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<HttpClient, Promise<ServerInfo>>();
}

export async function getCachedAgentServerInfo(client: HttpClient): Promise<ServerInfo> {
const cached = serverInfoCache.get(client);
if (cached) {
return cached;
}

const request = client.get<ServerInfo>('/server_info').then((response) => response.data);
serverInfoCache.set(client, request);
return request;
}

export async function assertAgentServerSupports(
client: HttpClient,
requirement: AgentServerFeatureRequirement
): Promise<ServerInfo> {
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 };
}
6 changes: 5 additions & 1 deletion src/client/conversation-client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { HttpClient } from './http-client';
import { Success } from '../types/base';
import { LLM, Success } from '../types/base';
import type {
AskAgentResponse,
ConfirmationResponseRequest,
Expand Down Expand Up @@ -138,6 +138,10 @@ export class ConversationClient {
});
}

async switchLLM(conversationId: string, llm: LLM): Promise<void> {
await this.client.post<Success>(`/api/conversations/${conversationId}/switch_llm`, { llm });
}

async deleteConversation(conversationId: string): Promise<void> {
await this.client.delete<Success>(`/api/conversations/${conversationId}`);
}
Expand Down
Loading
Loading