import { CommandId, EventId, ProjectId, ThreadId, type OrchestrationEvent, } from "@t3tools/contracts"; import { Effect } from "effect"; import { describe, expect, it } from "vitest"; import { createEmptyReadModel, projectEvent } from "./projector.ts"; function makeEvent(input: { sequence: number; type: OrchestrationEvent["type"]; occurredAt: string; aggregateKind: OrchestrationEvent["aggregateKind"]; aggregateId: string; commandId: string | null; payload: unknown; }): OrchestrationEvent { return { sequence: input.sequence, eventId: EventId.makeUnsafe(`event-${input.sequence}`), type: input.type, aggregateKind: input.aggregateKind, aggregateId: input.aggregateKind === "project" ? ProjectId.makeUnsafe(input.aggregateId) : ThreadId.makeUnsafe(input.aggregateId), occurredAt: input.occurredAt, commandId: input.commandId === null ? null : CommandId.makeUnsafe(input.commandId), causationEventId: null, correlationId: null, metadata: {}, payload: input.payload as never, } as OrchestrationEvent; } describe("orchestration projector", () => { it("applies thread.created events", async () => { const now = new Date().toISOString(); const model = createEmptyReadModel(now); const next = await Effect.runPromise( projectEvent( model, makeEvent({ sequence: 1, type: "thread.created", aggregateKind: "thread", aggregateId: "thread-1", occurredAt: now, commandId: "cmd-thread-create", payload: { threadId: "thread-1", projectId: "project-1", title: "demo", model: "gpt-5-codex", branch: null, worktreePath: null, createdAt: now, updatedAt: now, }, }), ), ); expect(next.snapshotSequence).toBe(1); expect(next.threads).toEqual([ { id: "thread-1", projectId: "project-1", title: "demo", model: "gpt-5-codex", branch: null, worktreePath: null, latestTurnId: null, createdAt: now, updatedAt: now, deletedAt: null, messages: [], activities: [], checkpoints: [], session: null, }, ]); }); it("fails when event payload cannot be decoded by runtime schema", async () => { const now = new Date().toISOString(); const model = createEmptyReadModel(now); await expect( Effect.runPromise( projectEvent( model, makeEvent({ sequence: 1, type: "thread.created", aggregateKind: "thread", aggregateId: "thread-1", occurredAt: now, commandId: "cmd-invalid", payload: { // missing required threadId projectId: "project-1", title: "demo", model: "gpt-5-codex", branch: null, worktreePath: null, createdAt: now, updatedAt: now, }, }), ), ), ).rejects.toBeDefined(); }); it("keeps projector forward-compatible for unhandled event types", async () => { const now = new Date().toISOString(); const model = createEmptyReadModel(now); const next = await Effect.runPromise( projectEvent( model, makeEvent({ sequence: 7, type: "thread.turn-start-requested", aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-01-01T00:00:00.000Z", commandId: "cmd-unhandled", payload: { threadId: "thread-1", messageId: "message-1", createdAt: "2026-01-01T00:00:00.000Z", }, }), ), ); expect(next.snapshotSequence).toBe(7); expect(next.updatedAt).toBe("2026-01-01T00:00:00.000Z"); expect(next.threads).toEqual([]); }); it("tracks latest turn id from session lifecycle events", async () => { const createdAt = "2026-02-23T08:00:00.000Z"; const startedAt = "2026-02-23T08:00:05.000Z"; const model = createEmptyReadModel(createdAt); const afterCreate = await Effect.runPromise( projectEvent( model, makeEvent({ sequence: 1, type: "thread.created", aggregateKind: "thread", aggregateId: "thread-1", occurredAt: createdAt, commandId: "cmd-create", payload: { threadId: "thread-1", projectId: "project-1", title: "demo", model: "gpt-5.3-codex", branch: null, worktreePath: null, createdAt, updatedAt: createdAt, }, }), ), ); const afterRunning = await Effect.runPromise( projectEvent( afterCreate, makeEvent({ sequence: 2, type: "thread.session-set", aggregateKind: "thread", aggregateId: "thread-1", occurredAt: startedAt, commandId: "cmd-running", payload: { threadId: "thread-1", session: { threadId: "thread-1", status: "running", providerName: "codex", providerSessionId: "session-1", providerThreadId: "provider-thread-1", activeTurnId: "turn-1", lastError: null, updatedAt: startedAt, }, }, }), ), ); const thread = afterRunning.threads[0]; expect(thread?.latestTurnId).toBe("turn-1"); expect(thread?.session?.status).toBe("running"); }); it("marks assistant messages completed with non-streaming updates", async () => { const createdAt = "2026-02-23T09:00:00.000Z"; const deltaAt = "2026-02-23T09:00:01.000Z"; const completeAt = "2026-02-23T09:00:03.500Z"; const model = createEmptyReadModel(createdAt); const afterCreate = await Effect.runPromise( projectEvent( model, makeEvent({ sequence: 1, type: "thread.created", aggregateKind: "thread", aggregateId: "thread-1", occurredAt: createdAt, commandId: "cmd-create", payload: { threadId: "thread-1", projectId: "project-1", title: "demo", model: "gpt-5.3-codex", branch: null, worktreePath: null, createdAt, updatedAt: createdAt, }, }), ), ); const afterDelta = await Effect.runPromise( projectEvent( afterCreate, makeEvent({ sequence: 2, type: "thread.message-sent", aggregateKind: "thread", aggregateId: "thread-1", occurredAt: deltaAt, commandId: "cmd-delta", payload: { threadId: "thread-1", messageId: "assistant:msg-1", role: "assistant", text: "hello", turnId: "turn-1", streaming: true, createdAt: deltaAt, updatedAt: deltaAt, }, }), ), ); const afterComplete = await Effect.runPromise( projectEvent( afterDelta, makeEvent({ sequence: 3, type: "thread.message-sent", aggregateKind: "thread", aggregateId: "thread-1", occurredAt: completeAt, commandId: "cmd-complete", payload: { threadId: "thread-1", messageId: "assistant:msg-1", role: "assistant", text: "", turnId: "turn-1", streaming: false, createdAt: completeAt, updatedAt: completeAt, }, }), ), ); const message = afterComplete.threads[0]?.messages[0]; expect(message?.id).toBe("assistant:msg-1"); expect(message?.text).toBe("hello"); expect(message?.streaming).toBe(false); expect(message?.updatedAt).toBe(completeAt); }); it("prunes reverted turn messages from in-memory thread snapshot", async () => { const createdAt = "2026-02-23T10:00:00.000Z"; const model = createEmptyReadModel(createdAt); const afterCreate = await Effect.runPromise( projectEvent( model, makeEvent({ sequence: 1, type: "thread.created", aggregateKind: "thread", aggregateId: "thread-1", occurredAt: createdAt, commandId: "cmd-create", payload: { threadId: "thread-1", projectId: "project-1", title: "demo", model: "gpt-5.3-codex", branch: null, worktreePath: null, createdAt, updatedAt: createdAt, }, }), ), ); const events: ReadonlyArray = [ makeEvent({ sequence: 2, type: "thread.message-sent", aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-02-23T10:00:01.000Z", commandId: "cmd-user-1", payload: { threadId: "thread-1", messageId: "user-msg-1", role: "user", text: "First edit", turnId: null, streaming: false, createdAt: "2026-02-23T10:00:01.000Z", updatedAt: "2026-02-23T10:00:01.000Z", }, }), makeEvent({ sequence: 3, type: "thread.message-sent", aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-02-23T10:00:02.000Z", commandId: "cmd-assistant-1", payload: { threadId: "thread-1", messageId: "assistant-msg-1", role: "assistant", text: "Updated README to v2.\n", turnId: "turn-1", streaming: false, createdAt: "2026-02-23T10:00:02.000Z", updatedAt: "2026-02-23T10:00:02.000Z", }, }), makeEvent({ sequence: 4, type: "thread.turn-diff-completed", aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-02-23T10:00:02.500Z", commandId: "cmd-turn-1-complete", payload: { threadId: "thread-1", turnId: "turn-1", checkpointTurnCount: 1, checkpointRef: "refs/t3/checkpoints/thread-1/turn/1", status: "ready", files: [], assistantMessageId: "assistant-msg-1", completedAt: "2026-02-23T10:00:02.500Z", }, }), makeEvent({ sequence: 5, type: "thread.activity-appended", aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-02-23T10:00:02.750Z", commandId: "cmd-activity-1", payload: { threadId: "thread-1", activity: { id: "activity-1", tone: "tool", kind: "tool.started", summary: "Edit file started", payload: { toolKind: "command" }, turnId: "turn-1", createdAt: "2026-02-23T10:00:02.750Z", }, }, }), makeEvent({ sequence: 6, type: "thread.message-sent", aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-02-23T10:00:03.000Z", commandId: "cmd-user-2", payload: { threadId: "thread-1", messageId: "user-msg-2", role: "user", text: "Second edit", turnId: null, streaming: false, createdAt: "2026-02-23T10:00:03.000Z", updatedAt: "2026-02-23T10:00:03.000Z", }, }), makeEvent({ sequence: 7, type: "thread.message-sent", aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-02-23T10:00:04.000Z", commandId: "cmd-assistant-2", payload: { threadId: "thread-1", messageId: "assistant-msg-2", role: "assistant", text: "Updated README to v3.\n", turnId: "turn-2", streaming: false, createdAt: "2026-02-23T10:00:04.000Z", updatedAt: "2026-02-23T10:00:04.000Z", }, }), makeEvent({ sequence: 8, type: "thread.turn-diff-completed", aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-02-23T10:00:04.500Z", commandId: "cmd-turn-2-complete", payload: { threadId: "thread-1", turnId: "turn-2", checkpointTurnCount: 2, checkpointRef: "refs/t3/checkpoints/thread-1/turn/2", status: "ready", files: [], assistantMessageId: "assistant-msg-2", completedAt: "2026-02-23T10:00:04.500Z", }, }), makeEvent({ sequence: 9, type: "thread.activity-appended", aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-02-23T10:00:04.750Z", commandId: "cmd-activity-2", payload: { threadId: "thread-1", activity: { id: "activity-2", tone: "tool", kind: "tool.completed", summary: "Edit file complete", payload: { toolKind: "command" }, turnId: "turn-2", createdAt: "2026-02-23T10:00:04.750Z", }, }, }), makeEvent({ sequence: 10, type: "thread.reverted", aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-02-23T10:00:05.000Z", commandId: "cmd-revert", payload: { threadId: "thread-1", turnCount: 1, }, }), ]; const afterRevert = await events.reduce>>( (statePromise, event) => statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), Promise.resolve(afterCreate), ); const thread = afterRevert.threads[0]; expect(thread?.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual( [ { role: "user", text: "First edit" }, { role: "assistant", text: "Updated README to v2.\n" }, ], ); expect( thread?.activities.map((activity) => ({ id: activity.id, turnId: activity.turnId })), ).toEqual([{ id: "activity-1", turnId: "turn-1" }]); expect(thread?.checkpoints.map((checkpoint) => checkpoint.checkpointTurnCount)).toEqual([1]); expect(thread?.latestTurnId).toBe("turn-1"); }); it("does not fallback-retain messages tied to removed turn IDs", async () => { const createdAt = "2026-02-26T12:00:00.000Z"; const model = createEmptyReadModel(createdAt); const afterCreate = await Effect.runPromise( projectEvent( model, makeEvent({ sequence: 1, type: "thread.created", aggregateKind: "thread", aggregateId: "thread-revert", occurredAt: createdAt, commandId: "cmd-create-revert", payload: { threadId: "thread-revert", projectId: "project-1", title: "demo", model: "gpt-5.3-codex", branch: null, worktreePath: null, createdAt, updatedAt: createdAt, }, }), ), ); const events: ReadonlyArray = [ makeEvent({ sequence: 2, type: "thread.turn-diff-completed", aggregateKind: "thread", aggregateId: "thread-revert", occurredAt: "2026-02-26T12:00:01.000Z", commandId: "cmd-turn-1", payload: { threadId: "thread-revert", turnId: "turn-1", checkpointTurnCount: 1, checkpointRef: "refs/t3/checkpoints/thread-revert/turn/1", status: "ready", files: [], assistantMessageId: "assistant-keep", completedAt: "2026-02-26T12:00:01.000Z", }, }), makeEvent({ sequence: 3, type: "thread.message-sent", aggregateKind: "thread", aggregateId: "thread-revert", occurredAt: "2026-02-26T12:00:01.100Z", commandId: "cmd-assistant-keep", payload: { threadId: "thread-revert", messageId: "assistant-keep", role: "assistant", text: "kept", turnId: "turn-1", streaming: false, createdAt: "2026-02-26T12:00:01.100Z", updatedAt: "2026-02-26T12:00:01.100Z", }, }), makeEvent({ sequence: 4, type: "thread.turn-diff-completed", aggregateKind: "thread", aggregateId: "thread-revert", occurredAt: "2026-02-26T12:00:02.000Z", commandId: "cmd-turn-2", payload: { threadId: "thread-revert", turnId: "turn-2", checkpointTurnCount: 2, checkpointRef: "refs/t3/checkpoints/thread-revert/turn/2", status: "ready", files: [], assistantMessageId: "assistant-remove", completedAt: "2026-02-26T12:00:02.000Z", }, }), makeEvent({ sequence: 5, type: "thread.message-sent", aggregateKind: "thread", aggregateId: "thread-revert", occurredAt: "2026-02-26T12:00:02.050Z", commandId: "cmd-user-remove", payload: { threadId: "thread-revert", messageId: "user-remove", role: "user", text: "removed", turnId: "turn-2", streaming: false, createdAt: "2026-02-26T12:00:02.050Z", updatedAt: "2026-02-26T12:00:02.050Z", }, }), makeEvent({ sequence: 6, type: "thread.message-sent", aggregateKind: "thread", aggregateId: "thread-revert", occurredAt: "2026-02-26T12:00:02.100Z", commandId: "cmd-assistant-remove", payload: { threadId: "thread-revert", messageId: "assistant-remove", role: "assistant", text: "removed", turnId: "turn-2", streaming: false, createdAt: "2026-02-26T12:00:02.100Z", updatedAt: "2026-02-26T12:00:02.100Z", }, }), makeEvent({ sequence: 7, type: "thread.reverted", aggregateKind: "thread", aggregateId: "thread-revert", occurredAt: "2026-02-26T12:00:03.000Z", commandId: "cmd-revert", payload: { threadId: "thread-revert", turnCount: 1, }, }), ]; const afterRevert = await events.reduce>>( (statePromise, event) => statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), Promise.resolve(afterCreate), ); const thread = afterRevert.threads[0]; expect( thread?.messages.map((message) => ({ id: message.id, role: message.role, turnId: message.turnId, })), ).toEqual([{ id: "assistant-keep", role: "assistant", turnId: "turn-1" }]); }); it("caps message and checkpoint retention for long-lived threads", async () => { const createdAt = "2026-03-01T10:00:00.000Z"; const model = createEmptyReadModel(createdAt); const afterCreate = await Effect.runPromise( projectEvent( model, makeEvent({ sequence: 1, type: "thread.created", aggregateKind: "thread", aggregateId: "thread-capped", occurredAt: createdAt, commandId: "cmd-create-capped", payload: { threadId: "thread-capped", projectId: "project-1", title: "capped", model: "gpt-5-codex", branch: null, worktreePath: null, createdAt, updatedAt: createdAt, }, }), ), ); const messageEvents: ReadonlyArray = Array.from( { length: 2_100 }, (_, index) => makeEvent({ sequence: index + 2, type: "thread.message-sent", aggregateKind: "thread", aggregateId: "thread-capped", occurredAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, commandId: `cmd-message-${index}`, payload: { threadId: "thread-capped", messageId: `msg-${index}`, role: "assistant", text: `message-${index}`, turnId: `turn-${index}`, streaming: false, createdAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, updatedAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, }, }), ); const afterMessages = await messageEvents.reduce>>( (statePromise, event) => statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), Promise.resolve(afterCreate), ); const checkpointEvents: ReadonlyArray = Array.from( { length: 600 }, (_, index) => makeEvent({ sequence: index + 2_102, type: "thread.turn-diff-completed", aggregateKind: "thread", aggregateId: "thread-capped", occurredAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, commandId: `cmd-checkpoint-${index}`, payload: { threadId: "thread-capped", turnId: `turn-${index}`, checkpointTurnCount: index + 1, checkpointRef: `refs/t3/checkpoints/thread-capped/turn/${index + 1}`, status: "ready", files: [], assistantMessageId: `msg-${index}`, completedAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, }, }), ); const finalState = await checkpointEvents.reduce>>( (statePromise, event) => statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), Promise.resolve(afterMessages), ); const thread = finalState.threads[0]; expect(thread?.messages).toHaveLength(2_000); expect(thread?.messages[0]?.id).toBe("msg-100"); expect(thread?.messages.at(-1)?.id).toBe("msg-2099"); expect(thread?.checkpoints).toHaveLength(500); expect(thread?.checkpoints[0]?.turnId).toBe("turn-100"); expect(thread?.checkpoints.at(-1)?.turnId).toBe("turn-599"); }); });