| 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import { |
| 3 | CommandId, |
| 4 | EnvironmentId, |
| 5 | OrchestratorMcpFailure, |
| 6 | ProjectId, |
| 7 | ProviderDriverKind, |
| 8 | ProviderInstanceId, |
| 9 | ThreadId, |
| 10 | } from "@t3tools/contracts"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Layer from "effect/Layer"; |
| 13 | import * as Ref from "effect/Ref"; |
| 14 | import * as Schema from "effect/Schema"; |
| 15 | import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; |
| 16 | |
| 17 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 18 | import { DispatchModeLimit } from "../orchestration-v2/DispatchModeLimit.ts"; |
| 19 | import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; |
| 20 | import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; |
| 21 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; |
| 22 | import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; |
| 23 | import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; |
| 24 | import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; |
| 25 | import * as ServerSettings from "../serverSettings.ts"; |
| 26 | import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; |
| 27 | import * as McpHttpServer from "./McpHttpServer.ts"; |
| 28 | import * as McpInvocationContext from "./McpInvocationContext.ts"; |
| 29 | import * as McpToolAccess from "./McpToolAccess.ts"; |
| 30 | import 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 | |
| 36 | const instanceId = ProviderInstanceId.make("codex"); |
| 37 | const 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"]; |
| 44 | const layerDatabase = SqlitePersistence.layerMemory; |
| 45 | const 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 | |
| 55 | const 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 | }); |
| 65 | const RenameToolkit = Toolkit.make(RenameTool); |
| 66 | |
| 67 | const 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 | |
| 76 | const 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 | |
| 89 | const 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. */ |
| 99 | const 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 | |
| 159 | const 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 | |
| 178 | it.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 | }); |