From 1400b1af0848e9d6f1cf27c8d7842711c5a20e77 Mon Sep 17 00:00:00 2001 From: GaganSD Date: Tue, 3 Nov 2026 09:23:46 +0530 Subject: [PATCH 1/2] refactor(pi-web-search): replace typebox with @earendil-works/pi-schema --- pi-web-search/package-lock.json | 26 +++--- pi-web-search/package.json | 10 +-- pi-web-search/scripts/package-content.ts | 6 +- pi-web-search/src/format.ts | 88 +++++++++---------- pi-web-search/src/index.ts | 5 +- pi-web-search/src/search.ts | 34 ++++--- pi-web-search/tests/compact-output.test.ts | 16 ++-- .../tests/execution-hardening.test.ts | 10 ++- pi-web-search/tests/format.test.ts | 8 +- .../tests/package/package-content.test.ts | 12 +-- pi-web-search/tests/ref-evidence.test.ts | 6 +- pi-web-search/tests/unified-search.test.ts | 2 +- pi-web-search/tests/utils.test.ts | 8 +- pi-web-search/tests/web-search.test.ts | 7 +- 14 files changed, 130 insertions(+), 108 deletions(-) diff --git a/pi-web-search/package-lock.json b/pi-web-search/package-lock.json index 4e85a1be..2689df36 100644 --- a/pi-web-search/package-lock.json +++ b/pi-web-search/package-lock.json @@ -8,12 +8,14 @@ "name": "@gagansd/pi-web-search", "version": "0.4.0", "license": "MIT", + "dependencies": { + "@earendil-works/pi-schema": "1.5.0" + }, "devDependencies": { "@earendil-works/pi-ai": "1.3.0", "@earendil-works/pi-coding-agent": "1.3.0", "@earendil-works/pi-tui": "1.3.0", "@types/node": "26.6.5", - "typebox": "1.3.37", "typescript": "7.0.2" }, "engines": { @@ -22,8 +24,7 @@ "peerDependencies": { "@earendil-works/pi-ai": "*", "@earendil-works/pi-coding-agent": "*", - "@earendil-works/pi-tui": "*", - "typebox": "*" + "@earendil-works/pi-tui": "*" }, "peerDependenciesMeta": { "@earendil-works/pi-ai": { @@ -34,9 +35,6 @@ }, "@earendil-works/pi-tui": { "optional": true - }, - "typebox": { - "optional": true } } }, @@ -556,6 +554,15 @@ "node": ">=22.19.0" } }, + "node_modules/@earendil-works/pi-schema": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-schema/-/pi-schema-1.5.0.tgz", + "integrity": "sha512-RW0MbT2tR65OY6xMsgB/yVBb7m/RTNwA+9hurSN1vVXfHZRVmVnXbruBPrx/PzbrQnW5yLVlMRWxErGDQtF1Hg==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@earendil-works/pi-telemetry": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.3.0.tgz", @@ -1905,13 +1912,6 @@ "dev": true, "license": "0BSD" }, - "node_modules/typebox": { - "version": "1.3.37", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.37.tgz", - "integrity": "sha512-k+Qogwk7lztlItGvFf5v4GZPPStFAFehAyOKYeuqIHzi3dfTFqPOTx38JG09hmpcK6TQgctrTD06U/0CTTpM1g==", - "dev": true, - "license": "MIT" - }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", diff --git a/pi-web-search/package.json b/pi-web-search/package.json index c5137914..4c4845d4 100644 --- a/pi-web-search/package.json +++ b/pi-web-search/package.json @@ -47,11 +47,13 @@ "smoke:classifier": "node --experimental-strip-types scripts/classifier-smoke.ts", "prepublishOnly": "npm run audit:prod && npm test && npm run typecheck && npm run pack:check" }, + "dependencies": { + "@earendil-works/pi-schema": "1.5.0" + }, "peerDependencies": { "@earendil-works/pi-ai": "*", "@earendil-works/pi-coding-agent": "*", - "@earendil-works/pi-tui": "*", - "typebox": "*" + "@earendil-works/pi-tui": "*" }, "peerDependenciesMeta": { "@earendil-works/pi-ai": { @@ -62,9 +64,6 @@ }, "@earendil-works/pi-tui": { "optional": true - }, - "typebox": { - "optional": true } }, "overrides": { @@ -75,7 +74,6 @@ "@earendil-works/pi-coding-agent": "1.3.0", "@earendil-works/pi-tui": "1.3.0", "@types/node": "26.6.5", - "typebox": "1.3.37", "typescript": "7.0.2" }, "pi": { diff --git a/pi-web-search/scripts/package-content.ts b/pi-web-search/scripts/package-content.ts index d8c16f62..e813a557 100644 --- a/pi-web-search/scripts/package-content.ts +++ b/pi-web-search/scripts/package-content.ts @@ -5,7 +5,7 @@ import { SyntaxKind } from "typescript/unstable/ast"; import { createScanner } from "typescript/unstable/ast/scanner"; export const packageAllowlist = ["src", "README.md", "LICENSE", "llms.txt", "docs"]; -const hostPeers = ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui", "typebox"]; +const hostPeers = ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui"]; export interface PackageManifest { name: string; @@ -29,7 +29,7 @@ export function validatePackageContent(manifest: PackageManifest, files: Map; +export type WebSearchDetails = StaticObject; -export const SearchOutputSchema = Type.Object({ - status: Type.Union([Type.Literal("success"), Type.Literal("error")]), - text: Type.Optional(Type.String()), - ...DetailsSchema.properties, -}, { additionalProperties: false }); +export const SearchOutputSchema = obj({ + status: union([lit("success"), lit("error")]), + text: opt(str()), + ...DetailsProperties, +}); export interface TruncationLimits { maxLines?: number; diff --git a/pi-web-search/src/index.ts b/pi-web-search/src/index.ts index f8e78f01..4fe1c0e7 100644 --- a/pi-web-search/src/index.ts +++ b/pi-web-search/src/index.ts @@ -71,8 +71,9 @@ export default function webSearchExtension(pi: ExtensionAPI) { namespace: { name: "search", description: "Cited public web and code retrieval." }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true }, execute: (toolCallId, params, signal, onUpdate, ctx) => - search(toolCallId, params, signal, onUpdate, ctx), - renderCall(args: SearchInput, theme) { + search(toolCallId, params as SearchInput, signal, onUpdate, ctx), + renderCall(rawArgs: unknown, theme) { + const args = rawArgs as SearchInput; const target = args.query ?? args.ref ?? (args.urls?.length ? `${args.urls.length} URL(s)` : "…"); const scope = args.scope ?? "web"; const effort = args.effort ?? (scope === "both" ? "thorough" : "fast"); diff --git a/pi-web-search/src/search.ts b/pi-web-search/src/search.ts index 36eafd16..79a82230 100644 --- a/pi-web-search/src/search.ts +++ b/pi-web-search/src/search.ts @@ -3,7 +3,7 @@ import type { AgentToolUpdateCallback, ExtensionContext, } from "@earendil-works/pi-coding-agent"; -import { StringEnum, Type, type Static } from "@earendil-works/pi-ai"; +import { arr, obj, opt, str, type Schema, type Static } from "@earendil-works/pi-schema"; import { MAX_QUERY_CHARS } from "./providers/config.ts"; import { PARALLEL_MCP_SERVER } from "./providers/parallel.ts"; import { executeSearch, trimQuery } from "./execute.ts"; @@ -18,37 +18,49 @@ export const SEARCH_EFFORTS = ["fast", "thorough"] as const; export type SearchScope = (typeof SEARCH_SCOPES)[number]; export type SearchEffort = (typeof SEARCH_EFFORTS)[number]; -export const SearchSchema = Type.Object({ - query: Type.Optional(Type.String({ +/** `{ type: "string", enum }`: Google-compatible string enum, identical JSON to pi-ai's StringEnum. */ +function stringEnum( + values: T, + options?: { description?: string; default?: T[number] }, +): Schema { + return { + type: "string", + enum: [...values], + ...(options?.description ? { description: options.description } : {}), + }; +} + +export const SearchSchema = obj({ + query: opt(str({ minLength: 1, maxLength: MAX_QUERY_CHARS, description: "Search terms; code scope expects literal identifiers/snippets", })), - scope: Type.Optional(StringEnum(SEARCH_SCOPES, { + scope: opt(stringEnum(SEARCH_SCOPES, { description: "web (default), code, or both", })), - urls: Type.Optional(Type.Array(Type.String(), { + urls: opt(arr(str(), { description: "Fetch HTTP(S) pages instead of searching", maxItems: 20, })), - ref: Type.Optional(Type.String({ + ref: opt(str({ minLength: 1, maxLength: 200, description: "Read a returned evidence ref locally; no network", })), - find: Type.Optional(Type.String({ + find: opt(str({ minLength: 1, maxLength: 200, description: "With ref: case-insensitive literal passage match", })), - cursor: Type.Optional(Type.String({ + cursor: opt(str({ minLength: 1, maxLength: 200, description: "With ref: copy the cursor from a previous read", })), - effort: Type.Optional(StringEnum(SEARCH_EFFORTS, { + effort: opt(stringEnum(SEARCH_EFFORTS, { description: "fast (default), or thorough (opt-in multi-source)", })), -}); +}, { open: true }); export type SearchInput = Static; @@ -67,7 +79,7 @@ export interface UnifiedSearchOptions extends RunSearchOptions { readRef?: SearchReadRef; } -const SEARCH_PARAMS = Object.keys(SearchSchema.properties); +const SEARCH_PARAMS = Object.keys(SearchSchema.properties as Record); /** The registered unified tool. */ const SEARCH_TOOL: SearchToolName = "search"; diff --git a/pi-web-search/tests/compact-output.test.ts b/pi-web-search/tests/compact-output.test.ts index 1dcd3e32..08339d13 100644 --- a/pi-web-search/tests/compact-output.test.ts +++ b/pi-web-search/tests/compact-output.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { Check } from "typebox/value"; +import { compile } from "@earendil-works/pi-schema"; import { CLIP_MARKER, @@ -15,6 +15,8 @@ import { formatSearchError } from "../src/utils.ts"; import { providerError } from "../src/providers/types.ts"; import { mergeStreamResults, previewEvidence, uniqueEvidence } from "../src/providers/results.ts"; +const validateOutput = compile(SearchOutputSchema); + function textOf(result: { content: { type: string; text?: string }[] }): string { const part = result.content[0]; return part?.type === "text" ? (part.text ?? "") : ""; @@ -32,7 +34,7 @@ test("hit refs are one-based, identical in rendered text and structured data", ( ], }, undefined, { resultId, storedCount: 2, storedClipped: false }); - assert.equal(Check(SearchOutputSchema, output.structuredContent), true); + assert.equal(validateOutput.check(output.structuredContent), true); const data = output.structuredContent as { text?: string; searchResults: Array<{ ref?: string; citedText?: string }> }; assert.equal("text" in data, false); assert.deepEqual(data.searchResults.map((hit) => hit.ref), [hitRef(resultId, 0), hitRef(resultId, 1)]); @@ -100,7 +102,7 @@ test("structured listing includes useful bounded evidence once; errors keep text searchResults: [{ title: "Doc", url: "https://example.com", citedText: "x".repeat(800) }], }); const listed = listing.structuredContent as { text?: string; searchResults: Array<{ citedText?: string; url?: string }> }; - assert.equal(Check(SearchOutputSchema, listing.structuredContent), true); + assert.equal(validateOutput.check(listing.structuredContent), true); assert.equal("text" in listed, false); assert.equal(listed.searchResults[0].citedText, "x".repeat(EXCERPT_MAX_CHARS) + CLIP_MARKER); assert.equal(listed.searchResults[0].url, "https://example.com"); @@ -110,7 +112,7 @@ test("structured listing includes useful bounded evidence once; errors keep text resultCount: 0, grounded: false, sources: [], searchResults: [], error: { code: "timeout", message: "web_search failed (timeout): boom" }, }); - assert.equal(Check(SearchOutputSchema, failure.structuredContent), true); + assert.equal(validateOutput.check(failure.structuredContent), true); assert.equal((failure.structuredContent as { text: string }).text, textOf(failure)); }); @@ -196,7 +198,7 @@ test("source-only records and fixed fields include JSON escaping in their budget { text: "\u0001".repeat(1000), providerKind: "exa" as const, warnings: Array(6).fill("\u0001".repeat(200)) }, ]) { const output = formatWebSearchResult(input); - assert.equal(Check(SearchOutputSchema, output.structuredContent), true); + assert.equal(validateOutput.check(output.structuredContent), true); assert.ok(textOf(output).length <= OUTPUT_BUDGET_CHARS); assert.ok(JSON.stringify(output.structuredContent).length <= OUTPUT_BUDGET_CHARS); } @@ -207,7 +209,7 @@ test("escaped failure envelopes fit without changing error identity or shortenin configPath: "\u0001".repeat(20_000), }); const output = formatSearchError("search", error); - assert.equal(Check(SearchOutputSchema, output.structuredContent), true); + assert.equal(validateOutput.check(output.structuredContent), true); assert.ok(JSON.stringify(output.structuredContent).length <= OUTPUT_BUDGET_CHARS); assert.equal(output.details.error?.code, "http_error"); assert.equal(output.details.error?.status, 503); @@ -239,7 +241,7 @@ test("standalone citations survive structured output alongside search hits", () }); assert.deepEqual((output.structuredContent as { sources?: unknown }).sources, [source]); assert.ok(textOf(output).includes(source.url)); - assert.equal(Check(SearchOutputSchema, output.structuredContent), true); + assert.equal(validateOutput.check(output.structuredContent), true); }); test("clipping warnings describe model previews and point to local evidence", () => { diff --git a/pi-web-search/tests/execution-hardening.test.ts b/pi-web-search/tests/execution-hardening.test.ts index 645bde0a..870f7246 100644 --- a/pi-web-search/tests/execution-hardening.test.ts +++ b/pi-web-search/tests/execution-hardening.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; -import { Check } from "typebox/value"; +import { compile } from "@earendil-works/pi-schema"; import { PROVIDER_TEXT_MAX_CHARS, @@ -21,6 +21,8 @@ import { providerError, type StreamResult } from "../src/providers/types.ts"; import { formatSearchError } from "../src/utils.ts"; import { classifierRegistry } from "./fixtures/native-classifier.ts"; +const validateOutput = compile(SearchOutputSchema); + const settings = applyConfig("/unused.json", { classifier: { enabled: true, provider: "typesafe", model: "jev-latest" } }); const availability = { exa: true, parallel: true }; const hit: StreamResult = { @@ -107,7 +109,7 @@ test("formatWebSearchResult bounds 10MB upstream evidence without losing hits or })), }; const output = formatWebSearchResult(input); - assert.equal(Check(SearchOutputSchema, output.structuredContent), true); + assert.equal(validateOutput.check(output.structuredContent), true); assert.equal(output.details.resultCount, 12); assert.deepEqual(output.details.searchResults?.map((entry) => entry.url), input.searchResults?.map((entry) => entry.url)); assert.ok(output.details.searchResults?.every((entry) => (entry.citedText?.length ?? 0) <= STRUCTURED_EXCERPT_MAX_CHARS)); @@ -127,7 +129,7 @@ test("formatWebSearchResult bounds prose, metadata and warnings and preserves co warnings: Array.from({ length: 200 }, () => oversized), usage: [{ name: oversized, count: 1 }], }); - assert.equal(Check(SearchOutputSchema, output.structuredContent), true); + assert.equal(validateOutput.check(output.structuredContent), true); assert.equal(output.details.searchResults?.[0].url, url); assert.equal(output.details.sources?.[0].url, url); assert.ok(JSON.stringify(output.structuredContent).length < 100_000); @@ -153,7 +155,7 @@ test("formatWebSearchResult removes only provably duplicate Results prose", () = test("formatSearchError bounds huge failures without losing error identity", () => { const output = formatSearchError("code_search", providerError("http_error", "x".repeat(10 * 1024 * 1024), { status: 503 })); - assert.equal(Check(SearchOutputSchema, output.structuredContent), true); + assert.equal(validateOutput.check(output.structuredContent), true); assert.equal(output.isError, true); assert.equal(output.details.error?.code, "http_error"); assert.equal(output.details.error?.status, 503); diff --git a/pi-web-search/tests/format.test.ts b/pi-web-search/tests/format.test.ts index c8727a9c..44b22058 100644 --- a/pi-web-search/tests/format.test.ts +++ b/pi-web-search/tests/format.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { Check } from "typebox/value"; +import { compile } from "@earendil-works/pi-schema"; import { CLIP_MARKER, @@ -14,6 +14,8 @@ import { } from "../src/format.ts"; import type { StreamResult } from "../src/providers/types.ts"; +const validateOutput = compile(SearchOutputSchema); + function textOf(result: { content: { type: string; text?: string }[] }): string { const part = result.content[0]; return part?.type === "text" ? (part.text ?? "") : ""; @@ -26,7 +28,7 @@ test("codemode gets schema-valid citation data, not a markdown-only result", () sources: [{ title: "Source", url: "https://example.com/code" }], }); assert.equal(result.isError, false); - assert.equal(Check(SearchOutputSchema, result.structuredContent), true); + assert.equal(validateOutput.check(result.structuredContent), true); const data = result.structuredContent as { status: string; text?: string; searchResults: Array<{ citedText?: string }> }; assert.equal(data.status, "success"); assert.equal("text" in data, false); @@ -38,7 +40,7 @@ test("codemode gets schema-valid citation data, not a markdown-only result", () test("truncated model output and structured text have identical markers", () => { const result = formatResult("one\\ntwo\\nthree", {}, { maxLines: 1 }); - assert.equal(Check(SearchOutputSchema, result.structuredContent), true); + assert.equal(validateOutput.check(result.structuredContent), true); assert.equal((result.structuredContent as { text: string }).text, textOf(result)); }); diff --git a/pi-web-search/tests/package/package-content.test.ts b/pi-web-search/tests/package/package-content.test.ts index c76358cf..3775ea03 100644 --- a/pi-web-search/tests/package/package-content.test.ts +++ b/pi-web-search/tests/package/package-content.test.ts @@ -14,17 +14,16 @@ function fixture(): { manifest: PackageManifest; files: Map } { files: [...packageAllowlist], keywords: ["pi-package"], pi: { extensions: ["./src/index.ts"] }, + dependencies: { "@earendil-works/pi-schema": "1.5.0" }, peerDependenciesMeta: { "@earendil-works/pi-ai": { optional: true }, "@earendil-works/pi-coding-agent": { optional: true }, "@earendil-works/pi-tui": { optional: true }, - typebox: { optional: true }, }, peerDependencies: { "@earendil-works/pi-ai": "*", "@earendil-works/pi-coding-agent": "*", "@earendil-works/pi-tui": "*", - typebox: "*", }, }; const files = new Map([ @@ -53,7 +52,7 @@ test("package-content permits future llms.txt and Markdown/text docs, not genera files.set("llms.txt", "# Search\n"); files.set("docs/configuration.md", "# Configuration\n"); })); - for (const path of ["tests/leak.test.ts", "scripts/test.ts", "node_modules/typebox/index.js", "SPEC.md", "dist/index.js", "src/index.ts.map", "src/generated.d.ts", "src/.env", ".env", "auth.json", "docs/auth.json", "secrets.pem", "docs/node_modules/leak.md", "../README.md", "/README.md", "docs/../../secret.md", "src\\index.ts"]) { + for (const path of ["tests/leak.test.ts", "scripts/test.ts", "node_modules/@earendil-works/pi-schema/index.js", "SPEC.md", "dist/index.js", "src/index.ts.map", "src/generated.d.ts", "src/.env", ".env", "auth.json", "docs/auth.json", "secrets.pem", "docs/node_modules/leak.md", "../README.md", "/README.md", "docs/../../secret.md", "src\\index.ts"]) { assert.equal(isAllowedPackagePath(path), false, path); assert.throws(() => validate(({ files }) => files.set(path, "leak")), /Unexpected package file/); } @@ -85,14 +84,15 @@ test("package-content rejects imports outside published source and nonliteral dy test("package-content requires declared host imports and accepts their subpaths", () => { assert.throws(() => validate(({ files }) => files.set("src/value.ts", 'import "undeclared-package";')), /undeclared external import/); - assert.doesNotThrow(() => validate(({ files }) => files.set("src/value.ts", 'import "typebox/value"; import "fs";'))); + assert.doesNotThrow(() => validate(({ files }) => files.set("src/value.ts", 'import "@earendil-works/pi-schema"; import "fs";'))); }); test("package-content prevents bundling or separately version-resolving host packages", () => { - assert.throws(() => validate(({ manifest }) => { manifest.dependencies = { "@earendil-works/pi-ai": "0.99.0" }; }), /no runtime dependencies/); + assert.throws(() => validate(({ manifest }) => { manifest.dependencies = {}; }), /only allowed runtime dependency/); + assert.throws(() => validate(({ manifest }) => { manifest.dependencies = { "@earendil-works/pi-schema": "1.5.0", "left-pad": "1.0.0" }; }), /only allowed runtime dependency/); assert.throws(() => validate(({ manifest }) => { manifest.peerDependencies["@earendil-works/pi-ai"] = ">=0.99.0"; }), /must be supplied by Pi/); assert.throws(() => validate(({ manifest }) => { manifest.files.push("tests"); })); - assert.throws(() => validate(({ manifest }) => { manifest.peerDependenciesMeta.typebox.optional = false; }), /must not install automatically/); + assert.throws(() => validate(({ manifest }) => { manifest.peerDependenciesMeta["@earendil-works/pi-ai"].optional = false; }), /must not install automatically/); }); test("package-content rejects install-time lifecycle scripts", () => { diff --git a/pi-web-search/tests/ref-evidence.test.ts b/pi-web-search/tests/ref-evidence.test.ts index 220091cd..84bc8f7c 100644 --- a/pi-web-search/tests/ref-evidence.test.ts +++ b/pi-web-search/tests/ref-evidence.test.ts @@ -1,14 +1,16 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { Check } from "typebox/value"; +import { compile } from "@earendil-works/pi-schema"; import { SearchOutputSchema, type WebSearchDetails } from "../src/format.ts"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { EVIDENCE_MAX_LIFETIME_MS, EVIDENCE_TTL_MS, EvidenceStore, evidenceStore } from "../src/evidence.ts"; import { REF_LIST_PAGE, REF_OUTPUT_CHARS, REF_PASSAGE_CHARS, searchReadRef } from "../src/search_read.ts"; import type { StreamResult } from "../src/providers/types.ts"; +const validateOutput = compile(SearchOutputSchema); + const detailsOf = (output: { details: WebSearchDetails; structuredContent?: unknown }) => { - assert.equal(Check(SearchOutputSchema, output.structuredContent), true, "continuation satisfies the public output schema"); + assert.equal(validateOutput.check(output.structuredContent), true, "continuation satisfies the public output schema"); return output.details; }; const textOf = (output: { content: { type: string; text?: string }[] }) => output.content.map((part) => part.text ?? "").join("\n"); diff --git a/pi-web-search/tests/unified-search.test.ts b/pi-web-search/tests/unified-search.test.ts index 46cb9912..0fcdc6d5 100644 --- a/pi-web-search/tests/unified-search.test.ts +++ b/pi-web-search/tests/unified-search.test.ts @@ -37,7 +37,7 @@ function document(providerKind: StreamResult["providerKind"], url = "https://exa } test("unified search exposes one flat schema with documented defaults", () => { - assert.deepEqual(Object.keys(SearchSchema.properties).sort(), [ + assert.deepEqual(Object.keys(SearchSchema.properties as Record).sort(), [ "cursor", "effort", "find", "query", "ref", "scope", "urls", ]); const schema = JSON.stringify(SearchSchema); diff --git a/pi-web-search/tests/utils.test.ts b/pi-web-search/tests/utils.test.ts index c7b755b4..138d5f2b 100644 --- a/pi-web-search/tests/utils.test.ts +++ b/pi-web-search/tests/utils.test.ts @@ -1,15 +1,17 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { Check } from "typebox/value"; +import { compile } from "@earendil-works/pi-schema"; import type { InvalidConfigError } from "../src/providers/config.ts"; import { SearchOutputSchema } from "../src/format.ts"; import { providerError } from "../src/providers/types.ts"; import { formatSearchError } from "../src/utils.ts"; +const validateOutput = compile(SearchOutputSchema); + test("failed searches return isError plus schema-valid structured data", () => { const result = formatSearchError("web_search", providerError("rate_limited", "Too many requests.", { status: 429 })); assert.equal(result.isError, true); - assert.equal(Check(SearchOutputSchema, result.structuredContent), true); + assert.equal(validateOutput.check(result.structuredContent), true); assert.equal(result.details.error?.code, "rate_limited"); assert.equal(result.details.error?.status, 429); assert.equal(result.details.error?.message, "web_search failed (rate_limited): Too many requests."); @@ -29,7 +31,7 @@ test("missing HTTP fields are absent from the JSON result", () => { const data = result.structuredContent as { error: { status?: number; rpcCode?: number } }; assert.equal("status" in data.error, false); assert.equal("rpcCode" in data.error, false); - assert.equal(Check(SearchOutputSchema, data), true); + assert.equal(validateOutput.check(data), true); }); test("non-provider throws become unknown without stack or cause leakage", () => { diff --git a/pi-web-search/tests/web-search.test.ts b/pi-web-search/tests/web-search.test.ts index e06b5d5e..b74f2f0e 100644 --- a/pi-web-search/tests/web-search.test.ts +++ b/pi-web-search/tests/web-search.test.ts @@ -86,9 +86,10 @@ function recordingTransport( } test("the unified schema exposes retrieval and continuation modes and bounds the query", () => { - const urls = SearchSchema.properties.urls as { maxItems?: number }; - const query = SearchSchema.properties.query as { maxLength?: number }; - assert.deepEqual(Object.keys(SearchSchema.properties), ["query", "scope", "urls", "ref", "find", "cursor", "effort"]); + const properties = SearchSchema.properties as Record; + const urls = properties.urls; + const query = properties.query; + assert.deepEqual(Object.keys(properties), ["query", "scope", "urls", "ref", "find", "cursor", "effort"]); assert.equal((SearchSchema.required as string[] | undefined)?.includes("query") ?? false, false); assert.equal(urls.maxItems, 20); assert.equal(query.maxLength, MAX_QUERY_CHARS); From e0d6ac647ac76ab80a0cba0128fb39a78b46a067 Mon Sep 17 00:00:00 2001 From: GaganSD Date: Tue, 3 Nov 2026 09:28:26 +0530 Subject: [PATCH 2/2] refactor(pi-web-search): drop unused default option from string enum helper --- pi-web-search/src/search.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pi-web-search/src/search.ts b/pi-web-search/src/search.ts index 79a82230..b3bb6136 100644 --- a/pi-web-search/src/search.ts +++ b/pi-web-search/src/search.ts @@ -21,7 +21,7 @@ export type SearchEffort = (typeof SEARCH_EFFORTS)[number]; /** `{ type: "string", enum }`: Google-compatible string enum, identical JSON to pi-ai's StringEnum. */ function stringEnum( values: T, - options?: { description?: string; default?: T[number] }, + options?: { description?: string }, ): Schema { return { type: "string",