Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/guides/programmatic/remote-runs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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`;
Expand Down
28 changes: 19 additions & 9 deletions examples/sdk/05-hosted-agent/02-http-sse-api/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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;
Expand All @@ -44,5 +51,8 @@ export const HostedAgentApiErrorSchema = z.object({

export type StartHostedAgentRunInput = z.infer<typeof StartHostedAgentRunInputSchema>;
export type StartHostedAgentRunResult = z.infer<typeof StartHostedAgentRunResultSchema>;
export type HostedAgentRunEvent = z.infer<typeof HostedAgentRunEventSchema>;
export type HostedAgentRunEvent = ConversationRunProtocolEvent<
z.infer<typeof HostedAgentActivitySchema>,
z.infer<typeof HostedAgentResultSchema>
>;
export type CancelHostedAgentRunResult = z.infer<typeof CancelHostedAgentRunResultSchema>;
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
90 changes: 89 additions & 1 deletion src/__tests__/unit/core/conversation-run-protocol-codec.test.ts
Original file line number Diff line number Diff line change
@@ -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),
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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', () => {
Expand All @@ -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<unknown, { summary: string }> = {
'~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<unknown, { type: string }> = {
'~standard': {
version: 1,
vendor: 'test-async',
async validate(value) {
return { value: value as { type: string } };
},
},
};

function createCodec() {
return new ConversationRunProtocolCodec({
activity: PublicActivitySchema,
Expand Down
12 changes: 8 additions & 4 deletions src/core/chat/remote/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
3 changes: 3 additions & 0 deletions src/core/chat/remote/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
} from './consumer-service.js';
export {
ConversationRunProtocolCodec,
ConversationRunProtocolValidationError,
ConversationRunReferenceSchema,
ConversationRunReplayCursorSchema,
} from './protocol-codec.js';
Expand All @@ -17,8 +18,10 @@ export type {
ConversationRunProtocolEnvelope,
ConversationRunProtocolError,
ConversationRunProtocolEvent,
ConversationRunProtocolEventSchema,
ConversationRunProtocolEventKind,
ConversationRunReference,
ConversationRunRetry,
ConversationRunProtocolSafeParseResult,
ConversationRunSubscriptionInput,
} from './types.js';
Loading
Loading