Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
20b83ba
feat: add conversation-scoped runtime APIs and clients
openhands-agent Sep 12, 2026
f638e0e
fix(ci): recognize upstream approved LLM.modify_params removal
openhands-agent Sep 12, 2026
954892d
refactor(client): bind runtime services to explicit conversation owne…
openhands-agent Sep 12, 2026
29184d8
fix(client): keep runtime URL parameters within public type budget
openhands-agent Sep 12, 2026
c12cf0a
Merge main into conversation runtime API foundation
openhands-agent Sep 12, 2026
584aa4a
feat: expose runtime lifecycle contracts independently of Docker
openhands-agent Sep 13, 2026
f64e34f
chore: keep runtime lifecycle tests focused
openhands-agent Sep 13, 2026
ae05f6e
Merge branch 'factory/restack-lifecycle' into factory/ready-sdk-4966
openhands-agent Sep 13, 2026
ad08608
style: format extracted runtime lifecycle model
openhands-agent Sep 13, 2026
b8e0d3c
ci: use the native stack base instead of temporary branch filters
openhands-agent Sep 13, 2026
c3d8db9
Merge remote-tracking branch 'origin/main' into factory/ready-sdk-4966
openhands-agent Sep 13, 2026
d5719d4
docs: capture live Canvas evidence for #4966
openhands-agent Sep 13, 2026
86695aa
chore: Remove PR-only artifacts [automated]
Sep 13, 2026
2805ce4
refactor(agent-server): keep runtime contracts independent of Docker
openhands-agent Sep 13, 2026
3808768
Merge remote-tracking branch 'origin/main' into factory/ready-sdk-4966
openhands-agent Sep 13, 2026
4244f91
refactor(client): scope existing clients without runtime facades
openhands-agent Sep 14, 2026
3600b6d
fix(api): type download schemas on their owning routes
openhands-agent Sep 14, 2026
cd064c0
fix(runtime): limit process cleanup to scoped services
openhands-agent Sep 14, 2026
65911c8
docs: add live narrowed-client evidence
openhands-agent Sep 14, 2026
97e664a
refactor(runtime): keep route reuse compatibility explicit
openhands-agent Sep 14, 2026
9fd0983
refactor(runtime): remove unused scoped contracts
openhands-agent Sep 14, 2026
8152c39
refactor(client): rely on the compiled client contract
openhands-agent Sep 14, 2026
4b75798
docs: align runtime evidence with narrowed scope
openhands-agent Sep 14, 2026
72a74b3
docs(client): explain optional conversation ownership
openhands-agent Sep 14, 2026
c39586e
fix(runtime): keep host bash teardown unchanged
openhands-agent Sep 14, 2026
8182852
refactor(runtime): name conversation ownership explicitly
openhands-agent Sep 14, 2026
01398e7
refactor(runtime): make bash lifecycle intrinsic
openhands-agent Sep 14, 2026
1e1a610
chore: Remove PR-only artifacts [automated]
Sep 14, 2026
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
12 changes: 12 additions & 0 deletions clients/typescript/src/__tests__/api-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2285,6 +2285,8 @@ describe('Auxiliary API clients', () => {
await client.respondToConfirmation('c1', { accept: true });
await client.deleteConversation('c1');
await client.updateConversation('c1', { title: 'New title' });
await client.getRuntime('c1');
await client.reprovisionRuntime('c1');

expect(global.fetch).toHaveBeenNthCalledWith(
1,
Expand Down Expand Up @@ -2314,6 +2316,16 @@ describe('Auxiliary API clients', () => {
'http://example.com/api/conversations/c1',
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ title: 'New title' }) })
);
expect(global.fetch).toHaveBeenNthCalledWith(
9,
'http://example.com/api/conversations/c1/runtime',
expect.objectContaining({ method: 'GET' })
);
expect(global.fetch).toHaveBeenNthCalledWith(
10,
'http://example.com/api/conversations/c1/runtime/reprovision',
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
});

