| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { assert, describe, it } from "@effect/vitest"; |
| 4 | import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; |
| 5 | import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; |
| 6 | import * as Deferred from "effect/Deferred"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Layer from "effect/Layer"; |
| 10 | import * as Option from "effect/Option"; |
| 11 | import * as Path from "effect/Path"; |
| 12 | import * as Schema from "effect/Schema"; |
| 13 | import * as HttpClient from "effect/http/HttpClient"; |
| 14 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 15 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 16 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 17 | |
| 18 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 19 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| 20 | import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; |
| 21 | import * as ServerSettings from "../../serverSettings.ts"; |
| 22 | import type { |
| 23 | AcpRegistryAvailableCommands, |
| 24 | AcpRegistryLiveConfiguration, |
| 25 | } from "@t3tools/provider-acp-registry/testing"; |
| 26 | import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; |
| 27 | import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator"; |
| 28 | import { ACP_SESSION_MODE_OPTION_ID } from "@t3tools/provider-acp/server/sessionConfig"; |
| 29 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 30 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 31 | import { |
| 32 | decodeAcpReplayTranscript, |
| 33 | makeAcpReplayCompletenessAssertion, |
| 34 | makeAcpReplayRuntime, |
| 35 | } from "./AcpAdapterV2.testkit.ts"; |
| 36 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 37 | import { BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2 } from "../builtInProviderAdapterDrivers.ts"; |
| 38 | import { AcpRegistryAdapterV2Driver } from "@t3tools/provider-acp-registry/server"; |
| 39 | import { |
| 40 | ACP_REGISTRY_PROVIDER, |
| 41 | makeAcpRegistryAdapterV2, |
| 42 | acpRegistryPromptFailure, |
| 43 | } from "@t3tools/provider-acp-registry/testing"; |
| 44 | |
| 45 | const registryUrl = "https://registry.test/registry.json"; |
| 46 | const decodeAcpRegistryAdapterSettings = Schema.decodeUnknownEffect( |
| 47 | AcpRegistryAdapterV2Driver.configSchema, |
| 48 | ); |
| 49 | |
| 50 | const layerHost = TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)); |
| 51 | |
| 52 | const layerRegistry = Layer.succeed( |
| 53 | HttpClient.HttpClient, |
| 54 | HttpClient.make((request) => |
| 55 | Effect.succeed( |
| 56 | HttpClientResponse.fromWeb( |
| 57 | request, |
| 58 | Response.json({ |
| 59 | version: "1.0.0", |
| 60 | agents: [ |
| 61 | { |
| 62 | id: "fixture-agent", |
| 63 | name: "Fixture Agent", |
| 64 | version: "1.0.0", |
| 65 | description: "ACP V2 adapter fixture", |
| 66 | distribution: { |
| 67 | binary: { |
| 68 | "darwin-aarch64": { |
| 69 | archive: "https://registry.test/unused", |
| 70 | cmd: "fixture-agent", |
| 71 | args: [], |
| 72 | }, |
| 73 | "linux-x86_64": { |
| 74 | archive: "https://registry.test/unused", |
| 75 | cmd: "fixture-agent", |
| 76 | args: [], |
| 77 | }, |
| 78 | }, |
| 79 | }, |
| 80 | }, |
| 81 | ], |
| 82 | }), |
| 83 | ), |
| 84 | ), |
| 85 | ), |
| 86 | ); |
| 87 | |
| 88 | // Adapters given an explicit runtime never resolve through the catalog. |
| 89 | const layerInjectedRuntimeCatalog = Layer.mock(AcpRegistrySupport.AcpRegistryCatalog)({ |
| 90 | resolve: () => Effect.die("the runtime is injected"), |
| 91 | }); |
| 92 | |
| 93 | const layerTest = Layer.mergeAll( |
| 94 | NodeServices.layer, |
| 95 | IdAllocator.layer, |
| 96 | McpProviderSessions.layer, |
| 97 | ScriptProgress.layerUnavailable, |
| 98 | layerHost, |
| 99 | layerRegistry, |
| 100 | ServerSettings.layerTest(), |
| 101 | ); |
| 102 | |
| 103 | describe("AcpRegistryAdapterV2", () => { |
| 104 | it("preserves and sanitizes structured ACP errors without exposing arbitrary defects", () => { |
| 105 | const limit = new EffectAcpErrors.AcpRequestError({ |
| 106 | code: -31001, |
| 107 | errorMessage: "Rate limit exceeded for mistral (model: mistral-vibe-cli-latest).", |
| 108 | }); |
| 109 | assert.deepEqual(acpRegistryPromptFailure("mistral-vibe", limit), { |
| 110 | class: "usage_limit", |
| 111 | message: limit.errorMessage, |
| 112 | code: "-31001", |
| 113 | retryable: null, |
| 114 | }); |
| 115 | assert.equal(acpRegistryPromptFailure("other-agent", limit).class, "provider_error"); |
| 116 | assert.equal( |
| 117 | acpRegistryPromptFailure("mistral-vibe", new Error("private defect")).message, |
| 118 | "Provider turn failed.", |
| 119 | ); |
| 120 | const rejected = acpRegistryPromptFailure( |
| 121 | "any-agent", |
| 122 | new EffectAcpErrors.AcpRequestError({ |
| 123 | code: -32603, |
| 124 | errorMessage: "Request rejected. api_key=private-key https://example.test/?token=secret", |
| 125 | }), |
| 126 | ); |
| 127 | assert.include(rejected.message, "Request rejected."); |
| 128 | assert.notInclude(rejected.message, "private-key"); |
| 129 | assert.notInclude(rejected.message, "token=secret"); |
| 130 | }); |
| 131 | it("is registered as a generic provider driver with schema defaults", () => { |
| 132 | assert.isTrue(BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2.has(ACP_REGISTRY_PROVIDER)); |
| 133 | assert.equal(AcpRegistryAdapterV2Driver.driverKind, ACP_REGISTRY_PROVIDER); |
| 134 | assert.deepEqual(AcpRegistryAdapterV2Driver.defaultConfig(), { |
| 135 | source: "registry", |
| 136 | commandArgs: [], |
| 137 | enabled: true, |
| 138 | agentId: "", |
| 139 | commandPath: "", |
| 140 | authMethodId: "", |
| 141 | distribution: "auto", |
| 142 | customModels: [], |
| 143 | }); |
| 144 | }); |
| 145 | |
| 146 | describe("the agent's own mode picker", () => { |
| 147 | type Frame = Record<string, unknown>; |
| 148 | const outbound = (method: string, params: unknown = "<any>"): Frame => ({ |
| 149 | type: "expect_outbound", |
| 150 | frame: { kind: "request", method, params }, |
| 151 | }); |
| 152 | const answer = (method: string, result: unknown): Frame => ({ |
| 153 | type: "emit_inbound", |
| 154 | frame: { kind: "response", method, result }, |
| 155 | }); |
| 156 | const permissionModeOption = (currentValue: string) => ({ |
| 157 | id: "permission-mode", |
| 158 | name: "Permission mode", |
| 159 | category: "mode", |
| 160 | type: "select", |
| 161 | currentValue, |
| 162 | options: ["ask", "auto"].map((value) => ({ value, name: value })), |
| 163 | }); |
| 164 | |
| 165 | // A scripted ACP v1 agent: initialize, session/new answered with `setup`, |
| 166 | // then the frames T3 must send (and the agent's answers) to apply the |
| 167 | // user's stored pick from the agent's mode picker. |
| 168 | const openWithStoredModePick = Effect.fn("openWithStoredModePick")(function* (input: { |
| 169 | readonly setup: unknown; |
| 170 | readonly modeFrames: ReadonlyArray<Frame>; |
| 171 | readonly storedModePick: string; |
| 172 | }) { |
| 173 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 174 | const fileSystem = yield* FileSystem.FileSystem; |
| 175 | const path = yield* Path.Path; |
| 176 | const replayDir = yield* fileSystem.makeTempDirectoryScoped({ |
| 177 | prefix: "t3-acp-registry-mode-pick-", |
| 178 | }); |
| 179 | const statusPath = path.join(replayDir, "status.json"); |
| 180 | const transcript = yield* decodeAcpReplayTranscript( |
| 181 | { |
| 182 | provider: ACP_REGISTRY_PROVIDER, |
| 183 | protocol: "acp.ndjson-jsonrpc", |
| 184 | version: "1", |
| 185 | scenario: "stored-mode-pick", |
| 186 | entries: [ |
| 187 | outbound("initialize"), |
| 188 | answer("initialize", { |
| 189 | protocolVersion: 1, |
| 190 | agentCapabilities: { loadSession: false }, |
| 191 | authMethods: [{ id: "test", name: "Test" }], |
| 192 | }), |
| 193 | outbound("session/new"), |
| 194 | answer("session/new", { sessionId: "agent-session", ...(input.setup as object) }), |
| 195 | ...input.modeFrames, |
| 196 | ] as never, |
| 197 | }, |
| 198 | ACP_REGISTRY_PROVIDER, |
| 199 | ); |
| 200 | const instanceId = ProviderInstanceId.make("acp-registry-mode-pick"); |
| 201 | const adapter = yield* makeAcpRegistryAdapterV2({ |
| 202 | selfInvocation: yield* resolveSelfInvocation(), |
| 203 | instanceId, |
| 204 | settings: yield* decodeAcpRegistryAdapterSettings({ |
| 205 | agentId: "fixture-agent", |
| 206 | authMethodId: "test", |
| 207 | }), |
| 208 | environment: {}, |
| 209 | makeRuntime: makeAcpReplayRuntime({ |
| 210 | transcript, |
| 211 | statusPath, |
| 212 | scriptPath: yield* path.fromFileUrl( |
| 213 | new URL("../../../scripts/acp-replay-agent.ts", import.meta.url), |
| 214 | ), |
| 215 | childProcessSpawner, |
| 216 | fileSystem, |
| 217 | }), |
| 218 | }).pipe(Effect.provide(layerInjectedRuntimeCatalog)); |
| 219 | yield* adapter |
| 220 | .openSession({ |
| 221 | threadId: ThreadId.make("thread-acp-registry-mode-pick"), |
| 222 | providerSessionId: ProviderSessionId.make("provider-session-mode-pick"), |
| 223 | modelSelection: { |
| 224 | instanceId, |
| 225 | model: "default", |
| 226 | options: [{ id: ACP_SESSION_MODE_OPTION_ID, value: input.storedModePick }], |
| 227 | }, |
| 228 | runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ |
| 229 | runtimeMode: "approval-required", |
| 230 | interactionMode: "default", |
| 231 | cwd: replayDir, |
| 232 | }), |
| 233 | }) |
| 234 | .pipe(Effect.scoped); |
| 235 | // Closing the session stops the agent, which writes its replay status in |
| 236 | // the same tick as its last answer. The script must be consumed exactly. |
| 237 | yield* makeAcpReplayCompletenessAssertion(fileSystem, statusPath, transcript); |
| 238 | }); |
| 239 | |
| 240 | it.effect("switches an agent that only advertises modes with session/set_mode", () => |
| 241 | openWithStoredModePick({ |
| 242 | setup: { |
| 243 | modes: { |
| 244 | currentModeId: "default", |
| 245 | availableModes: ["default", "autoEdit"].map((id) => ({ id, name: id })), |
| 246 | }, |
| 247 | }, |
| 248 | modeFrames: [ |
| 249 | outbound("session/set_mode", { sessionId: "agent-session", modeId: "autoEdit" }), |
| 250 | answer("session/set_mode", {}), |
| 251 | ], |
| 252 | storedModePick: "autoEdit", |
| 253 | }).pipe(Effect.provide(layerTest), Effect.scoped), |
| 254 | ); |
| 255 | |
| 256 | it.effect("switches a mode config option under its own id", () => |
| 257 | openWithStoredModePick({ |
| 258 | setup: { configOptions: [permissionModeOption("ask")] }, |
| 259 | modeFrames: [ |
| 260 | outbound("session/set_config_option", { |
| 261 | sessionId: "agent-session", |
| 262 | configId: "permission-mode", |
| 263 | value: "auto", |
| 264 | }), |
| 265 | answer("session/set_config_option", { configOptions: [permissionModeOption("auto")] }), |
| 266 | ], |
| 267 | storedModePick: "auto", |
| 268 | }).pipe(Effect.provide(layerTest), Effect.scoped), |
| 269 | ); |
| 270 | }); |
| 271 | |
| 272 | it.effect("offers client terminals to Devin only and client fs to no registry agent", () => |
| 273 | Effect.gen(function* () { |
| 274 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 275 | const path = yield* Path.Path; |
| 276 | const mockAgentPath = yield* path.fromFileUrl( |
| 277 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| 278 | ); |
| 279 | const advertisedCapabilities = Effect.fn("advertisedCapabilities")(function* ( |
| 280 | agentId: string, |
| 281 | ) { |
| 282 | let clientCapabilities: unknown; |
| 283 | const instanceId = ProviderInstanceId.make(`acp-registry-capabilities-${agentId}`); |
| 284 | const adapter = yield* makeAcpRegistryAdapterV2({ |
| 285 | selfInvocation: yield* resolveSelfInvocation(), |
| 286 | instanceId, |
| 287 | settings: yield* decodeAcpRegistryAdapterSettings({ agentId, authMethodId: "test" }), |
| 288 | environment: {}, |
| 289 | makeRuntime: (input) => |
| 290 | Effect.gen(function* () { |
| 291 | clientCapabilities = input.clientCapabilities; |
| 292 | const { processEnvironment: _processEnvironment, ...runtimeInput } = input; |
| 293 | const context = yield* Layer.build( |
| 294 | AcpSessionRuntime.layer({ |
| 295 | ...runtimeInput, |
| 296 | spawn: { |
| 297 | command: process.execPath, |
| 298 | args: [mockAgentPath], |
| 299 | cwd: input.cwd, |
| 300 | env: { T3_ACP_SESSION_LIFECYCLE: "1" }, |
| 301 | }, |
| 302 | authMethodId: "test", |
| 303 | }).pipe( |
| 304 | Layer.provide( |
| 305 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), |
| 306 | ), |
| 307 | ), |
| 308 | ); |
| 309 | return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( |
| 310 | Effect.provide(context), |
| 311 | ); |
| 312 | }), |
| 313 | }).pipe(Effect.provide(layerInjectedRuntimeCatalog)); |
| 314 | const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ |
| 315 | runtimeMode: "full-access", |
| 316 | interactionMode: "default", |
| 317 | cwd: process.cwd(), |
| 318 | }); |
| 319 | yield* adapter.openSession({ |
| 320 | threadId: ThreadId.make(`thread-acp-registry-capabilities-${agentId}`), |
| 321 | providerSessionId: ProviderSessionId.make(`provider-session-capabilities-${agentId}`), |
| 322 | modelSelection: { instanceId, model: "default" }, |
| 323 | runtimePolicy, |
| 324 | }); |
| 325 | return clientCapabilities; |
| 326 | }); |
| 327 | |
| 328 | assert.deepInclude(yield* advertisedCapabilities("devin"), { |
| 329 | fs: { readTextFile: false, writeTextFile: false }, |
| 330 | terminal: true, |
| 331 | }); |
| 332 | assert.deepInclude(yield* advertisedCapabilities("gemini"), { |
| 333 | fs: { readTextFile: false, writeTextFile: false }, |
| 334 | terminal: false, |
| 335 | }); |
| 336 | }).pipe(Effect.provide(layerTest), Effect.scoped), |
| 337 | ); |
| 338 | |
| 339 | it.effect("opens a real ACP child process resolved from registry configuration", () => |
| 340 | Effect.gen(function* () { |
| 341 | const path = yield* Path.Path; |
| 342 | const host = yield* ProviderHost.ProviderHost; |
| 343 | const mockAgentPath = yield* path.fromFileUrl( |
| 344 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| 345 | ); |
| 346 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ |
| 347 | cacheDir: host.paths.providerStatusCacheDir, |
| 348 | toolsDir: host.paths.baseDir + "/tools", |
| 349 | registryUrl, |
| 350 | }); |
| 351 | const settings = yield* decodeAcpRegistryAdapterSettings({ |
| 352 | agentId: "fixture-agent", |
| 353 | commandPath: process.execPath, |
| 354 | authMethodId: "test", |
| 355 | }); |
| 356 | let startupActive = false; |
| 357 | let startupCount = 0; |
| 358 | const instanceId = ProviderInstanceId.make("acp-registry-fixture"); |
| 359 | const commandsPublished = yield* Deferred.make<{ |
| 360 | readonly instanceId: ProviderInstanceId; |
| 361 | readonly commands: AcpRegistryAvailableCommands; |
| 362 | }>(); |
| 363 | const configurationPublished = yield* Deferred.make<AcpRegistryLiveConfiguration>(); |
| 364 | const adapter = yield* makeAcpRegistryAdapterV2({ |
| 365 | selfInvocation: yield* resolveSelfInvocation(), |
| 366 | instanceId, |
| 367 | settings, |
| 368 | environment: { |
| 369 | T3_ACP_SESSION_LIFECYCLE: "1", |
| 370 | T3_ACP_COMMAND_ADVERTISEMENT_DELAY_MS: "750", |
| 371 | }, |
| 372 | }).pipe( |
| 373 | Effect.provide( |
| 374 | Layer.mergeAll( |
| 375 | Layer.mock(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator)({ |
| 376 | withForegroundStartup: (agentId, effect) => |
| 377 | Effect.acquireUseRelease( |
| 378 | Effect.sync(() => { |
| 379 | assert.equal(agentId, "fixture-agent"); |
| 380 | startupActive = true; |
| 381 | startupCount += 1; |
| 382 | }), |
| 383 | () => effect, |
| 384 | () => |
| 385 | Effect.sync(() => { |
| 386 | startupActive = false; |
| 387 | }), |
| 388 | ), |
| 389 | runBackgroundProbe: (_agentId, effect) => effect.pipe(Effect.map(Option.some)), |
| 390 | withSessionMutation: (effect) => effect, |
| 391 | clearAvailableCommands: () => Effect.void, |
| 392 | publishAvailableCommands: (publishedInstanceId, commands) => |
| 393 | Deferred.succeed(commandsPublished, { |
| 394 | instanceId: publishedInstanceId, |
| 395 | commands, |
| 396 | }).pipe(Effect.asVoid), |
| 397 | getAvailableCommands: () => Effect.succeed(Option.none()), |
| 398 | watchAvailableCommands: () => Effect.never, |
| 399 | clearLiveConfiguration: () => Effect.void, |
| 400 | publishLiveConfiguration: (_publishedInstanceId, configuration) => |
| 401 | Deferred.succeed(configurationPublished, configuration).pipe(Effect.asVoid), |
| 402 | getLiveConfiguration: () => Effect.succeed(Option.none()), |
| 403 | watchLiveConfiguration: () => Effect.never, |
| 404 | requestUrlAuthentication: () => Effect.succeed(false), |
| 405 | acceptUrlAuthentication: () => Effect.succeed(false), |
| 406 | getUrlAuthAction: () => Effect.succeed(Option.none()), |
| 407 | watchUrlAuthAction: () => Effect.never, |
| 408 | }), |
| 409 | Layer.mock(AcpRegistrySupport.AcpRegistryCatalog)({ |
| 410 | resolve: (configuredSettings, cwd, environment) => |
| 411 | Effect.sync(() => assert.isTrue(startupActive)).pipe( |
| 412 | Effect.andThen(resolver.resolve(configuredSettings, cwd, environment)), |
| 413 | Effect.map((resolved) => ({ |
| 414 | ...resolved, |
| 415 | spawn: { |
| 416 | ...resolved.spawn, |
| 417 | args: [mockAgentPath], |
| 418 | }, |
| 419 | })), |
| 420 | ), |
| 421 | }), |
| 422 | ), |
| 423 | ), |
| 424 | ); |
| 425 | const threadId = ThreadId.make("thread-acp-registry-fixture"); |
| 426 | const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ |
| 427 | runtimeMode: "full-access", |
| 428 | interactionMode: "default", |
| 429 | cwd: process.cwd(), |
| 430 | }); |
| 431 | const modelSelection = { instanceId, model: "default" } as const; |
| 432 | const runtime = yield* adapter.openSession({ |
| 433 | threadId, |
| 434 | providerSessionId: ProviderSessionId.make("provider-session-acp-registry-fixture"), |
| 435 | modelSelection, |
| 436 | runtimePolicy, |
| 437 | }); |
| 438 | const providerThread = yield* runtime.ensureThread({ |
| 439 | threadId, |
| 440 | modelSelection, |
| 441 | runtimePolicy, |
| 442 | }); |
| 443 | |
| 444 | assert.equal(runtime.providerSession.driver, "acpRegistry"); |
| 445 | assert.equal(startupCount, 1); |
| 446 | assert.isFalse(startupActive); |
| 447 | assert.equal(providerThread.nativeThreadRef?.nativeId, "mock-session-1"); |
| 448 | assert.equal(providerThread.nativeMetadata?.itemIdentityVersion, 2); |
| 449 | assert.isTrue(runtime.providerSession.capabilities.threads.canReadThreadSnapshot); |
| 450 | assert.isTrue(runtime.providerSession.capabilities.threads.canForkThread); |
| 451 | assert.deepEqual(yield* Deferred.await(commandsPublished), { |
| 452 | instanceId, |
| 453 | commands: { |
| 454 | slashCommands: [ |
| 455 | { |
| 456 | name: "review", |
| 457 | description: "Review the current changes", |
| 458 | input: { hint: "focus" }, |
| 459 | }, |
| 460 | ], |
| 461 | skills: [ |
| 462 | { |
| 463 | name: "workspace-skill", |
| 464 | description: "Run the workspace skill", |
| 465 | path: "acp://skill/workspace-skill", |
| 466 | scope: "agent", |
| 467 | enabled: true, |
| 468 | }, |
| 469 | ], |
| 470 | }, |
| 471 | }); |
| 472 | const configuration = yield* Deferred.await(configurationPublished); |
| 473 | assert.equal(configuration.currentModelId, "default"); |
| 474 | assert.deepInclude(configuration.models[0], { |
| 475 | id: "default", |
| 476 | name: "Auto", |
| 477 | description: null, |
| 478 | }); |
| 479 | }).pipe(Effect.provide(layerTest), Effect.scoped), |
| 480 | ); |
| 481 | }); |