853 lines · 31.9 KB · TypeScript
RawDownload
import {  CommandId,  EventId,  MessageId,  ProjectId,  ProviderInstanceId,  ThreadId,  type OrchestrationEvent,  type OrchestrationReadModel,  type OrchestrationSession,  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";import { projectEvent } from "./projector.ts"; const NOW = "2026-01-01T00:00:00.000Z";const SETTLED_AT = "2025-12-30T00:00:00.000Z";const SETTLE_BLOCKED_MESSAGE =  "This thread still needs attention. Resolve or interrupt it first, then try again."; function makeReadModel(  settledOverride: OrchestrationThread["settledOverride"],  archivedAt: string | null = null,  session: OrchestrationSession | null = null,  activities: OrchestrationThread["activities"] = [],  messages: OrchestrationThread["messages"] = [],  lifecycle: {    readonly pinnedAt?: string | null;    readonly snoozedUntil?: string | null;    readonly snoozedAt?: string | null;  } = {},): 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,        pullRequests: [],        latestTurn: null,        createdAt: NOW,        updatedAt: NOW,        archivedAt,        settledOverride,        settledAt: settledOverride === "settled" ? SETTLED_AT : null,        snoozedUntil: lifecycle.snoozedUntil ?? null,        snoozedAt: lifecycle.snoozedAt ?? (lifecycle.snoozedUntil != null ? SETTLED_AT : null),        pinnedAt: lifecycle.pinnedAt ?? null,        deletedAt: null,        messages,        proposedPlans: [],        activities,        checkpoints: [],        session,      },    ],    updatedAt: NOW,  };} function makeSession(status: OrchestrationSession["status"]): OrchestrationSession {  return {    threadId: ThreadId.make("thread-1"),    status,    providerName: "Codex",    runtimeMode: "full-access",    activeTurnId: null,    lastError: null,    updatedAt: NOW,  };} it.layer(NodeServices.layer)("settled thread decider", (it) => {  it.effect("preserves the activity stamp when automatically settling", () =>    Effect.gen(function* () {      const result = yield* decideOrchestrationCommand({        command: {          type: "thread.auto-settle",          commandId: CommandId.make("cmd-auto-settle-inactive"),          threadId: ThreadId.make("thread-1"),          snapshotSequence: 0,          settledAt: SETTLED_AT,        },        readModel: makeReadModel(null),      });      const events = Array.isArray(result) ? result : [result];      const settled = events.find((event) => event.type === "thread.settled");      expect(settled?.payload.settledAt).toBe(SETTLED_AT);      // updatedAt stays the command time so the row still moves on settle.      expect(settled?.payload.updatedAt).toBe(settled?.occurredAt);      expect(settled?.payload.updatedAt).not.toBe(SETTLED_AT);    }),  );   it.effect("rejects an automatic settle when the thread is pinned active", () =>    Effect.gen(function* () {      const command = {        type: "thread.auto-settle" as const,        commandId: CommandId.make("cmd-auto-settle"),        threadId: ThreadId.make("thread-1"),        snapshotSequence: 0,        settledAt: SETTLED_AT,      };      const pinnedActive = yield* decideOrchestrationCommand({        command,        readModel: makeReadModel("active"),      }).pipe(Effect.flip);      expect(pinnedActive._tag).toBe("OrchestrationCommandInvariantError");    }),  );   it.effect("settles awake threads without a redundant wake and re-emits idempotently", () =>    Effect.gen(function* () {      const event = yield* decideOrchestrationCommand({        command: {          type: "thread.settle",          commandId: CommandId.make("cmd-settle"),          threadId: ThreadId.make("thread-1"),        },        readModel: makeReadModel(null),      });      const events = Array.isArray(event) ? event : [event];      expect(events).toHaveLength(1);      expect(events[0]?.type).toBe("thread.settled");      if (events[0]?.type === "thread.settled") {        expect(events[0].payload.settledAt).toBe(events[0].payload.updatedAt);      }       // Already settled: the engine rejects zero-event commands, so idempotency      // is by re-emission — preserving the original settledAt.      const reEmit = yield* decideOrchestrationCommand({        command: {          type: "thread.settle",          commandId: CommandId.make("cmd-settle-again"),          threadId: ThreadId.make("thread-1"),        },        readModel: makeReadModel("settled"),      });      const reEmitEvents = Array.isArray(reEmit) ? reEmit : [reEmit];      expect(reEmitEvents).toHaveLength(1);      expect(reEmitEvents[0]?.type).toBe("thread.settled");      if (reEmitEvents[0]?.type === "thread.settled") {        expect(reEmitEvents[0].payload.settledAt).toBe(SETTLED_AT);        // updatedAt must NOT rewind to the historical settledAt: sorting and        // relative-time labels key on it.        expect(reEmitEvents[0].payload.updatedAt).not.toBe(SETTLED_AT);      }    }),  );   it.effect("settling a snoozed thread also wakes it", () =>    Effect.gen(function* () {      const result = yield* decideOrchestrationCommand({        command: {          type: "thread.settle",          commandId: CommandId.make("cmd-settle-snoozed"),          threadId: ThreadId.make("thread-1"),        },        readModel: makeReadModel(null, null, null, [], [], {          snoozedUntil: "1970-01-02T09:00:00.000Z",        }),      });      const events = Array.isArray(result) ? result : [result];      expect(events.map((entry) => entry.type)).toEqual(["thread.settled", "thread.unsnoozed"]);      const settled = events.find((entry) => entry.type === "thread.settled");      const unsnoozed = events.find((entry) => entry.type === "thread.unsnoozed");      if (settled?.type === "thread.settled" && unsnoozed?.type === "thread.unsnoozed") {        expect(unsnoozed.payload.reason).toBe("user");        expect(unsnoozed.payload.updatedAt).toBe(settled.payload.updatedAt);      }    }),  );   it.effect("repeated settle repairs legacy settled and snoozed state", () =>    Effect.gen(function* () {      const result = yield* decideOrchestrationCommand({        command: {          type: "thread.settle",          commandId: CommandId.make("cmd-settle-snoozed-again"),          threadId: ThreadId.make("thread-1"),        },        readModel: makeReadModel("settled", null, null, [], [], {          snoozedUntil: "1970-01-02T09:00:00.000Z",        }),      });      const events = Array.isArray(result) ? result : [result];      expect(events.map((entry) => entry.type)).toEqual(["thread.settled", "thread.unsnoozed"]);      const settled = events.find((entry) => entry.type === "thread.settled");      const unsnoozed = events.find((entry) => entry.type === "thread.unsnoozed");      if (settled?.type === "thread.settled" && unsnoozed?.type === "thread.unsnoozed") {        expect(settled.payload.settledAt).toBe(SETTLED_AT);        expect(settled.payload.updatedAt).toBe(NOW);        expect(unsnoozed.payload.updatedAt).not.toBe(NOW);      }    }),  );   it.effect("settling a pinned and snoozed thread clears the pin and snooze", () =>    Effect.gen(function* () {      const result = yield* decideOrchestrationCommand({        command: {          type: "thread.settle",          commandId: CommandId.make("cmd-settle-pinned-snoozed"),          threadId: ThreadId.make("thread-1"),        },        readModel: makeReadModel(null, null, null, [], [], {          pinnedAt: SETTLED_AT,          snoozedUntil: "1970-01-02T09:00:00.000Z",        }),      });      const events = Array.isArray(result) ? result : [result];      expect(events.map((entry) => entry.type)).toEqual([        "thread.settled",        "thread.unpinned",        "thread.unsnoozed",      ]);    }),  );   it.effect("rejects settling a thread with a live session", () =>    Effect.gen(function* () {      for (const status of ["starting", "running"] as const) {        const error = yield* decideOrchestrationCommand({          command: {            type: "thread.settle",            commandId: CommandId.make(`cmd-settle-live-${status}`),            threadId: ThreadId.make("thread-1"),          },          readModel: makeReadModel(null, null, makeSession(status)),        }).pipe(Effect.flip);        expect(error).toMatchObject({          _tag: "OrchestrationThreadSettleBlockedError",          threadId: ThreadId.make("thread-1"),          message: SETTLE_BLOCKED_MESSAGE,        });      }      // Stopped/error sessions are settleable — only live work is protected.      const settled = yield* decideOrchestrationCommand({        command: {          type: "thread.settle",          commandId: CommandId.make("cmd-settle-stopped"),          threadId: ThreadId.make("thread-1"),        },        readModel: makeReadModel(null, null, makeSession("stopped")),      });      const settledEvents = Array.isArray(settled) ? settled : [settled];      expect(settledEvents[0]?.type).toBe("thread.settled");    }),  );   it.effect("rejects settling a thread with an open approval or user-input request", () =>    Effect.gen(function* () {      const requestActivity = (kind: string, requestId: string, at: string) =>        ({          id: EventId.make(`activity-${requestId}-${kind}`),          tone: "approval" as const,          kind,          summary: kind,          payload: { requestId },          turnId: null,          createdAt: at,        }) as OrchestrationThread["activities"][number];       // Open approval request: settle rejected.      const openError = yield* decideOrchestrationCommand({        command: {          type: "thread.settle",          commandId: CommandId.make("cmd-settle-pending"),          threadId: ThreadId.make("thread-1"),        },        readModel: makeReadModel(null, null, null, [          requestActivity("approval.requested", "req-1", NOW),        ]),      }).pipe(Effect.flip);      expect(openError).toMatchObject({        _tag: "OrchestrationThreadSettleBlockedError",        threadId: ThreadId.make("thread-1"),        message: SETTLE_BLOCKED_MESSAGE,      });       // Same request later resolved: settleable again.      const settled = yield* decideOrchestrationCommand({        command: {          type: "thread.settle",          commandId: CommandId.make("cmd-settle-resolved"),          threadId: ThreadId.make("thread-1"),        },        readModel: makeReadModel(null, null, null, [          requestActivity("approval.requested", "req-1", NOW),          requestActivity("approval.resolved", "req-1", NOW),        ]),      });      const settledEvents = Array.isArray(settled) ? settled : [settled];      expect(settledEvents[0]?.type).toBe("thread.settled");       // Open user-input request: also rejected.      const inputError = yield* decideOrchestrationCommand({        command: {          type: "thread.settle",          commandId: CommandId.make("cmd-settle-pending-input"),          threadId: ThreadId.make("thread-1"),        },        readModel: makeReadModel(null, null, null, [          requestActivity("user-input.requested", "req-2", NOW),        ]),      }).pipe(Effect.flip);      expect(inputError).toMatchObject({        _tag: "OrchestrationThreadSettleBlockedError",        threadId: ThreadId.make("thread-1"),        message: SETTLE_BLOCKED_MESSAGE,      });    }),  );   it.effect("manual settlement dismisses async questions without starting a turn", () =>    Effect.gen(function* () {      const question = (requestId: string): OrchestrationThread["activities"][number] => ({        id: EventId.make(requestId),        kind: "user-input.requested",        summary: "Question",        tone: "approval",        turnId: null,        createdAt: "1969-12-31T00:00:00.000Z",        payload: { requestId, responseMode: "message" },      });      const readModel = makeReadModel(null, null, makeSession("ready"), [        question("first"),        question("second"),        question("answered"),        {          ...question("answered"),          id: EventId.make("answer"),          createdAt: "1969-12-31T01:00:00.000Z",          kind: "user-input.resolved",        },      ]);      const command = {        type: "thread.settle" as const,        commandId: CommandId.make("settle-async"),        threadId: ThreadId.make("thread-1"),      };      const result = yield* decideOrchestrationCommand({ command, readModel });      const events = Array.isArray(result) ? result : [result];      expect(events.map((event) => event.type)).toEqual([        "thread.settled",        "thread.activity-appended",        "thread.activity-appended",      ]);      expect(events.slice(1).map((event) => event.payload)).toEqual(        ["first", "second"].map((requestId) => ({          threadId: command.threadId,          activity: expect.objectContaining({            kind: "user-input.resolved",            summary: "User input dismissed",            payload: { requestId, responseMode: "message" },          }),        })),      );      let projected = readModel;      for (const [index, event] of events.entries()) {        projected = yield* projectEvent(projected, { ...event, sequence: index + 1 });      }      expect(projected.threads[0]?.settledOverride).toBe("settled");      expect(projected.threads[0]?.messages).toEqual([]);      const repeated = yield* decideOrchestrationCommand({ command, readModel: projected });      expect(repeated).toMatchObject({ type: "thread.settled" });    }),  );   it.effect("async questions do not bypass automatic settlement or other blockers", () =>    Effect.gen(function* () {      const question: OrchestrationThread["activities"][number] = {        id: EventId.make("async-question"),        kind: "user-input.requested",        summary: "Question",        tone: "approval",        turnId: null,        createdAt: NOW,        payload: { requestId: "async-question", responseMode: "message" },      };      for (const blocker of ["auto", "running", "starting", "approval", "native"] as const) {        const error = yield* decideOrchestrationCommand({          command:            blocker === "auto"              ? {                  type: "thread.auto-settle",                  commandId: CommandId.make(`settle-${blocker}`),                  threadId: ThreadId.make("thread-1"),                  snapshotSequence: 0,                  settledAt: NOW,                }              : {                  type: "thread.settle",                  commandId: CommandId.make(`settle-${blocker}`),                  threadId: ThreadId.make("thread-1"),                },          readModel: makeReadModel(            null,            null,            makeSession(blocker === "running" || blocker === "starting" ? blocker : "ready"),            [              question,              ...(blocker === "approval" || blocker === "native"                ? [                    {                      ...question,                      id: EventId.make("blocking-request"),                      kind: blocker === "approval" ? "approval.requested" : "user-input.requested",                      payload: { requestId: "blocking-request" },                    },                  ]                : []),            ],          ),        }).pipe(Effect.flip);        expect(error).toMatchObject({ _tag: "OrchestrationThreadSettleBlockedError" });      }    }),  );   it.effect("clears an open request when its respond failure marks it stale", () =>    Effect.gen(function* () {      const activity = (        kind: string,        requestId: string,        payload: Record<string, unknown>,      ): OrchestrationThread["activities"][number] =>        ({          id: EventId.make(`activity-${requestId}-${kind}`),          tone: "approval" as const,          kind,          summary: kind,          payload: { requestId, ...payload },          turnId: null,          createdAt: NOW,        }) as OrchestrationThread["activities"][number];       // Stale-failure details clear the request, matching the projection flags.      const settled = yield* decideOrchestrationCommand({        command: {          type: "thread.settle",          commandId: CommandId.make("cmd-settle-stale-failed"),          threadId: ThreadId.make("thread-1"),        },        readModel: makeReadModel(null, null, null, [          activity("approval.requested", "req-1", {}),          activity("provider.approval.respond.failed", "req-1", {            detail: "Unknown pending approval request req-1",          }),          activity("user-input.requested", "req-2", {}),          activity("provider.user-input.respond.failed", "req-2", {            detail: "stale pending user-input request req-2",          }),        ]),      });      const settledEvents = Array.isArray(settled) ? settled : [settled];      expect(settledEvents[0]?.type).toBe("thread.settled");       // A non-stale respond failure (transient provider error) keeps the      // request open: the user can retry, so it is still blocked-on-you.      const stillOpen = yield* decideOrchestrationCommand({        command: {          type: "thread.settle",          commandId: CommandId.make("cmd-settle-transient-failed"),          threadId: ThreadId.make("thread-1"),        },        readModel: makeReadModel(null, null, null, [          activity("approval.requested", "req-3", {}),          activity("provider.approval.respond.failed", "req-3", {            detail: "provider connection reset",          }),        ]),      }).pipe(Effect.flip);      expect(stillOpen).toMatchObject({        _tag: "OrchestrationThreadSettleBlockedError",        threadId: ThreadId.make("thread-1"),        message: SETTLE_BLOCKED_MESSAGE,      });    }),  );   it.effect("bounds the queued-turn grace window against client clock skew", () =>    Effect.gen(function* () {      const userMessage = (createdAt: string): OrchestrationThread["messages"][number] => ({        id: MessageId.make("message-queued"),        role: "user",        text: "Continue",        turnId: null,        streaming: false,

Lines 1–500 of 853.