Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 95 additions & 68 deletions apps/server/src/provider/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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<string>) => ({
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<string>) =>
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", () => {
Expand Down
17 changes: 8 additions & 9 deletions apps/server/src/provider/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 2 additions & 0 deletions packages/provider-acp-registry/src/server/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
? {}
: {
Expand Down
5 changes: 3 additions & 2 deletions packages/provider-acp-registry/src/testing.ts
Original file line number Diff line number Diff line change
@@ -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
*/
Expand All @@ -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,
Expand Down
Loading