// @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 { 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 = 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', }); }); });