| 1 | import { AcpRegistryOperationError, ProviderInstanceId } from "@t3tools/contracts"; |
| 2 | import { AcpRegistrySettings } from "../settings.ts"; |
| 3 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Option from "effect/Option"; |
| 6 | import * as Schema from "effect/Schema"; |
| 7 | |
| 8 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; |
| 9 | import { |
| 10 | acpRegistrySnapshotReadiness, |
| 11 | applyAcpRegistryAvailableCommands, |
| 12 | applyAcpRegistryLiveConfiguration, |
| 13 | buildCheckedAcpRegistrySnapshot, |
| 14 | checkAcpRegistryProviderReadiness, |
| 15 | checkAcpRegistryProviderStatus, |
| 16 | } from "./driver.ts"; |
| 17 | |
| 18 | const decodeSettings = Schema.decodeSync(AcpRegistrySettings); |
| 19 | const identity = { |
| 20 | instanceId: ProviderInstanceId.make("acpRegistry_test"), |
| 21 | displayName: "Test ACP", |
| 22 | accentColor: undefined, |
| 23 | continuationKey: "acpRegistry:instance:acpRegistry_test", |
| 24 | }; |
| 25 | const noSessionManagement = { |
| 26 | canList: false, |
| 27 | canLoad: false, |
| 28 | canResume: false, |
| 29 | canLogout: false, |
| 30 | canDelete: false, |
| 31 | canConfigureProviders: false, |
| 32 | } as const; |
| 33 | |
| 34 | function catalogWithInspection( |
| 35 | inspection: AcpRegistrySupport.AcpRegistryInspection, |
| 36 | ): AcpRegistrySupport.AcpRegistryCatalog["Service"] { |
| 37 | return { |
| 38 | search: () => Effect.die("unused search"), |
| 39 | prepare: () => Effect.die("unused prepare"), |
| 40 | inspect: () => Effect.succeed(inspection), |
| 41 | resolve: () => Effect.die("unused resolve"), |
| 42 | uninstallManagedBinary: () => Effect.die("unused uninstall"), |
| 43 | }; |
| 44 | } |
| 45 | |
| 46 | describe("acpRegistrySnapshotReadiness", () => { |
| 47 | it("treats a live empty command advertisement as an authoritative replacement", () => { |
| 48 | const provider = buildCheckedAcpRegistrySnapshot({ |
| 49 | ...identity, |
| 50 | settings: decodeSettings({ agentId: "test-agent" }), |
| 51 | checkedAt: "2026-08-13T10:00:00.000Z", |
| 52 | inspection: { |
| 53 | status: "ready", |
| 54 | agentId: "test-agent", |
| 55 | version: "1.0.0", |
| 56 | distribution: "npx", |
| 57 | documentationUrl: "https://example.test/agent/setup", |
| 58 | }, |
| 59 | probe: { |
| 60 | probe: { |
| 61 | instanceId: identity.instanceId, |
| 62 | ready: true, |
| 63 | icon: null, |
| 64 | authMethods: [], |
| 65 | models: [], |
| 66 | currentModelId: null, |
| 67 | configOptions: [], |
| 68 | sessionManagement: noSessionManagement, |
| 69 | }, |
| 70 | slashCommands: [{ name: "stale" }], |
| 71 | skills: [{ name: "stale-skill", path: "stale", enabled: true }], |
| 72 | }, |
| 73 | }); |
| 74 | |
| 75 | const replaced = applyAcpRegistryAvailableCommands( |
| 76 | provider, |
| 77 | Option.some({ slashCommands: [], skills: [] }), |
| 78 | ); |
| 79 | expect(replaced.slashCommands).toEqual([]); |
| 80 | expect(replaced.skills).toEqual([]); |
| 81 | expect(applyAcpRegistryAvailableCommands(provider, Option.none()).slashCommands).toEqual([ |
| 82 | { name: "stale" }, |
| 83 | ]); |
| 84 | expect(provider.iconUrl).toBe( |
| 85 | "https://cdn.agentclientprotocol.com/registry/v1/latest/test-agent.svg", |
| 86 | ); |
| 87 | expect(provider.setup).toMatchObject({ |
| 88 | canAuthenticate: false, |
| 89 | documentationUrl: "https://example.test/agent/setup", |
| 90 | }); |
| 91 | }); |
| 92 | |
| 93 | it("keeps authentication unknown when an agent permits a discovery session before sign-in", () => { |
| 94 | const snapshot = buildCheckedAcpRegistrySnapshot({ |
| 95 | ...identity, |
| 96 | settings: decodeSettings({ agentId: "test-agent" }), |
| 97 | checkedAt: "2026-08-13T10:00:00.000Z", |
| 98 | inspection: { |
| 99 | status: "ready", |
| 100 | agentId: "test-agent", |
| 101 | version: "1.0.0", |
| 102 | distribution: "binary", |
| 103 | }, |
| 104 | probe: { |
| 105 | probe: { |
| 106 | instanceId: identity.instanceId, |
| 107 | ready: true, |
| 108 | icon: null, |
| 109 | authMethods: [{ id: "browser", name: "Browser", description: null, type: "agent" }], |
| 110 | models: [], |
| 111 | currentModelId: null, |
| 112 | configOptions: [], |
| 113 | sessionManagement: noSessionManagement, |
| 114 | }, |
| 115 | slashCommands: [], |
| 116 | skills: [], |
| 117 | }, |
| 118 | }); |
| 119 | expect(snapshot.auth.status).toBe("unknown"); |
| 120 | expect(snapshot.setup?.canAuthenticate).toBe(true); |
| 121 | expect( |
| 122 | applyAcpRegistryLiveConfiguration( |
| 123 | snapshot, |
| 124 | { models: [], currentModelId: null, configOptions: [] }, |
| 125 | [], |
| 126 | ).auth.status, |
| 127 | ).toBe("unknown"); |
| 128 | }); |
| 129 | |
| 130 | it("overlays live configuration without dropping probe-owned session capabilities", () => { |
| 131 | const provider = buildCheckedAcpRegistrySnapshot({ |
| 132 | ...identity, |
| 133 | settings: decodeSettings({ agentId: "test-agent" }), |
| 134 | checkedAt: "2026-08-13T10:00:00.000Z", |
| 135 | inspection: { |
| 136 | status: "ready", |
| 137 | agentId: "test-agent", |
| 138 | version: "1.0.0", |
| 139 | distribution: "npx", |
| 140 | }, |
| 141 | probe: { |
| 142 | probe: { |
| 143 | instanceId: identity.instanceId, |
| 144 | ready: true, |
| 145 | icon: null, |
| 146 | authMethods: [], |
| 147 | models: [{ id: "probe-model", name: "Probe model", description: null }], |
| 148 | currentModelId: "probe-model", |
| 149 | configOptions: [], |
| 150 | sessionManagement: { |
| 151 | canList: true, |
| 152 | canLoad: true, |
| 153 | canResume: true, |
| 154 | canLogout: true, |
| 155 | canDelete: true, |
| 156 | canConfigureProviders: true, |
| 157 | }, |
| 158 | }, |
| 159 | slashCommands: [], |
| 160 | skills: [], |
| 161 | }, |
| 162 | }); |
| 163 | |
| 164 | expect( |
| 165 | applyAcpRegistryLiveConfiguration( |
| 166 | provider, |
| 167 | { |
| 168 | models: [{ id: "live-model", name: "Live model", description: null }], |
| 169 | currentModelId: "live-model", |
| 170 | configOptions: [], |
| 171 | }, |
| 172 | [], |
| 173 | ), |
| 174 | ).toMatchObject({ |
| 175 | auth: { status: "unknown", canLogout: true }, |
| 176 | nativeSessions: { canList: true, canLoad: true, canResume: true }, |
| 177 | models: [{ slug: "live-model", isDefault: true }], |
| 178 | }); |
| 179 | }); |
| 180 | |
| 181 | it("maps registry inspection status to provider readiness", () => { |
| 182 | expect( |
| 183 | acpRegistrySnapshotReadiness({ |
| 184 | status: "ready", |
| 185 | agentId: "gemini-cli", |
| 186 | version: "1.2.3", |
| 187 | distribution: "npx", |
| 188 | }), |
| 189 | ).toEqual({ installed: true, version: "1.2.3", status: "ready" }); |
| 190 | |
| 191 | expect( |
| 192 | acpRegistrySnapshotReadiness({ |
| 193 | status: "missing_runner", |
| 194 | agentId: "gemini-cli", |
| 195 | version: "1.2.3", |
| 196 | distribution: "npx", |
| 197 | runner: "npx", |
| 198 | }), |
| 199 | ).toMatchObject({ installed: false, version: "1.2.3", status: "error" }); |
| 200 | |
| 201 | expect( |
| 202 | acpRegistrySnapshotReadiness({ |
| 203 | status: "missing_runner", |
| 204 | version: null, |
| 205 | distribution: "local", |
| 206 | }), |
| 207 | ).toEqual({ |
| 208 | installed: false, |
| 209 | version: null, |
| 210 | status: "error", |
| 211 | message: "Local ACP executable is not available on this environment's PATH.", |
| 212 | }); |
| 213 | |
| 214 | expect( |
| 215 | acpRegistrySnapshotReadiness({ |
| 216 | status: "unprepared", |
| 217 | agentId: "zed-agent", |
| 218 | version: "2.0.0", |
| 219 | distribution: "binary", |
| 220 | }), |
| 221 | ).toMatchObject({ installed: false, version: "2.0.0", status: "warning" }); |
| 222 | |
| 223 | expect( |
| 224 | acpRegistrySnapshotReadiness({ status: "failed", message: "Registry unavailable." }), |
| 225 | ).toEqual({ |
| 226 | installed: false, |
| 227 | version: null, |
| 228 | status: "error", |
| 229 | message: "Registry unavailable.", |
| 230 | }); |
| 231 | }); |
| 232 | |
| 233 | it("projects discovery without claiming authentication when no login methods are advertised", () => { |
| 234 | const snapshot = buildCheckedAcpRegistrySnapshot({ |
| 235 | ...identity, |
| 236 | settings: decodeSettings({ |
| 237 | agentId: "test-agent", |
| 238 | customModels: [" custom-model ", "gpt-discovered"], |
| 239 | }), |
| 240 | checkedAt: "2026-08-13T10:00:00.000Z", |
| 241 | inspection: { |
| 242 | status: "ready", |
| 243 | agentId: "test-agent", |
| 244 | version: "1.0.0", |
| 245 | distribution: "npx", |
| 246 | }, |
| 247 | probe: { |
| 248 | probe: { |
| 249 | instanceId: identity.instanceId, |
| 250 | ready: true, |
| 251 | icon: null, |
| 252 | authMethods: [], |
| 253 | models: [{ id: "gpt-discovered", name: "GPT Discovered", description: null }], |
| 254 | currentModelId: "gpt-discovered", |
| 255 | configOptions: [], |
| 256 | sessionManagement: noSessionManagement, |
| 257 | }, |
| 258 | slashCommands: [{ name: "plan", description: "Create a plan", input: { hint: "topic" } }], |
| 259 | skills: [{ name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }], |
| 260 | }, |
| 261 | }); |
| 262 | |
| 263 | expect(snapshot.auth).toEqual({ status: "unknown", canLogout: false }); |
| 264 | expect(snapshot.supportsTextGeneration).toBe(false); |
| 265 | expect( |
| 266 | snapshot.models.map(({ slug, name, isCustom, isDefault }) => ({ |
| 267 | slug, |
| 268 | name, |
| 269 | isCustom, |
| 270 | isDefault, |
| 271 | })), |
| 272 | ).toEqual([ |
| 273 | { |
| 274 | slug: "gpt-discovered", |
| 275 | name: "GPT Discovered", |
| 276 | isCustom: false, |
| 277 | isDefault: true, |
| 278 | }, |
| 279 | { |
| 280 | slug: "custom-model", |
| 281 | name: "custom-model", |
| 282 | isCustom: true, |
| 283 | isDefault: undefined, |
| 284 | }, |
| 285 | ]); |
| 286 | expect(snapshot.slashCommands).toEqual([ |
| 287 | { name: "plan", description: "Create a plan", input: { hint: "topic" } }, |
| 288 | ]); |
| 289 | expect(snapshot.skills).toEqual([ |
| 290 | { name: "workspace-skill", path: "acp://skill/workspace-skill", enabled: true }, |
| 291 | ]); |
| 292 | }); |
| 293 | |
| 294 | it("keeps a default model only when the agent advertises none", () => { |
| 295 | const snapshot = buildCheckedAcpRegistrySnapshot({ |
| 296 | ...identity, |
| 297 | settings: decodeSettings({ agentId: "test-agent" }), |
| 298 | checkedAt: "2026-08-13T10:00:00.000Z", |
| 299 | inspection: { |
| 300 | status: "ready", |
| 301 | agentId: "test-agent", |
| 302 | version: "1.0.0", |
| 303 | distribution: "uvx", |
| 304 | }, |
| 305 | probe: { |
| 306 | probe: { |
| 307 | instanceId: identity.instanceId, |
| 308 | ready: true, |
| 309 | icon: null, |
| 310 | authMethods: [], |
| 311 | models: [], |
| 312 | currentModelId: null, |
| 313 | configOptions: [], |
| 314 | sessionManagement: noSessionManagement, |
| 315 | }, |
| 316 | slashCommands: [], |
| 317 | skills: [], |
| 318 | }, |
| 319 | }); |
| 320 | |
| 321 | expect(snapshot.models.map((model) => model.slug)).toEqual(["default"]); |
| 322 | expect(snapshot.models[0]?.isDefault).toBe(true); |
| 323 | }); |
| 324 | |
| 325 | it.each(["registry", "local"] as const)( |
| 326 | "reports failed authentication after successful %s inspection", |
| 327 | (source) => { |
| 328 | const snapshot = buildCheckedAcpRegistrySnapshot({ |
| 329 | ...identity, |
| 330 | settings: decodeSettings({ |
| 331 | source, |
| 332 | ...(source === "local" ? { commandPath: "test-agent" } : { agentId: "test-agent" }), |
| 333 | authMethodId: "grok-login", |
| 334 | }), |
| 335 | checkedAt: "2026-08-13T10:00:00.000Z", |
| 336 | inspection: { |
| 337 | status: "ready", |
| 338 | agentId: "test-agent", |
| 339 | version: source === "local" ? null : "1.0.0", |
| 340 | distribution: source === "local" ? "local" : "binary", |
| 341 | }, |
| 342 | probeError: new AcpRegistryOperationError({ |
| 343 | reason: "authentication_failed", |
| 344 | message: "Login required.", |
| 345 | authMethods: [ |
| 346 | { |
| 347 | id: "api-key", |
| 348 | name: "API key", |
| 349 | description: null, |
| 350 | type: "env_var", |
| 351 | }, |
| 352 | { |
| 353 | id: "grok-login", |
| 354 | name: "Log in with Grok", |
| 355 | description: null, |
| 356 | type: "agent", |
| 357 | }, |
| 358 | ], |
| 359 | }), |
| 360 | }); |
| 361 | |
| 362 | expect(snapshot).toMatchObject({ |
| 363 | installed: true, |
| 364 | version: source === "local" ? null : "1.0.0", |
| 365 | status: "warning", |
| 366 | setup: { canAuthenticate: true }, |
| 367 | auth: { |
| 368 | status: "unauthenticated", |
| 369 | type: "agent", |
| 370 | label: "Log in with Grok", |
| 371 | }, |
| 372 | message: 'Sign in in provider settings using "Log in with Grok".', |
| 373 | }); |
| 374 | }, |
| 375 | ); |
| 376 | |
| 377 | it.effect("runs the disposable probe only after local inspection is ready", () => |
| 378 | Effect.gen(function* () { |
| 379 | const settings = decodeSettings({ agentId: "test-agent" }); |
| 380 | const environment = { PATH: "/provider/bin" }; |
| 381 | let receivedEnvironment: NodeJS.ProcessEnv | undefined; |
| 382 | const snapshot = yield* checkAcpRegistryProviderStatus( |
| 383 | { |
| 384 | ...identity, |
| 385 | settings, |
| 386 | cwd: "/workspace", |
| 387 | environment, |
| 388 | }, |
| 389 | (input) => |
| 390 | Effect.sync(() => { |
| 391 | receivedEnvironment = input.environment; |
| 392 | return { |
| 393 | probe: { |
| 394 | instanceId: identity.instanceId, |
| 395 | ready: true as const, |
| 396 | icon: null, |
| 397 | authMethods: [], |
| 398 | models: [{ id: "agent-model", name: "Agent Model", description: null }], |
| 399 | currentModelId: "agent-model", |
| 400 | configOptions: [], |
| 401 | sessionManagement: noSessionManagement, |
| 402 | }, |
| 403 | slashCommands: [{ name: "review" }], |
| 404 | skills: [], |
| 405 | }; |
| 406 | }), |
| 407 | ).pipe( |
| 408 | Effect.provideService( |
| 409 | AcpRegistrySupport.AcpRegistryCatalog, |
| 410 | catalogWithInspection({ |
| 411 | status: "ready", |
| 412 | agentId: "test-agent", |
| 413 | version: "1.0.0", |
| 414 | distribution: "npx", |
| 415 | }), |
| 416 | ), |
| 417 | ); |
| 418 | |
| 419 | expect(receivedEnvironment).toBe(environment); |
| 420 | expect(snapshot).toMatchObject({ |
| 421 | auth: { status: "unknown" }, |
| 422 | models: [{ slug: "agent-model" }], |
| 423 | slashCommands: [{ name: "review" }], |
| 424 | skills: [], |
| 425 | }); |
| 426 | }), |
| 427 | ); |
| 428 | |
| 429 | it.effect("publishes concrete local readiness before background ACP discovery", () => |
| 430 | Effect.gen(function* () { |
| 431 | const snapshot = yield* checkAcpRegistryProviderReadiness({ |
| 432 | ...identity, |
| 433 | settings: decodeSettings({ agentId: "test-agent" }), |
| 434 | environment: { PATH: "/provider/bin" }, |
| 435 | }).pipe( |
| 436 | Effect.provideService( |
| 437 | AcpRegistrySupport.AcpRegistryCatalog, |
| 438 | catalogWithInspection({ |
| 439 | status: "ready", |
| 440 | agentId: "test-agent", |
| 441 | version: "1.0.0", |
| 442 | distribution: "npx", |
| 443 | }), |
| 444 | ), |
| 445 | ); |
| 446 | |
| 447 | expect(snapshot).toMatchObject({ |
| 448 | installed: true, |
| 449 | status: "ready", |
| 450 | version: "1.0.0", |
| 451 | auth: { status: "unknown" }, |
| 452 | message: "Checking ACP authentication, models, and commands in the background...", |
| 453 | }); |
| 454 | }), |
| 455 | ); |
| 456 | }); |