From fc68cf295131d8f73556bef59fdeae61239a649e Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Tue, 1 Sep 2026 02:03:17 +0800 Subject: [PATCH] feat(plugins): add hosted web search client --- ...260901-web-search-mlw02-client-c8e4a2d1.md | 123 +++++ .../coding-plugins/adapters/web-search.ts | 296 ++++++++++ electron/coding-plugins/registry.ts | 3 + electron/services/web-search-client.ts | 521 ++++++++++++++++++ ...ding-conversation-product-tool-protocol.ts | 5 + shared/data-service.ts | 5 + .../coding-conversation-contracts.test.ts | 27 + tests/unit/web-search-client.test.ts | 272 +++++++++ tests/unit/web-search-plugin-adapter.test.ts | 306 ++++++++++ 9 files changed, 1558 insertions(+) create mode 100644 .project-docs/30-worklog/tasks/20260901-web-search-mlw02-client-c8e4a2d1.md create mode 100644 electron/coding-plugins/adapters/web-search.ts create mode 100644 electron/services/web-search-client.ts create mode 100644 tests/unit/web-search-client.test.ts create mode 100644 tests/unit/web-search-plugin-adapter.test.ts diff --git a/.project-docs/30-worklog/tasks/20260901-web-search-mlw02-client-c8e4a2d1.md b/.project-docs/30-worklog/tasks/20260901-web-search-mlw02-client-c8e4a2d1.md new file mode 100644 index 0000000..13750c0 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-web-search-mlw02-client-c8e4a2d1.md @@ -0,0 +1,123 @@ +# Task: Implement MLW-02 Web Search Main client and adapter + +## Identity + +- Task ID: 20260901-web-search-mlw02-client-c8e4a2d1 +- Mode: Feature +- Branch: codex/20260901-web-search-mlw02-client-c8e4a2d1-web-search-mlw02-client +- Worktree: D:\Datas\OthersProjects\makelore-web-search-mlw02-client-c8e4a2d1 +- Base commit: f3874e90706692094f0f22fca465923143e23c7e +- Owner: web-search-mlw02-implementer +- Status: Ready for Integration + +## Scope + +- Implement MLW-02 from exact client coordinator frontier + `f3874e90706692094f0f22fca465923143e23c7e` and frozen server DTO/context + frontier `a49c696ebc4213e3d62ece780961efbe17576f8e`. +- Add the Main-only `electron/services/web-search-client.ts` with closed input, + fixed route, Works authentication/one-refresh behavior, bounded DTO parsing, + same-operation reconciliation, and honest billing receipt projection. +- Add `electron/coding-plugins/adapters/web-search.ts` as the code-owned hosted + adapter. It validates closed input and confirmation, invokes the shared MLW-01 + admission resolver, supplies trusted project/request identity, and maps server + outcomes without exposing Provider details or calculating Token Points. +- Make only the minimal shared contract changes required by the new + `platform_metered/not_started` and Main-only `receipt_unavailable` billing + statuses: `shared/data-service.ts`, + `shared/coding-conversation-product-tool-protocol.ts`, and the Registry billing + validator in `electron/coding-plugins/registry.ts`. +- Add focused Web Search, billing/parser, adapter, and conversation contract + tests within the ticket's owned test paths. +- Do not modify Renderer, composition, Pi worker/resource, Package Store, + effective resolver, server, MLW-03 paths, or any user/coordinator worktree. + +## Intent And Constraints + +- Project Context Loaded: client `AGENTS.md`, the complete + `maintain-project-docs` skill and document-system reference, TDD and + implement-spec instructions, required project-memory entry points, peer task + records, and Web Search design/implementation spec ยง12 were read before + planning. +- Concurrent Task Gate: Passed. `task_context.py start` created this isolated + feature task from the exact coordinator frontier; status identity matches the + task ID, owner, mode, branch, absolute worktree, and base. The user root and + client coordinator worktrees remain untouched. +- Planning Gate: Passed. The older broad Web Search coordinator/integration and + completed MLW-01 records were inspected read-only. MLW-01 is the only direct + dependency and its exact source commit is integrated at this task's base; + this task owns different files. No unresolved semantic conflict remains. +- Frozen server context is the typed Web Search route/receipt contract at + `a49c696ebc4213e3d62ece780961efbe17576f8e`; client code must not invent + Provider/model/key/URL/payload authority or a generic invoke surface. +- Main retains Works credentials, route, logical IDs, reconciliation and raw + server receipt projection. The client never calculates points, exposes query + beyond the typed request boundary, or forwards Provider response/header/raw + error data to Pi/Renderer. +- Transport uncertainty and `reserved`/`dispatched` responses reuse the same + logical operation and exact request body for one bounded window of at most + 155 seconds. A 429 is non-retryable and preserves only bounded + `Retry-After`; a new user confirmation must create a new logical operation. +- TDD vertical slices: closed parser/fixed request, auth refresh, reconciliation + and receipt states, adapter mappings, then shared billing/protocol validator + changes and regression gates. One source commit with this task record, + clean worktree, and `READY_FOR_INTEGRATION` handoff are required. No push, + PR, deploy, publish, or real OpenAI call. + +## Outcome + +- Added the Main-only `WebSearchClient` with the exact typed hosted route, + closed request/response parsing, one Works-token refresh, whole-response + bounds, bounded same-operation reconciliation, and honest projection of the + server-owned receipt. Transport ambiguity and in-progress receipts reuse the + same logical operation/body for at most 155 seconds; an exhausted window + returns only `plugin_receipt_unavailable` and never invents an amount. +- Added the code-owned Web Search Plugin adapter. It validates the exact + `query`/`confirmed` input, resolves the frozen Release admission through the + MLW-01 helper, and supplies only trusted durable project/request identity to + Main. Complete `succeeded` and result-bearing `pending_review` responses are + usable; result-less `submission_unknown`, 429, admission/auth/input failures, + and receipt uncertainty retain their bounded business code and billing state. +- Extended the shared billing union, Registry validator, and conversation + parser only for Main-owned `platform_metered/receipt_unavailable` while + retaining the existing `platform_metered/not_started` state for every + pre-transaction failure. +- Final review corrected two frozen-contract mismatches: source title/URL + bounds now exactly match Server `240/4096`, and the adapter preserves + MLW-01 `plugin_release_unavailable` / `plugin_account_changed` admission + failures instead of collapsing them into a generic backend error. +- No composition, Renderer, Pi worker/resource, Package Store, effective + resolver, Server, Provider/model/key/URL authority, generic invoke, deploy, + publish, or live Provider call was added. + +## Verification + +- Concurrent/Planning Gate identity remained exact after recovery: same task, + owner, worktree, branch, and base `f3874e90706692094f0f22fca465923143e23c7e`; + the coordinator and user root worktrees were not modified. +- The original delegated turn was interrupted after preserving the complete + owned test/product diff but before returning its red/green ledger. A second + delegated recovery also stalled without changing files. The parent + coordinator then became the sole writer in this same owned worktree; no + task release, rebuild, reset, stash, clean, or concurrent edit occurred. +- First observable recovered focused run: 3 files / 32 tests passed. Contract + review regressions for exact source bounds and both admission failure codes + brought the final focused result to 3 files / 35 tests passed. +- Adjacent hosted admission, Game Resource, Registry, conversation, + Marketplace-client and timeline regression: 11 files / 114 tests passed. +- `pnpm run typecheck` passed. Owned-file ESLint passed. Full + `pnpm run lint:check` passed with zero errors and the unchanged five warnings + in Home/Makelore outside this task. +- Full unit suite passed: 214 files / 1748 tests, 2 staged-runtime skips; the + pressure suite passed 1/1. `pnpm run build:vite` passed Renderer, Main, + Preload, and utility builds with only existing chunk/dynamic-import notices. +- Final diff, project-docs, document-drift, task-context completion, sole-parent, + and clean-worktree facts are recorded by the source commit handoff. + +## Follow-ups + +- None recorded. + +## Promotion Candidates + +- None recorded. diff --git a/electron/coding-plugins/adapters/web-search.ts b/electron/coding-plugins/adapters/web-search.ts new file mode 100644 index 0000000..6bae1dc --- /dev/null +++ b/electron/coding-plugins/adapters/web-search.ts @@ -0,0 +1,296 @@ +import type { CodingPluginToolDefinition } from '../../../shared/coding-plugins'; +import type { CapabilityBillingReceiptV1 } from '../../../shared/data-service'; +import { + MarketplaceHostedAdmissionError, + MarketplaceHostedAdmissionResolver, +} from '../hosted-admission'; +import type { MarketplacePackageClientPort, PluginPackageStore } from '../package-store'; +import type { + AdapterInvocationResult, + CodingPluginAdapter, + PluginBackendProjection, + TrustedCodingCapabilityContext, +} from '../registry'; +import { + WebSearchClientError, + type WebSearchClient, + type WebSearchRead, +} from '../../services/web-search-client'; + +const PLUGIN_ID = 'makelore.web-search'; +const TOOL_NAME = 'makelore_web_search'; +const CAPABILITY_ID = 'web-search.search'; +const OPERATION = 'search'; +const MAX_QUERY_LENGTH = 2_000; +const MAX_RETRY_AFTER_SECONDS = 86_400; +const SAFE_ERROR_CODES = new Set([ + 'authentication_required', + 'confirmation_required', + 'plugin_backend_invalid', + 'plugin_backend_unavailable', + 'plugin_backend_response_too_large', + 'plugin_billing_unavailable', + 'plugin_execution_unavailable', + 'plugin_operation_conflict', + 'plugin_account_changed', + 'plugin_provider_unavailable', + 'plugin_receipt_unavailable', + 'plugin_release_admission_required', + 'plugin_release_unavailable', + 'plugin_reservation_expired', + 'plugin_reservation_unavailable', + 'plugin_runtime_stale', + 'token_point_balance_exhausted', + 'web_search_provider_rejected', + 'web_search_rate_limited', + 'web_search_request_invalid', + 'web_search_result_invalid', + 'web_search_submission_unknown', +]); + +type Input = Record; +type MeteredBilling = Extract; + +const NOT_STARTED: MeteredBilling = { mode: 'platform_metered', status: 'not_started' }; +const RECEIPT_UNAVAILABLE: MeteredBilling = { + mode: 'platform_metered', + status: 'receipt_unavailable', +}; + +export interface WebSearchPluginAdapterOptions { + readonly client: WebSearchClient; + readonly marketplace?: MarketplacePackageClientPort; + readonly packageStore?: Pick; + readonly makeloreVersion?: string; + readonly admissionResolver?: MarketplaceHostedAdmissionResolver; +} + +function isRecord(value: unknown): value is Input { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function exactInput(value: Input): boolean { + const keys = Object.keys(value); + return keys.length === 2 && keys.includes('query') && keys.includes('confirmed'); +} + +function validRetryAfter(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0 + && (value as number) <= MAX_RETRY_AFTER_SECONDS; +} + +function safeErrorMessage(code: string, status: number): string { + switch (code) { + case 'confirmation_required': return 'Explicit Token Point Web Search confirmation is required'; + case 'plugin_input_invalid': return 'Web Search input is invalid'; + case 'plugin_release_admission_required': return 'Web Search Release admission is required'; + case 'plugin_release_unavailable': return 'Web Search Release is unavailable'; + case 'plugin_account_changed': return 'Marketplace account changed while resolving Web Search admission'; + case 'plugin_runtime_stale': return 'Web Search worker resources are stale'; + case 'plugin_operation_conflict': return 'Web Search operation conflicts with an existing request'; + case 'token_point_balance_exhausted': return 'Token Point balance is insufficient'; + case 'plugin_provider_unavailable': return 'Web Search Provider is unavailable'; + case 'web_search_provider_rejected': return 'Web Search Provider rejected the request'; + case 'web_search_rate_limited': return 'Web Search is rate limited; confirm a new search later'; + case 'web_search_submission_unknown': return 'Web Search submission status is unknown; do not retry automatically'; + case 'plugin_receipt_unavailable': return 'Web Search billing status could not be synchronized; do not retry automatically'; + case 'plugin_backend_invalid': return 'Web Search service returned an invalid response'; + case 'plugin_backend_response_too_large': return 'Web Search service returned an oversized response'; + case 'plugin_billing_unavailable': return 'Web Search billing is unavailable'; + case 'authentication_required': return 'Works Square sign-in is required'; + default: return status === 401 ? 'Works Square sign-in is required' : 'Web Search service is temporarily unavailable'; + } +} + +function safeErrorCode(code: unknown, fallback = 'plugin_backend_unavailable'): string { + return typeof code === 'string' && SAFE_ERROR_CODES.has(code) ? code : fallback; +} + +function failure( + code: string, + error: string, + status: number, + retryable: boolean, + billing: MeteredBilling = NOT_STARTED, + retryAfterSeconds?: number, +): AdapterInvocationResult { + return { + success: false, + status, + code, + error, + retryable, + ...(validRetryAfter(retryAfterSeconds) ? { retry_after_seconds: retryAfterSeconds } : {}), + payload_schema: 'web-search.v1', + data: null, + billing, + }; +} + +function resultData(result: WebSearchRead): { + answer: string; + sources: readonly { title: string; url: string }[]; + searchQueries: readonly string[]; +} | null { + if (typeof result.answer !== 'string' || result.answer.length === 0 + || result.answer.length > 16_000 || !Array.isArray(result.sources) + || result.sources.length > 20 || !Array.isArray(result.searchQueries) + || result.searchQueries.length > 8) return null; + return { + answer: result.answer, + sources: result.sources, + searchQueries: result.searchQueries, + }; +} + +function project(result: WebSearchRead): AdapterInvocationResult { + if (result.status === 'succeeded' || result.status === 'pending_review') { + const data = resultData(result); + if (!data) { + return failure('plugin_backend_invalid', 'Web Search result is incomplete', 502, false); + } + return { + success: true, + status: result.status === 'pending_review' ? 202 : 200, + code: null, + error: null, + retryable: false, + payload_schema: 'web-search.v1', + data, + billing: result.billing, + }; + } + if (result.status === 'submission_unknown') { + return failure( + safeErrorCode(result.errorCode, 'web_search_submission_unknown'), + 'Web Search submission status is unknown; do not retry automatically', + 503, + false, + result.billing, + ); + } + if (result.status === 'failed') { + const rateLimited = result.errorCode === 'web_search_rate_limited'; + return failure( + safeErrorCode(result.errorCode, 'web_search_request_invalid'), + rateLimited + ? 'Web Search is rate limited; confirm a new search later' + : 'Web Search request was rejected', + rateLimited ? 429 : 422, + false, + result.billing, + rateLimited ? result.retryAfterSeconds : undefined, + ); + } + return failure( + 'plugin_receipt_unavailable', + 'Web Search billing status could not be synchronized; do not retry automatically', + 503, + false, + RECEIPT_UNAVAILABLE, + ); +} + +function clientFailure(error: unknown): AdapterInvocationResult { + if (error instanceof MarketplaceHostedAdmissionError) { + const code = safeErrorCode(error.code); + return failure(code, safeErrorMessage(code, error.status), error.status, error.retryable); + } + if (error instanceof WebSearchClientError) { + const code = safeErrorCode(error.code); + if (code === 'plugin_receipt_unavailable') { + return failure(code, safeErrorMessage(code, 503), 503, false, RECEIPT_UNAVAILABLE); + } + return failure( + code, + safeErrorMessage(code, error.status), + error.status, + error.retryable, + NOT_STARTED, + error.status === 429 ? error.retryAfterSeconds : undefined, + ); + } + return failure( + 'plugin_backend_unavailable', + 'Web Search service is temporarily unavailable', + 503, + true, + ); +} + +export class WebSearchPluginAdapter implements CodingPluginAdapter { + readonly pluginId = PLUGIN_ID; + private readonly admissionResolver: MarketplaceHostedAdmissionResolver; + + constructor(private readonly options: WebSearchPluginAdapterOptions) { + if (options.admissionResolver) { + this.admissionResolver = options.admissionResolver; + } else if (options.marketplace && options.packageStore && options.makeloreVersion) { + this.admissionResolver = new MarketplaceHostedAdmissionResolver({ + marketplace: options.marketplace, + packageStore: options.packageStore, + makeloreVersion: options.makeloreVersion, + }); + } else { + throw new TypeError('Web Search adapter requires Marketplace admission dependencies'); + } + } + + async inspect(_projectPath: string): Promise { + const installed = await this.options.packageStore?.getInstalled(PLUGIN_ID).catch(() => null); + return installed ? { status: 'ready' } : { status: 'unconfigured' }; + } + + async invoke( + context: TrustedCodingCapabilityContext, + tool: CodingPluginToolDefinition, + input: unknown, + ): Promise { + if (tool.name !== TOOL_NAME || tool.capabilityId !== CAPABILITY_ID || tool.operation !== OPERATION) { + return failure('plugin_contract_unsupported', 'Web Search operation is unavailable', 503, true); + } + if (!isRecord(input) || !exactInput(input) + || typeof input.query !== 'string' || !input.query.trim() + || input.query.trim().length > MAX_QUERY_LENGTH) { + return failure('plugin_input_invalid', 'Web Search input is invalid', 422, false); + } + if (input.confirmed !== true) { + return failure( + 'confirmation_required', + 'Explicit Token Point Web Search confirmation is required', + 400, + false, + ); + } + let admission: { releaseId: string; releaseAdmissionId: string }; + try { + admission = await this.admissionResolver.resolve({ + pluginId: PLUGIN_ID, + workerSnapshot: { + requestId: context.requestId, + ...(context.pluginReleaseId === undefined ? {} : { pluginReleaseId: context.pluginReleaseId }), + }, + }); + } catch (error) { + return clientFailure(error); + } + try { + return project(await this.options.client.search({ + releaseId: admission.releaseId, + releaseAdmissionId: admission.releaseAdmissionId, + projectId: context.durableProjectId, + logicalOperationId: context.requestId, + query: input.query.trim(), + confirmed: true, + })); + } catch (error) { + return clientFailure(error); + } + } +} + +export function createWebSearchPluginAdapter( + options: WebSearchPluginAdapterOptions, +): WebSearchPluginAdapter { + return new WebSearchPluginAdapter(options); +} diff --git a/electron/coding-plugins/registry.ts b/electron/coding-plugins/registry.ts index ab28b47..014f89a 100644 --- a/electron/coding-plugins/registry.ts +++ b/electron/coding-plugins/registry.ts @@ -208,6 +208,9 @@ function validBillingReceipt(value: unknown): value is CapabilityBillingReceiptV } if (value.mode === 'included' && value.status === 'included') return keys.size === 2; if (value.mode === 'external_account' && value.status === 'external') return keys.size === 2; + if (value.mode === 'platform_metered' && value.status === 'receipt_unavailable') { + return keys.size === 2; + } if (value.mode !== 'platform_metered' || !validDecimal(value.reserved_points)) return false; const common = new Set(['mode', 'status', 'reserved_points', 'actual_points', 'usage_amount', 'unit']); if (keys.size !== [...keys].filter((key) => common.has(key)).length) return false; diff --git a/electron/services/web-search-client.ts b/electron/services/web-search-client.ts new file mode 100644 index 0000000..36a4981 --- /dev/null +++ b/electron/services/web-search-client.ts @@ -0,0 +1,521 @@ +import { Buffer } from 'node:buffer'; +import type { CapabilityBillingReceiptV1 } from '../../shared/data-service'; +import { WORKS_SQUARE_CONFIG } from '../api/works-config'; +import { proxyAwareFetch } from '../utils/proxy-fetch'; +import { getValidWorksSquareAccessToken } from './works-square-session'; + +export const WEB_SEARCH_ROUTE = '/api/plugins/v1/hosted/web-search/searches'; +export const WEB_SEARCH_RECONCILIATION_WINDOW_MS = 155_000; + +const MAX_JSON_BYTES = 1_048_576; +const MAX_REQUEST_BYTES = 98_304; +const MAX_QUERY_LENGTH = 2_000; +const MAX_ANSWER_LENGTH = 16_000; +const MAX_SOURCE_TITLE_LENGTH = 240; +const MAX_SOURCE_URL_LENGTH = 4_096; +const MAX_SEARCH_QUERY_LENGTH = 500; +const MAX_RETRY_AFTER_SECONDS = 86_400; +const DECIMAL = /^(?:0|[1-9]\d*)\.\d{2}$/u; +const SAFE_ID = /^[\x21-\x7e]{1,128}$/u; +const KNOWN_ERROR_CODES = new Set([ + 'authentication_required', + 'confirmation_required', + 'plugin_backend_unavailable', + 'plugin_billing_unavailable', + 'plugin_execution_unavailable', + 'plugin_operation_conflict', + 'plugin_provider_unavailable', + 'plugin_release_admission_required', + 'plugin_reservation_expired', + 'plugin_reservation_unavailable', + 'plugin_runtime_stale', + 'token_point_balance_exhausted', + 'web_search_provider_rejected', + 'web_search_rate_limited', + 'web_search_request_invalid', + 'web_search_result_invalid', + 'web_search_submission_unknown', +]); + +type FetchImplementation = typeof fetch; +type AccessTokenGetter = typeof getValidWorksSquareAccessToken; +type JsonRecord = Record; +type ServerBillingStatus = + | 'reserved' + | 'dispatched' + | 'settled' + | 'released' + | 'expired' + | 'pending_review' + | 'refunded'; + +export interface WebSearchRequest { + readonly releaseId: string; + readonly releaseAdmissionId: string; + readonly projectId: string; + readonly logicalOperationId: string; + readonly query: string; + readonly confirmed: true; +} + +export type WebSearchInput = WebSearchRequest; + +export interface WebSearchSource { + readonly title: string; + readonly url: string; +} + +export interface WebSearchServerBillingReceipt { + readonly mode: 'platform_metered'; + readonly status: ServerBillingStatus; + readonly reserved_points: string; + readonly actual_points?: string; + readonly usage_amount?: number; + readonly unit: 'search_request'; +} + +export interface WebSearchRead { + readonly executionId: string; + readonly releaseId: string; + readonly logicalOperationId: string; + readonly status: 'reserved' | 'dispatched' | 'succeeded' | 'failed' | 'submission_unknown' | 'pending_review'; + readonly answer: string | null; + readonly sources: readonly WebSearchSource[]; + readonly searchQueries: readonly string[]; + readonly errorCode: string | null; + readonly retryAfterSeconds?: number; + readonly billing: WebSearchServerBillingReceipt; +} + +export type WebSearchResult = WebSearchRead; + +export class WebSearchClientError extends Error { + constructor( + readonly code: string, + readonly status: number, + readonly retryable: boolean, + message: string, + readonly retryAfterSeconds?: number, + ) { + super(message); + this.name = 'WebSearchClientError'; + } +} + +export interface WebSearchClientOptions { + readonly fetchImpl?: FetchImplementation; + readonly getAccessToken?: AccessTokenGetter; + readonly apiBaseUrl?: string; + readonly now?: () => number; + readonly sleep?: (milliseconds: number) => Promise; +} + +class WebSearchTransportError extends Error {} + +function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function exactKeys(value: JsonRecord, required: readonly string[], optional: readonly string[] = []): boolean { + const allowed = new Set([...required, ...optional]); + return required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) + && Object.keys(value).every((key) => allowed.has(key)); +} + +function boundedText(value: unknown, maximum: number): string | null { + return typeof value === 'string' && value.length > 0 && value.length <= maximum ? value : null; +} + +function boundedIdentifier(value: unknown, maximum: number): string | null { + return typeof value === 'string' && value.length > 0 && value.length <= maximum && SAFE_ID.test(value) + ? value + : null; +} + +function nonNegativeInteger(value: unknown, maximum = Number.MAX_SAFE_INTEGER): number | null { + return Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= maximum + ? value as number + : null; +} + +function errorCode(value: unknown): string | null { + return typeof value === 'string' && KNOWN_ERROR_CODES.has(value) ? value : null; +} + +function billing(value: unknown): WebSearchServerBillingReceipt { + if (!isRecord(value) || !exactKeys( + value, + ['mode', 'status', 'reserved_points', 'unit'], + ['actual_points', 'usage_amount'], + )) { + throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search billing receipt is invalid'); + } + const status = value.status; + const reservedPoints = value.reserved_points; + const actualPoints = value.actual_points === null || value.actual_points === undefined + ? undefined + : boundedText(value.actual_points, 32) ?? undefined; + const usageAmount = value.usage_amount === null || value.usage_amount === undefined + ? undefined + : nonNegativeInteger(value.usage_amount) ?? undefined; + if (value.mode !== 'platform_metered' + || typeof status !== 'string' + || !['reserved', 'dispatched', 'settled', 'released', 'expired', 'pending_review', 'refunded'].includes(status) + || typeof reservedPoints !== 'string' + || !DECIMAL.test(reservedPoints) + || (value.actual_points !== null && value.actual_points !== undefined && actualPoints === undefined) + || (value.usage_amount !== null && value.usage_amount !== undefined && usageAmount === undefined) + || (['settled', 'refunded'].includes(status) && actualPoints === undefined) + || value.unit !== 'search_request') { + throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search billing receipt is invalid'); + } + return { + mode: 'platform_metered', + status: status as ServerBillingStatus, + reserved_points: reservedPoints, + ...(actualPoints === undefined ? {} : { actual_points: actualPoints }), + ...(usageAmount === undefined ? {} : { usage_amount: usageAmount }), + unit: 'search_request', + }; +} + +function source(value: unknown): WebSearchSource { + if (!isRecord(value) || !exactKeys(value, ['title', 'url'])) { + throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search source is invalid'); + } + const title = boundedText(value.title, MAX_SOURCE_TITLE_LENGTH); + const url = boundedText(value.url, MAX_SOURCE_URL_LENGTH); + if (!title || !url) { + throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search source is invalid'); + } + return { title, url }; +} + +function read(value: unknown, request: WebSearchRequest): WebSearchRead { + if (!isRecord(value) || !exactKeys( + value, + [ + 'schema_version', 'plugin_id', 'execution_id', 'release_id', 'logical_operation_id', + 'status', 'billing', + ], + ['answer', 'sources', 'search_queries', 'error_code', 'retry_after_seconds'], + ) || value.schema_version !== 1 || value.plugin_id !== 'makelore.web-search') { + throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid'); + } + const executionId = boundedIdentifier(value.execution_id, 36); + const releaseId = boundedIdentifier(value.release_id, 36); + const logicalOperationId = boundedIdentifier(value.logical_operation_id, 128); + const statuses = ['reserved', 'dispatched', 'succeeded', 'failed', 'submission_unknown', 'pending_review']; + const status = typeof value.status === 'string' && statuses.includes(value.status) ? value.status : null; + const answer = value.answer === undefined || value.answer === null + ? null + : boundedText(value.answer, MAX_ANSWER_LENGTH); + const rawSources = value.sources === undefined ? [] : value.sources; + const rawSearchQueries = value.search_queries === undefined ? [] : value.search_queries; + const retryAfter = value.retry_after_seconds === null || value.retry_after_seconds === undefined + ? undefined + : nonNegativeInteger(value.retry_after_seconds, MAX_RETRY_AFTER_SECONDS) ?? undefined; + const parsedErrorCode = value.error_code === undefined || value.error_code === null + ? null + : errorCode(value.error_code); + if (!executionId || !releaseId || !logicalOperationId || !status + || releaseId !== request.releaseId || logicalOperationId !== request.logicalOperationId + || (value.answer !== undefined && value.answer !== null && answer === null) + || !Array.isArray(rawSources) || rawSources.length > 20 + || !Array.isArray(rawSearchQueries) || rawSearchQueries.length > 8 + || (value.error_code !== undefined && value.error_code !== null && parsedErrorCode === null) + || (value.retry_after_seconds !== null && value.retry_after_seconds !== undefined && retryAfter === undefined) + || (retryAfter !== undefined && (status !== 'failed' || parsedErrorCode !== 'web_search_rate_limited'))) { + throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid'); + } + const sources = rawSources.map(source); + const searchQueries = rawSearchQueries.map((item) => boundedText(item, MAX_SEARCH_QUERY_LENGTH)); + if (searchQueries.some((item): item is null => item === null)) { + throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid'); + } + if (status === 'succeeded' || status === 'pending_review') { + if (!answer || (status === 'pending_review' && parsedErrorCode !== null)) { + throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search result is incomplete'); + } + } else if (answer !== null || sources.length > 0 || searchQueries.length > 0) { + throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response contains an unexpected result'); + } + if ((status === 'succeeded' || status === 'reserved' || status === 'dispatched' || status === 'pending_review') + && parsedErrorCode !== null) { + throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response contains an unexpected error'); + } + return { + executionId, + releaseId, + logicalOperationId, + status: status as WebSearchRead['status'], + answer, + sources, + searchQueries: searchQueries as string[], + errorCode: parsedErrorCode, + ...(retryAfter === undefined ? {} : { retryAfterSeconds: retryAfter }), + billing: billing(value.billing), + }; +} + +function retryAfterHeader(response: Response): number | undefined { + const raw = response.headers.get('retry-after'); + if (!raw || !/^\d+$/u.test(raw)) return undefined; + return nonNegativeInteger(Number(raw), MAX_RETRY_AFTER_SECONDS) ?? undefined; +} + +async function readBoundedJson(response: Response): Promise { + const declared = response.headers.get('content-length'); + if (declared && /^\d+$/u.test(declared) && Number(declared) > MAX_JSON_BYTES) { + await response.body?.cancel().catch(() => undefined); + throw new WebSearchClientError('plugin_backend_response_too_large', 502, false, 'Web Search response exceeds its bound'); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > MAX_JSON_BYTES) { + throw new WebSearchClientError('plugin_backend_response_too_large', 502, false, 'Web Search response exceeds its bound'); + } + if (bytes.byteLength === 0) return null; + try { + return JSON.parse(Buffer.from(bytes).toString('utf8')) as unknown; + } catch { + throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid'); + } +} + +function requestBody(input: WebSearchRequest): string { + const normalized = { + release_id: input.releaseId, + release_admission_id: input.releaseAdmissionId, + project_id: input.projectId, + logical_operation_id: input.logicalOperationId, + query: input.query.trim(), + confirmed: true, + }; + if (!normalized.query || normalized.query.length > MAX_QUERY_LENGTH + || Buffer.byteLength(normalized.query, 'utf8') > MAX_REQUEST_BYTES) { + throw new WebSearchClientError('plugin_input_invalid', 422, false, 'Web Search query is invalid'); + } + const encoded = JSON.stringify(normalized); + if (Buffer.byteLength(encoded, 'utf8') > MAX_REQUEST_BYTES) { + throw new WebSearchClientError('plugin_input_invalid', 422, false, 'Web Search request is too large'); + } + return encoded; +} + +function validateInput(input: WebSearchRequest): WebSearchRequest { + const value = input as unknown as JsonRecord; + if (!isRecord(value) || !exactKeys(value, [ + 'releaseId', 'releaseAdmissionId', 'projectId', 'logicalOperationId', 'query', 'confirmed', + ])) { + throw new WebSearchClientError('plugin_input_invalid', 422, false, 'Web Search input is invalid'); + } + for (const [key, maximum] of [ + ['releaseId', 36], + ['releaseAdmissionId', 36], + ['projectId', 36], + ['logicalOperationId', 128], + ] as const) { + if (!boundedIdentifier(value[key], maximum)) { + throw new WebSearchClientError('plugin_input_invalid', 422, false, 'Web Search input is invalid'); + } + } + if (typeof value.query !== 'string' || !value.query.trim() || value.query.trim().length > MAX_QUERY_LENGTH + || value.confirmed !== true) { + throw new WebSearchClientError( + value.confirmed === false ? 'confirmation_required' : 'plugin_input_invalid', + value.confirmed === false ? 400 : 422, + false, + value.confirmed === false ? 'Explicit Web Search confirmation is required' : 'Web Search input is invalid', + ); + } + return { + releaseId: value.releaseId as string, + releaseAdmissionId: value.releaseAdmissionId as string, + projectId: value.projectId as string, + logicalOperationId: value.logicalOperationId as string, + query: value.query as string, + confirmed: true, + }; +} + +function domainMessage(code: string, status: number): string { + switch (code) { + case 'confirmation_required': return 'Explicit Web Search confirmation is required'; + case 'web_search_request_invalid': return 'Web Search request is invalid'; + case 'plugin_release_admission_required': return 'Web Search Release admission is required'; + case 'plugin_runtime_stale': return 'Web Search worker resources are stale'; + case 'plugin_operation_conflict': return 'Web Search operation conflicts with an existing request'; + case 'token_point_balance_exhausted': return 'Token Point balance is insufficient'; + case 'plugin_provider_unavailable': return 'Web Search Provider is unavailable'; + case 'web_search_provider_rejected': return 'Web Search Provider rejected the request'; + case 'web_search_rate_limited': return 'Web Search is rate limited; confirm a new search later'; + case 'plugin_billing_unavailable': return 'Web Search billing is unavailable'; + default: + return status === 401 ? 'Works Square sign-in is required' : 'Web Search service is unavailable'; + } +} + +async function domainError(response: Response): Promise { + let payload: unknown = null; + try { + payload = await readBoundedJson(response); + } catch (error) { + if (error instanceof WebSearchClientError) return error; + } + const detail = isRecord(payload) && isRecord(payload.detail) ? payload.detail : null; + const code = errorCode(detail?.error_code) ?? (response.status === 429 ? 'web_search_rate_limited' : 'plugin_backend_unavailable'); + const retryAfter = response.status === 429 ? retryAfterHeader(response) : undefined; + const retryable = response.status >= 500 && response.status !== 401; + return new WebSearchClientError( + code, + response.status, + retryable, + domainMessage(code, response.status), + retryAfter, + ); +} + +function receiptUnavailable(): WebSearchClientError { + return new WebSearchClientError( + 'plugin_receipt_unavailable', + 503, + false, + 'Web Search billing status could not be synchronized; do not retry automatically', + ); +} + +function delayMilliseconds(response: Response): number { + const seconds = retryAfterHeader(response); + return (seconds === undefined ? 1 : seconds) * 1_000; +} + +export class WebSearchClient { + private readonly fetchImpl: FetchImplementation; + private readonly getAccessToken: AccessTokenGetter; + private readonly apiBaseUrl: string; + private readonly now: () => number; + private readonly sleep: (milliseconds: number) => Promise; + + constructor(options: WebSearchClientOptions = {}) { + this.fetchImpl = options.fetchImpl ?? proxyAwareFetch; + this.getAccessToken = options.getAccessToken ?? getValidWorksSquareAccessToken; + this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/u, ''); + this.now = options.now ?? (() => Date.now()); + this.sleep = options.sleep ?? (async (milliseconds) => { + await new Promise((resolve) => setTimeout(resolve, milliseconds)); + }); + } + + async search(input: WebSearchRequest): Promise { + const valid = validateInput(input); + const encoded = requestBody(valid); + let token: string | null; + try { + token = await this.getAccessToken({ fetchImpl: this.fetchImpl }); + } catch { + token = null; + } + if (!token) throw new WebSearchClientError('authentication_required', 401, false, 'Works Square sign-in is required'); + + let refreshAttempted = false; + const request = async (): Promise => { + const send = async (accessToken: string): Promise => await this.fetchImpl( + `${this.apiBaseUrl}${WEB_SEARCH_ROUTE}`, + { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: encoded, + redirect: 'manual', + signal: AbortSignal.timeout(35_000), + }, + ); + let response: Response; + try { + response = await send(token as string); + } catch { + throw new WebSearchTransportError('Web Search request transport failed'); + } + if (response.status !== 401) return response; + await response.body?.cancel().catch(() => undefined); + if (refreshAttempted) { + throw new WebSearchClientError('authentication_required', 401, false, 'Works Square sign-in is required'); + } + refreshAttempted = true; + let refreshed: string | null; + try { + refreshed = await this.getAccessToken({ fetchImpl: this.fetchImpl, forceRefresh: true }); + } catch { + refreshed = null; + } + if (!refreshed) throw new WebSearchClientError('authentication_required', 401, false, 'Works Square sign-in is required'); + token = refreshed; + try { + response = await send(refreshed); + } catch { + throw new WebSearchTransportError('Web Search request transport failed'); + } + if (response.status === 401) { + await response.body?.cancel().catch(() => undefined); + throw new WebSearchClientError('authentication_required', 401, false, 'Works Square sign-in is required'); + } + return response; + }; + + const deadline = this.now() + WEB_SEARCH_RECONCILIATION_WINDOW_MS; + while (true) { + let response: Response; + try { + response = await request(); + } catch (error) { + if (error instanceof WebSearchClientError) throw error; + if (this.now() >= deadline) throw receiptUnavailable(); + const remaining = deadline - this.now(); + await this.sleep(Math.min(1_000, remaining)); + continue; + } + if (response.status === 429) throw await domainError(response); + if (response.ok) { + let payload: unknown; + try { + payload = await readBoundedJson(response); + } catch (error) { + if (error instanceof WebSearchClientError) throw error; + if (this.now() >= deadline) throw receiptUnavailable(); + const remaining = deadline - this.now(); + await this.sleep(Math.min(1_000, remaining)); + continue; + } + const result = read(payload, valid); + const shouldBeAccepted = result.status === 'reserved' + || result.status === 'dispatched' + || result.status === 'submission_unknown' + || result.status === 'pending_review'; + if ((shouldBeAccepted && response.status !== 202) + || (!shouldBeAccepted && response.status !== 200)) { + throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response status is invalid'); + } + if (result.status !== 'reserved' && result.status !== 'dispatched') return result; + if (this.now() >= deadline) throw receiptUnavailable(); + const remaining = deadline - this.now(); + await this.sleep(Math.min(delayMilliseconds(response), remaining)); + continue; + } + throw await domainError(response); + } + } +} + +export function isWebSearchServerBillingReceipt(value: unknown): value is WebSearchServerBillingReceipt { + try { + billing(value); + return true; + } catch { + return false; + } +} + +export type WebSearchCapabilityBilling = Extract; diff --git a/shared/coding-conversation-product-tool-protocol.ts b/shared/coding-conversation-product-tool-protocol.ts index 14c6905..0d4634d 100644 --- a/shared/coding-conversation-product-tool-protocol.ts +++ b/shared/coding-conversation-product-tool-protocol.ts @@ -192,6 +192,11 @@ function capabilityBilling(value: unknown): CapabilityBillingReceiptV1 | null { ? { mode: 'external_account', status: 'external' } : null; } + if (billing.mode === 'platform_metered' && billing.status === 'receipt_unavailable') { + return exactKeys(billing, ['mode', 'status']) + ? { mode: 'platform_metered', status: 'receipt_unavailable' } + : null; + } if (billing.mode !== 'platform_metered' || typeof billing.reserved_points !== 'string' || !CAPABILITY_DECIMAL_PATTERN.test(billing.reserved_points) || billing.reserved_points.length > 32) return null; diff --git a/shared/data-service.ts b/shared/data-service.ts index ae0b059..e521b7e 100644 --- a/shared/data-service.ts +++ b/shared/data-service.ts @@ -100,6 +100,11 @@ export type CapabilityBillingReceiptV1 = actual_points: string; usage_amount?: number; unit?: string; + } + | { + /** Main-only projection used when a possibly-dispatched receipt cannot be read. */ + mode: 'platform_metered'; + status: 'receipt_unavailable'; }; export interface CapabilityResultV1 { diff --git a/tests/unit/coding-conversation-contracts.test.ts b/tests/unit/coding-conversation-contracts.test.ts index 5596b5e..6885255 100644 --- a/tests/unit/coding-conversation-contracts.test.ts +++ b/tests/unit/coding-conversation-contracts.test.ts @@ -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); diff --git a/tests/unit/web-search-client.test.ts b/tests/unit/web-search-client.test.ts new file mode 100644 index 0000000..89a1722 --- /dev/null +++ b/tests/unit/web-search-client.test.ts @@ -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 = {}) { + 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 = {}): 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() + .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().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({ + code: 'plugin_input_invalid', status: 422, retryable: false, + }); + expect(fetchImpl).not.toHaveBeenCalled(); + await expect(client.search(input())).rejects.toMatchObject({ + 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().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().mockResolvedValue(jsonResponse(search({ + sources: [{ title: 't'.repeat(241), url: 'https://example.test/source' }], + }))), + getAccessToken: vi.fn(async () => 'token') as never, + }).search(input())).rejects.toMatchObject({ + 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().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({ + 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().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().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({ + 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().mockResolvedValue(jsonResponse(search(), 202)), + getAccessToken: vi.fn(async () => 'token') as never, + }); + + await expect(client.search(input())).rejects.toMatchObject({ + 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().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() + .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().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({ + code: 'plugin_receipt_unavailable', status: 503, retryable: false, + }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/unit/web-search-plugin-adapter.test.ts b/tests/unit/web-search-plugin-adapter.test.ts new file mode 100644 index 0000000..4172a4e --- /dev/null +++ b/tests/unit/web-search-plugin-adapter.test.ts @@ -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 { + 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', + }); + }); +});