Skip to content

Commit 8101b07

Browse files
Nick Ficanoclaude
andcommitted
effect(core-messages-events): events + execution zod→Schema
Migrate messages/events.ts and messages/execution.ts to Effect Schema. Body schemas (Thought/ToolCall/ToolResult/Status/Delegate/Progress/ ResultChunk) and execution payload schemas (JobSubmit/Cancel/Result/ Error/Unsubscribe + JobBudget/JobState/JobErrorFinalStatus) are now native Schema.Struct / Schema.Literal / Schema.Record. The tool_result mutual-exclusion (result XOR error) is enforced via Schema.filter. RESERVED_EVENT_SCHEMAS is eliminated in favor of a per-kind decoder map (RESERVED_EVENT_DECODERS) of Schema.decodeUnknownSync thunks, keyed by ReservedEventKind with a satisfies exhaustiveness guard. parseJobEventBody dispatches through this map; vendor (x-vendor.*) and unknown kinds pass through unchecked per §15. Envelope-construction zod twins (*ZodSchema) are retained because messageEnvelope() is still zod-typed (slice #50). Branded payloads (JobAccepted, JobSubscribe, JobSubscribed) stay zod-only since brands.ts mirrors z.BRAND<B>. The slice-#35 zod twins (Log/Metric/ArtifactRefZodSchema) are removed as events.ts is the sole remaining consumer. Tests pin every body variant + the docs/guides/job-events.md JSON examples + vendor passthrough + unknown-kind passthrough + tool_result mutual exclusion + parseJobEventBody exhaustiveness. Closes #36 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7681713 commit 8101b07

6 files changed

Lines changed: 880 additions & 170 deletions

File tree

packages/core/src/messages/artifacts.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Schema } from "effect";
2-
import { z } from "zod";
32

43
// ARCP v1.0 §8.2 — `artifact_ref` event kind body shape only. There is no
54
// top-level artifact envelope in v1.0; agents emit a `job.event` with
@@ -27,18 +26,5 @@ export const ArtifactRefSchema = Schema.Struct({
2726
});
2827
export type ArtifactRef = Schema.Schema.Type<typeof ArtifactRefSchema>;
2928

30-
/**
31-
* Legacy zod twin of `ArtifactRefSchema`. Retained for call-sites that have
32-
* not yet been migrated to Effect `Schema` (notably the
33-
* `RESERVED_EVENT_SCHEMAS` discriminated-union map in `events.ts`, which is
34-
* still a `z.discriminatedUnion`). Slice #36 removes this export.
35-
*/
36-
export const ArtifactRefZodSchema = z.object({
37-
uri: z.string().min(1),
38-
content_type: z.string().min(1),
39-
byte_size: z.number().int().nonnegative().optional(),
40-
sha256: z.string().optional(),
41-
});
42-
4329
/** No top-level artifact envelopes in v1.0. */
4430
export const ARTIFACT_ENVELOPES = [] as const;

packages/core/src/messages/events.ts

Lines changed: 148 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
1+
import { Schema } from "effect";
12
import { z } from "zod";
23

3-
import { ErrorPayloadSchema } from "../errors.js";
4+
import { ERROR_CODES } from "../errors.js";
45

6+
import { ArtifactRefSchema } from "./artifacts.js";
57
import { 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

1618
export 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-vendor\.[a-z0-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

175224
function 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
*/
191242
export function parseJobEventBody<K extends ReservedEventKind>(

0 commit comments

Comments
 (0)