diff --git a/src/__tests__/api-clients.test.ts b/src/__tests__/api-clients.test.ts index 7b040ef..52c283d 100644 --- a/src/__tests__/api-clients.test.ts +++ b/src/__tests__/api-clients.test.ts @@ -791,6 +791,100 @@ describe('Auxiliary API clients', () => { ); }); + it('ConversationClient.getConversations omits include_skills by default', async () => { + // Default call must not send the param at all, so older + // agent-servers that don't know about it behave exactly as + // before this PR. The trim is purely opt-in. + global.fetch = jest.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify([]), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + ) as typeof fetch; + + const client = new ConversationClient({ host: 'http://example.com' }); + await client.getConversations(['c1', 'c2']); + + const [url] = (global.fetch as jest.Mock).mock.calls[0]; + expect(url).toBe('http://example.com/api/conversations?ids=c1&ids=c2'); + expect(url).not.toContain('include_skills'); + }); + + it('ConversationClient.getConversations sends include_skills=false when opted in', async () => { + global.fetch = jest.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify([]), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + ) as typeof fetch; + + const client = new ConversationClient({ host: 'http://example.com' }); + await client.getConversations(['c1'], { includeSkills: false }); + + const [url] = (global.fetch as jest.Mock).mock.calls[0]; + expect(url).toBe('http://example.com/api/conversations?ids=c1&include_skills=false'); + }); + + it('ConversationClient.getConversation sends include_skills on the single-id endpoint', async () => { + global.fetch = jest.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ id: 'c1' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + ) as typeof fetch; + + const client = new ConversationClient({ host: 'http://example.com' }); + await client.getConversation('c1', { includeSkills: false }); + + const [url] = (global.fetch as jest.Mock).mock.calls[0]; + expect(url).toBe('http://example.com/api/conversations/c1?include_skills=false'); + }); + + it('ConversationClient.searchConversations merges include_skills with other search params', async () => { + global.fetch = jest.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ items: [], next_page_id: null }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + ) as typeof fetch; + + const client = new ConversationClient({ host: 'http://example.com' }); + await client.searchConversations({ limit: 5, includeSkills: false }); + + const [url] = (global.fetch as jest.Mock).mock.calls[0]; + expect(url).toContain('limit=5'); + expect(url).toContain('include_skills=false'); + // ``includeSkills`` is the camelCase TS shape; we explicitly do + // NOT leak it onto the wire as-is (server expects snake_case). + expect(url).not.toContain('includeSkills'); + }); + + it('ConversationClient.createConversation forwards include_skills as a query param', async () => { + global.fetch = jest.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ id: 'c1' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + ) as typeof fetch; + + const client = new ConversationClient({ host: 'http://example.com' }); + await client.createConversation({ agent: {} }, { includeSkills: false }); + + const [url, init] = (global.fetch as jest.Mock).mock.calls[0]; + expect(url).toBe('http://example.com/api/conversations?include_skills=false'); + expect(init.method).toBe('POST'); + }); + it('Security ApiKeys Session and Shared clients wrap app endpoints', async () => { const responses = [ { policy: 'default' }, diff --git a/src/client/conversation-client.ts b/src/client/conversation-client.ts index 98ebfcb..3a6ec06 100644 --- a/src/client/conversation-client.ts +++ b/src/client/conversation-client.ts @@ -21,6 +21,41 @@ export interface SendConversationEventOptions { run?: boolean; } +/** + * Read-side options the agent-server's conversation routes accept as + * query parameters. Keep field names camelCase here; the client maps + * them to the snake_case wire form (``include_skills``) when emitting. + */ +export interface ConversationReadOptions { + /** + * When ``false``, the server drops ``agent.agent_context.skills`` from + * the response payload. Default is ``true`` (full response), which + * preserves the existing public-API shape — ``RemoteConversation`` + * and any other consumer that round-trips the agent config continue + * to work without changes. Opt in when the caller doesn't read + * skill bodies and wants to avoid the ~260 KB of inlined skill + * content that ``load_user_skills=true`` / ``load_public_skills=true`` + * agents accumulate. + */ + includeSkills?: boolean; +} + +/** + * Translate {@link ConversationReadOptions} into the query-param dict + * the agent-server expects. Omitted / undefined fields aren't sent, + * so the server applies its own default (currently ``include_skills=true``). + */ +function buildConversationReadParams( + options: ConversationReadOptions | undefined +): Record { + if (!options) return {}; + const params: Record = {}; + if (options.includeSkills !== undefined) { + params.include_skills = options.includeSkills; + } + return params; +} + export class ConversationClient { public readonly host: string; public readonly apiKey?: string; @@ -37,37 +72,51 @@ export class ConversationClient { } async createConversation( - payload: CreateConversationPayload + payload: CreateConversationPayload, + options?: ConversationReadOptions ): Promise { - const response = await this.client.post('/api/conversations', payload); + const response = await this.client.post( + '/api/conversations', + payload, + { params: buildConversationReadParams(options) } + ); return response.data; } async searchConversations( - options: ConversationSearchRequest = {} + options: ConversationSearchRequest & ConversationReadOptions = {} ): Promise { + const { includeSkills, ...searchOptions } = options; const response = await this.client.get( '/api/conversations/search', { - params: options as Record, + params: { + ...(searchOptions as Record), + ...buildConversationReadParams({ includeSkills }), + }, } ); return response.data; } async getConversations( - conversationIds: string[] + conversationIds: string[], + options?: ConversationReadOptions ): Promise> { const response = await this.client.get>('/api/conversations', { - params: { ids: conversationIds }, + params: { ids: conversationIds, ...buildConversationReadParams(options) }, }); return response.data; } async getConversation( - conversationId: string + conversationId: string, + options?: ConversationReadOptions ): Promise { - const response = await this.client.get(`/api/conversations/${conversationId}`); + const response = await this.client.get( + `/api/conversations/${conversationId}`, + { params: buildConversationReadParams(options) } + ); return response.data; }