diff --git a/apps/mobile/src/features/threads/FusionModelEditor.tsx b/apps/mobile/src/features/threads/FusionModelEditor.tsx new file mode 100644 index 000000000000..463beb5de0cd --- /dev/null +++ b/apps/mobile/src/features/threads/FusionModelEditor.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { Pressable, ScrollView, View } from "react-native"; +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import type { ModelOption } from "../../lib/modelOptions"; +import { getFusionChoices } from "@t3tools/client-runtime/fusionModels"; + +export function FusionModelEditor(props: { + readonly models: ReadonlyArray; + readonly initialKey: string; + readonly onSelect: (model: ModelOption) => void; +}) { + const [selectedKey, setSelectedKey] = useState(props.initialKey); + const [editing, setEditing] = useState<"lead" | "sidekick" | null>(null); + const available = props.models.filter((model) => model.fusion && !model.isUnavailable); + const selected = available.find((model) => model.key === selectedKey); + if (!selected?.fusion) { + return ( + + This pairing is no longer available. Go back to choose another model. + + ); + } + const pairing = selected.fusion; + const choices = getFusionChoices(available, selected); + + return ( + + + {selected.providerLabel} + + Choose a lead model and a sidekick to work together. + + + + {(["lead", "sidekick"] as const).map((role) => ( + + + {role === "lead" ? "Lead" : "Sidekick"} + + setEditing(editing === role ? null : role)} + className="min-h-14 flex-row items-center gap-3 rounded-2xl bg-card px-4 py-3 active:bg-subtle" + > + + + {pairing[role].name} + + + + {editing === role ? ( + + {choices[role].map((model) => ( + { + setSelectedKey(model.key); + setEditing(null); + }} + className="min-h-12 flex-row items-center gap-3 px-4 py-3 active:bg-subtle" + > + + {model.fusion?.[role].name} + + {model.fusion?.[role].id === pairing[role].id ? ( + + ) : null} + + ))} + + ) : null} + + ))} + + props.onSelect(selected)} + className="min-h-12 items-center justify-center rounded-2xl bg-primary px-4 py-3 active:opacity-70" + > + Use Fusion + + + ); +} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index c8512131631e..d023c8269e53 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -112,7 +112,7 @@ import { sourceControlEnvironment } from "../../state/sourceControl"; import { useAtomCommand } from "../../state/use-atom-command"; import { ProjectCloneBanner } from "../../components/ProjectCloneBanner"; import { - isModelSelectionUnavailable, + getModelSelectionUnavailableReason, resolveSelectableModelSelection, } from "../../lib/modelOptions"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; @@ -1200,14 +1200,12 @@ export function NewTaskDraftScreen(props: { ) { return; } - if ( - environmentConnected && - isModelSelectionUnavailable(selectedEnvironmentServerConfig, modelSelection) - ) { - Alert.alert( - "Antigravity model unavailable", - "Set up Antigravity on web or desktop, or choose another model.", - ); + const modelUnavailableReason = getModelSelectionUnavailableReason( + selectedEnvironmentServerConfig, + modelSelection, + ); + if (environmentConnected && modelUnavailableReason !== null) { + Alert.alert("Model unavailable", modelUnavailableReason); return; } // T3's own limits command is answered by the thread composer; a new task would diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index c251cf969879..27b42bf2572e 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -557,10 +557,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const providerOptionDescriptors = useMemo( () => resolveProviderOptionDescriptors({ + modelPolicy: currentModelOption?.modelPolicy, capabilities: currentModelOption?.capabilities, selections: currentModelSelection.options, }), - [currentModelOption?.capabilities, currentModelSelection.options], + [ + currentModelOption?.modelPolicy, + currentModelOption?.capabilities, + currentModelSelection.options, + ], ); const settingsOwnerId = composerOwnerKey; const settingsRouteSession = useMemo( diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 72110890c408..1ac4b5fc7d65 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -1,3 +1,5 @@ +import { FusionModelEditor } from "./FusionModelEditor"; +import { collapseFusionModels } from "@t3tools/client-runtime/fusionModels"; import type { EnvironmentId, ModelSelection, @@ -12,7 +14,6 @@ import { HeaderHeightContext } from "@react-navigation/elements"; import { getProviderOptionCurrentLabel, getProviderOptionCurrentValue, - getProviderOptionDescriptors, } from "@t3tools/shared/model"; import { useNavigation, useRoute, type RouteProp } from "@react-navigation/native"; import { @@ -115,7 +116,7 @@ function ModelRow(props: { return ( ) : null} + {props.option.fusion && !props.option.isUnavailable ? ( + + ) : null} {props.selected ? ( void; - readonly commitPendingModel: () => boolean; + readonly commitPendingModel: (model?: ModelOption) => boolean; readonly isApplied: (option: ModelOption) => boolean; readonly isDisplayed: (option: ModelOption) => boolean; readonly pressModel: (option: ModelOption) => void; @@ -406,6 +410,7 @@ const ThreadSettingsSessionContext = createContext(null); const [searchQuery, setSearchQuery] = useState(""); @@ -432,12 +437,11 @@ function ThreadSettingsSessionProvider( const displayedDescriptors = useMemo( () => pendingModel - ? pendingModel.capabilities - ? getProviderOptionDescriptors({ - caps: pendingModel.capabilities, - selections: pendingModel.selection.options, - }) - : [] + ? resolveProviderOptionDescriptors({ + capabilities: pendingModel.capabilities, + selections: pendingModel.selection.options, + modelPolicy: pendingModel.modelPolicy, + }) : props.optionDescriptors, [pendingModel, props.optionDescriptors], ); @@ -460,20 +464,23 @@ function ThreadSettingsSessionProvider( () => props.providerGroups.some((group) => group.models.some((model) => model.isLegacy)), [props.providerGroups], ); - const commitPendingModel = useCallback(() => { - if (pendingModel) { - if (!canCommitPendingModel(pendingModel, props.providerGroups)) { - Alert.alert( - "Model unavailable", - "Set up this provider on web or desktop, or select another model.", - ); - return false; + const commitPendingModel = useCallback( + (model = pendingModel) => { + if (model) { + if (!canCommitPendingModel(model, props.providerGroups)) { + Alert.alert( + "Model unavailable", + "Set up this provider on web or desktop, or select another model.", + ); + return false; + } + void Haptics.selectionAsync(); + onSelectModel(model); } - void Haptics.selectionAsync(); - props.onSelectModel(pendingModel); - } - return true; - }, [pendingModel, props.onSelectModel, props.providerGroups]); + return true; + }, + [pendingModel, onSelectModel, props.providerGroups], + ); const applyOptionChange = useCallback( (id: string, value: string | boolean) => { @@ -488,10 +495,10 @@ function ThreadSettingsSessionProvider( selection: { ...pendingModel.selection, options: next }, }); } else { - props.onUpdateOptionSelections(next); + onUpdateOptionSelections(next); } }, - [displayedDescriptors, pendingModel, props.onUpdateOptionSelections], + [displayedDescriptors, pendingModel, onUpdateOptionSelections], ); const toggleProvider = useCallback((providerKey: string) => { @@ -619,11 +626,18 @@ function ThreadSettingsModelListRow(props: { readonly isFirst: boolean; readonly isLast: boolean; }) { - const session = useThreadSettingsSession(); - const onPress = useCallback( - () => session.pressModel(props.option), - [props.option, session.pressModel], - ); + const { pressModel, isDisplayed } = useThreadSettingsSession(); + const navigation = useNavigation>(); + const onPress = useCallback(() => { + if (props.option.fusion && !props.option.isUnavailable) { + navigation.navigate("ThreadSettingsFusion", { + providerKey: props.option.providerKey, + initialKey: props.option.key, + }); + } else { + pressModel(props.option); + } + }, [navigation, props.option, pressModel]); return ( ); } @@ -639,10 +653,10 @@ function ThreadSettingsModelListRow(props: { function ThreadSettingsProviderListHeader(props: { readonly provider: ThreadSettingsProviderCatalog; }) { - const session = useThreadSettingsSession(); + const { toggleProvider } = useThreadSettingsSession(); const onToggle = useCallback( - () => session.toggleProvider(props.provider.key), - [props.provider.key, session.toggleProvider], + () => toggleProvider(props.provider.key), + [props.provider.key, toggleProvider], ); return ( @@ -661,6 +675,7 @@ function ThreadSettingsProviderListHeader(props: { function useThreadSettingsCatalogItems( session: ThreadSettingsSessionValue, ): ReadonlyArray { + const { isDisplayed } = session; return useMemo( () => session.providerGroups.flatMap((group) => { @@ -670,13 +685,17 @@ function useThreadSettingsCatalogItems( const driver = group.models[0]?.providerDriver ?? group.providerKey; const catalogModels = session.showLegacy ? group.models - : group.models.filter((model) => !model.isLegacy || session.isDisplayed(model)); - const visibleModels = catalogModels.filter((model) => - modelMatchesCatalogQuery({ - model, - providerLabel: group.providerLabel, - query: session.searchQuery, - }), + : group.models.filter((model) => !model.isLegacy || isDisplayed(model)); + const visibleModels = collapseFusionModels( + catalogModels.filter((model) => + modelMatchesCatalogQuery({ + model, + providerLabel: group.providerLabel, + query: session.searchQuery, + }), + ), + (model) => model.providerKey, + isDisplayed, ); if (visibleModels.length === 0) { return []; @@ -711,7 +730,10 @@ function useThreadSettingsCatalogItems( }, ...provider.models.map((option, index) => ({ kind: "model" as const, - key: `model:${option.key}`, + key: + option.fusion && !option.isUnavailable + ? `fusion:${option.providerKey}` + : `model:${option.key}`, option, isFirst: index === 0, isLast: index === provider.models.length - 1, @@ -720,7 +742,7 @@ function useThreadSettingsCatalogItems( }), [ session.isApplied, - session.isDisplayed, + isDisplayed, session.providerExpansionOverrides, session.providerFilter, session.providerGroups, @@ -999,6 +1021,7 @@ function ThreadSettingsChoiceContent(props: { type ThreadSettingsPickerStackParams = { ThreadSettingsModels: undefined; + ThreadSettingsFusion: { readonly providerKey: string; readonly initialKey: string }; ThreadSettingsChoice: ThreadSettingsSubmenuPage & { readonly title: string }; }; @@ -1219,6 +1242,36 @@ function ThreadSettingsModelsScreen() { ); } +function ThreadSettingsFusionScreen() { + const session = useThreadSettingsSession(); + const presentation = useThreadSettingsPickerPresentation(); + const navigation = useNavigation>(); + const route = useRoute>(); + const models = + session.providerGroups.find((group) => group.providerKey === route.params.providerKey) + ?.models ?? []; + return ( + <> + + {Platform.OS === "android" ? ( + navigation.goBack()} /> + ) : null} + { + const pending = session.pendingModel; + if (session.isApplied(option) && pending?.key !== option.key) { + presentation.onClose(); + } else if (session.commitPendingModel(pending?.key === option.key ? pending : option)) { + presentation.onClose(); + } + }} + /> + + ); +} + function ThreadSettingsChoiceScreen() { const navigation = useNavigation>(); const route = useRoute>(); @@ -1272,6 +1325,11 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) component={ThreadSettingsModelsScreen} options={{ headerBackVisible: false, title: "Thread settings" }} /> + resolveProviderOptionDescriptors({ + modelPolicy: flow.selectedModelOption?.modelPolicy, capabilities: flow.selectedModelOption?.capabilities, selections: flow.selectedModel?.options, }), - [flow.selectedModel?.options, flow.selectedModelOption?.capabilities], + [ + flow.selectedModel?.options, + flow.selectedModelOption?.capabilities, + flow.selectedModelOption?.modelPolicy, + ], ); return ( diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index ab60851396cc..a64797b25d69 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it } from "vite-plus/test"; -import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3tools/contracts"; +import { + ProviderDriverKind, + ProviderInstanceId, + type ModelSelection, + type ServerConfig, +} from "@t3tools/contracts"; import { buildModelOptions, groupByProvider, + getModelSelectionUnavailableReason, isModelSelectionUnavailable, resolveDefaultableModelSelection, resolveNewTaskModelSelection, @@ -235,7 +241,7 @@ describe("mobile model options", () => { expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled); }); - describe("Antigravity selections", () => { + describe.each(["antigravity", "acpRegistry"])("%s selections", (driver) => { const selection = { instanceId: ProviderInstanceId.make("google_work"), model: "gemini-3.1-pro-high", @@ -264,7 +270,12 @@ describe("mobile model options", () => { providers: [ { instanceId: selection.instanceId, - driver: "antigravity", + driver, + modelPolicy: { + catalogScope: "instance", + preserveUnavailableModels: true, + optionSelection: "exact", + }, displayName: "Google Work", enabled: true, installed: true, @@ -274,6 +285,104 @@ describe("mobile model options", () => { ], } as unknown as ServerConfig; + it("normalizes options when an instance catalog does not require exact variants", () => { + const ordinaryConfig = { + ...config, + providers: config.providers.map((provider) => ({ + ...provider, + modelPolicy: { catalogScope: "instance", preserveUnavailableModels: true } as const, + })), + }; + expect(buildModelOptions(ordinaryConfig, selection)[0]?.selection).toEqual({ + ...selection, + options: [{ id: "native-option", value: "current/default" }], + }); + }); + + it("accepts advertised aliases without changing the saved model or options", () => { + const aliasSelection = { ...selection, model: "catalog-alias" }; + const aliasConfig = { + ...config, + providers: config.providers.map((provider) => ({ + ...provider, + models: provider.models.map((entry) => ({ ...entry, aliases: [aliasSelection.model] })), + })), + }; + expect(getModelSelectionUnavailableReason(aliasConfig, aliasSelection)).toBeNull(); + expect(resolveSelectableModelSelection(aliasConfig, aliasSelection)).toBe(aliasSelection); + expect(resolveDefaultableModelSelection(aliasConfig, aliasSelection)).toBe(aliasSelection); + const options = buildModelOptions(aliasConfig, aliasSelection); + expect(options).toHaveLength(1); + const [option] = options; + expect(option?.key).toBe(`${selection.instanceId}:${model.slug}`); + expect(option?.isUnavailable).not.toBe(true); + expect(option?.label).toBe(model.name); + expect(option?.capabilities).toEqual(model.capabilities); + expect(option?.selection).toBe(aliasSelection); + expect( + isModelSelectionUnavailable(aliasConfig, { ...selection, model: "unknown-alias" }), + ).toBe(true); + expect( + isModelSelectionUnavailable( + { + ...aliasConfig, + providers: config.providers, + }, + aliasSelection, + ), + ).toBe(true); + }); + + it("prefers a direct slug over another model's alias", () => { + const collisionConfig = { + ...config, + providers: config.providers.map((provider) => ({ + ...provider, + models: [ + ...provider.models.map((entry) => ({ + ...entry, + slug: "other-model", + aliases: [selection.model], + })), + ...provider.models, + ], + })), + }; + const options = buildModelOptions(collisionConfig, selection); + expect(options).toHaveLength(2); + expect( + options.find((option) => option.key === `${selection.instanceId}:${selection.model}`) + ?.selection, + ).toBe(selection); + expect( + options.find((option) => option.key === `${selection.instanceId}:other-model`)?.selection + .model, + ).toBe("other-model"); + }); + + it("honors an unknown driver's advertised instance catalog", () => { + const advertisedConfig = { + ...config, + providers: config.providers.map( + (provider) => + ({ + ...provider, + driver: ProviderDriverKind.make("test-account-provider"), + modelPolicy: { + catalogScope: "instance", + preserveUnavailableModels: true, + optionSelection: "exact", + }, + models: [], + }) satisfies ServerConfig["providers"][number], + ), + }; + expect(resolveSelectableModelSelection(advertisedConfig, selection)).toBe(selection); + const [option] = buildModelOptions(advertisedConfig, selection); + expect(option?.isUnavailable).toBe(true); + expect(option?.selection).toBe(selection); + }); + it.each([ ["disabled", { enabled: false }], ["uninstalled", { installed: false }], @@ -296,7 +405,7 @@ describe("mobile model options", () => { subtitle: "Google", providerKey: "google_work", providerLabel: "Google Work", - providerDriver: "antigravity", + providerDriver: driver, isDefault: false, isLegacy: true, isUnavailable: true, @@ -321,7 +430,7 @@ describe("mobile model options", () => { expect(missing).toMatchObject({ label: selection.model, providerLabel: "Google Work", - providerDriver: "antigravity", + providerDriver: driver, isUnavailable: true, capabilities: null, }); @@ -348,16 +457,19 @@ describe("mobile model options", () => { providers: [], settings: { providerInstances: { - [selection.instanceId]: { driver: "antigravity", displayName: "Google Work" }, + [selection.instanceId]: { driver, displayName: "Google Work" }, }, }, } as unknown as ServerConfig; expect(resolveDefaultableModelSelection(missingStatusConfig, selection)).toBe(selection); expect(isModelSelectionUnavailable(missingStatusConfig, selection)).toBe(true); + expect(getModelSelectionUnavailableReason(missingStatusConfig, selection)).toBe( + "Google Work model unavailable. Set up this provider on web or desktop, or choose another model.", + ); expect(buildModelOptions(missingStatusConfig, selection)).toMatchObject([ { - providerDriver: "antigravity", + providerDriver: driver, providerLabel: "Google Work", isUnavailable: true, selection, @@ -365,11 +477,12 @@ describe("mobile model options", () => { ]); }); - it("keeps offline selections without assuming that an unknown instance is Antigravity", () => { + it("keeps offline selections without assuming an unknown instance belongs to this provider", () => { const unknownConfig = { ...config, providers: [] }; expect(resolveDefaultableModelSelection(null, selection)).toBe(selection); expect(isModelSelectionUnavailable(null, selection)).toBe(false); + expect(getModelSelectionUnavailableReason(null, selection)).toBeNull(); expect(buildModelOptions(null, selection)[0]?.selection).toBe(selection); expect(buildModelOptions(null, selection)[0]?.isUnavailable).not.toBe(true); expect(isModelSelectionUnavailable(unknownConfig, selection)).toBe(false); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index d394f9d046a7..7222a9b1e695 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -1,14 +1,13 @@ import type { MenuAction } from "@react-native-menu/menu"; +import { resolveProviderModelPolicy } from "@t3tools/contracts"; import type { ModelCapabilities, ModelSelection, RuntimeMode, + ServerProvider, ServerConfig as T3ServerConfig, } from "@t3tools/contracts"; -import { - buildExplicitProviderOptionSelectionsFromDescriptors, - getProviderOptionDescriptors, -} from "@t3tools/shared/model"; +import { resolveProviderModelOptions } from "@t3tools/client-runtime/providerModelOptions"; export type ModelOption = { readonly key: string; @@ -23,6 +22,8 @@ export type ModelOption = { readonly isLegacy: boolean; readonly isUnavailable?: boolean; readonly capabilities: ModelCapabilities | null; + readonly modelPolicy?: ServerProvider["modelPolicy"]; + readonly fusion?: ServerProvider["models"][number]["fusion"]; readonly selection: ModelSelection; }; @@ -47,17 +48,14 @@ function providerDisplayLabel(provider: { function normalizeSelectionOptions( selection: ModelSelection, capabilities: ModelCapabilities | null, + modelPolicy: ServerProvider["modelPolicy"], ): ModelSelection { - if (!capabilities) { - return selection; - } - const options = buildExplicitProviderOptionSelectionsFromDescriptors( - getProviderOptionDescriptors({ - caps: capabilities, - selections: selection.options, - }), + const { selections: options } = resolveProviderModelOptions( + capabilities, selection.options, + modelPolicy, ); + if (options === selection.options) return selection; return options ? { ...selection, options } : { @@ -66,32 +64,45 @@ function normalizeSelectionOptions( }; } -/** Whether a known Antigravity selection needs setup or a different model. */ -export function isModelSelectionUnavailable( +/** Explain how to recover a known account-model selection that is unavailable. */ +export function getModelSelectionUnavailableReason( config: T3ServerConfig | null | undefined, selection: ModelSelection | null | undefined, -): boolean { +): string | null { if (!config || !selection) { - return false; + return null; } const provider = config.providers.find( (candidate) => candidate.instanceId === selection.instanceId, ); - const driver = - provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver; - return ( - driver === "antigravity" && + const instanceConfig = config.settings?.providerInstances[selection.instanceId]; + if ( + resolveProviderModelPolicy(provider ?? instanceConfig).catalogScope === "instance" && (!provider || !provider.enabled || !provider.installed || provider.auth.status === "unauthenticated" || provider.availability === "unavailable" || - !provider.models.some((model) => model.slug === selection.model)) - ); + !provider.models.some( + (model) => model.slug === selection.model || model.aliases?.includes(selection.model), + )) + ) { + const name = provider?.displayName ?? instanceConfig?.displayName; + const subject = name ? `${name} model` : "Model"; + return `${subject} unavailable. Set up this provider on web or desktop, or choose another model.`; + } + return null; +} + +export function isModelSelectionUnavailable( + config: T3ServerConfig | null | undefined, + selection: ModelSelection | null | undefined, +): boolean { + return getModelSelectionUnavailableReason(config, selection) !== null; } /** - * Keep Antigravity selections when setup or catalog changes make them + * Keep selections marked for preservation when setup or catalog changes make them * unavailable. Other providers fall through to the server default when they * are disabled, missing, or signed out. Without config, keep stored selections. */ @@ -105,9 +116,10 @@ export function resolveSelectableModelSelection( const provider = config.providers.find( (candidate) => candidate.instanceId === selection.instanceId, ); - const driver = - provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver; - if (driver === "antigravity") { + if ( + resolveProviderModelPolicy(provider ?? config.settings?.providerInstances[selection.instanceId]) + .preserveUnavailableModels + ) { return selection; } return provider && @@ -119,7 +131,7 @@ export function resolveSelectableModelSelection( } /** - * Reject legacy models for implicit defaults, except Antigravity selections, + * Reject legacy models for implicit defaults, except preserved selections, * which must not silently change after a catalog update. Explicit picks in * the settings sheet are unaffected. */ @@ -132,8 +144,12 @@ export function resolveDefaultableModelSelection( return usable; } const provider = config.providers.find((candidate) => candidate.instanceId === usable.instanceId); - const model = provider?.models.find((candidate) => candidate.slug === usable.model); - return provider?.driver !== "antigravity" && model?.isLegacy === true ? null : usable; + const model = provider?.models.find( + (candidate) => candidate.slug === usable.model || candidate.aliases?.includes(usable.model), + ); + return !resolveProviderModelPolicy(provider).preserveUnavailableModels && model?.isLegacy === true + ? null + : usable; } export function resolveNewTaskModelSelection(input: { @@ -163,7 +179,7 @@ export function buildModelOptions( !provider.enabled || !provider.installed || provider.auth.status === "unauthenticated" || - (provider.driver === "antigravity" && provider.availability === "unavailable") + provider.availability === "unavailable" ) { continue; } @@ -173,8 +189,11 @@ export function buildModelOptions( const key = `${provider.instanceId}:${model.slug}`; options.set(key, { key, - label: model.name, - subtitle: model.subProvider ?? "", + label: model.fusion ? "Fusion" : model.name, + subtitle: model.fusion + ? `${model.fusion.lead.name} + ${model.fusion.sidekick.name}` + : (model.subProvider ?? ""), + fusion: model.fusion, providerKey: provider.instanceId, providerLabel, providerDriver: provider.driver, @@ -185,36 +204,41 @@ export function buildModelOptions( isDefault: model.isDefault === true, isLegacy: model.isLegacy === true, capabilities: model.capabilities, + modelPolicy: resolveProviderModelPolicy(provider), selection: normalizeSelectionOptions( { instanceId: provider.instanceId, model: model.slug, }, model.capabilities, + provider.modelPolicy, ), }); } } if (fallbackModelSelection) { - const key = `${fallbackModelSelection.instanceId}:${fallbackModelSelection.model}`; + const provider = config?.providers.find( + (candidate) => candidate.instanceId === fallbackModelSelection.instanceId, + ); + const model = + provider?.models.find((candidate) => candidate.slug === fallbackModelSelection.model) ?? + provider?.models.find((candidate) => + candidate.aliases?.includes(fallbackModelSelection.model), + ); + const key = `${fallbackModelSelection.instanceId}:${model?.slug ?? fallbackModelSelection.model}`; const existing = options.get(key); if (existing) { options.set(key, { ...existing, - selection: - existing.providerDriver === "antigravity" - ? fallbackModelSelection - : normalizeSelectionOptions(fallbackModelSelection, existing.capabilities), + selection: normalizeSelectionOptions( + fallbackModelSelection, + existing.capabilities, + existing.modelPolicy, + ), }); } else { - const provider = config?.providers.find( - (candidate) => candidate.instanceId === fallbackModelSelection.instanceId, - ); const instanceConfig = config?.settings?.providerInstances[fallbackModelSelection.instanceId]; - const model = provider?.models.find( - (candidate) => candidate.slug === fallbackModelSelection.model, - ); const providerDriver = provider?.driver ?? instanceConfig?.driver ?? fallbackModelSelection.instanceId; const providerLabel = providerDisplayLabel({ @@ -224,8 +248,11 @@ export function buildModelOptions( }); options.set(key, { key, - label: model?.name ?? fallbackModelSelection.model, - subtitle: model?.subProvider ?? "", + label: model?.fusion ? "Fusion" : (model?.name ?? fallbackModelSelection.model), + subtitle: model?.fusion + ? `${model.fusion.lead.name} + ${model.fusion.sidekick.name}` + : (model?.subProvider ?? ""), + fusion: model?.fusion, providerKey: fallbackModelSelection.instanceId, providerLabel, providerDriver, @@ -235,6 +262,7 @@ export function buildModelOptions( ? { isUnavailable: true } : {}), capabilities: model?.capabilities ?? null, + modelPolicy: resolveProviderModelPolicy(provider ?? instanceConfig), selection: fallbackModelSelection, }); } diff --git a/apps/mobile/src/lib/providerOptions.test.ts b/apps/mobile/src/lib/providerOptions.test.ts index 9b94cecb3db9..7b05fc52010d 100644 --- a/apps/mobile/src/lib/providerOptions.test.ts +++ b/apps/mobile/src/lib/providerOptions.test.ts @@ -62,3 +62,30 @@ describe("mobile provider options", () => { ]); }); }); + +it("keeps a removed Devin thinking choice visible until the user picks an available level", () => { + const descriptors = resolveProviderOptionDescriptors({ + modelPolicy: { optionSelection: "exact" }, + capabilities: { + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Thinking level", + type: "select", + currentValue: "high", + options: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + }, + ], + }, + selections: [{ id: "reasoningEffort", value: "max" }], + }); + expect(descriptors[0]?.currentValue).toBe("max"); + expect( + applyProviderOptionSelection(descriptors, { id: "reasoningEffort", value: "high" })?.find( + (option) => option.id === "reasoningEffort", + )?.value, + ).toBe("high"); +}); diff --git a/apps/mobile/src/lib/providerOptions.ts b/apps/mobile/src/lib/providerOptions.ts index dec0d327030d..a12f00efd31e 100644 --- a/apps/mobile/src/lib/providerOptions.ts +++ b/apps/mobile/src/lib/providerOptions.ts @@ -1,24 +1,19 @@ +import { resolveProviderModelOptions } from "@t3tools/client-runtime/providerModelOptions"; import type { ModelCapabilities, + ServerProvider, ProviderOptionDescriptor, ProviderOptionSelection, } from "@t3tools/contracts"; -import { - buildProviderOptionSelectionsFromDescriptors, - getProviderOptionDescriptors, -} from "@t3tools/shared/model"; +import { buildProviderOptionSelectionsFromDescriptors } from "@t3tools/shared/model"; export function resolveProviderOptionDescriptors(input: { + readonly modelPolicy?: ServerProvider["modelPolicy"]; readonly capabilities: ModelCapabilities | null | undefined; readonly selections: ReadonlyArray | null | undefined; }): ReadonlyArray { - if (!input.capabilities) { - return []; - } - return getProviderOptionDescriptors({ - caps: input.capabilities, - selections: input.selections, - }); + return resolveProviderModelOptions(input.capabilities, input.selections, input.modelPolicy) + .descriptors; } /** diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index e23f6028204e..df465523d7ac 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -35,7 +35,7 @@ import { composerContextSendBlockReason, reidentifyComposerContext } from "../li import { uuidv4 } from "../lib/uuid"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; -import { isModelSelectionUnavailable } from "../lib/modelOptions"; +import { getModelSelectionUnavailableReason } from "../lib/modelOptions"; import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; import { convertPastedImagesToAttachments, @@ -378,14 +378,12 @@ export function useThreadComposerState() { const modelSelection = draft.modelSelection ?? thread.modelSelection; const serverConfig = selectedEnvironmentRuntime?.serverConfig; + const modelUnavailableReason = getModelSelectionUnavailableReason(serverConfig, modelSelection); if ( selectedEnvironmentRuntime?.connectionState === "connected" && - isModelSelectionUnavailable(serverConfig, modelSelection) + modelUnavailableReason !== null ) { - Alert.alert( - "Antigravity model unavailable", - "Set up Antigravity on web or desktop, or choose another model.", - ); + Alert.alert("Model unavailable", modelUnavailableReason); return null; } const provider = serverConfig?.providers.find( diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 933023fb7898..396d07b68785 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -24,7 +24,7 @@ import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn" import { serializeComposerMessageForServer, uploadedComposerContext } from "../lib/composerContext"; import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload"; import { randomHex } from "../lib/uuid"; -import { isModelSelectionUnavailable } from "../lib/modelOptions"; +import { getModelSelectionUnavailableReason } from "../lib/modelOptions"; import { retainAcknowledgedThreadMessage, forgetAcknowledgedThreadMessage, @@ -697,11 +697,12 @@ export function useThreadOutboxDrain(): void { ); if (!serverConfig) return false; const settings = resolveQueuedThreadSettings(queuedMessage, thread, serverConfig.providers); - if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) { - return restoreQueuedMessage( - queuedMessage, - "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", - ); + const modelUnavailableReason = getModelSelectionUnavailableReason( + serverConfig, + settings.modelSelection, + ); + if (modelUnavailableReason !== null) { + return restoreQueuedMessage(queuedMessage, modelUnavailableReason); } const { reportFailure } = makeDeliveryHelpers(queuedMessage); @@ -790,11 +791,12 @@ export function useThreadOutboxDrain(): void { serverEnvironment.configValueAtom(queuedMessage.environmentId), ); if (!currentConfig) return false; - if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) { - return restoreQueuedMessage( - persistedMessage, - "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", - ); + const sendModelUnavailableReason = getModelSelectionUnavailableReason( + currentConfig, + settings.modelSelection, + ); + if (sendModelUnavailableReason !== null) { + return restoreQueuedMessage(persistedMessage, sendModelUnavailableReason); } const sendSettings = resolveQueuedThreadSettings( queuedMessage, @@ -880,11 +882,12 @@ export function useThreadOutboxDrain(): void { }, serverConfig.providers, ); - if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) { - return restoreQueuedMessage( - queuedMessage, - "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", - ); + const modelUnavailableReason = getModelSelectionUnavailableReason( + serverConfig, + settings.modelSelection, + ); + if (modelUnavailableReason !== null) { + return restoreQueuedMessage(queuedMessage, modelUnavailableReason); } let prepared: PreparedTurnAttachments; let persistedMessage: QueuedThreadMessage; @@ -924,11 +927,12 @@ export function useThreadOutboxDrain(): void { serverEnvironment.configValueAtom(queuedMessage.environmentId), ); if (!currentConfig) return false; - if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) { - return restoreQueuedMessage( - persistedMessage, - "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", - ); + const sendModelUnavailableReason = getModelSelectionUnavailableReason( + currentConfig, + settings.modelSelection, + ); + if (sendModelUnavailableReason !== null) { + return restoreQueuedMessage(persistedMessage, sendModelUnavailableReason); } const sendSettings = resolveQueuedThreadSettings( queuedMessage, diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index b72f631a6aa7..8c6dd57feffe 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -228,7 +228,10 @@ export interface AcpAdapterV2Flavor { readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; readonly startResult: AcpSessionRuntimeStartResult; readonly modelSelection: ModelSelection; + readonly cwd: string; }) => Effect.Effect; + /** Options consumed by native model resolution rather than ACP configuration. */ + readonly modelOptionIds?: ReadonlyArray; /** Native session mode to select for a runtime policy (e.g. Antigravity `yolo`). */ readonly sessionModeForPolicy?: (policy: ProviderAdapterV2RuntimePolicy) => string | undefined; /** @@ -5774,6 +5777,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV runtime, startResult, modelSelection, + cwd: runtimePolicy.cwd ?? process.cwd(), }); } else if ( requestedModel.length > 0 && @@ -5811,7 +5815,9 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV }; }); } - const optionSelections = modelSelection.options ?? []; + const optionSelections = (modelSelection.options ?? []).filter( + (selection) => !flavor.modelOptionIds?.includes(selection.id), + ); const configOptions = yield* runtime.getConfigOptions; const availableConfigIds = new Set(configOptions.map((option) => option.id)); const hasNativeConfigWithSyntheticModeId = availableConfigIds.has( diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 067a2de922cd..1561bbd30e72 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Crypto from "effect/Crypto"; @@ -13,6 +14,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ServerConfig } from "../../config.ts"; +import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; import type { AcpRegistryAvailableCommands, AcpRegistryLiveConfiguration, @@ -80,6 +82,111 @@ const testLayer = Layer.mergeAll( ); describe("AcpRegistryAdapterV2", () => { + it.effect( + "applies a Devin family selection even when ACP's cached catalog omits its native ID", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-devin-v2-adapter-" }); + const requestLog = path.join(cwd, "requests.jsonl"); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const command = writeFakeCli({ + directory: cwd, + name: "devin", + platform: yield* HostProcessPlatform, + source: ` + import { appendFileSync as logCatalogProbe } from 'node:fs'; + if (process.argv.slice(2).join(' ') === 'models list --format json') { + logCatalogProbe(process.env.T3_ACP_REQUEST_LOG_PATH, 'models/list ' + process.cwd() + '\\n'); + console.log(JSON.stringify({ families: [{ slug: 'opus', family_label: 'Opus', variants: [ + { model_uid: 'fresh-native-model', label: 'Opus High' } + ] }] })); process.exit(0); + } + ${execScriptSource({ scriptPath: mockAgentPath })} + `, + }); + const settings = yield* decodeAcpRegistryAdapterSettings({ + agentId: "devin", + commandPath: command, + }); + const instanceId = ProviderInstanceId.make("acpRegistry_devin_fixture"); + const adapter = makeAcpRegistryAdapterV2({ + crypto: yield* Crypto.Crypto, + instanceId, + settings, + environment: {}, + fileSystem, + path, + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + idAllocator: yield* IdAllocatorV2, + serverConfig: yield* ServerConfig, + resolver: { + resolve: (_settings, requestedCwd) => + Effect.succeed({ + agent: { + id: "devin", + name: "Devin", + version: "3000.10.23", + description: "Fixture", + distribution: {}, + }, + distribution: "binary", + spawn: { + command, + args: ["acp"], + cwd: requestedCwd, + env: { T3_ACP_REQUEST_LOG_PATH: requestLog, T3_ACP_SESSION_LIFECYCLE: "1" }, + }, + }), + }, + }); + const threadId = ThreadId.make("thread-devin-v2-fixture"); + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd, + }); + const modelSelection = { + instanceId, + model: "opus", + options: [{ id: "reasoningEffort", value: "high" }], + }; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("session-devin-v2-fixture"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const resumedCwd = path.join(cwd, "resumed-workspace"); + yield* fileSystem.makeDirectory(resumedCwd); + yield* runtime.resumeThread({ + providerThread: { + ...providerThread, + nativeThreadRef: { + driver: runtime.providerSession.driver, + nativeId: "mock-session-2", + strength: "strong", + }, + }, + modelSelection, + runtimePolicy: { ...runtimePolicy, cwd: resumedCwd }, + }); + const requests = yield* fileSystem.readFileString(requestLog); + assert.include(requests, `models/list ${resumedCwd}`); + assert.include(requests, '"value":"fresh-native-model"'); + assert.notInclude(requests, '"configId":"reasoningEffort"'); + assert.equal(runtime.providerSession.driver, "acpRegistry"); + }).pipe(Effect.provide(testLayer), Effect.scoped), + ); + it("is registered as a generic provider driver with schema defaults", () => { assert.isTrue(BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2.has(ACP_REGISTRY_PROVIDER)); assert.equal(AcpRegistryAdapterV2Driver.driverKind, ACP_REGISTRY_PROVIDER); @@ -130,6 +237,7 @@ describe("AcpRegistryAdapterV2", () => { }, childProcessSpawner, fileSystem, + path, idAllocator, runtimeCoordinator: { withForegroundStartup: (agentId, effect) => diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts index 4684d479450a..e88b0f3dc56d 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts @@ -61,6 +61,7 @@ function makeAcpRegistryProviderAdapterRegistryReplayLayer(transcript: AcpReplay childProcessSpawner, crypto, fileSystem, + path, idAllocator, resolver: { resolve: () => Effect.die("ACP registry resolver must not run during replay"), diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts index 422073222820..9d444c6f6875 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts @@ -14,10 +14,12 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/compat"; import { ServerConfig } from "../../config.ts"; import { @@ -28,6 +30,11 @@ import { import { AcpRegistryCatalog } from "../../provider/acp/AcpRegistrySupport.ts"; import { AcpRegistryRuntimeCoordinator } from "../../provider/acp/AcpRegistryRuntimeCoordinator.ts"; import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import { + applyDevinModelSelection, + DEVIN_MODEL_OPTION_IDS, + prepareDevinSkillPrompt, +} from "../../provider/acp/DevinCli.ts"; import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts"; import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; @@ -56,6 +63,7 @@ export interface AcpRegistryAdapterV2Options { readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly crypto: Crypto.Crypto; readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; readonly idAllocator: IdAllocatorV2["Service"]; readonly resolver: Pick; readonly runtimeCoordinator?: AcpRegistryRuntimeCoordinator["Service"]; @@ -95,6 +103,8 @@ function makeAcpRegistryRuntime(options: AcpRegistryAdapterV2Options) { const context = yield* Layer.build( AcpSessionRuntime.layer({ ...runtimeInput, + modelValidation: options.settings.agentId === "devin" ? "agent" : "catalog", + cancelBehavior: options.settings.agentId === "devin" ? "wait-for-prompt" : "interrupt", spawn: processEnvironment === undefined ? resolved.spawn @@ -109,9 +119,31 @@ function makeAcpRegistryRuntime(options: AcpRegistryAdapterV2Options) { ), ), ); - return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( Effect.provide(context), ); + if (options.settings.agentId !== "devin") return runtime; + return { + ...runtime, + prompt: (request) => + Effect.gen(function* () { + const prompt: EffectAcpSchema.ContentBlock[] = []; + for (const block of request.prompt) { + prompt.push( + block.type === "text" + ? { ...block, text: yield* prepareDevinSkillPrompt(block.text, resolved.spawn) } + : block, + ); + } + return yield* runtime.prompt({ ...request, prompt }); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + options.childProcessSpawner, + ), + Effect.provideService(Path.Path, options.path), + ), + } satisfies AcpSessionRuntime.AcpSessionRuntime["Service"]; }); } @@ -130,6 +162,20 @@ export function makeAcpRegistryAdapterV2(options: AcpRegistryAdapterV2Options) { normalizeSessionUpdate: normalizeDevinSessionUpdate, normalizeToolCall: normalizeDevinToolCall, extractSubagentUpdate: extractDevinSubagentUpdate, + modelOptionIds: DEVIN_MODEL_OPTION_IDS, + applyModelSelection: ({ runtime, modelSelection, cwd }) => + options.resolver.resolve(options.settings, cwd, options.environment).pipe( + Effect.flatMap(({ spawn }) => + applyDevinModelSelection(spawn, runtime, modelSelection), + ), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + options.childProcessSpawner, + ), + Effect.mapError((cause) => + EffectAcpErrors.AcpRequestError.invalidParams(cause.message), + ), + ), } : {}), makeRuntime: options.makeRuntime ?? makeAcpRegistryRuntime(options), @@ -182,6 +228,7 @@ export type AcpRegistryAdapterV2DriverEnv = | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem + | Path.Path | AcpRegistryCatalog | IdAllocatorV2 | ProviderEventLoggers @@ -213,6 +260,7 @@ export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver< childProcessSpawner, crypto, fileSystem, + path: yield* Path.Path, idAllocator, resolver, ...(Option.isSome(runtimeCoordinator) diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts index b4ce7686bac1..3e2c610f06aa 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.test.ts @@ -45,6 +45,45 @@ function catalogWithInspection(inspection: AcpRegistryInspection): AcpRegistryCa } describe("acpRegistrySnapshotReadiness", () => { + it("keeps Devin family metadata when live ACP configuration advertises native IDs", () => { + const provider = buildCheckedAcpRegistrySnapshot({ + ...identity, + settings: decodeSettings({ agentId: "devin" }), + checkedAt: "2026-09-14T12:00:00.000Z", + inspection: { + status: "ready", + agentId: "devin", + version: "3000.10.23", + distribution: "binary", + }, + }); + const models = [ + { + slug: "fusion/opus/swe", + name: "Fusion (Opus + SWE)", + isCustom: false, + fusion: { lead: { id: "opus", name: "Opus" }, sidekick: { id: "swe", name: "SWE" } }, + capabilities: { optionDescriptors: [] }, + }, + ]; + const updated = applyAcpRegistryLiveConfiguration( + { ...provider, models }, + { + models: [{ id: "native-fusion-id", name: "Native pairing", description: null }], + currentModelId: "native-fusion-id", + configOptions: [], + }, + decodeSettings({ agentId: "devin" }), + ); + expect(updated.models).toEqual(models); + expect(updated.modelPolicy).toEqual({ + catalogScope: "instance", + preserveUnavailableModels: true, + optionSelection: "exact", + }); + expect(updated.auth.status).toBe("authenticated"); + }); + it("treats a live empty command advertisement as an authoritative replacement", () => { const provider = buildCheckedAcpRegistrySnapshot({ ...identity, @@ -86,56 +125,59 @@ describe("acpRegistrySnapshotReadiness", () => { ); }); - it("overlays live configuration without dropping probe-owned session capabilities", () => { - const provider = buildCheckedAcpRegistrySnapshot({ - ...identity, - settings: decodeSettings({ agentId: "test-agent" }), - checkedAt: "2026-08-13T10:00:00.000Z", - inspection: { - status: "ready", - agentId: "test-agent", - version: "1.0.0", - distribution: "npx", - }, - probe: { + it.each([undefined, "exact"] as const)( + "overlays live configuration independently of %s option selection", + (optionSelection) => { + const provider = buildCheckedAcpRegistrySnapshot({ + ...identity, + settings: decodeSettings({ agentId: "test-agent" }), + checkedAt: "2026-08-13T10:00:00.000Z", + inspection: { + status: "ready", + agentId: "test-agent", + version: "1.0.0", + distribution: "npx", + }, probe: { - instanceId: identity.instanceId, - ready: true, - icon: null, - authMethods: [], - models: [{ id: "probe-model", name: "Probe model", description: null }], - currentModelId: "probe-model", - configOptions: [], - sessionManagement: { - canList: true, - canLoad: true, - canResume: true, - canLogout: true, - canDelete: true, - canConfigureProviders: true, + probe: { + instanceId: identity.instanceId, + ready: true, + icon: null, + authMethods: [], + models: [{ id: "probe-model", name: "Probe model", description: null }], + currentModelId: "probe-model", + configOptions: [], + sessionManagement: { + canList: true, + canLoad: true, + canResume: true, + canLogout: true, + canDelete: true, + canConfigureProviders: true, + }, }, + slashCommands: [], + skills: [], }, - slashCommands: [], - skills: [], - }, - }); + }); - expect( - applyAcpRegistryLiveConfiguration( - provider, - { - models: [{ id: "live-model", name: "Live model", description: null }], - currentModelId: "live-model", - configOptions: [], - }, - [], - ), - ).toMatchObject({ - auth: { status: "authenticated", canLogout: true }, - nativeSessions: { canList: true, canLoad: true, canResume: true }, - models: [{ slug: "live-model", isDefault: true }], - }); - }); + expect( + applyAcpRegistryLiveConfiguration( + { ...provider, modelPolicy: optionSelection ? { optionSelection } : {} }, + { + models: [{ id: "live-model", name: "Live model", description: null }], + currentModelId: "live-model", + configOptions: [], + }, + decodeSettings({ agentId: "test-agent" }), + ), + ).toMatchObject({ + auth: { status: "authenticated", canLogout: true }, + nativeSessions: { canList: true, canLoad: true, canResume: true }, + models: [{ slug: "live-model", isDefault: true }], + }); + }, + ); it("maps registry inspection status to provider readiness", () => { expect( diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts index 75a46510e7e3..3e898fea5226 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -15,6 +15,7 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -59,6 +60,8 @@ import { } from "../acp/AcpRegistryProbe.ts"; import { AcpRegistryCatalog, type AcpRegistryInspection } from "../acp/AcpRegistrySupport.ts"; import { AcpRegistryRuntimeCoordinator } from "../acp/AcpRegistryRuntimeCoordinator.ts"; +import { discoverDevinSkills, readDevinModelCatalog } from "../acp/DevinCli.ts"; +import { devinModels } from "../acp/DevinModels.ts"; const DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); const decodeSettings = Schema.decodeSync(AcpRegistrySettings); @@ -207,6 +210,10 @@ function baseSnapshot( // so selectors must not offer these instances for commit, PR, branch, or // title generation. supportsTextGeneration: false, + modelPolicy: + input.settings.agentId === "devin" + ? { catalogScope: "instance", preserveUnavailableModels: true, optionSelection: "exact" } + : { catalogScope: "instance", preserveUnavailableModels: true }, enabled: input.settings.enabled, installed: input.installed, version: input.version, @@ -244,14 +251,19 @@ export function applyAcpRegistryAvailableCommands( export function applyAcpRegistryLiveConfiguration( provider: ServerProvider, configuration: AcpRegistryLiveConfiguration, - customModels: ReadonlyArray, + settings: Pick, ): ServerProvider { const { message: _staleProbeMessage, ...snapshot } = provider; return { ...snapshot, status: provider.enabled ? "ready" : provider.status, auth: { ...provider.auth, status: "authenticated" }, - models: modelsFromDiscovery(configuration, customModels), + // Devin's CLI catalog owns model families and exact variant options. ACP + // advertises cached native IDs and must not replace that richer catalog. + models: + settings.agentId === "devin" + ? provider.models + : modelsFromDiscovery(configuration, settings.customModels), }; } @@ -463,6 +475,7 @@ export const AcpRegistryDriver: ProviderDriver({ generation: 0, entry: null }); const enrichProvider = checkAcpRegistryProviderStatus( { ...readinessInput, @@ -534,17 +554,40 @@ export const AcpRegistryDriver: ProviderDriver { + if (effectiveConfig.agentId !== "devin") { + return Effect.succeed(provider); + } + if (provider.auth.status !== "authenticated") { + return Effect.succeed(provider); + } + return catalog.resolve(effectiveConfig, serverConfig.cwd, processEnvironment).pipe( + Effect.flatMap(({ spawn }) => readDevinModelCatalog(spawn)), + Effect.map(devinModels), + Effect.catch((cause) => + Effect.logWarning("Devin model discovery failed", cause).pipe( + Effect.andThen(Ref.get(enrichmentCache)), + Effect.map((cache) => cache.entry?.provider.models ?? []), + ), + ), + Effect.map((models) => + models.length === 0 + ? provider + : { + ...provider, + models: providerModelsFromSettings( + models, + effectiveConfig.customModels, + EMPTY_CAPABILITIES, + ), + }, + ), + ); + }), Effect.provideService(AcpRegistryCatalog, catalog), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(Crypto.Crypto, crypto), ); - const enrichmentCache = yield* Ref.make<{ - readonly generation: number; - readonly entry: { - readonly provider: ServerProvider; - readonly expiresAt: number; - } | null; - }>({ generation: 0, entry: null }); const liveSnapshotSemaphore = yield* Semaphore.make(1); const invalidateEnrichmentCache = Ref.update(enrichmentCache, (current) => ({ generation: current.generation + 1, @@ -641,11 +684,7 @@ export const AcpRegistryDriver: ProviderDriver publishSnapshot( - applyAcpRegistryLiveConfiguration( - current, - configuration, - effectiveConfig.customModels, - ), + applyAcpRegistryLiveConfiguration(current, configuration, effectiveConfig), ), ), ), @@ -718,6 +757,32 @@ export const AcpRegistryDriver: ProviderDriver + snapshot.getSnapshot.pipe( + Effect.flatMap((provider) => { + if (!provider.enabled || provider.auth.status !== "authenticated") { + return Effect.succeed(null); + } + return catalog.resolve(effectiveConfig, cwd, processEnvironment).pipe( + Effect.flatMap(({ spawn }) => discoverDevinSkills(spawn)), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.map((skills) => ({ ...provider, skills })), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to discover Devin workspace skills.", + cause, + }), + ), + ); + }), + ) + : undefined, orchestrationAdapter, textGeneration: makeUnsupportedTextGeneration(), acpSessionManagement: { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index d81f4d294a8e..02c1549457eb 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1504,7 +1504,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); - it.effect("deduplicates cwd probes and clears snapshots when an instance rebuilds", () => + it.effect("deduplicates and retires workspace probes", () => Effect.gen(function* () { const driver = ProviderDriverKind.make("codex"); const instanceId = ProviderInstanceId.make("codex"); @@ -1534,6 +1534,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te slashCommands: [], } as const satisfies ServerProvider; const snapshotCalls = yield* Ref.make(0); + const pendingResult = yield* Ref.make(null); const returnPendingSnapshot = yield* Ref.make(true); const probeStarted = yield* Deferred.make(); const releaseProbe = yield* Deferred.make(); @@ -1569,9 +1570,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const firstInstance = makeInstance(machineProvider, () => Effect.gen(function* () { yield* Ref.update(snapshotCalls, (count) => count + 1); - if (yield* Ref.get(returnPendingSnapshot)) return pendingScopedProvider; + if (yield* Ref.get(returnPendingSnapshot)) return yield* Ref.get(pendingResult); yield* Deferred.succeed(probeStarted, undefined); yield* Deferred.await(releaseProbe); + yield* Effect.yieldNow; return scopedProvider; }), ); @@ -1620,6 +1622,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const registry = yield* ProviderRegistry.ProviderRegistry; yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); assert.strictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, undefined); + yield* Ref.set(pendingResult, pendingScopedProvider); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + assert.strictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, undefined); yield* Ref.set(returnPendingSnapshot, false); const workspaceUpdate = yield* registry.streamChanges.pipe( Stream.runHead, @@ -1634,20 +1639,34 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }) .pipe(Effect.forkChild); yield* Effect.yieldNow; - assert.strictEqual(yield* Ref.get(snapshotCalls), 2); + assert.strictEqual(yield* Ref.get(snapshotCalls), 3); + yield* registry.refreshInstance(instanceId); yield* Deferred.succeed(releaseProbe, undefined); yield* Fiber.join(firstRefresh); yield* Fiber.join(duplicateRefresh); const published = yield* Fiber.join(workspaceUpdate); assert.strictEqual(published._tag, "Some"); + assert.deepStrictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, []); + yield* Effect.all( + ["/workspace", "/other-workspace"].map((cwd) => + registry.refreshWorkspaceSnapshot({ instanceId, cwd }), + ), + { concurrency: "unbounded" }, + ); const providers = yield* registry.getProviders; assert.deepStrictEqual(providers[0]?.skills, machineProvider.skills); + assert.deepStrictEqual( + providers[0]?.workspaceSnapshots?.map((snapshot) => snapshot.cwd).toSorted(), + ["/other-workspace", "/workspace"], + ); assert.deepStrictEqual( providers[0]?.workspaceSnapshots?.[0]?.skills, scopedProvider.skills, ); yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual(yield* Ref.get(snapshotCalls), 2); + assert.strictEqual(yield* Ref.get(snapshotCalls), 5); + yield* registry.refreshInstance(instanceId); + assert.deepStrictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, []); yield* Ref.set(instancesRef, [rebuiltInstance]); yield* PubSub.publish(registryChanges, undefined); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index dccea02c2a91..90731ac3339d 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -395,7 +395,7 @@ export const ProviderRegistryLive = Layer.effect( ); const providersRef = yield* Ref.make>(cachedProviders); const workspaceRefreshesRef = yield* Ref.make< - ReadonlyMap> + ReadonlyMap> >(new Map()); const maintenanceActionStatesRef = yield* Ref.make< ReadonlyMap @@ -561,10 +561,23 @@ export const ProviderRegistryLive = Layer.effect( const refreshOneSource = Effect.fn("refreshOneSource")(function* ( providerSource: ProviderSnapshotSource, ) { + const instance = (yield* Ref.get(liveSubsRef)).get(providerSource.instanceId); return yield* providerSource.refresh.pipe( + Effect.tap(() => + Ref.update(workspaceRefreshesRef, (refreshes) => { + const next = new Map(refreshes); + if (instance) next.delete(instance); + return next; + }), + ), Effect.flatMap((nextProvider) => correlateSnapshotWithSource(providerSource, nextProvider).pipe( - Effect.flatMap(syncProvider), + // Explicit refreshes include authentication changes; discard workspace data with them. + Effect.flatMap((provider) => + syncProvider( + instance?.snapshotForCwd ? { ...provider, workspaceSnapshots: [] } : provider, + ), + ), ), ), ); @@ -854,45 +867,50 @@ export const ProviderRegistryLive = Layer.effect( return providers; } const instance = yield* instanceRegistry.getInstance(input.instanceId); - if (!instance?.snapshotForCwd) return providers; + const snapshotForCwd = instance?.snapshotForCwd; + if (!instance || !snapshotForCwd) return providers; + const probe = Symbol(); const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { const current = refreshes.get(instance); if (current?.has(input.cwd)) return [false, refreshes] as const; const next = new Map(refreshes); - next.set(instance, new Set(current).add(input.cwd)); + next.set(instance, new Map(current).set(input.cwd, probe)); return [true, next] as const; }); if (!claimed) return yield* Ref.get(providersRef); - return yield* instance.snapshotForCwd(input.cwd).pipe( - Effect.flatMap((scopedSnapshot) => - scopedSnapshot.status === "error" - ? Ref.get(providersRef) - : instanceRegistry.getInstance(input.instanceId).pipe( - Effect.flatMap((currentInstance) => { - if (currentInstance !== instance) return Ref.get(providersRef); - return Ref.modify(providersRef, (currentProviders) => { - const nextProviders = currentProviders.map((candidate) => - candidate.instanceId === input.instanceId && - !candidate.workspaceSnapshots?.some((s) => s.cwd === input.cwd) - ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) - : candidate, - ); - return [[currentProviders, nextProviders] as const, nextProviders]; - }).pipe( - Effect.tap(([previousProviders, nextProviders]) => - haveProvidersChanged(previousProviders, nextProviders) - ? PubSub.publish(changesPubSub, nextProviders) - : Effect.void, - ), - Effect.map(([, nextProviders]) => nextProviders), - ); - }), - ), - ), + return yield* Effect.gen(function* () { + const scopedSnapshot = yield* snapshotForCwd(input.cwd); + if (scopedSnapshot === null || scopedSnapshot.status === "error") { + return yield* Ref.get(providersRef); + } + const currentInstance = yield* instanceRegistry.getInstance(input.instanceId); + // Explicit refresh removes the claim, retiring probes started before it. + const currentProbe = (yield* Ref.get(workspaceRefreshesRef)).get(instance)?.get(input.cwd); + if (currentInstance !== instance || currentProbe !== probe) { + return yield* Ref.get(providersRef); + } + const [previousProviders, nextProviders] = yield* Ref.modify( + providersRef, + (currentProviders) => { + const nextProviders = currentProviders.map((candidate) => + candidate.instanceId === input.instanceId && + !candidate.workspaceSnapshots?.some((snapshot) => snapshot.cwd === input.cwd) + ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) + : candidate, + ); + return [[currentProviders, nextProviders] as const, nextProviders]; + }, + ); + if (haveProvidersChanged(previousProviders, nextProviders)) { + yield* PubSub.publish(changesPubSub, nextProviders); + } + return nextProviders; + }).pipe( Effect.ensuring( Ref.update(workspaceRefreshesRef, (refreshes) => { + if (refreshes.get(instance)?.get(input.cwd) !== probe) return refreshes; const next = new Map(refreshes); - const current = new Set(next.get(instance)); + const current = new Map(next.get(instance)); current.delete(input.cwd); if (current.size) next.set(instance, current); else next.delete(instance); diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index 8d290aff767a..ee79e5b47cf4 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -76,7 +76,10 @@ export interface ProviderInstance { readonly accentColor?: string | undefined; readonly enabled: boolean; readonly snapshot: ServerProviderShape; - readonly snapshotForCwd?: (cwd: string) => Effect.Effect; + /** Null means workspace discovery is not ready and must be retried instead of cached. */ + readonly snapshotForCwd?: + | ((cwd: string) => Effect.Effect) + | undefined; readonly refreshModels?: () => Effect.Effect; /** * Redeem one banked rate-limit reset credit on the signed-in account, then diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 0b7d47f7e174..088515a6d2c6 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -106,6 +106,8 @@ export interface AcpSessionRuntimeOptions { readonly linuxCgroupController?: AcpLinuxCgroupController | null; /** Native cancellation waits for the prompt response and the getEvents consumer to drain. */ readonly cancelBehavior?: "interrupt" | "wait-for-prompt"; + /** Agents with a stale advertised catalog can validate model IDs themselves. */ + readonly modelValidation?: "catalog" | "agent"; readonly cancelTimeout?: Duration.Input; readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; readonly clientInfo: { @@ -1873,6 +1875,9 @@ export const make = ( if (!configOption) { return; } + if (configOption.category === "model" && options.modelValidation === "agent") { + return; + } if (configOption.type === "boolean") { if (typeof value === "boolean") { return; diff --git a/apps/server/src/provider/acp/DevinCli.test.ts b/apps/server/src/provider/acp/DevinCli.test.ts new file mode 100644 index 000000000000..5398cf621b95 --- /dev/null +++ b/apps/server/src/provider/acp/DevinCli.test.ts @@ -0,0 +1,123 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { writeFakeCli } from "../../testUtils/fakeCli.ts"; +import { + applyDevinModelSelection, + DevinSkillCatalog, + discoverDevinSkills, + prepareDevinSkillPrompt, +} from "./DevinCli.ts"; +import { DevinModelCatalog } from "./DevinModels.ts"; + +const encodeSkills = Schema.encodeEffect(DevinSkillCatalog); +const isJson = Schema.is(Schema.Json); + +const models = Schema.encodeSync(DevinModelCatalog)({ + families: [ + { + slug: "opus", + family_label: "Opus", + variants: [ + { model_uid: "native-high", label: "Opus High" }, + { model_uid: "native-medium", label: "Opus Medium" }, + ], + }, + ], +}); + +const makeCli = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-devin-v2-cli-" }); + const command = writeFakeCli({ + directory: cwd, + name: "devin", + platform: yield* HostProcessPlatform, + source: ` + import { readFileSync } from 'node:fs'; + const args = process.argv.slice(2).join(' '); + if (args === 'models list --format json') console.log(process.env.T3_TEST_MODELS); + else if (args === 'skills list --json') console.log(readFileSync('skills.json', 'utf8')); + else process.exit(2); + `, + }); + const skill = (name: string, triggers: string[], errors: string[] = []) => ({ + name: "Frontmatter label", + display_name: name, + description: "Workspace skill", + base_dir: path.join(cwd, name), + triggers, + errors, + }); + yield* fs.writeFileString( + path.join(cwd, "skills.json"), + yield* encodeSkills([ + skill("review", ["user"]), + { ...skill("internal", ["model"]), description: "", display_name: "" }, + skill("broken", ["user"], ["invalid"]), + { ...skill("builtin", ["user"]), base_dir: "" }, + ]), + ); + return { + command, + args: ["acp"], + cwd, + env: { ...(yield* HostProcessEnvironment), T3_TEST_MODELS: models }, + }; +}); + +it.effect("resolves options using the configured executable and account environment", () => + Effect.gen(function* () { + const spawn = yield* makeCli; + const selected: string[] = []; + const runtime = { + setModel: (model: string) => + Effect.sync(() => { + selected.push(model); + }), + }; + const instanceId = ProviderInstanceId.make("acpRegistry_devin"); + expect( + yield* applyDevinModelSelection(spawn, runtime, { + instanceId, + model: "opus", + options: [{ id: "reasoningEffort", value: "medium" }], + }), + ).toBe("native-medium"); + const error = yield* applyDevinModelSelection(spawn, runtime, { + instanceId, + model: "opus", + options: [{ id: "reasoningEffort", value: "max" }], + }).pipe(Effect.flip); + expect(error.message).toContain("unavailable"); + expect(selected).toEqual(["native-medium"]); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), +); + +it.effect("discovers skills in the requested workspace and invokes only enabled user skills", () => + Effect.gen(function* () { + const spawn = yield* makeCli; + const skills = yield* discoverDevinSkills(spawn); + expect(isJson(skills)).toBe(true); + expect( + skills.map(({ name, enabled, userInvocable }) => ({ name, enabled, userInvocable })), + ).toEqual([ + { name: "broken", enabled: false, userInvocable: true }, + { name: "internal", enabled: true, userInvocable: false }, + { name: "review", enabled: true, userInvocable: true }, + ]); + expect(yield* prepareDevinSkillPrompt("Check $review please", spawn)).toBe( + "/review Check please", + ); + expect(yield* prepareDevinSkillPrompt("$internal $broken", spawn)).toBe("$internal $broken"); + expect( + (yield* prepareDevinSkillPrompt("$review $review", spawn).pipe(Effect.flip)).message, + ).toContain("one skill"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), +); diff --git a/apps/server/src/provider/acp/DevinCli.ts b/apps/server/src/provider/acp/DevinCli.ts new file mode 100644 index 000000000000..9de80d531a62 --- /dev/null +++ b/apps/server/src/provider/acp/DevinCli.ts @@ -0,0 +1,141 @@ +import type { ModelSelection, ServerProviderSkill } from "@t3tools/contracts"; +import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Path from "effect/Path"; +import { ChildProcess } from "effect/unstable/process"; +import * as AcpErrors from "effect-acp/errors"; + +import { spawnAndCollect } from "../providerSnapshot.ts"; +import type { AcpSpawnInput, AcpSessionRuntime } from "./AcpSessionRuntime.ts"; +import { DevinModelCatalog, resolveDevinModel } from "./DevinModels.ts"; + +export const DEVIN_MODEL_OPTION_IDS = ["reasoningEffort", "fastMode", "contextWindow"]; + +/** Registry resolution owns the executable and environment, including managed installs. */ +const runDevinCommand = Effect.fn("DevinCli.runCommand")(function* ( + spawn: AcpSpawnInput, + args: ReadonlyArray, +) { + const resolved = yield* resolveSpawnCommand(spawn.command, args, { env: spawn.env ?? {} }); + const result = yield* spawnAndCollect( + spawn.command, + ChildProcess.make(resolved.command, resolved.args, { + cwd: spawn.cwd, + env: spawn.env, + extendEnv: spawn.extendEnv, + shell: resolved.shell, + }), + ); + if (result.code !== 0) { + return yield* AcpErrors.AcpRequestError.internalError( + `Devin CLI could not run ${args.join(" ")}. Check this provider instance's sign-in.`, + ); + } + return result.stdout; +}, Effect.timeout("10 seconds")); + +/** Unlike ACP's initial disk cache, this command waits for the account's current catalog. */ +export const readDevinModelCatalog = (spawn: AcpSpawnInput) => + runDevinCommand(spawn, ["models", "list", "--format", "json"]).pipe( + Effect.flatMap(Schema.decodeEffect(DevinModelCatalog)), + ); + +export const applyDevinModelSelection = Effect.fn("DevinCli.applyModelSelection")( + function* ( + spawn: AcpSpawnInput, + runtime: Pick, + selection: ModelSelection, + ) { + if (selection.model === "default" || selection.model === "auto") return undefined; + const catalog = yield* readDevinModelCatalog(spawn); + const model = resolveDevinModel(catalog, selection); + if (model === undefined) { + return yield* AcpErrors.AcpRequestError.invalidParams( + "This Devin model and option combination is unavailable. Choose an available model or change its options.", + ); + } + yield* runtime.setModel(model); + return model; + }, + Effect.mapError((cause) => AcpErrors.AcpRequestError.invalidParams(cause.message)), +); + +export const DevinSkillCatalog = Schema.fromJsonString( + Schema.Array( + Schema.Struct({ + name: Schema.NonEmptyString, + description: Schema.String, + base_dir: Schema.String, + display_name: Schema.String, + triggers: Schema.Array(Schema.String), + errors: Schema.Array(Schema.Unknown), + }), + ), +); + +/** The CLI resolves workspace roots, overrides, plugins, and invocation policy. */ +export const discoverDevinSkills = Effect.fn("DevinCli.discoverSkills")(function* ( + spawn: AcpSpawnInput, +) { + const path = yield* Path.Path; + return yield* runDevinCommand(spawn, ["skills", "list", "--json"]).pipe( + Effect.flatMap(Schema.decodeEffect(DevinSkillCatalog)), + Effect.map((skills) => + skills + // Built-in CLI commands have no SKILL.md and belong in the slash-command menu. + .filter((skill) => skill.base_dir.length > 0) + .map((skill): ServerProviderSkill => { + let entry: ServerProviderSkill = { + name: path.basename(skill.base_dir), + path: path.join(skill.base_dir, "SKILL.md"), + enabled: skill.errors.length === 0, + userInvocable: skill.triggers.includes("user"), + userInvocationOnly: !skill.triggers.includes("model"), + }; + if (skill.description.trim()) entry = { ...entry, description: skill.description.trim() }; + if (skill.display_name.trim()) + entry = { ...entry, displayName: skill.display_name.trim() }; + return entry; + }) + .sort((left, right) => left.name.localeCompare(right.name)), + ), + ); +}); + +/** ACP expands one leading slash command, including user-only workspace skills. */ +export function devinSkillPrompt(prompt: string, skills: ReadonlyArray) { + const names = new Set( + skills.filter((skill) => skill.enabled && skill.userInvocable).map((skill) => skill.name), + ); + const mentions = collectComposerInlineTokens(`${prompt} `).filter( + (token) => token.type === "skill" && names.has(token.value), + ); + if (mentions.length > 1) { + return Effect.fail( + AcpErrors.AcpRequestError.invalidParams( + "Devin can invoke one skill per message. Send each skill in a separate message.", + ), + ); + } + const mention = mentions[0]; + if (!mention) return Effect.succeed(prompt); + const argumentsText = `${prompt.slice(0, mention.start)}${prompt.slice(mention.end)}`.trim(); + return Effect.succeed(`/${mention.value}${argumentsText ? ` ${argumentsText}` : ""}`); +} + +export const prepareDevinSkillPrompt = Effect.fn("DevinCli.prepareSkillPrompt")(function* ( + prompt: string, + spawn: AcpSpawnInput, +) { + if (!collectComposerInlineTokens(`${prompt} `).some((token) => token.type === "skill")) { + return prompt; + } + const skills = yield* discoverDevinSkills(spawn).pipe( + Effect.catch((cause) => + Effect.logWarning("Devin skill discovery failed", cause).pipe(Effect.as([])), + ), + ); + return yield* devinSkillPrompt(prompt, skills); +}); diff --git a/apps/server/src/provider/acp/DevinModels.test.ts b/apps/server/src/provider/acp/DevinModels.test.ts new file mode 100644 index 000000000000..41b0428d2957 --- /dev/null +++ b/apps/server/src/provider/acp/DevinModels.test.ts @@ -0,0 +1,204 @@ +import { expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; +import { devinModels, resolveDevinModel } from "./DevinModels.ts"; + +const isJson = Schema.is(Schema.Json); + +const lead = { + slug: "opus", + family_label: "Opus", + variants: [ + { model_uid: "high", label: "Opus High" }, + { model_uid: "medium", label: "Opus Medium" }, + { model_uid: "fast-high", label: "Opus High Fast" }, + { model_uid: "fast-medium", label: "Opus Medium Fast" }, + ], +}; +const sidekick = { + slug: "swe", + family_label: "SWE", + variants: [{ model_uid: "native-swe", label: "SWE High" }], +}; +const catalog = { families: [lead, sidekick] }; + +it("emits JSON-safe catalog metadata for the v2 provider stream", () => { + expect(isJson(devinModels(catalog))).toBe(true); +}); + +it("groups variants into independent controls, including a single remaining thinking level", () => { + const models = devinModels(catalog); + expect(models.map((model) => model.slug)).toEqual(["opus", "swe"]); + expect(models[0]?.capabilities?.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Thinking level", + type: "select", + currentValue: "high", + options: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + }, + { id: "fastMode", label: "Fast mode", type: "boolean", currentValue: false }, + ]); + expect(models[1]?.capabilities?.optionDescriptors?.[0]).toMatchObject({ + currentValue: "high", + options: [{ id: "high", label: "High" }], + }); + expect(resolveDevinModel(catalog, { model: "opus" })).toBe("high"); + const reordered = { families: [{ ...lead, variants: lead.variants.toReversed() }] }; + expect(devinModels(reordered)[0]?.capabilities?.optionDescriptors).toEqual([ + { ...models[0]!.capabilities!.optionDescriptors![0], currentValue: "medium" }, + { ...models[0]!.capabilities!.optionDescriptors![1], currentValue: true }, + ]); + expect(resolveDevinModel(reordered, { model: "opus" })).toBe("fast-medium"); +}); + +it.each([ + { effort: "medium", fast: false, expected: "medium" }, + { effort: "high", fast: true, expected: "fast-high" }, + { effort: "max", fast: false, expected: undefined }, +])("resolves only offered thinking/speed choices: $effort, $fast", ({ effort, fast, expected }) => { + expect( + resolveDevinModel(catalog, { + model: "opus", + options: [ + { id: "reasoningEffort", value: effort }, + { id: "fastMode", value: fast }, + ], + }), + ).toBe(expected); +}); + +it.each(["XHigh", "X-High"])("orders native thinking labels, including %s", (extraHigh) => { + const suffixes = ["Medium Thinking", "No Thinking", "Max", extraHigh, "High", "Low", "Minimal"]; + const models = devinModels({ + families: [ + { + slug: "test", + family_label: "Test", + variants: suffixes.map((suffix, index) => ({ + model_uid: `native-${index}`, + label: `Test ${suffix}`, + })), + }, + ], + }); + const thinking = models[0]?.capabilities?.optionDescriptors?.[0]; + expect(thinking?.type === "select" && thinking.options.map((option) => option.id)).toEqual([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + ]); +}); + +it("maps thinking and context controls to opaque native IDs", () => { + const contextCatalog = { + families: [ + { + slug: "opus", + family_label: "Opus", + variants: [ + { model_uid: "NATIVE_A", label: "Opus" }, + { model_uid: "NATIVE_B", label: "Opus Thinking" }, + { model_uid: "NATIVE_C", label: "Opus 1M" }, + { model_uid: "NATIVE_D", label: "Opus Thinking 1M" }, + ], + }, + ], + }; + expect(devinModels(contextCatalog)[0]?.capabilities?.optionDescriptors).toMatchObject([ + { id: "reasoningEffort", options: [{ id: "none" }, { id: "thinking" }] }, + { id: "contextWindow", options: [{ id: "standard" }, { id: "1m" }] }, + ]); + expect( + resolveDevinModel(contextCatalog, { + model: "opus", + options: [ + { id: "reasoningEffort", value: "thinking" }, + { id: "contextWindow", value: "1m" }, + ], + }), + ).toBe("NATIVE_D"); + expect(resolveDevinModel(catalog, { model: "custom-id" })).toBe("custom-id"); + expect(resolveDevinModel(catalog, { model: "fast-high" })).toBe("fast-high"); +}); + +it.each([ + { labels: ["Future Special"] }, + { labels: ["Future High", "Future Medium Fast"] }, + { labels: ["Future None", "Future No Thinking"] }, + { labels: ["Fusion (Unknown Lead + Unknown Sidekick)"], fusion: true }, +])("preserves exact IDs for unfamiliar or ambiguous families: $labels", ({ labels, fusion }) => { + const variants = labels.map((label, index) => ({ model_uid: `native-${index}`, label })); + const catalog = { + families: [{ slug: fusion ? "fusion" : "future", family_label: "Future", variants }], + }; + expect(devinModels(catalog).map((model) => [model.slug, model.name])).toEqual( + variants.map((variant) => [variant.model_uid, variant.label]), + ); + for (const variant of variants) + expect(resolveDevinModel(catalog, { model: variant.model_uid })).toBe(variant.model_uid); +}); + +it("groups Fusion by lead and exact sidekick while keeping the lead's thinking and speed controls", () => { + const fusionCatalog = { + families: [ + ...catalog.families, + { + slug: "fusion", + family_label: "Fusion", + variants: [ + { model_uid: "pair-high", label: "Fusion (Opus High + SWE High)" }, + { model_uid: "pair-medium", label: "Fusion (Opus Medium + SWE High)" }, + { model_uid: "pair-fast-high", label: "Fusion (Opus High Fast + SWE High)" }, + { model_uid: "pair-fast-medium", label: "Fusion (Opus Medium Fast + SWE High)" }, + { model_uid: "pair-other", label: "Fusion (Opus High + Opus Medium)" }, + ], + }, + ], + }; + const pairings = devinModels(fusionCatalog).filter((model) => model.fusion); + expect(pairings.map((model) => model.name)).toEqual([ + "Fusion (Opus + SWE High)", + "Fusion (Opus + Opus Medium)", + ]); + expect(pairings[0]?.fusion).toEqual({ + lead: { id: "opus", name: "Opus" }, + sidekick: { id: "native-swe", name: "SWE High" }, + }); + expect(pairings[0]?.capabilities).toEqual(devinModels(catalog)[0]?.capabilities); + expect( + resolveDevinModel(fusionCatalog, { + model: pairings[0]!.slug, + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], + }), + ).toBe("pair-fast-high"); + expect(resolveDevinModel(fusionCatalog, { model: pairings[1]!.slug })).toBe("pair-other"); +}); + +it("preserves native Fusion labels when the offered pairings cannot form independent controls", () => { + const variants = [ + { model_uid: "pair-high", label: "Fusion (Opus High + SWE High)" }, + { model_uid: "pair-fast-medium", label: "Fusion (Opus Medium Fast + SWE High)" }, + ]; + const incomplete = { + families: [...catalog.families, { slug: "fusion", family_label: "Fusion", variants }], + }; + expect( + devinModels(incomplete) + .slice(-2) + .map(({ slug, name }) => ({ slug, name })), + ).toEqual(variants.map(({ model_uid, label }) => ({ slug: model_uid, name: label }))); + for (const variant of variants) { + expect(resolveDevinModel(incomplete, { model: variant.model_uid })).toBe(variant.model_uid); + } + expect(resolveDevinModel(incomplete, { model: "fusion/opus/native-swe" })).toBeUndefined(); +}); diff --git a/apps/server/src/provider/acp/DevinModels.ts b/apps/server/src/provider/acp/DevinModels.ts new file mode 100644 index 000000000000..ef9fc8ee9756 --- /dev/null +++ b/apps/server/src/provider/acp/DevinModels.ts @@ -0,0 +1,226 @@ +import type { + ModelSelection, + ProviderOptionDescriptor, + ServerProviderModel, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export const DevinModelCatalog = Schema.fromJsonString( + Schema.Struct({ + families: Schema.Array( + Schema.Struct({ + slug: Schema.NonEmptyString, + family_label: Schema.NonEmptyString, + aliases: Schema.optional(Schema.Array(Schema.String)), + variants: Schema.Array( + Schema.Struct({ + model_uid: Schema.NonEmptyString, + label: Schema.NonEmptyString, + is_new: Schema.optional(Schema.Boolean), + }), + ), + }), + ), + }), +); +type Catalog = typeof DevinModelCatalog.Type; +type Family = Catalog["families"][number]; +type Variant = Family["variants"][number]; +type SelectableFamily = Omit & + Pick & { + readonly variants: ReadonlyArray< + Variant & { readonly traits: ReturnType } + >; + }; + +const THINKING_LEVEL_ORDER = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "thinking", +]; + +// The CLI supplies families and exact IDs, but encodes these traits only in labels. +// Keep unfamiliar labels as separate models rather than inventing a mapping. +function variantTraits(family: Family, variant: Variant) { + if (!variant.label.startsWith(family.family_label)) return undefined; + const suffix = variant.label.slice(family.family_label.length).trim(); + const match = + /^(?:(None|No Thinking|Minimal|Low|Medium|High|X-?High|Max|Thinking)(?: Thinking)?)?(?:\s*(Fast))?(?:\s*(1M))?$/i.exec( + suffix, + ); + if (!match) return undefined; + return { + reasoningEffort: (match[1] ?? "none") + .toLowerCase() + .replace("no thinking", "none") + .replace("-", ""), + fastMode: match[2] !== undefined, + contextWindow: match[3] ? "1m" : "standard", + }; +} + +function familyVariants(family: SelectableFamily) { + const variants = family.variants.flatMap((variant) => { + const traits = variant.traits; + return traits ? [{ ...variant, ...traits }] : []; + }); + const combinations = new Set( + variants.map( + (variant) => `${variant.reasoningEffort}:${variant.fastMode}:${variant.contextWindow}`, + ), + ); + const optionCount = (["reasoningEffort", "fastMode", "contextWindow"] as const).reduce( + (count, id) => count * new Set(variants.map((variant) => variant[id])).size, + 1, + ); + // Independent controls must describe a complete, unambiguous product for this account. + return variants.length === family.variants.length && + combinations.size === variants.length && + optionCount === variants.length + ? variants + : undefined; +} + +/** Split Fusion by known lead family and exact sidekick; retain the CLI's native IDs. */ +function selectableFamilies(catalog: Catalog): ReadonlyArray { + const families = catalog.families.map((family) => ({ + ...family, + variants: family.variants.map((variant) => ({ + ...variant, + traits: family.slug === "fusion" ? undefined : variantTraits(family, variant), + })), + })); + const variantsByLabel = new Map( + families.flatMap((family) => + family.slug === "fusion" + ? [] + : family.variants.map((variant) => [variant.label, { family, variant }] as const), + ), + ); + return families.flatMap((family): SelectableFamily[] => { + if (family.slug !== "fusion") return [family]; + const groups = new Map(); + const unfamiliar: SelectableFamily["variants"][number][] = []; + for (const variant of family.variants) { + const pairing = /^Fusion \((.+) \+ (.+)\)$/.exec(variant.label); + const leadLabel = pairing?.[1]; + const sidekickLabel = pairing?.[2]; + const lead = leadLabel ? variantsByLabel.get(leadLabel) : undefined; + const sidekick = sidekickLabel ? variantsByLabel.get(sidekickLabel)?.variant : undefined; + if (!leadLabel || !lead || !sidekick) { + unfamiliar.push(variant); + continue; + } + const slug = `fusion/${lead.family.slug}/${sidekick.model_uid}`; + const label = `Fusion (${lead.family.family_label} + ${sidekick.label})`; + const group = groups.get(slug); + const groupedVariant = { + ...variant, + traits: lead.variant.traits, + }; + groups.set(slug, { + slug, + family_label: label, + fusion: { + lead: { id: lead.family.slug, name: lead.family.family_label }, + sidekick: { id: sidekick.model_uid, name: sidekick.label }, + }, + variants: [...(group?.variants ?? []), groupedVariant], + }); + } + return [ + ...groups.values(), + ...(unfamiliar.length ? [{ ...family, variants: unfamiliar }] : []), + ]; + }); +} + +export function devinModels(catalog: Catalog): ServerProviderModel[] { + return selectableFamilies(catalog).flatMap((family): ServerProviderModel[] => { + const variants = familyVariants(family); + if (!variants) { + return family.variants.map((variant) => ({ + slug: variant.model_uid, + name: variant.label, + isCustom: false, + isDefault: false, + capabilities: { optionDescriptors: [] }, + })); + } + const first = variants[0]; + if (!first) return []; + const descriptors: ProviderOptionDescriptor[] = []; + const efforts = THINKING_LEVEL_ORDER.filter((level) => + variants.some((variant) => variant.reasoningEffort === level), + ); + // Keep even a single level so a removed saved choice can be replaced in the picker. + descriptors.push({ + id: "reasoningEffort", + label: "Thinking level", + type: "select", + currentValue: first.reasoningEffort, + options: efforts.map((id) => ({ + id, + label: id === "xhigh" ? "XHigh" : id.charAt(0).toUpperCase() + id.slice(1), + })), + }); + if (variants.some((variant) => variant.fastMode !== first.fastMode)) + descriptors.push({ + id: "fastMode", + label: "Fast mode", + type: "boolean", + currentValue: first.fastMode, + }); + const contexts = ["standard", "1m"].filter((context) => + variants.some((variant) => variant.contextWindow === context), + ); + if (contexts.length > 1) + descriptors.push({ + id: "contextWindow", + label: "Context window", + type: "select", + currentValue: first.contextWindow, + options: contexts.map((id) => ({ id, label: id === "1m" ? "1M" : "Standard" })), + }); + // Provider snapshots cross a JSON boundary before their schema is applied. + // Omit absent metadata instead of sending explicit undefined properties. + let model: ServerProviderModel = { + slug: family.slug, + name: family.family_label, + isCustom: false, + isDefault: false, + capabilities: { optionDescriptors: descriptors }, + }; + if (family.aliases !== undefined) model = { ...model, aliases: family.aliases }; + if (family.fusion !== undefined) model = { ...model, fusion: family.fusion }; + if (variants.some((variant) => variant.is_new)) model = { ...model, badge: "new" }; + return [model]; + }); +} + +/** Resolve only combinations present in this account's catalog; never construct native IDs. */ +export function resolveDevinModel( + catalog: Catalog, + selection: Pick, +) { + const family = selectableFamilies(catalog).find( + (family) => family.slug === selection.model || family.aliases?.includes(selection.model), + ); + // Exact native IDs (including custom models) remain valid for existing sessions. + if (!family) return selection.model; + const variants = familyVariants(family); + const first = variants?.[0]; + if (!variants || !first) + return family.variants.find((variant) => variant.model_uid === selection.model)?.model_uid; + return variants.find((variant) => + (["reasoningEffort", "fastMode", "contextWindow"] as const).every( + (id) => + variant[id] === (selection.options?.find((option) => option.id === id)?.value ?? first[id]), + ), + )?.model_uid; +} diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index e409a7dd209d..858d7f386eab 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1471,6 +1471,34 @@ describe("environment reconnect warning grace", () => { }); describe("resolveComposerProviderSelection", () => { + it.each(["codex", "claudeAgent", "cursor", "grok"])( + "allows a locked %s thread to use another instance without account metadata", + (driver) => { + const missing = ProviderInstanceId.make("missing-account"); + const fallback = entry(driver); + expect( + resolveComposerProviderSelection({ + entries: [fallback], + candidateInstanceIds: [missing], + lockedProvider: ProviderDriverKind.make(driver), + lockedInstanceId: missing, + }).selectedProviderEntry, + ).toBe(fallback); + }, + ); + + it("keeps ACP threads on their original instance when continuation metadata is absent", () => { + const missing = ProviderInstanceId.make("missing-account"); + expect( + resolveComposerProviderSelection({ + entries: [entry("acpRegistry", "another-account")], + candidateInstanceIds: [missing], + lockedProvider: ProviderDriverKind.make("acpRegistry"), + lockedInstanceId: missing, + }).selectedProviderEntry, + ).toBeUndefined(); + }); + const catalogModels: ServerProvider["models"] = [ { slug: "gemini-pro", name: "Gemini Pro", isCustom: false, capabilities: null }, ]; diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index e6d07bb648e3..f9c13c83441b 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,3 +1,4 @@ +import { resolveProviderModelPolicy } from "@t3tools/contracts"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { ANTIGRAVITY_DEFAULT_MODEL, @@ -608,13 +609,15 @@ export function resolveComposerProviderSelection(input: { input.entries.find((entry) => entry.instanceId === requestedInstanceId)?.driverKind ?? input.entries[0]?.driverKind ?? ProviderDriverKind.make("unconfigured"); + const lockedEntry = input.entries.find((entry) => entry.instanceId === input.lockedInstanceId); const lockedContinuationGroupKey = input.lockedProvider - ? (input.entries.find((entry) => entry.instanceId === input.lockedInstanceId) - ?.continuationGroupKey ?? null) + ? (lockedEntry?.continuationGroupKey ?? null) : null; - // Missing metadata must not move Antigravity history into another Google profile. + // Missing metadata must not move account-bound history into another account. const requiresExactInstance = - input.lockedProvider === "antigravity" && + input.lockedProvider != null && + resolveProviderModelPolicy(lockedEntry?.snapshot ?? { driver: input.lockedProvider }) + .catalogScope === "instance" && input.lockedInstanceId != null && lockedContinuationGroupKey === null; const compatibleEntries = input.entries.filter( diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index c643ecde0235..e157e98f5f08 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -772,6 +772,15 @@ export const PiAgentIcon: Icon = ({ className, ...props }) => ( ); +// Cognition mark from https://cognition.com/icon.svg. +export const DevinIcon: Icon = (props) => ( + +); + // Official two-color mark from https://forgejo.org/favicon.svg. export const ForgejoIcon: Icon = (props) => (
+
+
+

Fusion

+
+ + {props.providerName} +
+
+ {pairing ? ( +
+ + {(["lead", "sidekick"] as const).map((role) => ( +
+ + + {role === "lead" ? "Lead" : "Sidekick"} + +
+ ))} +
+ ) : ( +

+ This pairing is no longer available. Go back to choose another model. +

+ )} +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/chat/FusionWave.css b/apps/web/src/components/chat/FusionWave.css new file mode 100644 index 000000000000..239461b03b36 --- /dev/null +++ b/apps/web/src/components/chat/FusionWave.css @@ -0,0 +1,24 @@ +.fusion-wave-packets { + /* Play on opening, then rest instead of repainting for the life of the editor. */ + animation: fusion-wave-flow 5s linear; +} + +.fusion-wave[data-paused="true"] .fusion-wave-packets { + animation-play-state: paused; +} + +@keyframes fusion-wave-flow { + from { + stroke-dashoffset: 0; + } + to { + stroke-dashoffset: -120; + } +} + +@media (prefers-reduced-motion: reduce) { + .fusion-wave-packets { + animation: none; + opacity: 0.3; + } +} diff --git a/apps/web/src/components/chat/FusionWave.tsx b/apps/web/src/components/chat/FusionWave.tsx new file mode 100644 index 000000000000..2dc1bb45b685 --- /dev/null +++ b/apps/web/src/components/chat/FusionWave.tsx @@ -0,0 +1,95 @@ +import { useId, useSyncExternalStore } from "react"; +import "./FusionWave.css"; + +function subscribeVisibility(onChange: () => void) { + document.addEventListener("visibilitychange", onChange); + return () => document.removeEventListener("visibilitychange", onChange); +} + +const isDocumentVisible = () => document.visibilityState === "visible"; +const serverVisible = () => false; + +// Two streams skirt the controls, then meet in the reserved space at the right. +// Dither is generated once; only the open editor animates the packet paths. +const streams = [ + "M-8 10 C65 28 200 24 330 24 C352 24 352 60 375 60", + "M-8 110 C65 92 200 96 330 96 C352 96 352 60 375 60", + "M375 60 H408", +]; +const wavePaths = [0, 1, 2].map((tone) => { + const upper: string[] = []; + const lower: string[] = []; + for (let column = 0; column < 100; column++) { + const x = column * 4; + const merge = Math.min(1, Math.max(0, (x - 330) / 45)); + const spread = + (36 + 14 * Math.pow(Math.max(0, 1 - x / 100), 2)) * (1 - merge * merge * (3 - 2 * merge)); + for (let row = 0; row < 30; row++) { + const y = row * 4; + if (x < 330 && y > 33 && y < 87) continue; + const distance = Math.abs(Math.abs(y - 60) - spread); + const density = Math.max(0, 1 - distance / 10); + const noise = ((column * 73 + row * 37 + column * row * 11) % 101) / 101; + if (noise < density && (column + row * 2) % 3 === tone) { + (y < 60 ? upper : lower).push(`M${x} ${y}h2v2h-2z`); + } + } + } + return { upper: upper.join(""), lower: lower.join(""), tone, opacity: [0.12, 0.24, 0.4][tone] }; +}); + +export function FusionWave({ animated = false }: { animated?: boolean }) { + const gradientId = useId(); + const upperFill = `url(#${gradientId}-upper)`; + const lowerFill = `url(#${gradientId}-lower)`; + const visible = useSyncExternalStore(subscribeVisibility, isDocumentVisible, serverVisible); + return ( + + ); +} diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 7059d06f6c31..6ef729069377 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -7,11 +7,27 @@ import { import { resolveSelectableModel } from "@t3tools/shared/model"; import { useAtomValue } from "@effect/atom-react"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; -import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { + memo, + useMemo, + useState, + useCallback, + type KeyboardEvent, + useLayoutEffect, + useRef, +} from "react"; import { ChevronRightIcon, SearchIcon } from "lucide-react"; +import { collapseFusionModels } from "@t3tools/client-runtime/fusionModels"; +import { + adjacentModelPickerProvider, + resolveModelPickerSelectedModel, + shouldIncludeModelPickerOption, + shouldOfferModelPickerSetup, + modelPickerInstanceMatchesLock, +} from "./modelPickerLogic"; import { ModelListRow } from "./ModelListRow"; import { ModelPickerSidebar } from "./ModelPickerSidebar"; -import { getProviderStatusMessage, hasProviderSetup } from "./ProviderStatusBanner"; +import { getProviderStatusMessage } from "./ProviderStatusBanner"; import { modelPickerLegacySectionKey, modelPickerModelKey, @@ -47,12 +63,8 @@ import { } from "../../providerInstances"; import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; -type ModelPickerItem = { - slug: string; - name: string; - shortName?: string; - subProvider?: string; - badge?: "new"; +type ModelPickerItem = ModelEsque & { + isFusionGroup?: boolean; instanceId: ProviderInstanceId; driverKind: ProviderDriverKind; instanceDisplayName: string; @@ -60,96 +72,15 @@ type ModelPickerItem = { acpRegistryAgentId?: string | undefined; acpRegistryIconUrl?: string | undefined; continuationGroupKey?: string | undefined; - isLegacy?: boolean | undefined; - isUnavailable?: boolean | undefined; }; -export function resolveModelPickerSelectedModel(input: { - driverKind: ProviderDriverKind | undefined; - model: string; - options: ReadonlyArray; -}) { - if (input.driverKind === "antigravity" && input.model === ANTIGRAVITY_DEFAULT_MODEL) { - const availableModels = input.options.filter( - (option) => option.slug !== ANTIGRAVITY_DEFAULT_MODEL && !option.isUnavailable, - ); - return ( - availableModels.find((option) => option.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL)) ?? - availableModels.find((option) => option.isDefault) - ); - } - return input.options.find((option) => option.slug === input.model); -} - -export function shouldIncludeModelPickerOption(input: { - readonly entry: ProviderInstanceEntry; - readonly option: ModelEsque; - readonly activeInstanceId: ProviderInstanceId; - readonly activeModel: string; -}): boolean { - if (input.entry.driverKind === "antigravity" && input.option.slug === ANTIGRAVITY_DEFAULT_MODEL) { - return false; - } - if (isProviderInstancePickerReady(input.entry)) return true; - return ( - input.entry.enabled && - (input.entry.driverKind === "opencode" || input.entry.driverKind === "antigravity") && - input.entry.instanceId === input.activeInstanceId && - input.option.slug === input.activeModel && - input.option.isUnavailable === true - ); -} - -export function shouldOfferModelPickerSetup( - entry: ProviderInstanceEntry, - options: ReadonlyArray, -): boolean { - return ( - entry.enabled && - entry.status !== "disabled" && - hasProviderSetup(entry.snapshot) && - (!isProviderInstancePickerReady(entry) || - !entry.installed || - entry.snapshot.auth.status === "unauthenticated" || - !options.some((option) => !option.isUnavailable)) - ); -} - -export function adjacentModelPickerProvider(input: { - entries: ReadonlyArray; - selectedInstanceId: ProviderInstanceId | "favorites"; - direction: 1 | -1; - disabledInstanceIds: ReadonlySet | undefined; - selectableUnavailableInstanceIds: ReadonlySet | undefined; -}) { - const providers: Array = [ - "favorites", - ...input.entries - .filter( - (entry) => - !input.disabledInstanceIds?.has(entry.instanceId) && - (isProviderInstancePickerReady(entry) || - input.selectableUnavailableInstanceIds?.has(entry.instanceId)), - ) - .map((entry) => entry.instanceId), - ]; - const index = providers.indexOf(input.selectedInstanceId); - return providers[ - index < 0 - ? input.direction === 1 - ? 0 - : providers.length - 1 - : (index + input.direction + providers.length) % providers.length - ]!; -} - const EMPTY_MODEL_JUMP_LABELS = new Map(); function ModelListSeparator() { return
; } -export const ModelPickerContent = memo(function ModelPickerContent(props: { +export type ModelPickerProps = { /** The instance currently selected in the composer (combobox "value"). */ activeInstanceId: ProviderInstanceId; model: string; @@ -181,13 +112,20 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void; getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; -}) { +}; + +export const ModelPickerContent = memo(function ModelPickerContent( + props: ModelPickerProps & { + onOpenFusion: (instanceId: ProviderInstanceId, model: string) => void; + }, +) { const { keybindings: providedKeybindings, modelOptionsByInstance, instanceEntries, getModelDisabledReason, onInstanceModelChange, + onOpenFusion, } = props; const [searchQuery, setSearchQuery] = useState(""); const [showTopScrollFade, setShowTopScrollFade] = useState(false); @@ -302,10 +240,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ); const matchesLockedProvider = useCallback( (entry: Pick): boolean => { - if (props.lockedProvider === null) return true; - if (entry.driverKind !== props.lockedProvider) return false; - if (!props.lockedContinuationGroupKey) return true; - return entry.continuationGroupKey === props.lockedContinuationGroupKey; + return modelPickerInstanceMatchesLock( + entry, + props.lockedProvider, + props.lockedContinuationGroupKey, + ); }, [props.lockedContinuationGroupKey, props.lockedProvider], ); @@ -360,6 +299,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { out.push({ slug: model.slug, name: model.name, + fusion: model.fusion, ...(model.shortName ? { shortName: model.shortName } : {}), ...(model.subProvider ? { subProvider: model.subProvider } : {}), ...(model.badge ? { badge: model.badge } : {}), @@ -417,95 +357,44 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ); // Filter models based on search query and selected instance - const filteredModels = useMemo(() => { - let result = flatModels; + const matchingModels = useMemo(() => { + const available = flatModels.filter(matchesLockedProvider); - // Apply tokenized fuzzy search across the combined provider/model search fields. + // Search across accounts; the sidebar only limits the unfiltered catalog. if (searchQuery.trim()) { - const rankedMatches = result - .map((model) => ({ - model, - score: scoreModelPickerSearch( - { - name: model.name, - ...(model.shortName ? { shortName: model.shortName } : {}), - ...(model.subProvider ? { subProvider: model.subProvider } : {}), - driverKind: model.driverKind, - providerDisplayName: model.instanceDisplayName, - isFavorite: favoritesSet.has(providerModelKey(model.instanceId, model.slug)), - }, - searchQuery, - ), - isFavorite: favoritesSet.has(providerModelKey(model.instanceId, model.slug)), - tieBreaker: buildModelPickerSearchText({ - name: model.name, - ...(model.shortName ? { shortName: model.shortName } : {}), - ...(model.subProvider ? { subProvider: model.subProvider } : {}), - driverKind: model.driverKind, + return available + .flatMap((model) => { + const searchable = { + ...model, providerDisplayName: model.instanceDisplayName, - }), - })) - .filter( - ( - rankedModel, - ): rankedModel is { - model: ModelPickerItem; - score: number; - isFavorite: boolean; - tieBreaker: string; - } => rankedModel.score !== null, - ); - - // When searching, we only respect locked provider (by driver kind), - // ignoring sidebar selection so account-scoped searches can find a - // model before the user chooses a specific instance rail item. - if (props.lockedProvider !== null) { - const lockedProviderMatches: Array<(typeof rankedMatches)[number]> = []; - for (const rankedModel of rankedMatches) { - if (matchesLockedProvider(rankedModel.model)) { - lockedProviderMatches.push(rankedModel); - } - } - return lockedProviderMatches - .toSorted((a, b) => { - const scoreDelta = a.score - b.score; - if (scoreDelta !== 0) { - return scoreDelta; - } - if (a.isFavorite !== b.isFavorite) { - return a.isFavorite ? -1 : 1; - } - return a.tieBreaker.localeCompare(b.tieBreaker); - }) - .map((rankedModel) => rankedModel.model); - } - - return rankedMatches - .toSorted((a, b) => { - const scoreDelta = a.score - b.score; - if (scoreDelta !== 0) { - return scoreDelta; - } - if (a.isFavorite !== b.isFavorite) { - return a.isFavorite ? -1 : 1; - } - return a.tieBreaker.localeCompare(b.tieBreaker); + isFavorite: favoritesSet.has(providerModelKey(model.instanceId, model.slug)), + }; + const score = scoreModelPickerSearch(searchable, searchQuery); + return score === null + ? [] + : [ + { + model, + score, + isFavorite: searchable.isFavorite, + tieBreaker: buildModelPickerSearchText(searchable), + }, + ]; }) - .map((rankedModel) => rankedModel.model); + .toSorted( + (a, b) => + a.score - b.score || + Number(b.isFavorite) - Number(a.isFavorite) || + a.tieBreaker.localeCompare(b.tieBreaker), + ) + .map(({ model }) => model); } - if (props.lockedProvider !== null) { - result = result.filter((m) => matchesLockedProvider(m)); - if (selectedInstanceId === "favorites") { - result = result.filter((m) => favoritesSet.has(providerModelKey(m.instanceId, m.slug))); - } else { - result = result.filter((m) => m.instanceId === selectedInstanceId); - } - } else if (selectedInstanceId === "favorites") { - result = result.filter((m) => favoritesSet.has(providerModelKey(m.instanceId, m.slug))); - } else { - result = result.filter((m) => m.instanceId === selectedInstanceId); - } + const result = available.filter((model) => + selectedInstanceId === "favorites" + ? favoritesSet.has(providerModelKey(model.instanceId, model.slug)) + : model.instanceId === selectedInstanceId, + ); return sortProviderModelItems(result, { favoriteModelKeys: favoritesSet, @@ -517,11 +406,27 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { flatModels, instanceOrder, matchesLockedProvider, - props.lockedProvider, searchQuery, selectedInstanceId, ]); + const filteredModels = useMemo( + () => + selectedInstanceId === "favorites" && !isSearching + ? matchingModels + : collapseFusionModels( + matchingModels, + (model) => model.instanceId, + (model) => + model.instanceId === props.activeInstanceId && model.slug === activeModelSlug, + ).map((model) => + model.fusion && !model.isUnavailable + ? { ...model, name: "Fusion", shortName: "Fusion", isFusionGroup: true } + : model, + ), + [matchingModels, props.activeInstanceId, activeModelSlug, selectedInstanceId, isSearching], + ); + const legacySection = useMemo(() => { if (isSearching || selectedInstanceId === "favorites") { return null; @@ -580,15 +485,16 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const handleModelSelect = useCallback( (modelSlug: string, instanceId: ProviderInstanceId) => { - if (getModelDisabledReason?.(instanceId, modelSlug)) { - return; - } const options = modelOptionsByInstance.get(instanceId); - if (!options) { - return; - } const entry = entryByInstanceId.get(instanceId); - if (!entry) { + if (!options || !entry || getModelDisabledReason?.(instanceId, modelSlug)) return; + const option = options.find((model) => model.slug === modelSlug); + if ( + option?.fusion && + !option.isUnavailable && + (selectedInstanceId !== "favorites" || isSearching) + ) { + onOpenFusion(instanceId, modelSlug); return; } // `resolveSelectableModel` uses the driver kind for normalization @@ -599,7 +505,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onInstanceModelChange(instanceId, resolvedModel); } }, - [entryByInstanceId, getModelDisabledReason, modelOptionsByInstance, onInstanceModelChange], + [ + entryByInstanceId, + getModelDisabledReason, + modelOptionsByInstance, + onInstanceModelChange, + onOpenFusion, + selectedInstanceId, + isSearching, + ], ); const toggleFavorite = useCallback( @@ -709,84 +623,56 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { [favoritesSet, modelJumpLabelByKey], ); - useEffect(() => { - const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.defaultPrevented || event.repeat || isCommandPaletteOpen()) { - return; - } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.repeat || isCommandPaletteOpen()) { + return; + } - const command = resolveShortcutCommand(event, keybindings, { - platform: navigator.platform, - context: modelJumpShortcutContext, - }); - if (command === "modelPicker.previousProvider" || command === "modelPicker.nextProvider") { - event.preventDefault(); - event.stopPropagation(); - const next = adjacentModelPickerProvider({ - entries: sidebarInstanceEntries, - selectedInstanceId, - direction: command === "modelPicker.nextProvider" ? 1 : -1, - disabledInstanceIds: lockedDisabledInstanceIds, - selectableUnavailableInstanceIds, - }); - setSearchQuery(""); - handleSelectInstance(next); - return; - } - const jumpIndex = modelPickerJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) { - return; - } + const command = resolveShortcutCommand(event, keybindings, { + platform: navigator.platform, + context: modelJumpShortcutContext, + }); + if (command === "modelPicker.previousProvider" || command === "modelPicker.nextProvider") { event.preventDefault(); event.stopPropagation(); + const next = adjacentModelPickerProvider({ + entries: sidebarInstanceEntries, + selectedInstanceId, + direction: command === "modelPicker.nextProvider" ? 1 : -1, + disabledInstanceIds: lockedDisabledInstanceIds, + selectableUnavailableInstanceIds, + }); + setSearchQuery(""); + handleSelectInstance(next); + return; + } + const jumpIndex = modelPickerJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) { + return; + } + event.preventDefault(); + event.stopPropagation(); - const targetModelKey = modelJumpModelKeys[jumpIndex]; - if (!targetModelKey) { - return; - } - const model = parseModelPickerModelKey(targetModelKey); - if (!model) { - return; - } - handleModelSelect(model.slug, model.instanceId); - }; - - window.addEventListener("keydown", onWindowKeyDown, true); - - return () => { - window.removeEventListener("keydown", onWindowKeyDown, true); - }; - }, [ - handleModelSelect, - handleSelectInstance, - keybindings, - lockedDisabledInstanceIds, - modelJumpModelKeys, - modelJumpShortcutContext, - selectableUnavailableInstanceIds, - selectedInstanceId, - sidebarInstanceEntries, - ]); - - useLayoutEffect(() => { - setShowTopScrollFade(false); - setShowBottomScrollFade(filteredItemKeys.length > 5); - let nestedFrame = 0; - const frame = window.requestAnimationFrame(() => { - updateModelListScrollFades(); - nestedFrame = window.requestAnimationFrame(updateModelListScrollFades); - }); - return () => { - window.cancelAnimationFrame(frame); - window.cancelAnimationFrame(nestedFrame); - }; - }, [filteredItemKeys, updateModelListScrollFades]); + const targetModelKey = modelJumpModelKeys[jumpIndex]; + if (!targetModelKey) { + return; + } + const model = parseModelPickerModelKey(targetModelKey); + if (!model) { + return; + } + handleModelSelect(model.slug, model.instanceId); + }; return (
{/* Sidebar */} {showSidebar && ( @@ -966,7 +852,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { model={model} instanceId={model.instanceId} driverKind={model.driverKind} - providerDisplayName={model.instanceDisplayName} + providerDisplayName={ + model.isFusionGroup && model.fusion + ? `${model.instanceDisplayName} · ${model.fusion.lead.name} + ${model.fusion.sidekick.name}` + : model.instanceDisplayName + } providerAccentColor={model.instanceAccentColor} acpRegistryAgentId={model.acpRegistryAgentId} acpRegistryIconUrl={model.acpRegistryIconUrl} @@ -990,6 +880,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { recycleItems contentContainerClassName="pl-2 pr-px" ItemSeparatorComponent={ModelListSeparator} + onLoad={updateModelListScrollFades} onLayout={updateModelListScrollFades} onScroll={updateModelListScrollFades} className={cn( diff --git a/apps/web/src/components/chat/ModelPickerViews.tsx b/apps/web/src/components/chat/ModelPickerViews.tsx new file mode 100644 index 000000000000..55c838c55f88 --- /dev/null +++ b/apps/web/src/components/chat/ModelPickerViews.tsx @@ -0,0 +1,60 @@ +import { Activity, memo, useState } from "react"; +import type { ProviderInstanceId } from "@t3tools/contracts"; +import { isProviderInstancePickerReady } from "../../providerInstances"; +import { FusionModelPicker } from "./FusionModelPicker"; +import { ModelPickerContent, type ModelPickerProps } from "./ModelPickerContent"; +import { + modelPickerInstanceMatchesLock, + resolveModelPickerSelectedModel, +} from "./modelPickerLogic"; + +export const ModelPickerViews = memo(function ModelPickerViews(props: ModelPickerProps) { + const [fusion, setFusion] = useState<{ instanceId: ProviderInstanceId; model: string } | null>( + () => { + const entry = props.instanceEntries.find( + (entry) => entry.instanceId === props.activeInstanceId, + ); + const model = resolveModelPickerSelectedModel({ + driverKind: entry?.driverKind, + model: props.model, + options: props.modelOptionsByInstance.get(props.activeInstanceId) ?? [], + }); + return model?.fusion && !model.isUnavailable + ? { instanceId: props.activeInstanceId, model: model.slug } + : null; + }, + ); + const entry = props.instanceEntries.find((entry) => entry.instanceId === fusion?.instanceId); + const models = + entry && + isProviderInstancePickerReady(entry) && + modelPickerInstanceMatchesLock(entry, props.lockedProvider, props.lockedContinuationGroupKey) + ? (props.modelOptionsByInstance.get(entry.instanceId) ?? []).filter( + (model) => + model.fusion && + !model.isUnavailable && + !props.getModelDisabledReason?.(entry.instanceId, model.slug), + ) + : []; + + return ( + <> + {/* Preserve the list's search and scroll state while React cleans up its hidden effects. */} + + setFusion({ instanceId, model })} + /> + + {fusion && ( + setFusion(null)} + onSelect={(model) => props.onInstanceModelChange(fusion.instanceId, model)} + /> + )} + + ); +}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index e1c689599a9d..742ca1dac18e 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,3 +1,4 @@ +import { resolveProviderModelPolicy } from "@t3tools/contracts"; import { ANTIGRAVITY_DEFAULT_MODEL, type ProviderInstanceId, @@ -11,7 +12,8 @@ import { buttonVariants } from "../ui/button"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; -import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent"; +import { ModelPickerViews } from "./ModelPickerViews"; +import { resolveModelPickerSelectedModel } from "./modelPickerLogic"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; import { ModelEsque, @@ -79,14 +81,16 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { model: props.model, options: selectedInstanceOptions, }) ?? - (activeEntry?.driverKind === "opencode" || activeEntry?.driverKind === "antigravity" + (resolveProviderModelPolicy(activeEntry?.snapshot).preserveUnavailableModels ? undefined : selectedInstanceOptions[0]); - const triggerTitle = selectedModel - ? getTriggerDisplayModelName(selectedModel) - : props.model === ANTIGRAVITY_DEFAULT_MODEL - ? "Choose model" - : props.model || "Choose model"; + const triggerTitle = selectedModel?.fusion + ? "Fusion" + : selectedModel + ? getTriggerDisplayModelName(selectedModel) + : props.model === ANTIGRAVITY_DEFAULT_MODEL + ? "Choose model" + : props.model || "Choose model"; const triggerLabel = selectedModel ? `${getTriggerDisplayModelLabel(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}` : triggerTitle; @@ -229,10 +233,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { - option.id !== "agent" || option.value !== "plan"), ) - : getProviderOptionDescriptors({ - caps, - selections: modelOptions, - }); + : resolveProviderModelOptions(caps, modelOptions, modelPolicy).descriptors; const selectDescriptors = descriptors.filter( (descriptor): descriptor is Extract => descriptor.type === "select", @@ -217,6 +216,7 @@ function getSelectedTraits( function getTraitsSectionVisibility(input: { provider: ProviderDriverKind; + modelPolicy?: ServerProvider["modelPolicy"]; models: ReadonlyArray; model: string | null | undefined; prompt: string; @@ -232,6 +232,7 @@ function getTraitsSectionVisibility(input: { input.modelOptions, input.allowPromptInjectedEffort ?? true, input.planModeEnabled, + input.modelPolicy, ); const showEffort = selected.primarySelectDescriptor !== null; @@ -259,6 +260,7 @@ function getTraitsSectionVisibility(input: { export function shouldRenderTraitsControls(input: { provider: ProviderDriverKind; + modelPolicy?: ServerProvider["modelPolicy"]; models: ReadonlyArray; model: string | null | undefined; prompt: string; @@ -271,6 +273,7 @@ export function shouldRenderTraitsControls(input: { export interface TraitsMenuContentProps { provider: ProviderDriverKind; + modelPolicy?: ServerProvider["modelPolicy"]; instanceId?: ProviderInstanceId; models: ReadonlyArray; model: string | null | undefined; @@ -286,6 +289,7 @@ export interface TraitsMenuContentProps { export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ provider, + modelPolicy, instanceId, models, model, @@ -326,6 +330,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ modelIsUnavailable, } = getTraitsSectionVisibility({ provider, + modelPolicy, models, model, prompt, @@ -538,6 +543,7 @@ export function buildTraitsTriggerDisplay(input: { export const TraitsPicker = memo(function TraitsPicker({ provider, + modelPolicy, instanceId, models, model, @@ -561,6 +567,7 @@ export const TraitsPicker = memo(function TraitsPicker({ const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = getTraitsSectionVisibility({ provider, + modelPolicy, models, model, prompt, @@ -571,6 +578,7 @@ export const TraitsPicker = memo(function TraitsPicker({ if ( !shouldRenderTraitsControls({ provider, + modelPolicy, models, model, prompt, @@ -651,6 +659,7 @@ export const TraitsPicker = memo(function TraitsPicker({ { }); describe("provider traits render guards", () => { + it("keeps unavailable OpenCode options available to the traits controls", () => { + const options = selections(["variant", "max"], ["agent", "build"]); + expect( + resolveProviderTraitsProps({ + provider: ProviderDriverKind.make("opencode"), + model: "removed-model", + models: [], + modelOptions: options, + prompt: "", + planModeEnabled: false, + threadRef: { environmentId: EnvironmentId.make("test"), threadId: ThreadId.make("test") }, + })?.modelOptions, + ).toEqual(options); + }); + it("returns null when no thread target is provided", () => { const models = modelWith([ selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), @@ -482,11 +498,44 @@ describe("provider traits render guards", () => { models, modelOptions: undefined, prompt: "", - onPromptChange: () => {}, planModeEnabled: true, }; - expect(renderProviderTraitsPicker(args)).toBeNull(); - expect(renderProviderTraitsMenuContent(args)).toBeNull(); + expect(resolveProviderTraitsProps(args)).toBeNull(); }); }); + +it("preserves exact catalog options for an unknown driver", () => { + const modelOptions = selections(["reasoningEffort", "max"], ["fastMode", true]); + const models = modelWith([ + selectDescriptor("reasoningEffort", [{ id: "high", label: "High", isDefault: true }]), + ]); + const state = getComposerProviderState({ + provider: ProviderDriverKind.make("test-account-provider"), + modelPolicy: { optionSelection: "exact" }, + model: MODEL, + models, + modelOptions, + planModeEnabled: false, + }); + expect(state.modelOptionsForDispatch).toEqual(modelOptions); + const defaultState = getComposerProviderState({ + provider: ProviderDriverKind.make("test-account-provider"), + modelPolicy: { optionSelection: "exact" }, + model: MODEL, + models: modelWith([{ id: "fastMode", label: "Fast", type: "boolean", currentValue: true }]), + modelOptions: undefined, + planModeEnabled: false, + }); + expect(defaultState.modelOptionsForDispatch).toBeUndefined(); + const descriptors = getProviderOptionDescriptors({ + caps: models[0]!.capabilities!, + selections: modelOptions, + preserveUnavailableSelections: true, + }); + expect(descriptors[0]?.currentValue).toBe("max"); + expect(descriptors[1]?.currentValue).toBe(true); + expect(descriptors[0]?.type === "select" && descriptors[0].options.at(-1)?.label).toContain( + "Unavailable", + ); +}); diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index c6837700cdfa..eb66318d6b39 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -1,29 +1,31 @@ import { - type ModelCapabilities, + resolveProviderModelOptions, + withImplicitFastModeDefault, +} from "@t3tools/client-runtime/providerModelOptions"; +import { type ProviderDriverKind, type ProviderInstanceId, type ProviderOptionSelection, type ScopedThreadRef, type ServerProviderModel, + type ServerProvider, } from "@t3tools/contracts"; import { - buildExplicitProviderOptionSelectionsFromDescriptors, getProviderOptionCurrentValue, - getProviderOptionDescriptors, isClaudeUltrathinkPrompt, normalizeModelSlug, } from "@t3tools/shared/model"; import type { VariantProps } from "class-variance-authority"; -import type { ReactNode } from "react"; import type { buttonVariants } from "../ui/button"; import type { DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; import type { ComposerControlSize } from "./ComposerControl"; -import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; +import { shouldRenderTraitsControls } from "./TraitsPicker"; export type ComposerProviderStateInput = { provider: ProviderDriverKind; + modelPolicy?: ServerProvider["modelPolicy"]; model: string; models: ReadonlyArray; promptInjectionState?: ComposerPromptInjectionState; @@ -44,6 +46,7 @@ export type ComposerProviderState = { type TraitsRenderInput = { provider: ProviderDriverKind; + modelPolicy?: ServerProvider["modelPolicy"]; instanceId?: ProviderInstanceId; threadRef?: ScopedThreadRef; draftId?: DraftId; @@ -51,7 +54,6 @@ type TraitsRenderInput = { models: ReadonlyArray; modelOptions: ReadonlyArray | undefined; prompt: string; - onPromptChange: (prompt: string) => void; planModeEnabled: boolean; size?: ComposerControlSize; hidden?: boolean; @@ -64,42 +66,16 @@ export function getComposerPromptInjectionState(prompt: string): ComposerPromptI return isClaudeUltrathinkPrompt(prompt) ? "ultrathink" : "none"; } -/** - * Cursor ACP can report `fastMode: true` as the provider default. T3 only - * treats Fast as selected when the user chose it (draft/sticky/settings). - * Otherwise inject an explicit `false` so new chats stay Normal and the - * send path can overwrite a prior Fast session — descriptor defaults are - * otherwise omitted by `buildExplicitProviderOptionSelectionsFromDescriptors`. - */ -export function withImplicitFastModeDefault( - caps: ModelCapabilities, - modelOptions: ReadonlyArray | null | undefined, -): ReadonlyArray | undefined { - const hasExplicitFastMode = modelOptions?.some((selection) => selection.id === "fastMode"); - if (hasExplicitFastMode) { - return modelOptions ?? undefined; - } - const hasFastModeDescriptor = caps.optionDescriptors?.some( - (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", - ); - if (!hasFastModeDescriptor) { - return modelOptions ?? undefined; - } - return [...(modelOptions ?? []), { id: "fastMode", value: false }]; -} - function resolveComposerOptionSelections( models: ReadonlyArray, model: string, provider: ProviderDriverKind, modelOptions: ReadonlyArray | null | undefined, planModeEnabled: boolean, -): { - caps: ModelCapabilities; - selections: ReadonlyArray | undefined; -} { + modelPolicy: ServerProvider["modelPolicy"], +) { const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled); - return { caps, selections: withImplicitFastModeDefault(caps, modelOptions) }; + return { caps, selections: withImplicitFastModeDefault(caps, modelOptions, modelPolicy) }; } export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { @@ -110,6 +86,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com modelOptions, promptInjectionState = "none", planModeEnabled, + modelPolicy, } = input; if (provider === "opencode") { const normalizedModel = normalizeModelSlug(model, provider); @@ -126,14 +103,19 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com }; } } - const { caps, selections } = resolveComposerOptionSelections( + const { caps, selections: explicitSelections } = resolveComposerOptionSelections( models, model, provider, modelOptions, planModeEnabled, + modelPolicy, + ); + const { descriptors, selections } = resolveProviderModelOptions( + caps, + explicitSelections, + modelPolicy, ); - const descriptors = getProviderOptionDescriptors({ caps, selections }); const primarySelectDescriptor = descriptors.find( (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> => descriptor.type === "select", @@ -147,10 +129,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com return { provider, promptEffort, - modelOptionsForDispatch: buildExplicitProviderOptionSelectionsFromDescriptors( - descriptors, - selections, - ), + modelOptionsForDispatch: selections, ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame", @@ -161,73 +140,39 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com }; } -function renderTraitsControl( - Component: typeof TraitsMenuContent | typeof TraitsPicker, - input: TraitsRenderInput, -): ReactNode { +/** Resolve visibility and model options without invoking render-time callbacks. */ +export function resolveProviderTraitsProps(input: TraitsRenderInput) { const { provider, - instanceId, threadRef, draftId, model, models, modelOptions, prompt, - onPromptChange, planModeEnabled, - size, - hidden, - triggerVariant, - triggerClassName, - isComposerOwned, + modelPolicy, } = input; - const hasTarget = threadRef !== undefined || draftId !== undefined; - const { selections: resolvedModelOptions } = resolveComposerOptionSelections( + const { selections } = resolveComposerOptionSelections( models, model, provider, modelOptions, planModeEnabled, + modelPolicy, ); if ( - !hasTarget || + (threadRef === undefined && draftId === undefined) || !shouldRenderTraitsControls({ provider, + modelPolicy, models, model, - modelOptions: resolvedModelOptions, + modelOptions: selections, prompt, planModeEnabled, }) - ) { + ) return null; - } - return ( -