Skip to content
60 changes: 56 additions & 4 deletions apps/server/src/provider/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -906,7 +906,14 @@ it.layer(
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 }],
models: [
{
slug: "gpt-5.6-sol",
name: "GPT-5.6-Sol",
isCustom: false,
capabilities: codexModelCapabilities,
},
],
slashCommands: [],
skills: [],
} as const satisfies ServerProvider;
Expand All @@ -933,22 +940,67 @@ it.layer(
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 },
...previousProvider.models,
],
);
assert.deepStrictEqual(
ProviderRegistry.mergeProviderSnapshot(previousProvider, confirmedCheckingProvider).models,
[
{ slug: "default", name: "Default", isCustom: false, capabilities: null },
{ slug: "gpt-5.6-sol", name: "GPT-5.6-Sol", isCustom: false, capabilities: null },
...previousProvider.models,
],
);
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 },
...previousProvider.models,
],
);
});

it("does not carry an ACP Registry model's older options onto a refresh that lists it without options", () => {
const thinking = createModelCapabilities({
optionDescriptors: [
selectDescriptor("thought_level", "Thinking", [
{ id: "low", label: "Low" },
{ id: "high", label: "High", isDefault: true },
]),
],
});
const previousProvider = {
instanceId: ProviderInstanceId.make("acpRegistry_devin"),
driver: ProviderDriverKind.make("acpRegistry"),
status: "ready",
enabled: true,
installed: true,
auth: { status: "unknown" },
checkedAt: "2026-10-03T00:00:00.000Z",
version: "3000.11.3",
models: [
{ slug: "opus", name: "Opus", isCustom: false, capabilities: thinking },
{ slug: "glm", name: "GLM", isCustom: false, capabilities: thinking },
],
slashCommands: [],
skills: [],
} as const satisfies ServerProvider;
const refreshedProvider = {
...previousProvider,
checkedAt: "2026-10-03T00:01:00.000Z",
models: [
{
slug: "opus",
name: "Opus",
isCustom: false,
capabilities: createModelCapabilities({ optionDescriptors: [] }),
},
{ slug: "glm", name: "GLM", isCustom: false, capabilities: thinking },
],
} satisfies ServerProvider;

assert.deepStrictEqual(
ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models,
[...refreshedProvider.models],
);
});

Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/provider/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,12 @@ const mergeProviderModels = (
const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const));
const mergedModels = nextModels.map((model) => {
const previousModel = previousBySlug.get(model.slug);
if (!previousModel || hasModelCapabilities(model) || !hasModelCapabilities(previousModel)) {
if (
provider.driver === ProviderDriverKind.make("acpRegistry") ||
!previousModel ||
hasModelCapabilities(model) ||
!hasModelCapabilities(previousModel)
) {
return model;
}
return {
Expand Down
33 changes: 33 additions & 0 deletions apps/server/src/provider/providerStatusCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,39 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {
message: cachedCodex.message,
},
);

const acpDriver = ProviderDriverKind.make("acpRegistry");
const thinking = createModelCapabilities({
optionDescriptors: [
{
id: "thought_level",
label: "Thinking",
type: "select",
options: [{ id: "high", label: "High" }],
},
],
});
// Older servers copied the current model's options onto every cached model.
const cachedAcp = makeProvider(acpDriver, {
models: ["opus", "glm"].map((slug) => ({
slug,
name: slug,
isCustom: false,
capabilities: thinking,
})),
});
const fallbackAcp = makeProvider(acpDriver, {
models: [
{ slug: "default", name: "Default", isCustom: false, capabilities: emptyCapabilities },
],
});
assert.deepStrictEqual(
hydrateCachedProvider({ cachedProvider: cachedAcp, fallbackProvider: fallbackAcp }).models,
[
...fallbackAcp.models,
...cachedAcp.models.map((model) => ({ ...model, capabilities: emptyCapabilities })),
],
);
});

it("does not resurrect cached custom models that settings no longer declare", () => {
Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/provider/providerStatusCache.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
ProviderDriverKind,
type ProviderInstanceId,
type ServerProvider,
ServerProvider as ServerProviderSchema,
} from "@t3tools/contracts";
import { createModelCapabilities } from "@t3tools/shared/model";
import { causeErrorTag } from "@t3tools/shared/observability";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
Expand All @@ -11,6 +13,8 @@ import * as Schema from "effect/Schema";

import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";

const EMPTY_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] });