it('ConversationClient wraps SDK v1.23.0 conversation endpoints', async () => {
Expand Down
170 changes: 170 additions & 0 deletions clients/typescript/src/__tests__/conversation-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { createServer, Server } from 'node:http';
import { AddressInfo } from 'node:net';
import { ConversationManager } from '../conversation/conversation-manager';
import { FileClient } from '../client/file-client';
import { HttpClient } from '../client/http-client';
import { BashClient } from '../client/bash-client';
import type { AgentBase } from '../types/base';
import { RemoteWorkspace } from '../workspace/remote-workspace';

describe('conversation-scoped requests', () => {
let server: Server;
let host: string;
const urls: string[] = [];
let scoped = false;
let discoveryStatus = 200;
beforeAll(async () => {
server = createServer((req, res) => {
urls.push(req.url!);

Check warning on line 18 in clients/typescript/src/__tests__/conversation-scope.test.ts

View workflow job for this annotation

GitHub Actions / test (24.x)

Forbidden non-null assertion

Check warning on line 18 in clients/typescript/src/__tests__/conversation-scope.test.ts

View workflow job for this annotation

GitHub Actions / test (22.12)

Forbidden non-null assertion
if (req.method === 'DELETE' && req.url === '/api/auth/workspace-session') {
res.writeHead(204).end();
return;
}
if (req.url === '/server_info') {
res.statusCode = discoveryStatus;
res.setHeader('content-type', 'application/json');
res.end(
JSON.stringify({
version: '1.47.0',
capabilities: scoped ? ['conversation_runtime_routes_v1'] : [],
})
);
return;
}
if (
req.url === '/api/conversations' ||
req.url === '/api/conversations/created' ||
req.url === '/api/conversations/created/fork'
) {
res.setHeader('content-type', 'application/json');
res.end(
JSON.stringify({
id: req.url.endsWith('/fork') ? 'forked' : 'created',
agent: { kind: 'Agent' },
workspace: { working_dir: '/workspace' },
})
);
return;
}
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ exit_code: 0, stdout: 'ok', stderr: '' }));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
host = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
urls.length = 0;
scoped = true;
discoveryStatus = 200;
});
it.each([false, true])('routes workspace commands with scoped support=%s', async (supported) => {
scoped = supported;
const workspace = new RemoteWorkspace({
host,
workingDir: '/workspace',
conversationId: 'selected',
});
const result = await workspace.executeCommand('pwd');
expect(result.stdout).toBe('ok');
expect(urls.pop()).toBe(
supported
? '/api/conversations/selected/bash/execute_bash_command'
: '/api/bash/execute_bash_command?cid=selected'
);
});
it('scopes workspace operations while keeping setup operations global', async () => {
const options = { host, conversationId: 'selected' };
const files = new FileClient(options);
await files.downloadFile('/workspace/a');
expect(urls.pop()).toBe('/api/conversations/selected/file/download?path=%2Fworkspace%2Fa');
await files.getHome();
expect(urls.pop()).toBe('/api/file/home');
await new BashClient(options).executeCommand({ command: 'pwd' });
expect(urls.pop()).toBe('/api/conversations/selected/bash/execute_bash_command');
await new RemoteWorkspace({ ...options, workingDir: '/workspace' }).gitChanges('/workspace');
expect(urls.pop()).toBe('/api/conversations/selected/git/changes?path=%2Fworkspace');
});
it('rejects requests that override the selected conversation', async () => {
const workspace = new RemoteWorkspace({
host,
workingDir: '/workspace',
conversationId: 'selected',
});
await expect(
workspace.client.get('/api/file/download', { params: { cid: 'other' } })
).rejects.toThrow('cannot be overridden');
await expect(workspace.client.get('/api/file/../../settings')).rejects.toThrow('API path');
expect(
() => new RemoteWorkspace({ host, workingDir: '/workspace', conversationId: '' })
).toThrow('conversation ID');
expect(urls).toEqual([]);
});
it('preserves unscoped clients without capability discovery', async () => {
await new HttpClient({ baseUrl: host }).get('/api/file/download', {
params: { cid: 'explicit' },
});
await new FileClient({ host }).downloadFile('/workspace/a');
await new RemoteWorkspace({ host, workingDir: '/workspace' }).executeCommand('pwd');
expect(urls).toEqual([
'/api/file/download?cid=explicit',
'/api/file/download?path=%2Fworkspace%2Fa',
'/api/bash/execute_bash_command',
]);
});
it('reuses existing discovery for concurrent requests on a client', async () => {
const files = new FileClient({ host, conversationId: 'selected' });
await Promise.all([files.downloadFile('/workspace/a'), files.downloadFile('/workspace/b')]);
expect(urls.filter((url) => url === '/server_info')).toHaveLength(1);
expect(urls).toContain('/api/conversations/selected/file/download?path=%2Fworkspace%2Fa');
expect(urls).toContain('/api/conversations/selected/file/download?path=%2Fworkspace%2Fb');
});
it('retries failed discovery without silently downgrading', async () => {
discoveryStatus = 503;
const files = new FileClient({ host, conversationId: 'retry' });
await expect(files.downloadFile('/workspace/a')).rejects.toMatchObject({ status: 503 });
expect(urls).toEqual(['/server_info']);
discoveryStatus = 200;
await files.downloadFile('/workspace/a');
expect(urls.pop()).toBe('/api/conversations/retry/file/download?path=%2Fworkspace%2Fa');
});
it('supports older servers without server info', async () => {
discoveryStatus = 404;
const files = new FileClient({ host, conversationId: 'selected' });
await files.downloadFile('/workspace/a');
expect(urls.pop()).toBe('/api/file/download?path=%2Fworkspace%2Fa&cid=selected');
});
it('keeps workspace session authentication global and identity fixed', async () => {
const workspace = new RemoteWorkspace({
host,
workingDir: '/workspace',
conversationId: 'preview',
});
expect(await workspace.startWorkspaceSession('preview')).toBe(
`${host}/api/conversations/preview/workspace/`
);
expect(urls.pop()).toBe('/api/auth/workspace-session');
await expect(workspace.startWorkspaceSession('other')).rejects.toThrow('selected runtime');
await workspace.deleteWorkspaceSession();
expect(urls.pop()).toBe('/api/auth/workspace-session');
});
it('binds created, loaded and forked workspaces to their conversations', async () => {
const manager = new ConversationManager({ host });
const created = await manager.createConversation({ kind: 'Agent' } as AgentBase, {
workingDir: '/workspace',
});
await created.workspace.executeCommand('pwd');
expect(urls.pop()).toBe('/api/conversations/created/bash/execute_bash_command');
const loaded = await manager.loadConversation('created', '/workspace');
await loaded.workspace.executeCommand('pwd');
expect(urls.pop()).toBe('/api/conversations/created/bash/execute_bash_command');
const forked = await created.fork();
await forked.workspace.executeCommand('pwd');
expect(urls.pop()).toBe('/api/conversations/forked/bash/execute_bash_command');
await created.workspace.executeCommand('pwd');
expect(urls.pop()).toBe('/api/conversations/created/bash/execute_bash_command');
});
});
18 changes: 7 additions & 11 deletions clients/typescript/src/client/bash-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { HttpClient, HttpError } from './http-client';
import { HttpError } from './http-client';
import { createRuntimeHttpClients } from './runtime-transport';
import type { RuntimeServiceClientOptions } from './runtime-transport';
import type { HttpClient } from './http-client';
import {
BashCommand,
BashEvent,
Expand All @@ -9,25 +12,18 @@ import {
ExecuteBashRequest,
} from '../models/workspace';

export interface BashClientOptions {
host: string;
apiKey?: string;
timeout?: number;
}
export type BashClientOptions = RuntimeServiceClientOptions;

export class BashClient {
public readonly host: string;
public readonly apiKey?: string;
private readonly client: HttpClient;

constructor(options: BashClientOptions) {
const { runtimeClient } = createRuntimeHttpClients(options);
this.host = options.host.replace(/\/$/, '');
this.apiKey = options.apiKey;
this.client = new HttpClient({
baseUrl: this.host,
apiKey: this.apiKey,
timeout: options.timeout || 60000,
});
this.client = runtimeClient;
}

async searchEvents(options: BashEventSearchOptions = {}): Promise<BashEventPage> {
Expand Down
16 changes: 16 additions & 0 deletions clients/typescript/src/client/conversation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
ConversationEventPage,
ConversationEventSearchOptions,
ConversationInfo,
ConversationRuntimeInfo,
ConversationSearchRequest,
ConversationSearchResponse,
ForkConversationRequest,
Expand Down Expand Up @@ -108,6 +109,21 @@ export class ConversationClient {
return response.data;
}

async getRuntime(conversationId: string): Promise<ConversationRuntimeInfo> {
const response = await this.client.get<ConversationRuntimeInfo>(
`/api/conversations/${conversationId}/runtime`
);
return response.data;
}

async reprovisionRuntime(conversationId: string): Promise<ConversationRuntimeInfo> {
const response = await this.client.post<ConversationRuntimeInfo>(
`/api/conversations/${conversationId}/runtime/reprovision`,
{}
);
return response.data;
}

async searchEvents(
conversationId: string,
options: ConversationEventSearchOptions = {}
Expand Down
17 changes: 6 additions & 11 deletions clients/typescript/src/client/desktop-client.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,20 @@
import { HttpClient } from './http-client';
import { createRuntimeHttpClients } from './runtime-transport';
import type { RuntimeServiceClientOptions } from './runtime-transport';
import type { HttpClient } from './http-client';
import { DesktopUrlResponse } from '../models/api';

export interface DesktopClientOptions {
host: string;
apiKey?: string;
timeout?: number;
}
export type DesktopClientOptions = RuntimeServiceClientOptions;

export class DesktopClient {
public readonly host: string;
public readonly apiKey?: string;
private readonly client: HttpClient;

constructor(options: DesktopClientOptions) {
const { runtimeClient } = createRuntimeHttpClients(options);
this.host = options.host.replace(/\/$/, '');
this.apiKey = options.apiKey;
this.client = new HttpClient({
baseUrl: this.host,
apiKey: this.apiKey,
timeout: options.timeout || 60000,
});
this.client = runtimeClient;
}

async getUrl(baseUrl?: string): Promise<string | null> {
Expand Down
33 changes: 15 additions & 18 deletions clients/typescript/src/client/file-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { HttpClient } from './http-client';
import { createRuntimeHttpClients } from './runtime-transport';
import type { RuntimeServiceClientOptions } from './runtime-transport';
import type { HttpClient } from './http-client';
import type {
FileHomeOptions,
FileHomeResponse,
Expand All @@ -7,34 +9,29 @@ import type {
} from '../models/api';
import type { Success } from '../types/base';

export interface FileClientOptions {
host: string;
apiKey?: string;
timeout?: number;
}
export type FileClientOptions = RuntimeServiceClientOptions;

export type FileUploadContent = string | Blob | File;

export class FileClient {
public readonly host: string;
public readonly apiKey?: string;
private readonly client: HttpClient;
private readonly runtimeClient: HttpClient;
private readonly serverClient: HttpClient;

constructor(options: FileClientOptions) {
const { serverClient, runtimeClient } = createRuntimeHttpClients(options);
this.host = options.host.replace(/\/$/, '');
this.apiKey = options.apiKey;
this.client = new HttpClient({
baseUrl: this.host,
apiKey: this.apiKey,
timeout: options.timeout || 60000,
});
this.runtimeClient = runtimeClient;
this.serverClient = serverClient;
}

async searchSubdirectories(
path: string,
options: FileSearchSubdirsOptions = {}
): Promise<FileSubdirectoryPage> {
const response = await this.client.get<FileSubdirectoryPage>('/api/file/search_subdirs', {
const response = await this.serverClient.get<FileSubdirectoryPage>('/api/file/search_subdirs', {
params: {
path,
page_id: options.pageId,
Expand All @@ -46,7 +43,7 @@ export class FileClient {
}

async getHome(options: FileHomeOptions = {}): Promise<FileHomeResponse> {
const response = await this.client.get<FileHomeResponse>('/api/file/home', {
const response = await this.serverClient.get<FileHomeResponse>('/api/file/home', {
params: {
include_hidden: options.includeHidden || undefined,
},
Expand All @@ -55,7 +52,7 @@ export class FileClient {
}

async downloadFile(path: string): Promise<ArrayBuffer> {
const response = await this.client.get<ArrayBuffer>('/api/file/download', {
const response = await this.runtimeClient.get<ArrayBuffer>('/api/file/download', {
params: { path },
responseType: 'arrayBuffer',
});
Expand Down Expand Up @@ -86,7 +83,7 @@ export class FileClient {
);
}

const response = await this.client.post<Success>('/api/file/upload', formData, {
const response = await this.runtimeClient.post<Success>('/api/file/upload', formData, {
params: { path: destinationPath },
});
return response.data;
Expand All @@ -97,14 +94,14 @@ export class FileClient {
}

async downloadTrajectory(conversationId: string): Promise<Blob> {
const response = await this.client.get<Blob>(
const response = await this.runtimeClient.get<Blob>(
`/api/file/download-trajectory/${encodeURIComponent(conversationId)}`,
{ responseType: 'blob' }
);
return response.data;
}

close(): void {
this.client.close();
this.runtimeClient.close();
}
}
Loading
Loading