| author | |
| committer | |
| log | 9d029a1df25126745dd70ac561ff25d3f8fbac42 |
| tree | 8aaf43d965842d07272f2e5d9a21c9125a495b94 |
| parent | 4dbc0129c244d5869d7598a22f8d9386cca17884 |
| signature | Signed by PGP key B5690EEEBB952194 |
26 files changed, 982 insertions(+), 250 deletions(-)
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts+4| ... | ... | @@ -15,6 +15,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; |
| 15 | 15 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 16 | 16 | |
| 17 | 17 | import * as ServerConfig from "../../config.ts"; |
| 18 | import * as ServerSettings from "../../serverSettings.ts"; | |
| 18 | 19 | import type { |
| 19 | 20 | AcpRegistryAvailableCommands, |
| 20 | 21 | AcpRegistryLiveConfiguration, |
| ... | ... | @@ -87,6 +88,7 @@ const testLayer = Layer.mergeAll( |
| 87 | 88 | IdAllocator.layer, |
| 88 | 89 | serverConfigLayer, |
| 89 | 90 | registryLayer, |
| 91 | ServerSettings.layerTest(), | |
| 90 | 92 | ); |
| 91 | 93 | |
| 92 | 94 | describe("AcpRegistryAdapterV2", () => { |
| ... | ... | @@ -121,6 +123,8 @@ describe("AcpRegistryAdapterV2", () => { |
| 121 | 123 | assert.isTrue(BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2.has(ACP_REGISTRY_PROVIDER)); |
| 122 | 124 | assert.equal(AcpRegistryAdapterV2Driver.driverKind, ACP_REGISTRY_PROVIDER); |
| 123 | 125 | assert.deepEqual(AcpRegistryAdapterV2Driver.defaultConfig(), { |
| 126 | source: "registry", | |
| 127 | commandArgs: [], | |
| 124 | 128 | enabled: true, |
| 125 | 129 | agentId: "", |
| 126 | 130 | commandPath: "", |
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts+7-4| ... | ... | @@ -183,14 +183,17 @@ function makeAcpRegistryRuntime(options: AcpRegistryAdapterV2Options) { |
| 183 | 183 | |
| 184 | 184 | export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) { |
| 185 | 185 | const runtimeCoordinator = options.runtimeCoordinator; |
| 186 | const isDevin = options.settings.agentId === "devin"; | |
| 186 | const registryAgentId = options.settings.source === "local" ? "" : options.settings.agentId; | |
| 187 | const startupKey = | |
| 188 | options.settings.source === "local" ? `local:${options.instanceId}` : registryAgentId; | |
| 189 | const isDevin = registryAgentId === "devin"; | |
| 187 | 190 | const flavor: AcpAdapterV2Flavor = { |
| 188 | 191 | driver: ACP_REGISTRY_PROVIDER, |
| 189 | 192 | capabilities: AcpProviderCapabilitiesV2, |
| 190 | promptFailure: (cause) => acpRegistryPromptFailure(options.settings.agentId, cause), | |
| 193 | promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause), | |
| 191 | 194 | // Per-agent exceptions (Mistral Vibe, Devin): see the note above |
| 192 | 195 | // registerMistralVibeAcpExtensions before adding any more. |
| 193 | ...(options.settings.agentId === "mistral-vibe" | |
| 196 | ...(registryAgentId === "mistral-vibe" | |
| 194 | 197 | ? { registerExtensions: registerMistralVibeAcpExtensions } |
| 195 | 198 | : {}), |
| 196 | 199 | ...(isDevin |
| ... | ... | @@ -230,7 +233,7 @@ export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) { |
| 230 | 233 | }); |
| 231 | 234 | }, |
| 232 | 235 | withRuntimeStartup: <A, E, R>(effect: Effect.Effect<A, E, R>) => |
| 233 | runtimeCoordinator.withForegroundStartup(options.settings.agentId, effect), | |
| 236 | runtimeCoordinator.withForegroundStartup(startupKey, effect), | |
| 234 | 237 | }), |
| 235 | 238 | ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), |
| 236 | 239 | }; |
apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts+63-42| ... | ... | @@ -201,6 +201,19 @@ describe("acpRegistrySnapshotReadiness", () => { |
| 201 | 201 | }), |
| 202 | 202 | ).toMatchObject({ installed: false, version: "1.2.3", status: "error" }); |
| 203 | 203 | |
| 204 | expect( | |
| 205 | acpRegistrySnapshotReadiness({ | |
| 206 | status: "missing_runner", | |
| 207 | version: null, | |
| 208 | distribution: "local", | |
| 209 | }), | |
| 210 | ).toEqual({ | |
| 211 | installed: false, | |
| 212 | version: null, | |
| 213 | status: "error", | |
| 214 | message: "Local ACP executable is not available on this environment's PATH.", | |
| 215 | }); | |
| 216 | ||
| 204 | 217 | expect( |
| 205 | 218 | acpRegistrySnapshotReadiness({ |
| 206 | 219 | status: "unprepared", |
| ... | ... | @@ -312,49 +325,57 @@ describe("acpRegistrySnapshotReadiness", () => { |
| 312 | 325 | expect(snapshot.models[0]?.isDefault).toBe(true); |
| 313 | 326 | }); |
| 314 | 327 | |
| 315 | it("reports failed authentication without hiding successful local inspection", () => { | |
| 316 | const snapshot = buildCheckedAcpRegistrySnapshot({ | |
| 317 | ...identity, | |
| 318 | settings: decodeSettings({ agentId: "test-agent", authMethodId: "grok-login" }), | |
| 319 | checkedAt: "2026-08-13T10:00:00.000Z", | |
| 320 | inspection: { | |
| 321 | status: "ready", | |
| 322 | agentId: "test-agent", | |
| 323 | version: "1.0.0", | |
| 324 | distribution: "binary", | |
| 325 | }, | |
| 326 | probeError: new AcpRegistryOperationError({ | |
| 327 | reason: "authentication_failed", | |
| 328 | message: "Login required.", | |
| 329 | authMethods: [ | |
| 330 | { | |
| 331 | id: "api-key", | |
| 332 | name: "API key", | |
| 333 | description: null, | |
| 334 | type: "env_var", | |
| 335 | }, | |
| 336 | { | |
| 337 | id: "grok-login", | |
| 338 | name: "Log in with Grok", | |
| 339 | description: null, | |
| 340 | type: "agent", | |
| 341 | }, | |
| 342 | ], | |
| 343 | }), | |
| 344 | }); | |
| 328 | it.each(["registry", "local"] as const)( | |
| 329 | "reports failed authentication after successful %s inspection", | |
| 330 | (source) => { | |
| 331 | const snapshot = buildCheckedAcpRegistrySnapshot({ | |
| 332 | ...identity, | |
| 333 | settings: decodeSettings({ | |
| 334 | source, | |
| 335 | ...(source === "local" ? { commandPath: "test-agent" } : { agentId: "test-agent" }), | |
| 336 | authMethodId: "grok-login", | |
| 337 | }), | |
| 338 | checkedAt: "2026-08-13T10:00:00.000Z", | |
| 339 | inspection: { | |
| 340 | status: "ready", | |
| 341 | agentId: "test-agent", | |
| 342 | version: source === "local" ? null : "1.0.0", | |
| 343 | distribution: source === "local" ? "local" : "binary", | |
| 344 | }, | |
| 345 | probeError: new AcpRegistryOperationError({ | |
| 346 | reason: "authentication_failed", | |
| 347 | message: "Login required.", | |
| 348 | authMethods: [ | |
| 349 | { | |
| 350 | id: "api-key", | |
| 351 | name: "API key", | |
| 352 | description: null, | |
| 353 | type: "env_var", | |
| 354 | }, | |
| 355 | { | |
| 356 | id: "grok-login", | |
| 357 | name: "Log in with Grok", | |
| 358 | description: null, | |
| 359 | type: "agent", | |
| 360 | }, | |
| 361 | ], | |
| 362 | }), | |
| 363 | }); | |
| 345 | 364 | |
| 346 | expect(snapshot).toMatchObject({ | |
| 347 | installed: true, | |
| 348 | version: "1.0.0", | |
| 349 | status: "warning", | |
| 350 | auth: { | |
| 351 | status: "unauthenticated", | |
| 352 | type: "agent", | |
| 353 | label: "Log in with Grok", | |
| 354 | }, | |
| 355 | message: 'Sign in in provider settings using "Log in with Grok".', | |
| 356 | }); | |
| 357 | }); | |
| 365 | expect(snapshot).toMatchObject({ | |
| 366 | installed: true, | |
| 367 | version: source === "local" ? null : "1.0.0", | |
| 368 | status: "warning", | |
| 369 | setup: { canAuthenticate: true }, | |
| 370 | auth: { | |
| 371 | status: "unauthenticated", | |
| 372 | type: "agent", | |
| 373 | label: "Log in with Grok", | |
| 374 | }, | |
| 375 | message: 'Sign in in provider settings using "Log in with Grok".', | |
| 376 | }); | |
| 377 | }, | |
| 378 | ); | |
| 358 | 379 | |
| 359 | 380 | it.effect("runs the disposable probe only after local inspection is ready", () => |
| 360 | 381 | Effect.gen(function* () { |
apps/server/src/provider/Drivers/AcpRegistryDriver.ts+17-6| ... | ... | @@ -138,7 +138,8 @@ export function acpRegistrySnapshotReadiness( |
| 138 | 138 | installed: false, |
| 139 | 139 | version: null, |
| 140 | 140 | status: "warning", |
| 141 | message: "Select an ACP Registry agent before starting a thread.", | |
| 141 | message: | |
| 142 | "Select an ACP Registry agent or configure a local ACP executable before starting a thread.", | |
| 142 | 143 | }; |
| 143 | 144 | case "not_found": |
| 144 | 145 | return { |
| ... | ... | @@ -159,7 +160,10 @@ export function acpRegistrySnapshotReadiness( |
| 159 | 160 | installed: false, |
| 160 | 161 | version: inspection.version, |
| 161 | 162 | status: "error", |
| 162 | message: `ACP Registry agent '${inspection.agentId}' requires '${inspection.runner}' on this environment's PATH.`, | |
| 163 | message: | |
| 164 | inspection.distribution === "local" | |
| 165 | ? "Local ACP executable is not available on this environment's PATH." | |
| 166 | : `ACP executable '${inspection.runner}' is not available on this environment's PATH.`, | |
| 163 | 167 | }; |
| 164 | 168 | case "unprepared": |
| 165 | 169 | return { |
| ... | ... | @@ -196,11 +200,14 @@ function baseSnapshot( |
| 196 | 200 | readonly documentationUrl?: string; |
| 197 | 201 | readonly message?: string; |
| 198 | 202 | readonly probe?: AcpRegistryConfigurationProbeResult; |
| 203 | readonly probeError?: AcpRegistryOperationError; | |
| 199 | 204 | }, |
| 200 | 205 | ): ServerProvider { |
| 201 | 206 | const iconUrl = |
| 202 | resolveOfficialAcpRegistryIconUrl(input.probe?.probe.icon) ?? | |
| 203 | officialAcpRegistryIconUrlForAgentId(input.settings.agentId); | |
| 207 | input.settings.source === "local" | |
| 208 | ? null | |
| 209 | : (resolveOfficialAcpRegistryIconUrl(input.probe?.probe.icon) ?? | |
| 210 | officialAcpRegistryIconUrlForAgentId(input.settings.agentId)); | |
| 204 | 211 | return { |
| 205 | 212 | instanceId: input.instanceId, |
| 206 | 213 | driver: DRIVER_KIND, |
| ... | ... | @@ -225,7 +232,9 @@ function baseSnapshot( |
| 225 | 232 | input.installed && |
| 226 | 233 | (input.probe |
| 227 | 234 | ? input.probe.probe.authMethods.length > 0 |
| 228 | : input.settings.agentId.length > 0), | |
| 235 | : input.settings.source === "local" | |
| 236 | ? (input.probeError?.authMethods?.length ?? 0) > 0 | |
| 237 | : input.settings.agentId.length > 0), | |
| 229 | 238 | }, |
| 230 | 239 | ...(input.message ? { message: input.message } : {}), |
| 231 | 240 | models: modelsFromDiscovery(input.probe?.probe, input.settings.customModels), |
| ... | ... | @@ -633,7 +642,9 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD |
| 633 | 642 | const publishEnrichment = ( |
| 634 | 643 | Option.isSome(runtimeCoordinator) |
| 635 | 644 | ? runtimeCoordinator.value.runBackgroundProbe( |
| 636 | effectiveConfig.agentId, | |
| 645 | effectiveConfig.source === "local" | |
| 646 | ? `local:${instanceId}` | |
| 647 | : effectiveConfig.agentId, | |
| 637 | 648 | enrichProviderCached(snapshot), |
| 638 | 649 | ) |
| 639 | 650 | : enrichProviderCached(snapshot).pipe(Effect.map(Option.some)) |
apps/server/src/provider/acp/AcpRegistryAuth.test.ts+28-3| ... | ... | @@ -57,7 +57,12 @@ const catalog: AcpRegistrySupport.AcpRegistryCatalog["Service"] = { |
| 57 | 57 | uninstallManagedBinary: () => Effect.die("unused"), |
| 58 | 58 | }; |
| 59 | 59 | |
| 60 | const makeHarness = (method: AcpSchema.AuthMethod, failVerification = false) => | |
| 60 | const makeHarness = ( | |
| 61 | method: AcpSchema.AuthMethod, | |
| 62 | failVerification = false, | |
| 63 | settings = decodeSettings({ agentId: "test-agent" }), | |
| 64 | providerInstanceId = instanceId, | |
| 65 | ) => | |
| 61 | 66 | Effect.gen(function* () { |
| 62 | 67 | const verify = yield* Deferred.make<void>(); |
| 63 | 68 | const changed: boolean[] = []; |
| ... | ... | @@ -74,8 +79,8 @@ const makeHarness = (method: AcpSchema.AuthMethod, failVerification = false) => |
| 74 | 79 | const written: string[] = []; |
| 75 | 80 | const sizes: number[][] = []; |
| 76 | 81 | const controller = yield* makeAcpRegistryAuth({ |
| 77 | instanceId, | |
| 78 | settings: decodeSettings({ agentId: "test-agent" }), | |
| 82 | instanceId: providerInstanceId, | |
| 83 | settings, | |
| 79 | 84 | cwd: "/workspace", |
| 80 | 85 | environment: { PATH: "/tools", OVERRIDE: "base" }, |
| 81 | 86 | onChanged: (value) => |
| ... | ... | @@ -211,6 +216,26 @@ const makeHarness = (method: AcpSchema.AuthMethod, failVerification = false) => |
| 211 | 216 | }; |
| 212 | 217 | }).pipe(Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog)); |
| 213 | 218 | |
| 219 | it.effect("keeps local credentials separate from a matching registry agent ID", () => | |
| 220 | Effect.gen(function* () { | |
| 221 | const registry = yield* makeHarness(browserMethod); | |
| 222 | const local = yield* makeHarness( | |
| 223 | browserMethod, | |
| 224 | false, | |
| 225 | decodeSettings({ source: "local", commandPath: "/different/agent" }), | |
| 226 | ProviderInstanceId.make("test-agent"), | |
| 227 | ); | |
| 228 | assert.deepEqual(registry.controller.credentialBinding, { | |
| 229 | owner: "provider", | |
| 230 | key: "acp:test-agent", | |
| 231 | }); | |
| 232 | assert.deepEqual(local.controller.credentialBinding, { | |
| 233 | owner: "provider", | |
| 234 | key: "acp:local:test-agent", | |
| 235 | }); | |
| 236 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 237 | ); | |
| 238 | ||
| 214 | 239 | it.effect( |
| 215 | 240 | "discovers methods without signing in, then waits for browser consent and session verification", |
| 216 | 241 | () => |
apps/server/src/provider/acp/AcpRegistryAuth.ts+6-6| ... | ... | @@ -45,6 +45,8 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o |
| 45 | 45 | const coordinator = yield* Effect.serviceOption( |
| 46 | 46 | AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator, |
| 47 | 47 | ); |
| 48 | const agentKey = | |
| 49 | options.settings.source === "local" ? `local:${options.instanceId}` : options.settings.agentId; | |
| 48 | 50 | const failure = (operation: string, detail: string, cause?: unknown) => |
| 49 | 51 | new ProviderSetupError({ instanceId: options.instanceId, operation, detail, cause }); |
| 50 | 52 | const resolve = catalog |
| ... | ... | @@ -139,7 +141,7 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o |
| 139 | 141 | |
| 140 | 142 | const methods = Option.isSome(coordinator) |
| 141 | 143 | ? coordinator.value |
| 142 | .runBackgroundProbe(options.settings.agentId, discoverMethods) | |
| 144 | .runBackgroundProbe(agentKey, discoverMethods) | |
| 143 | 145 | .pipe( |
| 144 | 146 | Effect.flatMap((result) => |
| 145 | 147 | Option.isSome(result) |
| ... | ... | @@ -327,7 +329,7 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o |
| 327 | 329 | }), |
| 328 | 330 | ); |
| 329 | 331 | yield* Option.isSome(coordinator) |
| 330 | ? coordinator.value.withForegroundStartup(options.settings.agentId, login) | |
| 332 | ? coordinator.value.withForegroundStartup(agentKey, login) | |
| 331 | 333 | : login; |
| 332 | 334 | yield* options.onChanged(true); |
| 333 | 335 | }); |
| ... | ... | @@ -353,15 +355,13 @@ export const makeAcpRegistryAuth = Effect.fn("makeAcpRegistryAuth")(function* (o |
| 353 | 355 | }), |
| 354 | 356 | ); |
| 355 | 357 | const signOut = ( |
| 356 | Option.isSome(coordinator) | |
| 357 | ? coordinator.value.withForegroundStartup(options.settings.agentId, logout) | |
| 358 | : logout | |
| 358 | Option.isSome(coordinator) ? coordinator.value.withForegroundStartup(agentKey, logout) : logout | |
| 359 | 359 | ).pipe(Effect.andThen(options.onChanged(false))); |
| 360 | 360 | return yield* ProviderAuthFlow.make({ |
| 361 | 361 | instanceId: options.instanceId, |
| 362 | 362 | // ACP doesn't advertise its credential scope. Conservatively treat all |
| 363 | 363 | // instances of the same agent on this environment as sharing credentials. |
| 364 | credentialBinding: { owner: "provider", key: `acp:${options.settings.agentId}` }, | |
| 364 | credentialBinding: { owner: "provider", key: `acp:${agentKey}` }, | |
| 365 | 365 | methods, |
| 366 | 366 | ...(options.settings.authMethodId ? { defaultMethodId: options.settings.authMethodId } : {}), |
| 367 | 367 | authenticate, |
apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts+3| ... | ... | @@ -34,6 +34,9 @@ export const makeAcpRegistryAuthenticationState = Effect.fn("makeAcpRegistryAuth |
| 34 | 34 | // Cosmetic settings and model discovery can rebuild the driver without |
| 35 | 35 | // changing the account. Credential overrides and profile paths cannot. |
| 36 | 36 | const binding = hash({ |
| 37 | ...(input.settings.source === "local" | |
| 38 | ? { source: "local", commandArgs: input.settings.commandArgs } | |
| 39 | : {}), | |
| 37 | 40 | agentId: input.settings.agentId, |
| 38 | 41 | commandPath: input.settings.commandPath, |
| 39 | 42 | distribution: input.settings.distribution, |
apps/server/src/provider/acp/AcpRegistryProbe.test.ts+69| ... | ... | @@ -222,6 +222,75 @@ describe("ACP Registry probe", () => { |
| 222 | 222 | expect(result.currentModelId).toBeNull(); |
| 223 | 223 | }); |
| 224 | 224 | |
| 225 | it("preserves grouped model values and advertises reasoning choices", () => { | |
| 226 | const codexModel = '["codex","gpt-6.1-sol"]'; | |
| 227 | const goModel = '["opencode-go","deepseek-v4.1-flash"]'; | |
| 228 | const result = acpRegistryProbeResult(instanceId, { | |
| 229 | sessionId: "probe-session", | |
| 230 | initializeResult: { protocolVersion: 1 }, | |
| 231 | sessionSetupResult: { | |
| 232 | sessionId: "probe-session", | |
| 233 | configOptions: [ | |
| 234 | { | |
| 235 | id: "model", | |
| 236 | name: "Model", | |
| 237 | category: "model", | |
| 238 | type: "select", | |
| 239 | currentValue: goModel, | |
| 240 | options: [ | |
| 241 | { | |
| 242 | groupId: "codex", | |
| 243 | name: "Codex", | |
| 244 | options: [{ value: codexModel, name: "GPT-6.1 Sol" }], | |
| 245 | }, | |
| 246 | { | |
| 247 | groupId: "opencode-go", | |
| 248 | name: "OpenCode Go", | |
| 249 | options: [{ value: goModel, name: "DeepSeek V4.1 Flash" }], | |
| 250 | }, | |
| 251 | ], | |
| 252 | }, | |
| 253 | { | |
| 254 | id: "reasoning", | |
| 255 | name: "Reasoning", | |
| 256 | category: "thought_level", | |
| 257 | type: "select", | |
| 258 | currentValue: "high", | |
| 259 | options: [ | |
| 260 | { | |
| 261 | groupId: "effort", | |
| 262 | name: "Effort", | |
| 263 | options: [ | |
| 264 | { value: "medium", name: "Medium" }, | |
| 265 | { value: "high", name: "High" }, | |
| 266 | ], | |
| 267 | }, | |
| 268 | ], | |
| 269 | }, | |
| 270 | ], | |
| 271 | }, | |
| 272 | modelConfigId: "model", | |
| 273 | } satisfies AcpSessionRuntimeStartResult); | |
| 274 | ||
| 275 | expect(result.models).toEqual([ | |
| 276 | { id: codexModel, name: "GPT-6.1 Sol", description: null }, | |
| 277 | { id: goModel, name: "DeepSeek V4.1 Flash", description: null }, | |
| 278 | ]); | |
| 279 | expect(result.currentModelId).toBe(goModel); | |
| 280 | expect(result.configOptions).toEqual([ | |
| 281 | { | |
| 282 | id: "reasoning", | |
| 283 | label: "Reasoning", | |
| 284 | type: "select", | |
| 285 | currentValue: "high", | |
| 286 | options: [ | |
| 287 | { id: "medium", label: "Medium" }, | |
| 288 | { id: "high", label: "High" }, | |
| 289 | ], | |
| 290 | }, | |
| 291 | ]); | |
| 292 | }); | |
| 293 | ||
| 225 | 294 | it("omits a current model that falls outside the bounded model catalog", () => { |
| 226 | 295 | const result = acpRegistryProbeResult(instanceId, { |
| 227 | 296 | sessionId: "probe-session", |
apps/server/src/provider/acp/AcpRegistryProbe.ts+1-1| ... | ... | @@ -440,7 +440,7 @@ export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeCo |
| 440 | 440 | probe: acpRegistryProbeResult( |
| 441 | 441 | input.instanceId, |
| 442 | 442 | result.started, |
| 443 | result.resolved.agent.icon ?? null, | |
| 443 | result.resolved.agent?.icon ?? null, | |
| 444 | 444 | { |
| 445 | 445 | command: result.resolved.spawn.command, |
| 446 | 446 | args: result.resolved.spawn.args, |
apps/server/src/provider/acp/AcpRegistrySupport.test.ts+243-55| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | import { AcpRegistrySettings } from "@t3tools/contracts"; | |
| 3 | import { AcpRegistrySettings, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; | |
| 4 | 4 | import { |
| 5 | 5 | HostProcessArchitecture, |
| 6 | 6 | HostProcessEnvironment, |
| ... | ... | @@ -13,8 +13,6 @@ import * as FileSystem from "effect/FileSystem"; |
| 13 | 13 | import * as Path from "effect/Path"; |
| 14 | 14 | import * as Fiber from "effect/Fiber"; |
| 15 | 15 | import * as Layer from "effect/Layer"; |
| 16 | import * as Option from "effect/Option"; | |
| 17 | import * as Ref from "effect/Ref"; | |
| 18 | 16 | import * as Schema from "effect/Schema"; |
| 19 | 17 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 20 | 18 | import * as TestClock from "effect/testing/TestClock"; |
| ... | ... | @@ -22,6 +20,7 @@ import * as NodeCrypto from "node:crypto"; |
| 22 | 20 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 23 | 21 | |
| 24 | 22 | import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; |
| 23 | import * as ServerSettings from "../../serverSettings.ts"; | |
| 25 | 24 | |
| 26 | 25 | const registryUrl = "https://registry.test/registry.json"; |
| 27 | 26 | const archiveUrl = "https://registry.test/example-agent.bin"; |
| ... | ... | @@ -57,6 +56,7 @@ function resolverLayer( |
| 57 | 56 | ) { |
| 58 | 57 | return Layer.mergeAll( |
| 59 | 58 | NodeServices.layer, |
| 59 | ServerSettings.layerTest(), | |
| 60 | 60 | Layer.succeed(HostProcessPlatform, "linux"), |
| 61 | 61 | Layer.succeed(HostProcessArchitecture, "x64"), |
| 62 | 62 | Layer.succeed(HostProcessEnvironment, environment), |
| ... | ... | @@ -209,6 +209,154 @@ describe("AcpRegistrySupport", () => { |
| 209 | 209 | ).toBeUndefined(); |
| 210 | 210 | }); |
| 211 | 211 | |
| 212 | it.effect("resolves a local command on the selected environment without the registry", () => { | |
| 213 | const requests: Array<string> = []; | |
| 214 | return Effect.gen(function* () { | |
| 215 | const fileSystem = yield* FileSystem.FileSystem; | |
| 216 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-local-" }); | |
| 217 | const commandPath = `${cacheDir}/dsh`; | |
| 218 | yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); | |
| 219 | yield* fileSystem.chmod(commandPath, 0o755); | |
| 220 | const hostEnvironment = { PATH: "/missing-host-bin", INHERITED: "host", OVERRIDE: "host" }; | |
| 221 | const environment = { ...hostEnvironment, PATH: cacheDir, OVERRIDE: "provider" }; | |
| 222 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 223 | cacheDir, | |
| 224 | toolsDir: `${cacheDir}/tools`, | |
| 225 | registryUrl, | |
| 226 | }).pipe(Effect.provideService(HostProcessEnvironment, hostEnvironment)); | |
| 227 | const localSettings = decodeAcpRegistrySettings({ | |
| 228 | source: "local", | |
| 229 | commandPath: "dsh", | |
| 230 | commandArgs: ["--profile", "acp", "", " spaced ", "$(touch injected); $VALUE"], | |
| 231 | }); | |
| 232 | ||
| 233 | expect(yield* resolver.inspect(localSettings)).toMatchObject({ status: "missing_runner" }); | |
| 234 | expect(yield* resolver.inspect(localSettings, environment)).toEqual({ | |
| 235 | status: "ready", | |
| 236 | agentId: "dsh", | |
| 237 | version: null, | |
| 238 | distribution: "local", | |
| 239 | }); | |
| 240 | const resolved = yield* resolver.resolve(localSettings, "/workspace", environment); | |
| 241 | expect(resolved).toEqual({ | |
| 242 | distribution: "local", | |
| 243 | spawn: { | |
| 244 | command: commandPath, | |
| 245 | args: ["--profile", "acp", "", " spaced ", "$(touch injected); $VALUE"], | |
| 246 | cwd: "/workspace", | |
| 247 | env: environment, | |
| 248 | shell: false, | |
| 249 | }, | |
| 250 | }); | |
| 251 | expect(requests).toEqual([]); | |
| 252 | expect(yield* fileSystem.exists(`${cacheDir}/tools`)).toBe(false); | |
| 253 | }).pipe( | |
| 254 | Effect.scoped, | |
| 255 | Effect.provide( | |
| 256 | resolverLayer((request) => { | |
| 257 | requests.push(request.url); | |
| 258 | return Effect.die("unexpected registry request for a local provider"); | |
| 259 | }), | |
| 260 | ), | |
| 261 | ); | |
| 262 | }); | |
| 263 | ||
| 264 | it.effect("resolves a local executable path and inherits the host environment", () => | |
| 265 | Effect.gen(function* () { | |
| 266 | const fileSystem = yield* FileSystem.FileSystem; | |
| 267 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-acp-local-path-" }); | |
| 268 | const commandPath = `${cacheDir}/dsh wrapper`; | |
| 269 | yield* fileSystem.writeFileString(commandPath, "#!/bin/sh\n"); | |
| 270 | yield* fileSystem.chmod(commandPath, 0o755); | |
| 271 | const environment = { PATH: "/unused", INHERITED: "host" }; | |
| 272 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 273 | cacheDir, | |
| 274 | toolsDir: `${cacheDir}/tools`, | |
| 275 | registryUrl, | |
| 276 | }).pipe(Effect.provideService(HostProcessEnvironment, environment)); | |
| 277 | const resolved = yield* resolver.resolve( | |
| 278 | decodeAcpRegistrySettings({ source: "local", commandPath }), | |
| 279 | "/workspace", | |
| 280 | ); | |
| 281 | ||
| 282 | expect(resolved.spawn).toEqual({ | |
| 283 | command: commandPath, | |
| 284 | args: [], | |
| 285 | cwd: "/workspace", | |
| 286 | env: environment, | |
| 287 | shell: false, | |
| 288 | }); | |
| 289 | expect(resolved.agent).toBeUndefined(); | |
| 290 | ||
| 291 | for (const extension of [".cmd", ".BAT"]) { | |
| 292 | const windowsResolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 293 | cacheDir, | |
| 294 | toolsDir: `${cacheDir}/tools`, | |
| 295 | registryUrl, | |
| 296 | }).pipe( | |
| 297 | Effect.provideService(HostProcessPlatform, "win32"), | |
| 298 | Effect.provideService(SpawnExecutableResolution, () => `C:\\bin\\dsh${extension}`), | |
| 299 | ); | |
| 300 | const localSettings = decodeAcpRegistrySettings({ source: "local", commandPath: "dsh" }); | |
| 301 | for (const failure of [ | |
| 302 | yield* windowsResolver.inspect(localSettings).pipe(Effect.flip), | |
| 303 | yield* windowsResolver.resolve(localSettings, "/workspace").pipe(Effect.flip), | |
| 304 | ]) { | |
| 305 | expect(failure).toMatchObject({ | |
| 306 | reason: "runner_unavailable", | |
| 307 | detail: expect.stringContaining("node.exe"), | |
| 308 | }); | |
| 309 | } | |
| 310 | } | |
| 311 | }).pipe( | |
| 312 | Effect.scoped, | |
| 313 | Effect.provide(resolverLayer(() => Effect.die("unexpected registry request"))), | |
| 314 | ), | |
| 315 | ); | |
| 316 | ||
| 317 | it.effect("reports missing or non-executable local commands without registry fallback", () => | |
| 318 | Effect.gen(function* () { | |
| 319 | const fileSystem = yield* FileSystem.FileSystem; | |
| 320 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 321 | prefix: "t3-acp-local-missing-", | |
| 322 | }); | |
| 323 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 324 | cacheDir, | |
| 325 | toolsDir: `${cacheDir}/tools`, | |
| 326 | registryUrl, | |
| 327 | }); | |
| 328 | const environment = { PATH: cacheDir }; | |
| 329 | const unconfigured = decodeAcpRegistrySettings({ source: "local" }); | |
| 330 | expect(yield* resolver.inspect(unconfigured, environment)).toEqual({ | |
| 331 | status: "unconfigured", | |
| 332 | }); | |
| 333 | expect( | |
| 334 | yield* resolver.resolve(unconfigured, "/workspace", environment).pipe(Effect.flip), | |
| 335 | ).toMatchObject({ reason: "agent_not_configured" }); | |
| 336 | ||
| 337 | const nonExecutable = `${cacheDir}/non-executable`; | |
| 338 | yield* fileSystem.writeFileString(nonExecutable, "#!/bin/sh\n"); | |
| 339 | yield* fileSystem.chmod(nonExecutable, 0o644); | |
| 340 | for (const commandPath of ["dsh", `${cacheDir}/missing`, cacheDir, nonExecutable]) { | |
| 341 | const localSettings = decodeAcpRegistrySettings({ source: "local", commandPath }); | |
| 342 | expect(yield* resolver.inspect(localSettings, environment)).toEqual({ | |
| 343 | status: "missing_runner", | |
| 344 | version: null, | |
| 345 | distribution: "local", | |
| 346 | }); | |
| 347 | expect( | |
| 348 | yield* resolver.resolve(localSettings, "/workspace", environment).pipe(Effect.flip), | |
| 349 | ).toMatchObject({ | |
| 350 | reason: "runner_unavailable", | |
| 351 | detail: "Local ACP executable is not available on this environment's PATH.", | |
| 352 | }); | |
| 353 | } | |
| 354 | }).pipe( | |
| 355 | Effect.scoped, | |
| 356 | Effect.provide(resolverLayer(() => Effect.die("unexpected registry request"))), | |
| 357 | ), | |
| 358 | ); | |
| 359 | ||
| 212 | 360 | it.effect("resolves command overrides while preserving registry args and environment", () => { |
| 213 | 361 | const agent = makeAgent({ |
| 214 | 362 | binary: { |
| ... | ... | @@ -619,7 +767,7 @@ describe("AcpRegistrySupport", () => { |
| 619 | 767 | ); |
| 620 | 768 | }).pipe( |
| 621 | 769 | Effect.scoped, |
| 622 | Effect.provide(NodeServices.layer), | |
| 770 | Effect.provide(Layer.mergeAll(NodeServices.layer, ServerSettings.layerTest())), | |
| 623 | 771 | Effect.provideService(HostProcessPlatform, "linux"), |
| 624 | 772 | Effect.provideService(HostProcessArchitecture, "x64"), |
| 625 | 773 | ); |
| ... | ... | @@ -1188,42 +1336,72 @@ describe("AcpRegistrySupport", () => { |
| 1188 | 1336 | ); |
| 1189 | 1337 | }); |
| 1190 | 1338 | |
| 1191 | it.effect("uninstalls only the T3-managed binary tree and is idempotent", () => { | |
| 1192 | return Effect.gen(function* () { | |
| 1193 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1194 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1195 | prefix: "t3-acp-registry-uninstall-", | |
| 1196 | }); | |
| 1197 | const agentRoot = `${cacheDir}/tools/example-agent`; | |
| 1198 | const runnerCache = `${cacheDir}/external-npx-cache/example-agent/package.json`; | |
| 1199 | yield* fileSystem.makeDirectory(`${agentRoot}/1.2.3/linux-x86_64`, { recursive: true }); | |
| 1200 | yield* fileSystem.writeFileString(`${agentRoot}/1.2.3/linux-x86_64/agent`, "binary"); | |
| 1201 | yield* fileSystem.makeDirectory(`${cacheDir}/external-npx-cache/example-agent`, { | |
| 1202 | recursive: true, | |
| 1203 | }); | |
| 1204 | yield* fileSystem.writeFileString(runnerCache, "{}"); | |
| 1205 | ||
| 1206 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1207 | cacheDir, | |
| 1208 | toolsDir: `${cacheDir}/tools`, | |
| 1209 | registryUrl, | |
| 1210 | }); | |
| 1211 | const first = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); | |
| 1212 | const second = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); | |
| 1339 | it.effect.each([ | |
| 1340 | { name: "legacy registry", source: undefined, agentId: "example-agent", referenced: true }, | |
| 1341 | { name: "registry", source: "registry", agentId: "example-agent", referenced: true }, | |
| 1342 | { name: "padded registry", source: "registry", agentId: " example-agent ", referenced: true }, | |
| 1343 | { name: "local command", source: "local", agentId: "example-agent", referenced: false }, | |
| 1344 | ] as const)( | |
| 1345 | "checks $name references before uninstalling only managed binaries", | |
| 1346 | ({ source, agentId, referenced }) => { | |
| 1347 | return Effect.gen(function* () { | |
| 1348 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1349 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 1350 | prefix: "t3-acp-registry-uninstall-", | |
| 1351 | }); | |
| 1352 | const agentRoot = `${cacheDir}/tools/example-agent`; | |
| 1353 | const runnerCache = `${cacheDir}/external-npx-cache/example-agent/package.json`; | |
| 1354 | yield* fileSystem.makeDirectory(`${agentRoot}/1.2.3/linux-x86_64`, { recursive: true }); | |
| 1355 | yield* fileSystem.writeFileString(`${agentRoot}/1.2.3/linux-x86_64/agent`, "binary"); | |
| 1356 | yield* fileSystem.makeDirectory(`${cacheDir}/external-npx-cache/example-agent`, { | |
| 1357 | recursive: true, | |
| 1358 | }); | |
| 1359 | yield* fileSystem.writeFileString(runnerCache, "{}"); | |
| 1213 | 1360 | |
| 1214 | expect(first).toEqual({ agentId: "example-agent", removed: true }); | |
| 1215 | expect(second).toEqual({ agentId: "example-agent", removed: false }); | |
| 1216 | expect(yield* fileSystem.exists(agentRoot)).toBe(false); | |
| 1217 | expect(yield* fileSystem.exists(runnerCache)).toBe(true); | |
| 1218 | }).pipe( | |
| 1219 | Effect.scoped, | |
| 1220 | Effect.provide( | |
| 1221 | resolverLayer((request) => | |
| 1222 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused"))), | |
| 1361 | const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ | |
| 1362 | cacheDir, | |
| 1363 | toolsDir: `${cacheDir}/tools`, | |
| 1364 | registryUrl, | |
| 1365 | }); | |
| 1366 | const serverSettings = yield* ServerSettings.ServerSettingsService; | |
| 1367 | const instanceId = ProviderInstanceId.make("uninstall-reference"); | |
| 1368 | yield* serverSettings.updateProviderInstance({ | |
| 1369 | operation: "upsert", | |
| 1370 | instanceId, | |
| 1371 | instance: { | |
| 1372 | driver: ProviderDriverKind.make("acpRegistry"), | |
| 1373 | displayName: "Uninstall reference", | |
| 1374 | enabled: true, | |
| 1375 | config: { | |
| 1376 | agentId, | |
| 1377 | commandPath: "/local/agent", | |
| 1378 | ...(source ? { source } : {}), | |
| 1379 | }, | |
| 1380 | }, | |
| 1381 | }); | |
| 1382 | const first = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); | |
| 1383 | expect(first).toEqual({ agentId: "example-agent", removed: !referenced }); | |
| 1384 | expect(yield* fileSystem.exists(agentRoot)).toBe(referenced); | |
| 1385 | yield* serverSettings.updateProviderInstance({ operation: "remove", instanceId }); | |
| 1386 | const second = yield* resolver.uninstallManagedBinary({ agentId: "example-agent" }); | |
| 1387 | ||
| 1388 | expect(second).toEqual({ agentId: "example-agent", removed: referenced }); | |
| 1389 | expect(yield* resolver.uninstallManagedBinary({ agentId: "example-agent" })).toEqual({ | |
| 1390 | agentId: "example-agent", | |
| 1391 | removed: false, | |
| 1392 | }); | |
| 1393 | expect(yield* fileSystem.exists(agentRoot)).toBe(false); | |
| 1394 | expect(yield* fileSystem.exists(runnerCache)).toBe(true); | |
| 1395 | }).pipe( | |
| 1396 | Effect.scoped, | |
| 1397 | Effect.provide( | |
| 1398 | resolverLayer((request) => | |
| 1399 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unused"))), | |
| 1400 | ), | |
| 1223 | 1401 | ), |
| 1224 | ), | |
| 1225 | ); | |
| 1226 | }); | |
| 1402 | ); | |
| 1403 | }, | |
| 1404 | ); | |
| 1227 | 1405 | |
| 1228 | 1406 | it.effect("keeps managed package installs when removing the same agent's binaries", () => |
| 1229 | 1407 | Effect.gen(function* () { |
| ... | ... | @@ -1272,8 +1450,6 @@ describe("AcpRegistrySupport", () => { |
| 1272 | 1450 | return Effect.gen(function* () { |
| 1273 | 1451 | const downloadStarted = yield* Deferred.make<void>(); |
| 1274 | 1452 | const releaseDownload = yield* Deferred.make<void>(); |
| 1275 | const referenceChecked = yield* Deferred.make<void>(); | |
| 1276 | const isReferenced = yield* Ref.make(false); | |
| 1277 | 1453 | return yield* Effect.gen(function* () { |
| 1278 | 1454 | const fileSystem = yield* FileSystem.FileSystem; |
| 1279 | 1455 | const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ |
| ... | ... | @@ -1290,16 +1466,10 @@ describe("AcpRegistrySupport", () => { |
| 1290 | 1466 | .pipe(Effect.forkChild({ startImmediately: true })); |
| 1291 | 1467 | yield* Deferred.await(downloadStarted); |
| 1292 | 1468 | const uninstallFiber = yield* resolver |
| 1293 | .uninstallManagedBinary( | |
| 1294 | { agentId: agent.id }, | |
| 1295 | Deferred.succeed(referenceChecked, undefined).pipe( | |
| 1296 | Effect.andThen(Ref.get(isReferenced)), | |
| 1297 | ), | |
| 1298 | ) | |
| 1469 | .uninstallManagedBinary({ agentId: agent.id }) | |
| 1299 | 1470 | .pipe(Effect.forkChild({ startImmediately: true })); |
| 1300 | 1471 | |
| 1301 | expect(Option.isNone(yield* Deferred.poll(referenceChecked))).toBe(true); | |
| 1302 | yield* Ref.set(isReferenced, true); | |
| 1472 | expect(uninstallFiber.pollUnsafe()).toBeUndefined(); | |
| 1303 | 1473 | yield* Deferred.succeed(releaseDownload, undefined); |
| 1304 | 1474 | |
| 1305 | 1475 | expect(yield* Fiber.join(prepareFiber)).toMatchObject({ prepared: true }); |
| ... | ... | @@ -1310,13 +1480,31 @@ describe("AcpRegistrySupport", () => { |
| 1310 | 1480 | const agentRoot = `${cacheDir}/tools/${agent.id}`; |
| 1311 | 1481 | expect(yield* fileSystem.exists(agentRoot)).toBe(true); |
| 1312 | 1482 | |
| 1313 | yield* Ref.set(isReferenced, false); | |
| 1314 | expect( | |
| 1315 | yield* resolver.uninstallManagedBinary({ agentId: agent.id }, Ref.get(isReferenced)), | |
| 1316 | ).toEqual({ agentId: agent.id, removed: true }); | |
| 1317 | expect( | |
| 1318 | yield* resolver.uninstallManagedBinary({ agentId: agent.id }, Ref.get(isReferenced)), | |
| 1319 | ).toEqual({ agentId: agent.id, removed: false }); | |
| 1483 | const serverSettings = yield* ServerSettings.ServerSettingsService; | |
| 1484 | const instanceId = ProviderInstanceId.make("registry-reference"); | |
| 1485 | yield* serverSettings.updateProviderInstance({ | |
| 1486 | operation: "upsert", | |
| 1487 | instanceId, | |
| 1488 | instance: { | |
| 1489 | driver: ProviderDriverKind.make("acpRegistry"), | |
| 1490 | displayName: "Registry reference", | |
| 1491 | enabled: true, | |
| 1492 | config: { agentId: agent.id }, | |
| 1493 | }, | |
| 1494 | }); | |
| 1495 | expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ | |
| 1496 | agentId: agent.id, | |
| 1497 | removed: false, | |
| 1498 | }); | |
| 1499 | yield* serverSettings.updateProviderInstance({ operation: "remove", instanceId }); | |
| 1500 | expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ | |
| 1501 | agentId: agent.id, | |
| 1502 | removed: true, | |
| 1503 | }); | |
| 1504 | expect(yield* resolver.uninstallManagedBinary({ agentId: agent.id })).toEqual({ | |
| 1505 | agentId: agent.id, | |
| 1506 | removed: false, | |
| 1507 | }); | |
| 1320 | 1508 | }).pipe( |
| 1321 | 1509 | Effect.scoped, |
| 1322 | 1510 | Effect.provide( |
apps/server/src/provider/acp/AcpRegistrySupport.ts+149-70| ... | ... | @@ -40,6 +40,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 40 | 40 | import * as NodeCrypto from "node:crypto"; |
| 41 | 41 | |
| 42 | 42 | import { collectUint8StreamText } from "../../stream/collectUint8StreamText.ts"; |
| 43 | import * as ServerSettings from "../../serverSettings.ts"; | |
| 43 | 44 | import type { AcpSpawnInput } from "./AcpSessionRuntime.ts"; |
| 44 | 45 | |
| 45 | 46 | const ACP_REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; |
| ... | ... | @@ -209,7 +210,7 @@ export function toAcpRegistryOperationError(error: AcpRegistryError): AcpRegistr |
| 209 | 210 | }); |
| 210 | 211 | } |
| 211 | 212 | |
| 212 | export const isAcpRegistryError = Schema.is(AcpRegistryError); | |
| 213 | const isAcpRegistryError = Schema.is(AcpRegistryError); | |
| 213 | 214 | |
| 214 | 215 | export type AcpRegistryPlatformTarget = |
| 215 | 216 | | "darwin-aarch64" |
| ... | ... | @@ -439,8 +440,8 @@ export function resolveAcpRegistryDistribution(input: { |
| 439 | 440 | } |
| 440 | 441 | |
| 441 | 442 | export interface ResolvedAcpRegistryAgent { |
| 442 | readonly agent: AcpRegistryAgent; | |
| 443 | readonly distribution: AcpRegistryDistributionKind; | |
| 443 | readonly agent?: AcpRegistryAgent; | |
| 444 | readonly distribution: AcpRegistryDistributionKind | "local"; | |
| 444 | 445 | readonly spawn: AcpSpawnInput; |
| 445 | 446 | } |
| 446 | 447 | |
| ... | ... | @@ -459,6 +460,11 @@ export type AcpRegistryInspection = |
| 459 | 460 | readonly distribution: AcpRegistryDistributionKind; |
| 460 | 461 | readonly runner: string; |
| 461 | 462 | } |
| 463 | | { | |
| 464 | readonly status: "missing_runner"; | |
| 465 | readonly version: null; | |
| 466 | readonly distribution: "local"; | |
| 467 | } | |
| 462 | 468 | | { |
| 463 | 469 | readonly status: "unprepared"; |
| 464 | 470 | readonly agentId: string; |
| ... | ... | @@ -469,7 +475,7 @@ export type AcpRegistryInspection = |
| 469 | 475 | readonly status: "ready"; |
| 470 | 476 | readonly agentId: string; |
| 471 | 477 | readonly version: string | null; |
| 472 | readonly distribution: AcpRegistryDistributionKind; | |
| 478 | readonly distribution: AcpRegistryDistributionKind | "local"; | |
| 473 | 479 | readonly documentationUrl?: string; |
| 474 | 480 | }; |
| 475 | 481 | |
| ... | ... | @@ -493,7 +499,6 @@ export class AcpRegistryCatalog extends Context.Service< |
| 493 | 499 | ) => Effect.Effect<ResolvedAcpRegistryAgent, AcpRegistryError>; |
| 494 | 500 | readonly uninstallManagedBinary: ( |
| 495 | 501 | input: AcpRegistryManagedBinaryUninstallInput, |
| 496 | isReferenced?: Effect.Effect<boolean, AcpRegistryError>, | |
| 497 | 502 | ) => Effect.Effect<AcpRegistryManagedBinaryUninstallResult, AcpRegistryError>; |
| 498 | 503 | } |
| 499 | 504 | >()("t3/provider/acp/AcpRegistrySupport/AcpRegistryCatalog") { |
| ... | ... | @@ -595,11 +600,13 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct |
| 595 | 600 | | FileSystem.FileSystem |
| 596 | 601 | | HttpClient.HttpClient |
| 597 | 602 | | Path.Path |
| 603 | | ServerSettings.ServerSettingsService | |
| 598 | 604 | > { |
| 599 | 605 | const fileSystem = yield* FileSystem.FileSystem; |
| 600 | 606 | const path = yield* Path.Path; |
| 601 | 607 | const httpClient = yield* HttpClient.HttpClient; |
| 602 | 608 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 609 | const serverSettings = yield* ServerSettings.ServerSettingsService; | |
| 603 | 610 | const platform = yield* HostProcessPlatform; |
| 604 | 611 | const architecture = yield* HostProcessArchitecture; |
| 605 | 612 | const hostEnvironment = yield* HostProcessEnvironment; |
| ... | ... | @@ -1610,8 +1617,32 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct |
| 1610 | 1617 | } satisfies AcpRegistryPrepareResult; |
| 1611 | 1618 | }); |
| 1612 | 1619 | |
| 1620 | const validateLocalExecutable = (command: string) => | |
| 1621 | platform === "win32" && /\.(?:cmd|bat)$/iu.test(command) | |
| 1622 | ? Effect.fail( | |
| 1623 | new AcpRegistryError({ | |
| 1624 | reason: "runner_unavailable", | |
| 1625 | detail: | |
| 1626 | "Local ACP commands launch without a shell. For a Windows batch wrapper, configure the underlying executable (for example node.exe) and pass the script path as an argument.", | |
| 1627 | }), | |
| 1628 | ) | |
| 1629 | : Effect.void; | |
| 1630 | ||
| 1613 | 1631 | const inspect: AcpRegistryCatalog["Service"]["inspect"] = (settings, environment) => |
| 1614 | 1632 | Effect.gen(function* () { |
| 1633 | if (settings.source === "local") { | |
| 1634 | const command = settings.commandPath.trim(); | |
| 1635 | if (!command) return { status: "unconfigured" } as const; | |
| 1636 | const executable = resolveExecutable(command, platform, environment ?? hostEnvironment); | |
| 1637 | if (executable !== undefined) yield* validateLocalExecutable(executable); | |
| 1638 | return executable === undefined | |
| 1639 | ? ({ | |
| 1640 | status: "missing_runner", | |
| 1641 | version: null, | |
| 1642 | distribution: "local", | |
| 1643 | } as const) | |
| 1644 | : ({ status: "ready", agentId: command, version: null, distribution: "local" } as const); | |
| 1645 | } | |
| 1615 | 1646 | const agentId = settings.agentId.trim(); |
| 1616 | 1647 | if (agentId.length === 0) return { status: "unconfigured" } as const; |
| 1617 | 1648 | const registry = yield* loadCachedRegistry(); |
| ... | ... | @@ -1713,6 +1744,28 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct |
| 1713 | 1744 | |
| 1714 | 1745 | const resolve: AcpRegistryCatalog["Service"]["resolve"] = (settings, cwd, environment) => |
| 1715 | 1746 | Effect.gen(function* () { |
| 1747 | if (settings.source === "local") { | |
| 1748 | const executable = settings.commandPath.trim(); | |
| 1749 | if (!executable) { | |
| 1750 | return yield* new AcpRegistryError({ | |
| 1751 | reason: "agent_not_configured", | |
| 1752 | detail: "Local ACP provider requires an executable.", | |
| 1753 | }); | |
| 1754 | } | |
| 1755 | const env = environment ?? hostEnvironment; | |
| 1756 | const command = resolveExecutable(executable, platform, env); | |
| 1757 | if (command === undefined) { | |
| 1758 | return yield* new AcpRegistryError({ | |
| 1759 | reason: "runner_unavailable", | |
| 1760 | detail: "Local ACP executable is not available on this environment's PATH.", | |
| 1761 | }); | |
| 1762 | } | |
| 1763 | yield* validateLocalExecutable(command); | |
| 1764 | return { | |
| 1765 | distribution: "local", | |
| 1766 | spawn: { command, args: settings.commandArgs, cwd, env, shell: false }, | |
| 1767 | } satisfies ResolvedAcpRegistryAgent; | |
| 1768 | } | |
| 1716 | 1769 | const agentId = settings.agentId.trim(); |
| 1717 | 1770 | if (agentId.length === 0) { |
| 1718 | 1771 | return yield* new AcpRegistryError({ |
| ... | ... | @@ -1782,72 +1835,98 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct |
| 1782 | 1835 | } satisfies ResolvedAcpRegistryAgent; |
| 1783 | 1836 | }); |
| 1784 | 1837 | |
| 1785 | const uninstallManagedBinary: AcpRegistryCatalog["Service"]["uninstallManagedBinary"] = ( | |
| 1786 | input, | |
| 1787 | isReferenced = Effect.succeed(false), | |
| 1788 | ) => | |
| 1789 | installSemaphore.withPermits(1)( | |
| 1790 | Effect.gen(function* () { | |
| 1791 | const safeAgentId = yield* decodeBoundedAgentId(input.agentId).pipe( | |
| 1792 | Effect.mapError( | |
| 1793 | (cause) => | |
| 1794 | new AcpRegistryError({ | |
| 1795 | reason: "install_failed", | |
| 1796 | detail: "ACP Registry managed binary uninstall received an invalid agent ID.", | |
| 1797 | cause, | |
| 1798 | }), | |
| 1799 | ), | |
| 1800 | ); | |
| 1801 | if (yield* isReferenced) { | |
| 1802 | yield* consumePreparedBinaryReservation(safeAgentId); | |
| 1803 | return { agentId: safeAgentId, removed: false }; | |
| 1804 | } | |
| 1805 | if (yield* hasPreparedBinaryReservation(safeAgentId)) { | |
| 1806 | return { agentId: safeAgentId, removed: false }; | |
| 1807 | } | |
| 1808 | const agentRoot = path.join(installsDirectory, safeAgentId); | |
| 1809 | const existed = yield* fileSystem.exists(agentRoot).pipe( | |
| 1810 | Effect.mapError( | |
| 1811 | (cause) => | |
| 1812 | new AcpRegistryError({ | |
| 1813 | reason: "install_failed", | |
| 1814 | detail: `Could not inspect the managed binary cache for ACP Registry agent ${safeAgentId}.`, | |
| 1815 | cause, | |
| 1816 | }), | |
| 1817 | ), | |
| 1818 | ); | |
| 1819 | if (!existed) return { agentId: safeAgentId, removed: false }; | |
| 1820 | ||
| 1821 | let removed = false; | |
| 1822 | yield* Effect.gen(function* () { | |
| 1823 | for (const version of yield* fileSystem.readDirectory(agentRoot)) { | |
| 1824 | const versionRoot = path.join(agentRoot, version); | |
| 1825 | for (const entry of yield* fileSystem.readDirectory(versionRoot)) { | |
| 1826 | if (!/^(?:darwin|linux|windows)-(?:aarch64|x86_64)$/u.test(entry)) continue; | |
| 1827 | yield* fileSystem.remove(path.join(versionRoot, entry), { | |
| 1828 | recursive: true, | |
| 1829 | force: true, | |
| 1830 | }); | |
| 1831 | removed = true; | |
| 1838 | const uninstallManagedBinary: AcpRegistryCatalog["Service"]["uninstallManagedBinary"] = (input) => | |
| 1839 | serverSettings | |
| 1840 | .withSettingsSnapshot((settings) => | |
| 1841 | installSemaphore.withPermits(1)( | |
| 1842 | Effect.gen(function* () { | |
| 1843 | const safeAgentId = yield* decodeBoundedAgentId(input.agentId).pipe( | |
| 1844 | Effect.mapError( | |
| 1845 | (cause) => | |
| 1846 | new AcpRegistryError({ | |
| 1847 | reason: "install_failed", | |
| 1848 | detail: "ACP Registry managed binary uninstall received an invalid agent ID.", | |
| 1849 | cause, | |
| 1850 | }), | |
| 1851 | ), | |
| 1852 | ); | |
| 1853 | const isReferenced = Object.values(settings.providerInstances).some((instance) => { | |
| 1854 | if ( | |
| 1855 | instance.driver !== "acpRegistry" || | |
| 1856 | instance.config === null || | |
| 1857 | typeof instance.config !== "object" | |
| 1858 | ) | |
| 1859 | return false; | |
| 1860 | const config = instance.config as Record<string, unknown>; | |
| 1861 | return ( | |
| 1862 | config.source !== "local" && | |
| 1863 | typeof config.agentId === "string" && | |
| 1864 | config.agentId.trim() === safeAgentId | |
| 1865 | ); | |
| 1866 | }); | |
| 1867 | if (isReferenced) { | |
| 1868 | yield* consumePreparedBinaryReservation(safeAgentId); | |
| 1869 | return { agentId: safeAgentId, removed: false }; | |
| 1832 | 1870 | } |
| 1833 | if ((yield* fileSystem.readDirectory(versionRoot)).length === 0) | |
| 1834 | yield* fileSystem.remove(versionRoot, { recursive: true }); | |
| 1835 | } | |
| 1836 | if ((yield* fileSystem.readDirectory(agentRoot)).length === 0) | |
| 1837 | yield* fileSystem.remove(agentRoot, { recursive: true }); | |
| 1838 | }).pipe( | |
| 1839 | Effect.mapError( | |
| 1840 | (cause) => | |
| 1841 | new AcpRegistryError({ | |
| 1842 | reason: "install_failed", | |
| 1843 | detail: `Could not remove managed binaries for ACP Registry agent ${safeAgentId}.`, | |
| 1844 | cause, | |
| 1845 | }), | |
| 1846 | ), | |
| 1847 | ); | |
| 1848 | return { agentId: safeAgentId, removed }; | |
| 1849 | }), | |
| 1850 | ); | |
| 1871 | if (yield* hasPreparedBinaryReservation(safeAgentId)) { | |
| 1872 | return { agentId: safeAgentId, removed: false }; | |
| 1873 | } | |
| 1874 | const agentRoot = path.join(installsDirectory, safeAgentId); | |
| 1875 | const existed = yield* fileSystem.exists(agentRoot).pipe( | |
| 1876 | Effect.mapError( | |
| 1877 | (cause) => | |
| 1878 | new AcpRegistryError({ | |
| 1879 | reason: "install_failed", | |
| 1880 | detail: `Could not inspect the managed binary cache for ACP Registry agent ${safeAgentId}.`, | |
| 1881 | cause, | |
| 1882 | }), | |
| 1883 | ), | |
| 1884 | ); | |
| 1885 | if (!existed) return { agentId: safeAgentId, removed: false }; | |
| 1886 | ||
| 1887 | let removed = false; | |
| 1888 | yield* Effect.gen(function* () { | |
| 1889 | for (const version of yield* fileSystem.readDirectory(agentRoot)) { | |
| 1890 | const versionRoot = path.join(agentRoot, version); | |
| 1891 | for (const entry of yield* fileSystem.readDirectory(versionRoot)) { | |
| 1892 | if (!/^(?:darwin|linux|windows)-(?:aarch64|x86_64)$/u.test(entry)) continue; | |
| 1893 | yield* fileSystem.remove(path.join(versionRoot, entry), { | |
| 1894 | recursive: true, | |
| 1895 | force: true, | |
| 1896 | }); | |
| 1897 | removed = true; | |
| 1898 | } | |
| 1899 | if ((yield* fileSystem.readDirectory(versionRoot)).length === 0) | |
| 1900 | yield* fileSystem.remove(versionRoot, { recursive: true }); | |
| 1901 | } | |
| 1902 | if ((yield* fileSystem.readDirectory(agentRoot)).length === 0) | |
| 1903 | yield* fileSystem.remove(agentRoot, { recursive: true }); | |
| 1904 | }).pipe( | |
| 1905 | Effect.mapError( | |
| 1906 | (cause) => | |
| 1907 | new AcpRegistryError({ | |
| 1908 | reason: "install_failed", | |
| 1909 | detail: `Could not remove managed binaries for ACP Registry agent ${safeAgentId}.`, | |
| 1910 | cause, | |
| 1911 | }), | |
| 1912 | ), | |
| 1913 | ); | |
| 1914 | return { agentId: safeAgentId, removed }; | |
| 1915 | }), | |
| 1916 | ), | |
| 1917 | ) | |
| 1918 | .pipe( | |
| 1919 | Effect.catchTag( | |
| 1920 | "ServerSettingsError", | |
| 1921 | (cause) => | |
| 1922 | new AcpRegistryError({ | |
| 1923 | reason: "install_failed", | |
| 1924 | detail: | |
| 1925 | "Could not read provider settings while checking managed ACP binary references.", | |
| 1926 | cause, | |
| 1927 | }), | |
| 1928 | ), | |
| 1929 | ); | |
| 1851 | 1930 | |
| 1852 | 1931 | return AcpRegistryCatalog.of({ |
| 1853 | 1932 | search, |
apps/server/src/provider/acp/AcpSessionRuntime.ts+8-4| ... | ... | @@ -87,6 +87,7 @@ export interface AcpSpawnInput { |
| 87 | 87 | readonly cwd?: string; |
| 88 | 88 | readonly env?: NodeJS.ProcessEnv; |
| 89 | 89 | readonly extendEnv?: boolean; |
| 90 | readonly shell?: false; | |
| 90 | 91 | } |
| 91 | 92 | |
| 92 | 93 | export interface AcpSessionRuntimeOptions { |
| ... | ... | @@ -1516,10 +1517,13 @@ export const make = ( |
| 1516 | 1517 | ), |
| 1517 | 1518 | ); |
| 1518 | 1519 | |
| 1519 | const spawnCommand = yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, { | |
| 1520 | ...(options.spawn.env ? { env: options.spawn.env } : {}), | |
| 1521 | extendEnv: options.spawn.extendEnv ?? true, | |
| 1522 | }); | |
| 1520 | const spawnCommand = | |
| 1521 | options.spawn.shell === false | |
| 1522 | ? { command: options.spawn.command, args: options.spawn.args, shell: false } | |
| 1523 | : yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, { | |
| 1524 | ...(options.spawn.env ? { env: options.spawn.env } : {}), | |
| 1525 | extendEnv: options.spawn.extendEnv ?? true, | |
| 1526 | }); | |
| 1523 | 1527 | const linuxCgroupLease = |
| 1524 | 1528 | options.ownDescendantProcessGroups === true && options.processGroupPlatform === "linux" |
| 1525 | 1529 | ? yield* Effect.sync(() => { |
apps/server/src/server.ts+1-1| ... | ... | @@ -574,7 +574,7 @@ const RuntimeCoreDependenciesLive = RuntimeCoreDependenciesBaseLive.pipe( |
| 574 | 574 | Layer.provideMerge(PtyAdapterLive), |
| 575 | 575 | // Search, prepare, status inspection, and turn launch share one registry |
| 576 | 576 | // cache so every client and provider instance sees the same prepared agents. |
| 577 | Layer.provideMerge(AcpRegistryCatalogLive), | |
| 577 | Layer.provideMerge(AcpRegistryCatalogLive.pipe(Layer.provide(ServerSettingsLayerLive))), | |
| 578 | 578 | // Shared native/canonical NDJSON writers used by both the per-instance |
| 579 | 579 | // V2 drivers and the orchestration runtime. Provide resource attribution so |
| 580 | 580 | // the rewritten telemetry pipeline can account for logical NDJSON writes. |
apps/server/src/ws.ts+3-30| ... | ... | @@ -2114,36 +2114,9 @@ const makeWsRpcLayer = ( |
| 2114 | 2114 | [WS_METHODS.serverUninstallAcpRegistryManagedBinary]: (input) => |
| 2115 | 2115 | observeRpcEffect( |
| 2116 | 2116 | WS_METHODS.serverUninstallAcpRegistryManagedBinary, |
| 2117 | serverSettings | |
| 2118 | .withSettingsSnapshot((settings) => | |
| 2119 | acpRegistryCatalog.uninstallManagedBinary( | |
| 2120 | input, | |
| 2121 | Effect.succeed( | |
| 2122 | Object.values(settings.providerInstances).some((instance) => { | |
| 2123 | if ( | |
| 2124 | instance.driver !== "acpRegistry" || | |
| 2125 | instance.config === null || | |
| 2126 | typeof instance.config !== "object" | |
| 2127 | ) { | |
| 2128 | return false; | |
| 2129 | } | |
| 2130 | return (instance.config as Record<string, unknown>).agentId === input.agentId; | |
| 2131 | }), | |
| 2132 | ), | |
| 2133 | ), | |
| 2134 | ) | |
| 2135 | .pipe( | |
| 2136 | Effect.mapError((cause) => | |
| 2137 | AcpRegistrySupport.isAcpRegistryError(cause) | |
| 2138 | ? cause | |
| 2139 | : new AcpRegistrySupport.AcpRegistryError({ | |
| 2140 | reason: "install_failed", | |
| 2141 | detail: `Could not read provider settings while checking references for ACP Registry agent ${input.agentId}.`, | |
| 2142 | cause, | |
| 2143 | }), | |
| 2144 | ), | |
| 2145 | Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError), | |
| 2146 | ), | |
| 2117 | acpRegistryCatalog | |
| 2118 | .uninstallManagedBinary(input) | |
| 2119 | .pipe(Effect.mapError(AcpRegistrySupport.toAcpRegistryOperationError)), | |
| 2147 | 2120 | { |
| 2148 | 2121 | "rpc.aggregate": "server", |
| 2149 | 2122 | "acp_registry.agent_id": input.agentId, |
apps/web/src/components/settings/AcpRegistrySearchStep.tsx+13| ... | ... | @@ -34,6 +34,7 @@ interface AcpRegistrySearchStepProps { |
| 34 | 34 | readonly providerInstances: Readonly<Record<string, ProviderInstanceConfig>>; |
| 35 | 35 | readonly onPrepared: (agent: AcpRegistrySearchAgent) => void; |
| 36 | 36 | readonly onManualConfiguration: () => void; |
| 37 | readonly onLocalConfiguration?: () => void; | |
| 37 | 38 | readonly onLoadingChange?: (loading: boolean) => void; |
| 38 | 39 | readonly onPreparingChange?: (preparing: boolean) => void; |
| 39 | 40 | } |
| ... | ... | @@ -55,6 +56,7 @@ export function AcpRegistrySearchStep({ |
| 55 | 56 | providerInstances, |
| 56 | 57 | onPrepared, |
| 57 | 58 | onManualConfiguration, |
| 59 | onLocalConfiguration, | |
| 58 | 60 | onLoadingChange, |
| 59 | 61 | onPreparingChange, |
| 60 | 62 | }: AcpRegistrySearchStepProps) { |
| ... | ... | @@ -169,6 +171,17 @@ export function AcpRegistrySearchStep({ |
| 169 | 171 | > |
| 170 | 172 | Enter manually |
| 171 | 173 | </Button> |
| 174 | {onLocalConfiguration ? ( | |
| 175 | <Button | |
| 176 | disabled={preparingId !== null} | |
| 177 | onClick={onLocalConfiguration} | |
| 178 | size="sm" | |
| 179 | type="button" | |
| 180 | variant="outline" | |
| 181 | > | |
| 182 | Local ACP command | |
| 183 | </Button> | |
| 184 | ) : null} | |
| 172 | 185 | </form> |
| 173 | 186 | |
| 174 | 187 | <div className="sr-only" role="status"> |
apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx+68| ... | ... | @@ -239,6 +239,74 @@ describe("AddProviderInstanceDialog environment routing", () => { |
| 239 | 239 | ).not.toBeNull(); |
| 240 | 240 | }); |
| 241 | 241 | |
| 242 | it("creates a local command in the selected environment without a sign-in step", async () => { | |
| 243 | const onOpenChange = vi.fn(); | |
| 244 | let tree = render(onOpenChange); | |
| 245 | const search = visitElements( | |
| 246 | tree, | |
| 247 | (element) => | |
| 248 | typeof element.type === "function" && element.type.name === "AcpRegistrySearchStep", | |
| 249 | ); | |
| 250 | (search!.props.onLocalConfiguration as () => void)(); | |
| 251 | tree = render(onOpenChange); | |
| 252 | (findByChildren(tree, "Next").props.onClick as () => void)(); | |
| 253 | tree = render(onOpenChange); | |
| 254 | expect(findByChildren(tree, "Executable is required.")).not.toBeNull(); | |
| 255 | expect(settingsHooks.mutate).not.toHaveBeenCalled(); | |
| 256 | ||
| 257 | const configuration = visitElements( | |
| 258 | tree, | |
| 259 | (element) => element.props.idPrefix === "add-provider-acpRegistry-manual", | |
| 260 | ); | |
| 261 | const commandArgs = ["--profile", "acp", " literal $(value) ; ", ""]; | |
| 262 | (configuration!.props.onChange as (value: Record<string, unknown>) => void)({ | |
| 263 | source: "local", | |
| 264 | commandPath: "dsh", | |
| 265 | commandArgs, | |
| 266 | }); | |
| 267 | const environmentEditor = visitElements( | |
| 268 | tree, | |
| 269 | (element) => | |
| 270 | typeof element.type === "function" && element.type.name === "ProviderEnvironmentSection", | |
| 271 | ); | |
| 272 | const environment = [{ name: "DSH_PROFILE", value: "work", sensitive: false }]; | |
| 273 | (environmentEditor!.props.onChange as (value: typeof environment) => void)(environment); | |
| 274 | tree = render(onOpenChange); | |
| 275 | (findByChildren(tree, "Next").props.onClick as () => void)(); | |
| 276 | tree = render(onOpenChange); | |
| 277 | const label = visitElements(tree, (element) => element.props.id === "add-provider-label"); | |
| 278 | (label!.props.onChange as (event: { target: { value: string } }) => void)({ | |
| 279 | target: { value: "Deepseek Harness" }, | |
| 280 | }); | |
| 281 | tree = render(onOpenChange); | |
| 282 | (findByChildren(tree, "Add instance").props.onClick as () => void)(); | |
| 283 | await Promise.resolve(); | |
| 284 | await Promise.resolve(); | |
| 285 | ||
| 286 | expect(settingsHooks.useMutation).toHaveBeenCalledWith(remoteEnvironmentId); | |
| 287 | expect(settingsHooks.mutate).toHaveBeenCalledWith({ | |
| 288 | operation: "create", | |
| 289 | instanceId: "acpRegistry_deepseek_harness", | |
| 290 | instance: { | |
| 291 | driver: "acpRegistry", | |
| 292 | enabled: true, | |
| 293 | displayName: "Deepseek Harness", | |
| 294 | config: { source: "local", commandPath: "dsh", commandArgs }, | |
| 295 | environment, | |
| 296 | }, | |
| 297 | }); | |
| 298 | expect(onOpenChange).toHaveBeenCalledWith(false); | |
| 299 | tree = render(onOpenChange); | |
| 300 | expect( | |
| 301 | visitElements( | |
| 302 | tree, | |
| 303 | (element) => | |
| 304 | typeof element.type === "function" && | |
| 305 | element.type.name === "ProviderWizardAuthenticationStep", | |
| 306 | ), | |
| 307 | ).toBeNull(); | |
| 308 | }); | |
| 309 | ||
| 242 | 310 | it("keeps the dialog open when the atomic upsert fails", async () => { |
| 243 | 311 | settingsHooks.mutate.mockResolvedValueOnce({ _tag: "Failure", cause: new Error("Conflict") }); |
| 244 | 312 | const onOpenChange = vi.fn(); |
apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts+3-1| ... | ... | @@ -7,6 +7,7 @@ const ACP_REGISTRY_IDENTITY_STEP = 1; |
| 7 | 7 | |
| 8 | 8 | export const ADD_PROVIDER_WIZARD_STEPS = ["Provider", "Identity", "Config"] as const; |
| 9 | 9 | export const ACP_REGISTRY_WIZARD_STEPS = ["Provider", "Identity", "Sign in"] as const; |
| 10 | export const LOCAL_ACP_WIZARD_STEPS = ["Provider", "Identity"] as const; | |
| 10 | 11 | |
| 11 | 12 | export interface ProviderIdentityDraft { |
| 12 | 13 | readonly label: string; |
| ... | ... | @@ -65,7 +66,8 @@ export function isConfiguredAcpRegistryAgent( |
| 65 | 66 | ) { |
| 66 | 67 | return false; |
| 67 | 68 | } |
| 68 | return (instance.config as Record<string, unknown>).agentId === agentId; | |
| 69 | const config = instance.config as Record<string, unknown>; | |
| 70 | return config.source !== "local" && config.agentId === agentId; | |
| 69 | 71 | }); |
| 70 | 72 | } |
| 71 | 73 |
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+68-13| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | ProviderDriverKind, |
| 14 | 14 | type EnvironmentId, |
| 15 | 15 | type ProviderInstanceConfig, |
| 16 | type ProviderInstanceEnvironmentVariable, | |
| 16 | 17 | } from "@t3tools/contracts"; |
| 17 | 18 | |
| 18 | 19 | import { |
| ... | ... | @@ -39,6 +40,7 @@ import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wiza |
| 39 | 40 | import { |
| 40 | 41 | ADD_PROVIDER_WIZARD_STEPS, |
| 41 | 42 | ACP_REGISTRY_WIZARD_STEPS, |
| 43 | LOCAL_ACP_WIZARD_STEPS, | |
| 42 | 44 | deriveAvailableInstanceId, |
| 43 | 45 | resolveAcpRegistryWizardNavigation, |
| 44 | 46 | resolveWizardNavigation, |
| ... | ... | @@ -51,6 +53,7 @@ import { AcpRegistrySearchStep } from "./AcpRegistrySearchStep"; |
| 51 | 53 | import { ProviderWizardAuthenticationStep } from "./ProviderWizardAuthenticationStep"; |
| 52 | 54 | import { resolveOfficialAcpRegistryIconUrl } from "./AcpRegistryIcon"; |
| 53 | 55 | import { AddManagedCodexAccountDialog } from "./CodexSetupSection"; |
| 56 | import { ProviderEnvironmentSection } from "./ProviderInstanceCard"; | |
| 54 | 57 | |
| 55 | 58 | /** |
| 56 | 59 | * Normalize a user-provided label into a slug suffix for the instance id. |
| ... | ... | @@ -118,6 +121,9 @@ export function AddProviderInstanceDialog({ |
| 118 | 121 | {}, |
| 119 | 122 | ); |
| 120 | 123 | const [selectedAcp, setSelectedAcp] = useState<AcpRegistrySearchAgent | null>(null); |
| 124 | const [localEnvironment, setLocalEnvironment] = useState< | |
| 125 | ReadonlyArray<ProviderInstanceEnvironmentVariable> | |
| 126 | >([]); | |
| 121 | 127 | const [isManualAcpConfiguration, setIsManualAcpConfiguration] = useState(false); |
| 122 | 128 | const [isRegistryLoading, setIsRegistryLoading] = useState(false); |
| 123 | 129 | const [isPreparingRegistryAgent, setIsPreparingRegistryAgent] = useState(false); |
| ... | ... | @@ -170,13 +176,23 @@ export function AddProviderInstanceDialog({ |
| 170 | 176 | const previewLabel = label.trim() || `${driverOption.label} Workspace`; |
| 171 | 177 | |
| 172 | 178 | const configDraft = configByDriver[driver] ?? EMPTY_CONFIG_DRAFT; |
| 179 | const isLocalAcp = isAcpRegistry && isManualAcpConfiguration && configDraft.source === "local"; | |
| 180 | const localCommandPath = | |
| 181 | typeof configDraft.commandPath === "string" ? configDraft.commandPath.trim() : ""; | |
| 173 | 182 | const manualAgentId = typeof configDraft.agentId === "string" ? configDraft.agentId.trim() : ""; |
| 174 | const acpSelectionError = | |
| 175 | selectedAcp !== null || (isManualAcpConfiguration && manualAgentId.length > 0) | |
| 183 | const acpSelectionError = isLocalAcp | |
| 184 | ? localCommandPath.length > 0 | |
| 185 | ? null | |
| 186 | : "Executable is required." | |
| 187 | : selectedAcp !== null || (isManualAcpConfiguration && manualAgentId.length > 0) | |
| 176 | 188 | ? null |
| 177 | 189 | : "Select an ACP or configure one manually."; |
| 178 | 190 | const wizardStepSummaries = isAcpRegistry |
| 179 | ? ([selectedAcp?.name ?? (manualAgentId || null), previewLabel, null] as const) | |
| 191 | ? ([ | |
| 192 | isLocalAcp ? "Local ACP command" : (selectedAcp?.name ?? (manualAgentId || null)), | |
| 193 | previewLabel, | |
| 194 | null, | |
| 195 | ] as const) | |
| 180 | 196 | : ([driverOption.label, previewLabel, null] as const); |
| 181 | 197 | const setConfigDraft = (config: Record<string, unknown> | undefined) => { |
| 182 | 198 | setConfigByDriver((existing) => { |
| ... | ... | @@ -201,7 +217,7 @@ export function AddProviderInstanceDialog({ |
| 201 | 217 | if (navigation.kind === "blocked") { |
| 202 | 218 | setHasAttemptedSubmit(true); |
| 203 | 219 | } |
| 204 | if (isAcpRegistry && navigation.kind === "navigate" && navigation.step === 2) { | |
| 220 | if (isAcpRegistry && !isLocalAcp && navigation.kind === "navigate" && navigation.step === 2) { | |
| 205 | 221 | void handleSave(); |
| 206 | 222 | return; |
| 207 | 223 | } |
| ... | ... | @@ -211,10 +227,15 @@ export function AddProviderInstanceDialog({ |
| 211 | 227 | const navigateToStep = (requestedStep: number) => { |
| 212 | 228 | applyWizardNavigation( |
| 213 | 229 | isAcpRegistry |
| 214 | ? resolveAcpRegistryWizardNavigation(wizardStep, requestedStep, { | |
| 215 | instanceIdError, | |
| 216 | selectionError: acpSelectionError, | |
| 217 | }) | |
| 230 | ? isLocalAcp | |
| 231 | ? resolveWizardNavigation(wizardStep, requestedStep, LOCAL_ACP_WIZARD_STEPS.length, { | |
| 232 | instanceIdError, | |
| 233 | prerequisite: { step: 0, error: acpSelectionError }, | |
| 234 | }) | |
| 235 | : resolveAcpRegistryWizardNavigation(wizardStep, requestedStep, { | |
| 236 | instanceIdError, | |
| 237 | selectionError: acpSelectionError, | |
| 238 | }) | |
| 218 | 239 | : resolveWizardNavigation(wizardStep, requestedStep, ADD_PROVIDER_WIZARD_STEPS.length, { |
| 219 | 240 | instanceIdError, |
| 220 | 241 | }), |
| ... | ... | @@ -268,6 +289,24 @@ export function AddProviderInstanceDialog({ |
| 268 | 289 | setHasAttemptedSubmit(false); |
| 269 | 290 | }; |
| 270 | 291 | |
| 292 | const handleLocalAcpConfiguration = () => { | |
| 293 | setDriver(ACP_REGISTRY_DRIVER_KIND); | |
| 294 | setSelectedAcp(null); | |
| 295 | setIsManualAcpConfiguration(true); | |
| 296 | setConfigByDriver((existing) => ({ | |
| 297 | ...existing, | |
| 298 | [ACP_REGISTRY_DRIVER_KIND]: { source: "local", commandArgs: [] }, | |
| 299 | })); | |
| 300 | setIdentityByDriver((existing) => | |
| 301 | updateProviderIdentityDraft(existing, ACP_REGISTRY_DRIVER_KIND, { | |
| 302 | label: "Local ACP", | |
| 303 | instanceIdOverride: null, | |
| 304 | }), | |
| 305 | ); | |
| 306 | setLocalEnvironment([]); | |
| 307 | setHasAttemptedSubmit(false); | |
| 308 | }; | |
| 309 | ||
| 271 | 310 | const handleSave = async () => { |
| 272 | 311 | if (isSaving || createdInstanceId) return; |
| 273 | 312 | setHasAttemptedSubmit(true); |
| ... | ... | @@ -286,6 +325,7 @@ export function AddProviderInstanceDialog({ |
| 286 | 325 | ...(label.trim().length > 0 ? { displayName: label.trim() } : {}), |
| 287 | 326 | ...(normalizedAccentColor ? { accentColor: normalizedAccentColor } : {}), |
| 288 | 327 | ...(hasConfig ? { config } : {}), |
| 328 | ...(isLocalAcp && localEnvironment.length > 0 ? { environment: localEnvironment } : {}), | |
| 289 | 329 | }; |
| 290 | 330 | // `ProviderInstanceId.make` revalidates the slug; we've already checked |
| 291 | 331 | // it via `validateInstanceId`, but going through the brand constructor |
| ... | ... | @@ -309,7 +349,7 @@ export function AddProviderInstanceDialog({ |
| 309 | 349 | return; |
| 310 | 350 | } |
| 311 | 351 | onCreated?.(brandedId); |
| 312 | if (isAcpRegistry) { | |
| 352 | if (isAcpRegistry && !isLocalAcp) { | |
| 313 | 353 | setCreatedInstanceId(brandedId); |
| 314 | 354 | setIsSaving(false); |
| 315 | 355 | setWizardStep(2); |
| ... | ... | @@ -344,7 +384,7 @@ export function AddProviderInstanceDialog({ |
| 344 | 384 | currentStep={wizardStep} |
| 345 | 385 | summaries={wizardStepSummaries} |
| 346 | 386 | instanceIdError={instanceIdError} |
| 347 | steps={ACP_REGISTRY_WIZARD_STEPS} | |
| 387 | steps={isLocalAcp ? LOCAL_ACP_WIZARD_STEPS : ACP_REGISTRY_WIZARD_STEPS} | |
| 348 | 388 | disabled={isSaving || isPreparingRegistryAgent || createdInstanceId !== null} |
| 349 | 389 | identityStep={1} |
| 350 | 390 | prerequisite={{ step: 0, error: acpSelectionError }} |
| ... | ... | @@ -434,9 +474,13 @@ export function AddProviderInstanceDialog({ |
| 434 | 474 | <div className="grid gap-4"> |
| 435 | 475 | <div className="flex items-start justify-between gap-3"> |
| 436 | 476 | <div> |
| 437 | <h3 className="text-sm font-medium text-foreground">Enter manually</h3> | |
| 477 | <h3 className="text-sm font-medium text-foreground"> | |
| 478 | {isLocalAcp ? "Local ACP command" : "Enter manually"} | |
| 479 | </h3> | |
| 438 | 480 | <p className="mt-0.5 text-xs text-muted-foreground"> |
| 439 | Enter an official registry ID and any local executable or auth override. | |
| 481 | {isLocalAcp | |
| 482 | ? `Run an installed ACP executable on ${environmentLabel}.` | |
| 483 | : "Enter an official registry ID and any local executable or auth override."} | |
| 440 | 484 | </p> |
| 441 | 485 | </div> |
| 442 | 486 | <Button |
| ... | ... | @@ -458,6 +502,12 @@ export function AddProviderInstanceDialog({ |
| 458 | 502 | variant="settings" |
| 459 | 503 | onChange={setConfigDraft} |
| 460 | 504 | /> |
| 505 | {isLocalAcp ? ( | |
| 506 | <ProviderEnvironmentSection | |
| 507 | environment={localEnvironment} | |
| 508 | onChange={setLocalEnvironment} | |
| 509 | /> | |
| 510 | ) : null} | |
| 461 | 511 | </SettingsGroup> |
| 462 | 512 | {isAcpRegistry && hasAttemptedSubmit && acpSelectionError ? ( |
| 463 | 513 | <p className="text-2xs text-destructive">{acpSelectionError}</p> |
| ... | ... | @@ -470,6 +520,7 @@ export function AddProviderInstanceDialog({ |
| 470 | 520 | providerInstances={settings.providerInstances} |
| 471 | 521 | onPrepared={handleAcpPrepared} |
| 472 | 522 | onManualConfiguration={handleManualAcpConfiguration} |
| 523 | onLocalConfiguration={handleLocalAcpConfiguration} | |
| 473 | 524 | onLoadingChange={setIsRegistryLoading} |
| 474 | 525 | onPreparingChange={setIsPreparingRegistryAgent} |
| 475 | 526 | /> |
| ... | ... | @@ -644,7 +695,11 @@ export function AddProviderInstanceDialog({ |
| 644 | 695 | </Button> |
| 645 | 696 | ) : ( |
| 646 | 697 | <Button size="sm" disabled={isSaving} onClick={() => void handleSave()}> |
| 647 | {isSaving ? "Adding..." : isAcpRegistry ? "Continue to sign-in" : "Add instance"} | |
| 698 | {isSaving | |
| 699 | ? "Adding..." | |
| 700 | : isAcpRegistry && !isLocalAcp | |
| 701 | ? "Continue to sign-in" | |
| 702 | : "Add instance"} | |
| 648 | 703 | </Button> |
| 649 | 704 | )} |
| 650 | 705 | </WizardFooter> |
apps/web/src/components/settings/ProviderInstanceCard.tsx+11-3| ... | ... | @@ -296,7 +296,7 @@ function ProviderEnvironmentFieldRow(props: { |
| 296 | 296 | ); |
| 297 | 297 | } |
| 298 | 298 | |
| 299 | function ProviderEnvironmentSection(props: { | |
| 299 | export function ProviderEnvironmentSection(props: { | |
| 300 | 300 | readonly environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>; |
| 301 | 301 | readonly onChange: (environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>) => void; |
| 302 | 302 | }) { |
| ... | ... | @@ -717,8 +717,16 @@ export function ProviderInstanceCard({ |
| 717 | 717 | driverKind={driverKind ?? instance.driver} |
| 718 | 718 | displayName={displayName} |
| 719 | 719 | accentColor={accentColor} |
| 720 | acpRegistryAgentId={readConfigString(instance.config, "agentId") ?? undefined} | |
| 721 | acpRegistryIconUrl={readConfigString(instance.config, "registryIconUrl") ?? undefined} | |
| 720 | acpRegistryAgentId={ | |
| 721 | readConfigString(instance.config, "source") === "local" | |
| 722 | ? undefined | |
| 723 | : (readConfigString(instance.config, "agentId") ?? undefined) | |
| 724 | } | |
| 725 | acpRegistryIconUrl={ | |
| 726 | readConfigString(instance.config, "source") === "local" | |
| 727 | ? undefined | |
| 728 | : (readConfigString(instance.config, "registryIconUrl") ?? undefined) | |
| 729 | } | |
| 722 | 730 | showBadge={Boolean(accentColor)} |
| 723 | 731 | className="size-5" |
| 724 | 732 | iconClassName="size-4 text-foreground/80" |
apps/web/src/components/settings/ProviderSettingsForm.test.ts+8| ... | ... | @@ -57,12 +57,20 @@ describe("ProviderSettingsForm helpers", () => { |
| 57 | 57 | expect(acpRegistry).toBeDefined(); |
| 58 | 58 | expect(acpRegistry?.hasDefaultInstance).toBe(false); |
| 59 | 59 | expect(deriveProviderSettingsFields(acpRegistry!).map((field) => field.key)).toEqual([ |
| 60 | "source", | |
| 60 | 61 | "agentId", |
| 61 | 62 | "commandPath", |
| 62 | 63 | "authMethodId", |
| 63 | 64 | ]); |
| 64 | 65 | }); |
| 65 | 66 | |
| 67 | it("shows the local executable without registry identity or authentication fields", () => { | |
| 68 | const acpRegistry = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("acpRegistry")]; | |
| 69 | expect( | |
| 70 | deriveProviderSettingsFields(acpRegistry!, { source: "local" }).map((field) => field.key), | |
| 71 | ).toEqual(["source", "commandPath"]); | |
| 72 | }); | |
| 73 | ||
| 66 | 74 | it("derives a select control with its choices for the Antigravity sign-in method", () => { |
| 67 | 75 | const antigravity = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("antigravity")]; |
| 68 | 76 | expect(antigravity).toBeDefined(); |
apps/web/src/components/settings/ProviderSettingsForm.tsx+134-7| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | import { useMemo, type ReactNode } from "react"; | |
| 3 | import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | |
| 4 | 4 | import * as Option from "effect/Option"; |
| 5 | 5 | import * as Schema from "effect/Schema"; |
| 6 | 6 | import type { |
| ... | ... | @@ -9,8 +9,10 @@ import type { |
| 9 | 9 | ProviderSettingsFormOption, |
| 10 | 10 | ProviderSettingsFormSchemaAnnotation, |
| 11 | 11 | } from "@t3tools/contracts"; |
| 12 | import { PlusIcon, XIcon } from "lucide-react"; | |
| 12 | 13 | |
| 13 | 14 | import { cn } from "../../lib/utils"; |
| 15 | import { Button } from "../ui/button"; | |
| 14 | 16 | import { DraftInput } from "../ui/draft-input"; |
| 15 | 17 | import { Input } from "../ui/input"; |
| 16 | 18 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| ... | ... | @@ -76,7 +78,10 @@ function readFieldBooleanDefault( |
| 76 | 78 | |
| 77 | 79 | export function deriveProviderSettingsFields( |
| 78 | 80 | definition: ProviderClientDefinition, |
| 81 | value?: unknown, | |
| 79 | 82 | ): ReadonlyArray<ProviderSettingsFieldModel> { |
| 83 | const isLocalAcp = | |
| 84 | definition.value === "acpRegistry" && readProviderConfigString(value, "source") === "local"; | |
| 80 | 85 | const schemaAnnotation = readProviderSettingsFormSchemaAnnotation(definition); |
| 81 | 86 | const orderedKeys = new Map( |
| 82 | 87 | (schemaAnnotation.order ?? []).map((key, index) => [key, index] as const), |
| ... | ... | @@ -95,6 +100,7 @@ export function deriveProviderSettingsFields( |
| 95 | 100 | const fieldSchema = definition.settingsSchema.fields[key]!; |
| 96 | 101 | const formAnnotation = readProviderSettingsFormAnnotation(fieldSchema); |
| 97 | 102 | if (formAnnotation.hidden) return []; |
| 103 | if (isLocalAcp && key !== "source" && key !== "commandPath") return []; | |
| 98 | 104 | |
| 99 | 105 | const annotatedTitle = readFieldAnnotationString(fieldSchema, "title"); |
| 100 | 106 | const annotatedDescription = readFieldAnnotationString(fieldSchema, "description"); |
| ... | ... | @@ -102,11 +108,20 @@ export function deriveProviderSettingsFields( |
| 102 | 108 | { |
| 103 | 109 | key, |
| 104 | 110 | control: formAnnotation.control ?? "text", |
| 105 | label: annotatedTitle ?? titleizeFieldKey(key), | |
| 106 | ...(annotatedDescription !== undefined ? { description: annotatedDescription } : {}), | |
| 107 | ...(formAnnotation.placeholder !== undefined | |
| 108 | ? { placeholder: formAnnotation.placeholder } | |
| 109 | : {}), | |
| 111 | label: | |
| 112 | isLocalAcp && key === "commandPath" | |
| 113 | ? "Executable" | |
| 114 | : (annotatedTitle ?? titleizeFieldKey(key)), | |
| 115 | ...(isLocalAcp && key === "commandPath" | |
| 116 | ? { description: "Executable name or path on this environment." } | |
| 117 | : annotatedDescription !== undefined | |
| 118 | ? { description: annotatedDescription } | |
| 119 | : {}), | |
| 120 | ...(isLocalAcp && key === "commandPath" | |
| 121 | ? { placeholder: "e.g. dsh" } | |
| 122 | : formAnnotation.placeholder !== undefined | |
| 123 | ? { placeholder: formAnnotation.placeholder } | |
| 124 | : {}), | |
| 110 | 125 | clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit", |
| 111 | 126 | ...(formAnnotation.control === "switch" |
| 112 | 127 | ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) } |
| ... | ... | @@ -119,6 +134,112 @@ export function deriveProviderSettingsFields( |
| 119 | 134 | }); |
| 120 | 135 | } |
| 121 | 136 | |
| 137 | let commandArgumentDraftId = 0; | |
| 138 | const makeCommandArgumentDraftRow = (value: string) => ({ | |
| 139 | id: `provider-argument-${commandArgumentDraftId++}`, | |
| 140 | value, | |
| 141 | }); | |
| 142 | ||
| 143 | function commandArgumentsEqual(left: ReadonlyArray<string>, right: ReadonlyArray<string>) { | |
| 144 | return left.length === right.length && left.every((argument, index) => argument === right[index]); | |
| 145 | } | |
| 146 | ||
| 147 | function ProviderCommandArguments({ | |
| 148 | value, | |
| 149 | onChange, | |
| 150 | }: Pick<ProviderSettingsFormProps, "value" | "onChange">) { | |
| 151 | const args = useMemo(() => { | |
| 152 | const configured = | |
| 153 | value !== null && typeof value === "object" | |
| 154 | ? (value as Record<string, unknown>).commandArgs | |
| 155 | : undefined; | |
| 156 | return Array.isArray(configured) | |
| 157 | ? configured.filter((argument): argument is string => typeof argument === "string") | |
| 158 | : []; | |
| 159 | }, [value]); | |
| 160 | const [rows, setRows] = useState(() => args.map(makeCommandArgumentDraftRow)); | |
| 161 | const rowsRef = useRef(rows); | |
| 162 | const previousArgsRef = useRef(args); | |
| 163 | const lastPublishedArgsRef = useRef<ReadonlyArray<string> | undefined>(undefined); | |
| 164 | ||
| 165 | useEffect(() => { | |
| 166 | const previousArgs = previousArgsRef.current; | |
| 167 | const lastPublishedArgs = lastPublishedArgsRef.current; | |
| 168 | previousArgsRef.current = args; | |
| 169 | lastPublishedArgsRef.current = undefined; | |
| 170 | if ( | |
| 171 | commandArgumentsEqual(previousArgs, args) || | |
| 172 | (lastPublishedArgs !== undefined && commandArgumentsEqual(lastPublishedArgs, args)) | |
| 173 | ) | |
| 174 | return; | |
| 175 | const nextRows = args.map(makeCommandArgumentDraftRow); | |
| 176 | rowsRef.current = nextRows; | |
| 177 | setRows(nextRows); | |
| 178 | }, [args]); | |
| 179 | ||
| 180 | const updateArguments = (nextRows: typeof rows) => { | |
| 181 | rowsRef.current = nextRows; | |
| 182 | setRows(nextRows); | |
| 183 | const next = nextRows.map((row) => row.value); | |
| 184 | lastPublishedArgsRef.current = next; | |
| 185 | const config = | |
| 186 | value !== null && typeof value === "object" ? { ...(value as Record<string, unknown>) } : {}; | |
| 187 | onChange({ ...config, commandArgs: next }); | |
| 188 | }; | |
| 189 | ||
| 190 | return ( | |
| 191 | <SettingsRow | |
| 192 | title="Arguments" | |
| 193 | description="One literal argument per row, in launch order." | |
| 194 | control={ | |
| 195 | <Button | |
| 196 | type="button" | |
| 197 | size="sm" | |
| 198 | variant="outline" | |
| 199 | onClick={() => updateArguments([...rowsRef.current, makeCommandArgumentDraftRow("")])} | |
| 200 | > | |
| 201 | <PlusIcon /> | |
| 202 | Add argument | |
| 203 | </Button> | |
| 204 | } | |
| 205 | > | |
| 206 | {rows.length > 0 ? ( | |
| 207 | <div className="mt-3 min-w-0 space-y-2 pb-2"> | |
| 208 | {rows.map((argument, index) => ( | |
| 209 | <div key={argument.id} className="flex min-w-0 items-center gap-1.5"> | |
| 210 | <DraftInput | |
| 211 | size="sm" | |
| 212 | font="mono" | |
| 213 | value={argument.value} | |
| 214 | onCommit={(next) => | |
| 215 | updateArguments( | |
| 216 | rowsRef.current.map((current) => | |
| 217 | current.id === argument.id ? { ...current, value: next } : current, | |
| 218 | ), | |
| 219 | ) | |
| 220 | } | |
| 221 | aria-label={`Argument ${index + 1}`} | |
| 222 | spellCheck={false} | |
| 223 | /> | |
| 224 | <Button | |
| 225 | type="button" | |
| 226 | size="icon-micro" | |
| 227 | variant="ghost-destructive" | |
| 228 | onClick={() => | |
| 229 | updateArguments(rowsRef.current.filter((current) => current.id !== argument.id)) | |
| 230 | } | |
| 231 | aria-label={`Remove argument ${index + 1}`} | |
| 232 | > | |
| 233 | <XIcon /> | |
| 234 | </Button> | |
| 235 | </div> | |
| 236 | ))} | |
| 237 | </div> | |
| 238 | ) : null} | |
| 239 | </SettingsRow> | |
| 240 | ); | |
| 241 | } | |
| 242 | ||
| 122 | 243 | function readProviderConfigString(config: unknown, key: string): string { |
| 123 | 244 | if (config === null || typeof config !== "object") return ""; |
| 124 | 245 | const value = (config as Record<string, unknown>)[key]; |
| ... | ... | @@ -411,7 +532,12 @@ export function ProviderSettingsForm({ |
| 411 | 532 | variant, |
| 412 | 533 | onChange, |
| 413 | 534 | }: ProviderSettingsFormProps) { |
| 414 | const fields = useMemo(() => deriveProviderSettingsFields(definition), [definition]); | |
| 535 | const fields = useMemo( | |
| 536 | () => deriveProviderSettingsFields(definition, value), | |
| 537 | [definition, value], | |
| 538 | ); | |
| 539 | const isLocalAcp = | |
| 540 | definition.value === "acpRegistry" && readProviderConfigString(value, "source") === "local"; | |
| 415 | 541 | |
| 416 | 542 | if (fields.length === 0) { |
| 417 | 543 | return null; |
| ... | ... | @@ -429,6 +555,7 @@ export function ProviderSettingsForm({ |
| 429 | 555 | onChange={onChange} |
| 430 | 556 | /> |
| 431 | 557 | ))} |
| 558 | {isLocalAcp ? <ProviderCommandArguments value={value} onChange={onChange} /> : null} | |
| 432 | 559 | </> |
| 433 | 560 | ); |
| 434 | 561 | } |
apps/web/src/components/settings/ProviderSettingsPanel.tsx+1| ... | ... | @@ -922,6 +922,7 @@ export function EnvironmentProviderSettings({ |
| 922 | 922 | } |
| 923 | 923 | |
| 924 | 924 | if (row.driver !== ProviderDriverKind.make("acpRegistry")) return; |
| 925 | if (providerConfigString(row.instance.config, "source") === "local") return; | |
| 925 | 926 | const agentId = providerConfigString(row.instance.config, "agentId"); |
| 926 | 927 | if (agentId === null) return; |
| 927 | 928 |
apps/web/src/providerInstances.test.ts+39| ... | ... | @@ -619,6 +619,45 @@ describe("resolveDefaultProviderModelSelection", () => { |
| 619 | 619 | }); |
| 620 | 620 | |
| 621 | 621 | describe("provider icon metadata", () => { |
| 622 | it("clears stale registry branding when an instance switches to a local command", () => { | |
| 623 | const instanceId = ProviderInstanceId.make("custom-acp"); | |
| 624 | const driver = ProviderDriverKind.make("acpRegistry"); | |
| 625 | const iconUrl = "https://cdn.agentclientprotocol.com/registry/icons/swe-agent.svg"; | |
| 626 | const snapshots = deriveProviderInstanceEntries([ | |
| 627 | { ...provider({ provider: driver, instanceId }), iconUrl }, | |
| 628 | ]); | |
| 629 | const registryConfig = { agentId: "swe-agent", registryIconUrl: iconUrl }; | |
| 630 | const brandedEntries = applyProviderInstanceSettings(snapshots, { | |
| 631 | providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, | |
| 632 | providers: {} as never, | |
| 633 | }); | |
| 634 | expect(brandedEntries[0]?.acpRegistryAgentId).toBe("swe-agent"); | |
| 635 | expect(brandedEntries[0]?.acpRegistryIconUrl).toBe(iconUrl); | |
| 636 | ||
| 637 | const [localEntry] = applyProviderInstanceSettings(brandedEntries, { | |
| 638 | providerInstances: { | |
| 639 | [instanceId]: { | |
| 640 | driver, | |
| 641 | enabled: false, | |
| 642 | config: { ...registryConfig, source: "local", commandPath: "dsh" }, | |
| 643 | }, | |
| 644 | }, | |
| 645 | providers: {} as never, | |
| 646 | }); | |
| 647 | expect(localEntry?.acpRegistryAgentId).toBeUndefined(); | |
| 648 | expect(localEntry?.acpRegistryIconUrl).toBeUndefined(); | |
| 649 | expect(localEntry?.enabled).toBe(false); | |
| 650 | expect(localEntry?.snapshot).toBe(snapshots[0]?.snapshot); | |
| 651 | ||
| 652 | const [restoredEntry] = applyProviderInstanceSettings(localEntry ? [localEntry] : [], { | |
| 653 | providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, | |
| 654 | providers: {} as never, | |
| 655 | }); | |
| 656 | expect(restoredEntry?.acpRegistryAgentId).toBe("swe-agent"); | |
| 657 | expect(restoredEntry?.acpRegistryIconUrl).toBe(iconUrl); | |
| 658 | expect(restoredEntry?.enabled).toBe(true); | |
| 659 | }); | |
| 660 | ||
| 622 | 661 | it("retains server-published registry icons without local settings", () => { |
| 623 | 662 | const iconUrl = "https://cdn.agentclientprotocol.com/registry/icons/swe-agent.svg"; |
| 624 | 663 | const [entry] = deriveProviderInstanceEntries([ |
apps/web/src/providerInstances.ts+3| ... | ... | @@ -210,6 +210,9 @@ export function applyProviderInstanceSettings( |
| 210 | 210 | explicitInstance.config !== null && typeof explicitInstance.config === "object" |
| 211 | 211 | ? (explicitInstance.config as Readonly<Record<string, unknown>>) |
| 212 | 212 | : null; |
| 213 | if (config?.source === "local") { | |
| 214 | return { ...entry, enabled, acpRegistryAgentId: undefined, acpRegistryIconUrl: undefined }; | |
| 215 | } | |
| 213 | 216 | const agentId = config?.agentId; |
| 214 | 217 | const iconUrl = config?.registryIconUrl; |
| 215 | 218 | return { |
docs/user/providers-acp.md+13-2| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | # ACP Registry | |
| 1 | # ACP providers | |
| 2 | 2 | |
| 3 | T3 Code can run coding agents from the official | |
| 3 | T3 Code can run installed ACP commands or coding agents from the official | |
| 4 | 4 | [ACP Registry](https://agentclientprotocol.com/get-started/registry). Registry agents bring their |
| 5 | 5 | own models, tools, and sign-in, while T3 Code provides projects, threads, checkpoints, and task |
| 6 | 6 | delegation. |
| ... | ... | @@ -23,6 +23,17 @@ work through the same generic integration. |
| 23 | 23 | Search only shows agents that can run on the connected server. Registry agents are third-party |
| 24 | 24 | code; review an agent's source and license before adding it. |
| 25 | 25 | |
| 26 | ## Add a local command | |
| 27 | ||
| 28 | In **Settings → Providers → Add provider**, select **Local ACP command**. Enter the executable | |
| 29 | name or path on the selected environment, add one literal argument per row, and choose a display | |
| 30 | name. For DeepSeek Harness, use `dsh` with arguments `--profile` and `acp`. | |
| 31 | ||
| 32 | Local commands require no registry entry or download. Environment overrides apply to the command; | |
| 33 | credentials and skills remain managed by the installed agent or its wrapper. Models and reasoning | |
| 34 | options come from ACP, including model IDs that contain JSON. T3 Code launches the executable directly | |
| 35 | without expanding shell expressions. | |
| 36 | ||
| 26 | 37 | ## Where agents run |
| 27 | 38 | |
| 28 | 39 | Registry agents always run on the machine that hosts your T3 Code server. That stays true when you |
packages/contracts/src/settings.ts+19-2| ... | ... | @@ -871,6 +871,19 @@ export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPr |
| 871 | 871 | |
| 872 | 872 | export const AcpRegistrySettings = makeProviderSettingsSchema( |
| 873 | 873 | { |
| 874 | source: Schema.Literals(["registry", "local"]).pipe( | |
| 875 | Schema.withDecodingDefault(Effect.succeed("registry")), | |
| 876 | Schema.annotateKey({ | |
| 877 | title: "ACP source", | |
| 878 | providerSettingsForm: { | |
| 879 | control: "select", | |
| 880 | options: [ | |
| 881 | { value: "registry", label: "ACP Registry" }, | |
| 882 | { value: "local", label: "Local command" }, | |
| 883 | ], | |
| 884 | }, | |
| 885 | }), | |
| 886 | ), | |
| 874 | 887 | enabled: Schema.Boolean.pipe( |
| 875 | 888 | Schema.withDecodingDefault(Effect.succeed(true)), |
| 876 | 889 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| ... | ... | @@ -888,10 +901,14 @@ export const AcpRegistrySettings = makeProviderSettingsSchema( |
| 888 | 901 | Schema.annotateKey({ |
| 889 | 902 | title: "Executable override", |
| 890 | 903 | description: |
| 891 | "Optional local executable to use instead of installing the registry distribution. Registry arguments and environment are still applied.", | |
| 904 | "Executable on this environment. For registry agents, this overrides the distribution executable while keeping its arguments and environment.", | |
| 892 | 905 | providerSettingsForm: { placeholder: "Registry default", clearWhenEmpty: "omit" }, |
| 893 | 906 | }), |
| 894 | 907 | ), |
| 908 | commandArgs: Schema.Array(Schema.String).pipe( | |
| 909 | Schema.withDecodingDefault(Effect.succeed([])), | |
| 910 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 911 | ), | |
| 895 | 912 | authMethodId: TrimmedString.pipe( |
| 896 | 913 | Schema.withDecodingDefault(Effect.succeed("")), |
| 897 | 914 | Schema.annotateKey({ |
| ... | ... | @@ -911,7 +928,7 @@ export const AcpRegistrySettings = makeProviderSettingsSchema( |
| 911 | 928 | ), |
| 912 | 929 | }, |
| 913 | 930 | { |
| 914 | order: ["agentId", "commandPath", "authMethodId"], | |
| 931 | order: ["source", "agentId", "commandPath", "authMethodId"], | |
| 915 | 932 | }, |
| 916 | 933 | ); |
| 917 | 934 | export type AcpRegistrySettings = typeof AcpRegistrySettings.Type; |