286 lines · 10.0 KB · TypeScript
RawDownload
import {  CommandId,  EventId,  MessageId,  ProjectId,  ProviderInstanceId,  ThreadId,  type OrchestrationReadModel,  type OrchestrationThread,} from "@t3tools/contracts";import * as NodeServices from "@effect/platform-node/NodeServices";import { expect, it } from "@effect/vitest";import * as Effect from "effect/Effect"; import { decideOrchestrationCommand } from "./decider.ts"; const NOW = "2026-01-01T00:00:00.000Z";// The decider's clock is the Effect test clock, pinned to the epoch, so// "future" wake times are relative to 1970-01-01T00:00:00.000Z.const FUTURE_WAKE = "1970-01-02T09:00:00.000Z";const PAST_WAKE = "1969-12-31T09:00:00.000Z";const SNOOZED_AT = "1969-12-30T00:00:00.000Z"; function makeReadModel(input: {  readonly snoozedUntil?: string | null;  readonly snoozedAt?: string | null;  readonly archivedAt?: string | null;  readonly activities?: OrchestrationThread["activities"];  readonly messages?: OrchestrationThread["messages"];}): OrchestrationReadModel {  return {    snapshotSequence: 0,    projects: [],    threads: [      {        id: ThreadId.make("thread-1"),        projectId: ProjectId.make("project-1"),        title: "Thread",        modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },        runtimeMode: "full-access",        interactionMode: "default",        branch: null,        worktreePath: null,        latestTurn: null,        createdAt: NOW,        updatedAt: NOW,        archivedAt: input.archivedAt ?? null,        settledOverride: null,        settledAt: null,        snoozedUntil: input.snoozedUntil ?? null,        snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? SNOOZED_AT : null),        deletedAt: null,        messages: input.messages ?? [],        proposedPlans: [],        activities: input.activities ?? [],        checkpoints: [],        session: null,      },    ],    updatedAt: NOW,  };} it.layer(NodeServices.layer)("snoozed thread decider", (it) => {  it.effect("snoozes a thread to a future wake time", () =>    Effect.gen(function* () {      const event = yield* decideOrchestrationCommand({        command: {          type: "thread.snooze",          commandId: CommandId.make("cmd-snooze"),          threadId: ThreadId.make("thread-1"),          snoozedUntil: FUTURE_WAKE,        },        readModel: makeReadModel({}),      });      const events = Array.isArray(event) ? event : [event];      expect(events).toHaveLength(1);      expect(events[0]?.type).toBe("thread.snoozed");      if (events[0]?.type === "thread.snoozed") {        expect(events[0].payload.snoozedUntil).toBe(FUTURE_WAKE);        expect(events[0].payload.snoozedAt).toBe(events[0].payload.updatedAt);      }    }),  );   it.effect("rejects a wake time that is not in the future", () =>    Effect.gen(function* () {      const error = yield* decideOrchestrationCommand({        command: {          type: "thread.snooze",          commandId: CommandId.make("cmd-snooze-past"),          threadId: ThreadId.make("thread-1"),          snoozedUntil: PAST_WAKE,        },        readModel: makeReadModel({}),      }).pipe(Effect.flip);      expect(error._tag).toBe("OrchestrationCommandInvariantError");    }),  );   it.effect("rejects an unparseable wake time", () =>    Effect.gen(function* () {      // IsoDateTime is structurally a string, so garbage can reach the      // decider; a NaN wake time must never persist as snooze state.      const error = yield* decideOrchestrationCommand({        command: {          type: "thread.snooze",          commandId: CommandId.make("cmd-snooze-garbage"),          threadId: ThreadId.make("thread-1"),          snoozedUntil: "not-a-date",        },        readModel: makeReadModel({}),      }).pipe(Effect.flip);      expect(error._tag).toBe("OrchestrationCommandInvariantError");    }),  );   it.effect("rejects snoozing blocked-on-you work", () =>    Effect.gen(function* () {      const requestActivity = {        id: EventId.make("activity-req-1"),        tone: "approval" as const,        kind: "approval.requested",        summary: "approval.requested",        payload: { requestId: "req-1" },        turnId: null,        createdAt: NOW,      } as OrchestrationThread["activities"][number];      const error = yield* decideOrchestrationCommand({        command: {          type: "thread.snooze",          commandId: CommandId.make("cmd-snooze-blocked"),          threadId: ThreadId.make("thread-1"),          snoozedUntil: FUTURE_WAKE,        },        readModel: makeReadModel({ activities: [requestActivity] }),      }).pipe(Effect.flip);      expect(error._tag).toBe("OrchestrationCommandInvariantError");    }),  );   it.effect("re-emits idempotently for a duplicate snooze to the same wake time", () =>    Effect.gen(function* () {      const reEmit = yield* decideOrchestrationCommand({        command: {          type: "thread.snooze",          commandId: CommandId.make("cmd-snooze-again"),          threadId: ThreadId.make("thread-1"),          snoozedUntil: FUTURE_WAKE,        },        readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }),      });      const events = Array.isArray(reEmit) ? reEmit : [reEmit];      expect(events).toHaveLength(1);      if (events[0]?.type === "thread.snoozed") {        // Original snoozedAt preserved; updatedAt must not churn.        expect(events[0].payload.snoozedAt).toBe(SNOOZED_AT);        expect(events[0].payload.updatedAt).toBe(NOW);      }    }),  );   it.effect("re-snoozing to a DIFFERENT wake time stamps fresh", () =>    Effect.gen(function* () {      const event = yield* decideOrchestrationCommand({        command: {          type: "thread.snooze",          commandId: CommandId.make("cmd-snooze-extend"),          threadId: ThreadId.make("thread-1"),          snoozedUntil: "1970-01-03T09:00:00.000Z",        },        readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }),      });      const events = Array.isArray(event) ? event : [event];      if (events[0]?.type === "thread.snoozed") {        expect(events[0].payload.snoozedUntil).toBe("1970-01-03T09:00:00.000Z");        expect(events[0].payload.updatedAt).not.toBe(NOW);      }    }),  );   it.effect("unsnoozes with reason user and re-emits idempotently when awake", () =>    Effect.gen(function* () {      const event = yield* decideOrchestrationCommand({        command: {          type: "thread.unsnooze",          commandId: CommandId.make("cmd-unsnooze"),          threadId: ThreadId.make("thread-1"),          reason: "user",        },        readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }),      });      const events = Array.isArray(event) ? event : [event];      expect(events[0]?.type).toBe("thread.unsnoozed");      if (events[0]?.type === "thread.unsnoozed") {        expect(events[0].payload.reason).toBe("user");        expect(events[0].payload.updatedAt).not.toBe(NOW);      }       const awake = yield* decideOrchestrationCommand({        command: {          type: "thread.unsnooze",          commandId: CommandId.make("cmd-unsnooze-awake"),          threadId: ThreadId.make("thread-1"),          reason: "user",        },        readModel: makeReadModel({}),      });      const awakeEvents = Array.isArray(awake) ? awake : [awake];      expect(awakeEvents[0]?.type).toBe("thread.unsnoozed");      if (awakeEvents[0]?.type === "thread.unsnoozed") {        // No state change — keep the existing updatedAt.        expect(awakeEvents[0].payload.updatedAt).toBe(NOW);      }    }),  );   it.effect("rejects snoozing a thread with a queued turn start", () =>    Effect.gen(function* () {      // The decider clock is the Effect test clock pinned to the epoch: a      // user message 30s before it with no adopting turn is queued work.      const queuedMessage = {        id: MessageId.make("message-queued"),        role: "user",        text: "Continue",        turnId: null,        streaming: false,        createdAt: "1969-12-31T23:59:30.000Z",        updatedAt: "1969-12-31T23:59:30.000Z",      } as OrchestrationThread["messages"][number];      const error = yield* decideOrchestrationCommand({        command: {          type: "thread.snooze",          commandId: CommandId.make("cmd-snooze-queued"),          threadId: ThreadId.make("thread-1"),          snoozedUntil: FUTURE_WAKE,        },        readModel: makeReadModel({ messages: [queuedMessage] }),      }).pipe(Effect.flip);      expect(error._tag).toBe("OrchestrationCommandInvariantError");    }),  );   it.effect("rejects snoozing an archived thread", () =>    Effect.gen(function* () {      const error = yield* decideOrchestrationCommand({        command: {          type: "thread.snooze",          commandId: CommandId.make("cmd-snooze-archived"),          threadId: ThreadId.make("thread-1"),          snoozedUntil: FUTURE_WAKE,        },        readModel: makeReadModel({ archivedAt: NOW }),      }).pipe(Effect.flip);      expect(error._tag).toBe("OrchestrationCommandInvariantError");    }),  );   it.effect("a user message spends the snooze return ticket (activity wake)", () =>    Effect.gen(function* () {      const result = yield* decideOrchestrationCommand({        command: {          type: "thread.turn.start",          commandId: CommandId.make("cmd-turn-start"),          threadId: ThreadId.make("thread-1"),          message: {            messageId: MessageId.make("message-1"),            role: "user",            text: "Continue",            attachments: [],          },          runtimeMode: "full-access",          interactionMode: "default",          createdAt: NOW,        },        readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }),      });      const events = Array.isArray(result) ? result : [result];      const unsnoozed = events.find((entry) => entry.type === "thread.unsnoozed");      expect(unsnoozed).toBeDefined();      if (unsnoozed?.type === "thread.unsnoozed") {        expect(unsnoozed.payload.reason).toBe("activity");      }    }),  );});