diff --git a/README.md b/README.md index 27b5dc491693..78648bbaf867 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). -Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, and Google Antigravity. If they're set up on your computer, T3 Code can control them. +Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, Devin CLI, and Google Antigravity. If they're set up on your computer, T3 Code can control them. ## "Wait, what are you selling me?" @@ -13,13 +13,14 @@ We wanted something performant, remote-ready, and truly open. If we ever go the ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, and Antigravity. Install and authenticate at least one provider before use: +> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, Devin CLI, and Antigravity. Install and authenticate at least one provider before use: > > - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` > - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` > - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login` > - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login` > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` +> - Devin: install [Devin CLI](https://docs.devin.ai/cli), run `devin auth login`, then enable it in Settings → Providers. > - Antigravity: enable it in Settings, then use **Install Antigravity** and **Sign in with Google**. No CLI is required. ### Try it out (install-free) diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 374738d0aeca..7da354542504 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -26,6 +26,17 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + // Cognition mark from https://cognition.com/icon.svg. + if (props.provider === "devin") { + return ( + + + + + + ); + } + if (props.provider === "claudeAgent") { return ( diff --git a/apps/mobile/src/features/threads/FusionModelEditor.tsx b/apps/mobile/src/features/threads/FusionModelEditor.tsx new file mode 100644 index 000000000000..d831a11c552a --- /dev/null +++ b/apps/mobile/src/features/threads/FusionModelEditor.tsx @@ -0,0 +1,114 @@ +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 { fusionLeadPairing } from "./fusion-model-options"; + +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 = editing + ? [ + ...new Map( + available + .filter((model) => editing === "lead" || model.fusion?.lead.id === pairing.lead.id) + .map((model) => [model.fusion?.[editing].id, model]), + ).values(), + ] + : []; + + 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.map((model) => ( + { + const next = + role === "lead" + ? fusionLeadPairing(available, selected, model.fusion?.lead.id ?? "") + : model; + if (next) setSelectedKey(next.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 fddd89d74ea7..18409b05f8f1 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -104,7 +104,7 @@ import { } from "../../state/use-composer-drafts"; import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; import { - isModelSelectionUnavailable, + getModelSelectionUnavailableReason, resolveSelectableModelSelection, } from "../../lib/modelOptions"; import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; @@ -1127,14 +1127,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 680c9cf0babe..1146a834cfc3 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -548,10 +548,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 2d4276606ec3..15fe43bf1f1b 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 { collapseFusionOptions } from "./fusion-model-options"; import type { EnvironmentId, ModelSelection, @@ -110,7 +112,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; @@ -399,6 +404,7 @@ const ThreadSettingsSessionContext = createContext(null); const [searchQuery, setSearchQuery] = useState(""); @@ -429,6 +435,7 @@ function ThreadSettingsSessionProvider( ? getProviderOptionDescriptors({ caps: pendingModel.capabilities, selections: pendingModel.selection.options, + preserveUnavailableSelections: pendingModel.modelPolicy?.optionSelection === "exact", }) : [] : props.optionDescriptors, @@ -439,20 +446,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) => { @@ -466,10 +476,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) => { @@ -594,11 +604,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 ( ); } @@ -614,10 +631,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 ( @@ -635,6 +652,7 @@ function ThreadSettingsProviderListHeader(props: { function useThreadSettingsCatalogItems( session: ThreadSettingsSessionValue, ): ReadonlyArray { + const { isDisplayed } = session; return useMemo( () => session.providerGroups.flatMap((group) => { @@ -644,13 +662,16 @@ 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 = collapseFusionOptions( + catalogModels.filter((model) => + modelMatchesCatalogQuery({ + model, + providerLabel: group.providerLabel, + query: session.searchQuery, + }), + ), + isDisplayed, ); if (visibleModels.length === 0) { return []; @@ -684,7 +705,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, @@ -693,7 +717,7 @@ function useThreadSettingsCatalogItems( }), [ session.isApplied, - session.isDisplayed, + isDisplayed, session.providerExpansionOverrides, session.providerFilter, session.providerGroups, @@ -971,6 +995,7 @@ function ThreadSettingsChoiceContent(props: { type ThreadSettingsPickerStackParams = { ThreadSettingsModels: undefined; + ThreadSettingsFusion: { readonly providerKey: string; readonly initialKey: string }; ThreadSettingsChoice: ThreadSettingsSubmenuPage & { readonly title: string }; }; @@ -1191,6 +1216,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>(); @@ -1244,6 +1299,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/features/threads/fusion-model-options.test.ts b/apps/mobile/src/features/threads/fusion-model-options.test.ts new file mode 100644 index 000000000000..2652db5e4d67 --- /dev/null +++ b/apps/mobile/src/features/threads/fusion-model-options.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vite-plus/test"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import type { ModelOption } from "../../lib/modelOptions"; +import { collapseFusionOptions, fusionLeadPairing } from "./fusion-model-options"; + +function pairing(provider: string, lead: string, sidekick: string): ModelOption { + return { + key: `${provider}:${lead}:${sidekick}`, + providerKey: provider, + providerDriver: "devin", + providerLabel: provider, + label: "Fusion", + subtitle: `${lead} + ${sidekick}`, + isDefault: false, + isLegacy: false, + capabilities: null, + selection: { + instanceId: ProviderInstanceId.make(provider), + model: `${lead}:${sidekick}`, + }, + fusion: { lead: { id: lead, name: lead }, sidekick: { id: sidekick, name: sidekick } }, + }; +} + +describe("mobile Fusion pairing selection", () => { + it("keeps the selected pairing per account and unavailable selections visible", () => { + const first = pairing("a", "lead", "one"); + const selected = pairing("a", "lead", "two"); + const otherAccount = pairing("b", "lead", "two"); + const unavailable = { ...pairing("a", "removed", "one"), isUnavailable: true }; + expect( + collapseFusionOptions([first, selected, otherAccount, unavailable], (m) => m === selected), + ).toEqual([selected, otherAccount, unavailable]); + }); + + it("preserves the sidekick when changing leads, falling back only to an offered pairing", () => { + const current = pairing("a", "old", "two"); + const first = pairing("a", "new", "one"); + const matching = pairing("a", "new", "two"); + expect(fusionLeadPairing([first, matching], current, "new")).toBe(matching); + expect(fusionLeadPairing([first, { ...matching, isUnavailable: true }], current, "new")).toBe( + first, + ); + expect(fusionLeadPairing([pairing("b", "new", "two")], current, "new")).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/features/threads/fusion-model-options.ts b/apps/mobile/src/features/threads/fusion-model-options.ts new file mode 100644 index 000000000000..80c07391bd4f --- /dev/null +++ b/apps/mobile/src/features/threads/fusion-model-options.ts @@ -0,0 +1,40 @@ +import type { ModelOption } from "../../lib/modelOptions"; + +/** Collapse only available pairings, keeping unavailable selections visible. */ +export function collapseFusionOptions( + models: ReadonlyArray, + isSelected: (model: ModelOption) => boolean, +) { + const seen = new Set(); + return models.flatMap((model) => { + if (!model.fusion || model.isUnavailable) return [model]; + if (seen.has(model.providerKey)) return []; + seen.add(model.providerKey); + return [ + models.find( + (candidate) => + candidate.providerKey === model.providerKey && + candidate.fusion && + !candidate.isUnavailable && + isSelected(candidate), + ) ?? model, + ]; + }); +} + +export function fusionLeadPairing( + models: ReadonlyArray, + current: ModelOption, + leadId: string, +) { + const candidates = models.filter( + (model) => + model.providerKey === current.providerKey && + !model.isUnavailable && + model.fusion?.lead.id === leadId, + ); + return ( + candidates.find((model) => model.fusion?.sidekick.id === current.fusion?.sidekick.id) ?? + candidates[0] + ); +} diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 98896b990ec4..4f820036e6e0 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, @@ -197,7 +203,8 @@ describe("mobile model options", () => { expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled); }); - describe("Antigravity selections", () => { + describe.each(["antigravity", "devin"])("%s selections", (driver) => { + const providerName = driver === "devin" ? "Devin" : "Antigravity"; const selection = { instanceId: ProviderInstanceId.make("google_work"), model: "gemini-3.1-pro-high", @@ -226,7 +233,7 @@ describe("mobile model options", () => { providers: [ { instanceId: selection.instanceId, - driver: "antigravity", + driver, displayName: "Google Work", enabled: true, installed: true, @@ -236,6 +243,90 @@ describe("mobile model options", () => { ], } as unknown as ServerConfig; + 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 }], @@ -258,7 +349,7 @@ describe("mobile model options", () => { subtitle: "Google", providerKey: "google_work", providerLabel: "Google Work", - providerDriver: "antigravity", + providerDriver: driver, isDefault: false, isLegacy: true, isUnavailable: true, @@ -283,7 +374,7 @@ describe("mobile model options", () => { expect(missing).toMatchObject({ label: selection.model, providerLabel: "Google Work", - providerDriver: "antigravity", + providerDriver: driver, isUnavailable: true, capabilities: null, }); @@ -310,16 +401,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( + `${providerName} model unavailable. Set up ${providerName} on web or desktop, or choose another model.`, + ); expect(buildModelOptions(missingStatusConfig, selection)).toMatchObject([ { - providerDriver: "antigravity", + providerDriver: driver, providerLabel: "Google Work", isUnavailable: true, selection, @@ -327,11 +421,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 4e8295733141..ae2c453a98c4 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -1,6 +1,8 @@ +import { resolveProviderModelPolicy } from "@t3tools/contracts"; import type { ModelCapabilities, ModelSelection, + ServerProvider, ServerConfig as T3ServerConfig, } from "@t3tools/contracts"; import { @@ -19,6 +21,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; }; @@ -61,32 +65,47 @@ 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?.driver ?? instanceConfig?.driver ?? selection.instanceId).replace( + /^./, + (letter) => letter.toUpperCase(), + ); + return `${name} model unavailable. Set up ${name} 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 instance-catalog selections 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. */ @@ -100,9 +119,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]) + .catalogScope === "instance" + ) { return selection; } return provider && @@ -114,7 +134,7 @@ export function resolveSelectableModelSelection( } /** - * Reject legacy models for implicit defaults, except Antigravity selections, + * Reject legacy models for implicit defaults, except instance-catalog selections, * which must not silently change after a catalog update. Explicit picks in * the settings sheet are unaffected. */ @@ -127,8 +147,13 @@ 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).catalogScope !== "instance" && + model?.isLegacy === true + ? null + : usable; } export function resolveNewTaskModelSelection(input: { @@ -158,7 +183,7 @@ export function buildModelOptions( !provider.enabled || !provider.installed || provider.auth.status === "unauthenticated" || - (provider.driver === "antigravity" && provider.availability === "unavailable") + provider.availability === "unavailable" ) { continue; } @@ -168,14 +193,18 @@ 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, isDefault: model.isDefault === true, isLegacy: model.isLegacy === true, capabilities: model.capabilities, + modelPolicy: resolveProviderModelPolicy(provider), selection: normalizeSelectionOptions( { instanceId: provider.instanceId, @@ -188,24 +217,26 @@ export function buildModelOptions( } 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" + existing.modelPolicy?.catalogScope === "instance" ? fallbackModelSelection : normalizeSelectionOptions(fallbackModelSelection, existing.capabilities), }); } 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({ @@ -215,8 +246,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, @@ -226,6 +260,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..66f837f876b1 100644 --- a/apps/mobile/src/lib/providerOptions.ts +++ b/apps/mobile/src/lib/providerOptions.ts @@ -1,5 +1,6 @@ import type { ModelCapabilities, + ServerProvider, ProviderOptionDescriptor, ProviderOptionSelection, } from "@t3tools/contracts"; @@ -9,15 +10,17 @@ import { } 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) { + if (!input.capabilities && input.modelPolicy?.optionSelection !== "exact") { return []; } return getProviderOptionDescriptors({ - caps: input.capabilities, + caps: input.capabilities ?? {}, selections: input.selections, + preserveUnavailableSelections: input.modelPolicy?.optionSelection === "exact", }); } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index d21af7d79cc3..a51d1e630f33 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -29,7 +29,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, @@ -374,14 +374,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 1147e9fd47af..48e5d5708db7 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -22,7 +22,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, @@ -695,11 +695,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); @@ -788,11 +789,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, @@ -872,11 +874,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; @@ -916,11 +919,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/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index e4fd848ab6e1..cae5e56909e2 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -15,6 +15,8 @@ import type * as AcpSchema from "effect-acp/schema"; const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH; const antigravityProfile = process.env.T3_ACP_ANTIGRAVITY === "1"; +const devinProfile = process.env.T3_ACP_DEVIN === "1"; +let devinModelsRefreshed = process.env.T3_ACP_DEVIN_STALE_MODELS !== "1"; const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; @@ -69,8 +71,12 @@ const permissionRequestCount = Math.max( ); const sessionId = "mock-session-1"; -let currentModeId = antigravityProfile ? "default" : "ask"; -let currentModelId = antigravityProfile ? "gemini-test-low" : "default"; +let currentModeId = antigravityProfile ? "default" : devinProfile ? "normal" : "ask"; +let currentModelId = antigravityProfile + ? "gemini-test-low" + : devinProfile + ? "devin-test-low" + : "default"; let parameterizedModelPicker = false; let currentReasoning = "medium"; let currentContext = "272k"; @@ -116,6 +122,29 @@ process.once("exit", (code) => { }); function configOptions(): ReadonlyArray { + if (devinProfile) { + return [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: currentModelId, + options: [ + { + group: "devin-test", + name: "Devin Test", + options: [ + { value: "devin-test-low", name: "Devin Test Low" }, + ...(devinModelsRefreshed + ? [{ value: "devin-test-high", name: "Devin Test High" }] + : []), + ], + }, + ], + }, + ]; + } if (antigravityProfile) { return [ { @@ -300,29 +329,40 @@ const antigravityModels = [ { modelId: "gemini-test-high", name: "Gemini Test High" }, ] satisfies ReadonlyArray; -const availableModes: ReadonlyArray = antigravityProfile - ? [ - { id: "default", name: "Default" }, - { id: "auto_edit", name: "Auto edit" }, - { id: "yolo", name: "YOLO" }, - ] - : [ - { - id: "ask", - name: "Ask", - description: "Request permission before making any changes", - }, - { - id: "architect", - name: "Architect", - description: "Design and plan software systems without implementation", - }, - { - id: "code", - name: "Code", - description: "Write and modify code with full tool access", - }, - ]; +const devinModes = [ + { id: "ask", name: "Ask", description: "Answer questions without changing files" }, + { id: "accept-edits", name: "Accept edits" }, + { id: "smart", name: "Smart" }, + { id: "plan", name: "Plan" }, + { id: "bypass", name: "Bypass" }, +] satisfies ReadonlyArray; + +// Devin accepts normal even though its advertised picker omits it. +const availableModes: ReadonlyArray = devinProfile + ? devinModes + : antigravityProfile + ? [ + { id: "default", name: "Default" }, + { id: "auto_edit", name: "Auto edit" }, + { id: "yolo", name: "YOLO" }, + ] + : [ + { + id: "ask", + name: "Ask", + description: "Request permission before making any changes", + }, + { + id: "architect", + name: "Architect", + description: "Design and plan software systems without implementation", + }, + { + id: "code", + name: "Code", + description: "Write and modify code with full tool access", + }, + ]; function modeState(): AcpSchema.SessionModeState { return { @@ -352,6 +392,15 @@ const grokAcpModels: ReadonlyArray = [ ]; function modelState(): AcpSchema.SessionModelState { + if (devinProfile) { + return { + currentModelId, + availableModels: [ + { modelId: "devin-test-low", name: "Devin Test Low" }, + ...(devinModelsRefreshed ? [{ modelId: "devin-test-high", name: "Devin Test High" }] : []), + ], + }; + } if (antigravityProfile) { return { currentModelId, availableModels: antigravityModels }; } @@ -368,6 +417,7 @@ const program = Effect.gen(function* () { const agent = yield* EffectAcpAgent.AcpAgent; const resumeRelease = yield* Deferred.make(); const nativeCancelRequested = yield* Deferred.make(); + let promptCancellation: Deferred.Deferred | undefined; const nativeCancelRelease = yield* Deferred.make(); const publishAntigravityCommands = (targetSessionId: string) => agent.client.sessionUpdate({ @@ -435,6 +485,18 @@ const program = Effect.gen(function* () { yield* agent.handleCreateSession(() => Effect.gen(function* () { + if (devinProfile) { + yield* agent.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: [ + { name: "compact", description: "Compact the conversation" }, + { name: "plan", description: "Plan changes", input: { hint: "[prompt]" } }, + ], + }, + }); + } if (antigravityProfile) { yield* publishAntigravityCommands(sessionId); } @@ -556,7 +618,10 @@ const program = Effect.gen(function* () { process.exit(7); }); } - if (failSetConfigOption) { + if ( + failSetConfigOption || + (request.configId === "model" && request.value === process.env.T3_ACP_REJECT_MODEL) + ) { return yield* AcpError.AcpRequestError.invalidParams( "Mock invalid params for session/set_config_option", { @@ -590,6 +655,7 @@ const program = Effect.gen(function* () { Effect.gen(function* () { const cancelledSessionId = String(sessionId ?? "mock-session-1"); cancelledSessions.add(cancelledSessionId); + if (promptCancellation) yield* Deferred.succeed(promptCancellation, undefined); if (completeFirstPromptOnCancel) { yield* Deferred.succeed(nativeCancelRequested, undefined); yield* agent.client.sessionUpdate({ @@ -619,6 +685,22 @@ const program = Effect.gen(function* () { Effect.gen(function* () { const requestedSessionId = String(request.sessionId ?? sessionId); promptCount += 1; + if (process.env.T3_ACP_WAIT_FOR_CANCEL === "1") + promptCancellation = yield* Deferred.make(); + if (devinProfile) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { sessionUpdate: "usage_update", used: 1800, size: 200000 }, + }); + } + + if (promptCancellation) { + if (!cancelledSessions.delete(requestedSessionId)) + yield* Deferred.await(promptCancellation); + cancelledSessions.delete(requestedSessionId); + promptCancellation = undefined; + return { stopReason: "cancelled" }; + } if (completeFirstPromptOnCancel && promptCount === 1) { yield* agent.client.sessionUpdate({ @@ -1336,6 +1418,20 @@ const program = Effect.gen(function* () { ); yield* agent.handleUnknownExtRequest((method, params) => { + if (devinProfile && method === "_cognition.ai/mcp/connectServer") { + return Effect.succeed({ + connectionStatus: process.env.T3_ACP_DEVIN_MCP_STATUS ?? "connected", + }); + } + if (devinProfile && method === "_test/refresh-models") { + devinModelsRefreshed = true; + return agent.client + .sessionUpdate({ + sessionId, + update: { sessionUpdate: "config_option_update", configOptions: configOptions() }, + }) + .pipe(Effect.as({})); + } if (method === "_test/environment") { return Effect.succeed({ inherited: process.env.T3_ACP_RUNTIME_AMBIENT === "sentinel", @@ -1396,7 +1492,7 @@ const program = Effect.gen(function* () { }); } - if (method !== "session/mode/set") { + if (method !== (devinProfile ? "session/set_mode" : "session/mode/set")) { return Effect.fail(AcpError.AcpRequestError.methodNotFound(method)); } @@ -1421,6 +1517,14 @@ const program = Effect.gen(function* () { : sessionId; if (typeof nextModeId === "string" && nextModeId.trim()) { + if ( + devinProfile && + !["normal", "accept-edits", "smart", "ask", "plan", "bypass"].includes(nextModeId) + ) { + return Effect.fail( + AcpError.AcpRequestError.invalidParams(`Unsupported Devin mode: ${nextModeId}`), + ); + } currentModeId = nextModeId.trim(); return agent.client .sessionUpdate({ diff --git a/apps/server/src/provider/Drivers/DevinDriver.test.ts b/apps/server/src/provider/Drivers/DevinDriver.test.ts new file mode 100644 index 000000000000..084f729122b7 --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinDriver.test.ts @@ -0,0 +1,244 @@ +import { vi } from "vite-plus/test"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as DevinSkills from "./DevinSkills.ts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { ThreadId } from "@t3tools/contracts"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Layer from "effect/Layer"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { DevinDriver } from "./DevinDriver.ts"; +import { MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER } from "../ProviderDriver.ts"; +import { + makeDevinCli as makeHarness, + devinTestLayer as layer, + devinTestSkills, +} from "../testUtils/devinCli.ts"; +const threadId = ThreadId.make("devin-thread"); +const instanceId = ProviderInstanceId.make("devin-account"); +const driverLayer = layer.pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + ), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), +); + +it.effect("bounds workspace metadata and evicts commands with the oldest skills", () => + Effect.gen(function* () { + const h = yield* makeHarness({ T3_DEVIN_AUTH_STATUS: "Logged in (via Devin)." }); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const instance = yield* DevinDriver.create({ + instanceId, + displayName: "Devin test account", + enabled: true, + config: h.settings, + environment: [], + }); + const snapshotForCwd = instance.snapshotForCwd; + if (!snapshotForCwd) throw new Error("Devin must expose workspace metadata."); + yield* instance.snapshot.refresh; + yield* instance.adapter.startSession({ threadId, cwd: h.root, runtimeMode: "full-access" }); + expect((yield* instance.snapshot.getSnapshot).workspaceSnapshots).toEqual([]); + expect( + (yield* snapshotForCwd(h.root)).slashCommands.some((entry) => entry.name === "plan"), + ).toBe(true); + yield* instance.adapter.stopSession(threadId); + const workspaces = Array.from({ length: MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER }, (_, index) => + path.join(h.root, `workspace-${index}`), + ); + for (const cwd of workspaces) { + yield* fs.makeDirectory(cwd); + yield* snapshotForCwd(cwd); + } + expect( + (yield* instance.snapshot.getSnapshot).workspaceSnapshots?.map((entry) => entry.cwd), + ).toEqual(workspaces); + const refreshed = yield* instance.snapshot.refresh; + expect(refreshed.workspaceSnapshots?.map((entry) => entry.cwd)).toEqual(workspaces); + const recent = workspaces[0]!; + yield* snapshotForCwd(recent); + const rediscovered = yield* snapshotForCwd(h.root); + expect(rediscovered.workspaceSnapshots?.map((entry) => entry.cwd)).toEqual([ + ...workspaces.slice(2), + recent, + h.root, + ]); + expect(rediscovered.slashCommands.some((entry) => entry.name === "plan")).toBe(false); + }).pipe(Effect.provide(driverLayer)), +); + +it.effect("refreshes account models and workspace skills and clears metadata after logout", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-devin-auth-" }); + const statusFile = path.join(root, "status.txt"); + const modelsFile = path.join(root, "models.json"); + yield* fs.writeFileString(statusFile, "Logged in (via Devin)."); + const catalog = + '{"families":[{"slug":"devin-test","family_label":"Devin Test","variants":[{"model_uid":"devin-test-low","label":"Devin Test Low"},{"model_uid":"devin-test-high","label":"Devin Test High"}]}]}'; + yield* fs.writeFileString(modelsFile, catalog); + const h = yield* makeHarness({ + T3_ACP_DEVIN: "1", + T3_ACP_DEVIN_STALE_MODELS: "1", + T3_DEVIN_MODELS_FILE: modelsFile, + }); + const instance = yield* DevinDriver.create({ + instanceId, + displayName: "Devin test account", + enabled: true, + config: { ...h.settings, customModels: ["custom-devin-model"] }, + environment: [ + { name: "T3_DEVIN_AUTH_STATUS_FILE", value: statusFile, sensitive: false }, + { name: "WINDSURF_API_KEY", value: "", sensitive: true }, + ], + }); + if (!instance.snapshotForCwd) throw new Error("Devin must expose workspace metadata."); + const snapshot = yield* instance.snapshot.refresh; + expect(snapshot.supportsTextGeneration).toBe(false); + const skillsFile = path.join(h.root, "devin-test-skills.json"); + yield* fs.writeFileString(skillsFile, devinTestSkills); + const workspace = yield* instance.snapshotForCwd(h.root); + expect(workspace.skills).toEqual([ + expect.objectContaining({ name: "broken", enabled: false }), + expect.objectContaining({ name: "internal", userInvocable: false }), + expect.objectContaining({ + name: "visual-check", + displayName: "Visual check", + path: path.join("/skills/visual-check", "SKILL.md"), + enabled: true, + userInvocable: true, + userInvocationOnly: true, + }), + ]); + const other = yield* fs.makeTempDirectoryScoped({ prefix: "t3-devin-other-" }); + expect((yield* instance.snapshotForCwd(other)).skills).toEqual([]); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.status).toBe("ready"); + expect(yield* fs.exists(h.launchLog)).toBe(false); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "devin-test", + "custom-devin-model", + ]); + yield* instance.adapter.startSession({ + threadId, + cwd: h.root, + runtimeMode: "approval-required", + }); + const activeSnapshot = yield* instance.snapshotForCwd(h.root); + expect( + activeSnapshot.workspaceSnapshots?.find((entry) => entry.cwd === h.root)?.skills, + ).toEqual(workspace.skills); + expect(activeSnapshot.models).toEqual(snapshot.models); + expect( + activeSnapshot.slashCommands.find((command) => command.name === "plan")?.input?.hint, + ).toBe("[prompt]"); + expect( + (yield* instance.snapshotForCwd(h.root)).slashCommands.some( + (command) => command.name === "plan", + ), + ).toBe(true); + yield* fs.writeFileString(modelsFile, "invalid response"); + const failedRefresh = yield* instance.snapshot.refresh; + expect(failedRefresh.status).toBe("warning"); + expect(failedRefresh.auth.status).toBe("authenticated"); + expect(failedRefresh.message).toContain("Could not load Devin models"); + expect(failedRefresh.models).toEqual(snapshot.models); + yield* fs.writeFileString(skillsFile, "invalid response"); + expect(yield* instance.snapshotForCwd(h.root).pipe(Effect.flip)).toMatchObject({ + _tag: "ProviderDriverError", + }); + yield* fs.writeFileString(skillsFile, "[]"); + expect( + (yield* instance.snapshot.refresh).workspaceSnapshots?.find((entry) => entry.cwd === h.root) + ?.skills, + ).toEqual([]); + yield* fs.writeFileString(modelsFile, '{"families":[]}'); + expect((yield* instance.snapshot.refresh).models.map((model) => model.slug)).toEqual([ + "custom-devin-model", + ]); + yield* fs.writeFileString(modelsFile, catalog); + expect((yield* instance.snapshot.refresh).models).toEqual(snapshot.models); + for (const status of ["Unauthenticated", "Not authenticated", "Not logged in."]) { + yield* fs.writeFileString(statusFile, status); + yield* instance.snapshot.refresh; + const signedOut = yield* instance.snapshotForCwd(h.root); + expect(signedOut.auth.status).toBe("unauthenticated"); + expect(signedOut.models.map((model) => model.slug)).toEqual(["custom-devin-model"]); + expect(signedOut.workspaceSnapshots).toEqual([]); + expect(signedOut.slashCommands.some((command) => command.name === "plan")).toBe(false); + } + }).pipe(Effect.provide(driverLayer)), +); + +it.effect("refreshes skills with bounded concurrency without reordering workspace metadata", () => + Effect.gen(function* () { + const h = yield* makeHarness({ T3_DEVIN_AUTH_STATUS: "Logged in (via Devin)." }); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const instance = yield* DevinDriver.create({ + instanceId, + displayName: "Devin test", + enabled: true, + config: h.settings, + environment: [], + }); + const snapshotForCwd = instance.snapshotForCwd; + if (!snapshotForCwd) throw new Error("Devin must expose workspace metadata."); + yield* instance.snapshot.refresh; + const workspaces = Array.from({ length: 5 }, (_, i) => path.join(h.root, `workspace-${i}`)); + for (const cwd of workspaces) { + yield* fs.makeDirectory(cwd); + yield* snapshotForCwd(cwd); + } + const gates = yield* Effect.forEach(workspaces, () => Deferred.make()); + const firstBatch = yield* Deferred.make(); + const lastStarted = yield* Deferred.make(); + let active = 0; + let peak = 0; + let started = 0; + yield* Effect.acquireRelease( + Effect.sync(() => + vi.spyOn(DevinSkills, "discoverDevinSkills").mockImplementation((_settings, _env, cwd) => + Effect.gen(function* () { + active++; + peak = Math.max(peak, active); + if (++started === 4) yield* Deferred.succeed(firstBatch, undefined); + if (started === 5) yield* Deferred.succeed(lastStarted, undefined); + yield* Deferred.await(gates[workspaces.indexOf(cwd)]!); + return [{ name: "refreshed", path: path.join(cwd, "SKILL.md"), enabled: true }]; + }).pipe( + Effect.ensuring( + Effect.sync(() => { + active--; + }), + ), + ), + ), + ), + (spy) => Effect.sync(() => spy.mockRestore()), + ); + const refresh = yield* instance.snapshot.refresh.pipe(Effect.forkScoped); + yield* Deferred.await(firstBatch); + expect(started).toBe(4); + expect(active).toBe(4); + yield* Deferred.succeed(gates[3]!, undefined); + yield* Deferred.await(lastStarted); + for (const index of [4, 2, 1, 0]) yield* Deferred.succeed(gates[index]!, undefined); + const snapshot = yield* Fiber.join(refresh); + expect(peak).toBe(4); + expect(snapshot.workspaceSnapshots?.map(({ cwd }) => cwd)).toEqual(workspaces); + expect( + snapshot.workspaceSnapshots?.every(({ skills }) => skills[0]?.name === "refreshed"), + ).toBe(true); + }).pipe(Effect.provide(driverLayer)), +); diff --git a/apps/server/src/provider/Drivers/DevinDriver.ts b/apps/server/src/provider/Drivers/DevinDriver.ts new file mode 100644 index 000000000000..81d8d37603a5 --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinDriver.ts @@ -0,0 +1,259 @@ +import { + DevinSettings, + ProviderDriverKind, + TextGenerationError, + type ServerProviderModel, + type ServerProviderSkill, + type ServerProviderSlashCommand, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeDevinAdapter } from "../Layers/DevinAdapter.ts"; +import { checkDevinProviderStatus, initialDevinProviderSnapshot } from "../Layers/DevinProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, +} from "../providerUpdateSettings.ts"; +import { withInstanceIdentity } from "./instanceIdentity.ts"; +import { discoverDevinSkills } from "./DevinSkills.ts"; + +const DRIVER = ProviderDriverKind.make("devin"); +const unsupportedTextGeneration = (operation: keyof ProviderInstance["textGeneration"]) => + Effect.fail( + new TextGenerationError({ + operation, + detail: + "Devin does not support background text generation. Choose another provider in Settings → General.", + }), + ); +const decodeSettings = Schema.decodeSync(DevinSettings); +const maintenance = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER, + packageName: null, +}); + +export type DevinDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +export const DevinDriver: ProviderDriver = { + driverKind: DRIVER, + metadata: { displayName: "Devin", supportsMultipleInstances: true }, + configSchema: DevinSettings, + defaultConfig: () => decodeSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const settings = { ...config, enabled }; + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER, + instanceId, + }); + const stamp = withInstanceIdentity({ + instanceId, + driverKind: DRIVER, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const metadataChanges = yield* Effect.acquireRelease( + PubSub.unbounded(), + PubSub.shutdown, + ); + type WorkspaceMetadata = { + skills?: ReadonlyArray; + slashCommands?: ReadonlyArray; + }; + const metadataByCwd = new Map(); + const updateWorkspaceMetadata = (cwd: string, update: WorkspaceMetadata) => { + const previous = metadataByCwd.get(cwd); + metadataByCwd.delete(cwd); + metadataByCwd.set(cwd, { ...previous, ...update }); + if (metadataByCwd.size > MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER) { + const oldest = metadataByCwd.keys().next().value; + if (oldest !== undefined) metadataByCwd.delete(oldest); + } + }; + const probeSkills = (cwd: string) => + discoverDevinSkills(settings, processEnv, cwd).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: `Could not discover Devin skills for '${cwd}'.`, + cause, + }), + ), + ); + const refreshWorkspaceSkills = Effect.gen(function* () { + const results = yield* Effect.forEach( + [...metadataByCwd].filter(([, metadata]) => metadata.skills !== undefined), + ([cwd]) => + probeSkills(cwd).pipe( + Effect.map((skills) => ({ cwd, skills })), + Effect.catch((cause) => Effect.logWarning(cause.message).pipe(Effect.as(undefined))), + ), + { concurrency: 4 }, + ); + // Apply the batch together without resurrecting evicted entries or reordering the LRU. + for (const result of results) { + if (!result) continue; + const metadata = metadataByCwd.get(result.cwd); + if (metadata) metadataByCwd.set(result.cwd, { ...metadata, skills: result.skills }); + } + }); + let lastKnownModels: ReadonlyArray = []; + const adapter = yield* makeDevinAdapter(settings, { + instanceId, + environment: processEnv, + nativeEventLogger: eventLoggers.native, + onAvailableCommands: (commands, cwd) => + Effect.gen(function* () { + const next = commands.map((command) => ({ + name: command.name, + description: command.description, + ...(command.input ? { input: command.input } : {}), + })); + const unchanged = Equal.equals(metadataByCwd.get(cwd)?.slashCommands, next); + updateWorkspaceMetadata(cwd, { slashCommands: next }); + if (unchanged) return; + yield* PubSub.publish(metadataChanges, undefined); + }), + }); + const snapshotSettings = makeProviderSnapshotSettingsSource(settings, serverSettings); + const managed = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenance), + ...snapshotSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (current) => + initialDevinProviderSnapshot(current.provider).pipe(Effect.map(stamp)), + checkProvider: Effect.suspend(() => + checkDevinProviderStatus(settings, processEnv, lastKnownModels), + ).pipe( + Effect.tap((snapshot) => + Effect.sync(() => { + lastKnownModels = + snapshot.auth.status === "authenticated" + ? snapshot.models.filter((model) => !model.isCustom) + : []; + if (snapshot.auth.status !== "authenticated") { + metadataByCwd.clear(); + } + }), + ), + Effect.map(stamp), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Could not initialize Devin provider status.", + cause, + }), + ), + ); + const withWorkspaceMetadata = (snapshot: Parameters[0]) => ({ + ...stamp(snapshot), + // A workspace snapshot marks discovery complete in ProviderRegistry. + // ACP commands can arrive first; don't cache an unprobed empty skill list. + workspaceSnapshots: (snapshot.auth.status === "authenticated" + ? [...metadataByCwd] + : [] + ).flatMap(([cwd, metadata]) => + metadata.skills === undefined + ? [] + : [ + { + cwd, + slashCommands: metadata.slashCommands ?? snapshot.slashCommands, + skills: metadata.skills, + checkedAt: snapshot.checkedAt, + }, + ], + ), + }); + const getSnapshot = managed.getSnapshot.pipe(Effect.map(withWorkspaceMetadata)); + return { + instanceId, + driverKind: DRIVER, + continuationIdentity, + displayName, + accentColor, + enabled, + adapter, + textGeneration: { + generateCommitMessage: () => unsupportedTextGeneration("generateCommitMessage"), + generatePrContent: () => unsupportedTextGeneration("generatePrContent"), + generateBranchName: () => unsupportedTextGeneration("generateBranchName"), + generateThreadTitle: () => unsupportedTextGeneration("generateThreadTitle"), + }, + snapshot: { + ...managed, + getSnapshot, + refresh: managed.refresh.pipe( + Effect.tap((snapshot) => + snapshot.auth.status === "authenticated" ? refreshWorkspaceSkills : Effect.void, + ), + Effect.map(withWorkspaceMetadata), + ), + streamChanges: Stream.merge( + managed.streamChanges.pipe(Stream.map(withWorkspaceMetadata)), + Stream.fromPubSub(metadataChanges).pipe(Stream.mapEffect(() => getSnapshot)), + ), + }, + snapshotForCwd: (cwd) => + Effect.gen(function* () { + const snapshot = yield* getSnapshot; + if (!enabled || snapshot.auth.status !== "authenticated") return snapshot; + const skills = yield* probeSkills(cwd); + updateWorkspaceMetadata(cwd, { skills }); + return { + ...withWorkspaceMetadata(snapshot), + skills, + slashCommands: metadataByCwd.get(cwd)?.slashCommands ?? snapshot.slashCommands, + }; + }), + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/DevinSkills.ts b/apps/server/src/provider/Drivers/DevinSkills.ts new file mode 100644 index 000000000000..804be3c8293b --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinSkills.ts @@ -0,0 +1,83 @@ +import type { DevinSettings, ServerProviderSkill } from "@t3tools/contracts"; +import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { runDevinCommand } from "../acp/DevinAcpSupport.ts"; + +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), + }), + ), +); +const decodeCatalog = Schema.decodeEffect(DevinSkillCatalog); +class DevinSkillsError extends Schema.TaggedError()("DevinSkillsError", { + message: Schema.String, +}) {} + +/** Let the CLI resolve skill roots, overrides, plugins, and invocation policy. */ +export const discoverDevinSkills = Effect.fn("discoverDevinSkills")(function* ( + settings: DevinSettings, + environment: NodeJS.ProcessEnv, + cwd: string, +) { + const path = yield* Path.Path; + const result = yield* runDevinCommand(settings, environment, ["skills", "list", "--json"], cwd); + if (result.code !== 0) + return yield* new DevinSkillsError({ message: "Devin CLI could not list skills." }); + const catalog = yield* decodeCatalog(result.stdout); + // Built-in CLI commands have no SKILL.md; they belong in the native command menu. + return catalog + .filter((skill) => skill.base_dir.length > 0) + .map((skill): ServerProviderSkill => ({ + // Devin invokes skills by their directory name, not frontmatter name. + 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"), + ...(skill.description.trim() ? { description: skill.description.trim() } : {}), + ...(skill.display_name.trim() ? { displayName: skill.display_name.trim() } : {}), + })) + .sort((left, right) => left.name.localeCompare(right.name)); +}, Effect.timeout("5 seconds")); + +/** ACP expands one leading slash command. Inline mentions do not invoke user-only skills. */ +export const prepareDevinSkillPrompt = Effect.fn("prepareDevinSkillPrompt")(function* ( + prompt: string, + settings: DevinSettings, + environment: NodeJS.ProcessEnv, + cwd: string, +) { + const candidates = collectComposerInlineTokens(`${prompt} `).filter( + (token) => token.type === "skill", + ); + if (candidates.length === 0) return prompt; + const skills = yield* discoverDevinSkills(settings, environment, cwd).pipe( + Effect.catch((cause) => + Effect.logWarning("Devin skill discovery failed; sending the original prompt", cause).pipe( + Effect.as([]), + ), + ), + ); + const names = new Set( + skills.filter((skill) => skill.enabled && skill.userInvocable).map((skill) => skill.name), + ); + const mentions = candidates.filter((token) => names.has(token.value)); + if (mentions.length > 1) + return yield* new DevinSkillsError({ + message: "Devin can invoke one skill per message. Send each skill in a separate message.", + }); + const mention = mentions[0]; + if (!mention) return prompt; + const argumentsText = `${prompt.slice(0, mention.start)}${prompt.slice(mention.end)}`.trim(); + return `/${mention.value}${argumentsText ? ` ${argumentsText}` : ""}`; +}); diff --git a/apps/server/src/provider/Layers/AntigravityProvider.ts b/apps/server/src/provider/Layers/AntigravityProvider.ts index 956a5d81d3f4..842fc194097d 100644 --- a/apps/server/src/provider/Layers/AntigravityProvider.ts +++ b/apps/server/src/provider/Layers/AntigravityProvider.ts @@ -135,6 +135,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); const initialDraft = { + modelPolicy: { catalogScope: "instance" as const, preserveUnavailableModels: true }, ...buildServerProvider({ presentation: { displayName: "Antigravity", showInteractionModeToggle: false }, enabled: settings.enabled, diff --git a/apps/server/src/provider/Layers/DevinAdapter.test.ts b/apps/server/src/provider/Layers/DevinAdapter.test.ts new file mode 100644 index 000000000000..27b0d18b70c8 --- /dev/null +++ b/apps/server/src/provider/Layers/DevinAdapter.test.ts @@ -0,0 +1,639 @@ +import * as Schema from "effect/Schema"; +import { expect, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; +import * as Effect from "effect/Effect"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { + ApprovalRequestId, + EnvironmentId, + ThreadId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as AcpErrors from "effect-acp/errors"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { ServerConfig } from "../../config.ts"; +import { makeDevinAdapter } from "./DevinAdapter.ts"; +import * as DevinAcpSupport from "../acp/DevinAcpSupport.ts"; +import type { AcpSessionRuntimeEvent } from "../acp/AcpSessionRuntime.ts"; +import { + makeDevinCli, + devinTestLayer as layer, + decodeDevinLaunch as decodeLaunch, + devinTestSkills, +} from "../testUtils/devinCli.ts"; + +const threadId = ThreadId.make("devin-thread"); +const instanceId = ProviderInstanceId.make("devin-account"); +const RequestLog = Schema.Struct({ + event: Schema.Struct({ + kind: Schema.Literal("request"), + payload: Schema.Struct({ method: Schema.String, status: Schema.String }), + }), +}); +const isRequestLog = Schema.is(RequestLog); +const makeHarness = Effect.fn("makeDevinAdapterHarness")(function* ( + env: Parameters[0] = {}, + onRequest?: (request: typeof RequestLog.Type.event.payload) => Effect.Effect, +) { + const cli = yield* makeDevinCli(env); + const { settings, environment, root } = cli; + const adapter = yield* makeDevinAdapter(settings, { + instanceId, + environment, + nativeEventLogger: { + filePath: cli.requestLog, + close: () => Effect.void, + write: (event) => + isRequestLog(event) ? (onRequest?.(event.event.payload) ?? Effect.void) : Effect.void, + }, + }); + const events: ProviderRuntimeEvent[] = []; + const firstPrompt = yield* Deferred.make(); + const approval = + yield* Deferred.make>(); + yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + events.push(event); + if (event.type === "request.opened") yield* Deferred.succeed(approval, event); + if (event.type === "thread.token-usage.updated") + yield* Deferred.succeed(firstPrompt, undefined); + }), + ).pipe(Effect.forkScoped({ startImmediately: true })); + const start = (model = "devin-test-low") => + adapter.startSession({ + threadId, + cwd: root, + runtimeMode: "approval-required", + modelSelection: { instanceId, model }, + }); + return { ...cli, adapter, events, approval, firstPrompt, start }; +}); + +const registerT3Tools = Effect.acquireRelease( + Effect.sync(() => + McpProviderSession.setMcpProviderSession({ + environmentId: EnvironmentId.make("test-environment"), + threadId, + providerSessionId: "test-session", + providerInstanceId: instanceId, + endpoint: "http://127.0.0.1:1234/mcp", + authorizationHeader: "Bearer test-only", + capabilities: new Set(["preview", "device"]), + agentDeviceEnvironment: { T3_TEST_DEVICE: "available" }, + }), + ), + () => Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)), +); + +it.effect("streams a turn and loads the saved ACP session ID without authenticating", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const session = yield* h.start(); + expect(session.providerInstanceId).toBe(instanceId); + const result = yield* h.adapter.sendTurn({ threadId, input: "Say hello" }); + expect(result.resumeCursor).toEqual(session.resumeCursor); + yield* h.adapter.stopSession(threadId); + expect(yield* h.adapter.hasSession(threadId)).toBe(false); + yield* h.adapter.startSession({ + threadId, + cwd: h.root, + runtimeMode: "full-access", + resumeCursor: session.resumeCursor, + }); + const requests = yield* h.requests; + const config = yield* ServerConfig; + for (const method of ["session/new", "session/load"]) { + expect( + requests.find((request) => request.method === method)?.params?.additionalDirectories, + ).toEqual([config.attachmentsDir]); + } + expect(requests.some((request) => request.method === "authenticate")).toBe(false); + expect( + requests.some( + (request) => + request.method === "session/load" && request.params?.sessionId === "mock-session-1", + ), + ).toBe(true); + expect( + requests + .filter((request) => request.method === "session/set_mode") + .map((request) => request.params?.modeId), + ).toEqual(["normal", "normal", "bypass"]); + expect( + h.events.some((event) => event.type === "content.delta" && event.payload.delta.length > 0), + ).toBe(true); + expect(h.events.filter((event) => event.type === "turn.completed")).toHaveLength(1); + }).pipe(Effect.provide(layer)), +); + +it.effect("connects T3 tools with isolated credentials and restores tool roots on resume", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig; + yield* registerT3Tools; + const session = yield* h.start(); + const firstDirectory = (yield* h.requests).find((request) => request.method === "session/new") + ?.params?.additionalDirectories?.[1]; + if (!firstDirectory) throw new Error("Devin must receive its MCP configuration root."); + yield* h.adapter.stopSession(threadId); + expect(yield* fs.exists(firstDirectory)).toBe(false); + yield* h.adapter.startSession({ + threadId, + cwd: h.root, + runtimeMode: "approval-required", + resumeCursor: session.resumeCursor, + }); + const requests = yield* h.requests; + for (const method of ["session/new", "session/load"]) { + const request = requests.find((request) => request.method === method); + expect(request?.params?.cwd).toBe(h.root); + expect(request?.params?.additionalDirectories).toHaveLength(2); + expect(request?.params?.additionalDirectories?.[0]).toBe(config.attachmentsDir); + expect( + requests.some( + (entry) => + entry.method === "_cognition.ai/mcp/connectServer" && + entry.params?.workspaceDirs?.[0] === request?.params?.additionalDirectories?.[1], + ), + ).toBe(true); + } + const directory = requests.find((request) => request.method === "session/load")?.params + ?.additionalDirectories?.[1]; + if (!directory) throw new Error("Resumed Devin sessions must receive fresh MCP configuration."); + expect(directory).not.toBe(firstDirectory); + const path = yield* Path.Path; + const configFile = path.join(directory, ".devin", "mcp_config.local.json"); + const decodeConfig = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + mcpServers: Schema.Struct({ + "t3-code": Schema.Struct({ + serverUrl: Schema.String, + headers: Schema.Struct({ Authorization: Schema.String }), + }), + }), + }), + ), + ); + expect( + (yield* decodeConfig(yield* fs.readFileString(configFile))).mcpServers["t3-code"], + ).toEqual({ + serverUrl: "http://127.0.0.1:1234/mcp", + headers: { Authorization: "Bearer test-only" }, + }); + yield* h.adapter.stopSession(threadId); + expect(yield* fs.exists(directory)).toBe(false); + const launches = (yield* fs.readFileString(h.launchLog)) + .trim() + .split("\n") + .map((line) => decodeLaunch(line)); + expect(launches.map((launch) => launch.device)).toEqual(["available", "available"]); + }).pipe(Effect.provide(layer)), +); + +it.effect("dispatches a graphical skill pick as a native command with its arguments", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skillsFile = path.join(h.root, "devin-test-skills.json"); + yield* fs.writeFileString(skillsFile, devinTestSkills); + yield* h.start(); + for (const [input, text] of [ + ["$visual-check", "/visual-check"], + ["Please $visual-check inspect localhost", "/visual-check Please inspect localhost"], + [ + "$visual-check\nKeep $HOME and $20k unchanged", + "/visual-check Keep $HOME and $20k unchanged", + ], + ["$internal", "$internal"], + ["/visual-check", "/visual-check"], + ]) { + yield* h.adapter.sendTurn({ threadId, input }); + expect( + (yield* h.requests).findLast((request) => request.method === "session/prompt")?.params + ?.prompt?.[0], + ).toEqual({ type: "text", text }); + } + expect( + yield* h.adapter + .sendTurn({ threadId, input: "$visual-check $visual-check" }) + .pipe(Effect.flip), + ).toMatchObject({ _tag: "ProviderAdapterRequestError" }); + const config = yield* ServerConfig; + const attachmentId = "devin-thread-11111111-1111-4111-8111-111111111111"; + yield* h.adapter.sendTurn({ + threadId, + input: "$visual-check inspect this", + attachments: [ + { type: "file", id: attachmentId, name: "page.txt", mimeType: "text/plain", sizeBytes: 1 }, + ], + }); + const withFile = (yield* h.requests).findLast((entry) => entry.method === "session/prompt"); + expect(withFile?.params?.prompt).toEqual([ + { + type: "text", + text: `/visual-check inspect this Attached file: ${path.join(config.attachmentsDir, `${attachmentId}.txt`)}`, + }, + ]); + yield* fs.writeFileString(skillsFile, "invalid response"); + yield* h.adapter.sendTurn({ threadId, input: "Print $HOME" }); + expect( + (yield* h.requests).findLast((request) => request.method === "session/prompt")?.params + ?.prompt?.[0], + ).toEqual({ type: "text", text: "Print $HOME" }); + expect( + h.events.some( + (event) => event.type === "turn.completed" && event.payload.state === "completed", + ), + ).toBe(true); + }).pipe(Effect.provide(layer)), +); + +it.effect("cleans up MCP credentials when the tool server cannot connect", () => + Effect.gen(function* () { + const h = yield* makeHarness({ T3_ACP_DEVIN_MCP_STATUS: "auth_required" }); + const fs = yield* FileSystem.FileSystem; + yield* registerT3Tools; + const error = yield* h.start().pipe(Effect.flip); + expect(error.message).toContain("Devin could not connect to T3 Code tools."); + expect(yield* h.adapter.hasSession(threadId)).toBe(false); + const directory = (yield* h.requests).find((request) => request.method === "session/new") + ?.params?.additionalDirectories?.[1]; + if (!directory) throw new Error("The failed session must have attempted MCP setup."); + expect(yield* fs.exists(directory)).toBe(false); + }).pipe(Effect.provide(layer)), +); + +for (const operation of ["startSession", "sendTurn"] as const) { + it.effect(`rejects ${operation} when a disconnect is consumed before returning`, () => + Effect.gen(function* () { + const makeRuntime = DevinAcpSupport.makeDevinAcpRuntime; + yield* Effect.acquireRelease( + Effect.sync(() => + vi.spyOn(DevinAcpSupport, "makeDevinAcpRuntime").mockImplementation((...args) => + Effect.gen(function* () { + const runtime = yield* makeRuntime(...args); + const events = yield* Queue.unbounded(); + const closed = yield* Deferred.make(); + yield* Effect.addFinalizer(() => Deferred.succeed(closed, undefined)); + let drains = 0; + return { + ...runtime, + getEvents: () => Stream.fromQueue(events), + drainEvents: Effect.gen(function* () { + if (++drains === (operation === "startSession" ? 1 : 2)) { + yield* Queue.offer(events, { + _tag: "ConnectionTerminated", + error: new AcpErrors.AcpTransportError({ + detail: "Devin process disconnected.", + cause: undefined, + }), + }); + } + const acknowledge = yield* Deferred.make(); + yield* Queue.offer(events, { _tag: "EventStreamBarrier", acknowledge }); + yield* Effect.raceFirst(Deferred.await(acknowledge), Deferred.await(closed)); + }), + }; + }), + ), + ), + (spy) => Effect.sync(() => spy.mockRestore()), + ); + const h = yield* makeHarness(); + if (operation === "sendTurn") yield* h.start(); + const error = yield* ( + operation === "startSession" + ? h.start().pipe(Effect.asVoid) + : h.adapter.sendTurn({ threadId, input: "Hello" }).pipe(Effect.asVoid) + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "ProviderAdapterSessionClosedError", + provider: "devin", + threadId, + }); + expect(yield* h.adapter.hasSession(threadId)).toBe(false); + }).pipe(Effect.provide(layer)), + ); +} + +it.effect("applies family thinking choices and enters and leaves plan mode", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.start("devin-test"); + yield* h.adapter.sendTurn({ + threadId, + input: "Plan it", + interactionMode: "plan", + modelSelection: { + instanceId, + model: "devin-test", + options: [{ id: "reasoningEffort", value: "high" }], + }, + }); + yield* h.adapter.sendTurn({ + threadId, + input: "Implement it", + interactionMode: "default", + modelSelection: { instanceId, model: "devin-test-low" }, + }); + expect((yield* h.adapter.listSessions())[0]?.model).toBe("devin-test-low"); + expect(h.events.find((event) => event.type === "thread.token-usage.updated")?.payload).toEqual({ + usage: { usedTokens: 1800, maxTokens: 200000 }, + }); + const requests = yield* h.requests; + expect( + requests + .filter( + (request) => + request.method === "session/set_config_option" && request.params?.configId === "model", + ) + .map((request) => request.params?.value), + ).toEqual(["devin-test-high", "devin-test-low"]); + expect( + requests + .filter((request) => request.method === "session/set_mode") + .map((request) => request.params?.modeId), + ).toEqual(["normal", "plan", "normal"]); + }).pipe(Effect.provide(layer)), +); + +it.effect("resolves native approval option IDs and streams the completed tool", () => + Effect.gen(function* () { + const h = yield* makeHarness({ + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_ALLOW_ONCE_OPTION_ID: "devin:approve:42", + }); + yield* h.start(); + const turn = yield* h.adapter + .sendTurn({ threadId, input: "Read metadata" }) + .pipe(Effect.forkScoped); + const request = yield* Deferred.await(h.approval); + expect(request.payload.options?.map((option) => option.decision)).toEqual([ + "accept", + "acceptForSession", + "decline", + ]); + yield* h.adapter.respondToRequest( + threadId, + ApprovalRequestId.make(request.requestId ?? "missing-request-id"), + "accept", + ); + yield* Fiber.join(turn); + expect( + h.events.some( + (event) => event.type === "request.resolved" && event.payload.decision === "accept", + ), + ).toBe(true); + expect( + (yield* h.requests).some( + (request) => request.result?.outcome?.optionId === "devin:approve:42", + ), + ).toBe(true); + expect( + h.events.some( + (event) => + event.type === "item.completed" && event.payload.itemType === "command_execution", + ), + ).toBe(true); + }).pipe(Effect.provide(layer)), +); + +it.effect("cancels while waiting for permission and returns the session to ready", () => + Effect.gen(function* () { + const h = yield* makeHarness({ T3_ACP_EMIT_TOOL_CALLS: "1" }); + yield* h.start(); + const turn = yield* h.adapter + .sendTurn({ threadId, input: "Read metadata" }) + .pipe(Effect.forkScoped); + yield* Deferred.await(h.approval); + yield* h.adapter.interruptTurn(threadId); + yield* Fiber.join(turn); + expect( + h.events.some( + (event) => event.type === "turn.completed" && event.payload.state === "cancelled", + ), + ).toBe(true); + expect((yield* h.adapter.listSessions())[0]?.status).toBe("ready"); + }).pipe(Effect.provide(layer)), +); + +it.effect("reports prompt failures and refuses a model from another instance", () => + Effect.gen(function* () { + const h = yield* makeHarness({ T3_ACP_FAIL_PROMPT: "1" }); + yield* h.start(); + const other = yield* h.adapter + .sendTurn({ + threadId, + input: "No", + modelSelection: { + instanceId: ProviderInstanceId.make("another-account"), + model: "default", + }, + }) + .pipe(Effect.result); + expect(other._tag).toBe("Failure"); + const failed = yield* h.adapter.sendTurn({ threadId, input: "Fail" }).pipe(Effect.result); + expect(failed._tag).toBe("Failure"); + expect( + h.events.some((event) => event.type === "turn.completed" && event.payload.state === "failed"), + ).toBe(true); + }).pipe(Effect.provide(layer)), +); + +it.effect("settles a steered turn when the replacement model is unavailable", () => + Effect.gen(function* () { + const h = yield* makeHarness({ + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_REJECT_MODEL: "unavailable-model", + }); + yield* h.start(); + const first = yield* h.adapter + .sendTurn({ threadId, input: "Read metadata" }) + .pipe(Effect.forkScoped); + yield* Deferred.await(h.approval); + const replacement = yield* h.adapter + .sendTurn({ + threadId, + input: "Use another model", + modelSelection: { instanceId, model: "unavailable-model" }, + }) + .pipe(Effect.result); + expect(replacement._tag).toBe("Failure"); + expect( + (yield* h.requests).filter((request) => request.method === "session/prompt"), + ).toHaveLength(1); + yield* Fiber.join(first); + expect((yield* h.adapter.listSessions())[0]?.activeTurnId).toBeUndefined(); + expect(h.events.filter((event) => event.type === "turn.completed")).toHaveLength(1); + expect( + h.events.some((event) => event.type === "turn.completed" && event.payload.state === "failed"), + ).toBe(true); + }).pipe(Effect.provide(layer)), +); + +it.effect("stopping one adapter leaves another adapter session running", () => + Effect.gen(function* () { + const first = yield* makeHarness(); + const second = yield* makeHarness(); + yield* first.start(); + yield* second.start(); + yield* first.adapter.stopAll(); + expect(yield* first.adapter.hasSession(threadId)).toBe(false); + expect(yield* second.adapter.hasSession(threadId)).toBe(true); + yield* second.adapter.sendTurn({ threadId, input: "Still here" }); + expect((yield* second.adapter.listSessions())[0]?.status).toBe("ready"); + }).pipe(Effect.provide(layer)), +); + +it.effect("stopping an active session settles its turn before reopening the thread", () => + Effect.gen(function* () { + const h = yield* makeHarness({ T3_ACP_EMIT_TOOL_CALLS: "1" }); + yield* h.start(); + const turn = yield* h.adapter + .sendTurn({ threadId, input: "Read metadata" }) + .pipe(Effect.forkScoped); + yield* Deferred.await(h.approval); + yield* h.adapter.stopSession(threadId); + yield* Fiber.await(turn); + expect(yield* h.adapter.hasSession(threadId)).toBe(false); + yield* h.start(); + expect((yield* h.adapter.listSessions())[0]?.status).toBe("ready"); + expect(h.events.filter((event) => event.type === "turn.completed")).toHaveLength(1); + }).pipe(Effect.provide(layer)), +); + +it.effect("Stop waits for a steering launch and cancels its replacement prompt", () => + Effect.gen(function* () { + const modeHeld = yield* Deferred.make(); + const releaseMode = yield* Deferred.make(); + const secondPrompt = yield* Deferred.make(); + let modeCount = 0; + let promptCount = 0; + const h = yield* makeHarness({ T3_ACP_WAIT_FOR_CANCEL: "1" }, ({ method, status }) => + Effect.gen(function* () { + if (status !== "started") return; + if (method === "session/set_mode" && ++modeCount === 3) { + yield* Deferred.succeed(modeHeld, undefined); + yield* Deferred.await(releaseMode); + } + if (method === "session/prompt" && ++promptCount === 2) + yield* Deferred.succeed(secondPrompt, undefined); + }), + ); + const { adapter, firstPrompt } = h; + yield* h.start(); + const first = yield* adapter.sendTurn({ threadId, input: "First" }).pipe(Effect.forkScoped); + yield* Deferred.await(firstPrompt); + const replacement = yield* adapter + .sendTurn({ threadId, input: "Steer" }) + .pipe(Effect.forkScoped); + yield* Deferred.await(modeHeld); + const stop = yield* adapter + .interruptTurn(threadId) + .pipe(Effect.forkScoped({ startImmediately: true })); + yield* Deferred.succeed(releaseMode, undefined); + yield* Deferred.await(secondPrompt); + yield* Fiber.join(stop); + const methods = (yield* h.requests) + .map((request) => request.method) + .filter((method) => method === "session/prompt" || method === "session/cancel"); + expect(methods).toEqual([ + "session/prompt", + "session/cancel", + "session/prompt", + "session/cancel", + ]); + yield* Fiber.join(first); + yield* Fiber.join(replacement); + expect((yield* adapter.listSessions())[0]?.status).toBe("ready"); + }).pipe(Effect.provide(layer)), +); + +it.effect("finishes interrupted turn cleanup before allowing a replacement to launch", () => + Effect.gen(function* () { + const secondPrompt = yield* Deferred.make(); + const cancelHeld = yield* Deferred.make(); + const releaseCancel = yield* Deferred.make(); + let cancellations = 0; + let prompts = 0; + const h = yield* makeHarness({ T3_ACP_WAIT_FOR_CANCEL: "1" }, ({ method, status }) => + Effect.gen(function* () { + if (method !== "session/prompt") return; + if (status === "succeeded" && ++cancellations === 1) { + yield* Deferred.succeed(cancelHeld, undefined); + yield* Deferred.await(releaseCancel); + } + if (status === "started" && ++prompts === 2) + yield* Deferred.succeed(secondPrompt, undefined); + }), + ); + const { adapter, firstPrompt, events } = h; + yield* h.start(); + const first = yield* adapter.sendTurn({ threadId, input: "First" }).pipe(Effect.forkScoped); + yield* Deferred.await(firstPrompt); + const interrupt = yield* Fiber.interrupt(first).pipe(Effect.forkScoped); + yield* Deferred.await(cancelHeld); + const replacement = yield* adapter + .sendTurn({ threadId, input: "Replacement" }) + .pipe(Effect.forkScoped({ startImmediately: true })); + yield* Deferred.succeed(releaseCancel, undefined); + yield* Fiber.join(interrupt); + yield* Deferred.await(secondPrompt); + yield* adapter.interruptTurn(threadId); + yield* Fiber.join(replacement); + const started = events.filter((event) => event.type === "turn.started"); + const completed = events.filter((event) => event.type === "turn.completed"); + expect(started).toHaveLength(2); + expect(completed.map((event) => event.turnId)).toEqual(started.map((event) => event.turnId)); + expect(new Set(started.map((event) => event.turnId)).size).toBe(2); + }).pipe(Effect.provide(layer)), +); + +it.effect("keeps lifecycle operations ordered per thread without blocking other threads", () => + Effect.gen(function* () { + const starting = yield* Deferred.make(); + const release = yield* Deferred.make(); + let starts = 0; + const h = yield* makeHarness({}, ({ method, status }) => + Effect.gen(function* () { + if (method === "initialize" && status === "started" && ++starts === 2) { + yield* Deferred.succeed(starting, undefined); + yield* Deferred.await(release); + } + }), + ); + yield* h.start(); + const pendingThread = ThreadId.make("pending-devin-thread"); + const pending = yield* h.adapter + .startSession({ + threadId: pendingThread, + cwd: h.root, + runtimeMode: "full-access", + }) + .pipe(Effect.forkScoped); + yield* Deferred.await(starting); + const stopRequested = yield* Deferred.make(); + const stopPending = yield* Deferred.succeed(stopRequested, undefined).pipe( + Effect.andThen(h.adapter.stopSession(pendingThread)), + Effect.forkScoped, + ); + yield* Deferred.await(stopRequested); + yield* h.adapter.stopSession(threadId); + expect(yield* h.adapter.hasSession(threadId)).toBe(false); + yield* h.start(); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(pending); + yield* Fiber.join(stopPending); + expect(yield* h.adapter.hasSession(pendingThread)).toBe(false); + expect(yield* h.adapter.hasSession(threadId)).toBe(true); + }).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/provider/Layers/DevinAdapter.ts b/apps/server/src/provider/Layers/DevinAdapter.ts new file mode 100644 index 000000000000..92779060a436 --- /dev/null +++ b/apps/server/src/provider/Layers/DevinAdapter.ts @@ -0,0 +1,779 @@ +import { + ApprovalRequestId, + EventId, + ProviderDriverKind, + RuntimeRequestId, + TurnId, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + type DevinSettings, + type ProviderApprovalDecision, + type ProviderInstanceId, + type ProviderRuntimeEvent, + type ProviderSession, + type ThreadId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as RcMap from "effect/RcMap"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; +import { prepareDevinSkillPrompt } from "../Drivers/DevinSkills.ts"; +import { + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import type { ProviderThreadTurnSnapshot } from "../Services/ProviderAdapter.ts"; +import type { ProviderInstance } from "../ProviderDriver.ts"; +import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import type { AcpSessionRuntimeEvent } from "../acp/AcpSessionRuntime.ts"; +import { + applyDevinMode, + checkDevinExecutable, + makeDevinAcpRuntime, + prepareDevinMcp, + selectDevinPermissionOption, +} from "../acp/DevinAcpSupport.ts"; + +const PROVIDER = ProviderDriverKind.make("devin"); +const ResumeCursor = Schema.Struct({ sessionId: Schema.NonEmptyString }); +const isResumeCursor = Schema.is(ResumeCursor); +type Adapter = ProviderInstance["adapter"]; +const invalidInput = (operation: keyof Adapter, issue: string) => + new ProviderAdapterValidationError({ provider: PROVIDER, operation, issue }); + +interface SessionContext { + readonly runtime: Effect.Success>; + readonly scope: Scope.Closeable; + readonly sessionId: string; + readonly lock: Semaphore.Semaphore; + readonly stopLock: Semaphore.Semaphore; + readonly approvals: Map< + ApprovalRequestId, + { + readonly request: EffectAcpSchema.RequestPermissionRequest; + readonly decision: Deferred.Deferred; + } + >; + readonly turns: Array; + session: ProviderSession; + promptFiber: Fiber.Fiber | undefined; + generation: number; + stopped: boolean; +} + +export interface DevinAdapterOptions { + readonly instanceId: ProviderInstanceId; + readonly environment: NodeJS.ProcessEnv; + readonly nativeEventLogger?: EventNdjsonLogger | undefined; + readonly onAvailableCommands?: ( + commands: ReadonlyArray, + cwd: string, + ) => Effect.Effect; +} + +/** Each thread owns one ACP process. Closing an instance releases only its own sessions. */ +export const makeDevinAdapter = Effect.fn("makeDevinAdapter")(function* ( + settings: DevinSettings, + options: DevinAdapterOptions, +) { + const crypto = yield* Crypto.Crypto; + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const config = yield* ServerConfig; + const ownerScope = yield* Effect.scope; + const makeLoggers = yield* makeAcpNativeLoggerFactory(); + const sessions = new Map(); + const lifecycleLocks = yield* RcMap.make({ + lookup: (_threadId: ThreadId) => Semaphore.make(1), + }); + const withLifecycleLock = (threadId: ThreadId, effect: Effect.Effect) => + RcMap.get(lifecycleLocks, threadId).pipe( + Effect.flatMap((lock) => lock.withPermit(effect)), + Effect.scoped, + ); + const events = yield* PubSub.unbounded(); + const now = Effect.map(DateTime.now, DateTime.formatIso); + const requestError = (method: string, cause: { readonly message: string }) => + new ProviderAdapterRequestError({ provider: PROVIDER, method, detail: cause.message, cause }); + const randomId = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Could not create a Devin event ID.", + cause, + }), + ), + ); + const stamp = Effect.all({ eventId: Effect.map(randomId, EventId.make), createdAt: now }); + const emit = (event: ProviderRuntimeEvent) => PubSub.publish(events, event).pipe(Effect.asVoid); + const requireSession = (threadId: ThreadId) => { + const context = sessions.get(threadId); + return context && !context.stopped + ? Effect.succeed(context) + : Effect.fail(new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId })); + }; + const cancelApprovals = (context: SessionContext) => + Effect.forEach( + context.approvals.values(), + (pending) => Deferred.succeed(pending.decision, "cancel"), + { discard: true }, + ); + + const stopContext = Effect.fn("DevinAdapter.stopContext")(function* ( + context: SessionContext, + errorMessage?: string, + ) { + return yield* context.stopLock.withPermit( + Effect.gen(function* () { + if (context.session.status === "closed") return; + context.stopped = true; + const turnId = context.session.activeTurnId; + yield* cancelApprovals(context); + yield* Scope.close(context.scope, Exit.void); + if (sessions.get(context.session.threadId) === context) + sessions.delete(context.session.threadId); + context.session = { + ...context.session, + status: "closed", + activeTurnId: undefined, + updatedAt: yield* now, + }; + if (turnId) { + yield* emit({ + type: "turn.completed", + ...(yield* stamp), + provider: PROVIDER, + threadId: context.session.threadId, + turnId, + payload: errorMessage + ? { state: "failed", errorMessage } + : { state: "cancelled", stopReason: "cancelled" }, + }); + } + yield* emit({ + type: "session.exited", + ...(yield* stamp), + provider: PROVIDER, + threadId: context.session.threadId, + payload: { reason: errorMessage ?? "Devin session stopped" }, + }); + }), + ); + }); + + const handleEvent = Effect.fn("DevinAdapter.handleEvent")(function* ( + context: SessionContext, + event: AcpSessionRuntimeEvent, + ) { + if (event._tag === "EventStreamBarrier") { + yield* Deferred.succeed(event.acknowledge, undefined); + return; + } + if (context.stopped) return; + const identity = { + provider: PROVIDER, + threadId: context.session.threadId, + turnId: context.session.activeTurnId, + }; + switch (event._tag) { + case "ModeChanged": + return; + case "AvailableCommandsUpdated": + yield* ( + options.onAvailableCommands?.( + event.availableCommands, + context.session.cwd ?? config.cwd, + ) ?? Effect.void + ); + return; + case "ConfigOptionsUpdated": + context.session = { + ...context.session, + model: yield* context.runtime.applyModel(), + }; + return; + case "ConnectionTerminated": + context.stopped = true; + yield* stopContext(context, event.error.message).pipe(Effect.forkIn(ownerScope)); + return; + case "AssistantItemStarted": + case "AssistantItemCompleted": + yield* emit( + makeAcpAssistantItemEvent({ + ...identity, + stamp: yield* stamp, + itemId: event.itemId, + lifecycle: event._tag === "AssistantItemStarted" ? "item.started" : "item.completed", + }), + ); + return; + case "ContentDelta": + yield* emit(makeAcpContentDeltaEvent({ ...identity, stamp: yield* stamp, ...event })); + return; + case "ThoughtDelta": + yield* emit( + makeAcpContentDeltaEvent({ + ...identity, + stamp: yield* stamp, + ...event, + streamKind: "reasoning_text", + }), + ); + return; + case "ToolCallUpdated": + yield* emit(makeAcpToolCallEvent({ ...identity, stamp: yield* stamp, ...event })); + return; + case "PlanUpdated": + yield* emit( + makeAcpPlanUpdatedEvent({ + ...identity, + stamp: yield* stamp, + ...event, + source: "acp.jsonrpc", + method: "session/update", + }), + ); + return; + } + }); + + const requestPermission = Effect.fn("DevinAdapter.requestPermission")(function* ( + context: SessionContext, + request: EffectAcpSchema.RequestPermissionRequest, + ) { + const requestId = ApprovalRequestId.make(yield* randomId); + const decision = yield* Deferred.make(); + context.approvals.set(requestId, { request, decision }); + const identity = { + provider: PROVIDER, + threadId: context.session.threadId, + turnId: context.session.activeTurnId, + requestId: RuntimeRequestId.make(requestId), + permissionRequest: parsePermissionRequest(request), + }; + return yield* Effect.gen(function* () { + const choices = ["accept", "acceptForSession", "decline"] as const; + yield* emit( + makeAcpRequestOpenedEvent({ + ...identity, + stamp: yield* stamp, + approvalOptions: choices.flatMap((choice) => { + const option = selectDevinPermissionOption(request, choice); + return option ? [{ decision: choice, label: option.name }] : []; + }), + detail: + identity.permissionRequest.detail ?? + request.toolCall.title ?? + "Devin requests permission", + args: request, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: request, + }), + ); + const resolved = yield* Deferred.await(decision); + yield* emit( + makeAcpRequestResolvedEvent({ ...identity, stamp: yield* stamp, decision: resolved }), + ); + const option = selectDevinPermissionOption(request, resolved); + return { + outcome: option + ? { outcome: "selected", optionId: option.optionId } + : { outcome: "cancelled" }, + } satisfies EffectAcpSchema.RequestPermissionResponse; + }).pipe(Effect.ensuring(Effect.sync(() => context.approvals.delete(requestId)))); + }); + + const startSession: Adapter["startSession"] = (input) => + withLifecycleLock( + input.threadId, + Effect.gen(function* () { + if (input.modelSelection && input.modelSelection.instanceId !== options.instanceId) { + return yield* invalidInput( + "startSession", + "The selected model belongs to another provider instance.", + ); + } + if (input.resumeCursor !== undefined && !isResumeCursor(input.resumeCursor)) { + return yield* invalidInput("startSession", "The saved Devin session ID is invalid."); + } + const resumeSessionId = isResumeCursor(input.resumeCursor) + ? input.resumeCursor.sessionId + : undefined; + const previous = sessions.get(input.threadId); + if (previous) yield* stopContext(previous); + yield* checkDevinExecutable(settings, options.environment).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError((cause) => requestError("session/start", cause)), + ); + const scope = yield* Scope.make(); + const cwd = input.cwd ?? config.cwd; + return yield* Effect.gen(function* () { + const mcp = McpProviderSession.readMcpProviderSession(input.threadId); + const environment = McpProviderSession.withAgentDeviceEnvironment( + options.environment, + mcp, + ); + const mcpConfig = mcp ? yield* prepareDevinMcp(mcp) : undefined; + const runtime = yield* makeDevinAcpRuntime(settings, environment, { + cwd, + resumeSessionId, + additionalDirectories: [ + config.attachmentsDir, + ...(mcpConfig ? [mcpConfig.directory] : []), + ], + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...makeLoggers({ + nativeEventLogger: options.nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }), + }); + const result = yield* runtime.start(); + if (mcpConfig) yield* mcpConfig.connect(runtime); + const model = yield* runtime.applyModel(input.modelSelection); + yield* applyDevinMode(runtime, result.sessionId, input.runtimeMode); + const createdAt = yield* now; + const context: SessionContext = { + runtime, + scope, + sessionId: result.sessionId, + lock: yield* Semaphore.make(1), + stopLock: yield* Semaphore.make(1), + approvals: new Map(), + turns: [], + promptFiber: undefined, + generation: 0, + stopped: false, + session: { + provider: PROVIDER, + providerInstanceId: options.instanceId, + threadId: input.threadId, + cwd, + model, + status: "ready", + runtimeMode: input.runtimeMode, + resumeCursor: { sessionId: result.sessionId }, + createdAt, + updatedAt: createdAt, + }, + }; + yield* runtime.handleSessionUpdate(({ sessionId, update }) => + Effect.gen(function* () { + if ( + context.stopped || + sessionId !== context.sessionId || + update.sessionUpdate !== "usage_update" + ) + return; + yield* emit({ + type: "thread.token-usage.updated", + ...(yield* stamp), + provider: PROVIDER, + threadId: context.session.threadId, + turnId: context.session.activeTurnId, + payload: { + usage: { + usedTokens: update.used, + maxTokens: update.size > 0 ? update.size : undefined, + }, + }, + }); + }).pipe(Effect.ignoreCause({ log: true })), + ); + yield* runtime.handleRequestPermission((request) => + requestPermission(context, request).pipe( + // Permission-handler failures deny this action; they must never authorize it. + Effect.orElseSucceed( + () => + ({ + outcome: { outcome: "cancelled" }, + }) satisfies EffectAcpSchema.RequestPermissionResponse, + ), + ), + ); + sessions.set(input.threadId, context); + yield* Stream.runForEach(runtime.getEvents(), (event) => + handleEvent(context, event), + ).pipe( + Effect.catchCause((cause) => + Effect.logError("Could not process a Devin runtime event", cause), + ), + Effect.forkIn(scope), + ); + yield* emit({ + type: "session.started", + ...(yield* stamp), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: result.initializeResult }, + }); + yield* emit({ + type: "session.state.changed", + ...(yield* stamp), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Devin ACP session ready" }, + }); + yield* emit({ + type: "thread.started", + ...(yield* stamp), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: result.sessionId }, + }); + yield* runtime.drainEvents; + if (context.stopped) { + return yield* new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId: input.threadId, + }); + } + return context.session; + }).pipe( + Effect.provideService(Scope.Scope, scope), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, pathService), + Effect.onError(() => + Effect.gen(function* () { + sessions.delete(input.threadId); + yield* Scope.close(scope, Exit.void); + }), + ), + Effect.mapError((cause) => + cause._tag === "ProviderAdapterSessionClosedError" + ? cause + : requestError("session/start", cause), + ), + ); + }), + ); + + const sendTurn: Adapter["sendTurn"] = Effect.fn("DevinAdapter.sendTurn")(function* (input) { + const context = yield* requireSession(input.threadId); + if (input.modelSelection && input.modelSelection.instanceId !== options.instanceId) { + return yield* invalidInput( + "sendTurn", + "The selected model belongs to another provider instance.", + ); + } + const prompt: Array = []; + if (input.input?.trim()) { + const text = yield* prepareDevinSkillPrompt( + input.input, + settings, + options.environment, + context.session.cwd ?? config.cwd, + ).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(Path.Path, pathService), + Effect.mapError((cause) => requestError("session/prompt", cause)), + ); + prompt.push({ type: "text", text }); + } + for (const attachment of input.attachments ?? []) { + const path = resolveAttachmentPath({ attachmentsDir: config.attachmentsDir, attachment }); + if (!path) return yield* invalidInput("sendTurn", `Invalid attachment '${attachment.name}'.`); + if (attachment.type === "file") { + prompt.push({ type: "text", text: `Attached file: ${path}` }); + continue; + } + const bytes = yield* fs.readFile(path).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Could not read '${attachment.name}'.`, + cause, + }), + ), + ); + if (bytes.length > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) + return yield* invalidInput("sendTurn", `Image '${attachment.name}' is too large.`); + prompt.push({ + type: "image", + mimeType: attachment.mimeType, + data: Buffer.from(bytes).toString("base64"), + }); + } + if (prompt.length === 0) + return yield* invalidInput("sendTurn", "A turn requires text or attachments."); + // Devin expands a leading command across all text blocks. Appended context + // can break argument-free commands and otherwise becomes part of $ARGUMENTS. + // Join user-supplied file references with a space so they remain command arguments. + const isNativeCommand = prompt[0]?.type === "text" && /^\/\S+(?:\s|$)/.test(prompt[0].text); + + let intent: + | { readonly turnId: TurnId; readonly generation: number; settled: boolean } + | undefined; + // Caller holds the turn lock through ownership checks and settlement. + const finish = ( + payload: Extract["payload"], + ) => + Effect.gen(function* () { + if ( + context.stopped || + !intent || + intent.settled || + context.generation !== intent.generation + ) + return; + intent.settled = true; + context.promptFiber = undefined; + context.session = { + ...context.session, + activeTurnId: undefined, + status: payload.state === "failed" ? "error" : "ready", + updatedAt: yield* now, + lastError: payload.errorMessage, + }; + yield* emit({ + type: "turn.completed", + ...(yield* stamp), + provider: PROVIDER, + threadId: input.threadId, + turnId: intent.turnId, + payload, + }); + }).pipe(Effect.uninterruptible); + return yield* Effect.gen(function* () { + const launch = yield* context.lock + .withPermit( + Effect.gen(function* () { + yield* requireSession(input.threadId); + const turnId = context.session.activeTurnId ?? TurnId.make(yield* randomId); + const steering = context.session.activeTurnId !== undefined; + const generation = ++context.generation; + if (steering) intent = { turnId, generation, settled: false }; + if (context.promptFiber) { + yield* cancelApprovals(context); + yield* context.runtime.cancel; + yield* Fiber.await(context.promptFiber); + } + const model = yield* context.runtime.applyModel(input.modelSelection); + yield* applyDevinMode( + context.runtime, + context.sessionId, + context.session.runtimeMode, + input.interactionMode, + ); + intent = { turnId, generation, settled: false }; + context.session = { + ...context.session, + model, + status: "running", + activeTurnId: turnId, + updatedAt: yield* now, + lastError: undefined, + }; + if (!steering) + yield* emit({ + type: "turn.started", + ...(yield* stamp), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: model ? { model } : {}, + }); + const dispatched = yield* Deferred.make(); + const fiber = yield* context.runtime + .prompt( + { + prompt: isNativeCommand + ? [ + { + type: "text", + text: prompt + .filter((block) => block.type === "text") + .map((block) => block.text) + .join(" "), + }, + ...prompt.filter((block) => block.type !== "text"), + ] + : [ + ...prompt, + { + type: "text", + text: buildRuntimeInstructions({ harness: "Devin", model }), + }, + ], + }, + { dispatched }, + ) + .pipe(Effect.forkIn(context.scope)); + context.promptFiber = fiber; + yield* Effect.raceFirst( + Deferred.await(dispatched), + Fiber.await(fiber).pipe( + Effect.flatMap((result) => result), + Effect.asVoid, + ), + ); + return { fiber, turnId, generation }; + }), + ) + .pipe(Effect.mapError((cause) => requestError("session/prompt", cause))); + + const result = yield* Fiber.await(launch.fiber).pipe( + Effect.flatMap((exit) => exit), + Effect.mapError((cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", cause), + ), + ); + yield* context.runtime.drainEvents; + if (context.stopped) { + return yield* new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId: input.threadId, + }); + } + if (context.generation === launch.generation) { + const previousTurn = context.turns.findIndex((turn) => turn.id === launch.turnId); + if (previousTurn === -1) context.turns.push({ id: launch.turnId, items: [result] }); + else context.turns[previousTurn] = { id: launch.turnId, items: [result] }; + } + yield* context.lock.withPermit( + finish({ + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason, + usage: result.usage ?? undefined, + }), + ); + return { + threadId: input.threadId, + turnId: launch.turnId, + resumeCursor: context.session.resumeCursor, + }; + }).pipe( + Effect.tapError((cause) => + context.lock.withPermit(finish({ state: "failed", errorMessage: cause.message })), + ), + Effect.onInterrupt(() => + context.lock.withPermit( + Effect.gen(function* () { + if ( + !intent || + intent.settled || + context.generation !== intent.generation || + context.stopped + ) + return; + yield* cancelApprovals(context); + yield* Effect.ignore(context.runtime.cancel); + yield* finish({ state: "cancelled", stopReason: "cancelled" }); + }), + ), + ), + ); + }); + + const respondToRequest: Adapter["respondToRequest"] = (threadId, requestId, decision) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const pending = context.approvals.get(requestId); + if (!pending) + return yield* invalidInput( + "respondToRequest", + "This permission request is no longer pending.", + ); + if (decision !== "cancel" && !selectDevinPermissionOption(pending.request, decision)) + return yield* invalidInput( + "respondToRequest", + "Devin did not offer this permission choice.", + ); + yield* Deferred.succeed(pending.decision, decision); + }); + const stopAll = () => + Effect.forEach(sessions.values(), (context) => stopContext(context), { discard: true }); + yield* Effect.addFinalizer(() => + stopAll().pipe(Effect.ensuring(PubSub.shutdown(events)), Effect.ignoreCause({ log: true })), + ); + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false }, + compaction: { type: "slash-command", command: "/compact" }, + startSession, + sendTurn, + respondToRequest, + interruptTurn: (threadId) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + // A steer may be preparing its replacement prompt. Stop must cancel + // that launch too, after it releases the same lock used by sendTurn. + yield* context.lock.withPermit( + Effect.gen(function* () { + yield* cancelApprovals(context); + yield* context.runtime.cancel.pipe( + Effect.mapError((cause) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", cause), + ), + ); + }), + ); + }), + respondToUserInput: () => + Effect.fail( + invalidInput("respondToUserInput", "Reply to Devin's question in the conversation."), + ), + stopSession: (threadId) => + withLifecycleLock( + threadId, + Effect.suspend(() => Effect.flatMap(requireSession(threadId), stopContext)), + ), + stopAll, + listSessions: () => + Effect.sync(() => + [...sessions.values()] + .filter((context) => !context.stopped) + .map((context) => ({ ...context.session })), + ), + hasSession: (threadId) => + Effect.sync(() => sessions.has(threadId) && !sessions.get(threadId)?.stopped), + readThread: (threadId) => + Effect.map(requireSession(threadId), (context) => ({ threadId, turns: context.turns })), + rollbackThread: () => + Effect.fail( + invalidInput( + "rollbackThread", + "Devin ACP does not support conversation rewind. Start a new thread instead.", + ), + ), + streamEvents: Stream.fromPubSub(events), + } satisfies Adapter; +}); diff --git a/apps/server/src/provider/Layers/DevinProvider.ts b/apps/server/src/provider/Layers/DevinProvider.ts new file mode 100644 index 000000000000..e9bdc29e34ee --- /dev/null +++ b/apps/server/src/provider/Layers/DevinProvider.ts @@ -0,0 +1,126 @@ +import { type DevinSettings, type ServerProviderModel } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import { checkDevinExecutable, readDevinModels, runDevinCommand } from "../acp/DevinAcpSupport.ts"; +import { + buildServerProvider, + isCommandMissingCause, + providerModelsFromSettings, + type ProviderProbeResult, +} from "../providerSnapshot.ts"; + +const buildDevinProviderSnapshot = Effect.fn("buildDevinProviderSnapshot")(function* ( + settings: DevinSettings, + probe: ProviderProbeResult, + models: ReadonlyArray = [], +) { + return { + ...buildServerProvider({ + presentation: { + displayName: "Devin", + badgeLabel: "Early Access", + showInteractionModeToggle: true, + }, + enabled: settings.enabled, + checkedAt: yield* Effect.map(DateTime.now, DateTime.formatIso), + models: providerModelsFromSettings(models, settings.customModels, { optionDescriptors: [] }), + slashCommands: [ + { name: "compact", description: "Summarize the conversation and reduce context usage" }, + ], + probe, + }), + supportsTextGeneration: false, + modelPolicy: { + catalogScope: "instance" as const, + preserveUnavailableModels: true, + optionSelection: "exact" as const, + }, + }; +}); + +export const initialDevinProviderSnapshot = (settings: DevinSettings) => + buildDevinProviderSnapshot(settings, { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: settings.enabled + ? "Checking Devin CLI…" + : "Enable Devin after installing the CLI and running devin auth login on this environment.", + }); + +/** Status checks use stored credentials; they never start an interactive login or prompt. */ +export const checkDevinProviderStatus = Effect.fn("checkDevinProviderStatus")(function* ( + settings: DevinSettings, + environment: NodeJS.ProcessEnv, + previousModels: ReadonlyArray = [], +) { + if (!settings.enabled) return yield* initialDevinProviderSnapshot(settings); + const probe = yield* Effect.gen(function* () { + const version = yield* checkDevinExecutable(settings, environment); + if (environment.WINDSURF_API_KEY?.trim()) { + return { + installed: true, + version, + status: "ready", + auth: { status: "authenticated", type: "apiKey" }, + } satisfies ProviderProbeResult; + } + const auth = yield* runDevinCommand(settings, environment, ["auth", "status"]); + const output = `${auth.stdout}\n${auth.stderr}`; + if (/\b(?:not logged in|not authenticated|unauthenticated)\b/i.test(output)) { + return { + installed: true, + version, + status: "warning", + auth: { status: "unauthenticated" }, + message: + "Run devin auth login with the configured CLI on this environment, then refresh provider status.", + } satisfies ProviderProbeResult; + } + if (auth.code === 0 && /\b(?:logged in|authenticated)\b/i.test(output)) { + return { + installed: true, + version, + status: "ready", + auth: { status: "authenticated" }, + } satisfies ProviderProbeResult; + } + return { + installed: true, + version, + status: "warning", + auth: { status: "unknown" }, + message: "Could not verify Devin sign-in. Run devin auth status on this environment.", + } satisfies ProviderProbeResult; + }).pipe( + Effect.timeout("15 seconds"), + Effect.catch((cause) => + Effect.succeed({ + installed: !isCommandMissingCause(cause), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(cause) + ? "Devin CLI was not found. Install it from https://docs.devin.ai/cli, then run devin auth login." + : cause.message, + } satisfies ProviderProbeResult), + ), + ); + if (probe.status !== "ready") return yield* buildDevinProviderSnapshot(settings, probe); + return yield* readDevinModels(settings, environment).pipe( + Effect.timeout("15 seconds"), + Effect.flatMap((models) => buildDevinProviderSnapshot(settings, probe, models)), + Effect.catch((cause) => + buildDevinProviderSnapshot( + settings, + { + ...probe, + status: "warning", + message: `Could not load Devin models: ${cause.message}`, + }, + previousModels, + ), + ), + ); +}); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 7fc33d2bb9f2..13d454651ba9 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -328,6 +328,7 @@ export const makePendingOpenCodeProvider = ( if (!openCodeSettings.enabled) { return buildServerProvider({ + modelPolicy: { preserveUnavailableModels: true }, presentation: OPENCODE_PRESENTATION, enabled: false, checkedAt, @@ -346,6 +347,7 @@ export const makePendingOpenCodeProvider = ( } return buildServerProvider({ + modelPolicy: { preserveUnavailableModels: true }, presentation: OPENCODE_PRESENTATION, enabled: true, checkedAt, @@ -388,6 +390,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu serverUrl: openCodeSettings.serverUrl, }); return buildServerProvider({ + modelPolicy: { preserveUnavailableModels: true }, presentation: OPENCODE_PRESENTATION, enabled: openCodeSettings.enabled, checkedAt, @@ -404,6 +407,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu if (!openCodeSettings.enabled) { return buildServerProvider({ + modelPolicy: { preserveUnavailableModels: true }, presentation: OPENCODE_PRESENTATION, enabled: false, checkedAt, @@ -459,6 +463,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu } if (compareSemverVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { return buildServerProvider({ + modelPolicy: { preserveUnavailableModels: true }, presentation: OPENCODE_PRESENTATION, enabled: openCodeSettings.enabled, checkedAt, @@ -521,6 +526,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills); const connectedCount = inventoryExit.value.inventory.providerList.connected.length; return buildServerProvider({ + modelPolicy: { preserveUnavailableModels: true }, presentation: OPENCODE_PRESENTATION, enabled: true, checkedAt, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 988c89e1e679..a99d64816095 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1066,10 +1066,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); }); - describe("Antigravity model inventories", () => { + const inventoryDrivers = ["antigravity", "devin", "test-account-provider"]; + describe.each(inventoryDrivers)("%s model inventories", (driver) => { const previousProvider = { instanceId: ProviderInstanceId.make("antigravity-personal"), - driver: ProviderDriverKind.make("antigravity"), + driver: ProviderDriverKind.make(driver), + modelPolicy: + driver === "test-account-provider" ? { catalogScope: "instance" } : undefined, status: "ready", enabled: true, installed: true, @@ -1120,7 +1123,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te for (const installed of [false, true]) { const pendingProvider = { ...previousProvider, - status: "warning", + status: driver !== "antigravity" && installed ? "error" : "warning", installed, auth: { status: "unknown" }, checkedAt: "2026-09-02T00:01:00.000Z", @@ -1286,7 +1289,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }); }); - it("fills missing capabilities from the previous provider snapshot", () => { + it("fills missing capabilities except for authoritative Devin inventories", () => { const previousProvider = { instanceId: ProviderInstanceId.make("cursor"), driver: ProviderDriverKind.make("cursor"), @@ -1333,6 +1336,24 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ ...previousProvider.models, ]); + assert.deepStrictEqual( + mergeProviderSnapshot( + { ...previousProvider, driver: ProviderDriverKind.make("devin") }, + { ...refreshedProvider, driver: ProviderDriverKind.make("devin") }, + ).models, + refreshedProvider.models, + ); + assert.deepStrictEqual( + mergeProviderSnapshot( + { ...previousProvider, driver: ProviderDriverKind.make("test-account-provider") }, + { + ...refreshedProvider, + driver: ProviderDriverKind.make("test-account-provider"), + modelPolicy: { optionSelection: "exact" }, + }, + ).models, + refreshedProvider.models, + ); }); it.effect("does not run provider probes during layer construction", () => @@ -2615,6 +2636,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "claudeAgent", "codex", "cursor", + "devin", "grok", "opencode", ]); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index a8e6caf95aa7..e99f7dc0dd3d 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -23,6 +23,7 @@ * @module ProviderRegistryLive */ import { + resolveProviderModelPolicy, defaultInstanceIdForDriver, ProviderDriverKind, type ProviderInstanceId, @@ -51,7 +52,7 @@ import { resolveProviderStatusCachePath, writeProviderStatusCache, } from "../providerStatusCache.ts"; -import type { ProviderInstance } from "../ProviderDriver.ts"; +import { MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER, type ProviderInstance } from "../ProviderDriver.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; @@ -78,8 +79,6 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => (model.capabilities?.optionDescriptors?.length ?? 0) > 0; -const MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER = 16; - export function upsertProviderWorkspaceSnapshot( provider: ServerProvider, cwd: string, @@ -103,12 +102,18 @@ export function upsertProviderWorkspaceSnapshot( const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); const isCodex = provider.driver === ProviderDriverKind.make("codex"); - if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) { + const isInstanceCatalog = resolveProviderModelPolicy(provider).catalogScope === "instance"; + if ( + !isAntigravity && + !isCodex && + !isInstanceCatalog && + provider.driver !== ProviderDriverKind.make("opencode") + ) { return true; } if ( - (isAntigravity || isCodex) && + (isAntigravity || isCodex || isInstanceCatalog) && (!provider.enabled || provider.auth.status === "unauthenticated") ) { return false; @@ -148,7 +153,12 @@ const mergeProviderModels = ( const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const)); const mergedModels = nextModels.map((model) => { const previousModel = previousBySlug.get(model.slug); - if (!previousModel || hasModelCapabilities(model) || !hasModelCapabilities(previousModel)) { + if ( + resolveProviderModelPolicy(provider).optionSelection === "exact" || + !previousModel || + hasModelCapabilities(model) || + !hasModelCapabilities(previousModel) + ) { return model; } return { diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index a5c092233052..e1023c8ad9ea 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -38,6 +38,8 @@ import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; import type { ServerProviderShape } from "./Services/ServerProvider.ts"; import type { ProviderAuthController } from "./Services/ProviderAuthService.ts"; +export const MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER = 16; + /** * Static metadata advertised by a driver. Used for default presentation * and (later) settings UI. Doesn't need to be Effect-typed because nothing diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index b5894192eed9..981944ddb965 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -80,19 +80,22 @@ export interface AcpSpawnInput { export interface AcpSessionRuntimeOptions { readonly spawn: AcpSpawnInput; readonly cwd: string; - readonly resumeSessionId?: string; + readonly resumeSessionId?: string | undefined; readonly resumeMethod?: "load" | "resume"; readonly sessionLoadTimeout?: Duration.Input; readonly sessionLoadReplayIdleGap?: Duration.Input; /** Native cancellation waits for the prompt response and the getEvents consumer to drain. */ readonly cancelBehavior?: "interrupt" | "wait-for-prompt"; readonly cancelTimeout?: Duration.Input; + /** Let providers with incomplete cached model lists validate selections themselves. */ + readonly modelValidation?: "client" | "agent"; readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; readonly clientInfo: { readonly name: string; readonly version: string; }; - readonly authMethodId: string; + /** Omit when the CLI reads existing credentials without an ACP authentication request. */ + readonly authMethodId?: string; readonly mcpServers?: ReadonlyArray; /** Extra workspace roots the agent may read and write besides `cwd`. */ readonly additionalDirectories?: ReadonlyArray; @@ -650,8 +653,9 @@ export const make = ( const setConfigOption = ( configId: string, value: string | boolean, + validate = true, ): Effect.Effect => - validateConfigOptionValue(configId, value).pipe( + (validate ? validateConfigOptionValue(configId, value) : Effect.void).pipe( Effect.flatMap(() => getStartedState), Effect.flatMap((started) => Ref.get(configOptionsRef).pipe( @@ -699,15 +703,14 @@ export const make = ( const startOnce = Effect.gen(function* () { const initializeResult = yield* sendInitialize; - const authenticatePayload = { - methodId: options.authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; - - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); + if (options.authMethodId !== undefined) { + const authenticatePayload = { methodId: options.authMethodId }; + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + } let sessionId: string; let sessionSetupResult: @@ -755,6 +758,9 @@ export const make = ( sessionId: options.resumeSessionId, cwd: options.cwd, mcpServers: options.mcpServers ?? [], + ...(options.additionalDirectories && options.additionalDirectories.length > 0 + ? { additionalDirectories: options.additionalDirectories } + : {}), } satisfies EffectAcpSchema.LoadSessionRequest; const sessionLoadTimeout = Duration.fromInputUnsafe( options.sessionLoadTimeout ?? defaultSessionLoadTimeout, @@ -1055,7 +1061,13 @@ export const make = ( setConfigOption, setModel: (model) => getStartedState.pipe( - Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), + Effect.flatMap((started) => + setConfigOption( + started.modelConfigId ?? "model", + model, + options.modelValidation !== "agent", + ), + ), Effect.asVoid, ), setSessionModel: (modelId, meta) => diff --git a/apps/server/src/provider/acp/DevinAcpSupport.test.ts b/apps/server/src/provider/acp/DevinAcpSupport.test.ts new file mode 100644 index 000000000000..d31b4dc43db6 --- /dev/null +++ b/apps/server/src/provider/acp/DevinAcpSupport.test.ts @@ -0,0 +1,117 @@ +import * as FileSystem from "effect/FileSystem"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; +import { + devinMode, + checkDevinExecutable, + makeDevinAcpRuntime, + runDevinCommand, +} from "./DevinAcpSupport.ts"; +import { + makeDevinCli as makeHarness, + devinTestLayer as layer, + decodeDevinSettings as decodeSettings, +} from "../testUtils/devinCli.ts"; + +it.effect("passes resolved Windows command-shim shell options to the process launcher", () => + Effect.gen(function* () { + const spawner = ChildProcessSpawner.make((command) => { + expect(command._tag).toBe("StandardCommand"); + if (command._tag !== "StandardCommand") return Effect.die("Unexpected pipeline"); + expect(command.command).toContain("devin.cmd"); + expect(command.options.shell).toBe(true); + expect(command.args.join(" ")).toContain("--version"); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make("devin 3000.6.19")), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + const result = yield* runDevinCommand(decodeSettings({ binaryPath: "devin" }), {}, [ + "--version", + ]).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(SpawnExecutableResolution, () => "C:\\tools\\devin.cmd"), + ); + expect(result.stdout).toBe("devin 3000.6.19"); + }), +); + +it.effect.each([ + { modelId: "devin-test-high", staleCatalog: true }, + { modelId: "fusion-gpt-5-6-sol-max-sidekick-swe-2-high", staleCatalog: false }, +])("selects $modelId without relying on ACP's cached catalog", ({ modelId, staleCatalog }) => + Effect.gen(function* () { + const h = yield* makeHarness({ + T3_ACP_DEVIN: "1", + T3_ACP_DEVIN_STALE_MODELS: staleCatalog ? "1" : "0", + }); + const runtime = yield* makeDevinAcpRuntime(h.settings, h.environment, { + cwd: h.root, + clientInfo: { name: "t3-code-test", version: "0.0.0" }, + }); + if (staleCatalog) yield* runtime.start(); + yield* runtime.setModel(modelId); + const model = (yield* runtime.getConfigOptions).find((option) => option.id === "model"); + expect(model?.currentValue).toBe(modelId); + expect( + (yield* h.requests) + .filter((request) => request.method === "session/set_config_option") + .map((request) => request.params?.value), + ).toEqual([modelId]); + }).pipe(Effect.provide(layer)), +); + +it.effect("preserves provider rejection when an unlisted model is not accepted", () => + Effect.gen(function* () { + const h = yield* makeHarness({ T3_ACP_DEVIN: "1", T3_ACP_FAIL_SET_CONFIG_OPTION: "1" }); + const runtime = yield* makeDevinAcpRuntime(h.settings, h.environment, { + cwd: h.root, + clientInfo: { name: "t3-code-test", version: "0.0.0" }, + }); + const error = yield* runtime.setModel("unavailable-fusion").pipe(Effect.flip); + expect(error.message).toContain("Mock invalid params"); + const model = (yield* runtime.getConfigOptions).find((option) => option.id === "model"); + expect(model?.currentValue).not.toBe("unavailable-fusion"); + }).pipe(Effect.provide(layer)), +); + +it.effect("rejects a Desktop version response before starting ACP", () => + Effect.gen(function* () { + const cli = yield* makeHarness({}, "1.108.0\ndesktop-commit\nx64"); + const result = yield* checkDevinExecutable(cli.settings, cli.environment).pipe(Effect.result); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") expect(result.failure.message).toContain("not Devin CLI"); + expect(yield* (yield* FileSystem.FileSystem).exists(cli.launchLog)).toBe(false); + }).pipe(Effect.provide(layer)), +); + +it.each([ + ["auto", "smart"], + ["auto-accept-edits", "accept-edits"], + ["full-access", "bypass"], + ["approval-required", "normal"], +] as const)("maps %s to Devin's %s mode", (policy, expected) => { + expect(devinMode(policy, undefined, [{ id: "smart" }])).toBe(expected); +}); + +it("falls back when smart mode is absent and lets plan mode override access policy", () => { + expect(devinMode("auto", undefined, [])).toBe("normal"); + expect(devinMode("full-access", "plan", [])).toBe("plan"); +}); diff --git a/apps/server/src/provider/acp/DevinAcpSupport.ts b/apps/server/src/provider/acp/DevinAcpSupport.ts new file mode 100644 index 000000000000..e39589ef4fe8 --- /dev/null +++ b/apps/server/src/provider/acp/DevinAcpSupport.ts @@ -0,0 +1,245 @@ +import { + type DevinSettings, + type ProviderApprovalDecision, + type ProviderInteractionMode, + type RuntimeMode, + ModelSelection, +} from "@t3tools/contracts"; +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 * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import { DevinModelCatalog, devinModels, resolveDevinModel } from "./DevinModels.ts"; + +import { spawnAndCollect } from "../providerSnapshot.ts"; +import type { McpProviderSessionConfig } from "../../mcp/McpProviderSession.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const encodeMcpConfig = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeMcpConnected = Schema.decodeUnknownEffect( + Schema.Struct({ connectionStatus: Schema.Literal("connected") }), +); + +/** Devin resolves MCP tools from its session roots, even after connectServer succeeds. */ +export const prepareDevinMcp = Effect.fn("prepareDevinMcp")(function* ( + session: Pick, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-devin-mcp-" }); + const configDirectory = path.join(directory, ".devin"); + yield* fs.makeDirectory(configDirectory, { mode: 0o700 }); + yield* fs.writeFileString( + path.join(configDirectory, "mcp_config.local.json"), + encodeMcpConfig({ + mcpServers: { + "t3-code": { + serverUrl: session.endpoint, + headers: { Authorization: session.authorizationHeader }, + }, + }, + }), + { mode: 0o600 }, + ); + const connect = (runtime: Pick) => + runtime + .request("_cognition.ai/mcp/connectServer", { + serverId: "t3-code", + workspaceDirs: [directory], + }) + .pipe( + Effect.flatMap(decodeMcpConnected), + Effect.timeout("20 seconds"), + Effect.asVoid, + Effect.mapError(() => + EffectAcpErrors.AcpRequestError.internalError( + "Devin could not connect to T3 Code tools.", + ), + ), + ); + return { directory, connect }; +}); + +/** Use the same executable and environment for health checks and actual sessions. */ +export const runDevinCommand = Effect.fn("runDevinCommand")(function* ( + settings: DevinSettings, + environment: NodeJS.ProcessEnv, + args: ReadonlyArray, + cwd?: string, +) { + const binaryPath = settings.binaryPath || "devin"; + const resolved = yield* resolveSpawnCommand(binaryPath, args, { env: environment }); + return yield* spawnAndCollect( + binaryPath, + ChildProcess.make(resolved.command, resolved.args, { + env: environment, + shell: resolved.shell, + ...(cwd ? { cwd } : {}), + }), + ); +}); + +/** The Desktop launcher also owns `devin` on some machines, but cannot speak ACP. */ +export const checkDevinExecutable = Effect.fn("checkDevinExecutable")(function* ( + settings: DevinSettings, + environment: NodeJS.ProcessEnv, +) { + const result = yield* runDevinCommand(settings, environment, ["--version"]).pipe( + Effect.timeout("5 seconds"), + ); + const version = /^devin\s+(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)/im.exec(result.stdout)?.[1]; + if (result.code !== 0 || !version) { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + "The configured command is not Devin CLI. Install Devin CLI from https://docs.devin.ai/cli or set its binary path in Settings → Providers. The Devin Desktop launcher does not support ACP.", + ); + } + return version; +}); + +const decodeDevinModelCatalog = Schema.decodeEffect(DevinModelCatalog); +const sameSelection = Schema.toEquivalence(ModelSelection); + +/** This command waits for fresh account models; ACP initially returns its disk cache. */ +const readDevinModelCatalog = Effect.fn("readDevinModelCatalog")(function* ( + settings: DevinSettings, + environment: NodeJS.ProcessEnv, +) { + const result = yield* runDevinCommand(settings, environment, [ + "models", + "list", + "--format", + "json", + ]); + if (result.code !== 0) { + return yield* EffectAcpErrors.AcpRequestError.internalError( + "Devin CLI could not list available models. Run devin models list on this environment.", + ); + } + return yield* decodeDevinModelCatalog(result.stdout); +}); + +export const readDevinModels = (settings: DevinSettings, environment: NodeJS.ProcessEnv) => + readDevinModelCatalog(settings, environment).pipe(Effect.map(devinModels)); + +export const makeDevinAcpRuntime = Effect.fn("makeDevinAcpRuntime")(function* ( + settings: DevinSettings, + environment: NodeJS.ProcessEnv, + input: Omit, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + // `authenticate` starts Devin's browser flow. Ordinary launches use the credentials + // saved by `devin auth login` or WINDSURF_API_KEY, including remote environments. + const runtime = yield* AcpSessionRuntime.make({ + ...input, + spawn: { + command: settings.binaryPath || "devin", + args: ["acp"], + cwd: input.cwd, + env: environment, + }, + cancelBehavior: "wait-for-prompt", + modelValidation: "agent", + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + _meta: { "cognition.ai/mcp": true, "cognition.ai/mcpWorkspaceDirs": true }, + }, + }); + const getCatalog = readDevinModelCatalog(settings, environment).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.timeout("10 seconds"), + Effect.mapError((cause) => EffectAcpErrors.AcpRequestError.internalError(cause.message)), + ); + // ACP's cached options can omit valid Fusion IDs indefinitely. The fresh CLI + // catalog resolves selections; Devin's setter remains authoritative for availability. + const setModel = (modelId: string) => + runtime.start().pipe( + Effect.andThen(runtime.setModel(modelId)), + Effect.mapError((cause) => + cause.message.includes("Could not determine pairing") + ? EffectAcpErrors.AcpRequestError.invalidParams( + "Devin CLI could not resolve this Fusion pairing. Update the configured Devin CLI, then start a new thread to use the updated runtime.", + ) + : cause, + ), + ); + let previousSelection: ModelSelection | undefined; + let previousModel: string | undefined; + return { + ...runtime, + setModel, + applyModel: Effect.fn("DevinAcpRuntime.applyModel")(function* (selection?: ModelSelection) { + const config = (yield* runtime.getConfigOptions).find((option) => option.id === "model"); + const current = config?.type === "select" ? config.currentValue : undefined; + if (!selection) return current; + if ( + previousSelection && + sameSelection(selection, previousSelection) && + current === previousModel + ) + return current; + const model = resolveDevinModel(yield* getCatalog, selection); + if (!model) + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Devin does not offer the selected thinking, speed, and context combination for ${selection.model}. Refresh provider status and choose an available combination.`, + ); + if (model !== current) yield* setModel(model); + previousSelection = selection; + previousModel = model; + return model; + }), + }; +}); + +export function devinMode( + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode | undefined, + availableModes: ReadonlyArray<{ readonly id: string }>, +): string { + if (interactionMode === "plan") return "plan"; + switch (runtimeMode) { + case "full-access": + return "bypass"; + case "auto-accept-edits": + return "accept-edits"; + case "auto": + return availableModes.some((mode) => mode.id === "smart") ? "smart" : "normal"; + case "approval-required": + return "normal"; + } +} + +export const applyDevinMode = Effect.fn("applyDevinMode")(function* ( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + sessionId: string, + runtimeMode: RuntimeMode, + interactionMode?: ProviderInteractionMode, +) { + const state = yield* runtime.getModeState; + const modeId = devinMode(runtimeMode, interactionMode, state?.availableModes ?? []); + // Devin accepts `normal` via set_mode but omits it from its config-option choices. + // setMode uses those choices for validation, so use the native mode request here. + yield* runtime.request("session/set_mode", { sessionId, modeId }); +}); + +/** Match the option kind; providers are free to choose arbitrary option IDs. */ +export function selectDevinPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, + decision: ProviderApprovalDecision, +) { + const kind = + decision === "accept" + ? "allow_once" + : decision === "acceptForSession" || decision === "acceptAlways" + ? "allow_always" + : decision === "decline" + ? "reject_once" + : undefined; + return kind === undefined ? undefined : request.options.find((option) => option.kind === kind); +} 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..a52aa2247ff1 --- /dev/null +++ b/apps/server/src/provider/acp/DevinModels.test.ts @@ -0,0 +1,197 @@ +import { expect, it } from "@effect/vitest"; +import { devinModels, resolveDevinModel } from "./DevinModels.ts"; + +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("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..888a8b6164c1 --- /dev/null +++ b/apps/server/src/provider/acp/DevinModels.ts @@ -0,0 +1,225 @@ +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" })), + }); + return [ + { + slug: family.slug, + name: family.family_label, + isCustom: false, + isDefault: false, + aliases: family.aliases, + fusion: family.fusion, + badge: variants.some((variant) => variant.is_new) ? "new" : undefined, + capabilities: { optionDescriptors: descriptors }, + }, + ]; + }); +} + +/** 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/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 60e3402eed42..04b8ba827843 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -26,6 +26,7 @@ import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; +import { DevinDriver, type DevinDriverEnv } from "./Drivers/DevinDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; /** @@ -39,7 +40,8 @@ export type BuiltInDriversEnv = | CursorDriverEnv | GrokDriverEnv | OpenCodeDriverEnv - | AntigravityDriverEnv; + | AntigravityDriverEnv + | DevinDriverEnv; /** * Ordered list of built-in drivers. Order matters only for tie-breaking in @@ -53,4 +55,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray; @@ -208,7 +209,7 @@ export function buildServerProvider(input: { checkedAt: input.checkedAt, }) : undefined; - return { + const snapshot = { displayName: input.presentation.displayName, ...(typeof input.presentation.supportsConversationRollback === "boolean" ? { supportsConversationRollback: input.presentation.supportsConversationRollback } @@ -235,7 +236,8 @@ export function buildServerProvider(input: { skills: [...(input.skills ?? [])], ...(input.probe.usageLimits ? { usageLimits: input.probe.usageLimits } : {}), ...(versionAdvisory ? { versionAdvisory } : {}), - }; + } satisfies ServerProviderDraft; + return input.modelPolicy ? { ...snapshot, modelPolicy: input.modelPolicy } : snapshot; } export const collectStreamAsString = ( diff --git a/apps/server/src/provider/testUtils/devinCli.ts b/apps/server/src/provider/testUtils/devinCli.ts new file mode 100644 index 000000000000..61626e6fce06 --- /dev/null +++ b/apps/server/src/provider/testUtils/devinCli.ts @@ -0,0 +1,154 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { DevinSettings } from "@t3tools/contracts"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { ServerConfig } from "../../config.ts"; +import { DevinSkillCatalog } from "../Drivers/DevinSkills.ts"; +import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; + +const encodeString = Schema.encodeSync(Schema.fromJsonString(Schema.String)); +export const decodeDevinSettings = Schema.decodeSync(DevinSettings); +export const devinTestSkills = Schema.encodeSync(DevinSkillCatalog)([ + { + name: "Visual audit", + display_name: "Visual check", + description: "Check a page.", + base_dir: "/skills/visual-check", + triggers: ["user"], + errors: [], + }, + { + name: "internal", + display_name: "internal", + description: "Internal skill.", + base_dir: "/skills/internal", + triggers: ["model"], + errors: [], + }, + { + name: "broken", + display_name: "broken", + description: "Invalid skill.", + base_dir: "/skills/broken", + triggers: ["user"], + errors: ["Invalid frontmatter"], + }, + { + name: "builtin", + display_name: "builtin", + description: "Built-in command.", + base_dir: "", + triggers: ["user"], + errors: [], + }, +]); +const Request = Schema.Struct({ + result: Schema.optional( + Schema.Struct({ + outcome: Schema.optional( + Schema.Struct({ outcome: Schema.String, optionId: Schema.optional(Schema.String) }), + ), + }), + ), + method: Schema.optional(Schema.String), + params: Schema.optional( + Schema.Struct({ + sessionId: Schema.optional(Schema.String), + cwd: Schema.optional(Schema.String), + additionalDirectories: Schema.optional(Schema.Array(Schema.String)), + workspaceDirs: Schema.optional(Schema.Array(Schema.String)), + modeId: Schema.optional(Schema.String), + configId: Schema.optional(Schema.String), + value: Schema.optional(Schema.String), + prompt: Schema.optional( + Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.optional(Schema.String) })), + ), + }), + ), +}); +export const decodeDevinLaunch = Schema.decodeSync( + Schema.fromJsonString( + Schema.Struct({ + args: Schema.Array(Schema.String), + device: Schema.optional(Schema.String), + }), + ), +); +const decodeRequest = Schema.decodeSync(Schema.fromJsonString(Request)); +export const devinTestLayer = ServerConfig.layerTest("/tmp", { prefix: "t3-devin-test-" }).pipe( + Layer.provideMerge(NodeServices.layer), +); + +export const makeDevinCli = Effect.fn("makeDevinCli")(function* ( + env: Readonly> = {}, + version = "devin 3000.5.20 (test)", +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const environment = yield* HostProcessEnvironment; + const platform = yield* HostProcessPlatform; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-devin-cli-" }); + const requestLog = path.join(root, "requests.jsonl"); + const launchLog = path.join(root, "launches.jsonl"); + const scriptPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const binaryPath = writeFakeCli({ + directory: root, + name: "devin", + platform, + env: { + T3_ACP_DEVIN: "1", + ...env, + T3_ACP_REQUEST_LOG_PATH: requestLog, + }, + source: `const cliArgs = process.argv.slice(2); +const matches = (...expected) => cliArgs.length === expected.length && cliArgs.every((arg, i) => arg === expected[i]); +if (matches("--version")) { process.stdout.write(${encodeString(version + "\n")}); process.exit(0); } +if (matches("auth", "status")) { + const statusFile = process.env.T3_DEVIN_AUTH_STATUS_FILE; + process.stdout.write(statusFile ? (await import("node:fs")).readFileSync(statusFile, "utf8") : process.env.T3_DEVIN_AUTH_STATUS ?? "Not logged in.\\n"); + process.exit(0); +} +if (matches("models", "list", "--format", "json")) { + const modelsFile = process.env.T3_DEVIN_MODELS_FILE; + process.stdout.write(modelsFile ? (await import("node:fs")).readFileSync(modelsFile, "utf8") : JSON.stringify({families: [{slug: "devin-test", family_label: "Devin Test", variants: [ + {model_uid: "devin-test-low", label: "Devin Test Low"}, + {model_uid: "devin-test-high", label: "Devin Test High"} + ]}]})); + process.exit(0); +} +if (matches("skills", "list", "--json")) { + const fs = await import("node:fs"); + const skillsFile = process.env.T3_DEVIN_SKILLS_FILE ?? "devin-test-skills.json"; + process.stdout.write(fs.existsSync(skillsFile) ? fs.readFileSync(skillsFile, "utf8") : "[]"); + process.exit(0); +} +if (!matches("acp")) { process.stderr.write("Unexpected Devin CLI arguments: " + JSON.stringify(cliArgs)); process.exit(2); } +const fs = await import("node:fs"); +fs.appendFileSync(${encodeString(launchLog)}, JSON.stringify({args: cliArgs, cwd: process.cwd(), device: process.env.T3_TEST_DEVICE}) + "\\n"); +${execScriptSource({ scriptPath })}`, + }); + const settings = decodeDevinSettings({ enabled: true, binaryPath }); + const requests = fs.readFileString(requestLog).pipe( + Effect.map((text) => + text + .trim() + .split("\n") + .filter(Boolean) + .map((line) => decodeRequest(line)), + ), + ); + return { + settings, + environment, + root, + requests, + requestLog, + launchLog, + }; +}); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 208d75fb6517..2da256541e96 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -10,6 +10,7 @@ import { ServerSettings, ServerSettingsPatch, } from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { createModelSelection } from "@t3tools/shared/model"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -427,6 +428,27 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("does not assign a fallback background model to a Devin-only environment", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const next = yield* service.updateSettings({ + providers: { + codex: { enabled: false }, + claudeAgent: { enabled: false }, + cursor: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + antigravity: { enabled: false }, + devin: { enabled: true }, + }, + }); + assert.deepEqual( + next.textGenerationModelSelection, + DEFAULT_SERVER_SETTINGS.textGenerationModelSelection, + ); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("preserves enabled text generation selections for non-built-in drivers", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; @@ -1291,6 +1313,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const legacyProject = ProjectId.make("project-legacy"); const scriptedProject = ProjectId.make("project-scripted"); + const noPullProject = ProjectId.make("project-no-pull"); const script: ProjectScript = { id: "check", name: "Check", @@ -1308,6 +1331,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { // null override reset them; the fold must not bring them back. [legacyProject, modelJson, "worktree", 1, scriptsJson], [scriptedProject, null, null, 0, scriptsJson], + [noPullProject, null, null, 0, "[]"], ] as const) { yield* sql` INSERT INTO projection_projects ( @@ -1323,7 +1347,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { } yield* fileSystem.writeFileString( serverConfig.settingsPath, - `{"projectAgentBrowserAccessOverrides":{"${legacyProject}":false},"projectAutoPullOverrides":{"${scriptedProject}":true},"projectScriptOverrides":{"${legacyProject}":null}}`, + `{"defaultAutoPull":true,"projectAgentBrowserAccessOverrides":{"${legacyProject}":false},"projectAutoPullOverrides":{"${scriptedProject}":true},"projectScriptOverrides":{"${legacyProject}":null}}`, ); const settings = yield* serverSettings.getSettings; @@ -1338,14 +1362,17 @@ it.layer(NodeServices.layer)("server settings", (it) => { defaultAutoPull: true, }, [scriptedProject]: { defaultAutoPull: true, defaultProjectScripts: [script] }, + [noPullProject]: { defaultAutoPull: false }, }, ); + assert.isFalse(resolveProjectSettings(settings, noPullProject).settings.defaultAutoPull); // Derived legacy views keep older clients reading the same values. assert.deepEqual( settings.projectAutoPullOverrides, { [legacyProject]: true, [scriptedProject]: true, + [noPullProject]: false, }, ); assert.deepEqual(settings.projectScriptOverrides, { diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 50f8649eaacb..874b974fd32a 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -11,7 +11,6 @@ * @module ServerSettings */ import { - DEFAULT_TEXT_GENERATION_MODEL, DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, @@ -330,11 +329,18 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting // instance wins over the legacy providers map, which decodes to defaults // (codex enabled) when the Providers UI has only written providerInstances. const fallbackEntry = Object.entries(settings.providers).find(([driver, provider]) => { + const kind = ProviderDriverKind.make(driver); + // Account-discovered catalogs require an explicit model selection. + if (!DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[kind] && !DEFAULT_MODEL_BY_PROVIDER[kind]) + return false; const instance = settings.providerInstances[ProviderInstanceId.make(driver)]; return instance === undefined ? provider.enabled : resolveProviderInstanceEnabled(instance); }); const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; - if (!fallback) { + const model = fallback + ? (DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? DEFAULT_MODEL_BY_PROVIDER[fallback]) + : undefined; + if (!fallback || !model) { return settings; } @@ -342,10 +348,7 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting ...settings, textGenerationModelSelection: { instanceId: ProviderInstanceId.make(fallback), - model: - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? - DEFAULT_MODEL_BY_PROVIDER[fallback] ?? - DEFAULT_TEXT_GENERATION_MODEL, + model, } satisfies ModelSelection, }; } @@ -475,7 +478,9 @@ function foldLegacyProjectSettings( if (row.defaultThreadEnvMode === "local" || row.defaultThreadEnvMode === "worktree") { set(row.projectId, "defaultThreadEnvMode", row.defaultThreadEnvMode); } - if (row.autoPull === 1) set(row.projectId, "defaultAutoPull", true); + if (row.autoPull === 0 || row.autoPull === 1) { + set(row.projectId, "defaultAutoPull", row.autoPull === 1); + } const scripts = decodeProjectScriptsJson(row.scripts); if (Option.isSome(scripts) && scripts.value.length > 0 && !resetScripts.has(row.projectId)) { set(row.projectId, "defaultProjectScripts", scripts.value); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 6f2a9e1199f8..a4306b0b8c23 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1456,18 +1456,21 @@ describe("resolveComposerProviderSelection", () => { expect(getAntigravitySendBlockReason(provider, "gpt-model")).toBeNull(); }); - it("does not continue an existing Antigravity thread in another profile after deletion", () => { - const missingInstanceId = ProviderInstanceId.make("google_work"); - const selection = resolveComposerProviderSelection({ - entries: [entry("antigravity")], - candidateInstanceIds: [missingInstanceId], - lockedProvider: ProviderDriverKind.make("antigravity"), - lockedInstanceId: missingInstanceId, - }); + it.each(["antigravity", "devin", "test-account-provider"])( + "keeps a missing %s instance pinned to its account", + (driver) => { + const missingInstanceId = ProviderInstanceId.make(`${driver}_work`); + const selection = resolveComposerProviderSelection({ + entries: [entry(driver)], + candidateInstanceIds: [missingInstanceId], + lockedProvider: ProviderDriverKind.make(driver), + lockedInstanceId: missingInstanceId, + }); - expect(selection.selectedProviderEntry).toBeUndefined(); - expect(selection.unavailableProviderInstanceId).toBe(missingInstanceId); - }); + expect(selection.selectedProviderEntry).toBeUndefined(); + expect(selection.unavailableProviderInstanceId).toBe(missingInstanceId); + }, + ); it("does not treat the empty draft placeholder as a provider setup target", () => { const selection = resolveComposerProviderSelection({ diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index eae137201d37..91da6e429ae6 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -560,9 +560,9 @@ export function resolveComposerProviderSelection(input: { ? (input.entries.find((entry) => entry.instanceId === input.lockedInstanceId) ?.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 && 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 e4d41d53c3a8..98f2dc096dd9 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -769,6 +769,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.test.ts b/apps/web/src/components/chat/ModelPickerContent.test.ts index 5b9cfd0ff5f2..01508f7859e8 100644 --- a/apps/web/src/components/chat/ModelPickerContent.test.ts +++ b/apps/web/src/components/chat/ModelPickerContent.test.ts @@ -56,6 +56,8 @@ describe("shouldIncludeModelPickerOption", () => { ["opencode", "warning"], ["antigravity", "error"], ["antigravity", "warning"], + ["devin", "error"], + ["devin", "warning"], ] as const)( "keeps only the active synthetic %s row when the provider status is %s", (driver, status) => { diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 63dea7844c46..520ea1e404c8 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -1,4 +1,5 @@ import { + resolveProviderModelPolicy, ANTIGRAVITY_DEFAULT_MODEL, type ProviderInstanceId, type ProviderDriverKind, @@ -7,8 +8,19 @@ 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, + useEffect, + useEffectEvent, + useLayoutEffect, + useRef, +} from "react"; import { ChevronRightIcon, SearchIcon } from "lucide-react"; +import { FusionModelPicker } from "./FusionModelPicker"; +import { collapseFusionModels } from "./fusionModelPicker"; import { ModelListRow } from "./ModelListRow"; import { ModelPickerSidebar } from "./ModelPickerSidebar"; import { getProviderStatusMessage, hasProviderSetup } from "./ProviderStatusBanner"; @@ -48,6 +60,8 @@ import { import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; type ModelPickerItem = { + isFusionGroup?: boolean; + fusion?: ModelEsque["fusion"]; slug: string; name: string; shortName?: string; @@ -91,7 +105,7 @@ export function shouldIncludeModelPickerOption(input: { if (isProviderInstancePickerReady(input.entry)) return true; return ( input.entry.enabled && - (input.entry.driverKind === "opencode" || input.entry.driverKind === "antigravity") && + resolveProviderModelPolicy(input.entry.snapshot).preserveUnavailableModels === true && input.entry.instanceId === input.activeInstanceId && input.option.slug === input.activeModel && input.option.isUnavailable === true @@ -174,6 +188,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { model: props.model, options: modelOptionsByInstance.get(props.activeInstanceId) ?? [], }); + const [fusionSelection, setFusionSelection] = useState<{ + instanceId: ProviderInstanceId; + model: string; + } | null>(() => + activeModel?.fusion && !activeModel.isUnavailable + ? { instanceId: props.activeInstanceId, model: activeModel.slug } + : null, + ); + const isFusionPickerOpen = fusionSelection !== null; const activeModelSlug = activeModel?.slug ?? (props.model === ANTIGRAVITY_DEFAULT_MODEL ? "" : props.model); const activeModelKey = activeModelSlug @@ -239,6 +262,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ); useLayoutEffect(() => { + if (isFusionPickerOpen) return; focusSearchInput(); const frame = window.requestAnimationFrame(() => { focusSearchInput(); @@ -250,7 +274,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { window.cancelAnimationFrame(frame); window.clearTimeout(timeout); }; - }, [focusSearchInput]); + }, [focusSearchInput, isFusionPickerOpen]); // Create a Set for efficient lookup. Favorites are keyed by // `${instanceId}:${slug}`; the storage schema widened from ProviderDriverKind @@ -330,6 +354,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 } : {}), @@ -385,7 +410,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ); // Filter models based on search query and selected instance - const filteredModels = useMemo(() => { + const matchingModels = useMemo(() => { let result = flatModels; // Apply tokenized fuzzy search across the combined provider/model search fields. @@ -490,6 +515,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { selectedInstanceId, ]); + const filteredModels = useMemo( + () => + selectedInstanceId === "favorites" && !isSearching + ? matchingModels + : collapseFusionModels(matchingModels, props.activeInstanceId, activeModelSlug), + [matchingModels, props.activeInstanceId, activeModelSlug, selectedInstanceId, isSearching], + ); + const legacySection = useMemo(() => { if (isSearching || selectedInstanceId === "favorites") { return null; @@ -548,15 +581,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) + ) { + setFusionSelection({ instanceId, model: modelSlug }); return; } // `resolveSelectableModel` uses the driver kind for normalization @@ -567,7 +601,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onInstanceModelChange(instanceId, resolvedModel); } }, - [entryByInstanceId, getModelDisabledReason, modelOptionsByInstance, onInstanceModelChange], + [ + entryByInstanceId, + getModelDisabledReason, + modelOptionsByInstance, + onInstanceModelChange, + setFusionSelection, + selectedInstanceId, + isSearching, + ], ); const toggleFavorite = useCallback( @@ -677,44 +719,44 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { [favoritesSet, modelJumpLabelByKey], ); - useEffect(() => { - const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.defaultPrevented || event.repeat || isCommandPaletteOpen()) { - return; - } + const onWindowKeyDown = useEffectEvent((event: globalThis.KeyboardEvent) => { + if (isFusionPickerOpen || event.defaultPrevented || event.repeat || isCommandPaletteOpen()) { + return; + } - const command = resolveShortcutCommand(event, keybindings, { - platform: navigator.platform, - context: modelJumpShortcutContext, - }); - const jumpIndex = modelPickerJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) { - return; - } - event.preventDefault(); - event.stopPropagation(); + const command = resolveShortcutCommand(event, keybindings, { + platform: navigator.platform, + context: modelJumpShortcutContext, + }); + 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); - }; + const targetModelKey = modelJumpModelKeys[jumpIndex]; + if (!targetModelKey) { + return; + } + const model = parseModelPickerModelKey(targetModelKey); + if (!model) { + return; + } + handleModelSelect(model.slug, model.instanceId); + }); + useEffect(() => { window.addEventListener("keydown", onWindowKeyDown, true); return () => { window.removeEventListener("keydown", onWindowKeyDown, true); }; - }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]); + }, []); + // Remeasure when list contents change or the list remounts after leaving Fusion. useLayoutEffect(() => { - setShowTopScrollFade(false); - setShowBottomScrollFade(filteredItemKeys.length > 5); + if (isFusionPickerOpen) return; let nestedFrame = 0; const frame = window.requestAnimationFrame(() => { updateModelListScrollFades(); @@ -724,12 +766,36 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { window.cancelAnimationFrame(frame); window.cancelAnimationFrame(nestedFrame); }; - }, [filteredItemKeys, updateModelListScrollFades]); + }, [filteredItemKeys, isFusionPickerOpen, updateModelListScrollFades]); + + if (fusionSelection) { + const instanceId = fusionSelection.instanceId; + const models = flatModels.filter( + (model) => + model.instanceId === instanceId && + model.fusion && + !model.isUnavailable && + matchesLockedProvider(model) && + !getModelDisabledReason?.(instanceId, model.slug), + ); + return ( + setFusionSelection(null)} + onSelect={(model) => onInstanceModelChange(instanceId, model)} + /> + ); + } return (
{/* Sidebar */} @@ -887,7 +953,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} isFavorite={favoritesSet.has( providerModelKey(model.instanceId, model.slug), diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 6f777399451d..a0034bf4f0e8 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, @@ -78,14 +79,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; @@ -224,8 +227,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { => @@ -217,6 +222,7 @@ function getSelectedTraits( function getTraitsSectionVisibility(input: { provider: ProviderDriverKind; + modelPolicy?: ServerProvider["modelPolicy"]; models: ReadonlyArray; model: string | null | undefined; prompt: string; @@ -232,6 +238,7 @@ function getTraitsSectionVisibility(input: { input.modelOptions, input.allowPromptInjectedEffort ?? true, input.planModeEnabled, + input.modelPolicy, ); const showEffort = selected.primarySelectDescriptor !== null; @@ -259,6 +266,7 @@ function getTraitsSectionVisibility(input: { export function shouldRenderTraitsControls(input: { provider: ProviderDriverKind; + modelPolicy?: ServerProvider["modelPolicy"]; models: ReadonlyArray; model: string | null | undefined; prompt: string; @@ -271,6 +279,7 @@ export function shouldRenderTraitsControls(input: { export interface TraitsMenuContentProps { provider: ProviderDriverKind; + modelPolicy?: ServerProvider["modelPolicy"]; instanceId?: ProviderInstanceId; models: ReadonlyArray; model: string | null | undefined; @@ -286,6 +295,7 @@ export interface TraitsMenuContentProps { export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ provider, + modelPolicy, instanceId, models, model, @@ -326,6 +336,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ modelIsUnavailable, } = getTraitsSectionVisibility({ provider, + modelPolicy, models, model, prompt, @@ -538,6 +549,7 @@ export function buildTraitsTriggerDisplay(input: { export const TraitsPicker = memo(function TraitsPicker({ provider, + modelPolicy, instanceId, models, model, @@ -561,6 +573,7 @@ export const TraitsPicker = memo(function TraitsPicker({ const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = getTraitsSectionVisibility({ provider, + modelPolicy, models, model, prompt, @@ -571,6 +584,7 @@ export const TraitsPicker = memo(function TraitsPicker({ if ( !shouldRenderTraitsControls({ provider, + modelPolicy, models, model, prompt, @@ -650,6 +664,7 @@ export const TraitsPicker = memo(function TraitsPicker({ { expect(renderProviderTraitsMenuContent(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..828c6dfe9f75 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -1,10 +1,12 @@ import { + resolveProviderModelPolicy, type ModelCapabilities, type ProviderDriverKind, type ProviderInstanceId, type ProviderOptionSelection, type ScopedThreadRef, type ServerProviderModel, + type ServerProvider, } from "@t3tools/contracts"; import { buildExplicitProviderOptionSelectionsFromDescriptors, @@ -24,6 +26,7 @@ import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./T export type ComposerProviderStateInput = { provider: ProviderDriverKind; + modelPolicy?: ServerProvider["modelPolicy"]; model: string; models: ReadonlyArray; promptInjectionState?: ComposerPromptInjectionState; @@ -44,6 +47,7 @@ export type ComposerProviderState = { type TraitsRenderInput = { provider: ProviderDriverKind; + modelPolicy?: ServerProvider["modelPolicy"]; instanceId?: ProviderInstanceId; threadRef?: ScopedThreadRef; draftId?: DraftId; @@ -94,12 +98,19 @@ function resolveComposerOptionSelections( provider: ProviderDriverKind, modelOptions: ReadonlyArray | null | undefined, planModeEnabled: boolean, + modelPolicy: ServerProvider["modelPolicy"], ): { caps: ModelCapabilities; selections: ReadonlyArray | undefined; } { const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled); - return { caps, selections: withImplicitFastModeDefault(caps, modelOptions) }; + return { + caps, + selections: + resolveProviderModelPolicy({ driver: provider, modelPolicy }).optionSelection === "exact" + ? (modelOptions ?? undefined) + : withImplicitFastModeDefault(caps, modelOptions), + }; } export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { @@ -110,6 +121,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com modelOptions, promptInjectionState = "none", planModeEnabled, + modelPolicy, } = input; if (provider === "opencode") { const normalizedModel = normalizeModelSlug(model, provider); @@ -132,8 +144,14 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com provider, modelOptions, planModeEnabled, + modelPolicy, ); - const descriptors = getProviderOptionDescriptors({ caps, selections }); + const descriptors = getProviderOptionDescriptors({ + caps, + selections, + preserveUnavailableSelections: + resolveProviderModelPolicy({ driver: provider, modelPolicy }).optionSelection === "exact", + }); const primarySelectDescriptor = descriptors.find( (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> => descriptor.type === "select", @@ -178,6 +196,7 @@ function renderTraitsControl( planModeEnabled, size, hidden, + modelPolicy, triggerVariant, triggerClassName, isComposerOwned, @@ -189,11 +208,13 @@ function renderTraitsControl( provider, modelOptions, planModeEnabled, + modelPolicy, ); if ( !hasTarget || !shouldRenderTraitsControls({ provider, + modelPolicy, models, model, modelOptions: resolvedModelOptions, @@ -206,6 +227,7 @@ function renderTraitsControl( return ( { + it("shows one Fusion entry per account and preserves the selected pairing", () => { + const entries = [...models, { ...models[1]!, instanceId: otherInstanceId }]; + const result = collapseFusionModels(entries, instanceId, "opus-glm"); + expect(result.map((model) => [model.instanceId, model.slug, model.name])).toEqual([ + [instanceId, "swe", "SWE-2"], + [instanceId, "opus-glm", "Fusion"], + [otherInstanceId, "fable-swe", "Fusion"], + ]); + }); + + it("keeps a removed saved pairing visible alongside the available Fusion entry", () => { + const unavailable = { ...models[1]!, slug: "old-fusion", isUnavailable: true }; + expect( + collapseFusionModels([...models, unavailable], instanceId, "old-fusion").map( + (model) => model.slug, + ), + ).toEqual(["swe", "fable-swe", "old-fusion"]); + }); + + it("opens a matching pairing when a search excludes the selected one", () => { + const matches = models.filter((model) => model.fusion?.lead.id === "fable"); + expect(collapseFusionModels(matches, instanceId, "opus-glm")[0]?.slug).toBe("fable-swe"); + }); + + it("keeps the sidekick across leads and falls back only to an offered pairing", () => { + expect(findFusionLeadPairing(models, "opus", "swe")?.slug).toBe("opus-swe"); + expect(findFusionLeadPairing(models, "fable", "glm")?.slug).toBe("fable-swe"); + expect(findFusionLeadPairing(models, "missing", "swe")).toBeUndefined(); + expect( + findFusionLeadPairing( + models.map((model) => ({ ...model, isUnavailable: true })), + "opus", + "swe", + ), + ).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/chat/fusionModelPicker.ts b/apps/web/src/components/chat/fusionModelPicker.ts new file mode 100644 index 000000000000..8a62bc8b7fe1 --- /dev/null +++ b/apps/web/src/components/chat/fusionModelPicker.ts @@ -0,0 +1,39 @@ +import type { ProviderInstanceId } from "@t3tools/contracts"; +import type { ModelEsque } from "./providerIconUtils"; + +/** One entry per account opens the pairing editor; saved model IDs remain unchanged. */ +export function collapseFusionModels( + models: ReadonlyArray, + activeInstanceId: ProviderInstanceId, + activeModel: string, +): T[] { + const seen = new Set(); + return models.flatMap((model) => { + if (!model.fusion || model.isUnavailable) return [model]; + if (seen.has(model.instanceId)) return []; + seen.add(model.instanceId); + const active = + model.instanceId === activeInstanceId + ? models.find( + (entry) => + entry.instanceId === activeInstanceId && + entry.slug === activeModel && + entry.fusion && + !entry.isUnavailable, + ) + : undefined; + return [{ ...(active ?? model), name: "Fusion", shortName: "Fusion", isFusionGroup: true }]; + }); +} + +/** Preserve the sidekick when changing leads, falling back only to an offered pairing. */ +export function findFusionLeadPairing( + models: ReadonlyArray, + leadId: string, + sidekickId: string, +) { + const candidates = models.filter( + (model) => model.fusion?.lead.id === leadId && !model.isUnavailable, + ); + return candidates.find((model) => model.fusion?.sidekick.id === sidekickId) ?? candidates[0]; +} diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index db0e5ca222f3..1c284f6305d6 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,9 +1,10 @@ -import { ProviderDriverKind } from "@t3tools/contracts"; +import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts"; import { AntigravityIcon, ClaudeAI, CursorIcon, GrokIcon, + DevinIcon, Icon, OpenAI, OpenCodeIcon, @@ -15,10 +16,13 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("devin")]: DevinIcon, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, }; export type ModelEsque = { + isFusionGroup?: boolean; + fusion?: ServerProviderModel["fusion"]; slug: string; name: string; shortName?: string | undefined; diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index a08b918be4c5..aa84917eab20 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -201,6 +201,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting {!mixedModel ? (