Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
Use a fresh clone at the commit in Version or commit with the repository's locked dependencies installed. Save the script below as repro.sh (anywhere outside the checkout) and run it from the checkout root, passing the checkout root as its argument:
tmp="$(mktemp -d)"
# save the script below as "$tmp/repro.sh"
mkdir -p "$tmp/scratch"
TMPDIR="$tmp/scratch" sh "$tmp/repro.sh" "$PWD"The script needs no credentials and starts no model turn. It creates a per-run fixture with private HOME, TMPDIR and T3CODE_HOME, a version-only fake muse executable, and SDK host responses injected through the existing createHost argument of checkMuseProviderStatus; application sources are unchanged, and the fixture is removed on exit. It then:
- Builds a real managed Muse provider (
makeManagedServerProviderwithcheckMuseProviderStatus) and the realProviderRegistry.layer, with a completemodel/listresponse (source: "providerCatalog") listingmuse-current-fixtureandmuse-retired-fixture, both ordinary non-custom rows. It callsProviderRegistry.refreshInstance("muse")and checks that both appear. - Changes the fixture to a complete, nonempty
providerCatalogresponse listing onlymuse-current-fixtureand calls the same registry refresh. It checks that the real status probe returned installed/ready with only the current row, and that the registry result still contains both rows. - JSON-round-trips the returned array in the
{ providers }shape the server refresh handler returns, passes it through the real webderiveProviderInstanceEntries,resolveSelectableProviderInstanceEntry,isProviderInstancePickerReadyand sharedresolveSelectableModel, and checks thatmuse-retired-fixtureresolves as selectable. It also reads the isolatedmuse.jsonstatus cache and checks that it contains the retired slug. - Runs controls: the initial list is correct, the current row still resolves, a failed Muse catalog read (probe status
error) keeps the previous list, andmergeProviderSnapshotwith the same successful-probe shape relabeled as Cursor drops the missing row.
The script exits zero only when the stale Muse row survives and every control passes. It closes all fixture SDK hosts before exiting.
repro.sh
#!/bin/sh
set -eu
ROOT=$(cd "${1:-.}" && pwd)
: "${TMPDIR:?Set TMPDIR to an isolated scratch directory}"
RUN=$(mktemp -d "$TMPDIR/muse-registry.XXXXXX")
trap 'rm -rf "$RUN"' EXIT HUP INT TERM
mkdir -p "$RUN/userhome" "$RUN/tmp" "$RUN/t3"
export HOME="$RUN/userhome" TMPDIR="$RUN/tmp" T3CODE_HOME="$RUN/t3" CHECKOUT_ROOT="$ROOT" REPRO_RUN="$RUN"
cat > "$RUN/muse" <<'CLI'
#!/bin/sh
if [ "$1" = '--version' ]; then
echo 'muse 1.0.3-R2198.1'
else
exit 9
fi
CLI
chmod +x "$RUN/muse"
ln -s "$ROOT/apps/server/node_modules" "$RUN/node_modules"
cat > "$RUN/probe.mjs" <<'JS'
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
import { readFileSync } from 'node:fs';
const root = process.env.CHECKOUT_ROOT;
const run = process.env.REPRO_RUN;
const pkg = async (name) => import(name);
const local = async (name) => import(pathToFileURL(`${root}/${name}`).href);
const Effect = await pkg('effect/Effect');
const Layer = await pkg('effect/Layer');
const Stream = await pkg('effect/Stream');
const Schema = await pkg('effect/Schema');
const PubSub = await pkg('effect/PubSub');
const NodeServices = await pkg('@effect/platform-node/NodeServices');
const Contracts = await pkg('@t3tools/contracts');
const { makeManagedServerProvider } = await pkg('@t3tools/provider-core/server/managedProvider');
const { ProviderHost } = await pkg('@t3tools/provider-core/server/ProviderHost');
const Latest = await pkg('@t3tools/provider-core/server/ProviderLatestVersions');
const { makeManualOnlyProviderMaintenanceCapabilities } = await pkg('@t3tools/provider-core/server/maintenanceResolver');
const { resolveSelectableModel } = await pkg('@t3tools/shared/model');
const { MuseSettings } = await local('packages/provider-muse/src/settings.ts');
const { checkMuseProviderStatus, makePendingMuseProvider } = await local('packages/provider-muse/src/server/status.ts');
const Registry = await local('apps/server/src/provider/ProviderRegistry.ts');
const Instances = await local('apps/server/src/provider/ProviderInstanceRegistry.ts');
const Mutator = await local('apps/server/src/provider/ProviderInstanceRegistryMutator.ts');
const Manifest = await local('apps/server/src/provider/ModelManifest.ts');
const Config = await local('apps/server/src/config.ts');
const Client = await local('apps/web/src/providerInstances.ts');
const settings = Schema.decodeSync(MuseSettings)({enabled:true,binaryPath:`${run}/muse`});
let listed = ['muse-current-fixture','muse-retired-fixture'];
let failed = false;
let requests = 0;
let closes = 0;
const snapshots = [];
const createHost = async () => ({
initializeResult:{grantedCapabilities:[]},
connection:{
request: async (method, input) => {
assert.equal(method,'model/list'); assert.deepEqual(input,{}); requests++;
if (failed) throw new Error('fixture catalog failure');
return {providerId:'meta',profileId:null,source:'providerCatalog',models:listed.map((modelId,index)=>({modelId,displayLabel:modelId,providerId:'meta',profileId:null,isDefault:index===0,isActive:false,contextLimit:null,outputLimit:null,cost:null,description:null,releaseDate:null,variants:'unknown'}))};
},
command:async()=>({}),mintCommandId:()=> 'fixture',onNotification:()=>{},onServerRequest:()=>{},onProtocolError:()=>{},closed:new Promise(()=>{}),
},
exited:new Promise(()=>{}),close:async()=>{closes++;},
});
const stamp = (draft) => ({...draft,instanceId:'muse',driver:'muse'});
const slugs = (provider) => provider.models.map(x=>x.slug);
const program = Effect.gen(function*(){
const services = yield* Effect.context();
const host = {
paths:{cwd:run,baseDir:`${run}/t3`,stateDir:`${run}/t3/state`,providerStatusCacheDir:`${run}/t3/caches`,attachmentsDir:`${run}/t3/attachments`},
settings:{get:Effect.succeed(Contracts.DEFAULT_SERVER_SETTINGS),withSnapshot:use=>use(Contracts.DEFAULT_SERVER_SETTINGS),changes:Stream.empty,subscribe:Effect.succeed(Stream.empty)},
shouldRunBackgroundWork:()=>Effect.succeed(false),resolveAttachmentPath:()=>null,credentials:()=>Effect.die('unused'),
};
const check = checkMuseProviderStatus(settings,process.env,run,createHost).pipe(
Effect.map(stamp), Effect.tap(snapshot=>Effect.sync(()=>snapshots.push(snapshot))),Effect.provideContext(services),
);
const managed = yield* makeManagedServerProvider({
resolveMaintenance:()=>Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({provider:'muse',packageName:null})),
getSettings:Effect.succeed(settings),streamSettings:Stream.empty,haveSettingsChanged:()=>false,
initialSnapshot:()=>makePendingMuseProvider(settings).pipe(Effect.map(stamp)),checkProvider:check,refreshInterval:0,refreshOnInterval:false,
}).pipe(Effect.provideService(ProviderHost,host));
const instance = {
instanceId:'muse',driverKind:'muse',continuationIdentity:{driverKind:'muse',continuationKey:'muse:instance:muse'},displayName:undefined,enabled:true,snapshot:managed,
orchestrationAdapter:{},textGeneration:{},
};
const changes = yield* PubSub.unbounded();
yield* Effect.addFinalizer(()=>PubSub.shutdown(changes));
const instances = {getInstance:id=>Effect.succeed(id==='muse'?instance:undefined),listInstances:Effect.succeed([instance]),listUnavailable:Effect.succeed([]),streamChanges:Stream.empty,subscribeChanges:PubSub.subscribe(changes)};
const registryLayer = Registry.layer.pipe(
Layer.provide(Layer.succeed(Instances.ProviderInstanceRegistry,instances)),
Layer.provide(Layer.succeed(Mutator.ProviderInstanceRegistryMutator,{reconcile:()=>Effect.void,reloadSecrets:()=>Effect.void})),
Layer.provide(Manifest.layerTest),Layer.provide(Latest.layer),
Layer.provideMerge(Config.layerTest(run,`${run}/t3`)),Layer.provide(NodeServices.layer),
);
yield* Effect.gen(function*(){
const registry = yield* Registry.ProviderRegistry;
const first = (yield* registry.refreshInstance('muse'))[0];
assert.deepEqual(slugs(first),listed); assert.equal(first.status,'ready');
console.log('CONTROL initial registry refresh:',JSON.stringify({status:first.status,auth:first.auth,models:slugs(first)}));
listed = ['muse-current-fixture'];
const providers = yield* registry.refreshInstance('muse');
const nextProbe = snapshots.at(-1);
assert.deepEqual(slugs(nextProbe),listed);assert.equal(nextProbe.status,'ready');assert.equal(nextProbe.installed,true);
console.log('REAL Muse status probe after catalog shrink:',JSON.stringify({status:nextProbe.status,auth:nextProbe.auth,models:slugs(nextProbe)}));
// ws.ts refreshProviders returns this exact shape when no optional decoration is requested.
const response = JSON.parse(JSON.stringify({providers}));
const stale = response.providers[0];
assert.deepEqual(slugs(stale),['muse-current-fixture','muse-retired-fixture']);
console.log('REGISTRY refresh result serialized as refreshProviders body:',JSON.stringify({status:stale.status,availability:stale.availability,models:slugs(stale)}));
const entry = Client.resolveSelectableProviderInstanceEntry(Client.deriveProviderInstanceEntries(response.providers),'muse');
assert.ok(entry);assert.equal(Client.isProviderInstancePickerReady(entry),true);
const selected = resolveSelectableModel(entry.driverKind,'muse-retired-fixture',entry.models);
assert.equal(selected,'muse-retired-fixture');assert.ok(entry.models.some(x=>x.slug===selected));
console.log('CLIENT picker ready:',Client.isProviderInstancePickerReady(entry),'selected:',selected);
assert.equal(resolveSelectableModel(entry.driverKind,'muse-current-fixture',entry.models),'muse-current-fixture');
console.log('CONTROL client can choose current model: muse-current-fixture');
const config = yield* Config.ServerConfig;
const cache = JSON.parse(readFileSync(`${config.providerStatusCacheDir}/muse.json`,'utf8'));
console.log('PERSISTED cache contains retired slug:',JSON.stringify(cache).includes('muse-retired-fixture'));
assert.ok(JSON.stringify(cache).includes('muse-retired-fixture'));
const cursor = Registry.mergeProviderSnapshot({...first,instanceId:'cursor',driver:'cursor'},{...nextProbe,instanceId:'cursor',driver:'cursor'});
assert.deepEqual(slugs(cursor),listed);
console.log('CONTROL Cursor merge with same successful probe shape:',JSON.stringify(slugs(cursor)));
failed=true;
const errorProviders=yield* registry.refreshInstance('muse');
assert.equal(snapshots.at(-1).status,'error');assert.deepEqual(slugs(snapshots.at(-1)),[]);
assert.deepEqual(slugs(errorProviders[0]),slugs(first));
console.log('CONTROL failed Muse discovery:',JSON.stringify({probe:snapshots.at(-1).status,retained:slugs(errorProviders[0])}));
console.log('REPRODUCED: completed Muse discovery drops a model, registry and selectable client inventory retain it.');
}).pipe(Effect.provide(registryLayer));
});
await Effect.runPromise(program.pipe(Effect.scoped,Effect.provide(NodeServices.layer)));
assert.ok(requests>=3);assert.equal(closes,requests);
console.log('CLEANUP: all',closes,'SDK fixture hosts closed; scoped registry and managed-provider fibers stopped.');
JS
cd "$ROOT/apps/server"
node "$RUN/probe.mjs"The production caller of this registry operation is the server refresh handler (
Lines 2412 to 2454 in d9805b2
| let providers = yield* input.cwd !== undefined && input.instanceId !== undefined | |
| ? providerRegistry.refreshWorkspaceSnapshot({ | |
| instanceId: input.instanceId, | |
| cwd: input.cwd, | |
| scanCwd: yield* projectService.workspaceScanDirectory(input.cwd), | |
| fresh: input.fresh === true, | |
| }) | |
| : input.reloadSecrets === true | |
| ? providerRegistry.reloadSecretsAndRefresh(input.instanceId) | |
| : input.instanceId !== undefined | |
| ? providerRegistry.refreshInstance(input.instanceId) | |
| : providerRegistry.refresh(); | |
| if (input.refreshModels) { | |
| const instances = yield* providerInstances.listInstances; | |
| for (const instance of instances) { | |
| if ( | |
| !instance.refreshModels || | |
| (input.instanceId !== undefined && input.instanceId !== instance.instanceId) || | |
| !providers.some( | |
| (provider) => | |
| provider.instanceId === instance.instanceId && | |
| provider.enabled && | |
| provider.installed, | |
| ) | |
| ) | |
| continue; | |
| yield* instance.refreshModels().pipe( | |
| Effect.mapError( | |
| (error) => | |
| new ProviderSetupError({ | |
| instanceId: instance.instanceId, | |
| operation: "refresh-models", | |
| detail: error.detail, | |
| }), | |
| ), | |
| ); | |
| providers = yield* providerRegistry.refreshInstance(instance.instanceId); | |
| } | |
| } | |
| if (input.workspaceSnapshotsOnDemand !== true) return { providers }; | |
| const commandProviders = | |
| input.usageLimitsCommand === true | |
| ? withUsageLimitsCommands(providers, yield* usageLimitSources.current) |
{ providers }. The script drives that exported service operation directly; it does not start the server, capture a WebSocket exchange or render a browser.
Expected behavior
When Muse returns a successful, complete providerCatalog with fewer visible rows than before, the refreshed Muse model choices should match that snapshot, so a row Muse no longer lists is no longer offered or selectable. T3's Muse documentation (
t3code/docs/user/providers-muse.md
Lines 35 to 44 in d9805b2
| ## Models | |
| Models and reasoning choices come from Muse on the selected host. After changing | |
| Muse configuration, refresh provider status in **Settings → Providers**. On | |
| mobile, pull down in thread settings to refresh models. If a saved model becomes | |
| unavailable, select an available model before sending another message. | |
| Muse can list models that your account cannot use. If a message fails with | |
| "does not exist or you lack access", pick another model, or add one your account | |
| can use under **Custom models** in the instance settings. |
@muse-code/sdk 1.4.6 (dist/src/msp.d.ts) defines ModelListResult as "A snapshot at call time" whose models are "Visible rows only", and documents ModelCatalogEntry as "One visible catalog row" where "Catalog rows marked hidden never reach the wire, so a client cannot select one."
This expectation covers only a successful, nonempty providerCatalog response. It does not treat an empty or bundled catalog, a failed catalog read, a ready status or a cached model list as proof of account access, and keeping the previous list when the catalog read fails remains correct. The same documentation notes that Muse can list models an account cannot use, so this report makes no claim about whether a retained row is usable.
Actual behavior
Summary. When Muse's model/list returns a complete, nonempty providerCatalog that no longer contains a model it listed before, the real Muse status probe reports only the current models, but ProviderRegistry refresh still returns the removed model, keeps the provider ready, and writes the removed model to the persisted provider status cache. The web client's selection helpers then accept the removed model from that inventory. Merged #28018 (fixing #28008) made a completed catalog refresh drop missing models for Cursor, and its description says the other drivers that fall through to default retention (claudeAgent, fx, muse) were deliberately left unchanged. This report does not say #28018 missed Muse; it asks for the Muse follow-up on the basis of Muse's own snapshot contract and T3's Muse refresh documentation. Evidence comes from controlled SDK responses driven through the real Muse status check, managed provider, registry refresh and cache writer, plus the real client selection helpers; no live WebSocket exchange, rendered picker, real account or catalog change, or paid model turn was exercised.
Observed at both tested builds with identical output: after the catalog shrinks, the real Muse status probe returns installed/ready with only muse-current-fixture, but the registry refresh returns both muse-current-fixture and muse-retired-fixture with status ready, and the isolated muse.json status cache contains muse-retired-fixture. The real client helpers mark the Muse entry picker-ready and resolve muse-retired-fixture as a selectable model. The controls behave as expected: the current row resolves, a failed Muse catalog read keeps the previous two rows, and the same successful-probe shape labeled as Cursor drops the missing row.
Source path at the baseline: the Muse model/list decoder (
t3code/packages/provider-muse/src/server/modelCatalog.ts
Lines 102 to 148 in d9805b2
| settings: MuseSettings, | |
| environment: NodeJS.ProcessEnv | undefined, | |
| cwd?: string, | |
| createHost: typeof createMuseSdkHost = createMuseSdkHost, | |
| ) { | |
| const host = yield* Effect.acquireRelease( | |
| createMuseSdkHostEffect( | |
| { | |
| binaryPath: settings.binaryPath, | |
| ...(environment ? { environment } : {}), | |
| ...(cwd ? { cwd } : {}), | |
| readOnly: true, | |
| startupTimeoutMs: 8_000, | |
| }, | |
| createHost, | |
| ), | |
| (host: MuseSdkHost) => Effect.promise(() => host.close()), | |
| { interruptible: true }, | |
| ); | |
| const result = yield* Effect.tryPromise({ | |
| try: () => host.connection.request("model/list", {}), | |
| catch: (cause) => new MuseSdkError({ operation: "model/list", cause }), | |
| }); | |
| const catalog = yield* decodeModelList(result); | |
| if (catalog.providerId !== "meta") { | |
| return yield* new MuseCatalogError({ detail: "Muse returned a catalog for another provider." }); | |
| } | |
| const seen = new Set<string>(); | |
| return catalog.models.flatMap((model): ServerProviderModel[] => { | |
| if (model.providerId !== "meta" || seen.has(model.modelId)) return []; | |
| seen.add(model.modelId); | |
| const label = model.displayLabel.trim() || model.modelId; | |
| const name = | |
| label === model.modelId && label.startsWith("muse-") | |
| ? label.replaceAll("-", " ").replace(/\b[a-z]/g, (letter) => letter.toUpperCase()) | |
| : label; | |
| return [ | |
| { | |
| slug: model.modelId, | |
| name, | |
| isCustom: false, | |
| isDefault: model.isDefault, | |
| capabilities: museModelCapabilities(model), | |
| }, | |
| ]; | |
| }); | |
| }); |
t3code/packages/provider-muse/src/server/status.ts
Lines 125 to 153 in d9805b2
| ); | |
| if (Result.isFailure(catalog) || Option.isNone(catalog.success)) { | |
| return snapshot({ | |
| installed: true, | |
| version, | |
| status: "error", | |
| auth: { status: "unknown" }, | |
| message: | |
| "Muse Code SDK could not read the model catalog. Check your Muse installation and run `muse login` on this T3 server host.", | |
| }); | |
| } | |
| const models = catalog.success.value; | |
| // MSP has no auth signal and serves cached models while logged out; failures surface on first turn. | |
| return snapshot( | |
| { | |
| installed: true, | |
| version, | |
| auth: { status: "unknown" }, | |
| ...(models.length > 0 | |
| ? { status: "ready" } | |
| : { | |
| status: "warning", | |
| message: | |
| "Muse Code returned no models. Run `muse login` on this T3 server host and refresh its status.", | |
| }), | |
| }, | |
| models, | |
| ); | |
| }); |
warning and a failed read gives error. The Muse driver (t3code/packages/provider-muse/src/server/driver.ts
Lines 157 to 177 in d9805b2
| const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<MuseSettings>>({ | |
| // Status refresh respects update checks; explicit updates use the resolver below. | |
| resolveMaintenance: (options) => | |
| host.settings.get.pipe( | |
| Effect.orDie, | |
| Effect.flatMap((settings) => | |
| settings.enableProviderUpdateChecks | |
| ? resolveMaintenance(options) | |
| : resolveInstallation(options), | |
| ), | |
| ), | |
| getSettings: snapshotSettings.getSettings, | |
| streamSettings: snapshotSettings.streamSettings, | |
| haveSettingsChanged: haveProviderSnapshotSettingsChanged, | |
| initialSnapshot: (settings) => | |
| makePendingMuseProvider(settings.provider).pipe(Effect.map(stampIdentity)), | |
| checkProvider: checkMuseProviderStatus(effectiveConfig, processEnvironment, cwd).pipe( | |
| Effect.map(stampIdentity), | |
| Effect.flatMap(withLatestUsage), | |
| Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| Effect.provideService(FileSystem.FileSystem, fileSystem), |
t3code/apps/server/src/provider/ProviderRegistry.ts
Lines 616 to 631 in d9805b2
| const refreshOneSource = Effect.fn("refreshOneSource")(function* (instance: ProviderInstance) { | |
| const providerSource = buildSnapshotSource(instance); | |
| // Enrichment can publish a newer snapshot before refresh returns its pre-enrichment probe. | |
| const probe = yield* providerSource.refresh.pipe( | |
| Effect.andThen(providerSource.getSnapshot), | |
| Effect.exit, | |
| ); | |
| // Settings can replace or remove the instance mid-probe; its late result or failure is stale. | |
| if (!(yield* isCurrentInstance(instance))) { | |
| return yield* Ref.get(providersRef); | |
| } | |
| const nextProvider = yield* probe; | |
| return yield* correlateSnapshotWithSource(providerSource, nextProvider).pipe( | |
| Effect.flatMap(syncProvider), | |
| ); | |
| }); |
t3code/apps/server/src/provider/ProviderRegistry.ts
Lines 490 to 548 in d9805b2
| const upsertProviders = Effect.fn("upsertProviders")(function* ( | |
| nextProviders: ReadonlyArray<ServerProvider>, | |
| options?: { | |
| readonly publish?: boolean; | |
| readonly persist?: boolean; | |
| readonly replace?: boolean; | |
| }, | |
| ) { | |
| const manifest = yield* manifestService.current; | |
| const nextProvidersWithUpdateState = yield* Effect.forEach( | |
| nextProviders, | |
| applyProviderUpdateState, | |
| { | |
| concurrency: "unbounded", | |
| }, | |
| ); | |
| const [previousProviders, providers, providersToPersist] = yield* Ref.modify( | |
| providersRef, | |
| (previousProviders) => { | |
| const mergedProviders = new Map( | |
| previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), | |
| ); | |
| const updatedKeys = new Set<ProviderInstanceId>(); | |
| for (const provider of nextProvidersWithUpdateState) { | |
| const key = snapshotInstanceKey(provider); | |
| updatedKeys.add(key); | |
| mergedProviders.set( | |
| key, | |
| options?.replace === true | |
| ? provider | |
| : mergeProviderSnapshot(mergedProviders.get(key), provider), | |
| ); | |
| } | |
| const providers = orderProviderSnapshots( | |
| [...mergedProviders.values()].map((provider) => | |
| classifyCompatibility(provider, manifest), | |
| ), | |
| ); | |
| const providersToPersist = providers.filter((provider) => | |
| updatedKeys.has(snapshotInstanceKey(provider)), | |
| ); | |
| return [[previousProviders, providers, providersToPersist] as const, providers]; | |
| }, | |
| ); | |
| if (haveProvidersChanged(previousProviders, providers)) { | |
| if (options?.persist !== false) { | |
| yield* Effect.forEach(providersToPersist, persistProvider, { | |
| concurrency: "unbounded", | |
| discard: true, | |
| }); | |
| } | |
| if (options?.publish !== false) { | |
| // A newer upsert may have committed and published during the cache write; never republish an older array. | |
| yield* PubSub.publish(changesPubSub, yield* Ref.get(providersRef)); | |
| } | |
| } |
shouldRetainMissingProviderModels (t3code/apps/server/src/provider/ProviderRegistry.ts
Lines 179 to 211 in d9805b2
| const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { | |
| if (provider.driver === ProviderDriverKind.make("pi")) { | |
| // Pi auth becomes known only after inventory discovery: empty means unauthenticated; failed/interactive discovery preserves unknown auth and cached models. | |
| return !( | |
| provider.installed && | |
| (provider.status === "ready" || provider.status === "warning") && | |
| provider.auth.status !== "unknown" | |
| ); | |
| } | |
| if (provider.driver === ProviderDriverKind.make("acpRegistry")) { | |
| // Only completed ACP discovery provides nativeSessions and inventory; placeholder probes cannot establish auth for agents without sign-in. | |
| return !( | |
| provider.installed && | |
| provider.status === "ready" && | |
| provider.nativeSessions !== undefined | |
| ); | |
| } | |
| if ( | |
| provider.driver === ProviderDriverKind.make("grok") || | |
| provider.driver === ProviderDriverKind.make("cursor") | |
| ) { | |
| // Grok and Cursor report ready only after they discover a model list; the pending, | |
| // failed, empty-inventory, and logged-out probes are warning/error. | |
| return !(provider.installed && provider.status === "ready"); | |
| } | |
| const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); | |
| const isCodex = provider.driver === ProviderDriverKind.make("codex"); | |
| if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) { | |
| return true; | |
| } |
return true, and mergeProviderModels (t3code/apps/server/src/provider/ProviderRegistry.ts
Lines 235 to 265 in d9805b2
| const mergeProviderModels = ( | |
| provider: ServerProvider, | |
| previousModels: ReadonlyArray<ServerProvider["models"][number]>, | |
| nextModels: ReadonlyArray<ServerProvider["models"][number]>, | |
| ): ReadonlyArray<ServerProvider["models"][number]> => { | |
| const shouldRetainMissingModels = shouldRetainMissingProviderModels(provider); | |
| const updateRequiredSlugs = new Set(provider.updateRequiredModels?.map((model) => model.slug)); | |
| const retainablePreviousModels = previousModels.filter( | |
| (model) => !model.isCustom && !updateRequiredSlugs.has(model.slug), | |
| ); | |
| if (shouldRetainMissingModels && nextModels.length === 0 && retainablePreviousModels.length > 0) { | |
| return retainablePreviousModels; | |
| } | |
| 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)) { | |
| return model; | |
| } | |
| return { | |
| ...model, | |
| capabilities: previousModel.capabilities, | |
| }; | |
| }); | |
| const nextSlugs = new Set(nextModels.map((model) => model.slug)); | |
| return shouldRetainMissingModels | |
| ? [...mergedModels, ...retainablePreviousModels.filter((model) => !nextSlugs.has(model.slug))] | |
| : mergedModels; | |
| }; |
Read from source only, not exercised: the client carries returned models into provider instance entries (
t3code/apps/web/src/providerInstances.ts
Lines 47 to 80 in d9805b2
| export function isProviderInstancePickerReady(entry: ProviderInstanceEntry): boolean { | |
| return entry.enabled && entry.isAvailable && entry.status === "ready"; | |
| } | |
| export function isProviderInstancePickerVisible(entry: ProviderInstanceEntry): boolean { | |
| return entry.enabled; | |
| } | |
| export function deriveProviderInstanceEntries( | |
| providers: ReadonlyArray<ServerProvider>, | |
| ): ReadonlyArray<ProviderInstanceEntry> { | |
| return providers.map((snapshot) => { | |
| const instanceId = snapshot.instanceId; | |
| const driverKind = snapshot.driver; | |
| const defaultId = defaultInstanceIdForDriver(driverKind); | |
| const isDefault = instanceId === defaultId; | |
| return { | |
| instanceId, | |
| driverKind, | |
| displayName: resolveProviderInstanceDisplayName(snapshot), | |
| accentColor: normalizeProviderAccentColor(snapshot.accentColor), | |
| ...(driverKind === "acpRegistry" && snapshot.iconUrl | |
| ? { acpRegistryIconUrl: snapshot.iconUrl } | |
| : {}), | |
| continuationGroupKey: snapshot.continuation?.groupKey, | |
| enabled: snapshot.enabled, | |
| installed: snapshot.installed, | |
| status: snapshot.status, | |
| isDefault, | |
| isAvailable: snapshot.availability !== "unavailable", | |
| snapshot, | |
| models: snapshot.models, | |
| } satisfies ProviderInstanceEntry; | |
| }); |
t3code/apps/web/src/modelSelection.ts
Lines 208 to 246 in d9805b2
| export function getAppModelOptionsForInstance( | |
| settings: UnifiedSettings, | |
| entry: ProviderInstanceEntry, | |
| selectedModel?: string | null, | |
| ): AppModelOption[] { | |
| const options: AppModelOption[] = entry.models | |
| .filter((model) => !model.isCustom) | |
| .map((model) => toAppModelOption(model, entry.driverKind)); | |
| const seen = new Set(options.map((option) => option.slug)); | |
| const builtInModelSlugs = new Set( | |
| Arr.filterMap(entry.models, (model) => | |
| model.isCustom ? Result.failVoid : Result.succeed(model.slug), | |
| ), | |
| ); | |
| const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind); | |
| for (const custom of normalizeCustomModelEntries(customModels, builtInModelSlugs)) { | |
| if (seen.has(custom.slug)) { | |
| continue; | |
| } | |
| seen.add(custom.slug); | |
| const shortName = resolveModelShortName(custom, entry.driverKind); | |
| options.push({ | |
| slug: custom.slug, | |
| name: custom.name, | |
| isCustom: true, | |
| ...(shortName ? { shortName } : {}), | |
| }); | |
| } | |
| const preferences = readInstanceModelPreferences(settings, entry.instanceId); | |
| return appendUnavailableDynamicModelSelection( | |
| applyInstanceModelPreferences(options, preferences), | |
| entry.models, | |
| entry.driverKind, | |
| selectedModel, | |
| preferences.hiddenModels, | |
| ); |
t3code/apps/web/src/components/chat/ModelPickerContent.tsx
Lines 477 to 501 in d9805b2
| modelSlug: string, | |
| instanceId: ProviderInstanceId, | |
| additive = false, | |
| ): string | null => { | |
| if (getModelDisabledReason?.(instanceId, modelSlug)) { | |
| return null; | |
| } | |
| const options = modelOptionsByInstance.get(instanceId); | |
| if (!options) { | |
| return null; | |
| } | |
| const entry = instanceEntries.find((candidate) => candidate.instanceId === instanceId); | |
| if (!entry) { | |
| return null; | |
| } | |
| const resolvedModel = resolveSelectableModel(entry.driverKind, modelSlug, options); | |
| if (!resolvedModel) return null; | |
| if (additive && onToggleModel) { | |
| onToggleModel(instanceId, resolvedModel); | |
| } else { | |
| onInstanceModelChange(instanceId, resolvedModel); | |
| } | |
| return resolvedModel; | |
| }; | |
fx has the same shape from source inspection only (an authenticated, successfully decoded fx models --json listing reports ready, and fx also falls through to default retention); it was not reproduced and is not part of this report.
Hypothesis, not verified: the default retention branch treats a completed Muse catalog shrink the same way as incomplete discovery.
Evidence
- Expected source: T3's Muse documentation,
docs/user/providers-muse.mdlines 35-44, and the pinned@muse-code/sdk1.4.6dist/src/msp.d.ts(ModelCatalogEntryandModelListResultdoc comments) definingmodel/listas a call-time snapshot of visible, selectable rows. - Failure source: observed output of
repro.shabove, throughcheckMuseProviderStatus(packages/provider-muse/src/server/status.tslines 125-153) → managed provider →ProviderRegistry.refreshInstance(apps/server/src/provider/ProviderRegistry.tslines 616-631, 490-548) →shouldRetainMissingProviderModelsdefaulttrue(lines 179-211) andmergeProviderModels(lines 235-265) → persistedmuse.jsonand the client selection helpers. - Evidence provenance: observed
- Local verification: reproduced
- Reproduction completeness: complete
The reproduction controls only the external muse --version output and the SDK host's model/list responses; the Muse status check, managed provider, registry, cache writer and client helpers are unchanged production code. Instance discovery, the model manifest and latest-version lookups are test layers. It does not start an HTTP or WebSocket server, render the picker, use a real Muse account, or run a model turn.
The script output is under Logs or stack traces; it is identical at both tested builds.
Restoration check
With the script above, after the complete providerCatalog shrinks to muse-current-fixture, the registry refresh result and the persisted muse.json cache contain only muse-current-fixture, and resolveSelectableModel no longer resolves muse-retired-fixture from the returned inventory (the script's stale-row assertions then fail by design). The controls must keep their current results: the initial list holds both rows, the current row resolves, and a failed Muse catalog read (probe error) still keeps the previous list. An empty successful catalog (probe warning) should also keep its current retention behavior.
References
- Cursor model list never drops models after an account change; refreshes only ever add to it #28008 (Cursor model list never drops models after an account change; refreshes only ever add to it #28008), closed, and merged fix(cursor): a completed catalog refresh drops models it no longer lists #28018 (fix(cursor): a completed catalog refresh drops models it no longer lists #28018): a completed Cursor catalog refresh now drops models it no longer lists, by giving Cursor the rule Grok already had (keep previous models only while the probe is not installed and ready). fix(cursor): a completed catalog refresh drops models it no longer lists #28018 states: "Other drivers that fall through to the default (
claudeAgent,fx,muse) are deliberately left unchanged." Cursor model list never drops models after an account change; refreshes only ever add to it #28008 noted from source reading that Muse also falls through to default retention but did not reproduce it. - Related, not duplicates: [Bug]: Muse provider warns "returned no models" and asks for login on a working install #34260 (closed) concerns an empty bundled catalog producing a misleading warning, a different input; [Bug]: Muse Ember 1.0 doesn't work even though it works using standard terminal while having Muse Code sub #34409 (open) reports a Muse Ember turn failure without a catalog-removal transition, and this reproduction does not establish its cause; merged fix(server): refresh stale model catalogs before refusing delegation #33426 refreshes stale catalogs before refusing delegation, which adds newly available models rather than removing missing ones.
Impact
Minor bug or occasional failure
Assessed P2 (supported). After a complete, nonempty Muse catalog drops a model, the removed built-in stays in the refreshed inventory, remains selectable through the client helpers and is persisted to the status cache, which can mislead model selection; the reproduction uses controlled SDK responses, and no real account change, rendered picker, failed model turn or data loss was observed.
Version or commit
commit d9805b2; repeated on main at 4be96e0 with identical output; current main 2de3370 leaves ProviderRegistry.ts unchanged (source comparison, not rerun)
Environment
macOS arm64, Node v26.12.1, repository-pinned dependencies including @muse-code/sdk 1.4.6. Controlled SDK model/list responses and a version-only fake Muse CLI driven through the real Muse status check, registry refresh, cache writer and client selection helpers; no real Muse account, model turn or rendered client.
Logs or stack traces
CONTROL initial registry refresh: {"status":"ready","auth":{"status":"unknown"},"models":["muse-current-fixture","muse-retired-fixture"]}
REAL Muse status probe after catalog shrink: {"status":"ready","auth":{"status":"unknown"},"models":["muse-current-fixture"]}
REGISTRY refresh result serialized as refreshProviders body: {"status":"ready","models":["muse-current-fixture","muse-retired-fixture"]}
CLIENT picker ready: true selected: muse-retired-fixture
CONTROL client can choose current model: muse-current-fixture
PERSISTED cache contains retired slug: true
CONTROL Cursor merge with same successful probe shape: ["muse-current-fixture"]
CONTROL failed Muse discovery: {"probe":"error","retained":["muse-current-fixture","muse-retired-fixture"]}
REPRODUCED: completed Muse discovery drops a model, registry and selectable client inventory retain it.
CLEANUP: all 4 SDK fixture hosts closed; scoped registry and managed-provider fibers stopped.
Screenshots, recordings, or supporting files
No response
Workaround
Choosing a model Muse still lists works; the reproduction shows the current row stays selectable, though a model turn with it was not run. Restarting is not a workaround because the stale row is persisted to the status cache and reloaded.
Activity