const decodeProviderStatusCache = Schema.decodeUnknownEffect(
Schema.fromJsonString(ServerProviderSchema),
);
Expand Down Expand Up @@ -77,9 +81,14 @@ export const hydrateCachedProvider = (input: {
}

const { message: _fallbackMessage, ...fallbackWithoutMessage } = input.fallbackProvider;
// Saved ACP snapshots can contain options copied across models by older servers.
const cachedModels =
input.cachedProvider.driver === ProviderDriverKind.make("acpRegistry")
? input.cachedProvider.models.map((model) => ({ ...model, capabilities: EMPTY_CAPABILITIES }))
: input.cachedProvider.models;
const hydratedProvider: ServerProvider = {
...fallbackWithoutMessage,
models: mergeProviderModels(input.fallbackProvider.models, input.cachedProvider.models),
models: mergeProviderModels(input.fallbackProvider.models, cachedModels),
installed: input.cachedProvider.installed,
version: input.cachedProvider.version,
status: input.cachedProvider.status,
Expand Down
4 changes: 4 additions & 0 deletions docs/user/providers-acp.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ composer's model options menu like they do for built-in providers. Agents with t
build modes follow T3 Code's Plan and Build toggle. Models and options that change while an agent
is running update the picker without waiting for another provider probe.

Options are learned separately for each model from provider checks and running threads. Until
a model has been observed, it shows no options and the agent uses its defaults. After restarting
the server, models need to be observed again.

## Commands and skills

Slash commands the agent provides appear under **Provider** in the `/` menu while a session is
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,53 @@
import { describe, expect, it } from "@effect/vitest";
import { ProviderInstanceId } from "@t3tools/contracts";
import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Option from "effect/Option";
import * as TestClock from "effect/testing/TestClock";

import type { AcpRegistryAvailableCommands, AcpRegistryLiveConfiguration } from "./probe.ts";
import {
recordAcpRegistryModelOptions,
type AcpRegistryAvailableCommands,
type AcpRegistryLiveConfiguration,
} from "./probe.ts";
import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts";
import { applyAcpRegistryLiveConfiguration } from "./driver.ts";

type ObservedLiveConfiguration = Parameters<typeof applyAcpRegistryLiveConfiguration>[1];

const probedProvider = {
instanceId: ProviderInstanceId.make("acpRegistry_kilo"),
driver: ProviderDriverKind.make("acpRegistry"),
status: "ready",
enabled: true,
installed: true,
auth: { status: "unknown" },
checkedAt: "2026-10-03T00:00:00.000Z",
version: "1.0.0",
models: [],
slashCommands: [],
skills: [],
} satisfies ServerProvider;

function liveThinking(configuration: ObservedLiveConfiguration) {
return Object.fromEntries(
applyAcpRegistryLiveConfiguration(probedProvider, configuration, []).models.map((model) => {
const descriptor = model.capabilities?.optionDescriptors?.find(
(candidate) => candidate.id === "thought_level",
);
return [
model.slug,
descriptor?.type === "select"
? {
choices: descriptor.options.map((choice) => choice.id),
current: descriptor.currentValue,
}
: null,
];
}),
);
}

describe("AcpRegistryRuntimeCoordinator", () => {
it.effect("suppresses a background probe while foreground startup is active", () =>
Expand Down Expand Up @@ -142,7 +182,7 @@ describe("AcpRegistryRuntimeCoordinator", () => {
const instanceId = ProviderInstanceId.make("acpRegistry_kilo");
const firstSeen = yield* Deferred.make<void>();
const replacementSeen = yield* Deferred.make<void>();
const received: Array<AcpRegistryLiveConfiguration> = [];
const received: Array<ObservedLiveConfiguration> = [];
const first = {
models: [{ id: "sonnet", name: "Sonnet", description: null }],
currentModelId: "sonnet",
Expand Down Expand Up @@ -170,13 +210,113 @@ describe("AcpRegistryRuntimeCoordinator", () => {
yield* Deferred.await(replacementSeen);
yield* Fiber.interrupt(consumer);

expect(received).toEqual([first, replacement]);
expect(yield* coordinator.getLiveConfiguration(instanceId)).toEqual(Option.some(replacement));
const modelSlugs = (configuration: ObservedLiveConfiguration) =>
applyAcpRegistryLiveConfiguration(probedProvider, configuration, []).models.map(
(model) => model.slug,
);
expect(received.map(modelSlugs)).toEqual([["sonnet"], ["opus"]]);
expect(yield* coordinator.getLiveConfiguration(instanceId)).toEqual(Option.some(received[1]));
yield* coordinator.clearLiveConfiguration(instanceId);
expect(Option.isNone(yield* coordinator.getLiveConfiguration(instanceId))).toBe(true);
}).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped),
);

it.effect("retains per-model options across live sessions and refreshed snapshots", () =>
Effect.gen(function* () {
const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator;
const instanceId = ProviderInstanceId.make("acpRegistry_devin");
const otherInstanceId = ProviderInstanceId.make("acpRegistry_other");
const models = [
{ id: "opus", name: "Opus", description: null },
{ id: "glm", name: "GLM", description: null },
{ id: "sonnet", name: "Sonnet", description: null },
];
const thinking = (levels: ReadonlyArray<string>, currentValue: string) => ({
id: "thought_level",
label: "Thinking",
type: "select" as const,
options: levels.map((id) => ({ id, label: id })),
currentValue,
});
const wide = ["low", "medium", "high", "xhigh", "max"];
const narrow = ["low", "high", "max"];
const probedOn = (currentModelId: string): AcpRegistryLiveConfiguration => ({
models,
currentModelId,
configOptions: [thinking(narrow, "low")],
});
const observed = (id: ProviderInstanceId, probe?: AcpRegistryLiveConfiguration) =>
coordinator.getLiveConfiguration(id).pipe(
Effect.map(Option.getOrThrow),
Effect.map((configuration) => ({
...configuration,
modelOptions:
probe === undefined
? configuration.modelOptions
: new Map([
...recordAcpRegistryModelOptions(new Map(), probe),
...configuration.modelOptions,
]),
})),
);

yield* coordinator.publishLiveConfiguration(instanceId, {
models,
currentModelId: "opus",
configOptions: [thinking(wide, "xhigh")],
});
yield* coordinator.publishLiveConfiguration(instanceId, {
models,
currentModelId: "glm",
configOptions: [thinking(narrow, "high")],
});
yield* coordinator.publishLiveConfiguration(otherInstanceId, {
models,
currentModelId: "opus",
configOptions: [thinking(narrow, "low")],
});
// An unidentified current model must not overwrite observations from other sessions.
yield* coordinator.publishLiveConfiguration(instanceId, {
models,
currentModelId: null,
configOptions: [{ ...thinking(narrow, "low"), id: "unrelated" }],
});

expect(liveThinking(yield* observed(instanceId, probedOn("sonnet")))).toEqual({
opus: { choices: wide, current: "xhigh" },
glm: { choices: narrow, current: "high" },
sonnet: { choices: narrow, current: "low" },
});
expect(liveThinking(yield* observed(otherInstanceId))).toEqual({
opus: { choices: narrow, current: "low" },
glm: null,
sonnet: null,
});

yield* coordinator.publishLiveConfiguration(instanceId, {
models,
currentModelId: "opus",
configOptions: [thinking(narrow, "low")],
});
expect(liveThinking(yield* observed(instanceId, probedOn("sonnet")))).toEqual({
opus: { choices: narrow, current: "low" },
glm: { choices: narrow, current: "high" },
sonnet: { choices: narrow, current: "low" },
});

yield* coordinator.publishLiveConfiguration(instanceId, {
models,
currentModelId: "opus",
configOptions: [],
});
expect(liveThinking(yield* observed(instanceId, probedOn("opus")))).toEqual({
opus: null,
glm: { choices: narrow, current: "high" },
sonnet: null,
});
}).pipe(Effect.provide(AcpRegistryRuntimeCoordinator.layer), Effect.scoped),
);

it.effect("requires matching user consent before accepting URL authentication", () =>
Effect.gen(function* () {
const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator;
Expand Down
Loading
Loading