307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
// @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',
|
|
});
|
|
});
|
|
});
|