| author | |
| committer | |
| log | ca3f9738c39c3502dd2258aae872f971080969ef |
| tree | b2695644fbc171a1e1a86732ef41b71589a42ff1 |
| parent | 51f7f77f87318a5329d3bab15d513fda5654200b |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>5 files changed, 472 insertions(+), 31 deletions(-)
apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+4-1| ... | ... | @@ -329,7 +329,10 @@ it.effect.each( |
| 329 | 329 | assert.equal(wire.messages[0]?.text, task.prompt); |
| 330 | 330 | assert.equal(wire.messages[0]?.scheduledTaskId, task.id); |
| 331 | 331 | assert.equal(wire.messages[0]?.createdBy, createdBy); |
| 332 | const turnItem = wire.turnItems.find((item) => item.type === "user_message"); | |
| 332 | const turnItem = wire.turnItems.find( | |
| 333 | (item): item is Extract<typeof item, { type: "user_message" }> => | |
| 334 | item.type === "user_message", | |
| 335 | ); | |
| 333 | 336 | assert.equal(turnItem?.text, task.prompt); |
| 334 | 337 | assert.equal(turnItem?.scheduledTaskId, task.id); |
| 335 | 338 | }).pipe(Effect.provide(Layer.mergeAll(harness.layer, scheduledTasks))); |
packages/contracts/src/baseSchemas.test.ts created+107| ... | ... | @@ -0,0 +1,107 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import * as DateTime from "effect/DateTime"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Schema from "effect/Schema"; | |
| 5 | ||
| 6 | import { | |
| 7 | ForwardCompatibleArray, | |
| 8 | ForwardCompatibleUnion, | |
| 9 | ForwardCompatibleUnionArray, | |
| 10 | isUnknownUnionMember, | |
| 11 | UnknownUnionMember, | |
| 12 | } from "./baseSchemas.ts"; | |
| 13 | ||
| 14 | const at = "2026-10-05T00:00:00.000Z"; | |
| 15 | const Shape = Schema.Union([ | |
| 16 | Schema.Struct({ kind: Schema.Literal("circle"), radius: Schema.Number, at: Schema.DateTimeUtc }), | |
| 17 | Schema.Struct({ kind: Schema.Literal("square"), side: Schema.Number }), | |
| 18 | ]); | |
| 19 | const members = Shape.members; | |
| 20 | /** How clients decode: the JSON wire codec over the runtime schema. */ | |
| 21 | const fromWire = <S extends Schema.Top>(schema: S) => | |
| 22 | Schema.decodeUnknownSync(Schema.toCodecJson(schema) as never) as (value: unknown) => S["Type"]; | |
| 23 | ||
| 24 | describe("ForwardCompatibleArray", () => { | |
| 25 | it("decodes elements through their own codec and drops ones it cannot read", () => { | |
| 26 | const decoded = fromWire(ForwardCompatibleArray(Shape))([ | |
| 27 | { kind: "circle", radius: 1, at }, | |
| 28 | { kind: "triangle" }, | |
| 29 | { kind: "square", side: "wide" }, | |
| 30 | ]); | |
| 31 | expect(decoded).toHaveLength(1); | |
| 32 | expect(DateTime.isDateTime((decoded[0] as { at: unknown }).at)).toBe(true); | |
| 33 | }); | |
| 34 | ||
| 35 | it("applies decoding defaults instead of failing", () => { | |
| 36 | const WithDefault = Schema.Struct({ | |
| 37 | name: Schema.String, | |
| 38 | count: Schema.optionalKey(Schema.Number).pipe( | |
| 39 | Schema.withDecodingDefaultKey(Effect.succeed(7)), | |
| 40 | ), | |
| 41 | }); | |
| 42 | expect(Schema.decodeUnknownSync(ForwardCompatibleArray(WithDefault))([{ name: "a" }])).toEqual([ | |
| 43 | { name: "a", count: 7 }, | |
| 44 | ]); | |
| 45 | }); | |
| 46 | }); | |
| 47 | ||
| 48 | describe("ForwardCompatibleUnion", () => { | |
| 49 | const decode = fromWire(ForwardCompatibleUnion(members, "kind")); | |
| 50 | ||
| 51 | it("decodes a member from a newer server as unknown", () => { | |
| 52 | const decoded = decode({ kind: "triangle", corners: 3 }); | |
| 53 | expect(isUnknownUnionMember(decoded)).toBe(true); | |
| 54 | expect(decoded).toEqual(new UnknownUnionMember("kind", "triangle")); | |
| 55 | }); | |
| 56 | ||
| 57 | it("decodes known members in full, transformations included", () => { | |
| 58 | const decoded = decode({ kind: "circle", radius: 1, at }); | |
| 59 | expect(isUnknownUnionMember(decoded)).toBe(false); | |
| 60 | expect(DateTime.isDateTime((decoded as { at: unknown }).at)).toBe(true); | |
| 61 | }); | |
| 62 | ||
| 63 | it("still fails a known member whose payload is broken", () => { | |
| 64 | expect(() => decode({ kind: "square", side: "wide" })).toThrow(); | |
| 65 | }); | |
| 66 | ||
| 67 | it("refuses to encode an unknown member", () => { | |
| 68 | const encode = Schema.encodeSync(ForwardCompatibleUnion(members, "kind") as never); | |
| 69 | expect(() => encode(new UnknownUnionMember("kind", "triangle") as never)).toThrow(); | |
| 70 | }); | |
| 71 | }); | |
| 72 | ||
| 73 | describe("ForwardCompatibleUnionArray", () => { | |
| 74 | it("keeps a known member however its fields are named", () => { | |
| 75 | const Flagged = Schema.Struct({ | |
| 76 | kind: Schema.Literal("flag"), | |
| 77 | _unknown: Schema.Literal(true), | |
| 78 | tag: Schema.String, | |
| 79 | value: Schema.String, | |
| 80 | }); | |
| 81 | const flag = { kind: "flag", _unknown: true, tag: "kind", value: "x" }; | |
| 82 | expect(fromWire(ForwardCompatibleUnionArray([Flagged], "kind"))([flag])).toEqual([flag]); | |
| 83 | }); | |
| 84 | ||
| 85 | const decode = fromWire(ForwardCompatibleUnionArray(members, "kind")); | |
| 86 | ||
| 87 | it("drops members a newer server added and keeps the rest", () => { | |
| 88 | const decoded = decode([ | |
| 89 | { kind: "triangle" }, | |
| 90 | { kind: "circle", radius: 1, at }, | |
| 91 | { kind: "square", side: 2 }, | |
| 92 | ]); | |
| 93 | expect(decoded.map((shape) => shape.kind)).toEqual(["circle", "square"]); | |
| 94 | }); | |
| 95 | ||
| 96 | it("fails when a known member is broken, so real bugs stay visible", () => { | |
| 97 | expect(() => decode([{ kind: "square", side: "wide" }])).toThrow(); | |
| 98 | }); | |
| 99 | ||
| 100 | it("encodes as a plain array", () => { | |
| 101 | const encode = Schema.encodeSync( | |
| 102 | Schema.toCodecJson(ForwardCompatibleUnionArray(members, "kind")), | |
| 103 | ); | |
| 104 | const square = { kind: "square" as const, side: 2 }; | |
| 105 | expect(encode([square])).toEqual([square]); | |
| 106 | }); | |
| 107 | }); |
packages/contracts/src/baseSchemas.ts+160-15| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | import * as Option from "effect/Option"; |
| 3 | 3 | import * as Schema from "effect/Schema"; |
| 4 | import * as SchemaGetter from "effect/SchemaGetter"; | |
| 4 | 5 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 5 | 6 | |
| 6 | 7 | export const TrimmedString = Schema.String.pipe( |
| ... | ... | @@ -35,14 +36,6 @@ export type DpopFailureReason = typeof DpopFailureReason.Type; |
| 35 | 36 | export const IsoDateTime = Schema.String; |
| 36 | 37 | export type IsoDateTime = typeof IsoDateTime.Type; |
| 37 | 38 | |
| 38 | /** | |
| 39 | * Wire codec for server→client arrays whose element unions grow over time | |
| 40 | * (new literal members, new struct variants). Decoding drops elements the | |
| 41 | * current build cannot decode instead of failing the whole payload — a client | |
| 42 | * has to keep decoding configs sent by servers newer than itself, and | |
| 43 | * rejecting the payload would take down the connection over data the client | |
| 44 | * couldn't act on anyway. Encoding is the plain array encoding. | |
| 45 | */ | |
| 46 | 39 | /** |
| 47 | 40 | * Same idea for one optional value whose literal set grows over time: a |
| 48 | 41 | * member this build does not know decodes as absent rather than failing the |
| ... | ... | @@ -107,20 +100,172 @@ export const OmittedWhenNull = <Value extends Schema.Top>(value: Value) => { |
| 107 | 100 | ); |
| 108 | 101 | }; |
| 109 | 102 | |
| 110 | export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Element) => { | |
| 111 | const decodeElement = Schema.decodeUnknownOption(element as never); | |
| 112 | return Schema.Array(Schema.Unknown).pipe( | |
| 103 | /** | |
| 104 | * Wire codec for a server→client value whose shape grows over time: a union | |
| 105 | * a newer server may add members to, or an array of such values. Clients keep | |
| 106 | * decoding payloads from servers newer than themselves instead of failing the | |
| 107 | * connection over data they could not act on anyway. | |
| 108 | * | |
| 109 | * Decoding runs each value through its own schema, so transformations (dates, | |
| 110 | * trimming, decoding defaults) apply as usual. Only values that schema | |
| 111 | * rejects are dropped; encoding is the plain encoding. | |
| 112 | * | |
| 113 | * For a tagged union, prefer {@link ForwardCompatibleUnion}: it drops only | |
| 114 | * values whose tag this build does not know, so a known member with a broken | |
| 115 | * payload still fails loudly. | |
| 116 | */ | |
| 117 | export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Element) => | |
| 118 | Schema.Array( | |
| 119 | Schema.UndefinedOr(element).pipe( | |
| 120 | // An element this build cannot read becomes a hole, filtered out below. | |
| 121 | Schema.catchDecoding(() => Effect.succeedSome(undefined)), | |
| 122 | ), | |
| 123 | ).pipe( | |
| 124 | Schema.decodeTo( | |
| 125 | Schema.Array(Schema.toType(element)), | |
| 126 | SchemaTransformation.transform< | |
| 127 | ReadonlyArray<Element["Type"]>, | |
| 128 | ReadonlyArray<Element["Type"] | undefined> | |
| 129 | >({ | |
| 130 | decode: (values) => values.filter((value) => value !== undefined), | |
| 131 | encode: (values) => values, | |
| 132 | }), | |
| 133 | ), | |
| 134 | ) as unknown as ForwardCompatibleArray<Element>; | |
| 135 | export type ForwardCompatibleArray<Element extends Schema.Top> = Schema.Codec< | |
| 136 | ReadonlyArray<Element["Type"]>, | |
| 137 | ReadonlyArray<Element["Encoded"]>, | |
| 138 | Element["DecodingServices"], | |
| 139 | Element["EncodingServices"] | |
| 140 | >; | |
| 141 | ||
| 142 | /** | |
| 143 | * A member of a {@link ForwardCompatibleUnion} whose tag this build does not | |
| 144 | * know. A class, so only the codec can make one: no decoded payload, however | |
| 145 | * it is shaped, is ever mistaken for it. | |
| 146 | */ | |
| 147 | export class UnknownUnionMember<Tag extends string = string> { | |
| 148 | readonly tag: Tag; | |
| 149 | readonly value: string; | |
| 150 | constructor(tag: Tag, value: string) { | |
| 151 | this.tag = tag; | |
| 152 | this.value = value; | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| 156 | /** | |
| 157 | * Wire codec for a server→client tagged union a newer server may add members | |
| 158 | * to. A value whose `tag` field holds a value this build does not know decodes | |
| 159 | * to {@link UnknownUnionMember} instead of failing; a known member decodes | |
| 160 | * through its own schema, so a broken known payload still fails. Callers | |
| 161 | * decide what an unknown member means: skip it, or show a fallback. | |
| 162 | * | |
| 163 | * Unknown members are decode-only; encoding one fails. | |
| 164 | */ | |
| 165 | export const ForwardCompatibleUnion = < | |
| 166 | const Members extends ReadonlyArray<Schema.Top & { readonly fields: object }>, | |
| 167 | const Tag extends string, | |
| 168 | >( | |
| 169 | members: Members, | |
| 170 | tag: Tag, | |
| 171 | ) => { | |
| 172 | const known = knownTags(members, tag); | |
| 173 | const unknownMember = Schema.Struct({ | |
| 174 | [tag]: Schema.String.check( | |
| 175 | Schema.makeFilter( | |
| 176 | (value: string) => !known.has(value) || `A known ${tag} must decode in full.`, | |
| 177 | ), | |
| 178 | ), | |
| 179 | } as Record<Tag, Schema.String>).pipe( | |
| 180 | Schema.decodeTo(Schema.instanceOf(UnknownUnionMember<Tag>), { | |
| 181 | decode: SchemaGetter.transform( | |
| 182 | (raw: Record<string, string>) => new UnknownUnionMember(tag, raw[tag]!), | |
| 183 | ), | |
| 184 | encode: SchemaGetter.forbidden(() => `Unknown ${tag} values are never sent.`), | |
| 185 | }), | |
| 186 | ); | |
| 187 | return Schema.Union([...members, unknownMember]) as unknown as ForwardCompatibleUnion< | |
| 188 | Members, | |
| 189 | Tag | |
| 190 | >; | |
| 191 | }; | |
| 192 | export type ForwardCompatibleUnion< | |
| 193 | Members extends ReadonlyArray<Schema.Top>, | |
| 194 | Tag extends string, | |
| 195 | > = Schema.Codec< | |
| 196 | Members[number]["Type"] | UnknownUnionMember<Tag>, | |
| 197 | Members[number]["Encoded"], | |
| 198 | Members[number]["DecodingServices"], | |
| 199 | Members[number]["EncodingServices"] | |
| 200 | >; | |
| 201 | ||
| 202 | /** | |
| 203 | * Whether a raw, undecoded value carries a `tag` this build does not know | |
| 204 | * among `members`. For checks outside a {@link ForwardCompatibleUnion}, such | |
| 205 | * as an envelope that must skip a payload of an unknown kind. | |
| 206 | */ | |
| 207 | export const hasUnknownUnionTag = ( | |
| 208 | members: ReadonlyArray<Schema.Top & { readonly fields: object }>, | |
| 209 | tag: string, | |
| 210 | ): ((value: unknown) => boolean) => { | |
| 211 | const known = knownTags(members, tag); | |
| 212 | return (value) => | |
| 213 | typeof value === "object" && | |
| 214 | value !== null && | |
| 215 | tag in value && | |
| 216 | !known.has((value as Record<string, unknown>)[tag] as string); | |
| 217 | }; | |
| 218 | ||
| 219 | /** Whether a decoded {@link ForwardCompatibleUnion} value is a member this build does not know. */ | |
| 220 | export const isUnknownUnionMember = <A>( | |
| 221 | value: A, | |
| 222 | ): value is Extract<A, UnknownUnionMember<string>> => value instanceof UnknownUnionMember; | |
| 223 | ||
| 224 | /** | |
| 225 | * An array of a growing tagged union that drops members this build does not | |
| 226 | * know. Known members decode through their own schema and still fail when | |
| 227 | * broken. Encoding is the plain array encoding. | |
| 228 | */ | |
| 229 | export const ForwardCompatibleUnionArray = < | |
| 230 | const Members extends ReadonlyArray<Schema.Top & { readonly fields: object }>, | |
| 231 | const Tag extends string, | |
| 232 | >( | |
| 233 | members: Members, | |
| 234 | tag: Tag, | |
| 235 | ) => | |
| 236 | Schema.Array(ForwardCompatibleUnion(members, tag)).pipe( | |
| 113 | 237 | Schema.decodeTo( |
| 114 | Schema.Array(element), | |
| 115 | SchemaTransformation.transform<ReadonlyArray<Element["Encoded"]>, ReadonlyArray<unknown>>({ | |
| 238 | Schema.Array(Schema.toType(Schema.Union(members))), | |
| 239 | SchemaTransformation.transform< | |
| 240 | ReadonlyArray<Members[number]["Type"]>, | |
| 241 | ReadonlyArray<Members[number]["Type"] | UnknownUnionMember<Tag>> | |
| 242 | >({ | |
| 116 | 243 | decode: (values) => |
| 117 | values.filter((value) => Option.isSome(decodeElement(value))) as ReadonlyArray< | |
| 118 | Element["Encoded"] | |
| 244 | values.filter((value) => !isUnknownUnionMember(value)) as ReadonlyArray< | |
| 245 | Members[number]["Type"] | |
| 119 | 246 | >, |
| 120 | 247 | encode: (values) => values, |
| 121 | 248 | }), |
| 122 | 249 | ), |
| 123 | 250 | ); |
| 251 | ||
| 252 | const knownTags = ( | |
| 253 | members: ReadonlyArray<Schema.Top & { readonly fields: object }>, | |
| 254 | tag: string, | |
| 255 | ): ReadonlySet<string> => { | |
| 256 | const tags = new Set<string>(); | |
| 257 | for (const member of members) { | |
| 258 | const field = (member.fields as Record<string, unknown>)[tag] as | |
| 259 | | { readonly literal?: unknown; readonly literals?: ReadonlyArray<unknown> } | |
| 260 | | undefined; | |
| 261 | for (const literal of field?.literals ?? [field?.literal]) { | |
| 262 | if (typeof literal !== "string") { | |
| 263 | throw new Error(`Every ForwardCompatibleUnion member needs a string literal ${tag}.`); | |
| 264 | } | |
| 265 | tags.add(literal); | |
| 266 | } | |
| 267 | } | |
| 268 | return tags; | |
| 124 | 269 | }; |
| 125 | 270 | |
| 126 | 271 | /** |
packages/contracts/src/orchestrationV2.test.ts+129| ... | ... | @@ -34,6 +34,7 @@ import { |
| 34 | 34 | OrchestrationV2ShellSnapshot, |
| 35 | 35 | OrchestrationV2SubscribeThreadInput, |
| 36 | 36 | OrchestrationV2Subagent, |
| 37 | OrchestrationV2ThreadHistoryPage, | |
| 37 | 38 | OrchestrationV2ThreadProjection, |
| 38 | 39 | OrchestrationV2ThreadStreamItem, |
| 39 | 40 | OrchestrationV2ThreadShell, |
| ... | ... | @@ -175,6 +176,134 @@ describe("orchestration V2 contracts", () => { |
| 175 | 176 | ).toThrow(); |
| 176 | 177 | }); |
| 177 | 178 | |
| 179 | it("skips turn item types from a newer server in snapshots and turn-item events", () => { | |
| 180 | const decodeWireItems = Schema.decodeUnknownSync( | |
| 181 | Schema.toCodecJson(Schema.Array(OrchestrationV2RpcSchemas.subscribeThread.output)), | |
| 182 | ); | |
| 183 | const decodeHistoryPage = Schema.decodeUnknownSync( | |
| 184 | Schema.toCodecJson(OrchestrationV2ThreadHistoryPage), | |
| 185 | ); | |
| 186 | const item = (id: string, type: string, extra: Record<string, unknown>) => ({ | |
| 187 | id, | |
| 188 | type, | |
| 189 | threadId: "thread-1", | |
| 190 | runId: null, | |
| 191 | nodeId: null, | |
| 192 | providerThreadId: null, | |
| 193 | providerTurnId: null, | |
| 194 | nativeItemRef: null, | |
| 195 | parentItemId: null, | |
| 196 | ordinal: 1, | |
| 197 | status: "completed", | |
| 198 | title: null, | |
| 199 | startedAt: null, | |
| 200 | completedAt: null, | |
| 201 | updatedAt: DateTime.formatIso(now), | |
| 202 | ...extra, | |
| 203 | }); | |
| 204 | const known = item("item-known", "system_notice", { message: "Hello" }); | |
| 205 | // A type no build of this client knows, standing in for a newer server's item. | |
| 206 | const future = item("item-future", "secret_request", { secretRef: "ref-1" }); | |
| 207 | const projected = (position: number, turnItem: { readonly id: string }) => ({ | |
| 208 | position, | |
| 209 | visibility: "local", | |
| 210 | sourceThreadId: "thread-1", | |
| 211 | sourceItemId: turnItem.id, | |
| 212 | item: turnItem, | |
| 213 | }); | |
| 214 | const projection = { | |
| 215 | thread: { | |
| 216 | createdBy: "user", | |
| 217 | creationSource: "web", | |
| 218 | id: "thread-1", | |
| 219 | projectId: "project-1", | |
| 220 | title: "Thread", | |
| 221 | providerInstanceId: "codex", | |
| 222 | modelSelection: { instanceId: "codex", model: "gpt-5-codex" }, | |
| 223 | runtimeMode: "full-access", | |
| 224 | interactionMode: "default", | |
| 225 | branch: null, | |
| 226 | worktreePath: null, | |
| 227 | activeProviderThreadId: null, | |
| 228 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: "thread-1" }, | |
| 229 | forkedFrom: null, | |
| 230 | createdAt: DateTime.formatIso(now), | |
| 231 | updatedAt: DateTime.formatIso(now), | |
| 232 | archivedAt: null, | |
| 233 | deletedAt: null, | |
| 234 | }, | |
| 235 | runs: [], | |
| 236 | attempts: [], | |
| 237 | nodes: [], | |
| 238 | subagents: [], | |
| 239 | providerSessions: [], | |
| 240 | providerThreads: [], | |
| 241 | providerTurns: [], | |
| 242 | runtimeRequests: [], | |
| 243 | messages: [], | |
| 244 | plans: [], | |
| 245 | turnItems: [future, known], | |
| 246 | checkpointScopes: [], | |
| 247 | checkpoints: [], | |
| 248 | contextHandoffs: [], | |
| 249 | contextTransfers: [], | |
| 250 | visibleTurnItems: [projected(0, future), projected(1, known)], | |
| 251 | updatedAt: DateTime.formatIso(now), | |
| 252 | }; | |
| 253 | const turnItemEvent = (sequence: number, payload: unknown) => ({ | |
| 254 | kind: "event", | |
| 255 | sequence, | |
| 256 | event: { | |
| 257 | id: `event-${sequence}`, | |
| 258 | type: "turn-item.updated", | |
| 259 | threadId: "thread-1", | |
| 260 | occurredAt: DateTime.formatIso(now), | |
| 261 | payload, | |
| 262 | }, | |
| 263 | }); | |
| 264 | ||
| 265 | const [snapshot, futureEvent, knownEvent] = decodeWireItems([ | |
| 266 | { kind: "snapshot", snapshotSequence: 1, projection }, | |
| 267 | turnItemEvent(2, future), | |
| 268 | turnItemEvent(3, known), | |
| 269 | ]); | |
| 270 | ||
| 271 | expect(snapshot).toMatchObject({ kind: "snapshot" }); | |
| 272 | if (snapshot?.kind !== "snapshot") throw new Error("expected a snapshot"); | |
| 273 | expect(snapshot.projection.turnItems.map((turnItem) => turnItem.id)).toEqual(["item-known"]); | |
| 274 | expect(snapshot.projection.visibleTurnItems.map((row) => row.item.id)).toEqual(["item-known"]); | |
| 275 | expect(futureEvent).toEqual({ | |
| 276 | kind: "unknown-event", | |
| 277 | sequence: 2, | |
| 278 | eventType: "turn-item.updated", | |
| 279 | }); | |
| 280 | expect(knownEvent).toMatchObject({ | |
| 281 | kind: "event", | |
| 282 | event: { type: "turn-item.updated", payload: { id: "item-known", type: "system_notice" } }, | |
| 283 | }); | |
| 284 | expect( | |
| 285 | decodeHistoryPage({ | |
| 286 | snapshotSequence: 1, | |
| 287 | items: [projected(0, future), projected(1, known)], | |
| 288 | nextCursor: null, | |
| 289 | hasMoreHistory: false, | |
| 290 | }).items.map((row) => row.item.id), | |
| 291 | ).toEqual(["item-known"]); | |
| 292 | ||
| 293 | // A known turn item type with a broken payload is a real defect, not a newer item. | |
| 294 | const broken = item("item-broken", "system_notice", {}); | |
| 295 | expect(() => decodeWireItems([turnItemEvent(4, broken)])).toThrow(); | |
| 296 | expect(() => | |
| 297 | decodeWireItems([ | |
| 298 | { | |
| 299 | kind: "snapshot", | |
| 300 | snapshotSequence: 1, | |
| 301 | projection: { ...projection, turnItems: [broken] }, | |
| 302 | }, | |
| 303 | ]), | |
| 304 | ).toThrow(); | |
| 305 | }); | |
| 306 | ||
| 178 | 307 | it("negotiates bounded socket snapshots as an optional capability", () => { |
| 179 | 308 | expect( |
| 180 | 309 | decodeOrchestrationV2SubscribeThreadInput({ |
packages/contracts/src/orchestrationV2.ts+72-15| ... | ... | @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as Schema from "effect/Schema"; |
| 4 | 4 | import * as SchemaAST from "effect/SchemaAST"; |
| 5 | 5 | import * as SchemaGetter from "effect/SchemaGetter"; |
| 6 | import * as SchemaTransformation from "effect/SchemaTransformation"; | |
| 6 | 7 | |
| 7 | 8 | import { |
| 8 | 9 | CheckpointId, |
| ... | ... | @@ -15,6 +16,10 @@ import { |
| 15 | 16 | IsoDateTime, |
| 16 | 17 | MessageId, |
| 17 | 18 | NodeId, |
| 19 | ForwardCompatibleUnion, | |
| 20 | ForwardCompatibleUnionArray, | |
| 21 | hasUnknownUnionTag, | |
| 22 | isUnknownUnionMember, | |
| 18 | 23 | NonNegativeInt, |
| 19 | 24 | PlanId, |
| 20 | 25 | PositiveInt, |
| ... | ... | @@ -1494,6 +1499,36 @@ export const OrchestrationV2TurnItem = Schema.Union([ |
| 1494 | 1499 | ]); |
| 1495 | 1500 | export type OrchestrationV2TurnItem = typeof OrchestrationV2TurnItem.Type; |
| 1496 | 1501 | |
| 1502 | /** | |
| 1503 | * Turn item types grow over time, so clients decode them forward-compatibly: | |
| 1504 | * an item whose type this build does not know is dropped from snapshots and | |
| 1505 | * history instead of failing the thread. A known type that does not decode | |
| 1506 | * still fails. Arrays of projected rows filter on the row's nested item. | |
| 1507 | */ | |
| 1508 | const isUnknownTurnItem = hasUnknownUnionTag(OrchestrationV2TurnItem.members, "type"); | |
| 1509 | ||
| 1510 | const turnItemArray = <Members extends ReadonlyArray<Schema.Top & { readonly fields: object }>>( | |
| 1511 | union: Schema.Union<Members>, | |
| 1512 | ) => ForwardCompatibleUnionArray(union.members, "type"); | |
| 1513 | ||
| 1514 | /** Projected rows whose nested item may be of a type this build does not know. */ | |
| 1515 | const projectedTurnItemArray = <Row extends Schema.Top, Item extends Schema.Top>( | |
| 1516 | row: Row, | |
| 1517 | rowWithUnknownItem: Item, | |
| 1518 | ) => | |
| 1519 | Schema.Array(rowWithUnknownItem).pipe( | |
| 1520 | Schema.decodeTo( | |
| 1521 | Schema.Array(Schema.toType(row)), | |
| 1522 | SchemaTransformation.transform<ReadonlyArray<Row["Type"]>, ReadonlyArray<Item["Type"]>>({ | |
| 1523 | decode: (rows) => | |
| 1524 | rows.filter( | |
| 1525 | (projected) => !isUnknownUnionMember((projected as { readonly item: unknown }).item), | |
| 1526 | ) as ReadonlyArray<Row["Type"]>, | |
| 1527 | encode: (rows) => rows, | |
| 1528 | }), | |
| 1529 | ), | |
| 1530 | ); | |
| 1531 | ||
| 1497 | 1532 | export const OrchestrationV2ProjectedTurnItem = Schema.Struct({ |
| 1498 | 1533 | position: NonNegativeInt, |
| 1499 | 1534 | visibility: Schema.Literals(["local", "inherited", "synthetic"]), |
| ... | ... | @@ -1681,12 +1716,18 @@ export const OrchestrationV2ThreadProjection = Schema.Struct({ |
| 1681 | 1716 | runtimeRequests: Schema.Array(OrchestrationV2RuntimeRequest), |
| 1682 | 1717 | messages: Schema.Array(OrchestrationV2ConversationMessage), |
| 1683 | 1718 | plans: Schema.Array(OrchestrationV2PlanArtifact), |
| 1684 | turnItems: Schema.Array(OrchestrationV2TurnItem), | |
| 1719 | turnItems: turnItemArray(OrchestrationV2TurnItem), | |
| 1685 | 1720 | checkpointScopes: Schema.Array(OrchestrationV2CheckpointScope), |
| 1686 | 1721 | checkpoints: Schema.Array(OrchestrationV2Checkpoint), |
| 1687 | 1722 | contextHandoffs: Schema.Array(OrchestrationV2ContextHandoff), |
| 1688 | 1723 | contextTransfers: Schema.Array(OrchestrationV2ContextTransfer), |
| 1689 | visibleTurnItems: Schema.Array(OrchestrationV2ProjectedTurnItem), | |
| 1724 | visibleTurnItems: projectedTurnItemArray( | |
| 1725 | OrchestrationV2ProjectedTurnItem, | |
| 1726 | OrchestrationV2ProjectedTurnItem.mapFields((fields) => ({ | |
| 1727 | ...fields, | |
| 1728 | item: ForwardCompatibleUnion(OrchestrationV2TurnItem.members, "type"), | |
| 1729 | })), | |
| 1730 | ), | |
| 1690 | 1731 | updatedAt: Schema.DateTimeUtc, |
| 1691 | 1732 | }); |
| 1692 | 1733 | export type OrchestrationV2ThreadProjection = typeof OrchestrationV2ThreadProjection.Type; |
| ... | ... | @@ -2249,12 +2290,18 @@ export const OrchestrationV2ThreadProjectionJson = OrchestrationV2ThreadProjecti |
| 2249 | 2290 | runtimeRequests: Schema.Array(OrchestrationV2RuntimeRequestJson), |
| 2250 | 2291 | messages: Schema.Array(OrchestrationV2ConversationMessageJson), |
| 2251 | 2292 | plans: Schema.Array(OrchestrationV2PlanArtifact), |
| 2252 | turnItems: Schema.Array(OrchestrationV2TurnItemJson), | |
| 2293 | turnItems: turnItemArray(OrchestrationV2TurnItemJson), | |
| 2253 | 2294 | checkpointScopes: Schema.Array(OrchestrationV2CheckpointScopeJson), |
| 2254 | 2295 | checkpoints: Schema.Array(OrchestrationV2CheckpointJson), |
| 2255 | 2296 | contextHandoffs: Schema.Array(OrchestrationV2ContextHandoffJson), |
| 2256 | 2297 | contextTransfers: Schema.Array(OrchestrationV2ContextTransferJson), |
| 2257 | visibleTurnItems: Schema.Array(OrchestrationV2ProjectedTurnItemJson), | |
| 2298 | visibleTurnItems: projectedTurnItemArray( | |
| 2299 | OrchestrationV2ProjectedTurnItemJson, | |
| 2300 | OrchestrationV2ProjectedTurnItemJson.mapFields((fields) => ({ | |
| 2301 | ...fields, | |
| 2302 | item: ForwardCompatibleUnion(OrchestrationV2TurnItemJson.members, "type"), | |
| 2303 | })), | |
| 2304 | ), | |
| 2258 | 2305 | updatedAt: Schema.DateTimeUtcFromString, |
| 2259 | 2306 | }), |
| 2260 | 2307 | ); |
| ... | ... | @@ -3129,7 +3176,13 @@ export type OrchestrationV2ThreadBoundedSnapshot = typeof OrchestrationV2ThreadB |
| 3129 | 3176 | /** Older timeline page for progressive history. Rows are chronological. */ |
| 3130 | 3177 | export const OrchestrationV2ThreadHistoryPage = Schema.Struct({ |
| 3131 | 3178 | snapshotSequence: NonNegativeInt, |
| 3132 | items: Schema.Array(OrchestrationV2ProjectedTurnItem), | |
| 3179 | items: projectedTurnItemArray( | |
| 3180 | OrchestrationV2ProjectedTurnItem, | |
| 3181 | OrchestrationV2ProjectedTurnItem.mapFields((fields) => ({ | |
| 3182 | ...fields, | |
| 3183 | item: ForwardCompatibleUnion(OrchestrationV2TurnItem.members, "type"), | |
| 3184 | })), | |
| 3185 | ), | |
| 3133 | 3186 | nextCursor: Schema.NullOr(TrimmedNonEmptyString), |
| 3134 | 3187 | hasMoreHistory: Schema.Boolean, |
| 3135 | 3188 | }); |
| ... | ... | @@ -3143,22 +3196,26 @@ const knownDomainEventTypes: ReadonlySet<string> = new Set( |
| 3143 | 3196 | ); |
| 3144 | 3197 | |
| 3145 | 3198 | /** |
| 3146 | * A thread event whose type this build does not know. Newer servers add event | |
| 3147 | * types; older clients decode them to this case and skip them, still advancing | |
| 3148 | * their resume cursor, instead of failing the whole subscription. A known type | |
| 3149 | * whose payload does not decode still fails. Decode-only: servers never send it. | |
| 3199 | * A thread event whose type this build does not know, or a turn-item.updated | |
| 3200 | * carrying a turn item type it does not know. Newer servers add both; older | |
| 3201 | * clients decode them to this case and skip them, still advancing their resume | |
| 3202 | * cursor, instead of failing the whole subscription. A known type whose payload | |
| 3203 | * does not decode still fails. Decode-only: servers never send it. | |
| 3150 | 3204 | */ |
| 3151 | 3205 | const OrchestrationV2UnknownThreadStreamEvent = Schema.Struct({ |
| 3152 | 3206 | kind: Schema.Literal("event"), |
| 3153 | 3207 | sequence: NonNegativeInt, |
| 3154 | 3208 | event: Schema.Struct({ |
| 3155 | type: Schema.String.check( | |
| 3156 | Schema.makeFilter( | |
| 3157 | (type: string) => | |
| 3158 | !knownDomainEventTypes.has(type) || "A known event type must decode in full.", | |
| 3159 | ), | |
| 3209 | type: Schema.String, | |
| 3210 | payload: Schema.optional(Schema.Unknown), | |
| 3211 | }).check( | |
| 3212 | Schema.makeFilter( | |
| 3213 | (event) => | |
| 3214 | !knownDomainEventTypes.has(event.type) || | |
| 3215 | (event.type === "turn-item.updated" && isUnknownTurnItem(event.payload)) || | |
| 3216 | "A known event type must decode in full.", | |
| 3160 | 3217 | ), |
| 3161 | }), | |
| 3218 | ), | |
| 3162 | 3219 | }).pipe( |
| 3163 | 3220 | Schema.decodeTo( |
| 3164 | 3221 | Schema.Struct({ |