1import { assert, it } from "@effect/vitest";
2import {
3 CommandId,
4 ProjectId,
5 ProviderDriverKind,
6 ProviderInstanceId,
7 ThreadId,
8} from "@t3tools/contracts";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as SqlitePersistence from "../persistence/Sqlite.ts";
12import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
13import { DispatchModeLimit } from "./DispatchModeLimit.ts";
14import * as Orchestrator from "./Orchestrator.ts";
15import * as ProjectionStore from "./ProjectionStore.ts";
16import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
17import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts";
18import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
19
20const instanceId = ProviderInstanceId.make("codex");
21const adapter = {
22 instanceId,
23 driver: ProviderDriverKind.make("codex"),
24 getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2),
25 planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }),
26 openSession: () => Effect.die("No provider process needed for metadata commands"),
27} as ProviderAdapter.ProviderAdapterV2["Service"];
28const layerDatabase = SqlitePersistence.layerMemory;
29const layerTest = Layer.mergeAll(
30 layerDatabase,
31 ProjectionStore.layer.pipe(Layer.provide(layerDatabase)),
32 ProviderReplayHarness.layerWithRegistry(
33 { name: "dispatch-mode-limit" },
34 ProviderAdapterRegistry.layerFromAdapters([adapter]),
35 { databaseLayer: layerDatabase, runEffectWorker: false },
36 ),
37);
38
39const supervised = { runtimeMode: "approval-required", interactionMode: "default" } as const;
40
41const createThread = (threadId: ThreadId) =>
42 Effect.gen(function* () {
43 const orchestrator = yield* Orchestrator.OrchestratorV2;
44 yield* orchestrator.dispatch({
45 type: "thread.create",
46 commandId: CommandId.make(`create:${threadId}`),
47 threadId,
48 projectId: ProjectId.make("project:dispatch-mode-limit"),
49 title: "Before",
50 modelSelection: { instanceId, model: "gpt-5" },
51 ...supervised,
52 branch: null,
53 worktreePath: null,
54 createdBy: "user",
55 creationSource: "web",
56 });
57 });
58
59it.layer(layerTest)("DispatchModeLimit", (it) => {
60 it.effect("refuses a limited command on a thread its user raised, and records nothing", () =>
61 Effect.gen(function* () {
62 const orchestrator = yield* Orchestrator.OrchestratorV2;
63 const projections = yield* ProjectionStore.ProjectionStoreV2;
64 const threadId = ThreadId.make("thread:limit-raised");
65 yield* createThread(threadId);
66 // The user raises the thread; the user's own commands have no limit.
67 yield* orchestrator.dispatch({
68 type: "thread.runtime-mode.set",
69 commandId: CommandId.make("raise"),
70 threadId,
71 runtimeMode: "full-access",
72 });
73 const rename = {
74 type: "thread.metadata.update",
75 commandId: CommandId.make("rename"),
76 threadId,
77 title: "After",
78 } as const;
79 const refused = yield* orchestrator
80 .dispatch(rename)
81 .pipe(Effect.provideService(DispatchModeLimit, supervised), Effect.flip);
82 assert.equal(refused._tag, "OrchestratorThreadAboveModeLimitError");
83 assert.equal((yield* projections.getThreadShell(threadId))?.title, "Before");
84
85 // Refused before planning, so no receipt holds the command id: once the
86 // user lowers the thread again, the same command goes through.
87 yield* orchestrator.dispatch({
88 type: "thread.runtime-mode.set",
89 commandId: CommandId.make("lower"),
90 threadId,
91 runtimeMode: "approval-required",
92 });
93 yield* orchestrator
94 .dispatch(rename)
95 .pipe(Effect.provideService(DispatchModeLimit, supervised));
96 assert.equal((yield* projections.getThreadShell(threadId))?.title, "After");
97 }),
98 );
99
100 it.effect("names the interaction mode when that is what was raised", () =>
101 Effect.gen(function* () {
102 const orchestrator = yield* Orchestrator.OrchestratorV2;
103 const threadId = ThreadId.make("thread:limit-plan");
104 yield* createThread(threadId);
105 const refused = yield* orchestrator
106 .dispatch({
107 type: "thread.metadata.update",
108 commandId: CommandId.make("rename-plan"),
109 threadId,
110 title: "After",
111 })
112 .pipe(
113 Effect.provideService(DispatchModeLimit, {
114 runtimeMode: "full-access",
115 interactionMode: "plan",
116 }),
117 Effect.flip,
118 );
119 assert.ok(refused._tag === "OrchestratorThreadAboveModeLimitError");
120 assert.equal(refused.mode, "interaction");
121 }),
122 );
123
124 it.effect("refuses to fork a source thread its user raised", () =>
125 Effect.gen(function* () {
126 const orchestrator = yield* Orchestrator.OrchestratorV2;
127 const projections = yield* ProjectionStore.ProjectionStoreV2;
128 const sourceThreadId = ThreadId.make("thread:limit-fork-source");
129 const targetThreadId = ThreadId.make("thread:limit-fork-target");
130 yield* createThread(sourceThreadId);
131 yield* orchestrator.dispatch({
132 type: "thread.runtime-mode.set",
133 commandId: CommandId.make("raise-source"),
134 threadId: sourceThreadId,
135 runtimeMode: "full-access",
136 });
137 const fork = {
138 type: "thread.fork",
139 commandId: CommandId.make("fork"),
140 sourceThreadId,
141 targetThreadId,
142 sourcePoint: { type: "latest_stable" },
143 createdBy: "agent",
144 creationSource: "mcp",
145 } as const;
146 const refused = yield* orchestrator
147 .dispatch(fork)
148 .pipe(Effect.provideService(DispatchModeLimit, supervised), Effect.flip);
149 assert.ok(refused._tag === "OrchestratorThreadAboveModeLimitError");
150 assert.equal(refused.threadId, sourceThreadId);
151 assert.isNull(yield* projections.getThreadShell(targetThreadId));
152
153 // The refusal records no receipt: once the source is lowered, the same
154 // command is planned again (and fails only for want of a finished run).
155 yield* orchestrator.dispatch({
156 type: "thread.runtime-mode.set",
157 commandId: CommandId.make("lower-source"),
158 threadId: sourceThreadId,
159 runtimeMode: "approval-required",
160 });
161 const retried = yield* orchestrator
162 .dispatch(fork)
163 .pipe(Effect.provideService(DispatchModeLimit, supervised), Effect.flip);
164 assert.equal(retried._tag, "OrchestratorDispatchError");
165 }),
166 );
167});