Skip to content
Draft
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
94 changes: 94 additions & 0 deletions src/__tests__/api-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
65 changes: 57 additions & 8 deletions src/client/conversation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
if (!options) return {};
const params: Record<string, unknown> = {};
if (options.includeSkills !== undefined) {
params.include_skills = options.includeSkills;
}
return params;
}

export class ConversationClient {
public readonly host: string;
public readonly apiKey?: string;
Expand All @@ -37,37 +72,51 @@ export class ConversationClient {
}

async createConversation<TConversation = ConversationInfo>(
payload: CreateConversationPayload
payload: CreateConversationPayload,
options?: ConversationReadOptions
): Promise<TConversation> {
const response = await this.client.post<TConversation>('/api/conversations', payload);
const response = await this.client.post<TConversation>(
'/api/conversations',
payload,
{ params: buildConversationReadParams(options) }
);
return response.data;
}

async searchConversations(
options: ConversationSearchRequest = {}
options: ConversationSearchRequest & ConversationReadOptions = {}
): Promise<ConversationSearchResponse> {
const { includeSkills, ...searchOptions } = options;
const response = await this.client.get<ConversationSearchResponse>(
'/api/conversations/search',
{
params: options as Record<string, unknown>,
params: {
...(searchOptions as Record<string, unknown>),
...buildConversationReadParams({ includeSkills }),
},
}
);
return response.data;
}

async getConversations<TConversation = ConversationInfo>(
conversationIds: string[]
conversationIds: string[],
options?: ConversationReadOptions
): Promise<Array<TConversation | null>> {
const response = await this.client.get<Array<TConversation | null>>('/api/conversations', {
params: { ids: conversationIds },
params: { ids: conversationIds, ...buildConversationReadParams(options) },
});
return response.data;
}

async getConversation<TConversation = ConversationInfo>(
conversationId: string
conversationId: string,
options?: ConversationReadOptions
): Promise<TConversation> {
const response = await this.client.get<TConversation>(`/api/conversations/${conversationId}`);
const response = await this.client.get<TConversation>(
`/api/conversations/${conversationId}`,
{ params: buildConversationReadParams(options) }
);
return response.data;
}

Expand Down
Loading