From 94e415c94dfdadd4c5559a616cb2a7558ab44e6a Mon Sep 17 00:00:00 2001 From: Mnigos Date: Sun, 20 Sep 2026 20:07:12 +0200 Subject: [PATCH] fix(contracts): old message-sent events without turnId no longer stop the server from starting `thread.message-sent` events written before `turnId` existed have no key at all, and the payload schema required it. One such row fails the event store read with "Missing key", the server exits during startup, and the desktop app restarts it forever without opening a window. The field now decodes a missing key as null, the same way the other fields added after events were already persisted do. --- packages/contracts/src/orchestration.test.ts | 31 ++++++++++++++++++++ packages/contracts/src/orchestration.ts | 3 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index d89e1fb2957c..7c7bade4502e 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -812,6 +812,37 @@ it.effect("decodes thread pull request links with snapshot and stack", () => }), ); +// A stored event that fails to decode stops the event store read, and with it +// server startup, so rows written before `turnId` existed must still load. +it.effect("decodes a legacy message-sent event persisted without turnId", () => + Effect.gen(function* () { + const event = yield* decodeOrchestrationEvent({ + sequence: 539, + eventId: "event-message-legacy-1", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.message-sent", + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: "cmd-message-legacy-1", + causationEventId: null, + correlationId: "cmd-message-legacy-1", + metadata: {}, + payload: { + threadId: "thread-1", + messageId: "message-1", + role: "user", + text: "written before turn ids were recorded", + streaming: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + assert.strictEqual(event.type, "thread.message-sent"); + if (event.type !== "thread.message-sent") return; + assert.strictEqual(event.payload.turnId, null); + }), +); + it.effect("decodes thread archived and unarchived events", () => Effect.gen(function* () { const archived = yield* decodeOrchestrationEvent({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 6ca7e25ca493..2bed7eaf006f 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1873,7 +1873,8 @@ export const ThreadMessageSentPayload = Schema.Struct({ text: Schema.String, attachments: Schema.optional(Schema.Array(ChatAttachment)), context: Schema.optional(OrchestrationMessageContext), - turnId: Schema.NullOr(TurnId), + // Events persisted before the field existed carry no key at all. + turnId: Schema.NullOr(TurnId).pipe(Schema.withDecodingDefault(Effect.succeed(null))), streaming: Schema.Boolean, createdAt: IsoDateTime, updatedAt: IsoDateTime,