1+ import { Schema } from "effect" ;
12import { z } from "zod" ;
23
3- import { ErrorPayloadSchema } from "../errors.js" ;
4+ import { ERROR_CODES } from "../errors.js" ;
45
6+ import { ArtifactRefSchema } from "./artifacts.js" ;
57import { LeaseConstraintsSchema , LeaseSchema } from "./lease-schema.js" ;
6- // telemetry.ts now exports Effect `Schema` for `LogPayloadSchema` /
7- // `MetricPayloadSchema`. `RESERVED_EVENT_SCHEMAS` below is still a
8- // `z.discriminatedUnion`-style map keyed by `z.ZodTypeAny`, so we consume
9- // the legacy zod twins here until slice #36 migrates the event- body
10- // dispatch to Effect.
11- import {
12- LogPayloadZodSchema ,
13- MetricPayloadZodSchema ,
14- } from "./telemetry.js" ;
8+ import { LogPayloadSchema , MetricPayloadSchema } from "./telemetry.js" ;
9+ import { fromZod } from "./zod-adapter.js" ;
10+
11+ // ARCP v1.0 §8 + v1.1 §8.2/§8.4 — `job. event` body schemas.
12+ //
13+ // This module owns the per-kind body schemas and the `parseJobEventBody`
14+ // dispatch over the reserved-kind set. Bodies for `log`, `metric`, and
15+ // `artifact_ref` are imported from their owning modules (slice #35 migrated
16+ // them); the remaining bodies are defined here as native Effect `Schema`.
1517
1618export const RESERVED_EVENT_KINDS = [
1719 "log" ,
@@ -22,7 +24,7 @@ export const RESERVED_EVENT_KINDS = [
2224 "metric" ,
2325 "artifact_ref" ,
2426 "delegate" ,
25- // v1.1 §8.2
27+ // v1.1 §8.2.1 / §8.4
2628 "progress" ,
2729 "result_chunk" ,
2830] as const ;
@@ -36,104 +38,144 @@ export function isVendorEventKind(value: string): boolean {
3638 return / ^ x - v e n d o r \. [ a - z 0 - 9 _ . - ] + $ / . test ( value ) ;
3739}
3840
39- const ThoughtBodySchema = z . object ( {
40- text : z . string ( ) ,
41+ // Internal Effect mirror of `ErrorPayloadSchema` (zod, in `errors.ts`).
42+ // Used by the `tool_result` body where the optional `error` field carries
43+ // a §12 error payload. Field-for-field equivalent.
44+ const ErrorPayloadEffectSchema = Schema . Struct ( {
45+ code : Schema . Literal ( ...ERROR_CODES ) ,
46+ message : Schema . String . pipe ( Schema . nonEmptyString ( ) ) ,
47+ retryable : Schema . optional ( Schema . Boolean ) ,
48+ details : Schema . optional (
49+ Schema . Record ( { key : Schema . String , value : Schema . Unknown } ) ,
50+ ) ,
4151} ) ;
4252
43- const ToolCallBodySchema = z . object ( {
44- tool : z . string ( ) . min ( 1 ) ,
45- args : z . record ( z . string ( ) , z . unknown ( ) ) . optional ( ) ,
46- call_id : z . string ( ) . min ( 1 ) ,
53+ /** §8.2 `thought` event-kind body. */
54+ export const ThoughtBodySchema = Schema . Struct ( {
55+ text : Schema . String ,
4756} ) ;
48-
49- const ToolResultBodySchema = z
50- . object ( {
51- call_id : z . string ( ) . min ( 1 ) ,
52- result : z . unknown ( ) . optional ( ) ,
53- error : ErrorPayloadSchema . optional ( ) ,
54- } )
55- . superRefine ( ( b , ctx ) => {
56- if ( b . result === undefined && b . error === undefined ) {
57- // empty result for void tools is allowed
58- return ;
59- }
60- if ( b . result !== undefined && b . error !== undefined ) {
61- ctx . addIssue ( {
62- code : z . ZodIssueCode . custom ,
63- message : "tool_result body must not carry both `result` and `error`" ,
64- } ) ;
65- }
66- } ) ;
67-
68- const StatusBodySchema = z . object ( {
69- phase : z . string ( ) . min ( 1 ) ,
70- message : z . string ( ) . optional ( ) ,
57+ export type ThoughtBody = Schema . Schema . Type < typeof ThoughtBodySchema > ;
58+
59+ /** §8.2 `tool_call` event-kind body. */
60+ export const ToolCallBodySchema = Schema . Struct ( {
61+ tool : Schema . String . pipe ( Schema . nonEmptyString ( ) ) ,
62+ args : Schema . optional (
63+ Schema . Record ( { key : Schema . String , value : Schema . Unknown } ) ,
64+ ) ,
65+ call_id : Schema . String . pipe ( Schema . nonEmptyString ( ) ) ,
7166} ) ;
67+ export type ToolCallBody = Schema . Schema . Type < typeof ToolCallBodySchema > ;
7268
73- const ArtifactRefBodySchema = z . object ( {
74- uri : z . string ( ) . min ( 1 ) ,
75- content_type : z . string ( ) . min ( 1 ) ,
76- byte_size : z . number ( ) . int ( ) . nonnegative ( ) . optional ( ) ,
77- sha256 : z . string ( ) . optional ( ) ,
69+ /**
70+ * §8.2 `tool_result` event-kind body.
71+ *
72+ * Carries either `result` (success) or `error` (failure) but not both. An
73+ * empty body (neither field) is allowed for void tools. The mutual exclusion
74+ * is enforced via `Schema.filter` (zod parity: `superRefine`).
75+ */
76+ export const ToolResultBodySchema = Schema . Struct ( {
77+ call_id : Schema . String . pipe ( Schema . nonEmptyString ( ) ) ,
78+ result : Schema . optional ( Schema . Unknown ) ,
79+ error : Schema . optional ( ErrorPayloadEffectSchema ) ,
80+ } ) . pipe (
81+ Schema . filter ( ( b ) =>
82+ b . result !== undefined && b . error !== undefined
83+ ? "tool_result body must not carry both `result` and `error`"
84+ : undefined ,
85+ ) ,
86+ ) ;
87+ export type ToolResultBody = Schema . Schema . Type < typeof ToolResultBodySchema > ;
88+
89+ /** §8.2 `status` event-kind body. */
90+ export const StatusBodySchema = Schema . Struct ( {
91+ phase : Schema . String . pipe ( Schema . nonEmptyString ( ) ) ,
92+ message : Schema . optional ( Schema . String ) ,
7893} ) ;
79-
80- const DelegateBodySchema = z . object ( {
81- delegate_id : z . string ( ) . min ( 1 ) ,
82- agent : z . string ( ) . min ( 1 ) ,
83- input : z . unknown ( ) ,
84- lease_request : LeaseSchema . optional ( ) ,
94+ export type StatusBody = Schema . Schema . Type < typeof StatusBodySchema > ;
95+
96+ // `lease_request` / `lease_constraints` still live as zod (slice-boundary;
97+ // `lease-schema.ts` migration is its own future slice). Bridge through
98+ // `fromZod` so the surrounding Effect `Schema.Struct` can compose them.
99+ const LeaseEffectSchema = fromZod ( LeaseSchema ) ;
100+ const LeaseConstraintsEffectSchema = fromZod ( LeaseConstraintsSchema ) ;
101+
102+ /** §8.2 `delegate` event-kind body. */
103+ export const DelegateBodySchema = Schema . Struct ( {
104+ delegate_id : Schema . String . pipe ( Schema . nonEmptyString ( ) ) ,
105+ agent : Schema . String . pipe ( Schema . nonEmptyString ( ) ) ,
106+ input : Schema . Unknown ,
107+ lease_request : Schema . optional ( LeaseEffectSchema ) ,
85108 /** v1.1 §9.4/§9.5 — child lease bound; MUST NOT exceed parent's. */
86- lease_constraints : LeaseConstraintsSchema . optional ( ) ,
109+ lease_constraints : Schema . optional ( LeaseConstraintsEffectSchema ) ,
87110} ) ;
111+ export type DelegateBody = Schema . Schema . Type < typeof DelegateBodySchema > ;
88112
89113/**
90114 * v1.1 §8.2.1 `progress` body.
91115 *
92116 * `current` MUST be non-negative; `total` (if present) is the upper bound.
93117 * Advisory; the protocol does not act on progress events.
94118 */
95- export const ProgressBodySchema = z . object ( {
96- current : z . number ( ) . nonnegative ( ) ,
97- total : z . number ( ) . nonnegative ( ) . optional ( ) ,
98- units : z . string ( ) . min ( 1 ) . optional ( ) ,
99- message : z . string ( ) . optional ( ) ,
119+ export const ProgressBodySchema = Schema . Struct ( {
120+ current : Schema . Number . pipe ( Schema . nonNegative ( ) ) ,
121+ total : Schema . optional ( Schema . Number . pipe ( Schema . nonNegative ( ) ) ) ,
122+ units : Schema . optional ( Schema . String . pipe ( Schema . nonEmptyString ( ) ) ) ,
123+ message : Schema . optional ( Schema . String ) ,
100124} ) ;
101- export type ProgressBody = z . infer < typeof ProgressBodySchema > ;
125+ export type ProgressBody = Schema . Schema . Type < typeof ProgressBodySchema > ;
102126
103127/**
104128 * v1.1 §8.4 `result_chunk` body. Chunks for one `result_id` are emitted in
105129 * order; `more: false` marks the final chunk. The terminating `job.result`
106130 * MUST carry `result_id`.
107131 */
108- export const ResultChunkBodySchema = z . object ( {
109- result_id : z . string ( ) . min ( 1 ) ,
110- chunk_seq : z . number ( ) . int ( ) . nonnegative ( ) ,
111- data : z . string ( ) ,
112- encoding : z . enum ( [ "utf8" , "base64" ] ) ,
113- more : z . boolean ( ) ,
132+ export const ResultChunkBodySchema = Schema . Struct ( {
133+ result_id : Schema . String . pipe ( Schema . nonEmptyString ( ) ) ,
134+ chunk_seq : Schema . Number . pipe ( Schema . int ( ) , Schema . nonNegative ( ) ) ,
135+ data : Schema . String ,
136+ encoding : Schema . Literal ( "utf8" , "base64" ) ,
137+ more : Schema . Boolean ,
138+ } ) ;
139+ export type ResultChunkBody = Schema . Schema . Type < typeof ResultChunkBodySchema > ;
140+
141+ // Re-exported body type aliases for the telemetry + artifact bodies, so
142+ // `messages/types.ts` keeps a single import path for the body type surface.
143+ export type LogBody = Schema . Schema . Type < typeof LogPayloadSchema > ;
144+ export type MetricBody = Schema . Schema . Type < typeof MetricPayloadSchema > ;
145+ export type ArtifactRefBody = Schema . Schema . Type < typeof ArtifactRefSchema > ;
146+
147+ /**
148+ * Job event payload shape (top-level `payload` for `job.event` envelopes).
149+ *
150+ * `kind` is one of the eight v1.0 reserved values, one of the two v1.1
151+ * additions, OR a vendor-prefixed string. `body` is `unknown` at the
152+ * envelope layer; reserved kinds are validated against their specific
153+ * schemas via {@link parseJobEventBody}; vendor (`x-vendor.*`) and unknown
154+ * kinds pass through unchanged (caller MUST treat them as opaque per §15).
155+ */
156+ export const JobEventPayloadSchema = Schema . Struct ( {
157+ kind : Schema . String . pipe ( Schema . nonEmptyString ( ) ) ,
158+ ts : Schema . String . pipe ( Schema . nonEmptyString ( ) ) ,
159+ body : Schema . Unknown ,
114160} ) ;
115- export type ResultChunkBody = z . infer < typeof ResultChunkBodySchema > ;
116161
117162/**
118- * Job event payload shape. `kind` is one of the eight reserved values OR a
119- * vendor-prefixed string. `body` is validated when the kind matches a
120- * reserved schema; vendor and unknown kinds get a permissive object body.
163+ * Zod twin of {@link JobEventPayloadSchema}.
164+ *
165+ * `messageEnvelope()` in `envelope.ts` is still zod-typed (slice #50). Until
166+ * the envelope layer migrates, the envelope wrapper for `job.event` consumes
167+ * this zod twin. Shape parity with `JobEventPayloadSchema` is verified by
168+ * the test suite. `JobEventPayload` is the zod-inferred type so wire-shape
169+ * consumers (e.g. `client-dispatch.ts`) keep their existing TS contract —
170+ * notably `body` is an optional property because zod's `z.unknown()` infers
171+ * as `body?: unknown`.
121172 */
122- export const JobEventPayloadSchema = z . object ( {
173+ export const JobEventPayloadZodSchema = z . object ( {
123174 kind : z . string ( ) . min ( 1 ) ,
124175 ts : z . string ( ) . min ( 1 ) ,
125176 body : z . unknown ( ) ,
126177} ) ;
127- export type JobEventPayload = z . infer < typeof JobEventPayloadSchema > ;
128-
129- export type LogBody = z . infer < typeof LogPayloadZodSchema > ;
130- export type ThoughtBody = z . infer < typeof ThoughtBodySchema > ;
131- export type ToolCallBody = z . infer < typeof ToolCallBodySchema > ;
132- export type ToolResultBody = z . infer < typeof ToolResultBodySchema > ;
133- export type StatusBody = z . infer < typeof StatusBodySchema > ;
134- export type MetricBody = z . infer < typeof MetricPayloadZodSchema > ;
135- export type ArtifactRefBody = z . infer < typeof ArtifactRefBodySchema > ;
136- export type DelegateBody = z . infer < typeof DelegateBodySchema > ;
178+ export type JobEventPayload = z . infer < typeof JobEventPayloadZodSchema > ;
137179
138180/**
139181 * Map a reserved event kind to its strongly-typed body.
@@ -156,36 +198,45 @@ export interface ReservedEventBodyMap {
156198 result_chunk : ResultChunkBody ;
157199}
158200
159- // Exhaustiveness guard: every ReservedEventKind member MUST have a schema
160- // entry below. Adding a new kind to RESERVED_EVENT_KINDS without extending
161- // this map is a compile-time error (the `satisfies` clause enforces it).
162- const RESERVED_EVENT_SCHEMAS = {
163- log : LogPayloadZodSchema ,
164- thought : ThoughtBodySchema ,
165- tool_call : ToolCallBodySchema ,
166- tool_result : ToolResultBodySchema ,
167- status : StatusBodySchema ,
168- metric : MetricPayloadZodSchema ,
169- artifact_ref : ArtifactRefBodySchema ,
170- delegate : DelegateBodySchema ,
171- progress : ProgressBodySchema ,
172- result_chunk : ResultChunkBodySchema ,
173- } as const satisfies Record < ReservedEventKind , z . ZodTypeAny > ;
201+ // Per-kind sync decoders, keyed by reserved kind. `Schema.decodeUnknownSync`
202+ // throws a `ParseError` on bad input — matches the throw semantics of the
203+ // legacy `zodSchema.parse(body)` call site.
204+ //
205+ // `satisfies Record<ReservedEventKind, ...>` is the exhaustiveness guard:
206+ // adding a new kind to `RESERVED_EVENT_KINDS` without a corresponding
207+ // decoder here is a compile-time error.
208+ const RESERVED_EVENT_DECODERS = {
209+ log : Schema . decodeUnknownSync ( LogPayloadSchema ) ,
210+ thought : Schema . decodeUnknownSync ( ThoughtBodySchema ) ,
211+ tool_call : Schema . decodeUnknownSync ( ToolCallBodySchema ) ,
212+ tool_result : Schema . decodeUnknownSync ( ToolResultBodySchema ) ,
213+ status : Schema . decodeUnknownSync ( StatusBodySchema ) ,
214+ metric : Schema . decodeUnknownSync ( MetricPayloadSchema ) ,
215+ artifact_ref : Schema . decodeUnknownSync ( ArtifactRefSchema ) ,
216+ delegate : Schema . decodeUnknownSync ( DelegateBodySchema ) ,
217+ progress : Schema . decodeUnknownSync ( ProgressBodySchema ) ,
218+ result_chunk : Schema . decodeUnknownSync ( ResultChunkBodySchema ) ,
219+ } as const satisfies Record <
220+ ReservedEventKind ,
221+ ( body : unknown ) => ReservedEventBodyMap [ ReservedEventKind ]
222+ > ;
174223
175224function parseReservedEventBody < K extends ReservedEventKind > (
176225 kind : K ,
177226 body : unknown ,
178227) : ReservedEventBodyMap [ K ] {
179- const schema = RESERVED_EVENT_SCHEMAS [ kind ] ;
180- return schema . parse ( body ) as ReservedEventBodyMap [ K ] ;
228+ // The per-kind decoder return type is the union of all body types; cast
229+ // back to the discriminated branch keyed by `K`.
230+ const decode = RESERVED_EVENT_DECODERS [ kind ] ;
231+ return decode ( body ) as ReservedEventBodyMap [ K ] ;
181232}
182233
183234/**
184235 * Parse a `job.event.payload.body` against the kind-specific schema.
185236 *
186237 * Reserved kinds (§8.2) are validated against their schemas via the
187238 * exhaustively-checked {@link parseReservedEventBody}; vendor (`x-vendor.*`)
188- * and unknown kinds pass through unchanged (caller MUST treat them as
239+ * and unknown kinds pass through unchecked (caller MUST treat them as
189240 * opaque per §15).
190241 */
191242export function parseJobEventBody < K extends ReservedEventKind > (
0 commit comments