| 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,7 +329,10 @@ it.effect.each( |
| 329 | assert.equal(wire.messages[0]?.text, task.prompt); | 329 | assert.equal(wire.messages[0]?.text, task.prompt); |
| 330 | assert.equal(wire.messages[0]?.scheduledTaskId, task.id); | 330 | assert.equal(wire.messages[0]?.scheduledTaskId, task.id); |
| 331 | assert.equal(wire.messages[0]?.createdBy, createdBy); | 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 | assert.equal(turnItem?.text, task.prompt); | 336 | assert.equal(turnItem?.text, task.prompt); |
| 334 | assert.equal(turnItem?.scheduledTaskId, task.id); | 337 | assert.equal(turnItem?.scheduledTaskId, task.id); |
| 335 | }).pipe(Effect.provide(Layer.mergeAll(harness.layer, scheduledTasks))); | 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,6 +1,7 @@ |
| 1 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Option from "effect/Option"; | 2 | import * as Option from "effect/Option"; |
| 3 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 4 | import * as SchemaGetter from "effect/SchemaGetter"; | ||
| 4 | import * as SchemaTransformation from "effect/SchemaTransformation"; | 5 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 5 | 6 | ||
| 6 | export const TrimmedString = Schema.String.pipe( | 7 | export const TrimmedString = Schema.String.pipe( |
| ... | @@ -35,14 +36,6 @@ export type DpopFailureReason = typeof DpopFailureReason.Type; | ... | @@ -35,14 +36,6 @@ export type DpopFailureReason = typeof DpopFailureReason.Type; |
| 35 | export const IsoDateTime = Schema.String; | 36 | export const IsoDateTime = Schema.String; |
| 36 | export type IsoDateTime = typeof IsoDateTime.Type; | 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 | * Same idea for one optional value whose literal set grows over time: a | 40 | * Same idea for one optional value whose literal set grows over time: a |
| 48 | * member this build does not know decodes as absent rather than failing the | 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,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) => { | 103 | /** |
| 111 | const decodeElement = Schema.decodeUnknownOption(element as never); | 104 | * Wire codec for a server→client value whose shape grows over time: a union |
| 112 | return Schema.Array(Schema.Unknown).pipe( | 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 | Schema.decodeTo( | 237 | Schema.decodeTo( |
| 114 | Schema.Array(element), | 238 | Schema.Array(Schema.toType(Schema.Union(members))), |
| 115 | SchemaTransformation.transform<ReadonlyArray<Element["Encoded"]>, ReadonlyArray<unknown>>({ | 239 | SchemaTransformation.transform< |
| 240 | ReadonlyArray<Members[number]["Type"]>, | ||
| 241 | ReadonlyArray<Members[number]["Type"] | UnknownUnionMember<Tag>> | ||
| 242 | >({ | ||
| 116 | decode: (values) => | 243 | decode: (values) => |
| 117 | values.filter((value) => Option.isSome(decodeElement(value))) as ReadonlyArray< | 244 | values.filter((value) => !isUnknownUnionMember(value)) as ReadonlyArray< |
| 118 | Element["Encoded"] | 245 | Members[number]["Type"] |
| 119 | >, | 246 | >, |
| 120 | encode: (values) => values, | 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,6 +34,7 @@ import { |
| 34 | OrchestrationV2ShellSnapshot, | 34 | OrchestrationV2ShellSnapshot, |
| 35 | OrchestrationV2SubscribeThreadInput, | 35 | OrchestrationV2SubscribeThreadInput, |
| 36 | OrchestrationV2Subagent, | 36 | OrchestrationV2Subagent, |
| 37 | OrchestrationV2ThreadHistoryPage, | ||
| 37 | OrchestrationV2ThreadProjection, | 38 | OrchestrationV2ThreadProjection, |
| 38 | OrchestrationV2ThreadStreamItem, | 39 | OrchestrationV2ThreadStreamItem, |
| 39 | OrchestrationV2ThreadShell, | 40 | OrchestrationV2ThreadShell, |
| ... | @@ -175,6 +176,134 @@ describe("orchestration V2 contracts", () => { | ... | @@ -175,6 +176,134 @@ describe("orchestration V2 contracts", () => { |
| 175 | ).toThrow(); | 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 | it("negotiates bounded socket snapshots as an optional capability", () => { | 307 | it("negotiates bounded socket snapshots as an optional capability", () => { |
| 179 | expect( | 308 | expect( |
| 180 | decodeOrchestrationV2SubscribeThreadInput({ | 309 | decodeOrchestrationV2SubscribeThreadInput({ |
packages/contracts/src/orchestrationV2.ts+72-15| ... | @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; |
| 3 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 4 | import * as SchemaAST from "effect/SchemaAST"; | 4 | import * as SchemaAST from "effect/SchemaAST"; |
| 5 | import * as SchemaGetter from "effect/SchemaGetter"; | 5 | import * as SchemaGetter from "effect/SchemaGetter"; |
| 6 | import * as SchemaTransformation from "effect/SchemaTransformation"; | ||
| 6 | 7 | ||
| 7 | import { | 8 | import { |
| 8 | CheckpointId, | 9 | CheckpointId, |
| ... | @@ -15,6 +16,10 @@ import { | ... | @@ -15,6 +16,10 @@ import { |
| 15 | IsoDateTime, | 16 | IsoDateTime, |
| 16 | MessageId, | 17 | MessageId, |
| 17 | NodeId, | 18 | NodeId, |
| 19 | ForwardCompatibleUnion, | ||
| 20 | ForwardCompatibleUnionArray, | ||
| 21 | hasUnknownUnionTag, | ||
| 22 | isUnknownUnionMember, | ||
| 18 | NonNegativeInt, | 23 | NonNegativeInt, |
| 19 | PlanId, | 24 | PlanId, |
| 20 | PositiveInt, | 25 | PositiveInt, |
| ... | @@ -1494,6 +1499,36 @@ export const OrchestrationV2TurnItem = Schema.Union([ | ... | @@ -1494,6 +1499,36 @@ export const OrchestrationV2TurnItem = Schema.Union([ |
| 1494 | ]); | 1499 | ]); |
| 1495 | export type OrchestrationV2TurnItem = typeof OrchestrationV2TurnItem.Type; | 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 | export const OrchestrationV2ProjectedTurnItem = Schema.Struct({ | 1532 | export const OrchestrationV2ProjectedTurnItem = Schema.Struct({ |
| 1498 | position: NonNegativeInt, | 1533 | position: NonNegativeInt, |
| 1499 | visibility: Schema.Literals(["local", "inherited", "synthetic"]), | 1534 | visibility: Schema.Literals(["local", "inherited", "synthetic"]), |
| ... | @@ -1681,12 +1716,18 @@ export const OrchestrationV2ThreadProjection = Schema.Struct({ | ... | @@ -1681,12 +1716,18 @@ export const OrchestrationV2ThreadProjection = Schema.Struct({ |
| 1681 | runtimeRequests: Schema.Array(OrchestrationV2RuntimeRequest), | 1716 | runtimeRequests: Schema.Array(OrchestrationV2RuntimeRequest), |
| 1682 | messages: Schema.Array(OrchestrationV2ConversationMessage), | 1717 | messages: Schema.Array(OrchestrationV2ConversationMessage), |
| 1683 | plans: Schema.Array(OrchestrationV2PlanArtifact), | 1718 | plans: Schema.Array(OrchestrationV2PlanArtifact), |
| 1684 | turnItems: Schema.Array(OrchestrationV2TurnItem), | 1719 | turnItems: turnItemArray(OrchestrationV2TurnItem), |
| 1685 | checkpointScopes: Schema.Array(OrchestrationV2CheckpointScope), | 1720 | checkpointScopes: Schema.Array(OrchestrationV2CheckpointScope), |
| 1686 | checkpoints: Schema.Array(OrchestrationV2Checkpoint), | 1721 | checkpoints: Schema.Array(OrchestrationV2Checkpoint), |
| 1687 | contextHandoffs: Schema.Array(OrchestrationV2ContextHandoff), | 1722 | contextHandoffs: Schema.Array(OrchestrationV2ContextHandoff), |
| 1688 | contextTransfers: Schema.Array(OrchestrationV2ContextTransfer), | 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 | updatedAt: Schema.DateTimeUtc, | 1731 | updatedAt: Schema.DateTimeUtc, |
| 1691 | }); | 1732 | }); |
| 1692 | export type OrchestrationV2ThreadProjection = typeof OrchestrationV2ThreadProjection.Type; | 1733 | export type OrchestrationV2ThreadProjection = typeof OrchestrationV2ThreadProjection.Type; |
| ... | @@ -2249,12 +2290,18 @@ export const OrchestrationV2ThreadProjectionJson = OrchestrationV2ThreadProjecti | ... | @@ -2249,12 +2290,18 @@ export const OrchestrationV2ThreadProjectionJson = OrchestrationV2ThreadProjecti |
| 2249 | runtimeRequests: Schema.Array(OrchestrationV2RuntimeRequestJson), | 2290 | runtimeRequests: Schema.Array(OrchestrationV2RuntimeRequestJson), |
| 2250 | messages: Schema.Array(OrchestrationV2ConversationMessageJson), | 2291 | messages: Schema.Array(OrchestrationV2ConversationMessageJson), |
| 2251 | plans: Schema.Array(OrchestrationV2PlanArtifact), | 2292 | plans: Schema.Array(OrchestrationV2PlanArtifact), |
| 2252 | turnItems: Schema.Array(OrchestrationV2TurnItemJson), | 2293 | turnItems: turnItemArray(OrchestrationV2TurnItemJson), |
| 2253 | checkpointScopes: Schema.Array(OrchestrationV2CheckpointScopeJson), | 2294 | checkpointScopes: Schema.Array(OrchestrationV2CheckpointScopeJson), |
| 2254 | checkpoints: Schema.Array(OrchestrationV2CheckpointJson), | 2295 | checkpoints: Schema.Array(OrchestrationV2CheckpointJson), |
| 2255 | contextHandoffs: Schema.Array(OrchestrationV2ContextHandoffJson), | 2296 | contextHandoffs: Schema.Array(OrchestrationV2ContextHandoffJson), |
| 2256 | contextTransfers: Schema.Array(OrchestrationV2ContextTransferJson), | 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 | updatedAt: Schema.DateTimeUtcFromString, | 2305 | updatedAt: Schema.DateTimeUtcFromString, |
| 2259 | }), | 2306 | }), |
| 2260 | ); | 2307 | ); |
| ... | @@ -3129,7 +3176,13 @@ export type OrchestrationV2ThreadBoundedSnapshot = typeof OrchestrationV2ThreadB | ... | @@ -3129,7 +3176,13 @@ export type OrchestrationV2ThreadBoundedSnapshot = typeof OrchestrationV2ThreadB |
| 3129 | /** Older timeline page for progressive history. Rows are chronological. */ | 3176 | /** Older timeline page for progressive history. Rows are chronological. */ |
| 3130 | export const OrchestrationV2ThreadHistoryPage = Schema.Struct({ | 3177 | export const OrchestrationV2ThreadHistoryPage = Schema.Struct({ |
| 3131 | snapshotSequence: NonNegativeInt, | 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 | nextCursor: Schema.NullOr(TrimmedNonEmptyString), | 3186 | nextCursor: Schema.NullOr(TrimmedNonEmptyString), |
| 3134 | hasMoreHistory: Schema.Boolean, | 3187 | hasMoreHistory: Schema.Boolean, |
| 3135 | }); | 3188 | }); |
| ... | @@ -3143,22 +3196,26 @@ const knownDomainEventTypes: ReadonlySet<string> = new Set( | ... | @@ -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 | 3199 | * A thread event whose type this build does not know, or a turn-item.updated |
| 3147 | * types; older clients decode them to this case and skip them, still advancing | 3200 | * carrying a turn item type it does not know. Newer servers add both; older |
| 3148 | * their resume cursor, instead of failing the whole subscription. A known type | 3201 | * clients decode them to this case and skip them, still advancing their resume |
| 3149 | * whose payload does not decode still fails. Decode-only: servers never send it. | 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 | const OrchestrationV2UnknownThreadStreamEvent = Schema.Struct({ | 3205 | const OrchestrationV2UnknownThreadStreamEvent = Schema.Struct({ |
| 3152 | kind: Schema.Literal("event"), | 3206 | kind: Schema.Literal("event"), |
| 3153 | sequence: NonNegativeInt, | 3207 | sequence: NonNegativeInt, |
| 3154 | event: Schema.Struct({ | 3208 | event: Schema.Struct({ |
| 3155 | type: Schema.String.check( | 3209 | type: Schema.String, |
| 3156 | Schema.makeFilter( | 3210 | payload: Schema.optional(Schema.Unknown), |
| 3157 | (type: string) => | 3211 | }).check( |
| 3158 | !knownDomainEventTypes.has(type) || "A known event type must decode in full.", | 3212 | Schema.makeFilter( |
| 3159 | ), | 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 | }).pipe( | 3219 | }).pipe( |
| 3163 | Schema.decodeTo( | 3220 | Schema.decodeTo( |
| 3164 | Schema.Struct({ | 3221 | Schema.Struct({ |