Skip to content
Source
Sign inSign up
Open History to see the last commit

AcpRegistryProbe.test.ts

516 lines17.0 KB

1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodePath from "node:path";
3import * as NodeURL from "node:url";
4​
5import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import * as Layer from "effect/Layer";
8import { ProviderInstanceId } from "@t3tools/contracts";
9import { AcpRegistrySettings } from "@t3tools/provider-acp-registry/settings";
10import { describe, expect, it } from "@effect/vitest";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Schema from "effect/Schema";
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 id: "effort",
71 name: "Effort",
72 category: "thought_level",
73 type: "select",
74 currentValue: "high",
75 options: [{ value: "high", name: "High" }],
76 },
77 {
78 id: "mode",
79 name: "Mode",
80 category: "mode",
81 type: "select",
82 currentValue: "ask",
83 options: [{ value: "ask", name: "Ask" }],
84 },
85 ],
86 },
87 modelConfigId: "model",
88 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
89​
90 expect(result).toEqual({
91 instanceId,
92 ready: true,
93 icon: null,
94 authMethods: [
95 { id: "chatgpt", name: "ChatGPT", description: null, type: "agent" },
96 {
97 id: "api-key",
98 name: "API key",
99 description: null,
100 type: "env_var",
101 envVarNames: ["OPENAI_API_KEY"],
102 },
103 { id: "login", name: "Terminal login", description: null, type: "terminal" },
104 ],
105 models: [{ id: "gpt-5.4", name: "GPT-5.4", description: null }],
106 currentModelId: "gpt-5.4",
107 configOptions: [
108 {
109 id: "effort",
110 label: "Effort",
111 type: "select",
112 options: [{ id: "high", label: "High" }],
113 currentValue: "high",
114 role: "reasoning",
115 },
116 {
117 id: "mode",
118 label: "Mode",
119 type: "select",
120 options: [{ id: "ask", label: "Ask" }],
121 currentValue: "ask",
122 },
123 ],
124 modelOptionIds: ["effort"],
125 sessionManagement: {
126 canList: true,
127 canLoad: true,
128 canResume: true,
129 canLogout: true,
130 canDelete: false,
131 canConfigureProviders: false,
132 },
133 });
134 });
135​
136 it("composes runnable terminal auth commands from the resolved spawn recipe", () => {
137 const methods = normalizeAcpRegistryAuthMethods(
138 [
139 {
140 id: "login",
141 name: "Terminal login",
142 type: "terminal",
143 args: ["auth", "log in"],
144 env: { FORCE_TTY: "1" },
145 },
146 ],
147 { command: "/opt/agents/devin", args: ["--acp"] },
148 );
149​
150 expect(methods).toEqual([
151 {
152 id: "login",
153 name: "Terminal login",
154 description: null,
155 type: "terminal",
156 command: "FORCE_TTY=1 /opt/agents/devin --acp auth 'log in'",
157 },
158 ]);
159 });
160​
161 it("omits unsafe truncated auth commands instead of publishing broken shell", () => {
162 const methods = normalizeAcpRegistryAuthMethods(
163 [
164 {
165 id: "login",
166 name: "Terminal login",
167 type: "terminal",
168 args: ["x".repeat(2_048)],
169 },
170 ],
171 { command: "/opt/agents/devin", args: ["--acp"] },
172 );
173​
174 expect(methods[0]).not.toHaveProperty("command");
175 });
176​
177 it("omits overlong opaque model ids instead of publishing mutated ids", () => {
178 const result = acpRegistryProbeResult(instanceId, {
179 sessionId: "probe-session",
180 initializeResult: { protocolVersion: 1 },
181 sessionSetupResult: {
182 sessionId: "probe-session",
183 configOptions: [
184 {
185 id: "model",
186 name: "Model",
187 category: "model",
188 type: "select",
189 currentValue: "x".repeat(129),
190 options: [
191 { value: "x".repeat(129), name: "Too long" },
192 { value: "valid", name: "Valid" },
193 ],
194 },
195 ],
196 },
197 modelConfigId: "model",
198 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
199​
200 expect(result.models).toEqual([{ id: "valid", name: "Valid", description: null }]);
201 expect(result.currentModelId).toBeNull();
202 });
203​
204 it("preserves grouped model values and advertises reasoning choices", () => {
205 const codexModel = '["codex","gpt-6.1-sol"]';
206 const goModel = '["opencode-go","deepseek-v4.1-flash"]';
207 const result = acpRegistryProbeResult(instanceId, {
208 sessionId: "probe-session",
209 initializeResult: { protocolVersion: 1 },
210 sessionSetupResult: {
211 sessionId: "probe-session",
212 configOptions: [
213 {
214 id: "model",
215 name: "Model",
216 category: "model",
217 type: "select",
218 currentValue: goModel,
219 options: [
220 {
221 groupId: "codex",
222 name: "Codex",
223 options: [{ value: codexModel, name: "GPT-6.1 Sol" }],
224 },
225 {
226 groupId: "opencode-go",
227 name: "OpenCode Go",
228 options: [{ value: goModel, name: "DeepSeek V4.1 Flash" }],
229 },
230 ],
231 },
232 {
233 id: "reasoning",
234 name: "Reasoning",
235 category: "thought_level",
236 type: "select",
237 currentValue: "high",
238 options: [
239 {
240 groupId: "effort",
241 name: "Effort",
242 options: [
243 { value: "medium", name: "Medium" },
244 { value: "high", name: "High" },
245 ],
246 },
247 ],
248 },
249 ],
250 },
251 modelConfigId: "model",
252 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
253​
254 expect(result.models).toEqual([
255 { id: codexModel, name: "GPT-6.1 Sol", description: null },
256 { id: goModel, name: "DeepSeek V4.1 Flash", description: null },
257 ]);
258 expect(result.currentModelId).toBe(goModel);
259 expect(result.configOptions).toEqual([
260 {
261 id: "reasoning",
262 label: "Reasoning",
263 type: "select",
264 currentValue: "high",
265 options: [
266 { id: "medium", label: "Medium" },
267 { id: "high", label: "High" },
268 ],
269 role: "reasoning",
270 },
271 ]);
272 });
273​
274 it.each(["config option", "session model state"] as const)(
275 "retains the current model beyond the catalog cap from %s",
276 (source) => {
277 const models = Array.from({ length: 257 }, (_, index) => ({
278 modelId: `model-${index}`,
279 name: `Model ${index}`,
280 }));
281 const result = acpRegistryProbeResult(instanceId, {
282 sessionId: "probe-session",
283 initializeResult: { protocolVersion: 1 },
284 sessionSetupResult: {
285 sessionId: "probe-session",
286 ...(source === "config option"
287 ? {
288 configOptions: [
289 {
290 id: "model",
291 name: "Model",
292 category: "model",
293 type: "select",
294 currentValue: "model-256",
295 options: models.map(({ modelId, name }) => ({ value: modelId, name })),
296 },
297 ],
298 }
299 : { models: { currentModelId: "model-256", availableModels: models } }),
300 },
301 modelConfigId: source === "config option" ? "model" : undefined,
302 } satisfies AcpSessionRuntime.AcpSessionRuntimeStartResult);
303​
304 expect(result.models).toHaveLength(256);
305 expect(result.models[0]?.id).toBe("model-0");
306 expect(result.models.at(-1)?.id).toBe("model-256");
307 expect(result.currentModelId).toBe("model-256");
308 },
309 );
310​
311 it("keeps every distinct advertised command and de-duplicates the rest", () => {
312 const commands = normalizeAcpRegistryCommands([
313 {
314 name: "create_plan",
315 description: " Create a plan ",
316 input: { type: "text", hint: " topic " },
317 },
318 { name: "CREATE_PLAN", description: "duplicate" },
319 ...Array.from({ length: 140 }, (_, index) => ({
320 name: `command_${index}`,
321 description: "",
322 })),
323 { name: "$late-skill", description: "Advertised after 128 commands" },
324 ]);
325​
326 expect(commands.slashCommands).toHaveLength(141);
327 expect(commands.slashCommands.at(-1)).toEqual({ name: "command_139" });
328 expect(commands.slashCommands[0]).toEqual({
329 name: "create_plan",
330 description: "Create a plan",
331 input: { hint: "topic" },
332 });
333 expect(
334 commands.slashCommands.filter((command) => command.name.toLowerCase() === "create_plan"),
335 ).toHaveLength(1);
336 expect(commands.skills.map((skill) => skill.name)).toEqual(["late-skill"]);
337 });
338​
339 it("routes dollar-prefixed ACP commands to the provider skill menu", () => {
340 expect(
341 normalizeAcpRegistryCommands([
342 { name: "$workspace-skill", description: "Run the workspace skill" },
343 { name: "$WORKSPACE-SKILL", description: "duplicate" },
344 { name: "$", description: "empty" },
345 { name: "review", description: "Review changes" },
346 ]),
347 ).toEqual({
348 slashCommands: [{ name: "review", description: "Review changes" }],
349 skills: [
350 {
351 name: "workspace-skill",
352 description: "Run the workspace skill",
353 path: "acp://skill/workspace-skill",
354 scope: "agent",
355 enabled: true,
356 },
357 ],
358 });
359 });
360​
361 it.effect("lists native sessions and logs out through generic ACP lifecycle methods", () => {
362 const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({
363 search: () => Effect.die("unused search"),
364 prepare: () => Effect.die("unused prepare"),
365 inspect: () => Effect.die("unused inspect"),
366 uninstallManagedBinary: () => Effect.die("unused uninstall"),
367 resolve: () =>
368 Effect.succeed({
369 agent: {
370 id: "mock-agent",
371 name: "Mock Agent",
372 version: "1.0.0",
373 description: "ACP session management test agent",
374 distribution: { npx: { package: "mock-agent@1.0.0" } },
375 },
376 distribution: "npx" as const,
377 spawn: {
378 command: "node",
379 args: [mockAgentPath],
380 env: {
381 ...process.env,
382 T3_ACP_SESSION_LIFECYCLE: "1",
383 T3_ACP_AUTH_METHOD_ID: "mock-login",
384 },
385 },
386 }),
387 });
388 const input = {
389 instanceId,
390 settings: decodeSettings({ agentId: "mock-agent" }),
391 cwd: process.cwd(),
392 environment: process.env,
393 };
394​
395 return Effect.gen(function* () {
396 const listed = yield* listAcpRegistrySessions(input);
397 expect(listed).toMatchObject({
398 canLoad: true,
399 canResume: true,
400 nextCursor: null,
401 sessions: [
402 {
403 sessionId: "mock-session-1",
404 cwd: process.cwd(),
405 importedThreadId: null,
406 },
407 ],
408 });
409 yield* logoutAcpRegistry(input);
410 }).pipe(
411 Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog),
412 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
413 Effect.scoped,
414 );
415 });
416​
417 it.live.each(["succeeds", "fails"] as const)(
418 "deletes its health-probe session when deletion is advertised and %s",
419 (deleteOutcome) =>
420 Effect.gen(function* () {
421 const fs = yield* FileSystem.FileSystem;
422 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "acp-probe-delete-" });
423 const requestLogPath = NodePath.join(directory, "requests.ndjson");
424 const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({
425 search: () => Effect.die("unused search"),
426 prepare: () => Effect.die("unused prepare"),
427 inspect: () => Effect.die("unused inspect"),
428 uninstallManagedBinary: () => Effect.die("unused uninstall"),
429 resolve: () =>
430 Effect.succeed({
431 agent: {
432 id: "mock-agent",
433 name: "Mock Agent",
434 version: "1.0.0",
435 description: "ACP probe cleanup test agent",
436 distribution: { npx: { package: "mock-agent@1.0.0" } },
437 },
438 distribution: "npx" as const,
439 spawn: {
440 command: "node",
441 args: [mockAgentPath],
442 env: {
443 ...process.env,
444 T3_ACP_V2_MANAGEMENT: "1",
445 T3_ACP_REQUEST_LOG_PATH: requestLogPath,
446 ...(deleteOutcome === "fails" ? { T3_ACP_FAIL_DELETE_SESSION: "1" } : {}),
447 },
448 },
449 }),
450 });
451​
452 const result = yield* probeAcpRegistryConfiguration({
453 instanceId,
454 settings: decodeSettings({ agentId: "mock-agent" }),
455 cwd: process.cwd(),
456 environment: process.env,
457 }).pipe(Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog));
458 expect(result.probe.ready).toBe(true);
459 expect(result.probe.sessionManagement.canDelete).toBe(true);
460​
461 const requests = (yield* fs.readFileString(requestLogPath))
462 .split("\n")
463 .filter((line) => line.length > 0)
464 .map((line) => JSON.parse(line));
465 expect(requests.filter((request) => request.method === "session/delete")).toEqual([
466 expect.objectContaining({ params: { sessionId: "mock-session-1" } }),
467 ]);
468 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
469 );
470​
471 it("distinguishes authentication failures from generic probe failures", () => {
472 const advertised = normalizeAcpRegistryAuthMethods([
473 { id: "grok-login", name: "Log in with Grok" },
474 ]);
475 const cause = new EffectAcpErrors.AcpRequestError({
476 code: -32000,
477 errorMessage: "login required",
478 });
479 const authFailure = acpRegistryProbeFailure(cause, advertised);
480 expect(authFailure.reason).toBe("authentication_failed");
481 expect(authFailure.message).toBe("The ACP agent could not complete authentication.");
482 expect(authFailure.cause).toBe(cause);
483 expect(authFailure.authMethods).toEqual([
484 {
485 id: "grok-login",
486 name: "Log in with Grok",
487 description: null,
488 type: "agent",
489 },
490 ]);
491 expect(
492 acpRegistryProbeFailure(
493 new EffectAcpErrors.AcpTransportError({
494 detail: "Authentication required",
495 cause: "credentials missing",
496 }),
497 ).reason,
498 ).toBe("authentication_failed");
499 const genericCause = new EffectAcpErrors.AcpTransportError({
500 detail: "connection closed",
501 cause: "closed",
502 });
503 const genericFailure = acpRegistryProbeFailure(genericCause);
504 expect(genericFailure.reason).toBe("probe_failed");
505 expect(genericFailure.message).toBe("The ACP agent could not create a test session.");
506 expect(genericFailure.cause).toBe(genericCause);
507 expect(
508 acpRegistryProbeFailure(
509 new EffectAcpErrors.AcpTransportError({
510 detail: "Failed while logging request",
511 cause: "logging failed",
512 }),
513 ).reason,
514 ).toBe("probe_failed");
515 });
516});