feat(plugins): add hosted web search client
This commit is contained in:
@@ -106,6 +106,33 @@ describe('Conversation product contracts', () => {
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('round-trips the Main-only receipt-unavailable billing state without an amount', () => {
|
||||
const envelope = {
|
||||
schema: 'makelore-capability.v1',
|
||||
plugin_id: 'makelore.web-search',
|
||||
plugin_version: '1.0.0',
|
||||
capability_id: 'web-search.search',
|
||||
operation: 'search',
|
||||
request_id: 'pi:run-a:resource-a',
|
||||
success: false,
|
||||
status: 503,
|
||||
code: 'plugin_receipt_unavailable',
|
||||
error: 'Web Search billing status could not be synchronized; do not retry automatically',
|
||||
retryable: false,
|
||||
billing: { mode: 'platform_metered', status: 'receipt_unavailable' },
|
||||
payload_schema: 'web-search.v1',
|
||||
data: null,
|
||||
};
|
||||
expect(productToolDetails(envelope)).toMatchObject({
|
||||
plugin_id: 'makelore.web-search',
|
||||
billing: { mode: 'platform_metered', status: 'receipt_unavailable' },
|
||||
data: null,
|
||||
});
|
||||
expect(productToolDetails({ ...envelope, billing: {
|
||||
mode: 'platform_metered', status: 'receipt_unavailable', reserved_points: '0.00',
|
||||
} })).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts schema v1 snapshots and fail-closes unknown schemas until replacement', () => {
|
||||
const snapshot = createProductSnapshot();
|
||||
expect(isConversationSnapshot(snapshot)).toBe(true);
|
||||
|
||||
272
tests/unit/web-search-client.test.ts
Normal file
272
tests/unit/web-search-client.test.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
WebSearchClient,
|
||||
WebSearchClientError,
|
||||
} from '../../electron/services/web-search-client';
|
||||
|
||||
const RELEASE_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const ADMISSION_ID = 'admission-a';
|
||||
const PROJECT_ID = '33333333-3333-4333-8333-333333333333';
|
||||
const LOGICAL_OPERATION_ID = 'pi:run-a:resource-a';
|
||||
|
||||
function search(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
plugin_id: 'makelore.web-search',
|
||||
execution_id: '11111111-1111-4111-8111-111111111111',
|
||||
release_id: RELEASE_ID,
|
||||
logical_operation_id: LOGICAL_OPERATION_ID,
|
||||
status: 'succeeded',
|
||||
answer: 'The answer',
|
||||
sources: [{ title: 'Primary source', url: 'https://example.test/source' }],
|
||||
search_queries: ['latest release'],
|
||||
error_code: null,
|
||||
retry_after_seconds: null,
|
||||
billing: {
|
||||
mode: 'platform_metered',
|
||||
status: 'settled',
|
||||
reserved_points: '1.00',
|
||||
actual_points: '1.00',
|
||||
usage_amount: 1,
|
||||
unit: 'search_request',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
function input(query = 'latest release') {
|
||||
return {
|
||||
releaseId: RELEASE_ID,
|
||||
releaseAdmissionId: ADMISSION_ID,
|
||||
projectId: PROJECT_ID,
|
||||
logicalOperationId: LOGICAL_OPERATION_ID,
|
||||
query,
|
||||
confirmed: true as const,
|
||||
};
|
||||
}
|
||||
|
||||
describe('WebSearchClient', () => {
|
||||
it('posts the closed request, refreshes one 401, and projects the server receipt', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
||||
.mockResolvedValueOnce(jsonResponse(search(), 200));
|
||||
const getAccessToken = vi.fn(async (options?: { forceRefresh?: boolean }) => (
|
||||
options?.forceRefresh ? 'fresh-token' : 'stale-token'
|
||||
));
|
||||
const client = new WebSearchClient({
|
||||
apiBaseUrl: 'https://works.example/',
|
||||
fetchImpl,
|
||||
getAccessToken: getAccessToken as never,
|
||||
});
|
||||
|
||||
await expect(client.search(input())).resolves.toEqual({
|
||||
executionId: '11111111-1111-4111-8111-111111111111',
|
||||
releaseId: RELEASE_ID,
|
||||
logicalOperationId: LOGICAL_OPERATION_ID,
|
||||
status: 'succeeded',
|
||||
answer: 'The answer',
|
||||
sources: [{ title: 'Primary source', url: 'https://example.test/source' }],
|
||||
searchQueries: ['latest release'],
|
||||
errorCode: null,
|
||||
billing: {
|
||||
mode: 'platform_metered',
|
||||
status: 'settled',
|
||||
reserved_points: '1.00',
|
||||
actual_points: '1.00',
|
||||
usage_amount: 1,
|
||||
unit: 'search_request',
|
||||
},
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
expect(fetchImpl.mock.calls[0]?.[0]).toBe(
|
||||
'https://works.example/api/plugins/v1/hosted/web-search/searches',
|
||||
);
|
||||
expect(fetchImpl.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'POST',
|
||||
redirect: 'manual',
|
||||
headers: { Authorization: 'Bearer stale-token', 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(JSON.parse(String(fetchImpl.mock.calls[0]?.[1]?.body))).toEqual({
|
||||
release_id: RELEASE_ID,
|
||||
release_admission_id: ADMISSION_ID,
|
||||
project_id: PROJECT_ID,
|
||||
logical_operation_id: LOGICAL_OPERATION_ID,
|
||||
query: 'latest release',
|
||||
confirmed: true,
|
||||
});
|
||||
expect(fetchImpl.mock.calls[1]?.[1]).toMatchObject({
|
||||
headers: { Authorization: 'Bearer fresh-token' },
|
||||
});
|
||||
expect(getAccessToken).toHaveBeenNthCalledWith(2, { fetchImpl, forceRefresh: true });
|
||||
});
|
||||
|
||||
it('rejects oversized input and unknown response fields without a request', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(search({ extra: true })));
|
||||
const client = new WebSearchClient({
|
||||
fetchImpl,
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
});
|
||||
|
||||
await expect(client.search(input('x'.repeat(2_001)))).rejects.toMatchObject<WebSearchClientError>({
|
||||
code: 'plugin_input_invalid', status: 422, retryable: false,
|
||||
});
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
await expect(client.search(input())).rejects.toMatchObject<WebSearchClientError>({
|
||||
code: 'plugin_backend_invalid', status: 502, retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('matches the frozen server source title and URL bounds exactly', async () => {
|
||||
const maximumUrl = `https://example.test/${'x'.repeat(4_096 - 'https://example.test/'.length)}`;
|
||||
await expect(new WebSearchClient({
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(search({
|
||||
sources: [{ title: 't'.repeat(240), url: maximumUrl }],
|
||||
}))),
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
}).search(input())).resolves.toMatchObject({
|
||||
sources: [{ title: 't'.repeat(240), url: maximumUrl }],
|
||||
});
|
||||
|
||||
await expect(new WebSearchClient({
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(search({
|
||||
sources: [{ title: 't'.repeat(241), url: 'https://example.test/source' }],
|
||||
}))),
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
}).search(input())).rejects.toMatchObject<WebSearchClientError>({
|
||||
code: 'plugin_backend_invalid', status: 502, retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves only a bounded Retry-After for a known rate limit', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(
|
||||
{ detail: { error_code: 'web_search_rate_limited', message: 'try later' } },
|
||||
429,
|
||||
{ 'retry-after': '30' },
|
||||
));
|
||||
const client = new WebSearchClient({
|
||||
fetchImpl,
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
});
|
||||
|
||||
await expect(client.search(input())).rejects.toMatchObject<WebSearchClientError>({
|
||||
code: 'web_search_rate_limited', status: 429, retryable: false, retryAfterSeconds: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('projects a server-side rate-limit result and does not trust Retry-After on other statuses', async () => {
|
||||
const rateLimited = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(search({
|
||||
status: 'failed',
|
||||
answer: null,
|
||||
sources: [],
|
||||
search_queries: [],
|
||||
error_code: 'web_search_rate_limited',
|
||||
retry_after_seconds: 30,
|
||||
billing: { mode: 'platform_metered', status: 'released', reserved_points: '1.00', unit: 'search_request' },
|
||||
})));
|
||||
const client = new WebSearchClient({
|
||||
fetchImpl: rateLimited,
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
});
|
||||
await expect(client.search(input())).resolves.toMatchObject({
|
||||
status: 'failed', errorCode: 'web_search_rate_limited', retryAfterSeconds: 30,
|
||||
billing: { status: 'released' },
|
||||
});
|
||||
|
||||
const unavailable = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(
|
||||
{ detail: { error_code: 'plugin_provider_unavailable', message: 'private provider text' } },
|
||||
503,
|
||||
{ 'retry-after': '30' },
|
||||
));
|
||||
const unavailableClient = new WebSearchClient({
|
||||
fetchImpl: unavailable,
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
});
|
||||
await expect(unavailableClient.search(input())).rejects.toMatchObject<WebSearchClientError>({
|
||||
code: 'plugin_provider_unavailable', status: 503, retryable: true,
|
||||
retryAfterSeconds: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed on a result-bearing status with a mismatched HTTP status', async () => {
|
||||
const client = new WebSearchClient({
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(search(), 202)),
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
});
|
||||
|
||||
await expect(client.search(input())).rejects.toMatchObject<WebSearchClientError>({
|
||||
code: 'plugin_backend_invalid', status: 502, retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts omitted nullable/default response fields while keeping the object closed', async () => {
|
||||
const payload = search();
|
||||
delete payload.answer;
|
||||
delete payload.sources;
|
||||
delete payload.search_queries;
|
||||
delete payload.error_code;
|
||||
delete payload.retry_after_seconds;
|
||||
payload.status = 'failed';
|
||||
payload.billing = {
|
||||
mode: 'platform_metered', status: 'released', reserved_points: '1.00', unit: 'search_request',
|
||||
};
|
||||
await expect(new WebSearchClient({
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(payload)),
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
}).search(input())).resolves.toMatchObject({
|
||||
status: 'failed', answer: null, sources: [], searchQueries: [], errorCode: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('reconciles the same operation after in-progress and transport responses', async () => {
|
||||
let clock = 0;
|
||||
const fetchImpl = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(jsonResponse(search({
|
||||
status: 'dispatched',
|
||||
answer: null,
|
||||
sources: [],
|
||||
search_queries: [],
|
||||
billing: {
|
||||
mode: 'platform_metered', status: 'dispatched', reserved_points: '1.00',
|
||||
usage_amount: 1, unit: 'search_request',
|
||||
},
|
||||
}), 202, { 'retry-after': '1' }))
|
||||
.mockRejectedValueOnce(new TypeError('connection lost'))
|
||||
.mockResolvedValueOnce(jsonResponse(search()));
|
||||
const client = new WebSearchClient({
|
||||
fetchImpl,
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
now: () => clock,
|
||||
sleep: async (milliseconds) => { clock += milliseconds; },
|
||||
});
|
||||
|
||||
await expect(client.search(input())).resolves.toMatchObject({ status: 'succeeded' });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
expect(String(fetchImpl.mock.calls[0]?.[1]?.body)).toBe(String(fetchImpl.mock.calls[1]?.[1]?.body));
|
||||
expect(String(fetchImpl.mock.calls[1]?.[1]?.body)).toBe(String(fetchImpl.mock.calls[2]?.[1]?.body));
|
||||
});
|
||||
|
||||
it('returns receipt_unavailable when the bounded reconciliation window is exhausted', async () => {
|
||||
let clock = 0;
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockRejectedValue(new TypeError('offline'));
|
||||
const client = new WebSearchClient({
|
||||
fetchImpl,
|
||||
getAccessToken: vi.fn(async () => 'token') as never,
|
||||
now: () => clock,
|
||||
sleep: async () => { clock = 155_000; },
|
||||
});
|
||||
|
||||
await expect(client.search(input())).rejects.toMatchObject<WebSearchClientError>({
|
||||
code: 'plugin_receipt_unavailable', status: 503, retryable: false,
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
306
tests/unit/web-search-plugin-adapter.test.ts
Normal file
306
tests/unit/web-search-plugin-adapter.test.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { CodingPluginToolDefinition } from '../../shared/coding-plugins';
|
||||
import {
|
||||
WebSearchPluginAdapter,
|
||||
} from '../../electron/coding-plugins/adapters/web-search';
|
||||
import {
|
||||
MarketplaceHostedAdmissionError,
|
||||
} from '../../electron/coding-plugins/hosted-admission';
|
||||
import type { MarketplaceHostedAdmissionResolver } from '../../electron/coding-plugins/hosted-admission';
|
||||
import type { TrustedCodingCapabilityContext } from '../../electron/coding-plugins/registry';
|
||||
import {
|
||||
WebSearchClientError,
|
||||
type WebSearchClient,
|
||||
type WebSearchRead,
|
||||
} from '../../electron/services/web-search-client';
|
||||
|
||||
const RELEASE_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const PROJECT_ID = '33333333-3333-4333-8333-333333333333';
|
||||
const REQUEST_ID = 'pi:run-a:resource-a';
|
||||
|
||||
const TOOL: CodingPluginToolDefinition = {
|
||||
name: 'makelore_web_search',
|
||||
label: 'Search the web',
|
||||
description: 'Search the web',
|
||||
capabilityId: 'web-search.search',
|
||||
operation: 'search',
|
||||
roles: ['parent'],
|
||||
mutation: 'read',
|
||||
projectWriteLease: false,
|
||||
permissions: ['hosted.web-search.search'],
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['query', 'confirmed'],
|
||||
properties: {
|
||||
query: { type: 'string', minLength: 1, maxLength: 2_000 },
|
||||
confirmed: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
executionMode: 'synchronous',
|
||||
};
|
||||
|
||||
const BILLING = {
|
||||
mode: 'platform_metered' as const,
|
||||
status: 'settled' as const,
|
||||
reserved_points: '1.00',
|
||||
actual_points: '1.00',
|
||||
usage_amount: 1,
|
||||
unit: 'search_request' as const,
|
||||
};
|
||||
|
||||
function response(overrides: Partial<WebSearchRead> = {}): WebSearchRead {
|
||||
return {
|
||||
executionId: '11111111-1111-4111-8111-111111111111',
|
||||
releaseId: RELEASE_ID,
|
||||
logicalOperationId: REQUEST_ID,
|
||||
status: 'succeeded',
|
||||
answer: 'The answer',
|
||||
sources: [{ title: 'Primary source', url: 'https://example.test/source' }],
|
||||
searchQueries: ['latest release'],
|
||||
errorCode: null,
|
||||
billing: BILLING,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function toolContext(): TrustedCodingCapabilityContext {
|
||||
return {
|
||||
conversationId: 'conversation-a',
|
||||
runId: 'run-a',
|
||||
resourceId: 'resource-a',
|
||||
requestId: REQUEST_ID,
|
||||
localProjectId: 'local-project',
|
||||
projectPath: 'C:/project',
|
||||
durableProjectId: PROJECT_ID,
|
||||
workerRole: 'parent',
|
||||
effectiveSkillIds: ['makelore-web-search'],
|
||||
pluginReleaseId: RELEASE_ID,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(search: ReturnType<typeof vi.fn> = vi.fn(async () => response())) {
|
||||
const resolve = vi.fn(async () => ({
|
||||
releaseId: RELEASE_ID,
|
||||
releaseAdmissionId: 'admission-a',
|
||||
}));
|
||||
const admissionResolver = { resolve } as unknown as MarketplaceHostedAdmissionResolver;
|
||||
const client = { search } as unknown as WebSearchClient;
|
||||
const adapter = new WebSearchPluginAdapter({ client, admissionResolver });
|
||||
return { adapter, client, resolve };
|
||||
}
|
||||
|
||||
describe('WebSearchPluginAdapter', () => {
|
||||
it('uses trusted project/request/admission context and projects a successful result', async () => {
|
||||
const search = vi.fn(async () => response());
|
||||
const { adapter, client, resolve } = fixture(search);
|
||||
|
||||
const result = await adapter.invoke(toolContext(), TOOL, {
|
||||
query: 'latest release',
|
||||
confirmed: true,
|
||||
});
|
||||
|
||||
expect(resolve).toHaveBeenCalledTimes(1);
|
||||
expect(search).toHaveBeenCalledWith({
|
||||
releaseId: RELEASE_ID,
|
||||
releaseAdmissionId: 'admission-a',
|
||||
projectId: PROJECT_ID,
|
||||
logicalOperationId: REQUEST_ID,
|
||||
query: 'latest release',
|
||||
confirmed: true,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
status: 200,
|
||||
payload_schema: 'web-search.v1',
|
||||
billing: BILLING,
|
||||
data: {
|
||||
answer: 'The answer',
|
||||
sources: [{ title: 'Primary source', url: 'https://example.test/source' }],
|
||||
searchQueries: ['latest release'],
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain('provider');
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps a complete pending-review result usable without claiming settlement', async () => {
|
||||
const { adapter } = fixture(vi.fn(async () => response({
|
||||
status: 'pending_review',
|
||||
billing: { mode: 'platform_metered', status: 'pending_review', reserved_points: '1.00', usage_amount: 1, unit: 'search_request' },
|
||||
})));
|
||||
|
||||
await expect(adapter.invoke(toolContext(), TOOL, {
|
||||
query: 'latest release', confirmed: true,
|
||||
})).resolves.toMatchObject({
|
||||
success: true,
|
||||
status: 202,
|
||||
billing: { mode: 'platform_metered', status: 'pending_review' },
|
||||
data: { answer: 'The answer' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not resolve admission or call the client before confirmation', async () => {
|
||||
const search = vi.fn(async () => response());
|
||||
const { adapter, client, resolve } = fixture(search);
|
||||
|
||||
await expect(adapter.invoke(toolContext(), TOOL, {
|
||||
query: 'latest release', confirmed: false,
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
status: 400,
|
||||
code: 'confirmation_required',
|
||||
billing: { mode: 'platform_metered', status: 'not_started' },
|
||||
data: null,
|
||||
});
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
|
||||
it('preserves admission failures as not-started business failures', async () => {
|
||||
const admissionResolver = {
|
||||
resolve: vi.fn(async () => {
|
||||
throw new MarketplaceHostedAdmissionError('plugin_runtime_stale', 409, false, 'stale');
|
||||
}),
|
||||
} as unknown as MarketplaceHostedAdmissionResolver;
|
||||
const search = vi.fn(async () => response());
|
||||
const adapter = new WebSearchPluginAdapter({
|
||||
client: { search } as unknown as WebSearchClient,
|
||||
admissionResolver,
|
||||
});
|
||||
|
||||
await expect(adapter.invoke(toolContext(), TOOL, {
|
||||
query: 'latest release', confirmed: true,
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
status: 409,
|
||||
code: 'plugin_runtime_stale',
|
||||
billing: { mode: 'platform_metered', status: 'not_started' },
|
||||
});
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['plugin_release_unavailable', 409],
|
||||
['plugin_account_changed', 409],
|
||||
] as const)('preserves the typed %s admission failure', async (code, status) => {
|
||||
const admissionResolver = {
|
||||
resolve: vi.fn(async () => {
|
||||
throw new MarketplaceHostedAdmissionError(code, status, false, 'private admission detail');
|
||||
}),
|
||||
} as unknown as MarketplaceHostedAdmissionResolver;
|
||||
const search = vi.fn(async () => response());
|
||||
const adapter = new WebSearchPluginAdapter({
|
||||
client: { search } as unknown as WebSearchClient,
|
||||
admissionResolver,
|
||||
});
|
||||
|
||||
await expect(adapter.invoke(toolContext(), TOOL, {
|
||||
query: 'latest release', confirmed: true,
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
status,
|
||||
code,
|
||||
billing: { mode: 'platform_metered', status: 'not_started' },
|
||||
});
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps result-less submission unknown to a non-retryable null payload', async () => {
|
||||
const { adapter } = fixture(vi.fn(async () => response({
|
||||
status: 'submission_unknown',
|
||||
answer: null,
|
||||
sources: [],
|
||||
searchQueries: [],
|
||||
errorCode: 'web_search_submission_unknown',
|
||||
billing: { mode: 'platform_metered', status: 'pending_review', reserved_points: '1.00', usage_amount: 1, unit: 'search_request' },
|
||||
})));
|
||||
|
||||
await expect(adapter.invoke(toolContext(), TOOL, {
|
||||
query: 'latest release', confirmed: true,
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
status: 503,
|
||||
code: 'web_search_submission_unknown',
|
||||
retryable: false,
|
||||
billing: { mode: 'platform_metered', status: 'pending_review' },
|
||||
data: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps rate limiting to 429 while preserving only the bounded Retry-After', async () => {
|
||||
const { adapter } = fixture(vi.fn(async () => response({
|
||||
status: 'failed',
|
||||
answer: null,
|
||||
sources: [],
|
||||
searchQueries: [],
|
||||
errorCode: 'web_search_rate_limited',
|
||||
retryAfterSeconds: 30,
|
||||
billing: { mode: 'platform_metered', status: 'released', reserved_points: '1.00', unit: 'search_request' },
|
||||
})));
|
||||
|
||||
await expect(adapter.invoke(toolContext(), TOOL, {
|
||||
query: 'latest release', confirmed: true,
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
status: 429,
|
||||
code: 'web_search_rate_limited',
|
||||
retryable: false,
|
||||
retry_after_seconds: 30,
|
||||
billing: { mode: 'platform_metered', status: 'released' },
|
||||
});
|
||||
});
|
||||
|
||||
it('projects receipt-unavailable without inventing an amount', async () => {
|
||||
const search = vi.fn(async () => {
|
||||
throw new WebSearchClientError(
|
||||
'plugin_receipt_unavailable',
|
||||
503,
|
||||
false,
|
||||
'Web Search billing status could not be synchronized; do not retry automatically',
|
||||
);
|
||||
});
|
||||
const { adapter } = fixture(search);
|
||||
|
||||
await expect(adapter.invoke(toolContext(), TOOL, {
|
||||
query: 'latest release', confirmed: true,
|
||||
})).resolves.toMatchObject({
|
||||
success: false,
|
||||
status: 503,
|
||||
code: 'plugin_receipt_unavailable',
|
||||
billing: { mode: 'platform_metered', status: 'receipt_unavailable' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not project provider-shaped error text or client-supplied authority fields', async () => {
|
||||
const search = vi.fn(async () => {
|
||||
throw new WebSearchClientError(
|
||||
'plugin_provider_unavailable',
|
||||
503,
|
||||
true,
|
||||
'https://provider.example model=secret-model key=secret-key',
|
||||
);
|
||||
});
|
||||
const { adapter } = fixture(search);
|
||||
const result = await adapter.invoke(toolContext(), TOOL, {
|
||||
query: 'latest release', confirmed: true,
|
||||
projectId: 'attacker-project', logicalOperationId: 'attacker-operation',
|
||||
});
|
||||
expect(result).toMatchObject({ success: false, code: 'plugin_input_invalid', status: 422 });
|
||||
expect(JSON.stringify(result)).not.toContain('provider.example');
|
||||
expect(JSON.stringify(result)).not.toContain('secret-key');
|
||||
|
||||
const normal = await adapter.invoke(toolContext(), TOOL, {
|
||||
query: 'latest release', confirmed: true,
|
||||
});
|
||||
expect(normal).toMatchObject({
|
||||
success: false,
|
||||
code: 'plugin_provider_unavailable',
|
||||
status: 503,
|
||||
error: 'Web Search Provider is unavailable',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user