diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index d0c1f348a736..47f1b7b5f00f 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -19,6 +19,7 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as CodexErrors from "effect-codex-app-server/errors"; import { + AcpRegistryOperationError, EnvironmentId, ClaudeSettings, CodexSettings, @@ -37,6 +38,8 @@ import { ChildProcessSpawner } from "effect/process"; import { deepMerge } from "@t3tools/shared/Struct"; import { createModelCapabilities } from "@t3tools/shared/model"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { AcpRegistrySettings } from "@t3tools/provider-acp-registry/settings"; +import { buildCheckedAcpRegistrySnapshot } from "@t3tools/provider-acp-registry/testing"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; @@ -84,6 +87,7 @@ const disabledDefaultSlots = ( const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); const decodeCodexSettings = Schema.decodeSync(CodexSettings); +const decodeAcpRegistrySettings = Schema.decodeSync(AcpRegistrySettings); const disabledCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({ enabled: false, }); @@ -820,80 +824,103 @@ it.layer( ); }); - it("drops stale ACP Registry models missing from a completed discovery probe", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("acpRegistry_codex"), - driver: ProviderDriverKind.make("acpRegistry"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-08-13T00:00:00.000Z", - version: "1.2.0", - models: [ - { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, - { - slug: "gpt-5.6-sol[low]", - name: "GPT-5.6-Sol (low)", - isCustom: false, - capabilities: null, + describe("ACP Registry model discovery", () => { + const settings = decodeAcpRegistrySettings({ + agentId: "test-agent", + customModels: ["my-custom"], + }); + const snapshotInput = { + instanceId: ProviderInstanceId.make("acpRegistry_test"), + displayName: "Test ACP", + accentColor: undefined, + continuationKey: "acpRegistry:instance:acpRegistry_test", + settings, + checkedAt: "2026-10-14T00:00:00.000Z", + inspection: { + status: "ready", + agentId: "test-agent", + version: "1.0.0", + distribution: "npx", + }, + } as const; + const advertised = (ids: ReadonlyArray) => ({ + probe: { + instanceId: snapshotInput.instanceId, + ready: true as const, + icon: null, + // The agent offers no sign-in, so discovery never reports it authenticated. + authMethods: [], + models: ids.map((id) => ({ id, name: id, description: null })), + currentModelId: ids[0] ?? null, + configOptions: [], + sessionManagement: { + canList: false, + canLoad: false, + canResume: false, + canLogout: false, + canDelete: false, + canConfigureProviders: false, }, - { slug: "default", name: "Default", isCustom: false, capabilities: null }, - ], + }, slashCommands: [], skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-08-13T00:01:00.000Z", - models: [{ slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }], - } satisfies ServerProvider; + }); + const completedProbe = (ids: ReadonlyArray) => + buildCheckedAcpRegistrySnapshot({ ...snapshotInput, probe: advertised(ids) }); + const readiness = buildCheckedAcpRegistrySnapshot(snapshotInput); + const modelSummary = (provider: ServerProvider) => + provider.models.map(({ slug, isCustom, isDefault }) => ({ slug, isCustom, isDefault })); + + it("replaces the model list after a completed discovery probe, without sign-in", () => { + // The cached list already carries the readiness placeholder beside real models. + const cached = ProviderRegistry.mergeProviderSnapshot( + completedProbe(["old-default", "old-removed", "kept"]), + readiness, + ); + assert.deepStrictEqual( + cached.models.filter((model) => model.isDefault).map((model) => model.slug), + ["default", "old-default"], + ); - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, - [...refreshedProvider.models], - ); - }); + const probed = completedProbe(["current", "kept"]); + const merged = ProviderRegistry.mergeProviderSnapshot(cached, probed); - it("retains ACP Registry models while discovery has not completed", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("acpRegistry_codex"), - driver: ProviderDriverKind.make("acpRegistry"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-08-13T00:00:00.000Z", - version: "1.2.0", - models: [{ slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const checkingProvider = { - ...previousProvider, - checkedAt: "2026-08-13T00:01:00.000Z", - auth: { status: "unknown" }, - models: [{ slug: "default", name: "Default", isCustom: false, capabilities: null }], - } satisfies ServerProvider; - const failedProbeProvider = { - ...checkingProvider, - status: "warning", - } satisfies ServerProvider; + assert.strictEqual(merged.auth.status, "unknown"); + assert.deepStrictEqual(modelSummary(merged), [ + { slug: "current", isCustom: false, isDefault: true }, + { slug: "kept", isCustom: false, isDefault: undefined }, + { slug: "my-custom", isCustom: true, isDefault: undefined }, + ]); + }); - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, checkingProvider).models, - [ - { slug: "default", name: "Default", isCustom: false, capabilities: null }, - { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, - ], - ); - assert.deepStrictEqual( - ProviderRegistry.mergeProviderSnapshot(previousProvider, failedProbeProvider).models, - [ - { slug: "default", name: "Default", isCustom: false, capabilities: null }, - { slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null }, - ], - ); + it("keeps known models while discovery is pending or failed", () => { + const previous = completedProbe(["current", "kept"]); + const failedProbe = buildCheckedAcpRegistrySnapshot({ + ...snapshotInput, + probeError: new AcpRegistryOperationError({ + reason: "probe_failed", + message: "The ACP agent could not create a test session.", + }), + }); + // A saved sign-in confirmation marks the readiness snapshot authenticated + // before the background probe has discovered any models. + const confirmedReadiness = { + ...readiness, + auth: { ...readiness.auth, status: "authenticated" }, + } satisfies ServerProvider; + + for (const partial of [readiness, confirmedReadiness, failedProbe]) { + assert.deepStrictEqual( + modelSummary(ProviderRegistry.mergeProviderSnapshot(previous, partial)), + [ + { slug: "default", isCustom: false, isDefault: true }, + { slug: "my-custom", isCustom: true, isDefault: undefined }, + { slug: "current", isCustom: false, isDefault: true }, + { slug: "kept", isCustom: false, isDefault: undefined }, + ], + ); + } + }); }); it("drops stale OpenCode models missing from a successful refresh", () => { diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 0e2c50e6670e..0cf0f3baea5c 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -211,15 +211,14 @@ export function upsertProviderWorkspaceSnapshot( const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { if (provider.driver === ProviderDriverKind.make("acpRegistry")) { // ACP Registry discovery probes return the agent's complete inventory, so - // a completed probe (ready and authenticated) replaces the model list — - // otherwise agents that rename or collapse models leave stale entries - // pinned forever through the snapshot cache. Readiness-only and failed - // probe snapshots only know the "default" placeholder and stay partial. - return !( - provider.installed && - provider.status === "ready" && - provider.auth.status === "authenticated" - ); + // a completed probe replaces the model list — otherwise agents that rename + // or collapse models leave stale entries pinned forever through the + // snapshot cache. Only a completed probe reports the agent's session + // capabilities. Auth is no signal: agents without sign-in stay "unknown", + // and a saved sign-in marks readiness snapshots "authenticated". + // Readiness-only and failed probe snapshots only know the "default" + // placeholder and stay partial. + return provider.nativeSessions === undefined; } const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); diff --git a/packages/provider-acp-registry/src/server/driver.ts b/packages/provider-acp-registry/src/server/driver.ts index a480277acc73..b9aa42c4b118 100644 --- a/packages/provider-acp-registry/src/server/driver.ts +++ b/packages/provider-acp-registry/src/server/driver.ts @@ -234,6 +234,8 @@ function baseSnapshot( }, ...(input.message ? { message: input.message } : {}), models: modelsFromDiscovery(input.probe?.probe, input.settings.customModels), + // Only a completed discovery probe carries session capabilities. The + // provider registry reads them as "this model list is the full inventory". ...(input.probe === undefined ? {} : { diff --git a/packages/provider-acp-registry/src/testing.ts b/packages/provider-acp-registry/src/testing.ts index 83f9ba888085..f136064ec31e 100644 --- a/packages/provider-acp-registry/src/testing.ts +++ b/packages/provider-acp-registry/src/testing.ts @@ -1,6 +1,6 @@ /** - * ACP Registry internals the server's replay testkit and ACP adapter tests - * drive directly. + * ACP Registry internals the server's replay testkit, ACP adapter tests, and + * provider registry tests drive directly. * * @module provider-acp-registry/testing */ @@ -17,6 +17,7 @@ export { normalizeDevinToolCall, } from "./server/devinAcp.ts"; export { extractCopilotSubagentUpdate, makeCopilotSubagentRouting } from "./server/copilotAcp.ts"; +export { buildCheckedAcpRegistrySnapshot } from "./server/driver.ts"; export { acpRegistryProbeFailure, acpRegistryProbeResult,