1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";
4import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts";
5import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
6import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
11import * as Path from "effect/Path";
12import * as Schema from "effect/Schema";
13import * as HttpClient from "effect/http/HttpClient";
14import * as HttpClientResponse from "effect/http/HttpClientResponse";
15import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
16import * as EffectAcpErrors from "effect-acp/errors";
17
18import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
19import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
20import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
21import * as ServerSettings from "../../serverSettings.ts";
22import type {
23 AcpRegistryAvailableCommands,
24 AcpRegistryLiveConfiguration,
25} from "@t3tools/provider-acp-registry/testing";
26import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport";
27import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator";
28import { ACP_SESSION_MODE_OPTION_ID } from "@t3tools/provider-acp/server/sessionConfig";
29import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
30import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
31import {
32 decodeAcpReplayTranscript,
33 makeAcpReplayCompletenessAssertion,
34 makeAcpReplayRuntime,
35} from "./AcpAdapterV2.testkit.ts";
36import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
37import { BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2 } from "../builtInProviderAdapterDrivers.ts";
38import { AcpRegistryAdapterV2Driver } from "@t3tools/provider-acp-registry/server";
39import {
40 ACP_REGISTRY_PROVIDER,
41 makeAcpRegistryAdapterV2,
42 acpRegistryPromptFailure,
43} from "@t3tools/provider-acp-registry/testing";
44
45const registryUrl = "https://registry.test/registry.json";
46const decodeAcpRegistryAdapterSettings = Schema.decodeUnknownEffect(
47 AcpRegistryAdapterV2Driver.configSchema,
48);
49
50const layerHost = TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer));
51
52const 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.
89const layerInjectedRuntimeCatalog = Layer.mock(AcpRegistrySupport.AcpRegistryCatalog)({
90 resolve: () => Effect.die("the runtime is injected"),
91});
92
93const layerTest = Layer.mergeAll(
94 NodeServices.layer,
95 IdAllocator.layer,
96 McpProviderSessions.layer,
97 ScriptProgress.layerUnavailable,
98 layerHost,
99 layerRegistry,
100 ServerSettings.layerTest(),
101);
102
103describe("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});