1import { assert, it } from "@effect/vitest";
2import {
3 CommandId,
4 EnvironmentId,
5 OrchestratorMcpFailure,
6 ProjectId,
7 ProviderDriverKind,
8 ProviderInstanceId,
9 ThreadId,
10} from "@t3tools/contracts";
11import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";
13import * as Ref from "effect/Ref";
14import * as Schema from "effect/Schema";
15import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
16
17import * as SqlitePersistence from "../persistence/Sqlite.ts";
18import { DispatchModeLimit } from "../orchestration-v2/DispatchModeLimit.ts";
19import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts";
20import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts";
21import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
22import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
23import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
24import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts";
25import * as ServerSettings from "../serverSettings.ts";
26import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts";
27import * as McpHttpServer from "./McpHttpServer.ts";
28import * as McpInvocationContext from "./McpInvocationContext.ts";
29import * as McpToolAccess from "./McpToolAccess.ts";
30import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
31
32// A Supervised outside agent renames a thread through a `writesThreads` tool.
33// The thread's user raises it to full access after the tool's check but
34// before its write: the race the orchestrator closes under the thread's lock.
35
36const instanceId = ProviderInstanceId.make("codex");
37const adapter = {
38 instanceId,
39 driver: ProviderDriverKind.make("codex"),
40 getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2),
41 planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }),
42 openSession: () => Effect.die("No provider process needed for metadata commands"),
43} as ProviderAdapter.ProviderAdapterV2["Service"];
44const layerDatabase = SqlitePersistence.layerMemory;
45const layerOrchestrator = Layer.mergeAll(
46 layerDatabase,
47 ProjectionStore.layer.pipe(Layer.provide(layerDatabase)),
48 ProviderReplayHarness.layerWithRegistry(
49 { name: "mcp-mode-race" },
50 ProviderAdapterRegistry.layerFromAdapters([adapter]),
51 { databaseLayer: layerDatabase, runEffectWorker: false },
52 ),
53);
54
55const RenameTool = Tool.make("rename", {
56 parameters: Schema.Struct({ threadId: ThreadId }),
57 success: Schema.Struct({ renamed: Schema.Boolean }),
58 failure: OrchestratorMcpFailure,
59 failureMode: "return",
60 dependencies: [
61 McpInvocationContext.McpInvocationContext,
62 ThreadManagement.ThreadManagementService,
63 ],
64});
65const RenameToolkit = Toolkit.make(RenameTool);
66
67const supervisedClient: McpInvocationContext.McpInvocationScope = {
68 environmentId: EnvironmentId.make("environment"),
69 requestNamespace: "client:race",
70 thread: undefined,
71 client: { sessionId: "race", label: "Claude Code", access: "approval-required" },
72 capabilities: new Set(["orchestration"]),
73 issuedAt: 0,
74};
75
76const mcpClient = McpSchema.McpServerClient.of({
77 clientId: 1,
78 protocolVersion: "2025-06-18",
79 clientCapabilities: {},
80 clientInfo: { name: "mcp-race", version: "1" },
81 initializePayload: {
82 protocolVersion: "2025-06-18",
83 capabilities: {},
84 clientInfo: { name: "mcp-race", version: "1" },
85 },
86 getClient: Effect.die("unused"),
87});
88
89const decodeOutcome = Schema.decodeUnknownEffect(
90 Schema.fromJsonString(
91 Schema.Union([
92 Schema.Struct({ renamed: Schema.Boolean }),
93 Schema.Struct({ code: Schema.String }),
94 ]),
95 ),
96);
97
98/** Renames `threadId` as the Supervised client; the user may raise it in between. */
99const renameRacingTheUser = (threadId: ThreadId, userRaises: boolean) =>
100 Effect.gen(function* () {
101 const orchestrator = yield* Orchestrator.OrchestratorV2;
102 const threads = yield* ThreadManagement.ThreadManagementService;
103 const raised = yield* Ref.make(false);
104 const handlers = McpToolAccess.toLayer(RenameToolkit, {
105 rename: McpToolAccess.writesThreads(
106 (input) => [input.threadId],
107 (input) =>
108 Effect.gen(function* () {
109 // The tool's own check has passed; the user acts before the write.
110 if (userRaises) {
111 // The user's own command carries no limit, so it lands.
112 yield* orchestrator
113 .dispatch({
114 type: "thread.runtime-mode.set",
115 commandId: CommandId.make("user-raise"),
116 threadId: input.threadId,
117 runtimeMode: "full-access",
118 })
119 .pipe(Effect.provideService(DispatchModeLimit, undefined), Effect.orDie);
120 yield* Ref.set(raised, true);
121 }
122 yield* threads
123 .dispatch({
124 type: "thread.metadata.update",
125 commandId: CommandId.make(`agent-rename:${userRaises}`),
126 threadId: input.threadId,
127 title: "Renamed by the agent",
128 })
129 .pipe(
130 Effect.mapError(
131 () =>
132 new OrchestratorMcpFailure({
133 code: "orchestration_error",
134 message: "The rename failed.",
135 }),
136 ),
137 );
138 return { renamed: true };
139 }),
140 ),
141 });
142 const outcome = yield* McpServer.McpServer.pipe(
143 Effect.flatMap((server) => server.callTool({ name: "rename", arguments: { threadId } })),
144 Effect.flatMap(({ content }) =>
145 decodeOutcome(content.map((part) => (part.type === "text" ? part.text : "")).join("")),
146 ),
147 Effect.provideService(McpInvocationContext.McpInvocationContext, supervisedClient),
148 Effect.provideService(McpSchema.McpServerClient, mcpClient),
149 Effect.provide(
150 McpHttpServer.toolkitRegistration(RenameToolkit, handlers).pipe(
151 Layer.provideMerge(McpServer.McpServer.layer),
152 ),
153 ),
154 );
155 assert.equal(yield* Ref.get(raised), userRaises);
156 return outcome;
157 });
158
159const createSupervisedThread = (threadId: ThreadId) =>
160 Effect.gen(function* () {
161 const orchestrator = yield* Orchestrator.OrchestratorV2;
162 yield* orchestrator.dispatch({
163 type: "thread.create",
164 commandId: CommandId.make(`create:${threadId}`),
165 threadId,
166 projectId: ProjectId.make("project:mcp-race"),
167 title: "Before",
168 modelSelection: { instanceId, model: "gpt-5" },
169 runtimeMode: "approval-required",
170 interactionMode: "default",
171 branch: null,
172 worktreePath: null,
173 createdBy: "user",
174 creationSource: "web",
175 });
176 });
177
178it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => {
179 it.effect("refuses the write when the user raises the thread after the check", () =>
180 Effect.gen(function* () {
181 const projections = yield* ProjectionStore.ProjectionStoreV2;
182 const threadId = ThreadId.make("thread:race-raised");
183 yield* createSupervisedThread(threadId);
184 const outcome = yield* renameRacingTheUser(threadId, true);
185 assert.deepEqual(outcome, { code: "runtime_mode_escalation_denied" });
186 const shell = yield* projections.getThreadShell(threadId);
187 assert.equal(shell?.runtimeMode, "full-access");
188 assert.equal(shell?.title, "Before");
189 }).pipe(
190 Effect.provide(
191 ThreadManagement.layer.pipe(
192 Layer.provide(ServerSettings.layerTest()),
193 Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))),
194 ),
195 ),
196 ),
197 );
198
199 it.effect("lets the write through when the thread stays within the caller's modes", () =>
200 Effect.gen(function* () {
201 const projections = yield* ProjectionStore.ProjectionStoreV2;
202 const threadId = ThreadId.make("thread:race-steady");
203 yield* createSupervisedThread(threadId);
204 const outcome = yield* renameRacingTheUser(threadId, false);
205 assert.deepEqual(outcome, { renamed: true });
206 assert.equal((yield* projections.getThreadShell(threadId))?.title, "Renamed by the agent");
207 }).pipe(
208 Effect.provide(
209 ThreadManagement.layer.pipe(
210 Layer.provide(ServerSettings.layerTest()),
211 Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))),
212 ),
213 ),
214 ),
215 );
216});