import { expect, it } from "@effect/vitest"; import { CommandId, MessageId, NodeId, type OrchestrationV2Command, type OrchestrationV2ThreadProjection, ProjectId, ProviderInstanceId, RunId, ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { LegacyV1ThreadImporter, LegacyV1ThreadImportError } from "./LegacyV1ThreadImporter.ts"; import { OrchestratorProjectionError, OrchestratorV2 } from "./Orchestrator.ts"; import { existingThreadIdsForCommand, layer, layerWithLegacyImporter, ThreadManagementDurableRunProjectionError, ThreadManagementProjectThreadsListError, ThreadManagementProjectionLoadError, ThreadManagementRunNotFoundError, ThreadManagementService, ThreadManagementThreadNotFoundError, ThreadManagementThreadNotInterruptibleError, ThreadManagementThreadArchivedError, ThreadManagementNoSteerableRunError, withCreationProvenance, } from "./ThreadManagementService.ts"; it("stamps authoritative provenance on commands that create threads or messages", () => { const command: OrchestrationV2Command = { type: "thread.create", createdBy: "agent", creationSource: "mcp", commandId: CommandId.make("command:thread-management:create"), threadId: ThreadId.make("thread:thread-management:create"), projectId: ProjectId.make("project:thread-management"), title: "Thread management", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", interactionMode: "default", branch: null, worktreePath: null, }; expect( withCreationProvenance(command, { createdBy: "user", creationSource: "web", }), ).toMatchObject({ createdBy: "user", creationSource: "web", }); }); it("leaves commands that do not create durable authored content unchanged", () => { const command: OrchestrationV2Command = { type: "run.interrupt", commandId: CommandId.make("command:thread-management:interrupt"), threadId: ThreadId.make("thread:thread-management:interrupt"), runId: RunId.make("run:thread-management:interrupt"), }; expect( withCreationProvenance(command, { createdBy: "user", creationSource: "web", }), ).toBe(command); }); it("identifies every existing thread that must be hydrated before dispatch", () => { const sourceThreadId = ThreadId.make("thread:thread-management:source"); const targetThreadId = ThreadId.make("thread:thread-management:target"); const parentThreadId = ThreadId.make("thread:thread-management:parent"); expect( existingThreadIdsForCommand({ type: "thread.create", createdBy: "user", creationSource: "web", commandId: CommandId.make("command:thread-management:create"), threadId: targetThreadId, projectId: ProjectId.make("project:thread-management"), title: "Created thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", interactionMode: "default", branch: null, worktreePath: null, }), ).toEqual([]); expect( existingThreadIdsForCommand({ type: "thread.archive", commandId: CommandId.make("command:thread-management:archive"), threadId: targetThreadId, }), ).toEqual([targetThreadId]); // Read-state commands skip transcript hydration entirely: they fire on // every activity bump while a thread is open and never touch messages. expect( existingThreadIdsForCommand({ type: "thread.visit", commandId: CommandId.make("command:thread-management:visit"), threadId: targetThreadId, visitedAt: "2026-07-30T00:00:00.000Z", }), ).toEqual([]); expect( existingThreadIdsForCommand({ type: "thread.mark-unread", commandId: CommandId.make("command:thread-management:mark-unread"), threadId: targetThreadId, }), ).toEqual([]); expect( existingThreadIdsForCommand({ type: "thread.fork", createdBy: "user", creationSource: "web", commandId: CommandId.make("command:thread-management:fork"), sourceThreadId, targetThreadId, sourcePoint: { type: "run", runId: RunId.make("run:thread-management:source"), }, }), ).toEqual([sourceThreadId]); expect( existingThreadIdsForCommand({ type: "thread.merge_back", createdBy: "user", creationSource: "web", commandId: CommandId.make("command:thread-management:merge"), sourceThreadId, targetThreadId, sourcePoint: { type: "run", runId: RunId.make("run:thread-management:source"), }, }), ).toEqual([sourceThreadId, targetThreadId]); expect( existingThreadIdsForCommand({ type: "delegated_task.request", createdBy: "agent", creationSource: "provider", commandId: CommandId.make("command:thread-management:delegate"), parentThreadId, parentRunId: RunId.make("run:thread-management:parent"), parentNodeId: NodeId.make("node:thread-management:parent"), task: "Inspect the migration", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, runtimeMode: "full-access", interactionMode: "default", }), ).toEqual([parentThreadId]); expect( existingThreadIdsForCommand({ type: "delegated_task.wake-policy", commandId: CommandId.make("command:thread-management:wake-policy"), parentThreadId, taskId: NodeId.make("node:thread-management:delegated"), completionWake: "always", }), ).toEqual([parentThreadId]); expect( existingThreadIdsForCommand({ type: "thread.created.record", commandId: CommandId.make("command:thread-management:record"), parentThreadId, parentRunId: RunId.make("run:thread-management:parent"), parentNodeId: NodeId.make("node:thread-management:parent"), targetThreadId, targetRunId: null, }), ).toEqual([parentThreadId, targetThreadId]); }); it("derives thread management messages from structural error attributes", () => { const projectId = ProjectId.make("project:thread-management:errors"); const threadId = ThreadId.make("thread:thread-management:errors"); const runId = RunId.make("run:thread-management:errors"); const messageId = MessageId.make("message:thread-management:errors"); const infrastructureCause = new Error("private sqlite detail"); const threadNotFound = new ThreadManagementThreadNotFoundError({ projectId, threadId, }); expect(threadNotFound).toMatchObject({ projectId, threadId }); expect(threadNotFound.message).toBe(`Thread ${threadId} was not found in project ${projectId}.`); const runNotFound = new ThreadManagementRunNotFoundError({ threadId, runId }); expect(runNotFound).toMatchObject({ threadId, runId }); expect(runNotFound.message).toBe(`Run ${runId} does not belong to thread ${threadId}.`); const archived = new ThreadManagementThreadArchivedError({ threadId, }); expect(archived).toMatchObject({ threadId }); expect(archived.message).toBe(`Thread ${threadId} is archived and cannot receive messages.`); const notSteerable = new ThreadManagementNoSteerableRunError({ threadId, mode: "restart", }); expect(notSteerable).toMatchObject({ threadId, mode: "restart", }); expect(notSteerable.message).toBe( `Thread ${threadId} has no running turn that can be restarted.`, ); const notInterruptible = new ThreadManagementThreadNotInterruptibleError({ threadId, runId, }); expect(notInterruptible).toMatchObject({ threadId, runId }); expect(notInterruptible.message).toBe(`Run ${runId} is not currently interruptible.`); const listFailure = new ThreadManagementProjectThreadsListError({ projectId, cause: infrastructureCause, }); expect(listFailure).toMatchObject({ projectId, cause: infrastructureCause }); expect(listFailure.message).toBe(`Unable to list threads in project ${projectId}.`); expect(listFailure.message).not.toContain(infrastructureCause.message); const durableProjectionFailure = new ThreadManagementDurableRunProjectionError({ threadId, messageId, }); expect(durableProjectionFailure).toMatchObject({ threadId, messageId }); expect(durableProjectionFailure.message).toBe( `Message ${messageId} was accepted on thread ${threadId} without a durable run projection.`, ); }); it.effect("classifies projection infrastructure failures separately from a missing thread", () => { const projectId = ProjectId.make("project:thread-management:projection-failure"); const threadId = ThreadId.make("thread:thread-management:projection-failure"); const infrastructureCause = new Error("sqlite read failed"); const projectionError = new OrchestratorProjectionError({ threadId, cause: infrastructureCause, }); const testLayer = layer.pipe( Layer.provide( Layer.mock(OrchestratorV2)({ getThreadProjection: () => Effect.fail(projectionError), }), ), ); return Effect.gen(function* () { const service = yield* ThreadManagementService; const error = yield* Effect.flip(service.getProjectThread({ projectId, threadId })); expect(error).toBeInstanceOf(ThreadManagementProjectionLoadError); expect(error).toMatchObject({ projectId, threadId, cause: projectionError, }); expect(error.message).toBe(`Unable to load thread ${threadId} in project ${projectId}.`); }).pipe(Effect.provide(testLayer)); }); it.effect("uses thread-not-found only after a projection loads outside the project", () => { const projectId = ProjectId.make("project:thread-management:requested"); const otherProjectId = ProjectId.make("project:thread-management:other"); const threadId = ThreadId.make("thread:thread-management:wrong-project"); const projection = { thread: { id: threadId, projectId: otherProjectId, deletedAt: null, }, } as OrchestrationV2ThreadProjection; const testLayer = layer.pipe( Layer.provide( Layer.mock(OrchestratorV2)({ getThreadProjection: () => Effect.succeed(projection), }), ), ); return Effect.gen(function* () { const service = yield* ThreadManagementService; const error = yield* Effect.flip(service.getProjectThread({ projectId, threadId })); expect(error).toBeInstanceOf(ThreadManagementThreadNotFoundError); expect(error).toMatchObject({ projectId, threadId }); expect("cause" in error).toBe(false); }).pipe(Effect.provide(testLayer)); }); it.effect("preserves failed legacy materialization when reading checkpoint context", () => { const threadId = ThreadId.make("thread:thread-management:checkpoint-import-failure"); const importError = new LegacyV1ThreadImportError({ threadId, operation: "hydrate transcript for", cause: new Error("checkpoint import failed"), }); const testLayer = layerWithLegacyImporter.pipe( Layer.provide( Layer.mergeAll( Layer.mock(OrchestratorV2)({ getCheckpointContext: () => Effect.succeed({ runs: [], checkpointScopes: [], checkpoints: [] }), }), Layer.mock(LegacyV1ThreadImporter)({ ensureTranscript: () => Effect.fail(importError), }), ), ), ); return Effect.gen(function* () { const service = yield* ThreadManagementService; const error = yield* service.getCheckpointContext(threadId).pipe(Effect.flip); expect(error).toBeInstanceOf(OrchestratorProjectionError); expect(error).toMatchObject({ threadId, cause: importError }); }).pipe(Effect.provide(testLayer)); });