diff --git a/src/__tests__/api-clients.test.ts b/src/__tests__/api-clients.test.ts index 2390736..4afd5ef 100644 --- a/src/__tests__/api-clients.test.ts +++ b/src/__tests__/api-clients.test.ts @@ -712,6 +712,64 @@ describe('Auxiliary API clients', () => { ); }); + it('RemoteConversation.start sends the optional observability user ID', async () => { + global.fetch = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ id: 'conv-123' }), { + 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, { + userId: 'user-42', + }); + + await conversation.start({ initialMessage: 'hello' }); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/api/conversations', + expect.objectContaining({ + method: 'POST', + }) + ); + const [, init] = (global.fetch as jest.Mock).mock.calls[0]; + expect(JSON.parse(init.body)).toMatchObject({ + user_id: 'user-42', + initial_message: { + role: 'user', + content: [{ type: 'text', text: 'hello' }], + }, + }); + }); + + it('ConversationManager.createACPConversation sends the optional observability user ID', async () => { + global.fetch = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ id: 'acp-123' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) as typeof fetch; + + const manager = new ConversationManager({ host: 'http://example.com' }); + await manager.createACPConversation( + { kind: 'ACPAgent', llm: { model: 'gpt-4o' } }, + { userId: 'user-42' } + ); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/api/acp/conversations', + expect.objectContaining({ + method: 'POST', + }) + ); + const [, init] = (global.fetch as jest.Mock).mock.calls[0]; + expect(JSON.parse(init.body)).toMatchObject({ + user_id: 'user-42', + }); + }); + it('HttpClient can parse blob responses when requested', async () => { global.fetch = jest.fn().mockResolvedValue( new Response(new Blob(['zip-data']), { diff --git a/src/conversation/conversation-manager.ts b/src/conversation/conversation-manager.ts index b1dac5b..6d40871 100644 --- a/src/conversation/conversation-manager.ts +++ b/src/conversation/conversation-manager.ts @@ -215,6 +215,7 @@ export class ConversationManager { maxIterations?: number; stuckDetection?: boolean; workingDir?: string; + userId?: string; } = {} ): Promise { const workspace = new RemoteWorkspace({ @@ -226,10 +227,12 @@ export class ConversationManager { const conversation = new RemoteConversation(agent, workspace, { maxIterations: options.maxIterations, stuckDetection: options.stuckDetection, + userId: options.userId, }); await conversation.start({ initialMessage: options.initialMessage, + userId: options.userId, }); return conversation; @@ -340,6 +343,7 @@ export class ConversationManager { maxIterations?: number; stuckDetection?: boolean; workingDir?: string; + userId?: string; } = {} ): Promise { let initialMessage: CreateACPConversationRequest['initial_message']; @@ -356,6 +360,7 @@ export class ConversationManager { max_iterations: options.maxIterations || 500, stuck_detection: options.stuckDetection ?? true, workspace: { type: 'local', working_dir: options.workingDir || '/tmp' }, + user_id: options.userId ?? null, }; const response = await this.client.post('/api/acp/conversations', request); diff --git a/src/conversation/remote-conversation.ts b/src/conversation/remote-conversation.ts index 6468bc3..8e221d6 100644 --- a/src/conversation/remote-conversation.ts +++ b/src/conversation/remote-conversation.ts @@ -42,6 +42,10 @@ import type { HookConfig } from '../hooks'; * Options for creating a RemoteConversation instance. */ export interface RemoteConversationOptions extends BaseConversationOptions { + /** + * Optional user ID to associate with server-side observability traces. + */ + userId?: string; /** * Optional hook configuration for this conversation. * Hooks are shell scripts that run server-side at key lifecycle events @@ -87,6 +91,7 @@ export class RemoteConversation implements IConversation { private callback?: ConversationCallbackType; private onError?: ErrorCallbackType; private hookConfig?: HookConfig; + private userId?: string; constructor( agent: AgentBase, @@ -99,6 +104,7 @@ export class RemoteConversation implements IConversation { this.onError = options.onError; this._conversationId = options.conversationId; this.hookConfig = options.hookConfig; + this.userId = options.userId; this.client = new HttpClient({ baseUrl: workspace.host, @@ -132,6 +138,7 @@ export class RemoteConversation implements IConversation { maxIterations?: number; stuckDetection?: boolean; hookConfig?: HookConfig; + userId?: string; } = {} ): Promise { if (this._conversationId) { @@ -151,6 +158,7 @@ export class RemoteConversation implements IConversation { // Use hook config from start options, falling back to constructor option const hookConfig = options.hookConfig ?? this.hookConfig ?? undefined; + const userId = options.userId ?? this.userId ?? undefined; const request: CreateConversationRequest = { agent: this.agent, @@ -159,6 +167,7 @@ export class RemoteConversation implements IConversation { stuck_detection: options.stuckDetection ?? true, workspace: { type: 'local', working_dir: this.workspace.workingDir }, hook_config: hookConfig ?? null, + user_id: userId ?? null, }; const response = await this.client.post('/api/conversations', request); diff --git a/src/models/conversation.ts b/src/models/conversation.ts index d201111..3fce699 100644 --- a/src/models/conversation.ts +++ b/src/models/conversation.ts @@ -89,6 +89,7 @@ export interface CreateConversationRequest { stuck_detection: boolean; workspace: Record; hook_config?: HookConfig | null; + user_id?: string | null; } export interface CreateACPConversationRequest { @@ -98,6 +99,7 @@ export interface CreateACPConversationRequest { stuck_detection: boolean; workspace: Record; hook_config?: HookConfig | null; + user_id?: string | null; } export interface GenerateTitleRequest {