diff --git a/docs/guides/programmatic/remote-runs.md b/docs/guides/programmatic/remote-runs.md index ea4ae77d..4d02669d 100644 --- a/docs/guides/programmatic/remote-runs.md +++ b/docs/guides/programmatic/remote-runs.md @@ -22,7 +22,10 @@ import { ## Define the public wire payload Heddle owns the run envelope and terminal vocabulary. The host must explicitly -choose which activity and result fields are safe for remote clients. +choose which activity and result fields are safe for remote clients. Payload +validators use the validator-neutral +[Standard Schema](https://standardschema.dev/schema) interface; Zod 3.24+, Zod +4, Valibot, ArkType, and other compatible validators work without adapters. ```ts import { z } from 'zod' @@ -43,6 +46,10 @@ const protocol = new ConversationRunProtocolCodec({ }) ``` +Payload validation must be synchronous because streaming parse and +serialization are synchronous. The codec rejects an asynchronous validator with +a clear boundary error. + `protocol.parseEvent(untrustedValue)` validates: - non-empty `runId`; diff --git a/examples/sdk/05-hosted-agent/02-http-sse-api/contracts.ts b/examples/sdk/05-hosted-agent/02-http-sse-api/contracts.ts index 3310fa9f..129a0006 100644 --- a/examples/sdk/05-hosted-agent/02-http-sse-api/contracts.ts +++ b/examples/sdk/05-hosted-agent/02-http-sse-api/contracts.ts @@ -5,7 +5,10 @@ * Extend these schemas only with product data safe for remote clients. */ import { z } from 'zod'; -import { ConversationRunProtocolCodec } from '../../../../src/remote.js'; +import { + ConversationRunProtocolCodec, + type ConversationRunProtocolEvent, +} from '../../../../src/remote.js'; export const StartHostedAgentRunInputSchema = z.object({ sessionId: z.string().trim().min(1).max(128), @@ -19,14 +22,18 @@ export const StartHostedAgentRunResultSchema = z.object({ sessionId: z.string().min(1), }); +const HostedAgentActivitySchema = z.object({ + type: z.string().min(1), +}).passthrough(); + +const HostedAgentResultSchema = z.object({ + outcome: z.string().min(1), + summary: z.string(), +}); + export const HostedAgentRunProtocol = new ConversationRunProtocolCodec({ - activity: z.object({ - type: z.string().min(1), - }).passthrough(), - result: z.object({ - outcome: z.string().min(1), - summary: z.string(), - }), + activity: HostedAgentActivitySchema, + result: HostedAgentResultSchema, }); export const HostedAgentRunEventSchema = HostedAgentRunProtocol.eventSchema; @@ -44,5 +51,8 @@ export const HostedAgentApiErrorSchema = z.object({ export type StartHostedAgentRunInput = z.infer; export type StartHostedAgentRunResult = z.infer; -export type HostedAgentRunEvent = z.infer; +export type HostedAgentRunEvent = ConversationRunProtocolEvent< + z.infer, + z.infer +>; export type CancelHostedAgentRunResult = z.infer; diff --git a/package.json b/package.json index 18e16ef3..4cd2806f 100644 --- a/package.json +++ b/package.json @@ -198,6 +198,7 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", + "@standard-schema/spec": "^1.1.0", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-query": "^5.100.11", "@trpc/client": "^11.16.0", diff --git a/src/__tests__/unit/core/conversation-run-protocol-codec.test.ts b/src/__tests__/unit/core/conversation-run-protocol-codec.test.ts index 720b3a44..15bf4bea 100644 --- a/src/__tests__/unit/core/conversation-run-protocol-codec.test.ts +++ b/src/__tests__/unit/core/conversation-run-protocol-codec.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; import { z } from 'zod'; -import { ConversationRunProtocolCodec } from '@/core/chat/remote/index.js'; +import { + ConversationRunProtocolCodec, + ConversationRunProtocolValidationError, +} from '@/core/chat/remote/index.js'; const PublicActivitySchema = z.object({ type: z.string().min(1), @@ -48,6 +52,21 @@ describe('ConversationRunProtocolCodec', () => { })); }); + it('accepts validator-agnostic Standard Schema payloads and retains transforms', () => { + const codec = new ConversationRunProtocolCodec({ + activity: PublicActivitySchema, + result: trimmedSummarySchema, + }); + + expect(codec.parseEvent(envelope({ + kind: 'result', + result: { summary: ' Finished ' }, + }))).toMatchObject({ + kind: 'result', + result: { summary: 'Finished' }, + }); + }); + it('rejects malformed envelopes and host payloads', () => { const codec = createCodec(); @@ -76,6 +95,18 @@ describe('ConversationRunProtocolCodec', () => { kind: 'activity', activity: { type: 'tool.calling', unsafe: undefined }, }))).toThrow('JSON-safe'); + + const parsed = codec.safeParseEvent(envelope({ + kind: 'activity', + activity: { type: 'tool.calling', unsafe: undefined }, + })); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.error).toBeInstanceOf(ConversationRunProtocolValidationError); + expect((parsed.error as ConversationRunProtocolValidationError).issues).toEqual([ + expect.objectContaining({ path: ['activity', 'unsafe'] }), + ]); + } }); it('round-trips validated events through JSON serialization', () => { @@ -87,9 +118,66 @@ describe('ConversationRunProtocolCodec', () => { expect(codec.parseEvent(JSON.parse(codec.stringifyEvent(input)))).toEqual(input); expect(codec.safeParseEvent(input).success).toBe(true); + expect(codec.eventSchema.parse(input)).toEqual(input); + }); + + it('exposes a Standard Schema validator for the complete event', () => { + const codec = createCodec(); + const input = envelope({ + kind: 'result', + result: { outcome: 'done', summary: 'Finished' }, + }); + const validation = codec.eventSchema['~standard'].validate(input); + + expect(validation).not.toBeInstanceOf(Promise); + expect(validation).toEqual({ value: input }); + }); + + it('rejects asynchronous host validators with a clear synchronous-boundary error', () => { + const codec = new ConversationRunProtocolCodec({ + activity: asyncActivitySchema, + result: PublicResultSchema, + }); + + expect(() => codec.parseEvent(envelope({ + kind: 'activity', + activity: { type: 'assistant.stream' }, + }))).toThrow(ConversationRunProtocolValidationError); + expect(() => codec.parseEvent(envelope({ + kind: 'activity', + activity: { type: 'assistant.stream' }, + }))).toThrow('must validate synchronously'); }); }); +const trimmedSummarySchema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'test', + validate(value) { + const summary = typeof value === 'object' + && value !== null + && 'summary' in value + && typeof value.summary === 'string' + ? value.summary.trim() + : undefined; + return summary + ? { value: { summary } } + : { issues: [{ message: 'Expected a non-empty summary.', path: ['summary'] }] }; + }, + }, +}; + +const asyncActivitySchema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'test-async', + async validate(value) { + return { value: value as { type: string } }; + }, + }, +}; + function createCodec() { return new ConversationRunProtocolCodec({ activity: PublicActivitySchema, diff --git a/src/core/chat/remote/README.md b/src/core/chat/remote/README.md index 8653ad53..e76f42c2 100644 --- a/src/core/chat/remote/README.md +++ b/src/core/chat/remote/README.md @@ -11,7 +11,8 @@ conversation run across a remote boundary. - rejecting sequence gaps and post-terminal events; - recognizing result, cancellation, and error terminals; - bounded exponential reconnect timing; -- runtime validation of the canonical run envelope; +- runtime validation of the canonical run envelope and host payloads through + the validator-neutral Standard Schema interface; - JSON-safety validation before a transport serializes an event. ## Does not own @@ -42,9 +43,12 @@ const consumer = new ConversationRunConsumerService({ }) ``` -The host must supply schemas that expose only authorized public payloads. The -codec owns the envelope and JSON safety; it does not sanitize sensitive product -or tool data on the host's behalf. +The host must supply synchronous +[Standard Schema](https://standardschema.dev/schema) validators that expose only +authorized public payloads. Zod 3.24+, Zod 4, Valibot, ArkType, and other +implementations can be used without coupling the SDK to their schema objects. +The codec owns the envelope and JSON safety; it does not sanitize sensitive +product or tool data on the host's behalf. CLI-v2, web-v2, and SDK examples must reuse this service. Do not add another cursor/retry state machine in a client adapter. diff --git a/src/core/chat/remote/index.ts b/src/core/chat/remote/index.ts index 1135889f..ff971d50 100644 --- a/src/core/chat/remote/index.ts +++ b/src/core/chat/remote/index.ts @@ -5,6 +5,7 @@ export { } from './consumer-service.js'; export { ConversationRunProtocolCodec, + ConversationRunProtocolValidationError, ConversationRunReferenceSchema, ConversationRunReplayCursorSchema, } from './protocol-codec.js'; @@ -17,8 +18,10 @@ export type { ConversationRunProtocolEnvelope, ConversationRunProtocolError, ConversationRunProtocolEvent, + ConversationRunProtocolEventSchema, ConversationRunProtocolEventKind, ConversationRunReference, ConversationRunRetry, + ConversationRunProtocolSafeParseResult, ConversationRunSubscriptionInput, } from './types.js'; diff --git a/src/core/chat/remote/protocol-codec.ts b/src/core/chat/remote/protocol-codec.ts index e714912f..37c5fb29 100644 --- a/src/core/chat/remote/protocol-codec.ts +++ b/src/core/chat/remote/protocol-codec.ts @@ -1,7 +1,10 @@ -import { z, type ZodType } from 'zod'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import { z, ZodError } from 'zod'; import type { ConversationRunProtocolCodecOptions, ConversationRunProtocolEvent, + ConversationRunProtocolEventSchema, + ConversationRunProtocolSafeParseResult, } from './types.js'; const NonBlankStringSchema = z.string().refine((value) => Boolean(value.trim()), { @@ -20,55 +23,174 @@ const ConversationRunProtocolEnvelopeSchema = z.object({ timestamp: z.iso.datetime(), }); +const RawConversationRunProtocolEventSchema = z.discriminatedUnion('kind', [ + ConversationRunProtocolEnvelopeSchema.extend({ + kind: z.literal('activity'), + activity: z.unknown(), + }), + ConversationRunProtocolEnvelopeSchema.extend({ + kind: z.literal('result'), + result: z.unknown(), + }), + ConversationRunProtocolEnvelopeSchema.extend({ + kind: z.literal('cancelled'), + reason: z.string(), + }), + ConversationRunProtocolEnvelopeSchema.extend({ + kind: z.literal('error'), + error: z.object({ + code: NonBlankStringSchema, + message: z.string(), + }), + }), +]); + +export class ConversationRunProtocolValidationError extends Error { + readonly name = 'ConversationRunProtocolValidationError'; + + constructor(readonly issues: ReadonlyArray) { + super(issues.map(({ message }) => message).join('; ')); + } +} + /** * Owns runtime validation and JSON-safe serialization for the canonical remote * conversation-run envelope. Hosts supply their public activity/result schemas. */ export class ConversationRunProtocolCodec { - readonly eventSchema: ZodType>; - - constructor(options: ConversationRunProtocolCodecOptions) { - const schema = z.discriminatedUnion('kind', [ - ConversationRunProtocolEnvelopeSchema.extend({ - kind: z.literal('activity'), - activity: options.activity, - }), - ConversationRunProtocolEnvelopeSchema.extend({ - kind: z.literal('result'), - result: options.result, - }), - ConversationRunProtocolEnvelopeSchema.extend({ - kind: z.literal('cancelled'), - reason: z.string(), - }), - ConversationRunProtocolEnvelopeSchema.extend({ - kind: z.literal('error'), - error: z.object({ - code: NonBlankStringSchema, - message: z.string(), - }), - }), - ]).superRefine((event, context) => { - if (!z.json().safeParse(event).success) { - context.addIssue({ - code: 'custom', - message: 'Conversation run events must contain only JSON-safe values.', - }); - } - }); + readonly eventSchema: ConversationRunProtocolEventSchema; - this.eventSchema = schema as ZodType>; + constructor(private readonly options: ConversationRunProtocolCodecOptions) { + this.eventSchema = { + parse: (input) => this.parseEvent(input), + safeParse: (input) => this.safeParseEvent(input), + '~standard': { + version: 1, + vendor: 'heddle', + validate: (input) => { + const parsed = this.safeParseEvent(input); + return parsed.success + ? { value: parsed.data } + : { issues: standardIssuesFromError(parsed.error) }; + }, + }, + }; } parseEvent(input: unknown): ConversationRunProtocolEvent { - return this.eventSchema.parse(input); + try { + const envelope = RawConversationRunProtocolEventSchema.parse(input); + const event = this.parseHostPayload(envelope); + const jsonEvent = z.json().safeParse(event); + if (!jsonEvent.success) { + throw new ConversationRunProtocolValidationError( + jsonEvent.error.issues.map(({ message, path }) => ({ + message: `Conversation run events must contain only JSON-safe values: ${message}`, + path: findNonJsonValuePath(event) ?? path, + })), + ); + } + return event; + } catch (error) { + throw normalizeProtocolError(error); + } } - safeParseEvent(input: unknown) { - return this.eventSchema.safeParse(input); + safeParseEvent(input: unknown): ConversationRunProtocolSafeParseResult { + try { + return { success: true, data: this.parseEvent(input) }; + } catch (error) { + return { success: false, error: normalizeProtocolError(error) }; + } } stringifyEvent(input: unknown): string { return JSON.stringify(this.parseEvent(input)); } + + private parseHostPayload( + envelope: z.infer, + ): ConversationRunProtocolEvent { + if (envelope.kind === 'activity') { + return { + ...envelope, + activity: parseStandardSchema(this.options.activity, envelope.activity, 'activity'), + }; + } + if (envelope.kind === 'result') { + return { + ...envelope, + result: parseStandardSchema(this.options.result, envelope.result, 'result'), + }; + } + return envelope; + } +} + +function parseStandardSchema( + schema: StandardSchemaV1, + input: unknown, + field: 'activity' | 'result', +): Output { + const result = schema['~standard'].validate(input); + if (isPromiseLike(result)) { + throw new ConversationRunProtocolValidationError([{ + message: `Conversation run ${field} schemas must validate synchronously.`, + path: [field], + }]); + } + if (result.issues) { + throw new ConversationRunProtocolValidationError( + result.issues.map((issue) => ({ + ...issue, + path: [field, ...(issue.path ?? [])], + })), + ); + } + return result.value; +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return typeof value === 'object' + && value !== null + && 'then' in value + && typeof value.then === 'function'; +} + +function findNonJsonValuePath( + value: unknown, + path: PropertyKey[] = [], +): PropertyKey[] | undefined { + if (z.json().safeParse(value).success) { + return undefined; + } + if (Array.isArray(value)) { + return value.reduce( + (found, item, index) => found ?? findNonJsonValuePath(item, [...path, index]), + undefined, + ) ?? path; + } + if (typeof value === 'object' && value !== null) { + return Object.entries(value).reduce( + (found, [key, item]) => found ?? findNonJsonValuePath(item, [...path, key]), + undefined, + ) ?? path; + } + return path; +} + +function standardIssuesFromError(error: unknown): ReadonlyArray { + if (error instanceof ConversationRunProtocolValidationError) { + return error.issues; + } + if (error instanceof ZodError) { + return error.issues.map(({ message, path }) => ({ message, path })); + } + return [{ message: error instanceof Error ? error.message : String(error) }]; +} + +function normalizeProtocolError(error: unknown): ConversationRunProtocolValidationError { + return error instanceof ConversationRunProtocolValidationError + ? error + : new ConversationRunProtocolValidationError(standardIssuesFromError(error)); } diff --git a/src/core/chat/remote/types.ts b/src/core/chat/remote/types.ts index cf5ec027..f37d865d 100644 --- a/src/core/chat/remote/types.ts +++ b/src/core/chat/remote/types.ts @@ -1,4 +1,4 @@ -import type { ZodType } from 'zod'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; export type ConversationRunReference = { runId: string; @@ -66,6 +66,22 @@ export type ConversationRunProtocolEvent = }); export type ConversationRunProtocolCodecOptions = { - activity: ZodType; - result: ZodType; + activity: StandardSchemaV1; + result: StandardSchemaV1; }; + +export type ConversationRunProtocolSafeParseResult = + | { + success: true; + data: ConversationRunProtocolEvent; + } + | { + success: false; + error: Error; + }; + +export type ConversationRunProtocolEventSchema = + StandardSchemaV1> & { + parse(input: unknown): ConversationRunProtocolEvent; + safeParse(input: unknown): ConversationRunProtocolSafeParseResult; + };