diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3967aed71..8a877c233 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,6 @@ jobs: - name: Install dependencies run: npm ci - - name: Build server stack run: npm run build:server @@ -113,7 +112,6 @@ jobs: - name: Install dependencies run: npm ci - - name: Install agent CLIs for provider tests run: npm install -g @anthropic-ai/claude-code opencode-ai @@ -170,7 +168,6 @@ jobs: } Start-Sleep -Seconds (20 * $attempt) } - - name: Build server stack run: npm run build:server @@ -202,7 +199,6 @@ jobs: fi sleep $((attempt * 20)) done - - name: Install Playwright browsers timeout-minutes: 10 run: npx playwright install chromium @@ -227,7 +223,6 @@ jobs: - name: Install dependencies run: npm ci - - name: Build client dependencies run: npm run build:client @@ -270,7 +265,6 @@ jobs: fi sleep $((attempt * 20)) done - - name: Install Playwright browsers timeout-minutes: 10 run: npx playwright install chromium diff --git a/.github/workflows/deploy-app.yml b/.github/workflows/deploy-app.yml index 6997de9ee..11086c50f 100644 --- a/.github/workflows/deploy-app.yml +++ b/.github/workflows/deploy-app.yml @@ -27,7 +27,6 @@ jobs: run: npm ci env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Build app dependencies run: npm run build:app-deps diff --git a/.gitignore b/.gitignore index d966a05d4..5298013fd 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,7 @@ valknut-report.json/ .plans/ packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt +packages/protocol/src/generated/validation/*.aot.ts /artifacts packages/desktop/.cache/ diff --git a/CLAUDE.md b/CLAUDE.md index ae9cab8fb..eb53d32eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config | | [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP | | [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs | +| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules | | [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage | | [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization | | [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows | @@ -92,10 +93,13 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir - `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx` - **The protocol stays backward-compatible. Features don't have to.** Two separate contracts: - **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client. - - New fields: `.optional()` with a sensible default or `.transform()` fallback. + - New fields: `.optional()` with a sensible default. - Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null). - Removed fields stay accepted (we stop sending them, not stop reading them). - Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?" + - Wire schemas are pure structural declarations. Do not add `.transform()`, `.catch()`, or `.preprocess()` to WebSocket message schemas; put normalization in an explicit post-validation pass. + - Plain `z.union()` is forbidden when every branch has a shared literal tag. Use `z.discriminatedUnion()` unless generated-code regression tests prove that specific shape is miscompiled. + - `.default()` is acceptable on primitive leaves only. Never put defaults on item schemas for large arrays or big inbound containers. - **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it. - **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature. - **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape. diff --git a/docs/protocol-validation.md b/docs/protocol-validation.md new file mode 100644 index 000000000..5fc31a5fd --- /dev/null +++ b/docs/protocol-validation.md @@ -0,0 +1,42 @@ +# Protocol Validation + +The client validates inbound WebSocket messages with a zod-aot generated validator instead of runtime Zod on the hot path. Zod remains the authoring source of truth for schemas and TypeScript types. + +The reason is mobile performance. A captured 353 KB provider snapshot cost about 10.9 ms and 5.9 MB allocated per message for `JSON.parse` plus Zod on Hermes. After moving provider-model normalization out of the schema so zod-aot could compile the hot subtree, the generated validator path measured about 2.5 ms and 1.2 MB allocated. + +## Runtime Path + +`packages/protocol/src/validation/ws-outbound.ts` is the shipped boundary. It calls the generated `WSOutboundMessageSchema.safeParse` and returns the validated data. It does not normalize, repair, or re-validate the generated result. + +Generated validators preserve unknown keys where Zod object parsing strips them. The client dispatch path uses known `type` and payload fields, so this passthrough behavior is accepted for inbound messages. The wire format is unchanged. + +Provider model normalization is a parser-side compatibility shim in the client consumers that need it. Newer daemons normalize at the provider registry source. + +## Codegen Ownership + +The protocol package owns generation. + +- `packages/protocol/codegen/ws-outbound.compile.ts` is the build-time zod-aot discovery entry. +- `packages/protocol/scripts/generate-validation-aot.mjs` runs the exact-pinned compiler and applies the small local compiler patches before generation. +- `packages/protocol/scripts/watch-validation-aot.mjs` reruns generation while editing protocol sources. +- `packages/protocol/src/generated/validation/ws-outbound.aot.ts` is generated runtime code and is gitignored. +- `packages/protocol/src/validation/ws-outbound-schema-metadata.ts` is runtime schema metadata for zod-aot fallback/default references. + +Generation runs from protocol-owned lifecycle hooks: `prebuild`, `pretypecheck`, `pretest`, and `watch`. Installs do not run generation: published packages consume protocol from prebuilt `dist`, and local build/typecheck/test flows generate the source file at the point it is actually needed. + +## Regression Tests + +zod-aot is exact-pinned and young enough that compiler patches are treated as part of this package. `packages/protocol/tests/validation/ws-outbound.test.ts` keeps small regression tests for the patched cases: + +- discriminated-union branch output must propagate `.default()` fields +- current sequential item routing must accept `tool_call`-like status branches +- generated runtime imports must keep `.js` extensions for packaged Node ESM +- the generated WebSocket envelope accepts a minimal valid message and rejects a corrupted one + +## Schema Purity + +Message schemas are structural declarations. Do not put `.transform()`, `.catch()`, or `.preprocess()` on WebSocket message schemas. If parsed data needs normalization, put it in an explicit consumer or post-validation pass. + +Use `z.discriminatedUnion()` when every branch has a shared literal tag. Plain `z.union()` is acceptable only when there is no shared literal discriminator or when a generated-code regression test proves that specific shape is miscompiled. + +Defaults are allowed only on primitive leaves. Do not place `.default()` on large arrays, item schemas, or big containers in inbound message schemas. diff --git a/package-lock.json b/package-lock.json index 2f994ade4..3981db084 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21797,9 +21797,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -28563,9 +28563,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", "engines": { "node": ">=12" @@ -35069,6 +35069,26 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-aot": { + "version": "0.20.4", + "resolved": "https://registry.npmjs.org/zod-aot/-/zod-aot-0.20.4.tgz", + "integrity": "sha512-vVuKSG4MpJw3bRQu7UEd6ziJ8+AJY3JB2rWqshnSIRJmhxWFb3FiZ9QYnYcldKYZRzO7edgid8BxJkGbZg43OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "get-tsconfig": "^4.14.0", + "jiti": "^2.7.0", + "picomatch": "^4.0.4", + "unplugin": "^3.0.0" + }, + "bin": { + "zod-aot": "dist/cli/index.js" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, "node_modules/zod-to-json-schema": { "version": "3.25.1", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", @@ -37793,7 +37813,8 @@ "devDependencies": { "@types/node": "^20.9.0", "typescript": "^5.2.2", - "vitest": "^4.1.6" + "vitest": "^4.1.6", + "zod-aot": "0.20.4" } }, "packages/relay": { diff --git a/package.json b/package.json index ac775b0f4..a56474738 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs", "build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio", "build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio", - "watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput", + "watch:protocol": "npm run watch --workspace=@getpaseo/protocol", "watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput", "typecheck": "npm run typecheck --workspaces --if-present", "typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli", diff --git a/packages/client/package.json b/packages/client/package.json index 03b464712..3e461d7b1 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -27,7 +27,7 @@ }, "scripts": { "clean": "node ../../scripts/clean-package-dist.mjs", - "build": "tsc -p tsconfig.json --incremental false", + "build": "npm run build --workspace=@getpaseo/protocol && tsc -p tsconfig.json --incremental false", "build:clean": "npm run clean && npm run build", "prepack": "npm run build:clean", "typecheck": "tsgo --noEmit", diff --git a/packages/client/src/compat/normalize-provider-models.ts b/packages/client/src/compat/normalize-provider-models.ts new file mode 100644 index 000000000..0e78ce4c2 --- /dev/null +++ b/packages/client/src/compat/normalize-provider-models.ts @@ -0,0 +1,74 @@ +import type { AgentModelDefinition, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import { normalizeAgentModelDefinition } from "@getpaseo/protocol/agent-types"; +import type { + GetProvidersSnapshotResponseMessage, + ListProviderModelsResponseMessage, + SessionOutboundMessage, +} from "@getpaseo/protocol/messages"; + +type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"]; +type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"]; +type ProvidersSnapshotUpdatePayload = Extract< + SessionOutboundMessage, + { type: "providers_snapshot_update" } +>["payload"]; + +// COMPAT(model-normalize): daemon normalizes at source (provider-registry) — shim covers older daemons; drop when floor >= v0.1.104 +function normalizeAgentModels( + models: AgentModelDefinition[] | undefined, +): AgentModelDefinition[] | undefined { + if (!models) { + return models; + } + + let changed = false; + const normalized = models.map((model) => { + const next = normalizeAgentModelDefinition(model); + changed ||= next !== model; + return next; + }); + + return changed ? normalized : models; +} + +function normalizeProviderSnapshotEntry(entry: ProviderSnapshotEntry): ProviderSnapshotEntry { + const models = normalizeAgentModels(entry.models); + return models === entry.models ? entry : { ...entry, models }; +} + +function normalizeProviderSnapshotEntries( + entries: ProviderSnapshotEntry[], +): ProviderSnapshotEntry[] { + let changed = false; + const normalized = entries.map((entry) => { + const next = normalizeProviderSnapshotEntry(entry); + changed ||= next !== entry; + return next; + }); + + return changed ? normalized : entries; +} + +export function normalizeListProviderModelsPayload( + payload: ListProviderModelsPayload, +): ListProviderModelsPayload { + const models = normalizeAgentModels(payload.models); + return models === payload.models ? payload : { ...payload, models }; +} + +export function normalizeProvidersSnapshotPayload< + T extends GetProvidersSnapshotPayload | ProvidersSnapshotUpdatePayload, +>(payload: T): T { + const entries = normalizeProviderSnapshotEntries(payload.entries); + return entries === payload.entries ? payload : { ...payload, entries }; +} + +export function normalizeProviderSnapshotUpdateMessage( + msg: SessionOutboundMessage, +): SessionOutboundMessage { + if (msg.type !== "providers_snapshot_update") { + return msg; + } + + return { ...msg, payload: normalizeProvidersSnapshotPayload(msg.payload) }; +} diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index b85e2acac..8a4593f5d 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -13,8 +13,8 @@ import { DaemonUpdateResponseSchema, SessionInboundMessageSchema, type ServerInfoStatusPayload, - WSOutboundMessageSchema, } from "@getpaseo/protocol/messages"; +import { validateWSOutboundMessage } from "@getpaseo/protocol/validation/ws-outbound"; import type { AgentStreamEventPayload, AgentSnapshotPayload, @@ -118,6 +118,11 @@ import { type WebSocketFactory, } from "./daemon-client-transport.js"; import { DaemonClientRuntimeMetrics } from "./daemon-client-runtime-metrics.js"; +import { + normalizeListProviderModelsPayload, + normalizeProviderSnapshotUpdateMessage, + normalizeProvidersSnapshotPayload, +} from "./compat/normalize-provider-models.js"; import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js"; import type { BrowserAutomationExecuteRequest, @@ -370,6 +375,7 @@ type WriteProjectConfigPayload = Extract< SessionOutboundMessage, { type: "write_project_config_response" } >["payload"]; + type ListCommandsPayload = ListCommandsResponse["payload"]; type ListCommandsDraftConfig = Pick< AgentSessionConfig, @@ -3728,7 +3734,7 @@ export class DaemonClient { provider: AgentProvider, options?: { cwd?: string; requestId?: string }, ): Promise { - return this.sendCorrelatedSessionRequest({ + const payload = await this.sendCorrelatedSessionRequest({ requestId: options?.requestId, message: { type: "list_provider_models_request", @@ -3739,6 +3745,7 @@ export class DaemonClient { // Provider SDK cold starts (especially model discovery) can exceed 60s. timeout: 90000, }); + return normalizeListProviderModelsPayload(payload); } async listProviderModes( @@ -3788,7 +3795,7 @@ export class DaemonClient { cwd?: string; requestId?: string; }): Promise { - return this.sendCorrelatedSessionRequest({ + const payload = await this.sendCorrelatedSessionRequest({ requestId: options?.requestId, message: { type: "get_providers_snapshot_request", @@ -3796,6 +3803,7 @@ export class DaemonClient { }, responseType: "get_providers_snapshot_response", }); + return normalizeProvidersSnapshotPayload(payload); } async getDaemonConfig( @@ -4754,7 +4762,7 @@ export class DaemonClient { return; } - const parsed = WSOutboundMessageSchema.safeParse(parsedJson); + const parsed = validateWSOutboundMessage(parsedJson); if (!parsed.success) { const msgType = parsedJson != null && @@ -5017,8 +5025,10 @@ export class DaemonClient { } private handleSessionMessage(msg: SessionOutboundMessage): void { - if (msg.type === "status") { - const serverInfo = parseServerInfoStatusPayload(msg.payload); + const consumerMessage = normalizeProviderSnapshotUpdateMessage(msg); + + if (consumerMessage.type === "status") { + const serverInfo = parseServerInfoStatusPayload(consumerMessage.payload); if (serverInfo) { this.lastServerInfoMessage = serverInfo; if (this.connectionState.status === "connecting") { @@ -5034,39 +5044,39 @@ export class DaemonClient { } } - if (msg.type === "terminal_stream_exit") { - this.terminalStreams.removeTerminal(msg.payload.terminalId); + if (consumerMessage.type === "terminal_stream_exit") { + this.terminalStreams.removeTerminal(consumerMessage.payload.terminalId); } if (this.rawMessageListeners.size > 0) { for (const handler of this.rawMessageListeners) { try { - handler(msg); + handler(consumerMessage); } catch { // no-op } } } - const handlers = this.messageHandlers.get(msg.type); + const handlers = this.messageHandlers.get(consumerMessage.type); if (handlers) { for (const handler of handlers) { try { - handler(msg); + handler(consumerMessage); } catch { // no-op } } } - const event = this.toEvent(msg); + const event = this.toEvent(consumerMessage); if (event) { for (const handler of this.eventListeners) { handler(event); } } - this.resolveWaiters(msg); + this.resolveWaiters(consumerMessage); } private resolveWaiters(msg: SessionOutboundMessage): void { diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 3483956ca..5def7dc47 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -629,20 +629,37 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd }); const snapshotUpdates: string[] = []; + const snapshotModelDefaults: Array = []; const unsubscribe = client.providers.subscribe((update) => { snapshotUpdates.push(update.generatedAt); + snapshotModelDefaults.push(update.entries[0]?.models?.[0]?.defaultThinkingOptionId); }); ws.message( sessionMessage({ type: "providers_snapshot_update", payload: { cwd: "/repo/sdk", - entries: [{ provider: "codex", status: "ready", enabled: true }], + entries: [ + { + provider: "codex", + status: "ready", + enabled: true, + models: [ + { + provider: "codex", + id: "gpt-5.5", + label: "GPT-5.5", + thinkingOptions: [{ id: "high", label: "High", isDefault: true }], + }, + ], + }, + ], generatedAt: "2026-05-16T01:00:00.000Z", }, }), ); expect(snapshotUpdates).toEqual(["2026-05-16T01:00:00.000Z"]); + expect(snapshotModelDefaults).toEqual(["high"]); unsubscribe(); await client.close(); diff --git a/packages/protocol/codegen/README.md b/packages/protocol/codegen/README.md new file mode 100644 index 000000000..7b2b0bcee --- /dev/null +++ b/packages/protocol/codegen/README.md @@ -0,0 +1,7 @@ +# Protocol Validator Codegen + +This directory is build-time only. `ws-outbound.compile.ts` is the zod-aot discovery entry for the inbound WebSocket validator. + +The generated runtime file is written to `../src/generated/validation/ws-outbound.aot.ts` and is not committed. The protocol package owns every generation trigger through its npm lifecycle scripts. + +`zod-aot` is exact-pinned, and the protocol generator applies the small compiler patches it requires before generation. Treat changes to those patches like compiler changes: regenerate, inspect the output, and run the protocol validation regression tests before shipping. diff --git a/packages/protocol/codegen/ws-outbound.compile.ts b/packages/protocol/codegen/ws-outbound.compile.ts new file mode 100644 index 000000000..589986cf6 --- /dev/null +++ b/packages/protocol/codegen/ws-outbound.compile.ts @@ -0,0 +1,4 @@ +import { compile } from "zod-aot"; +import { WSOutboundMessageSchema as SourceWSOutboundMessageSchema } from "../src/messages.js"; + +export const WSOutboundMessageSchema = compile(SourceWSOutboundMessageSchema); diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 54c31554a..402d22b84 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -23,7 +23,12 @@ "build:clean": "npm run clean && npm run build", "prepack": "npm run build:clean", "typecheck": "tsgo --noEmit", - "test": "vitest run" + "test": "vitest run", + "generate:validators": "node scripts/generate-validation-aot.mjs", + "watch": "concurrently --kill-others --names validation,tsc --prefix-colors green,yellow \"node scripts/watch-validation-aot.mjs\" \"tsc -p tsconfig.json --watch --preserveWatchOutput\"", + "prebuild": "npm run generate:validators", + "pretypecheck": "npm run generate:validators", + "pretest": "npm run generate:validators" }, "dependencies": { "zod": "^4.4.3" @@ -31,6 +36,7 @@ "devDependencies": { "@types/node": "^20.9.0", "typescript": "^5.2.2", - "vitest": "^4.1.6" + "vitest": "^4.1.6", + "zod-aot": "0.20.4" } } diff --git a/packages/protocol/scripts/generate-validation-aot.mjs b/packages/protocol/scripts/generate-validation-aot.mjs new file mode 100644 index 000000000..c3fd0b72e --- /dev/null +++ b/packages/protocol/scripts/generate-validation-aot.mjs @@ -0,0 +1,106 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, relative, resolve, sep } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const source = resolve(packageRoot, "codegen/ws-outbound.compile.ts"); +const runtimeSchemaMetadata = resolve(packageRoot, "src/validation/ws-outbound-schema-metadata.ts"); +const output = resolve(packageRoot, "src/generated/validation/ws-outbound.aot.ts"); + +const require = createRequire(import.meta.url); +const zodAotEntry = require.resolve("zod-aot"); +const zodAotRoot = resolve(dirname(zodAotEntry), ".."); +const emitterPath = resolve(zodAotRoot, "dist/cli/emitter.js"); +const discriminatedUnionPath = resolve( + zodAotRoot, + "dist/core/codegen/schemas/discriminated-union.js", +); + +async function ensureZodAotRuntimeImportExtensionPatch() { + const emitter = await readFile(emitterPath, "utf8"); + if (emitter.includes('sourceRelPath.endsWith(".js")')) { + return; + } + + const before = 'let importPath = sourceRelPath.replace(/\\.[cm]?[jt]sx?$/, "");'; + const after = + 'let importPath = sourceRelPath.endsWith(".js")\n ? sourceRelPath\n : sourceRelPath.replace(/\\.[cm]?[jt]sx?$/, "");'; + if (!emitter.includes(before)) { + throw new Error("zod-aot emitter shape changed; update the runtime import extension patch"); + } + await writeFile(emitterPath, emitter.replace(before, after)); +} + +async function ensureZodAotDiscriminatedUnionOutputPatch() { + let discriminatedUnionEmitter = await readFile(discriminatedUnionPath, "utf8"); + if ( + discriminatedUnionEmitter.includes( + "const needsOutputPropagation = ir.options.some(hasMutation);", + ) + ) { + return; + } + + const importBefore = 'import { escapeString } from "../context.js";'; + const importAfter = 'import { escapeString, hasMutation } from "../context.js";'; + const outputFlagBefore = "const discKey = escapeString(ir.discriminator);\n let code = emit `"; + const outputFlagAfter = + "const discKey = escapeString(ir.discriminator);\n const needsOutputPropagation = ir.options.some(hasMutation);\n let code = emit `"; + const propagationBefore = + " ${g.visit(option, { input: objVar, output: objVar })}\n break;`;"; + const propagationAfter = + ' ${g.visit(option, { input: objVar, output: objVar })}\n ${needsOutputPropagation ? `${g.output}=${objVar};` : ""}\n break;`;'; + + if ( + !discriminatedUnionEmitter.includes(importBefore) || + !discriminatedUnionEmitter.includes(outputFlagBefore) || + !discriminatedUnionEmitter.includes(propagationBefore) + ) { + throw new Error("zod-aot discriminated-union emitter shape changed; update the output patch"); + } + + discriminatedUnionEmitter = discriminatedUnionEmitter + .replace(importBefore, importAfter) + .replace(outputFlagBefore, outputFlagAfter) + .replace(propagationBefore, propagationAfter); + await writeFile(discriminatedUnionPath, discriminatedUnionEmitter); +} + +await Promise.all([ + ensureZodAotRuntimeImportExtensionPatch(), + ensureZodAotDiscriminatedUnionOutputPatch(), +]); + +const [{ discoverSchemas }, { compileSchemas }, { generateCompiledFileContent }] = + await Promise.all([ + import(pathToFileURL(resolve(zodAotRoot, "dist/discovery.js")).href), + import(pathToFileURL(resolve(zodAotRoot, "dist/core/pipeline.js")).href), + import(pathToFileURL(resolve(zodAotRoot, "dist/cli/emitter.js")).href), + ]); + +const schemas = await discoverSchemas(source, { cacheBust: true }); +if (schemas.length === 0) { + throw new Error(`No zod-aot compile() exports found in ${relative(packageRoot, source)}`); +} + +const compiled = compileSchemas(schemas, { mode: "inline" }); +const runtimeImportPath = relative(dirname(output), runtimeSchemaMetadata) + .replace(/\.[cm]?[jt]sx?$/, ".js") + .split(sep) + .join("/"); +const content = generateCompiledFileContent(compiled, runtimeImportPath, { + zodCompat: false, +}).replace( + "// AUTO-GENERATED by zod-aot — DO NOT EDIT", + "// @ts-nocheck\n// AUTO-GENERATED by zod-aot — DO NOT EDIT", +); + +await mkdir(dirname(output), { recursive: true }); +await writeFile(output, content); + +console.info( + `generated ${relative(packageRoot, output)} from ${relative(packageRoot, source)} (${schemas + .map((schema) => schema.exportName) + .join(", ")})`, +); diff --git a/packages/protocol/scripts/watch-validation-aot.mjs b/packages/protocol/scripts/watch-validation-aot.mjs new file mode 100644 index 000000000..80664d4d3 --- /dev/null +++ b/packages/protocol/scripts/watch-validation-aot.mjs @@ -0,0 +1,96 @@ +import { readdir, stat } from "node:fs/promises"; +import { dirname, join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; + +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const protocolSrcDir = join(packageRoot, "src"); +const pollIntervalMs = 1000; + +let currentFingerprint = ""; +let generateInFlight = false; +let generateAgain = false; + +async function collectSourceFiles(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const path = join(dir, entry.name); + const relativePath = relative(protocolSrcDir, path); + const parts = relativePath.split(sep); + + if (parts[0] === "generated") { + continue; + } + + if (entry.isDirectory()) { + files.push(...(await collectSourceFiles(path))); + continue; + } + + if (entry.isFile() && entry.name.endsWith(".ts")) { + files.push(path); + } + } + + return files; +} + +async function fingerprintSources() { + const files = await collectSourceFiles(protocolSrcDir); + const stats = await Promise.all( + files.sort().map(async (file) => { + const fileStat = await stat(file); + return `${relative(packageRoot, file)}:${fileStat.mtimeMs}:${fileStat.size}`; + }), + ); + + return stats.join("\n"); +} + +function runGenerate() { + if (generateInFlight) { + generateAgain = true; + return; + } + + generateInFlight = true; + const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + const child = spawn(npmCommand, ["run", "generate:validators"], { + cwd: packageRoot, + stdio: "inherit", + }); + + child.on("exit", (code, signal) => { + generateInFlight = false; + if (code !== 0) { + console.error( + signal + ? `validation AOT generation stopped by ${signal}` + : `validation AOT generation failed with exit code ${code}`, + ); + } + + if (generateAgain) { + generateAgain = false; + runGenerate(); + } + }); +} + +async function poll() { + try { + const nextFingerprint = await fingerprintSources(); + if (currentFingerprint && nextFingerprint !== currentFingerprint) { + runGenerate(); + } + currentFingerprint = nextFingerprint; + } catch (error) { + console.error("validation AOT watcher failed to scan protocol sources", error); + } +} + +currentFingerprint = await fingerprintSources(); +console.log("Watching protocol schema sources for validation AOT changes..."); +setInterval(poll, pollIntervalMs); diff --git a/packages/protocol/src/generated/validation/README.md b/packages/protocol/src/generated/validation/README.md new file mode 100644 index 000000000..7d650faf4 --- /dev/null +++ b/packages/protocol/src/generated/validation/README.md @@ -0,0 +1,11 @@ +# Generated protocol validators + +`ws-outbound.aot.ts` is generated by zod-aot from `packages/protocol/codegen/ws-outbound.compile.ts`. + +Run: + +```bash +npm run generate:validators --workspace=@getpaseo/protocol +``` + +The generated TypeScript is intentionally gitignored. zod-aot is exact-pinned because this is a young solo-maintainer project; keep the generated validator behind protocol regression tests when upgrading it. diff --git a/packages/protocol/src/loop/rpc-schemas.ts b/packages/protocol/src/loop/rpc-schemas.ts index e5305cf61..53159afcb 100644 --- a/packages/protocol/src/loop/rpc-schemas.ts +++ b/packages/protocol/src/loop/rpc-schemas.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest"; +import { AgentProviderSchema } from "../provider-manifest.js"; export const LoopLogEntrySchema = z.object({ seq: z.number().int().positive(), diff --git a/packages/protocol/src/messages.providers-snapshot.test.ts b/packages/protocol/src/messages.providers-snapshot.test.ts index ca8a2c94c..fac33940a 100644 --- a/packages/protocol/src/messages.providers-snapshot.test.ts +++ b/packages/protocol/src/messages.providers-snapshot.test.ts @@ -38,41 +38,6 @@ describe("provider snapshot message schemas", () => { expect(parsed.enabled).toBe(true); }); - test("normalizes thinking option defaults on provider snapshot models", () => { - const parsed = ProviderSnapshotEntrySchema.parse({ - provider: "claude", - status: "ready", - models: [ - { - provider: "claude", - id: "MiniMax-M2.7", - label: "MiniMax-M2.7", - isDefault: true, - contextWindowMaxTokens: 1_000_000, - thinkingOptions: [ - { id: "off", label: "Off" }, - { id: "max", label: "Max", isDefault: true }, - ], - }, - ], - }); - - expect(parsed.models).toEqual([ - { - provider: "claude", - id: "MiniMax-M2.7", - label: "MiniMax-M2.7", - isDefault: true, - contextWindowMaxTokens: 1_000_000, - thinkingOptions: [ - { id: "off", label: "Off" }, - { id: "max", label: "Max", isDefault: true }, - ], - defaultThinkingOptionId: "max", - }, - ]); - }); - test("defaults missing enabled state in providers snapshot response entries", () => { const parsed = GetProvidersSnapshotResponseMessageSchema.parse({ type: "get_providers_snapshot_response", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index b91aa0d82..f4d89490f 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2,9 +2,9 @@ import { z } from "zod"; import { TerminalActivitySchema } from "./terminal-activity.js"; import { CLIENT_CAPS } from "./client-capabilities.js"; import { AGENT_LIFECYCLE_STATUSES } from "./agent-lifecycle.js"; -import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "@getpaseo/protocol/agent-title-limits"; -import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest"; -import { normalizeAgentModelDefinition, TOOL_CALL_ICON_NAMES } from "./agent-types.js"; +import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent-title-limits.js"; +import { AgentProviderSchema } from "./provider-manifest.js"; +import { TOOL_CALL_ICON_NAMES } from "./agent-types.js"; import { ChatCreateRequestSchema, ChatListRequestSchema, @@ -40,7 +40,7 @@ import { ScheduleDeleteResponseSchema, ScheduleRunOnceResponseSchema, ScheduleUpdateResponseSchema, -} from "@getpaseo/protocol/schedule/rpc-schemas"; +} from "./schedule/rpc-schemas.js"; import { LoopRunRequestSchema, LoopListRequestSchema, @@ -52,7 +52,7 @@ import { LoopInspectResponseSchema, LoopLogsResponseSchema, LoopStopResponseSchema, -} from "@getpaseo/protocol/loop/rpc-schemas"; +} from "./loop/rpc-schemas.js"; import { BrowserAutomationExecuteRequestSchema, BrowserAutomationExecuteResponseSchema, @@ -73,7 +73,7 @@ import { type PaseoMetadataGenerationEntry, type PaseoScriptEntryRaw, type ProjectConfigRpcError, -} from "@getpaseo/protocol/paseo-config-schema"; +} from "./paseo-config-schema.js"; export { PaseoConfigRawSchema, PaseoLifecycleCommandRawSchema, @@ -247,19 +247,17 @@ export const AgentFeatureSchema = z.discriminatedUnion("type", [ AgentFeatureSelectSchema, ]); -const AgentModelDefinitionSchema: z.ZodType = z - .object({ - provider: AgentProviderSchema, - id: z.string(), - label: z.string(), - description: z.string().optional(), - isDefault: z.boolean().optional(), - metadata: z.record(z.string(), z.unknown()).optional(), - contextWindowMaxTokens: z.number().optional(), - thinkingOptions: z.array(AgentSelectOptionSchema).optional(), - defaultThinkingOptionId: z.string().optional(), - }) - .transform(normalizeAgentModelDefinition); +const AgentModelDefinitionSchema: z.ZodType = z.object({ + provider: AgentProviderSchema, + id: z.string(), + label: z.string(), + description: z.string().optional(), + isDefault: z.boolean().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + contextWindowMaxTokens: z.number().optional(), + thinkingOptions: z.array(AgentSelectOptionSchema).optional(), + defaultThinkingOptionId: z.string().optional(), +}); export const ProviderSnapshotEntrySchema = z.object({ provider: AgentProviderSchema, @@ -361,20 +359,21 @@ const AgentPermissionActionSchema = z.object({ intent: z.enum(["implement", "implement_resume", "dismiss"]).optional(), }); -export const AgentPermissionResponseSchema: z.ZodType = z.union([ - z.object({ - behavior: z.literal("allow"), - selectedActionId: z.string().optional(), - updatedInput: z.record(z.string(), z.unknown()).optional(), - updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(), - }), - z.object({ - behavior: z.literal("deny"), - selectedActionId: z.string().optional(), - message: z.string().optional(), - interrupt: z.boolean().optional(), - }), -]); +export const AgentPermissionResponseSchema: z.ZodType = + z.discriminatedUnion("behavior", [ + z.object({ + behavior: z.literal("allow"), + selectedActionId: z.string().optional(), + updatedInput: z.record(z.string(), z.unknown()).optional(), + updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(), + }), + z.object({ + behavior: z.literal("deny"), + selectedActionId: z.string().optional(), + message: z.string().optional(), + interrupt: z.boolean().optional(), + }), + ]); export const AgentPermissionRequestPayloadSchema: z.ZodType = z.object({ @@ -554,13 +553,16 @@ const ToolCallCanceledPayloadSchema = ToolCallBasePayloadSchema.extend({ error: z.null(), }); -const ToolCallTimelineItemPayloadSchema: z.ZodType = z.union([ - ToolCallRunningPayloadSchema, - ToolCallCompletedPayloadSchema, - ToolCallFailedPayloadSchema, - ToolCallCanceledPayloadSchema, -]); +const ToolCallTimelineItemPayloadSchema: z.ZodType = + z.discriminatedUnion("status", [ + ToolCallRunningPayloadSchema, + ToolCallCompletedPayloadSchema, + ToolCallFailedPayloadSchema, + ToolCallCanceledPayloadSchema, + ]); +// zod-aot 0.20.4 miscompiles this as a nested discriminated union by omitting +// the inner tool_call branch from the generated outer dispatch. export const AgentTimelineItemPayloadSchema: z.ZodType = z.union([ z.object({ type: z.literal("user_message"), @@ -3113,7 +3115,9 @@ export const SetDaemonConfigResponseMessageSchema = z.object({ export const ReadProjectConfigResponseMessageSchema = z.object({ type: z.literal("read_project_config_response"), - payload: z.discriminatedUnion("ok", [ + // zod-aot 0.2.0 miscompiles boolean discriminators as string options + // (`"true"`/`"false"`), so keep this sequential until upstream fixes it. + payload: z.union([ z.object({ requestId: z.string(), repoRoot: z.string(), @@ -3132,7 +3136,9 @@ export const ReadProjectConfigResponseMessageSchema = z.object({ export const WriteProjectConfigResponseMessageSchema = z.object({ type: z.literal("write_project_config_response"), - payload: z.discriminatedUnion("ok", [ + // zod-aot 0.2.0 miscompiles boolean discriminators as string options + // (`"true"`/`"false"`), so keep this sequential until upstream fixes it. + payload: z.union([ z.object({ requestId: z.string(), repoRoot: z.string(), diff --git a/packages/protocol/src/schedule/types.ts b/packages/protocol/src/schedule/types.ts index 92cbab05a..ff41fa5df 100644 --- a/packages/protocol/src/schedule/types.ts +++ b/packages/protocol/src/schedule/types.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest"; +import { AgentProviderSchema } from "../provider-manifest.js"; export const ScheduleStatusSchema = z.enum(["active", "paused", "completed"]); export type ScheduleStatus = z.infer; diff --git a/packages/protocol/src/tool-call-display.ts b/packages/protocol/src/tool-call-display.ts index 9324e8e66..952d64c12 100644 --- a/packages/protocol/src/tool-call-display.ts +++ b/packages/protocol/src/tool-call-display.ts @@ -1,5 +1,5 @@ import type { ToolCallTimelineItem } from "./agent-types.js"; -import { getPaseoToolLeafName, isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization"; +import { getPaseoToolLeafName, isPaseoToolName } from "./tool-name-normalization.js"; import { stripCwdPrefix } from "./path-utils.js"; export type ToolCallDisplayInput = Pick< diff --git a/packages/protocol/src/validation/ws-outbound-schema-metadata.ts b/packages/protocol/src/validation/ws-outbound-schema-metadata.ts new file mode 100644 index 000000000..23c9b5e56 --- /dev/null +++ b/packages/protocol/src/validation/ws-outbound-schema-metadata.ts @@ -0,0 +1,3 @@ +import { WSOutboundMessageSchema as SourceWSOutboundMessageSchema } from "../messages.js"; + +export const WSOutboundMessageSchema = { schema: SourceWSOutboundMessageSchema }; diff --git a/packages/protocol/src/validation/ws-outbound.ts b/packages/protocol/src/validation/ws-outbound.ts new file mode 100644 index 000000000..bcb568cd8 --- /dev/null +++ b/packages/protocol/src/validation/ws-outbound.ts @@ -0,0 +1,19 @@ +import type { z } from "zod"; +import { WSOutboundMessageSchema } from "../generated/validation/ws-outbound.aot.js"; +import type { WSOutboundMessage } from "../messages.js"; + +type WSOutboundValidationResult = + | { success: true; data: WSOutboundMessage } + | { success: false; error: z.ZodError }; + +interface WSOutboundGeneratedValidator { + safeParse(input: unknown): WSOutboundValidationResult; +} + +// zod-aot emits runtime JavaScript from WSOutboundMessageSchema but not its TypeScript surface. +// Protocol regression tests cover the patched compiler cases behind this boundary. +const wsOutboundValidator = WSOutboundMessageSchema as WSOutboundGeneratedValidator; + +export function validateWSOutboundMessage(input: unknown): WSOutboundValidationResult { + return wsOutboundValidator.safeParse(input); +} diff --git a/packages/protocol/tests/validation/ws-outbound.test.ts b/packages/protocol/tests/validation/ws-outbound.test.ts new file mode 100644 index 000000000..81c1953ef --- /dev/null +++ b/packages/protocol/tests/validation/ws-outbound.test.ts @@ -0,0 +1,145 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { createJiti } from "jiti"; +import { describe, expect, it } from "vitest"; +import { WSOutboundMessageSchema as GeneratedWSOutboundMessageSchema } from "../../src/generated/validation/ws-outbound.aot.js"; + +interface GeneratedSchema { + safeParse(input: unknown): { success: boolean; data?: unknown }; +} + +const protocolRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const generatedWSOutboundPath = resolve( + protocolRoot, + "src/generated/validation/ws-outbound.aot.ts", +); +const require = createRequire(import.meta.url); + +async function compileInlineSchema(sourceSchema: string): Promise { + const scratchRoot = resolve(protocolRoot, "../../.tmp"); + await mkdir(scratchRoot, { recursive: true }); + const tempDir = await mkdtemp(join(scratchRoot, "paseo-zod-aot-")); + + try { + const sourcePath = join(tempDir, "schema.source.js"); + const outputPath = join(tempDir, "schema.generated.ts"); + await writeFile(join(tempDir, "package.json"), '{"type":"module"}\n'); + await writeFile( + sourcePath, + [ + 'import { z } from "zod";', + 'import { compile } from "zod-aot";', + sourceSchema, + "export const Schema = compile(SourceSchema);", + "", + ].join("\n"), + ); + + const zodAotEntry = require.resolve("zod-aot"); + const zodAotRoot = resolve(dirname(zodAotEntry), ".."); + const [{ discoverSchemas }, { compileSchemas }, { generateCompiledFileContent }] = + await Promise.all([ + import(pathToFileURL(resolve(zodAotRoot, "dist/discovery.js")).href), + import(pathToFileURL(resolve(zodAotRoot, "dist/core/pipeline.js")).href), + import(pathToFileURL(resolve(zodAotRoot, "dist/cli/emitter.js")).href), + ]); + + const schemas = await discoverSchemas(sourcePath, { cacheBust: true }); + const compiled = compileSchemas(schemas, { mode: "inline" }); + const content = generateCompiledFileContent(compiled, "./schema.source.js", { + zodCompat: false, + }); + await writeFile(outputPath, content); + + const jiti = createJiti(import.meta.url, { moduleCache: false }); + const generated = await jiti.import(outputPath); + return generated.Schema as GeneratedSchema; + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +} + +describe("WS outbound zod-aot validation", () => { + it("applies defaults inside discriminated-union branches", async () => { + const schema = await compileInlineSchema(` +const SourceSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("with_default"), + enabled: z.boolean().default(true), + }), + z.object({ + type: z.literal("without_default"), + label: z.string(), + }), +]); +`); + + expect(schema.safeParse({ type: "with_default" })).toMatchObject({ + success: true, + data: { type: "with_default", enabled: true }, + }); + }); + + it("routes tool-call-like status unions through the current sequential item union", async () => { + const schema = await compileInlineSchema(` +const ToolCallItemSchema = z.discriminatedUnion("status", [ + z.object({ type: z.literal("tool_call"), status: z.literal("running"), callId: z.string() }), + z.object({ type: z.literal("tool_call"), status: z.literal("completed"), callId: z.string(), output: z.string() }), + z.object({ type: z.literal("tool_call"), status: z.literal("failed"), callId: z.string(), error: z.string() }), + z.object({ type: z.literal("tool_call"), status: z.literal("canceled"), callId: z.string() }), +]); + +const TimelineItemSchema = z.union([ + z.object({ type: z.literal("assistant_message"), text: z.string() }), + ToolCallItemSchema, +]); + +const SourceSchema = z.object({ + item: TimelineItemSchema, +}); +`); + + expect( + schema.safeParse({ item: { type: "tool_call", status: "running", callId: "run" } }), + ).toMatchObject({ + success: true, + data: { item: { type: "tool_call", status: "running", callId: "run" } }, + }); + expect( + schema.safeParse({ + item: { type: "tool_call", status: "completed", callId: "done", output: "ok" }, + }), + ).toMatchObject({ + success: true, + data: { item: { type: "tool_call", status: "completed", callId: "done", output: "ok" } }, + }); + expect( + schema.safeParse({ + item: { type: "tool_call", status: "failed", callId: "fail", error: "boom" }, + }), + ).toMatchObject({ + success: true, + data: { item: { type: "tool_call", status: "failed", callId: "fail", error: "boom" } }, + }); + expect( + schema.safeParse({ item: { type: "tool_call", status: "canceled", callId: "stop" } }), + ).toMatchObject({ + success: true, + data: { item: { type: "tool_call", status: "canceled", callId: "stop" } }, + }); + }); + + it("accepts a minimal valid envelope and rejects a corrupted envelope", () => { + expect(GeneratedWSOutboundMessageSchema.safeParse({ type: "pong" }).success).toBe(true); + expect(GeneratedWSOutboundMessageSchema.safeParse({ type: "not_a_message" }).success).toBe( + false, + ); + }); + + it("emits runtime imports with .js extensions", async () => { + const generated = await readFile(generatedWSOutboundPath, "utf8"); + expect(generated).toContain('from "../../validation/ws-outbound-schema-metadata.js"'); + }); +});