1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodePath from "node:path";
3import * as NodeURL from "node:url";
4
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { ProviderInstanceId } from "@t3tools/contracts";
7import { AcpRegistrySettings } from "@t3tools/provider-acp-registry/settings";
8import { describe, expect, it } from "@effect/vitest";
9import * as Deferred from "effect/Deferred";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import * as Schema from "effect/Schema";
13import * as TestClock from "effect/testing/TestClock";
14import * as EffectAcpErrors from "effect-acp/errors";
15
16import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
17import {
18 acpRegistryProbeFailure,
19 acpRegistryProbeResult,
20 listAcpRegistrySessions,
21 logoutAcpRegistry,
22 normalizeAcpRegistryAuthMethods,
23 normalizeAcpRegistryCommands,
24 probeAcpRegistryConfiguration,
25} from "@t3tools/provider-acp-registry/testing";
26import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport";
27
28const instanceId = ProviderInstanceId.make("acpRegistry_codex");
29const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
30const mockAgentPath = NodePath.join(__dirname, "../../scripts/acp-mock-agent.ts");
31const decodeSettings = Schema.decodeSync(AcpRegistrySettings);
32
33describe("ACP Registry probe", () => {
34 it("returns advertised auth methods and de-duplicated models", () => {
35 const result = acpRegistryProbeResult(instanceId, {
36 sessionId: "probe-session",
37 initializeResult: {
38 protocolVersion: 1,
39 agentCapabilities: {
40 loadSession: true,
41 auth: { logout: {} },
42 sessionCapabilities: { list: {}, resume: {} },
43 },
44 authMethods: [
45 { id: "chatgpt", name: "ChatGPT" },
46 {
47 id: "api-key",
48 name: "API key",
49 type: "env_var",
50 vars: [{ name: "OPENAI_API_KEY", label: "OpenAI API key" }],
51 },
52 { id: "login", name: "Terminal login", type: "terminal" },
53 ],
54 },
55 sessionSetupResult: {
56 sessionId: "probe-session",
57 configOptions: [
58 {
59 id: "model",
60 name: "Model",
61 category: "model",
62 type: "select",
63 currentValue: "gpt-5.4",
64 options: [
65 { value: "gpt-5.4", name: "GPT-5.4" },
66 { value: "gpt-5.4", name: "Duplicate" },
67 ],
68 },
69 ],
70 },
71 modelConfigId: "model",
72 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
73
74 expect(result).toEqual({
75 instanceId,
76 ready: true,
77 icon: null,
78 authMethods: [
79 { id: "chatgpt", name: "ChatGPT", description: null, type: "agent" },
80 {
81 id: "api-key",
82 name: "API key",
83 description: null,
84 type: "env_var",
85 envVarNames: ["OPENAI_API_KEY"],
86 },
87 { id: "login", name: "Terminal login", description: null, type: "terminal" },
88 ],
89 models: [{ id: "gpt-5.4", name: "GPT-5.4", description: null }],
90 currentModelId: "gpt-5.4",
91 configOptions: [],
92 sessionManagement: {
93 canList: true,
94 canLoad: true,
95 canResume: true,
96 canLogout: true,
97 canDelete: false,
98 canConfigureProviders: false,
99 },
100 });
101 });
102
103 it("composes runnable terminal auth commands from the resolved spawn recipe", () => {
104 const methods = normalizeAcpRegistryAuthMethods(
105 [
106 {
107 id: "login",
108 name: "Terminal login",
109 type: "terminal",
110 args: ["auth", "log in"],
111 env: { FORCE_TTY: "1" },
112 },
113 ],
114 { command: "/opt/agents/devin", args: ["--acp"] },
115 );
116
117 expect(methods).toEqual([
118 {
119 id: "login",
120 name: "Terminal login",
121 description: null,
122 type: "terminal",
123 command: "FORCE_TTY=1 /opt/agents/devin --acp auth 'log in'",
124 },
125 ]);
126 });
127
128 it("omits unsafe truncated auth commands instead of publishing broken shell", () => {
129 const methods = normalizeAcpRegistryAuthMethods(
130 [
131 {
132 id: "login",
133 name: "Terminal login",
134 type: "terminal",
135 args: ["x".repeat(2_048)],
136 },
137 ],
138 { command: "/opt/agents/devin", args: ["--acp"] },
139 );
140
141 expect(methods[0]).not.toHaveProperty("command");
142 });
143
144 it("omits overlong opaque auth method and environment variable ids", () => {
145 const methods = normalizeAcpRegistryAuthMethods([
146 { id: "x".repeat(129), name: "Invalid" },
147 {
148 id: "api-key",
149 name: "API key",
150 type: "env_var",
151 vars: [
152 { name: "y".repeat(129), label: "Invalid" },
153 { name: "OPENAI_API_KEY", label: "OpenAI API key" },
154 ],
155 },
156 ]);
157
158 expect(methods).toEqual([
159 {
160 id: "api-key",
161 name: "API key",
162 description: null,
163 type: "env_var",
164 envVarNames: ["OPENAI_API_KEY"],
165 },
166 ]);
167 });
168
169 it("falls back to model-category configuration options", () => {
170 const result = acpRegistryProbeResult(instanceId, {
171 sessionId: "probe-session",
172 initializeResult: { protocolVersion: 1 },
173 sessionSetupResult: {
174 sessionId: "probe-session",
175 configOptions: [
176 {
177 id: "model",
178 name: "Model",
179 category: "model",
180 type: "select",
181 currentValue: "sonnet",
182 options: [
183 { value: "sonnet", name: "Sonnet", description: "Balanced" },
184 { value: "haiku", name: "Haiku" },
185 ],
186 },
187 ],
188 },
189 modelConfigId: "model",
190 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
191
192 expect(result.models).toEqual([
193 { id: "sonnet", name: "Sonnet", description: "Balanced" },
194 { id: "haiku", name: "Haiku", description: null },
195 ]);
196 expect(result.currentModelId).toBe("sonnet");
197 });
198
199 it("omits overlong opaque model ids instead of publishing mutated ids", () => {
200 const result = acpRegistryProbeResult(instanceId, {
201 sessionId: "probe-session",
202 initializeResult: { protocolVersion: 1 },
203 sessionSetupResult: {
204 sessionId: "probe-session",
205 configOptions: [
206 {
207 id: "model",
208 name: "Model",
209 category: "model",
210 type: "select",
211 currentValue: "x".repeat(129),
212 options: [
213 { value: "x".repeat(129), name: "Too long" },
214 { value: "valid", name: "Valid" },
215 ],
216 },
217 ],
218 },
219 modelConfigId: "model",
220 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
221
222 expect(result.models).toEqual([{ id: "valid", name: "Valid", description: null }]);
223 expect(result.currentModelId).toBeNull();
224 });
225
226 it("preserves grouped model values and advertises reasoning choices", () => {
227 const codexModel = '["codex","gpt-6.1-sol"]';
228 const goModel = '["opencode-go","deepseek-v4.1-flash"]';
229 const result = acpRegistryProbeResult(instanceId, {
230 sessionId: "probe-session",
231 initializeResult: { protocolVersion: 1 },
232 sessionSetupResult: {
233 sessionId: "probe-session",
234 configOptions: [
235 {
236 id: "model",
237 name: "Model",
238 category: "model",
239 type: "select",
240 currentValue: goModel,
241 options: [
242 {
243 groupId: "codex",
244 name: "Codex",
245 options: [{ value: codexModel, name: "GPT-6.1 Sol" }],
246 },
247 {
248 groupId: "opencode-go",
249 name: "OpenCode Go",
250 options: [{ value: goModel, name: "DeepSeek V4.1 Flash" }],
251 },
252 ],
253 },
254 {
255 id: "reasoning",
256 name: "Reasoning",
257 category: "thought_level",
258 type: "select",
259 currentValue: "high",
260 options: [
261 {
262 groupId: "effort",
263 name: "Effort",
264 options: [
265 { value: "medium", name: "Medium" },
266 { value: "high", name: "High" },
267 ],
268 },
269 ],
270 },
271 ],
272 },
273 modelConfigId: "model",
274 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
275
276 expect(result.models).toEqual([
277 { id: codexModel, name: "GPT-6.1 Sol", description: null },
278 { id: goModel, name: "DeepSeek V4.1 Flash", description: null },
279 ]);
280 expect(result.currentModelId).toBe(goModel);
281 expect(result.configOptions).toEqual([
282 {
283 id: "reasoning",
284 label: "Reasoning",
285 type: "select",
286 currentValue: "high",
287 options: [
288 { id: "medium", label: "Medium" },
289 { id: "high", label: "High" },
290 ],
291 },
292 ]);
293 });
294
295 it("omits a current model that falls outside the bounded model catalog", () => {
296 const result = acpRegistryProbeResult(instanceId, {
297 sessionId: "probe-session",
298 initializeResult: { protocolVersion: 1 },
299 sessionSetupResult: {
300 sessionId: "probe-session",
301 configOptions: [
302 {
303 id: "model",
304 name: "Model",
305 category: "model",
306 type: "select",
307 currentValue: "model-256",
308 options: Array.from({ length: 257 }, (_, index) => ({
309 value: `model-${index}`,
310 name: `Model ${index}`,
311 })),
312 },
313 ],
314 },
315 modelConfigId: "model",
316 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
317
318 expect(result.models).toHaveLength(256);
319 expect(result.currentModelId).toBeNull();
320 });
321
322 it("bounds and de-duplicates advertised commands", () => {
323 const commands = normalizeAcpRegistryCommands([
324 {
325 name: "create_plan",
326 description: " Create a plan ",
327 input: { type: "text", hint: " topic " },
328 },
329 { name: "CREATE_PLAN", description: "duplicate" },
330 ...Array.from({ length: 140 }, (_, index) => ({
331 name: `command_${index}`,
332 description: "",
333 })),
334 ]);
335
336 expect(commands.slashCommands).toHaveLength(128);
337 expect(commands.slashCommands[0]).toEqual({
338 name: "create_plan",
339 description: "Create a plan",
340 input: { hint: "topic" },
341 });
342 expect(
343 commands.slashCommands.filter((command) => command.name.toLowerCase() === "create_plan"),
344 ).toHaveLength(1);
345 expect(commands.skills).toEqual([]);
346 });
347
348 it("routes dollar-prefixed ACP commands to the provider skill menu", () => {
349 expect(
350 normalizeAcpRegistryCommands([
351 { name: "$workspace-skill", description: "Run the workspace skill" },
352 { name: "$WORKSPACE-SKILL", description: "duplicate" },
353 { name: "$", description: "empty" },
354 { name: "review", description: "Review changes" },
355 ]),
356 ).toEqual({
357 slashCommands: [{ name: "review", description: "Review changes" }],
358 skills: [
359 {
360 name: "workspace-skill",
361 description: "Run the workspace skill",
362 path: "acp://skill/workspace-skill",
363 scope: "agent",
364 enabled: true,
365 },
366 ],
367 });
368 });
369
370 it.effect("captures commands advertised just after session creation", () =>
371 Effect.gen(function* () {
372 const result = yield* probeAcpRegistryConfiguration({
373 instanceId,
374 settings: decodeSettings({ agentId: "mock-agent" }),
375 cwd: process.cwd(),
376 environment: process.env,
377 });
378
379 expect(result.slashCommands).toEqual([
380 {
381 name: "review",
382 description: "Review the current changes",
383 input: { hint: "focus" },
384 },
385 ]);
386 expect(result.skills).toEqual([
387 {
388 name: "workspace-skill",
389 description: "Run the workspace skill",
390 path: "acp://skill/workspace-skill",
391 scope: "agent",
392 enabled: true,
393 },
394 ]);
395 }).pipe(
396 Effect.provideService(
397 AcpRegistrySupport.AcpRegistryCatalog,
398 AcpRegistrySupport.AcpRegistryCatalog.of({
399 search: () => Effect.die("unused search"),
400 prepare: () => Effect.die("unused prepare"),
401 inspect: () => Effect.die("unused inspect"),
402 uninstallManagedBinary: () => Effect.die("unused uninstall"),
403 resolve: () =>
404 Effect.succeed({
405 agent: {
406 id: "mock-agent",
407 name: "Mock Agent",
408 version: "1.0.0",
409 description: "ACP probe test agent",
410 distribution: { npx: { package: "mock-agent@1.0.0" } },
411 },
412 distribution: "npx",
413 spawn: {
414 command: "node",
415 args: [mockAgentPath],
416 env: {
417 ...process.env,
418 T3_ACP_COMMAND_ADVERTISEMENT_DELAY_MS: "25",
419 },
420 },
421 }),
422 }),
423 ),
424 Effect.provide(NodeServices.layer),
425 Effect.scoped,
426 ),
427 );
428
429 it.effect("includes package resolution in the probe timeout", () =>
430 Effect.gen(function* () {
431 const resolveStarted = yield* Deferred.make<void>();
432 const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({
433 search: () => Effect.die("unused search"),
434 prepare: () => Effect.die("unused prepare"),
435 inspect: () => Effect.die("unused inspect"),
436 uninstallManagedBinary: () => Effect.die("unused uninstall"),
437 resolve: () =>
438 Deferred.succeed(resolveStarted, undefined).pipe(Effect.andThen(Effect.never)),
439 });
440 const probe = yield* probeAcpRegistryConfiguration({
441 instanceId,
442 settings: decodeSettings({ agentId: "slow-agent" }),
443 cwd: process.cwd(),
444 environment: process.env,
445 }).pipe(
446 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
447 Effect.flip,
448 Effect.forkChild({ startImmediately: true }),
449 );
450
451 yield* Deferred.await(resolveStarted);
452 yield* TestClock.adjust("60 seconds");
453
454 expect(yield* Fiber.join(probe)).toMatchObject({
455 reason: "probe_failed",
456 message: expect.stringContaining("within 60 seconds"),
457 });
458 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
459 );
460
461 it.effect("lists native sessions and logs out through generic ACP lifecycle methods", () => {
462 const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({
463 search: () => Effect.die("unused search"),
464 prepare: () => Effect.die("unused prepare"),
465 inspect: () => Effect.die("unused inspect"),
466 uninstallManagedBinary: () => Effect.die("unused uninstall"),
467 resolve: () =>
468 Effect.succeed({
469 agent: {
470 id: "mock-agent",
471 name: "Mock Agent",
472 version: "1.0.0",
473 description: "ACP session management test agent",
474 distribution: { npx: { package: "mock-agent@1.0.0" } },
475 },
476 distribution: "npx" as const,
477 spawn: {
478 command: "node",
479 args: [mockAgentPath],
480 env: {
481 ...process.env,
482 T3_ACP_SESSION_LIFECYCLE: "1",
483 T3_ACP_AUTH_METHOD_ID: "mock-login",
484 },
485 },
486 }),
487 });
488 const input = {
489 instanceId,
490 settings: decodeSettings({ agentId: "mock-agent" }),
491 cwd: process.cwd(),
492 environment: process.env,
493 };
494
495 return Effect.gen(function* () {
496 const listed = yield* listAcpRegistrySessions(input);
497 expect(listed).toMatchObject({
498 canLoad: true,
499 canResume: true,
500 nextCursor: null,
501 sessions: [
502 {
503 sessionId: "mock-session-1",
504 cwd: process.cwd(),
505 importedThreadId: null,
506 },
507 ],
508 });
509 yield* logoutAcpRegistry(input);
510 }).pipe(
511 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
512 Effect.provide(NodeServices.layer),
513 Effect.scoped,
514 );
515 });
516
517 it.effect("preserves the ACP method error for unsupported session management", () => {
518 const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({
519 search: () => Effect.die("unused search"),
520 prepare: () => Effect.die("unused prepare"),
521 inspect: () => Effect.die("unused inspect"),
522 uninstallManagedBinary: () => Effect.die("unused uninstall"),
523 resolve: () =>
524 Effect.succeed({
525 agent: {
526 id: "mock-agent",
527 name: "Mock Agent",
528 version: "1.0.0",
529 description: "ACP unsupported management test agent",
530 distribution: { npx: { package: "mock-agent@1.0.0" } },
531 },
532 distribution: "npx" as const,
533 spawn: {
534 command: "node",
535 args: [mockAgentPath],
536 env: { ...process.env, T3_ACP_OMIT_SESSION_LIST_HANDLER: "1" },
537 },
538 }),
539 });
540
541 return Effect.gen(function* () {
542 const failure = yield* listAcpRegistrySessions({
543 instanceId,
544 settings: decodeSettings({ agentId: "mock-agent" }),
545 cwd: process.cwd(),
546 environment: process.env,
547 }).pipe(Effect.flip);
548
549 expect(failure).toMatchObject({
550 reason: "session_list_unsupported",
551 message: "The ACP agent does not advertise session listing.",
552 cause: { _tag: "AcpRequestError", code: -32601 },
553 });
554 }).pipe(
555 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
556 Effect.provide(NodeServices.layer),
557 Effect.scoped,
558 );
559 });
560
561 it("distinguishes authentication failures from generic probe failures", () => {
562 const advertised = normalizeAcpRegistryAuthMethods([
563 { id: "grok-login", name: "Log in with Grok" },
564 ]);
565 const cause = new EffectAcpErrors.AcpRequestError({
566 code: -32000,
567 errorMessage: "login required",
568 });
569 const authFailure = acpRegistryProbeFailure(cause, advertised);
570 expect(authFailure.reason).toBe("authentication_failed");
571 expect(authFailure.message).toBe("The ACP agent could not complete authentication.");
572 expect(authFailure.cause).toBe(cause);
573 expect(authFailure.authMethods).toEqual([
574 {
575 id: "grok-login",
576 name: "Log in with Grok",
577 description: null,
578 type: "agent",
579 },
580 ]);
581 expect(
582 acpRegistryProbeFailure(
583 new EffectAcpErrors.AcpTransportError({
584 detail: "Authentication required",
585 cause: "credentials missing",
586 }),
587 ).reason,
588 ).toBe("authentication_failed");
589 const genericCause = new EffectAcpErrors.AcpTransportError({
590 detail: "connection closed",
591 cause: "closed",
592 });
593 const genericFailure = acpRegistryProbeFailure(genericCause);
594 expect(genericFailure.reason).toBe("probe_failed");
595 expect(genericFailure.message).toBe("The ACP agent could not create a test session.");
596 expect(genericFailure.cause).toBe(genericCause);
597 expect(
598 acpRegistryProbeFailure(
599 new EffectAcpErrors.AcpTransportError({
600 detail: "Failed while logging request",
601 cause: "logging failed",
602 }),
603 ).reason,
604 ).toBe("probe_failed");
605 });
606});