From 2b4c0c6924eb707dea105bd20177dfff6bc658e7 Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Fri, 11 Sep 2026 14:06:43 -0400 Subject: [PATCH 01/15] feat(providers): add Devin CLI through ACP --- apps/mobile/assets/devin.png | Bin 0 -> 2352 bytes apps/mobile/src/components/ProviderIcon.tsx | 11 + .../src/features/threads/ThreadComposer.tsx | 7 +- .../features/threads/ThreadSettingsSheet.tsx | 8 +- apps/mobile/src/lib/modelOptions.test.ts | 27 + apps/mobile/src/lib/modelOptions.ts | 21 +- apps/mobile/src/lib/providerOptions.test.ts | 27 + apps/mobile/src/lib/providerOptions.ts | 2 + .../src/state/use-thread-composer-state.ts | 4 +- apps/server/scripts/acp-mock-agent.ts | 153 +++- .../src/provider/Drivers/DevinDriver.test.ts | 167 ++++ .../src/provider/Drivers/DevinDriver.ts | 232 ++++++ .../src/provider/Drivers/DevinSkills.test.ts | 100 +++ .../src/provider/Drivers/DevinSkills.ts | 82 ++ .../src/provider/Layers/DevinAdapter.test.ts | 512 ++++++++++++ .../src/provider/Layers/DevinAdapter.ts | 767 ++++++++++++++++++ .../src/provider/Layers/DevinProvider.test.ts | 46 ++ .../src/provider/Layers/DevinProvider.ts | 128 +++ .../provider/Layers/ProviderRegistry.test.ts | 1 + .../src/provider/Layers/ProviderRegistry.ts | 17 +- .../src/provider/acp/AcpSessionRuntime.ts | 25 +- .../src/provider/acp/DevinAcpSupport.test.ts | 112 +++ .../src/provider/acp/DevinAcpSupport.ts | 284 +++++++ .../src/provider/acp/DevinModels.test.ts | 254 ++++++ apps/server/src/provider/acp/DevinModels.ts | 160 ++++ apps/server/src/provider/builtInDrivers.ts | 5 +- .../server/src/provider/testUtils/devinCli.ts | 136 ++++ apps/server/src/serverSettings.test.ts | 21 + apps/server/src/serverSettings.ts | 15 +- apps/web/src/components/Icons.tsx | 12 + .../chat/ModelPickerContent.test.ts | 2 + .../components/chat/ModelPickerContent.tsx | 4 +- .../components/chat/ProviderModelPicker.tsx | 4 +- apps/web/src/components/chat/TraitsPicker.tsx | 1 + .../chat/composerProviderState.test.tsx | 25 + .../components/chat/composerProviderState.tsx | 14 +- .../src/components/chat/providerIconUtils.ts | 2 + .../components/settings/providerDriverMeta.ts | 9 + .../src/components/settings/settingsSearch.ts | 4 +- apps/web/src/composerDraftStore.ts | 13 +- apps/web/src/modelSelection.test.ts | 50 ++ apps/web/src/modelSelection.ts | 6 +- docs/README.md | 2 +- docs/user/install.md | 2 +- docs/user/providers-devin.md | 17 + packages/contracts/src/model.ts | 1 + packages/contracts/src/settings.ts | 34 + packages/effect-acp/src/client.ts | 2 +- packages/effect-acp/src/rpc.ts | 2 +- packages/effect-acp/src/schema.ts | 21 + packages/shared/src/model.ts | 40 + 51 files changed, 3516 insertions(+), 75 deletions(-) create mode 100644 apps/mobile/assets/devin.png create mode 100644 apps/server/src/provider/Drivers/DevinDriver.test.ts create mode 100644 apps/server/src/provider/Drivers/DevinDriver.ts create mode 100644 apps/server/src/provider/Drivers/DevinSkills.test.ts create mode 100644 apps/server/src/provider/Drivers/DevinSkills.ts create mode 100644 apps/server/src/provider/Layers/DevinAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/DevinAdapter.ts create mode 100644 apps/server/src/provider/Layers/DevinProvider.test.ts create mode 100644 apps/server/src/provider/Layers/DevinProvider.ts create mode 100644 apps/server/src/provider/acp/DevinAcpSupport.test.ts create mode 100644 apps/server/src/provider/acp/DevinAcpSupport.ts create mode 100644 apps/server/src/provider/acp/DevinModels.test.ts create mode 100644 apps/server/src/provider/acp/DevinModels.ts create mode 100644 apps/server/src/provider/testUtils/devinCli.ts create mode 100644 docs/user/providers-devin.md diff --git a/apps/mobile/assets/devin.png b/apps/mobile/assets/devin.png new file mode 100644 index 0000000000000000000000000000000000000000..5bae1f3dcd66071bec4763c0101ef377307923a7 GIT binary patch literal 2352 zcmeH|=Tno37RKL%ydX_lkd-1JC4h(_R1pYO2$B#WNbibXl_qF{^spdBrK-4#ltha3 z01MJ2bO9+!mrw+0(jf>dA{W2xhx-TIPy5Vs&Y3x1&df9On-hn}nQ(Ima{vIqjWIQ{ zI-c}jb%N!%Ux#L>0RZ$q-ke};;%9!m`B(oY@c&GJoq~UMeD6b`70w!P<9GfEaXADd zl7GN_p*-tEww=EZ1VlJ`zf&4m)v-o4^^390;mMO+ihSo!<(9>{;7fAe$P1EcK_n|< zoatrr06T-|sII^AHwKnd>T(~~P*?vbFW&o1TPt}u@;W^+^3TlH?wpLOPfu4mAKCx_ zn2s^hCy+AMUhdjDV&D-QRE#MZwm19Hqg@{N*rx_w2RF-s+m)=1R9CE-O^#0f5^*EE zDnGj7Kii63bjHQSF!Z!a0Gie1T|LnY_$|i$9}}e39>0&#pz7IpJfDh>O;6GkPy6z9 z!talc>)B^URdd9nN68LOi}ov7Xpd(^NI}$~&F}h*Gc}@c$vuT3nVkptName~VLxLv z{Sf%Yn6_)JOg>sINis>+SIhyqbQfx`J{_q}y3IKt-7DgW2ya_j71ZM)GCZoVaR~O< zWY>7f4^}|hn-y=a7a4c64UGNGmj0H?JLwJbGetX)rx~m;zcL<;@U{v&j-zj$Fvj^y z!GNFfS--^9Rft}Kkz3~KY9#oDx>c5RP1Oa`b+ZAQ@___OQ@1!wFV6|$gTQqg=Na8$ zaLUrz&F&tgxJudZWA|Dh=y1NXf3qRsiPTd9_^G&iCdj(%C8csBUp~SF%eHY*T_Al^ zPZ1ND?lhNE6r)t6DH@(c0ZQvz+}wyxj&658n~|YoNqLuic3EGJr6q$*Sov1RjAc&% z7kR=r9)XDH^@lv|;qWc=XU2(rf$^#o9fp-omJKc(uXauCajv%SBe(rZsC%pw($czNqgo_NO)*UCQ)oVg220(&#}7 z+`(wL=bSDw$gf{P+(q8>GaogvZ#>WYFA0`04aLsvD`~)x?ya4;ucmU2;s(A6pfdE} z)*u?S{=D93dFkYI^q63eR^S7XR!OrAfbPk?ka}8TXSu3t0rPxsT8q)YQd}a!(1+^oKG6v#*z;;wOc$cwk{P@Sxw4aAVuJ zJcd{D6mz}6xV@-;I7hT z8w)x2DS|=~r-s2gg3!Lfy-1~DP*_l?!1hpnD1iWGnimIkUmlaCKrO<9+<)Y$UG;z4 zE)AlzU9s4t*%FOJ?N}I?0lFTc+q-hTqD#~!Vc=aB_1`lebRYwRUR{u(w&lC3!I@5h zs;3338%(j;-|Q!axF`FU8+(b>XVoGXdmedx!i&LICt}-RSF_arJcpQZCb=L!@{eh` zTOtEOc3R8_yIOP;gkA?I%{);l!&_{v5!swCTpbM!_liqm@(~ldoA%h!^vT)G1GZPM z673YFL#IvcL=3=9YAh7nU~j1xsdc-wS)acW7iW?`%Zu8TWy8pQxQ&!Ygo>{4rRWm6 zF-_KXP1vl0G#u_H;m6Lty`#AAm9Q>}5t>c)kyU@xL9TxhqQdZI|BDlm6s?-0q=&NR zjwYz~%oHiT8K;3J&-v(><~7QJlGG_b5;LZEa3$FI{kF@hwS)|wiuSRh^brp+mXrEW zFicIf_G?Xq??~>21PioBqyLW$^zeYW-Gv-<-)q2 z%vr?5^7up)|BDDOoo332%3|^D-W1pfN4Q)cjjoJ$q8HWCiwZf1y86zL468z_Ov9r0 zLnI-a9PcjAHT@5w)}f%wF~#}EG-sp9&{V@48i$OX8~f032*{9f_ks1^YAjZeNdDQ> z+Lbj>+snIMJ05!kmkYUj(F4_3+0*=Oe2ZX_$TsG+!VGJxbcDY}L$1|ZK%4&QLKBL6Y(yKvf6nGBh+t@Z2SVT^G`r3P-~ F{{nupE}8%U literal 0 HcmV?d00001 diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 374738d0aeca..767581f81a98 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -26,6 +26,17 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "devin") { + return ( + + ); + } + if (props.provider === "claudeAgent") { return ( diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 7ea20e49ce52..df4af2f2c8fa 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -501,10 +501,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const providerOptionDescriptors = useMemo( () => resolveProviderOptionDescriptors({ + provider: currentModelOption?.providerDriver, capabilities: currentModelOption?.capabilities, selections: currentModelSelection.options, }), - [currentModelOption?.capabilities, currentModelSelection.options], + [ + currentModelOption?.providerDriver, + 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..667784ed869d 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -429,6 +429,7 @@ function ThreadSettingsSessionProvider( ? getProviderOptionDescriptors({ caps: pendingModel.capabilities, selections: pendingModel.selection.options, + preserveUnavailableSelections: pendingModel.providerDriver === "devin", }) : [] : props.optionDescriptors, @@ -1292,10 +1293,15 @@ export function NewTaskThreadSettingsRouteScreen() { const optionDescriptors = useMemo( () => resolveProviderOptionDescriptors({ + provider: flow.selectedModelOption?.providerDriver, capabilities: flow.selectedModelOption?.capabilities, selections: flow.selectedModel?.options, }), - [flow.selectedModel?.options, flow.selectedModelOption?.capabilities], + [ + flow.selectedModel?.options, + flow.selectedModelOption?.capabilities, + flow.selectedModelOption?.providerDriver, + ], ); return ( diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 98896b990ec4..ab715b514eab 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -409,3 +409,30 @@ describe("mobile model options", () => { ).toBeNull(); }); }); + +describe("Devin account model selection", () => { + it("preserves SWE-2 and marks it unavailable after an account catalog change", () => { + const selection = { instanceId: ProviderInstanceId.make("devin_work"), model: "swe-2-high" }; + const config = { + providers: [ + { + instanceId: selection.instanceId, + driver: "devin", + displayName: "Devin Work", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [{ slug: "swe-1.6", name: "SWE-1.6", capabilities: {} }], + }, + ], + } as unknown as ServerConfig; + expect(resolveSelectableModelSelection(config, selection)).toBe(selection); + expect(resolveDefaultableModelSelection(config, selection)).toBe(selection); + expect(isModelSelectionUnavailable(config, selection)).toBe(true); + expect( + buildModelOptions(config, selection).find( + (option) => option.selection.model === selection.model, + ), + ).toMatchObject({ selection, isUnavailable: true, providerDriver: "devin" }); + }); +}); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 4e8295733141..5847ac7be0ad 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -61,7 +61,7 @@ function normalizeSelectionOptions( }; } -/** Whether a known Antigravity selection needs setup or a different model. */ +/** Whether a known account-model selection needs setup or a different model. */ export function isModelSelectionUnavailable( config: T3ServerConfig | null | undefined, selection: ModelSelection | null | undefined, @@ -75,7 +75,7 @@ export function isModelSelectionUnavailable( const driver = provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver; return ( - driver === "antigravity" && + (driver === "antigravity" || driver === "devin") && (!provider || !provider.enabled || !provider.installed || @@ -86,7 +86,7 @@ export function isModelSelectionUnavailable( } /** - * Keep Antigravity selections when setup or catalog changes make them + * Keep Antigravity and Devin 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. */ @@ -102,7 +102,7 @@ export function resolveSelectableModelSelection( ); const driver = provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver; - if (driver === "antigravity") { + if (driver === "antigravity" || driver === "devin") { return selection; } return provider && @@ -114,7 +114,7 @@ export function resolveSelectableModelSelection( } /** - * Reject legacy models for implicit defaults, except Antigravity selections, + * Reject legacy models for implicit defaults, except Antigravity and Devin selections, * which must not silently change after a catalog update. Explicit picks in * the settings sheet are unaffected. */ @@ -128,7 +128,11 @@ export function resolveDefaultableModelSelection( } 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; + return provider?.driver !== "antigravity" && + provider?.driver !== "devin" && + model?.isLegacy === true + ? null + : usable; } export function resolveNewTaskModelSelection(input: { @@ -158,7 +162,8 @@ export function buildModelOptions( !provider.enabled || !provider.installed || provider.auth.status === "unauthenticated" || - (provider.driver === "antigravity" && provider.availability === "unavailable") + ((provider.driver === "antigravity" || provider.driver === "devin") && + provider.availability === "unavailable") ) { continue; } @@ -194,7 +199,7 @@ export function buildModelOptions( options.set(key, { ...existing, selection: - existing.providerDriver === "antigravity" + existing.providerDriver === "antigravity" || existing.providerDriver === "devin" ? fallbackModelSelection : normalizeSelectionOptions(fallbackModelSelection, existing.capabilities), }); diff --git a/apps/mobile/src/lib/providerOptions.test.ts b/apps/mobile/src/lib/providerOptions.test.ts index 9b94cecb3db9..4e7a5d65b72b 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({ + provider: "devin", + 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..cea6cb1bc608 100644 --- a/apps/mobile/src/lib/providerOptions.ts +++ b/apps/mobile/src/lib/providerOptions.ts @@ -9,6 +9,7 @@ import { } from "@t3tools/shared/model"; export function resolveProviderOptionDescriptors(input: { + readonly provider?: string; readonly capabilities: ModelCapabilities | null | undefined; readonly selections: ReadonlyArray | null | undefined; }): ReadonlyArray { @@ -18,6 +19,7 @@ export function resolveProviderOptionDescriptors(input: { return getProviderOptionDescriptors({ caps: input.capabilities, selections: input.selections, + preserveUnavailableSelections: input.provider === "devin", }); } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 8f10bc92acf8..cd5fa9772f20 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -333,8 +333,8 @@ export function useThreadComposerState() { isModelSelectionUnavailable(serverConfig, modelSelection) ) { Alert.alert( - "Antigravity model unavailable", - "Set up Antigravity on web or desktop, or choose another model.", + "Model unavailable", + "Set up this provider on web or desktop, or choose another model.", ); return null; } diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index c923fa401021..015b6172f58b 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"; @@ -67,8 +69,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"; @@ -114,6 +120,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 [ { @@ -298,29 +327,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 { @@ -350,6 +390,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 }; } @@ -366,6 +415,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({ @@ -433,6 +483,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); } @@ -588,6 +650,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({ @@ -617,6 +680,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({ @@ -1232,6 +1311,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", @@ -1292,7 +1385,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)); } @@ -1317,6 +1410,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..a88707eaa1a1 --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinDriver.test.ts @@ -0,0 +1,167 @@ +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 { mergeProviderSnapshot } from "../Layers/ProviderRegistry.ts"; +import { DevinDriver } from "./DevinDriver.ts"; +import { + makeDevinCli as makeHarness, + devinTestLayer as layer, + encodeDevinProviders as encodeProviders, + encodeDevinSkills, +} 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("keeps skills discoverable when ACP commands arrive before the workspace probe", () => + Effect.gen(function* () { + const h = yield* makeHarness({ T3_DEVIN_AUTH_STATUS: "Logged in (via Devin)." }); + const instance = yield* DevinDriver.create({ + instanceId, + displayName: "Devin test account", + enabled: true, + config: h.settings, + environment: [], + }); + yield* instance.snapshot.refresh; + yield* instance.adapter.startSession({ threadId, cwd: h.root, runtimeMode: "full-access" }); + expect((yield* instance.snapshot.getSnapshot).workspaceSnapshots).toEqual([]); + if (!instance.snapshotForCwd) throw new Error("Devin must expose workspace metadata."); + const discovered = yield* instance.snapshotForCwd(h.root); + expect(discovered.slashCommands.some((command) => command.name === "plan")).toBe(true); + expect(discovered.workspaceSnapshots?.map((workspace) => workspace.cwd)).toEqual([h.root]); + }).pipe(Effect.provide(driverLayer)), +); + +it.effect( + "replaces cached Devin variants with family models and clears account 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_AUTH_STATUS_FILE: statusFile, + 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: [], + }); + if (!instance.snapshotForCwd) throw new Error("Devin must expose workspace metadata."); + const snapshot = yield* instance.snapshot.refresh; + expect(() => encodeProviders([snapshot])).not.toThrow(); + expect(snapshot.instanceId).toBe(instanceId); + expect(snapshot.supportsTextGeneration).toBe(false); + const skillsFile = path.join(h.root, "devin-test-skills.json"); + yield* fs.writeFileString( + skillsFile, + encodeDevinSkills([ + { + name: "visual-check", + description: "Check the browser.", + display_name: "Visual check", + base_dir: path.join(h.root, ".devin", "skills", "visual-check"), + triggers: ["user"], + errors: [], + }, + ]), + ); + const workspace = yield* instance.snapshotForCwd(h.root); + expect(workspace.skills.map((skill) => skill.name)).toEqual(["visual-check"]); + expect(yield* fs.exists(h.launchLog)).toBe(false); + const cached = { + ...snapshot, + models: snapshot.models.map((model) => ({ ...model, slug: `${model.slug}-old` })), + }; + expect(mergeProviderSnapshot(cached, snapshot).models).toEqual(snapshot.models); + const withoutTraits = { + ...snapshot, + models: snapshot.models.map((model) => ({ + ...model, + capabilities: { optionDescriptors: [] }, + })), + }; + expect(mergeProviderSnapshot(snapshot, withoutTraits).models).toEqual(withoutTraits.models); + const title = yield* instance.textGeneration + .generateThreadTitle({ + cwd: h.root, + message: "Title", + modelSelection: { instanceId, model: "devin-test" }, + }) + .pipe(Effect.result); + expect(title._tag).toBe("Failure"); + 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(() => encodeProviders([activeSnapshot])).not.toThrow(); + 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.models).toEqual(snapshot.models); + 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); + yield* fs.writeFileString(statusFile, "Not logged in."); + yield* instance.snapshot.refresh; + const signedOut = yield* instance.snapshotForCwd(h.root); + expect(signedOut.auth.status).toBe("unauthenticated"); + expect(mergeProviderSnapshot(snapshot, signedOut).models).toEqual(signedOut.models); + 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)), +); diff --git a/apps/server/src/provider/Drivers/DevinDriver.ts b/apps/server/src/provider/Drivers/DevinDriver.ts new file mode 100644 index 000000000000..ae9148202330 --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinDriver.ts @@ -0,0 +1,232 @@ +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, + 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, + ); + const commandsByCwd = new Map>(); + const skillsByCwd = new Map>(); + const probeSkills = (cwd: string) => + discoverDevinSkills(settings, processEnv, cwd).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(Path.Path, path), + Effect.tap((skills) => Effect.sync(() => skillsByCwd.set(cwd, skills))), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: `Could not discover Devin skills for '${cwd}'.`, + cause, + }), + ), + ); + 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 } : {}), + })); + if (Equal.equals(commandsByCwd.get(cwd), next)) return; + commandsByCwd.set(cwd, next); + 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") { + commandsByCwd.clear(); + skillsByCwd.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" + ? [...skillsByCwd.keys()] + : [] + ).map((cwd) => ({ + cwd, + slashCommands: commandsByCwd.get(cwd) ?? snapshot.slashCommands, + skills: skillsByCwd.get(cwd) ?? [], + 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" + ? Effect.forEach( + [...skillsByCwd.keys()], + (cwd) => + probeSkills(cwd).pipe( + Effect.catch((cause) => Effect.logWarning(cause.message)), + ), + { discard: true }, + ) + : 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); + return { + ...withWorkspaceMetadata(snapshot), + skills, + slashCommands: commandsByCwd.get(cwd) ?? snapshot.slashCommands, + }; + }), + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/DevinSkills.test.ts b/apps/server/src/provider/Drivers/DevinSkills.test.ts new file mode 100644 index 000000000000..47f0b8d761ab --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinSkills.test.ts @@ -0,0 +1,100 @@ +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { discoverDevinSkills, prepareDevinSkillPrompt } from "./DevinSkills.ts"; +import { makeDevinCli, devinTestLayer, encodeDevinSkills } from "../testUtils/devinCli.ts"; + +const skill = (name: string, triggers = ["user", "model"]) => ({ + name, + description: `Use ${name}.`, + base_dir: `/skills/${name}`, + display_name: name, + triggers, + errors: [], +}); + +it.effect( + "uses the CLI catalog for each workspace, including invocation policy and display names", + () => + Effect.gen(function* () { + const h = yield* makeDevinCli(); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString( + path.join(h.root, "devin-test-skills.json"), + encodeDevinSkills([ + { ...skill("visual-check", ["user"]), display_name: "Visual check" }, + skill("internal", ["model"]), + { ...skill("broken"), errors: ["Invalid frontmatter"] }, + { ...skill("builtin-command"), base_dir: "" }, + ]), + ); + const skills = yield* discoverDevinSkills(h.settings, h.environment, h.root); + expect(skills).toEqual([ + expect.objectContaining({ name: "broken", enabled: false }), + expect.objectContaining({ name: "internal", enabled: true, 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-project-" }); + expect(yield* discoverDevinSkills(h.settings, h.environment, other)).toEqual([]); + }).pipe(Effect.provide(devinTestLayer)), +); + +it.effect( + "invokes a picked user-only skill while retaining arguments and literal dollar tokens", + () => + Effect.gen(function* () { + const h = yield* makeDevinCli(); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString( + path.join(h.root, "devin-test-skills.json"), + encodeDevinSkills([skill("visual-check", ["user"]), skill("internal", ["model"])]), + ); + for (const [input, expected] 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"], + ] as const) { + expect(yield* prepareDevinSkillPrompt(input, h.settings, h.environment, h.root)).toBe( + expected, + ); + } + const multiple = yield* prepareDevinSkillPrompt( + "$visual-check $visual-check", + h.settings, + h.environment, + h.root, + ).pipe(Effect.result); + expect(multiple._tag).toBe("Failure"); + }).pipe(Effect.provide(devinTestLayer)), +); + +it.effect("surfaces failed discovery so it can be retried, without probing ordinary prompts", () => + Effect.gen(function* () { + const h = yield* makeDevinCli({ T3_DEVIN_FAIL_SKILLS: "1" }); + expect(yield* prepareDevinSkillPrompt("Say hello", h.settings, h.environment, h.root)).toBe( + "Say hello", + ); + expect(yield* prepareDevinSkillPrompt("Print $HOME", h.settings, h.environment, h.root)).toBe( + "Print $HOME", + ); + const failed = yield* discoverDevinSkills(h.settings, h.environment, h.root).pipe( + Effect.result, + ); + expect(failed._tag).toBe("Failure"); + }).pipe(Effect.provide(devinTestLayer)), +); diff --git a/apps/server/src/provider/Drivers/DevinSkills.ts b/apps/server/src/provider/Drivers/DevinSkills.ts new file mode 100644 index 000000000000..766d6e0ea159 --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinSkills.ts @@ -0,0 +1,82 @@ +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 => ({ + name: skill.name, + 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/DevinAdapter.test.ts b/apps/server/src/provider/Layers/DevinAdapter.test.ts new file mode 100644 index 000000000000..9f57bb9da8dd --- /dev/null +++ b/apps/server/src/provider/Layers/DevinAdapter.test.ts @@ -0,0 +1,512 @@ +import * as Schema from "effect/Schema"; +import { expect, it } from "@effect/vitest"; +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 Stream from "effect/Stream"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { ServerConfig } from "../../config.ts"; +import { makeDevinAdapter } from "./DevinAdapter.ts"; +import { + makeDevinCli, + devinTestLayer as layer, + decodeDevinLaunch as decodeLaunch, + encodeDevinSkills, +} from "../testUtils/devinCli.ts"; + +const threadId = ThreadId.make("devin-thread"); +const instanceId = ProviderInstanceId.make("devin-account"); +const makeHarness = Effect.fn("makeDevinAdapterHarness")(function* ( + ...args: Parameters +) { + const cli = yield* makeDevinCli(...args); + const { settings, environment, root } = cli; + const adapter = yield* makeDevinAdapter(settings, { instanceId, environment }); + const events: ProviderRuntimeEvent[] = []; + 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); + }), + ).pipe(Effect.forkScoped); + const start = (model = "devin-test-low") => + adapter.startSession({ + threadId, + cwd: root, + runtimeMode: "approval-required", + modelSelection: { instanceId, model }, + }); + return { ...cli, adapter, events, approval, start }; +}); + +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; + 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; + yield* 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)), + ); + const session = yield* h.start(); + const firstDirectory = (yield* h.requests).find((request) => request.method === "session/new") + ?.params?.additionalDirectories?.[0]; + 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(1); + expect( + requests.some( + (entry) => + entry.method === "_cognition.ai/mcp/connectServer" && + entry.params?.workspaceDirs?.[0] === request?.params?.additionalDirectories?.[0], + ), + ).toBe(true); + } + const directory = requests.find((request) => request.method === "session/load")?.params + ?.additionalDirectories?.[0]; + 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; + yield* fs.writeFileString( + path.join(h.root, "devin-test-skills.json"), + encodeDevinSkills([ + { + name: "visual-check", + description: "Check a browser page.", + display_name: "Visual check", + base_dir: path.join(h.root, ".devin", "skills", "visual-check"), + triggers: ["user"], + errors: [], + }, + ]), + ); + yield* h.start(); + for (const [input, text] of [ + ["$visual-check http://localhost:5173", "/visual-check http://localhost:5173"], + ["$visual-check", "/visual-check"], + ["/visual-check", "/visual-check"], + ]) { + yield* h.adapter.sendTurn({ threadId, input }); + const request = (yield* h.requests).findLast((entry) => entry.method === "session/prompt"); + expect(request?.params?.prompt).toEqual([{ type: "text", text }]); + } + const config = yield* ServerConfig; + const attachmentId = "devin-thread-11111111-1111-4111-8111-111111111111"; + yield* h.adapter.sendTurn({ + threadId, + input: "$visual-check", + 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 Attached file: ${path.join(config.attachmentsDir, `${attachmentId}.txt`)}`, + }, + ]); + 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* 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"]), + agentDeviceEnvironment: {}, + }), + ), + () => Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)), + ); + 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?.[0]; + if (!directory) throw new Error("The failed session must have attempted MCP setup."); + expect(yield* fs.exists(directory)).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" }); + 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"]); + 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("switches between explicit models and maps ACP usage updates", () => + Effect.gen(function* () { + const h = yield* makeHarness({ T3_ACP_DEVIN: "1" }); + yield* h.start("devin-test-high"); + yield* h.adapter.sendTurn({ + threadId, + input: "Use the low variant", + 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 }, + }); + expect( + (yield* h.requests) + .filter((request) => request.method === "session/set_config_option") + .map((request) => request.params?.value), + ).toEqual(["devin-test-high", "devin-test-low"]); + }).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" }); + 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"); + 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 cli = yield* makeDevinCli({ T3_ACP_WAIT_FOR_CANCEL: "1" }); + const modeHeld = yield* Deferred.make(); + const releaseMode = yield* Deferred.make(); + const firstPrompt = yield* Deferred.make(); + const secondPrompt = yield* Deferred.make(); + const isRequestLog = Schema.is( + Schema.Struct({ + event: Schema.Struct({ + kind: Schema.Literal("request"), + payload: Schema.Struct({ method: Schema.String, status: Schema.String }), + }), + }), + ); + let modeCount = 0; + let promptCount = 0; + const adapter = yield* makeDevinAdapter(cli.settings, { + instanceId, + environment: cli.environment, + nativeEventLogger: { + filePath: cli.requestLog, + close: () => Effect.void, + write: (event) => + Effect.gen(function* () { + if (!isRequestLog(event) || event.event.payload.status !== "started") return; + const { method } = event.event.payload; + if (method === "session/set_mode" && ++modeCount === 3) { + yield* Deferred.succeed(modeHeld, undefined); + yield* Deferred.await(releaseMode); + } + if (method === "session/prompt") { + if (++promptCount === 2) yield* Deferred.succeed(secondPrompt, undefined); + } + }), + }, + }); + yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "thread.token-usage.updated" + ? Deferred.succeed(firstPrompt, undefined) + : Effect.void, + ).pipe(Effect.forkScoped({ startImmediately: true })); + yield* adapter.startSession({ + threadId, + cwd: cli.root, + runtimeMode: "approval-required", + modelSelection: { instanceId, model: "devin-test-low" }, + }); + 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* cli.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)), +); diff --git a/apps/server/src/provider/Layers/DevinAdapter.ts b/apps/server/src/provider/Layers/DevinAdapter.ts new file mode 100644 index 000000000000..b132a7391183 --- /dev/null +++ b/apps/server/src/provider/Layers/DevinAdapter.ts @@ -0,0 +1,767 @@ +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 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, + 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"]; + +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 startLock = yield* Semaphore.make(1); + 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.stopped) 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": + 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) => + startLock.withPermit( + Effect.gen(function* () { + if (input.modelSelection && input.modelSelection.instanceId !== options.instanceId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "The selected model belongs to another provider instance.", + }); + } + if (input.resumeCursor !== undefined && !isResumeCursor(input.resumeCursor)) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "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.timeout("10 seconds"), + Effect.mapError((cause) => requestError("session/start", cause)), + ); + const scope = yield* Scope.make(); + const cwd = input.cwd ?? config.cwd; + const started = 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, + ...(mcpConfig ? { additionalDirectories: [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; + 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) => requestError("session/start", cause)), + ); + return started; + }), + ); + + 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* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "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* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `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* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `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* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "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; + const finish = ( + payload: Extract["payload"], + ) => + context.lock.withPermit( + 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, + }); + }), + ); + 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.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* 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) => finish({ state: "failed", errorMessage: cause.message })), + Effect.onInterrupt(() => + 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* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "respondToRequest", + issue: "This permission request is no longer pending.", + }); + if (decision !== "cancel" && !selectDevinPermissionOption(pending.request, decision)) + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "respondToRequest", + issue: "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( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "respondToUserInput", + issue: "Reply to Devin's question in the conversation.", + }), + ), + stopSession: (threadId) => + startLock.withPermit(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( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "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.test.ts b/apps/server/src/provider/Layers/DevinProvider.test.ts new file mode 100644 index 000000000000..80a141a44b55 --- /dev/null +++ b/apps/server/src/provider/Layers/DevinProvider.test.ts @@ -0,0 +1,46 @@ +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import { checkDevinProviderStatus } from "./DevinProvider.ts"; +import { makeDevinCli as makeHarness, devinTestLayer as layer } from "../testUtils/devinCli.ts"; + +it.effect("reports signed-out status without creating an ACP session", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const snapshot = yield* checkDevinProviderStatus(h.settings, h.environment); + expect(snapshot.auth.status).toBe("unauthenticated"); + const fs = yield* FileSystem.FileSystem; + expect(yield* fs.exists(h.requestLog)).toBe(false); + }).pipe(Effect.provide(layer)), +); + +it.effect("discovers account models before a chat without creating an ACP session", () => + Effect.gen(function* () { + const h = yield* makeHarness({ + T3_ACP_DEVIN: "1", + T3_DEVIN_AUTH_STATUS: "Logged in (via Devin).", + }); + const snapshot = yield* checkDevinProviderStatus(h.settings, h.environment); + expect(snapshot.status).toBe("ready"); + expect(snapshot.models.map((model) => [model.slug, model.name])).toEqual([ + ["devin-test", "Devin Test"], + ]); + const fs = yield* FileSystem.FileSystem; + expect(yield* fs.exists(h.requestLog)).toBe(false); + }).pipe(Effect.provide(layer)), +); + +it.effect("keeps authenticated status and reports catalog failures", () => + Effect.gen(function* () { + const h = yield* makeHarness({ + T3_ACP_DEVIN: "1", + T3_DEVIN_AUTH_STATUS: "Logged in (via Devin).", + T3_DEVIN_FAIL_MODELS: "1", + }); + const snapshot = yield* checkDevinProviderStatus(h.settings, h.environment); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.version).toBe("3000.5.20"); + expect(snapshot.status).toBe("warning"); + expect(snapshot.message).toContain("Could not load Devin models"); + }).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/provider/Layers/DevinProvider.ts b/apps/server/src/provider/Layers/DevinProvider.ts new file mode 100644 index 000000000000..b3cbd9c6a2a7 --- /dev/null +++ b/apps/server/src/provider/Layers/DevinProvider.ts @@ -0,0 +1,128 @@ +import { + type DevinSettings, + type ServerProviderModel, + type ServerProviderSlashCommand, +} 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"; + +export const buildDevinProviderSnapshot = Effect.fn("buildDevinProviderSnapshot")(function* ( + settings: DevinSettings, + probe: ProviderProbeResult, + models: ReadonlyArray = [], + slashCommands: ReadonlyArray = [ + { name: "compact", description: "Summarize the conversation and reduce context usage" }, + ], +) { + 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, + probe, + }), + supportsTextGeneration: false, + }; +}); + +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 (/not logged in|not authenticated/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 && /logged in|authenticated/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.scoped, + 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.scoped, + 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/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 988c89e1e679..5a3980ae0869 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2615,6 +2615,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..6067d9d2848c 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -103,12 +103,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 isDevin = provider.driver === ProviderDriverKind.make("devin"); + if ( + !isAntigravity && + !isCodex && + !isDevin && + provider.driver !== ProviderDriverKind.make("opencode") + ) { return true; } if ( - (isAntigravity || isCodex) && + (isAntigravity || isCodex || isDevin) && (!provider.enabled || provider.auth.status === "unauthenticated") ) { return false; @@ -148,7 +154,12 @@ const mergeProviderModels = ( const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const)); const mergedModels = nextModels.map((model) => { const previousModel = previousBySlug.get(model.slug); - if (!previousModel || hasModelCapabilities(model) || !hasModelCapabilities(previousModel)) { + if ( + provider.driver === "devin" || + !previousModel || + hasModelCapabilities(model) || + !hasModelCapabilities(previousModel) + ) { return model; } return { diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index b5894192eed9..5c63b3afafdc 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -80,7 +80,7 @@ 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; @@ -92,7 +92,8 @@ export interface AcpSessionRuntimeOptions { 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; @@ -699,15 +700,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 +755,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, 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..0371a5855982 --- /dev/null +++ b/apps/server/src/provider/acp/DevinAcpSupport.test.ts @@ -0,0 +1,112 @@ +import * as FileSystem from "effect/FileSystem"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +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 { + applyDevinMode, + 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("selects an account model after ACP refreshes its stale catalog", () => + Effect.gen(function* () { + const h = yield* makeHarness({ T3_ACP_DEVIN: "1", T3_ACP_DEVIN_STALE_MODELS: "1" }); + const runtime = yield* makeDevinAcpRuntime(h.settings, h.environment, { + cwd: h.root, + clientInfo: { name: "t3-code-test", version: "0.0.0" }, + }); + yield* runtime.start(); + const selection = yield* runtime + .setModel("devin-test-high") + .pipe(Effect.forkScoped({ startImmediately: true })); + yield* runtime.request("_test/refresh-models", {}); + yield* Fiber.join(selection); + const model = (yield* runtime.getConfigOptions).find((option) => option.id === "model"); + expect(model?.currentValue).toBe("devin-test-high"); + expect( + (yield* h.requests) + .filter((request) => request.method === "session/set_config_option") + .map((request) => request.params?.value), + ).toEqual(["devin-test-high"]); + }).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.effect("maps auto and edit approval policies to the advertised Devin modes", () => + Effect.gen(function* () { + const cli = yield* makeHarness(); + const runtime = yield* makeDevinAcpRuntime(cli.settings, cli.environment, { + cwd: cli.root, + clientInfo: { name: "t3-code-test", version: "0.0.0" }, + }); + const started = yield* runtime.start(); + const modes = (yield* runtime.getModeState)?.availableModes ?? []; + expect(modes.map((mode) => mode.id)).toContain("smart"); + expect(modes.map((mode) => mode.id)).not.toContain("normal"); + yield* applyDevinMode(runtime, started.sessionId, "auto"); + yield* applyDevinMode(runtime, started.sessionId, "auto-accept-edits"); + yield* applyDevinMode(runtime, started.sessionId, "approval-required"); + expect( + (yield* cli.requests) + .filter((request) => request.method === "session/set_mode") + .map((request) => request.params?.modeId), + ).toEqual(["smart", "accept-edits", "normal"]); + expect(devinMode("auto", undefined, [])).toBe("normal"); + }).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/provider/acp/DevinAcpSupport.ts b/apps/server/src/provider/acp/DevinAcpSupport.ts new file mode 100644 index 000000000000..8dc6855501c6 --- /dev/null +++ b/apps/server/src/provider/acp/DevinAcpSupport.ts @@ -0,0 +1,284 @@ +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 Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +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)); + +function includesModel(options: ReadonlyArray, model: string) { + const config = options.find((option) => option.id === "model"); + return ( + config?.type === "select" && + config.options.some((entry) => + "value" in entry + ? entry.value === model + : entry.options.some((option) => option.value === model), + ) + ); +} + +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", + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + _meta: { "cognition.ai/mcp": true, "cognition.ai/mcpWorkspaceDirs": true }, + }, + }); + const modelUpdates = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + yield* runtime.handleSessionUpdate((notification) => + notification.update.sessionUpdate === "config_option_update" + ? runtime.getConfigOptions.pipe( + Effect.flatMap((options) => SubscriptionRef.set(modelUpdates, options)), + ) + : Effect.void, + ); + const getCatalog = readDevinModelCatalog(settings, environment).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.timeout("10 seconds"), + Effect.mapError((cause) => EffectAcpErrors.AcpRequestError.internalError(cause.message)), + ); + const setModel = Effect.fn("DevinAcpRuntime.setModel")(function* (modelId: string) { + yield* runtime.start(); + if (!includesModel(yield* runtime.getConfigOptions, modelId)) { + // An upgrade can make the CLI catalog newer than the initial ACP config. + // Only wait for models the account actually offers, while consuming ACP updates. + const catalog = yield* getCatalog; + if ( + catalog.families.some((family) => + family.variants.some((variant) => variant.model_uid === modelId), + ) + ) { + yield* SubscriptionRef.changes(modelUpdates).pipe( + Stream.filter((options) => includesModel(options, modelId)), + Stream.take(1), + Stream.runDrain, + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => + Effect.fail( + EffectAcpErrors.AcpRequestError.invalidParams( + `Devin has not made model ${modelId} available to this session yet. Try again after refreshing provider status.`, + ), + ), + }), + ); + } + } + return yield* runtime.setModel(modelId); + }); + 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..c2b75d6c8032 --- /dev/null +++ b/apps/server/src/provider/acp/DevinModels.test.ts @@ -0,0 +1,254 @@ +import { expect, it } from "@effect/vitest"; +import { buildProviderOptionSelectionsFromDescriptors } from "@t3tools/shared/model"; +import { devinModels, resolveDevinModel } from "./DevinModels.ts"; + +const catalog = { + families: [ + { + slug: "swe-2", + family_label: "SWE-2", + variants: [ + { model_uid: "swe-2-high", label: "SWE-2 High" }, + { model_uid: "swe-2-medium", label: "SWE-2 Medium" }, + { model_uid: "swe-2-max", label: "SWE-2 Max" }, + ], + }, + { + slug: "swe-1.7", + family_label: "SWE-1.7", + variants: [ + { model_uid: "swe-1-7", label: "SWE-1.7 Max" }, + { model_uid: "swe-1-7-medium", label: "SWE-1.7 Medium" }, + ], + }, + { + slug: "claude-opus-5", + family_label: "Claude Opus 5", + variants: [ + { model_uid: "claude-opus-5-medium", label: "Claude Opus 5 Medium" }, + { model_uid: "claude-opus-5-high", label: "Claude Opus 5 High" }, + { model_uid: "claude-opus-5-medium-fast", label: "Claude Opus 5 Medium Fast" }, + { model_uid: "claude-opus-5-high-fast", label: "Claude Opus 5 High Fast" }, + ], + }, + { + slug: "claude-opus-4.6", + family_label: "Claude Opus 4.6", + variants: [ + { model_uid: "MODEL_CLAUDE_4_6_OPUS", label: "Claude Opus 4.6" }, + { model_uid: "MODEL_CLAUDE_4_6_OPUS_THINKING", label: "Claude Opus 4.6 Thinking" }, + { model_uid: "MODEL_CLAUDE_4_6_OPUS_1M", label: "Claude Opus 4.6 1M" }, + { model_uid: "MODEL_CLAUDE_4_6_OPUS_THINKING_1M", label: "Claude Opus 4.6 Thinking 1M" }, + ], + }, + ], +}; + +it("orders family thinking choices from lowest to highest and keeps the CLI's initial choice", () => { + const models = devinModels(catalog); + expect(models.map((model) => model.name)).toEqual([ + "SWE-2", + "SWE-1.7", + "Claude Opus 5", + "Claude Opus 4.6", + ]); + 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: "max", label: "Max" }, + ], + }, + ]); + expect(resolveDevinModel(catalog, { model: "swe-2" })).toBe("swe-2-high"); + expect( + resolveDevinModel(catalog, { + model: "swe-2", + options: [{ id: "reasoningEffort", value: "max" }], + }), + ).toBe("swe-2-max"); + expect( + resolveDevinModel(catalog, { + model: "swe-1.7", + options: [{ id: "reasoningEffort", value: "max" }], + }), + ).toBe("swe-1-7"); +}); + +it.each([ + { + family: "GPT-5.6 Sol", + suffixes: [ + "Medium Thinking", + "No Thinking", + "Max Thinking", + "XHigh Thinking", + "High Thinking", + "Low Thinking", + ], + expected: ["None", "Low", "Medium", "High", "XHigh", "Max"], + }, + { + family: "Gemini 3 Flash", + suffixes: ["High", "Low", "Minimal", "Medium"], + expected: ["Minimal", "Low", "Medium", "High"], + }, + { + family: "GLM-5.2", + suffixes: ["High", "Max", "No Thinking"], + expected: ["None", "High", "Max"], + }, + { + family: "Inkling", + suffixes: ["X-High", "Max", "None", "Medium", "High", "Low"], + expected: ["None", "Low", "Medium", "High", "XHigh", "Max"], + }, +])( + "orders $family thinking labels independently of the CLI's order", + ({ family, suffixes, expected }) => { + const models = devinModels({ + families: [ + { + slug: family, + family_label: family, + variants: suffixes.map((suffix, index) => ({ + model_uid: `native-${index}`, + label: `${family} ${suffix}`, + })), + }, + ], + }); + const thinking = models[0]?.capabilities?.optionDescriptors?.find( + (option) => option.id === "reasoningEffort", + ); + expect(thinking?.type === "select" && thinking.options.map((option) => option.label)).toEqual( + expected, + ); + }, +); + +it.each(catalog.families)( + "keeps $slug menus stable when the CLI changes its default variant", + (family) => { + const original = devinModels({ families: [family] })[0]; + const choices = (model: typeof original) => + model?.capabilities?.optionDescriptors?.map((descriptor) => + descriptor.type === "select" + ? { id: descriptor.id, options: descriptor.options } + : { id: descriptor.id }, + ); + for (let offset = 1; offset < family.variants.length; offset++) { + const variants = [...family.variants.slice(offset), ...family.variants.slice(0, offset)]; + const reorderedCatalog = { families: [{ ...family, variants }] }; + const reordered = devinModels(reorderedCatalog)[0]; + expect(choices(reordered)).toEqual(choices(original)); + const options = + buildProviderOptionSelectionsFromDescriptors(reordered?.capabilities?.optionDescriptors) ?? + []; + expect(resolveDevinModel(reorderedCatalog, { model: family.slug, options })).toBe( + variants[0]?.model_uid, + ); + } + }, +); + +it("exposes every catalog variant through independent thinking, speed, and context controls", () => { + for (const model of devinModels(catalog)) { + const descriptors = model.capabilities?.optionDescriptors ?? []; + const combinations = descriptors.reduce>>( + (selections, descriptor) => + selections.flatMap((selection) => + (descriptor.type === "boolean" + ? [false, true] + : descriptor.options.map((option) => option.id) + ).map((value) => [...selection, { id: descriptor.id, value }]), + ), + [[]], + ); + const resolved = combinations.map((options) => + resolveDevinModel(catalog, { model: model.slug, options }), + ); + expect(resolved.toSorted()).toEqual( + catalog.families + .find((family) => family.slug === model.slug) + ?.variants.map((variant) => variant.model_uid) + .toSorted(), + ); + } + expect( + resolveDevinModel(catalog, { + model: "claude-opus-5", + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], + }), + ).toBe("claude-opus-5-high-fast"); +}); + +it("rejects unavailable combinations and preserves exact custom IDs and unfamiliar variants", () => { + expect( + resolveDevinModel(catalog, { + model: "swe-2", + options: [{ id: "reasoningEffort", value: "low" }], + }), + ).toBeUndefined(); + expect( + resolveDevinModel(catalog, { model: "swe-2", options: [{ id: "fastMode", value: true }] }), + ).toBeUndefined(); + expect(resolveDevinModel(catalog, { model: "swe-2-medium" })).toBe("swe-2-medium"); + expect(resolveDevinModel(catalog, { model: "custom-model" })).toBe("custom-model"); + expect( + devinModels({ + families: [ + { + slug: "future", + family_label: "Future", + variants: [{ model_uid: "future-special", label: "Future Special" }], + }, + ], + }).map((model) => model.slug), + ).toEqual(["future-special"]); +}); + +it.each( + [ + [{ model_uid: "future", label: "Future Special" }], + [ + { model_uid: "future-high", label: "Future High" }, + { model_uid: "future-medium-fast", label: "Future Medium Fast" }, + ], + [ + { model_uid: "future-none", label: "Future None" }, + { model_uid: "future-no-thinking", label: "Future No Thinking" }, + ], + ].map((variants) => ({ variants })), +)("keeps ambiguous or incomplete families selectable by exact ID: %j", ({ variants }) => { + const catalog = { families: [{ slug: "future", family_label: "Future", variants }] }; + const models = devinModels(catalog); + expect(models.map((model) => model.slug)).toEqual(variants.map((variant) => variant.model_uid)); + for (const model of models) + expect(resolveDevinModel(catalog, { model: model.slug })).toBe(model.slug); +}); + +it("keeps the remaining thinking level selectable after an account catalog contracts", () => { + const models = devinModels({ + families: [ + { + slug: "swe-2", + family_label: "SWE-2", + variants: [{ model_uid: "swe-2-high", label: "SWE-2 High" }], + }, + ], + }); + expect(models[0]?.capabilities?.optionDescriptors?.[0]).toMatchObject({ + id: "reasoningEffort", + currentValue: "high", + options: [{ id: "high", label: "High" }], + }); +}); diff --git a/apps/server/src/provider/acp/DevinModels.ts b/apps/server/src/provider/acp/DevinModels.ts new file mode 100644 index 000000000000..22e6a4d7b798 --- /dev/null +++ b/apps/server/src/provider/acp/DevinModels.ts @@ -0,0 +1,160 @@ +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]; + +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: Family) { + const variants = family.variants.flatMap((variant) => { + const traits = variantTraits(family, variant); + 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 combinations.size === variants.length && optionCount === variants.length ? variants : []; +} + +export function devinModels(catalog: Catalog): ServerProviderModel[] { + return catalog.families.flatMap((family): ServerProviderModel[] => { + const variants = familyVariants(family); + if (variants.length !== family.variants.length) { + 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, + ...(family.aliases ? { aliases: family.aliases } : {}), + isCustom: false, + isDefault: false, + ...(variants.some((variant) => variant.is_new) ? { badge: "new" } : {}), + 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 = catalog.families.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 (!first || variants.length !== family.variants.length) + 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> = {}, + 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")) { + if (process.env.T3_DEVIN_FAIL_MODELS === "1") process.exit(1); + 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")) { + if (process.env.T3_DEVIN_FAIL_SKILLS === "1") process.exit(1); + 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..0f0c722402b6 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -427,6 +427,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; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 50f8649eaacb..a97ff3cfac40 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, }; } diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 99880a2512ad..61672326b6f7 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -768,3 +768,15 @@ export const PiAgentIcon: Icon = ({ className, ...props }) => ( ); + +// Cognition mark from the Devin documentation favicon. +export const DevinIcon: Icon = (props) => ( + +); 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..2226cf243828 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -91,7 +91,9 @@ export function shouldIncludeModelPickerOption(input: { if (isProviderInstancePickerReady(input.entry)) return true; return ( input.entry.enabled && - (input.entry.driverKind === "opencode" || input.entry.driverKind === "antigravity") && + (input.entry.driverKind === "opencode" || + input.entry.driverKind === "antigravity" || + input.entry.driverKind === "devin") && input.entry.instanceId === input.activeInstanceId && input.option.slug === input.activeModel && input.option.isUnavailable === true diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 6f777399451d..83219d2d29e3 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -78,7 +78,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { model: props.model, options: selectedInstanceOptions, }) ?? - (activeEntry?.driverKind === "opencode" || activeEntry?.driverKind === "antigravity" + (activeEntry?.driverKind === "opencode" || + activeEntry?.driverKind === "antigravity" || + activeEntry?.driverKind === "devin" ? undefined : selectedInstanceOptions[0]); const triggerTitle = selectedModel diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 47ce59efde52..3fef05d74aaa 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -155,6 +155,7 @@ function getSelectedTraits( : getProviderOptionDescriptors({ caps, selections: modelOptions, + preserveUnavailableSelections: provider === "devin", }); const selectDescriptors = descriptors.filter( (descriptor): descriptor is Extract => diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 44cd77b7e8d6..3ef1c9951aaa 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -490,3 +490,28 @@ describe("provider traits render guards", () => { expect(renderProviderTraitsMenuContent(args)).toBeNull(); }); }); + +it("preserves unavailable Devin thinking and speed choices for provider validation", () => { + const modelOptions = selections(["reasoningEffort", "max"], ["fastMode", true]); + const models = modelWith([ + selectDescriptor("reasoningEffort", [{ id: "high", label: "High", isDefault: true }]), + ]); + const state = getComposerProviderState({ + provider: ProviderDriverKind.make("devin"), + model: MODEL, + models, + modelOptions, + planModeEnabled: false, + }); + expect(state.modelOptionsForDispatch).toEqual(modelOptions); + 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..19089f1ae8ee 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -99,7 +99,13 @@ function resolveComposerOptionSelections( selections: ReadonlyArray | undefined; } { const caps = getProviderModelCapabilities(models, model, provider, planModeEnabled); - return { caps, selections: withImplicitFastModeDefault(caps, modelOptions) }; + return { + caps, + selections: + provider === "devin" + ? (modelOptions ?? undefined) + : withImplicitFastModeDefault(caps, modelOptions), + }; } export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { @@ -133,7 +139,11 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com modelOptions, planModeEnabled, ); - const descriptors = getProviderOptionDescriptors({ caps, selections }); + const descriptors = getProviderOptionDescriptors({ + caps, + selections, + preserveUnavailableSelections: provider === "devin", + }); const primarySelectDescriptor = descriptors.find( (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> => descriptor.type === "select", diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index db0e5ca222f3..fdf4f6262eeb 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -4,6 +4,7 @@ import { ClaudeAI, CursorIcon, GrokIcon, + DevinIcon, Icon, OpenAI, OpenCodeIcon, @@ -15,6 +16,7 @@ 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, }; diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 4bf4da3919ba..f174dd03a729 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -4,6 +4,7 @@ import { CodexSettings, CursorSettings, GrokSettings, + DevinSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; @@ -13,6 +14,7 @@ import { ClaudeAI, CursorIcon, GrokIcon, + DevinIcon, type Icon, OpenAI, OpenCodeIcon, @@ -82,6 +84,13 @@ const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ icon: AntigravityIcon, settingsSchema: AntigravitySettings, }, + { + value: ProviderDriverKind.make("devin"), + label: "Devin", + icon: DevinIcon, + badgeLabel: "Early Access", + settingsSchema: DevinSettings, + }, ]; const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index aa5b95642b9b..bc0dcc03872a 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -266,7 +266,7 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "provider-update-checks", title: "Provider update checks", to: "/settings/general", - searchTerms: ["installed cli versions newer available codex claude cursor grok opencode"], + searchTerms: ["installed cli versions newer available codex claude cursor grok devin opencode"], scope: "environment-defaults", }, { @@ -421,7 +421,7 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Providers", to: "/settings/providers", searchTerms: [ - "agents cli codex claude cursor grok opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory endpoint arguments environment variables display name accent color custom favorite hidden auto compact", + "agents cli codex claude cursor grok devin opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory endpoint arguments environment variables display name accent color custom favorite hidden auto compact", ], }, { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 8f4ad48e16ab..e0d5bc74adb0 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1188,8 +1188,11 @@ export function deriveEffectiveComposerModelState(input: { { preserveUnavailableSelection: preserveThreadModel }, ) : null) ?? - // Antigravity has no static model or cross-account catalog fallback. - (input.selectedProvider === "antigravity" && input.selectedInstanceId ? "" : null) ?? + // Account catalogs have no static model or cross-account fallback. + ((input.selectedProvider === "antigravity" || input.selectedProvider === "devin") && + input.selectedInstanceId + ? "" + : null) ?? resolveAppModelSelection( input.selectedProvider, input.settings, @@ -1206,7 +1209,7 @@ export function deriveEffectiveComposerModelState(input: { ? input.draft?.modelSelectionByProvider?.[input.selectedInstanceId] : undefined; const legacySelection = - input.selectedProvider === "antigravity" && + (input.selectedProvider === "antigravity" || input.selectedProvider === "devin") && input.selectedInstanceId && input.selectedInstanceId !== defaultInstanceIdForDriver(input.selectedProvider) ? undefined @@ -1223,7 +1226,9 @@ export function deriveEffectiveComposerModelState(input: { activeSelection.model, { preserveUnavailableSelection: true }, ) ?? - (input.selectedProvider === "antigravity" ? "" : null) ?? + (input.selectedProvider === "antigravity" || input.selectedProvider === "devin" + ? "" + : null) ?? resolveAppModelSelection( input.selectedProvider, input.settings, diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 959a5eb8c481..9f5ce7c11d60 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -914,3 +914,53 @@ describe("resolvePlanAgentHealPatch", () => { ).toEqual({ sourceControlWriterModelSelection: healed }); }); }); + +describe("Devin account model selection", () => { + const driver = ProviderDriverKind.make("devin"); + const instanceId = ProviderInstanceId.make("devin_work"); + const selection = createModelSelection(instanceId, "swe-2-high"); + + it("keeps the thread model unavailable until the account catalog restores it", () => { + for (const models of [[], ["swe-1.6"], ["swe-2-high"]]) { + const providers = [provider({ provider: driver, instanceId, models })]; + const state = deriveEffectiveComposerModelState({ + draft: null, + providers, + selectedProvider: driver, + selectedInstanceId: instanceId, + threadModelSelection: selection, + projectModelSelection: null, + settings: DEFAULT_UNIFIED_SETTINGS, + }); + expect(state.selectedModel).toBe(selection.model); + const options = getCustomModelOptionsByInstance( + DEFAULT_UNIFIED_SETTINGS, + providers, + instanceId, + state.selectedModel, + ).get(instanceId); + expect( + options?.find((option) => option.slug === selection.model)?.isUnavailable === true, + ).toBe(!models.includes(selection.model)); + } + }); + + it("excludes Devin from background generation even when its account has models", () => { + const providers = [ + { ...provider({ instanceId: "codex", models: ["gpt-5.6-luna"] }), enabled: false }, + { + ...provider({ provider: driver, instanceId, models: [selection.model] }), + supportsTextGeneration: false, + }, + ]; + expect(resolveAppModelSelectionState(DEFAULT_UNIFIED_SETTINGS, providers)).toEqual( + NO_PROVIDER_MODEL_SELECTION, + ); + expect( + resolveAppModelSelectionState( + { ...DEFAULT_UNIFIED_SETTINGS, textGenerationModelSelection: selection }, + providers, + ), + ).toEqual(NO_PROVIDER_MODEL_SELECTION); + }); +}); diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 1a39f06098ac..a6435ad3108f 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -98,7 +98,7 @@ function appendUnavailableDynamicModelSelection( selectedModel: string | null | undefined, hiddenModels: ReadonlyArray, ): AppModelOption[] { - if (provider !== "opencode" && provider !== "antigravity") return options; + if (provider !== "opencode" && provider !== "antigravity" && provider !== "devin") return options; const slug = normalizeCustomModelSlug(selectedModel); if (!slug) return options; if (provider === "antigravity" && slug === ANTIGRAVITY_DEFAULT_MODEL) return options; @@ -308,7 +308,9 @@ export function resolveAppModelSelectionForInstance( } if ( resolutionOptions?.preserveUnavailableSelection && - (entry.driverKind === "opencode" || entry.driverKind === "antigravity") + (entry.driverKind === "opencode" || + entry.driverKind === "antigravity" || + entry.driverKind === "devin") ) { const unavailableSelection = normalizeCustomModelSlug(selectedModel); const hiddenModels = readInstanceModelPreferences(settings, entry.instanceId).hiddenModels; diff --git a/docs/README.md b/docs/README.md index 4691e6f83c8e..3f6d423da330 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,7 @@ - [Remote access](./user/remote-access.md) - [Running in the background](./user/background-service.md) - [Updating T3 Code](./user/updating.md) -- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) +- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) · [Devin](./user/providers-devin.md) --- diff --git a/docs/user/install.md b/docs/user/install.md index 17e9291bf1a0..65b52ebf0cb9 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -94,7 +94,7 @@ base URL. Mark secret values as sensitive; after saving, T3 Code does not displa their original values. For provider-specific setup and accounts, see [Codex](./providers-codex.md), -[Claude](./providers-claude.md), [OpenCode](./providers-opencode.md), and +[Claude](./providers-claude.md), [Devin](./providers-devin.md), [OpenCode](./providers-opencode.md), and [Antigravity](./providers-antigravity.md). ## Next steps diff --git a/docs/user/providers-devin.md b/docs/user/providers-devin.md new file mode 100644 index 000000000000..b067217bf1bb --- /dev/null +++ b/docs/user/providers-devin.md @@ -0,0 +1,17 @@ +# Devin + +Devin runs locally on your environment through its CLI. Install [Devin CLI](https://docs.devin.ai/cli), run `devin auth login` on the machine running T3 Code, then enable **Devin** in **Settings → Providers**. + +If `devin` launches Devin Desktop, set **Binary path** to the actual CLI executable. The Desktop launcher does not support ACP. + +Available models load from `devin models list --format json` when provider status refreshes, before you start a chat. Refresh **Settings → Providers** after changing your Devin subscription. Choose a model family, then set its thinking level and, where available, Fast mode or context window. Workspace commands load when the session opens. + +Select a model from your account's catalog. Saved threads resume their Devin session, and model changes apply within the same thread. A temporary refresh failure keeps the last available model list; signing out clears it. + +T3 forwards permission requests and supports Plan mode, cancellation, images, file attachments, and `/compact`. Auto mode uses Devin's Smart mode when available and asks for approval otherwise. Conversation rewind is not supported by Devin ACP. + +Type `$` in the composer to select a skill discovered by Devin CLI for your workspace. T3 invokes the selected skill with the rest of your message as its arguments. Devin supports one explicit skill invocation per message; native `/skill-name` commands work too. Skills can use T3's browser and device tools when those capabilities are available on your connected environment. + +For remote connections, installation and sign-in happen on the server machine. Provider instances can use separate environments, including `WINDSURF_API_KEY` for API-key authentication. + +For generated titles and source-control text, choose another provider under **Settings → General → Text generation model**. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index bce1a766bc9b..783c6b6631e6 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -220,5 +220,6 @@ export const PROVIDER_DISPLAY_NAMES: Partial> [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", + [ProviderDriverKind.make("devin")]: "Devin", [OPENCODE_DRIVER_KIND]: "OpenCode", }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 90b176c3095a..9c50fc05247e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -712,6 +712,32 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const DevinSettings = makeProviderSettingsSchema( + { + // Enable explicitly after installing and signing in to Devin CLI. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("devin").pipe( + Schema.annotateKey({ + title: "Binary path", + description: + "Path to Devin CLI (not the Devin Desktop launcher). Sign in with devin auth login on this environment.", + providerSettingsForm: { placeholder: "devin", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type DevinSettings = typeof DevinSettings.Type; + /** * Antigravity ACP auth methods. Personal and Enterprise open a Google sign-in * in the browser. The API key and Agent Platform methods take credentials from @@ -1137,6 +1163,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + devin: DevinSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), antigravity: AntigravitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), @@ -1294,6 +1321,12 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), }); +const DevinSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), +}); + const AntigravitySettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), authMethod: Schema.optionalKey(AntigravityAuthMethod), @@ -1383,6 +1416,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), + devin: Schema.optionalKey(DevinSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), antigravity: Schema.optionalKey(AntigravitySettingsPatch), }), diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index b3bcf3cd2433..108ae8ef3187 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -12,7 +12,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne import * as AcpError from "./errors.ts"; import * as AcpProtocol from "./protocol.ts"; import * as AcpRpcs from "./rpc.ts"; -import * as AcpSchema from "./_generated/schema.gen.ts"; +import * as AcpSchema from "./schema.ts"; import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts"; import { callRpc, diff --git a/packages/effect-acp/src/rpc.ts b/packages/effect-acp/src/rpc.ts index 5026645374eb..6a93d2d15c29 100644 --- a/packages/effect-acp/src/rpc.ts +++ b/packages/effect-acp/src/rpc.ts @@ -1,7 +1,7 @@ import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; -import * as AcpSchema from "./_generated/schema.gen.ts"; +import * as AcpSchema from "./schema.ts"; import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts"; const InitializeRpc = Rpc.make(AGENT_METHODS.initialize, { diff --git a/packages/effect-acp/src/schema.ts b/packages/effect-acp/src/schema.ts index 8e354aca3701..da249114c8d3 100644 --- a/packages/effect-acp/src/schema.ts +++ b/packages/effect-acp/src/schema.ts @@ -1,2 +1,23 @@ +import * as Schema from "effect/Schema"; +import * as Generated from "./_generated/schema.gen.ts"; + export * from "./_generated/schema.gen.ts"; export * from "./_generated/meta.gen.ts"; + +// Agents already use additionalDirectories; preserve it until the generated ACP schema includes it. +const additionalDirectories = Schema.optionalKey(Schema.Array(Schema.String)); +export const NewSessionRequest = Schema.Struct({ + ...Generated.NewSessionRequest.fields, + additionalDirectories, +}); +export type NewSessionRequest = typeof NewSessionRequest.Type; +export const LoadSessionRequest = Schema.Struct({ + ...Generated.LoadSessionRequest.fields, + additionalDirectories, +}); +export type LoadSessionRequest = typeof LoadSessionRequest.Type; +export const ResumeSessionRequest = Schema.Struct({ + ...Generated.ResumeSessionRequest.fields, + additionalDirectories, +}); +export type ResumeSessionRequest = typeof ResumeSessionRequest.Type; diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index d6cb26f25be3..e70442805cfb 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -141,10 +141,50 @@ function withDescriptorCurrentValue( export function getProviderOptionDescriptors(input: { caps: ModelCapabilities; selections?: ReadonlyArray | null | undefined; + preserveUnavailableSelections?: boolean; }): ReadonlyArray { const { caps, selections } = input; const baseDescriptors = (caps.optionDescriptors ?? []).map(cloneDescriptor); + // Account catalogs can lose choices. Keep explicit selections visible and let + // the provider reject them instead of silently dispatching a different variant. + if (input.preserveUnavailableSelections) { + for (const selection of selections ?? []) { + const index = baseDescriptors.findIndex((descriptor) => descriptor.id === selection.id); + const descriptor = baseDescriptors[index]; + if (!descriptor) { + baseDescriptors.push( + typeof selection.value === "boolean" + ? { + id: selection.id, + label: `${selection.id} (Unavailable)`, + type: "boolean", + currentValue: selection.value, + } + : { + id: selection.id, + label: selection.id, + type: "select", + currentValue: selection.value, + options: [{ id: selection.value, label: `${selection.value} (Unavailable)` }], + }, + ); + } else if ( + descriptor.type === "select" && + typeof selection.value === "string" && + !descriptor.options.some((option) => option.id === selection.value) + ) { + baseDescriptors[index] = { + ...descriptor, + options: [ + ...descriptor.options, + { id: selection.value, label: `${selection.value} (Unavailable)` }, + ], + }; + } + } + } + return baseDescriptors.map((descriptor) => withDescriptorCurrentValue( descriptor, From edba53ee006a534af5cb4cec5de8036f57abb5ea Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Fri, 11 Sep 2026 15:37:36 -0400 Subject: [PATCH 02/15] fix(devin): handle disconnects and preserve workspace and account boundaries --- .../features/threads/NewTaskDraftScreen.tsx | 16 ++--- apps/mobile/src/lib/modelOptions.test.ts | 8 +++ apps/mobile/src/lib/modelOptions.ts | 23 ++++-- .../src/state/use-thread-composer-state.ts | 10 ++- .../src/state/use-thread-outbox-drain.ts | 46 ++++++------ .../src/provider/Drivers/DevinDriver.test.ts | 45 ++++++++++++ .../src/provider/Drivers/DevinDriver.ts | 53 +++++++++----- .../src/provider/Layers/DevinAdapter.test.ts | 72 +++++++++++++++++-- .../src/provider/Layers/DevinAdapter.ts | 21 +++++- .../src/provider/Layers/ProviderRegistry.ts | 4 +- apps/server/src/provider/ProviderDriver.ts | 2 + .../web/src/components/ChatView.logic.test.ts | 25 ++++--- apps/web/src/components/ChatView.logic.ts | 4 +- 13 files changed, 247 insertions(+), 82 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 2dd0000ea605..a28b7e9cb24a 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -82,7 +82,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"; @@ -966,14 +966,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/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index ab715b514eab..01e62fe16cbd 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -5,6 +5,7 @@ import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3t import { buildModelOptions, groupByProvider, + getModelSelectionUnavailableReason, isModelSelectionUnavailable, resolveDefaultableModelSelection, resolveNewTaskModelSelection, @@ -317,6 +318,9 @@ describe("mobile model options", () => { expect(resolveDefaultableModelSelection(missingStatusConfig, selection)).toBe(selection); expect(isModelSelectionUnavailable(missingStatusConfig, selection)).toBe(true); + expect(getModelSelectionUnavailableReason(missingStatusConfig, selection)).toBe( + "Antigravity model unavailable. Set up Antigravity on web or desktop, or choose another model.", + ); expect(buildModelOptions(missingStatusConfig, selection)).toMatchObject([ { providerDriver: "antigravity", @@ -332,6 +336,7 @@ describe("mobile model options", () => { 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); @@ -429,6 +434,9 @@ describe("Devin account model selection", () => { expect(resolveSelectableModelSelection(config, selection)).toBe(selection); expect(resolveDefaultableModelSelection(config, selection)).toBe(selection); expect(isModelSelectionUnavailable(config, selection)).toBe(true); + expect(getModelSelectionUnavailableReason(config, selection)).toBe( + "Devin model unavailable. Set up Devin on web or desktop, or choose another model.", + ); expect( buildModelOptions(config, selection).find( (option) => option.selection.model === selection.model, diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 5847ac7be0ad..6bbea5882e1e 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -61,20 +61,20 @@ function normalizeSelectionOptions( }; } -/** Whether a known account-model 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 ( + if ( (driver === "antigravity" || driver === "devin") && (!provider || !provider.enabled || @@ -82,7 +82,18 @@ export function isModelSelectionUnavailable( provider.auth.status === "unauthenticated" || provider.availability === "unavailable" || !provider.models.some((model) => model.slug === selection.model)) - ); + ) { + const name = driver === "devin" ? "Devin" : "Antigravity"; + 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; } /** diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index cd5fa9772f20..b96f1c0d0ec4 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -22,7 +22,7 @@ import { import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; 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, @@ -328,14 +328,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( - "Model unavailable", - "Set up this provider 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 7388037a5593..6a6e26b469b4 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -21,7 +21,7 @@ import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; 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, @@ -689,11 +689,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); @@ -782,11 +783,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, @@ -858,11 +860,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; @@ -902,11 +905,12 @@ export function useThreadOutboxDrain(): void { serverEnvironment.configValueAtom(queuedMessage.environmentId), ); if (!currentConfig) return false; - if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) { - return restoreQueuedMessage( - persistedMessage, - "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", - ); + const sendModelUnavailableReason = getModelSelectionUnavailableReason( + currentConfig, + settings.modelSelection, + ); + if (sendModelUnavailableReason !== null) { + return restoreQueuedMessage(persistedMessage, sendModelUnavailableReason); } const sendSettings = resolveQueuedThreadSettings( queuedMessage, diff --git a/apps/server/src/provider/Drivers/DevinDriver.test.ts b/apps/server/src/provider/Drivers/DevinDriver.test.ts index a88707eaa1a1..0682d745811a 100644 --- a/apps/server/src/provider/Drivers/DevinDriver.test.ts +++ b/apps/server/src/provider/Drivers/DevinDriver.test.ts @@ -10,6 +10,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { mergeProviderSnapshot } from "../Layers/ProviderRegistry.ts"; import { DevinDriver } from "./DevinDriver.ts"; +import { MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER } from "../ProviderDriver.ts"; import { makeDevinCli as makeHarness, devinTestLayer as layer, @@ -28,6 +29,50 @@ const driverLayer = layer.pipe( 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* 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("keeps skills discoverable when ACP commands arrive before the workspace probe", () => Effect.gen(function* () { const h = yield* makeHarness({ T3_DEVIN_AUTH_STATUS: "Logged in (via Devin)." }); diff --git a/apps/server/src/provider/Drivers/DevinDriver.ts b/apps/server/src/provider/Drivers/DevinDriver.ts index ae9148202330..bd947cc0685a 100644 --- a/apps/server/src/provider/Drivers/DevinDriver.ts +++ b/apps/server/src/provider/Drivers/DevinDriver.ts @@ -26,6 +26,7 @@ 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"; @@ -92,13 +93,25 @@ export const DevinDriver: ProviderDriver = { PubSub.unbounded(), PubSub.shutdown, ); - const commandsByCwd = new Map>(); - const skillsByCwd = new Map>(); + 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.tap((skills) => Effect.sync(() => skillsByCwd.set(cwd, skills))), + Effect.tap((skills) => Effect.sync(() => updateWorkspaceMetadata(cwd, { skills }))), Effect.mapError( (cause) => new ProviderDriverError({ @@ -121,8 +134,9 @@ export const DevinDriver: ProviderDriver = { description: command.description, ...(command.input ? { input: command.input } : {}), })); - if (Equal.equals(commandsByCwd.get(cwd), next)) return; - commandsByCwd.set(cwd, next); + const unchanged = Equal.equals(metadataByCwd.get(cwd)?.slashCommands, next); + updateWorkspaceMetadata(cwd, { slashCommands: next }); + if (unchanged) return; yield* PubSub.publish(metadataChanges, undefined); }), }); @@ -143,8 +157,7 @@ export const DevinDriver: ProviderDriver = { ? snapshot.models.filter((model) => !model.isCustom) : []; if (snapshot.auth.status !== "authenticated") { - commandsByCwd.clear(); - skillsByCwd.clear(); + metadataByCwd.clear(); } }), ), @@ -169,14 +182,20 @@ export const DevinDriver: ProviderDriver = { // 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" - ? [...skillsByCwd.keys()] + ? [...metadataByCwd] : [] - ).map((cwd) => ({ - cwd, - slashCommands: commandsByCwd.get(cwd) ?? snapshot.slashCommands, - skills: skillsByCwd.get(cwd) ?? [], - checkedAt: snapshot.checkedAt, - })), + ).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 { @@ -200,8 +219,8 @@ export const DevinDriver: ProviderDriver = { Effect.tap((snapshot) => snapshot.auth.status === "authenticated" ? Effect.forEach( - [...skillsByCwd.keys()], - (cwd) => + [...metadataByCwd].filter(([, metadata]) => metadata.skills !== undefined), + ([cwd]) => probeSkills(cwd).pipe( Effect.catch((cause) => Effect.logWarning(cause.message)), ), @@ -224,7 +243,7 @@ export const DevinDriver: ProviderDriver = { return { ...withWorkspaceMetadata(snapshot), skills, - slashCommands: commandsByCwd.get(cwd) ?? snapshot.slashCommands, + slashCommands: metadataByCwd.get(cwd)?.slashCommands ?? snapshot.slashCommands, }; }), } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Layers/DevinAdapter.test.ts b/apps/server/src/provider/Layers/DevinAdapter.test.ts index 9f57bb9da8dd..5c5671b0de5a 100644 --- a/apps/server/src/provider/Layers/DevinAdapter.test.ts +++ b/apps/server/src/provider/Layers/DevinAdapter.test.ts @@ -1,5 +1,6 @@ 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 { @@ -12,10 +13,14 @@ 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, @@ -66,6 +71,12 @@ it.effect("streams a turn and loads the saved ACP session ID without authenticat 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( @@ -89,6 +100,7 @@ it.effect("connects T3 tools with isolated credentials and restores tool roots o Effect.gen(function* () { const h = yield* makeHarness(); const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig; yield* Effect.acquireRelease( Effect.sync(() => McpProviderSession.setMcpProviderSession({ @@ -106,7 +118,7 @@ it.effect("connects T3 tools with isolated credentials and restores tool roots o ); const session = yield* h.start(); const firstDirectory = (yield* h.requests).find((request) => request.method === "session/new") - ?.params?.additionalDirectories?.[0]; + ?.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); @@ -120,17 +132,18 @@ it.effect("connects T3 tools with isolated credentials and restores tool roots o 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(1); + 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?.[0], + entry.params?.workspaceDirs?.[0] === request?.params?.additionalDirectories?.[1], ), ).toBe(true); } const directory = requests.find((request) => request.method === "session/load")?.params - ?.additionalDirectories?.[0]; + ?.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; @@ -238,12 +251,61 @@ it.effect("cleans up MCP credentials when the tool server cannot connect", () => 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?.[0]; + ?.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 result = yield* ( + operation === "startSession" + ? h.start().pipe(Effect.asVoid) + : h.adapter.sendTurn({ threadId, input: "Hello" }).pipe(Effect.asVoid) + ).pipe(Effect.result); + expect(result._tag).toBe("Failure"); + 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(); diff --git a/apps/server/src/provider/Layers/DevinAdapter.ts b/apps/server/src/provider/Layers/DevinAdapter.ts index b132a7391183..3161ab6fc36b 100644 --- a/apps/server/src/provider/Layers/DevinAdapter.ts +++ b/apps/server/src/provider/Layers/DevinAdapter.ts @@ -36,6 +36,7 @@ import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; import { prepareDevinSkillPrompt } from "../Drivers/DevinSkills.ts"; import { ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, } from "../Errors.ts"; @@ -147,7 +148,7 @@ export const makeDevinAdapter = Effect.fn("makeDevinAdapter")(function* ( ) { return yield* context.stopLock.withPermit( Effect.gen(function* () { - if (context.stopped) return; + if (context.session.status === "closed") return; context.stopped = true; const turnId = context.session.activeTurnId; yield* cancelApprovals(context); @@ -215,6 +216,7 @@ export const makeDevinAdapter = Effect.fn("makeDevinAdapter")(function* ( }; return; case "ConnectionTerminated": + context.stopped = true; yield* stopContext(context, event.error.message).pipe(Effect.forkIn(ownerScope)); return; case "AssistantItemStarted": @@ -344,7 +346,10 @@ export const makeDevinAdapter = Effect.fn("makeDevinAdapter")(function* ( const runtime = yield* makeDevinAcpRuntime(settings, environment, { cwd, resumeSessionId, - ...(mcpConfig ? { additionalDirectories: [mcpConfig.directory] } : {}), + additionalDirectories: [ + config.attachmentsDir, + ...(mcpConfig ? [mcpConfig.directory] : []), + ], clientInfo: { name: "t3-code", version: "0.0.0" }, ...makeLoggers({ nativeEventLogger: options.nativeEventLogger, @@ -446,6 +451,12 @@ export const makeDevinAdapter = Effect.fn("makeDevinAdapter")(function* ( 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), @@ -653,6 +664,12 @@ export const makeDevinAdapter = Effect.fn("makeDevinAdapter")(function* ( ), ); 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] }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 6067d9d2848c..93d79df5d0d0 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -51,7 +51,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 +78,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, 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/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index ae0cdc8581ff..4cc968f7952a 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1424,18 +1424,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"])( + "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 b6bb98b4c50a..578a081bd278 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -556,9 +556,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 === "antigravity" || input.lockedProvider === "devin") && input.lockedInstanceId != null && lockedContinuationGroupKey === null; const compatibleEntries = input.entries.filter( From 56f0d9708ccca18863a77dc6808c635105232c46 Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Fri, 11 Sep 2026 16:52:28 -0400 Subject: [PATCH 03/15] fix(devin): preserve closed-session startup errors --- apps/server/src/provider/Layers/DevinAdapter.test.ts | 10 +++++++--- apps/server/src/provider/Layers/DevinAdapter.ts | 6 +++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/DevinAdapter.test.ts b/apps/server/src/provider/Layers/DevinAdapter.test.ts index 5c5671b0de5a..9470a49a59f1 100644 --- a/apps/server/src/provider/Layers/DevinAdapter.test.ts +++ b/apps/server/src/provider/Layers/DevinAdapter.test.ts @@ -295,12 +295,16 @@ for (const operation of ["startSession", "sendTurn"] as const) { ); const h = yield* makeHarness(); if (operation === "sendTurn") yield* h.start(); - const result = yield* ( + const error = yield* ( operation === "startSession" ? h.start().pipe(Effect.asVoid) : h.adapter.sendTurn({ threadId, input: "Hello" }).pipe(Effect.asVoid) - ).pipe(Effect.result); - expect(result._tag).toBe("Failure"); + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "ProviderAdapterSessionClosedError", + provider: "devin", + threadId, + }); expect(yield* h.adapter.hasSession(threadId)).toBe(false); }).pipe(Effect.provide(layer)), ); diff --git a/apps/server/src/provider/Layers/DevinAdapter.ts b/apps/server/src/provider/Layers/DevinAdapter.ts index 3161ab6fc36b..7c4ca15d96ef 100644 --- a/apps/server/src/provider/Layers/DevinAdapter.ts +++ b/apps/server/src/provider/Layers/DevinAdapter.ts @@ -470,7 +470,11 @@ export const makeDevinAdapter = Effect.fn("makeDevinAdapter")(function* ( yield* Scope.close(scope, Exit.void); }), ), - Effect.mapError((cause) => requestError("session/start", cause)), + Effect.mapError((cause) => + cause._tag === "ProviderAdapterSessionClosedError" + ? cause + : requestError("session/start", cause), + ), ); return started; }), From dd1ebfed0ed3c277ab6a6f7b6a1a890c218d4b55 Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Fri, 11 Sep 2026 17:20:34 -0400 Subject: [PATCH 04/15] test(devin): cover authentication environment overrides --- .../src/provider/Layers/DevinProvider.test.ts | 24 ++++++++++++------- docs/user/providers-devin.md | 4 ++++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/Layers/DevinProvider.test.ts b/apps/server/src/provider/Layers/DevinProvider.test.ts index 80a141a44b55..67f33e24a528 100644 --- a/apps/server/src/provider/Layers/DevinProvider.test.ts +++ b/apps/server/src/provider/Layers/DevinProvider.test.ts @@ -14,30 +14,38 @@ it.effect("reports signed-out status without creating an ACP session", () => }).pipe(Effect.provide(layer)), ); -it.effect("discovers account models before a chat without creating an ACP session", () => +it.effect("uses the supplied environment to check sign-in and discover models before a chat", () => Effect.gen(function* () { - const h = yield* makeHarness({ - T3_ACP_DEVIN: "1", + const h = yield* makeHarness(); + const environment = { + ...h.environment, T3_DEVIN_AUTH_STATUS: "Logged in (via Devin).", - }); - const snapshot = yield* checkDevinProviderStatus(h.settings, h.environment); + }; + const snapshot = yield* checkDevinProviderStatus(h.settings, environment); + expect(snapshot.auth.status).toBe("authenticated"); expect(snapshot.status).toBe("ready"); expect(snapshot.models.map((model) => [model.slug, model.name])).toEqual([ ["devin-test", "Devin Test"], ]); const fs = yield* FileSystem.FileSystem; expect(yield* fs.exists(h.requestLog)).toBe(false); + const signedOut = yield* checkDevinProviderStatus(h.settings, { + ...environment, + T3_DEVIN_AUTH_STATUS: "Not logged in.", + }); + expect(signedOut.auth.status).toBe("unauthenticated"); + expect(signedOut.models).toEqual([]); }).pipe(Effect.provide(layer)), ); it.effect("keeps authenticated status and reports catalog failures", () => Effect.gen(function* () { - const h = yield* makeHarness({ - T3_ACP_DEVIN: "1", + const h = yield* makeHarness(); + const snapshot = yield* checkDevinProviderStatus(h.settings, { + ...h.environment, T3_DEVIN_AUTH_STATUS: "Logged in (via Devin).", T3_DEVIN_FAIL_MODELS: "1", }); - const snapshot = yield* checkDevinProviderStatus(h.settings, h.environment); expect(snapshot.auth.status).toBe("authenticated"); expect(snapshot.version).toBe("3000.5.20"); expect(snapshot.status).toBe("warning"); diff --git a/docs/user/providers-devin.md b/docs/user/providers-devin.md index b067217bf1bb..ac0ba5c13353 100644 --- a/docs/user/providers-devin.md +++ b/docs/user/providers-devin.md @@ -14,4 +14,8 @@ Type `$` in the composer to select a skill discovered by Devin CLI for your work For remote connections, installation and sign-in happen on the server machine. Provider instances can use separate environments, including `WINDSURF_API_KEY` for API-key authentication. +If the CLI is signed in in your terminal but T3 reports **Not authenticated**, compare the environment variables used by each. On Linux and macOS, Devin stores credentials under `$XDG_DATA_HOME/devin` when set, otherwise `~/.local/share/devin`; on Windows it uses `%APPDATA%\devin`. See [Devin's credential locations](https://docs.devin.ai/cli/enterprise/devin-auth#credentials-file-location). + +Use the same credential directory in the provider instance's **Environment variables**, or run `devin auth login` with that instance's environment, then refresh provider status. Custom launchers should isolate T3 with `T3CODE_HOME` without redirecting `XDG_DATA_HOME` for provider processes. + For generated titles and source-control text, choose another provider under **Settings → General → Text generation model**. From abbbe892d66ea5515940e13308cc73da5c678c5e Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Sat, 12 Sep 2026 15:09:47 -0400 Subject: [PATCH 05/15] feat(devin): add Fusion model pairing support - Group Fusion models by lead and sidekick in the model picker - Preserve native pairing IDs and configure thinking level and Fast mode --- .../src/provider/acp/DevinModels.test.ts | 79 +++ apps/server/src/provider/acp/DevinModels.ts | 71 ++- .../src/components/chat/FusionModelPicker.tsx | 135 +++++ apps/web/src/components/chat/FusionWave.css | 23 + apps/web/src/components/chat/FusionWave.tsx | 98 ++++ .../components/chat/ModelPickerContent.tsx | 138 ++++- .../components/chat/ProviderModelPicker.tsx | 12 +- .../components/chat/fusionModelPicker.test.ts | 70 +++ .../src/components/chat/fusionModelPicker.ts | 39 ++ .../src/components/chat/providerIconUtils.ts | 3 +- apps/web/src/modelSelection.ts | 2 + docs/user/providers-devin.md | 2 + package.json | 2 + packages/contracts/src/server.ts | 7 + pnpm-lock.yaml | 232 +++++++- tools/oxlint/anti-slop/effect/index.ts | 13 + .../rules/no-service-constructor-imports.ts | 52 ++ tools/oxlint/anti-slop/index.ts | 41 ++ .../rules/no-chained-type-assertions.ts | 77 +++ .../no-conditional-empty-object-spread.ts | 49 ++ .../rules/no-known-value-widening.ts | 247 +++++++++ .../anti-slop/rules/no-module-mocking.ts | 91 ++++ .../anti-slop/rules/no-object-parameters.ts | 126 +++++ .../anti-slop/rules/no-reflect-apply.ts | 28 + .../oxlint/anti-slop/rules/no-reflect-get.ts | 28 + .../anti-slop/rules/no-runtime-typeof.ts | 67 +++ .../rules/no-shape-in-symbol-names.ts | 39 ++ .../anti-slop/rules/no-unknown-parameters.ts | 83 +++ .../anti-slop/rules/no-unknown-returns.ts | 115 ++++ .../rules/no-unknown-type-aliases.ts | 70 +++ .../rules/no-unsafe-dictionary-type.ts | 134 +++++ .../anti-slop/rules/no-widen-then-assert.ts | 366 +++++++++++++ ...quire-safety-comment-for-type-assertion.ts | 62 +++ .../anti-slop/shared/dictionary-types.ts | 502 ++++++++++++++++++ .../shared/lexical-type-parameters.ts | 61 +++ .../oxlint/anti-slop/shared/reflect-method.ts | 35 ++ vite.config.ts | 48 +- 37 files changed, 3220 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/components/chat/FusionModelPicker.tsx create mode 100644 apps/web/src/components/chat/FusionWave.css create mode 100644 apps/web/src/components/chat/FusionWave.tsx create mode 100644 apps/web/src/components/chat/fusionModelPicker.test.ts create mode 100644 apps/web/src/components/chat/fusionModelPicker.ts create mode 100644 tools/oxlint/anti-slop/effect/index.ts create mode 100644 tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts create mode 100644 tools/oxlint/anti-slop/index.ts create mode 100644 tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts create mode 100644 tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts create mode 100644 tools/oxlint/anti-slop/rules/no-known-value-widening.ts create mode 100644 tools/oxlint/anti-slop/rules/no-module-mocking.ts create mode 100644 tools/oxlint/anti-slop/rules/no-object-parameters.ts create mode 100644 tools/oxlint/anti-slop/rules/no-reflect-apply.ts create mode 100644 tools/oxlint/anti-slop/rules/no-reflect-get.ts create mode 100644 tools/oxlint/anti-slop/rules/no-runtime-typeof.ts create mode 100644 tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts create mode 100644 tools/oxlint/anti-slop/rules/no-unknown-parameters.ts create mode 100644 tools/oxlint/anti-slop/rules/no-unknown-returns.ts create mode 100644 tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts create mode 100644 tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts create mode 100644 tools/oxlint/anti-slop/rules/no-widen-then-assert.ts create mode 100644 tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts create mode 100644 tools/oxlint/anti-slop/shared/dictionary-types.ts create mode 100644 tools/oxlint/anti-slop/shared/lexical-type-parameters.ts create mode 100644 tools/oxlint/anti-slop/shared/reflect-method.ts diff --git a/apps/server/src/provider/acp/DevinModels.test.ts b/apps/server/src/provider/acp/DevinModels.test.ts index c2b75d6c8032..7f75eddbd68a 100644 --- a/apps/server/src/provider/acp/DevinModels.test.ts +++ b/apps/server/src/provider/acp/DevinModels.test.ts @@ -252,3 +252,82 @@ it("keeps the remaining thinking level selectable after an account catalog contr options: [{ id: "high", label: "High" }], }); }); + +const fusionCatalog = { + families: [ + ...catalog.families, + { + slug: "fusion", + family_label: "Fusion", + variants: catalog.families[2]!.variants.flatMap((lead) => + catalog.families[0]!.variants.map((sidekick) => ({ + model_uid: `fusion-${lead.model_uid}-sidekick-${sidekick.model_uid}`, + label: `Fusion (${lead.label} + ${sidekick.label})`, + })), + ), + }, + ], +}; + +it("groups Fusion by lead and sidekick and resolves every thinking and speed combination", () => { + const models = devinModels(fusionCatalog).filter((model) => model.slug.startsWith("fusion/")); + expect(models.map((model) => model.name)).toEqual([ + "Fusion (Claude Opus 5 + SWE-2 High)", + "Fusion (Claude Opus 5 + SWE-2 Medium)", + "Fusion (Claude Opus 5 + SWE-2 Max)", + ]); + const resolved = models.flatMap((model) => + ["medium", "high"].flatMap((effort) => + [false, true].map((fastMode) => + resolveDevinModel(fusionCatalog, { + model: model.slug, + options: [ + { id: "reasoningEffort", value: effort }, + { id: "fastMode", value: fastMode }, + ], + }), + ), + ), + ); + expect(resolved.toSorted()).toEqual( + fusionCatalog.families + .at(-1) + ?.variants.map((v) => v.model_uid) + .toSorted(), + ); + const first = models[0]!; + expect(first.fusion).toEqual({ + lead: { id: "claude-opus-5", name: "Claude Opus 5" }, + sidekick: { id: "swe-2-high", name: "SWE-2 High" }, + }); + expect(first.capabilities?.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Thinking level", + type: "select", + currentValue: "medium", + options: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + }, + { id: "fastMode", label: "Fast mode", type: "boolean", currentValue: false }, + ]); + expect( + resolveDevinModel(fusionCatalog, { + model: first.slug, + options: [{ id: "reasoningEffort", value: "max" }], + }), + ).toBeUndefined(); + for (const native of fusionCatalog.families.at(-1)!.variants) + expect(resolveDevinModel(fusionCatalog, { model: native.model_uid })).toBe(native.model_uid); +}); + +it("preserves unfamiliar Fusion pairings as exact model choices", () => { + const variant = { model_uid: "fusion-future", label: "Fusion (Future Lead + Future Sidekick)" }; + const future = { families: [{ slug: "fusion", family_label: "Fusion", variants: [variant] }] }; + expect(devinModels(future).map((model) => [model.slug, model.name])).toEqual([ + [variant.model_uid, variant.label], + ]); + expect(resolveDevinModel(future, { model: variant.model_uid })).toBe(variant.model_uid); +}); diff --git a/apps/server/src/provider/acp/DevinModels.ts b/apps/server/src/provider/acp/DevinModels.ts index 22e6a4d7b798..6efd6e03c24d 100644 --- a/apps/server/src/provider/acp/DevinModels.ts +++ b/apps/server/src/provider/acp/DevinModels.ts @@ -26,6 +26,7 @@ export const DevinModelCatalog = Schema.fromJsonString( type Catalog = typeof DevinModelCatalog.Type; type Family = Catalog["families"][number]; type Variant = Family["variants"][number]; +type SelectableFamily = Family & Pick; const THINKING_LEVEL_ORDER = [ "none", @@ -76,8 +77,55 @@ function familyVariants(family: Family) { return combinations.size === variants.length && optionCount === variants.length ? variants : []; } +/** Split Fusion by known lead family and exact sidekick; retain the CLI's native IDs. */ +function selectableFamilies(catalog: Catalog): ReadonlyArray { + const variantsByLabel = new Map( + catalog.families.flatMap((family) => + family.slug === "fusion" + ? [] + : family.variants.map((variant) => [variant.label, { family, variant }] as const), + ), + ); + return catalog.families.flatMap((family) => { + if (family.slug !== "fusion") return [family]; + const groups = new Map(); + const unfamiliar: Variant[] = []; + 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)?.family : undefined; + const sidekick = sidekickLabel ? variantsByLabel.get(sidekickLabel)?.variant : undefined; + if (!leadLabel || !lead || !sidekick || !leadLabel.startsWith(lead.family_label)) { + unfamiliar.push(variant); + continue; + } + const slug = `fusion/${lead.slug}/${sidekick.model_uid}`; + const label = `Fusion (${lead.family_label} + ${sidekick.label})`; + const group = groups.get(slug); + const groupedVariant = { + ...variant, + label: `${label}${leadLabel.slice(lead.family_label.length)}`, + }; + groups.set(slug, { + slug, + family_label: label, + fusion: { + lead: { id: lead.slug, name: lead.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 catalog.families.flatMap((family): ServerProviderModel[] => { + return selectableFamilies(catalog).flatMap((family): ServerProviderModel[] => { const variants = familyVariants(family); if (variants.length !== family.variants.length) { return family.variants.map((variant) => ({ @@ -123,16 +171,17 @@ export function devinModels(catalog: Catalog): ServerProviderModel[] { currentValue: first.contextWindow, options: contexts.map((id) => ({ id, label: id === "1m" ? "1M" : "Standard" })), }); + const model: ServerProviderModel = { + slug: family.slug, + name: family.family_label, + isCustom: false, + isDefault: false, + capabilities: { optionDescriptors: descriptors }, + }; + const withAliases = family.aliases ? { ...model, aliases: family.aliases } : model; + const withFusion = family.fusion ? { ...withAliases, fusion: family.fusion } : withAliases; return [ - { - slug: family.slug, - name: family.family_label, - ...(family.aliases ? { aliases: family.aliases } : {}), - isCustom: false, - isDefault: false, - ...(variants.some((variant) => variant.is_new) ? { badge: "new" } : {}), - capabilities: { optionDescriptors: descriptors }, - }, + variants.some((variant) => variant.is_new) ? { ...withFusion, badge: "new" } : withFusion, ]; }); } @@ -142,7 +191,7 @@ export function resolveDevinModel( catalog: Catalog, selection: Pick, ) { - const family = catalog.families.find( + 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. diff --git a/apps/web/src/components/chat/FusionModelPicker.tsx b/apps/web/src/components/chat/FusionModelPicker.tsx new file mode 100644 index 000000000000..be631f20cd45 --- /dev/null +++ b/apps/web/src/components/chat/FusionModelPicker.tsx @@ -0,0 +1,135 @@ +import { useId, useState } from "react"; +import { ArrowLeftIcon } from "lucide-react"; +import { Button } from "../ui/button"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { DevinIcon } from "../Icons"; +import { composerFloatingLayerProps } from "./composerEventScope"; +import { findFusionLeadPairing } from "./fusionModelPicker"; +import { FusionWave } from "./FusionWave"; +import type { ModelEsque } from "./providerIconUtils"; + +export function FusionModelPicker(props: { + models: ReadonlyArray; + model: string; + providerName: string; + onBack: () => void; + onSelect: (model: string) => void; +}) { + const leadLabelId = useId(); + const sidekickLabelId = useId(); + const [selectedSlug, setSelectedSlug] = useState(() => + props.models.some((model) => model.slug === props.model) ? props.model : props.models[0]?.slug, + ); + const selected = props.models.find((model) => model.slug === selectedSlug); + const pairing = selected?.fusion; + const leads = [ + ...new Map( + props.models.flatMap((model) => + model.fusion ? [[model.fusion.lead.id, model.fusion.lead] as const] : [], + ), + ).values(), + ]; + const sidekicks = props.models.flatMap((model) => + model.fusion?.lead.id === pairing?.lead.id && model.fusion + ? [{ ...model.fusion.sidekick, slug: model.slug }] + : [], + ); + + return ( +
+
+
+

Fusion

+
+ + {props.providerName} +
+
+ {pairing ? ( +
+ +
+ + + 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..48a48748c228 --- /dev/null +++ b/apps/web/src/components/chat/FusionWave.css @@ -0,0 +1,23 @@ +.fusion-wave-packets { + animation: fusion-wave-flow 5s linear infinite; +} + +.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..8a6d3ddebbaa --- /dev/null +++ b/apps/web/src/components/chat/FusionWave.tsx @@ -0,0 +1,98 @@ +import { useId, useSyncExternalStore } from "react"; +import "./FusionWave.css"; + +function subscribeVisibility(onChange: () => void) { + document.addEventListener("visibilitychange", onChange); + return () => document.removeEventListener("visibilitychange", onChange); +} + +const isDocumentVisible = () => document.visibilityState === "visible"; +const serverVisible = () => false; + +// Two streams skirt the controls, then meet in the reserved space at the right. +// Dither is generated once; only the open editor animates the packet paths. +const streams = [ + "M-8 10 C65 28 200 24 330 24 C352 24 352 60 375 60", + "M-8 110 C65 92 200 96 330 96 C352 96 352 60 375 60", + "M375 60 H408", +]; +const wavePaths = [0, 1, 2].map((tone) => { + const upper: string[] = []; + const lower: string[] = []; + for (let column = 0; column < 100; column++) { + const x = column * 4; + const merge = Math.min(1, Math.max(0, (x - 330) / 45)); + const spread = + (36 + 14 * Math.pow(Math.max(0, 1 - x / 100), 2)) * (1 - merge * merge * (3 - 2 * merge)); + for (let row = 0; row < 30; row++) { + const y = row * 4; + if (x < 330 && y > 33 && y < 87) continue; + const distance = Math.abs(Math.abs(y - 60) - spread); + const density = Math.max(0, 1 - distance / 10); + const noise = ((column * 73 + row * 37 + column * row * 11) % 101) / 101; + if (noise < density && (column + row * 2) % 3 === tone) { + (y < 60 ? upper : lower).push(`M${x} ${y}h2v2h-2z`); + } + } + } + return { upper: upper.join(""), lower: lower.join(""), tone, opacity: [0.12, 0.24, 0.4][tone] }; +}); + +export function FusionWave({ animated = false }: { animated?: boolean }) { + const gradientId = useId(); + const upperFill = `url(#${gradientId}-upper)`; + const lowerFill = `url(#${gradientId}-lower)`; + const visible = useSyncExternalStore(subscribeVisibility, isDocumentVisible, serverVisible); + return ( + + ); +} diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 2226cf243828..a7c58a87e66a 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -8,7 +8,10 @@ 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 { ChevronRightIcon, SearchIcon } from "lucide-react"; +import { ChevronRightIcon, SearchIcon, StarIcon } from "lucide-react"; +import { DevinIcon } from "../Icons"; +import { FusionModelPicker } from "./FusionModelPicker"; +import { collapseFusionModels } from "./fusionModelPicker"; import { ModelListRow } from "./ModelListRow"; import { ModelPickerSidebar } from "./ModelPickerSidebar"; import { getProviderStatusMessage, hasProviderSetup } from "./ProviderStatusBanner"; @@ -38,7 +41,7 @@ import { import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; -import { TooltipProvider } from "../ui/tooltip"; +import { Tooltip, TooltipPopup, TooltipTrigger, TooltipProvider } from "../ui/tooltip"; import { Button } from "../ui/button"; import { isProviderInstancePickerReady, @@ -48,6 +51,7 @@ import { import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; type ModelPickerItem = { + fusion?: ModelEsque["fusion"]; slug: string; name: string; shortName?: string; @@ -176,6 +180,12 @@ 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 ? { instanceId: props.activeInstanceId, model: activeModel.slug } : null, + ); const activeModelSlug = activeModel?.slug ?? (props.model === ANTIGRAVITY_DEFAULT_MODEL ? "" : props.model); const activeModelKey = activeModelSlug @@ -332,6 +342,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 } : {}), @@ -387,7 +398,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. @@ -492,6 +503,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; @@ -550,6 +569,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const handleModelSelect = useCallback( (modelSlug: string, instanceId: ProviderInstanceId) => { + const option = modelOptionsByInstance + .get(instanceId) + ?.find((model) => model.slug === modelSlug); + if ( + option?.fusion && + !option.isUnavailable && + (selectedInstanceId !== "favorites" || isSearching) + ) { + setFusionSelection({ instanceId, model: modelSlug }); + return; + } if (getModelDisabledReason?.(instanceId, modelSlug)) { return; } @@ -569,7 +599,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( @@ -681,7 +719,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { useEffect(() => { const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.defaultPrevented || event.repeat || isCommandPaletteOpen()) { + if (fusionSelection || event.defaultPrevented || event.repeat || isCommandPaletteOpen()) { return; } @@ -712,7 +750,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return () => { window.removeEventListener("keydown", onWindowKeyDown, true); }; - }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]); + }, [ + handleModelSelect, + keybindings, + modelJumpModelKeys, + modelJumpShortcutContext, + fusionSelection, + ]); useLayoutEffect(() => { setShowTopScrollFade(false); @@ -728,10 +772,37 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }; }, [filteredItemKeys, 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); + window.requestAnimationFrame(focusSearchInput); + }} + onSelect={(model) => onInstanceModelChange(instanceId, model)} + /> + ); + } + return (
{/* Sidebar */} @@ -880,6 +951,59 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (!model) { return null; } + if (model.fusion && model.name === "Fusion") { + const isFavorite = favoritesSet.has( + providerModelKey(model.instanceId, model.slug), + ); + return ( + +
+
Fusion
+
+ + + + } + > + {model.fusion.lead.name} + {model.fusion.sidekick.name} + + + {model.instanceDisplayName} · {model.fusion.lead.name} +{" "} + {model.fusion.sidekick.name} + + +
+
+ + +
+ ); + } const disabledReason = getModelDisabledReason?.(model.instanceId, model.slug) ?? null; return ( diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 83219d2d29e3..4eb8e7cd0fc7 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -83,11 +83,13 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { activeEntry?.driverKind === "devin" ? 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; diff --git a/apps/web/src/components/chat/fusionModelPicker.test.ts b/apps/web/src/components/chat/fusionModelPicker.test.ts new file mode 100644 index 000000000000..e3e1d4c70d91 --- /dev/null +++ b/apps/web/src/components/chat/fusionModelPicker.test.ts @@ -0,0 +1,70 @@ +import { ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { collapseFusionModels, findFusionLeadPairing } from "./fusionModelPicker"; + +const instanceId = ProviderInstanceId.make("devin-work"); +const otherInstanceId = ProviderInstanceId.make("devin-personal"); +const fable = { id: "fable", name: "Claude Fable 5.1" }; +const opus = { id: "opus", name: "Claude Opus 5" }; +const swe = { id: "swe", name: "SWE-2 Medium" }; +const glm = { id: "glm", name: "GLM-5.2 High" }; +const models = [ + { instanceId, slug: "swe", name: "SWE-2" }, + { + instanceId, + slug: "fable-swe", + name: "Fusion (Fable + SWE)", + fusion: { lead: fable, sidekick: swe }, + }, + { + instanceId, + slug: "opus-swe", + name: "Fusion (Opus + SWE)", + fusion: { lead: opus, sidekick: swe }, + }, + { + instanceId, + slug: "opus-glm", + name: "Fusion (Opus + GLM)", + fusion: { lead: opus, sidekick: glm }, + }, +]; + +describe("Fusion picker", () => { + 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..874adea4aff3 --- /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" }]; + }); +} + +/** 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 fdf4f6262eeb..baccc9104488 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,4 +1,4 @@ -import { ProviderDriverKind } from "@t3tools/contracts"; +import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts"; import { AntigravityIcon, ClaudeAI, @@ -21,6 +21,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial }; export type ModelEsque = { + fusion?: ServerProviderModel["fusion"]; slug: string; name: string; shortName?: string | undefined; diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index a6435ad3108f..040bc5cd1199 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -79,6 +79,7 @@ function readInstanceCustomModels( } export interface AppModelOption { + fusion?: ServerProvider["models"][number]["fusion"]; slug: string; name: string; shortName?: string; @@ -119,6 +120,7 @@ function toAppModelOption(model: ServerProvider["models"][number]): AppModelOpti isCustom: model.isCustom, }; if (model.shortName) option.shortName = model.shortName; + if (model.fusion) option.fusion = model.fusion; if (model.subProvider) option.subProvider = model.subProvider; if (model.aliases) option.aliases = model.aliases; if (model.badge) option.badge = model.badge; diff --git a/docs/user/providers-devin.md b/docs/user/providers-devin.md index ac0ba5c13353..823a584ac709 100644 --- a/docs/user/providers-devin.md +++ b/docs/user/providers-devin.md @@ -6,6 +6,8 @@ If `devin` launches Devin Desktop, set **Binary path** to the actual CLI executa Available models load from `devin models list --format json` when provider status refreshes, before you start a chat. Refresh **Settings → Providers** after changing your Devin subscription. Choose a model family, then set its thinking level and, where available, Fast mode or context window. Workspace commands load when the session opens. +For [Fusion](https://docs.devin.ai/cli/fusion), use Devin CLI 3000.10.20 or newer with a paid plan, then refresh provider status. On web and desktop, choose **Fusion** in the model picker, select a lead and sidekick, then choose **Use Fusion**. Set the lead's thinking level and Fast mode with the composer controls. Mobile lists the available pairings directly. Switch to a regular model to leave Fusion in the same thread. + Select a model from your account's catalog. Saved threads resume their Devin session, and model changes apply within the same thread. A temporary refresh failure keeps the last available model list; signing out clears it. T3 forwards permission requests and supports Plan mode, cancellation, images, file attachments, and `/compact`. Auto mode uses Devin's Smart mode when available and asks for approval otherwise. Conversation rewind is not supported by Devin ACP. diff --git a/package.json b/package.json index dd1a106a0409..499301e90e49 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,10 @@ }, "devDependencies": { "@effect/tsgo": "catalog:", + "@oxlint/plugins": "1.82.0", "@types/node": "catalog:", "knip": "6.34.0", + "oxlint": "1.82.0", "typescript": "catalog:", "vite-plus": "catalog:" }, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c2fc8524ece2..c42fc6d3d010 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -76,6 +76,13 @@ export const ServerProviderModel = Schema.Struct({ isCustom: Schema.Boolean, isDefault: Schema.optional(Schema.Boolean), isLegacy: Schema.optional(Schema.Boolean), + /** Lets clients configure a Fusion pairing without parsing its display label or native ID. */ + fusion: Schema.optional( + Schema.Struct({ + lead: Schema.Struct({ id: TrimmedNonEmptyString, name: TrimmedNonEmptyString }), + sidekick: Schema.Struct({ id: TrimmedNonEmptyString, name: TrimmedNonEmptyString }), + }), + ), capabilities: Schema.NullOr(ModelCapabilities), }); export type ServerProviderModel = typeof ServerProviderModel.Type; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f175605d8bf..d1ed998070c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,12 +114,18 @@ importers: '@effect/tsgo': specifier: 'catalog:' version: 0.41.0 + '@oxlint/plugins': + specifier: 1.82.0 + version: 1.82.0 '@types/node': specifier: 24.12.4 version: 24.12.4 knip: specifier: 6.34.0 version: 6.34.0 + oxlint: + specifier: 1.82.0 + version: 1.82.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) typescript: specifier: 'catalog:' version: 7.0.2 @@ -781,7 +787,7 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.76 - version: 2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e) + version: 2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc version: 1.0.0-rc.5-ab785fc(8ab70e2706da13c78d64d8a92fef1884) @@ -4087,42 +4093,84 @@ packages: cpu: [arm] os: [android] + '@oxlint/binding-android-arm-eabi@1.82.0': + resolution: {integrity: sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@oxlint/binding-android-arm64@1.79.0': resolution: {integrity: sha512-KqqnOtAVgNsPPF0YSodkFZA1O80jcKoCZCTu3bgsszxA+MrMP9TLzfXitKjEj1FmrPprKDMdRDMmY3weESO9sg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@oxlint/binding-android-arm64@1.82.0': + resolution: {integrity: sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@oxlint/binding-darwin-arm64@1.79.0': resolution: {integrity: sha512-BVC2nsMzqQzRDPc5RhixkZ+m1p7iH4bxRRvqkbwDXX0PlQKm1BPy8J8cRjnAFafOq2QzI+BfO3vE8w2GZ3CBag==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@oxlint/binding-darwin-arm64@1.82.0': + resolution: {integrity: sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@oxlint/binding-darwin-x64@1.79.0': resolution: {integrity: sha512-p6Lm+snmhGuLKL1+CpCV8L6ijkE/qJzK2H2jG9+eKJT0n31RbY4FLsdhexekgP3bLpw4Kgde+9DZuDZQ4yIInA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@oxlint/binding-darwin-x64@1.82.0': + resolution: {integrity: sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@oxlint/binding-freebsd-x64@1.79.0': resolution: {integrity: sha512-qDMm0dXZnoHyRqSL4N4xUq82T4sqK5cbKSjvd/dF/YbMUXc2R1wEPf+vmA5S0qUmi0nwXfNbjXBtZaIqzQLIMg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@oxlint/binding-freebsd-x64@1.82.0': + resolution: {integrity: sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@oxlint/binding-linux-arm-gnueabihf@1.79.0': resolution: {integrity: sha512-2od7s0nuKPzqyUZAWk9KkCyGg7eI9dwFPZg+20lB15fKFkVZ0c9ZFxqPfiBAyDTlTkh9stPI0t+JlPCqMbItVA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxlint/binding-linux-arm-gnueabihf@1.82.0': + resolution: {integrity: sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxlint/binding-linux-arm-musleabihf@1.79.0': resolution: {integrity: sha512-ZOQUjkzDnvlhSE3+tWC3YXx94MMl+sYMlwH+u1+YGApGHOJP/YAc8ZBRFOXZ6eOBmxtXAWuS/fBcdZr8qqNO1A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxlint/binding-linux-arm-musleabihf@1.82.0': + resolution: {integrity: sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxlint/binding-linux-arm64-gnu@1.79.0': resolution: {integrity: sha512-lu158FR4nGqGeRS3BQvtG85wRgU/Fy4MD5Cxp1hzJXizGiLo6u2742wJSCDKh8cFcZntvX7fcxlq4mMmfryH1g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4130,6 +4178,13 @@ packages: os: [linux] libc: [glibc] + '@oxlint/binding-linux-arm64-gnu@1.82.0': + resolution: {integrity: sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@oxlint/binding-linux-arm64-musl@1.79.0': resolution: {integrity: sha512-mbpKQeE2aflTjddaHK7MP8KP/OFbUM++lt5M635ENM8IyIdK0jm2t9pb+2v9mVVIvhF6TqA4l7F79Pll1mi+uw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4137,6 +4192,13 @@ packages: os: [linux] libc: [musl] + '@oxlint/binding-linux-arm64-musl@1.82.0': + resolution: {integrity: sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@oxlint/binding-linux-ppc64-gnu@1.79.0': resolution: {integrity: sha512-WpGNua7gaxaHnpSDeog2ji8IDHn/QLPl9LPzwkR/FvVv58vT5BcXjRXnU+wbu3N75cpeha8CdC7ho/U2OIsB4g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4144,6 +4206,13 @@ packages: os: [linux] libc: [glibc] + '@oxlint/binding-linux-ppc64-gnu@1.82.0': + resolution: {integrity: sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@oxlint/binding-linux-riscv64-gnu@1.79.0': resolution: {integrity: sha512-tK1E93A5LVzISg4ngpKJnfTs7EqtIUceGI7MQ4GyDjJiLi8wPCkEyKlj2xkyKWZ1yzkDJyLHTBJ5/iFWRdnJvg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4151,6 +4220,13 @@ packages: os: [linux] libc: [glibc] + '@oxlint/binding-linux-riscv64-gnu@1.82.0': + resolution: {integrity: sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@oxlint/binding-linux-riscv64-musl@1.79.0': resolution: {integrity: sha512-qhQvUIrngXivA2A9pQ+xPCychztn/5qUv7yS3gDwXv3w7Rag+eTeeXWmRyx+t7XsW5x6LuY/8AsTq36UgFIblg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4158,6 +4234,13 @@ packages: os: [linux] libc: [musl] + '@oxlint/binding-linux-riscv64-musl@1.82.0': + resolution: {integrity: sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + '@oxlint/binding-linux-s390x-gnu@1.79.0': resolution: {integrity: sha512-sv6AaVgU/eE6u+6WFiQVDcPPwTxP6IJMSB9k701W2r/r6Tx465e8vPvVyRxquNH4Vy6KwRNu90mVbxXJN8+5gg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4165,6 +4248,13 @@ packages: os: [linux] libc: [glibc] + '@oxlint/binding-linux-s390x-gnu@1.82.0': + resolution: {integrity: sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@oxlint/binding-linux-x64-gnu@1.79.0': resolution: {integrity: sha512-iFZL02deziHslb3jEX9KdqlAkYoo4fGyotchKDzdfK1f5mxlIBeiQeHhvK3iFpuEJSB4ma/qeFn9oxPiwnhUPQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4172,6 +4262,13 @@ packages: os: [linux] libc: [glibc] + '@oxlint/binding-linux-x64-gnu@1.82.0': + resolution: {integrity: sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@oxlint/binding-linux-x64-musl@1.79.0': resolution: {integrity: sha512-3DtZR2raqObnh7wXZoFYFd0Fw7skBvcb3f7A+/lkEiDuh8hrE6vv9b/62Qxao1a9/OeHLw/FcXlXzgsW9wTRFg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4179,30 +4276,61 @@ packages: os: [linux] libc: [musl] + '@oxlint/binding-linux-x64-musl@1.82.0': + resolution: {integrity: sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@oxlint/binding-openharmony-arm64@1.79.0': resolution: {integrity: sha512-Oatt4GuA1WJkqzk2ozx4HrWROOi7opV3AKDw/U8qDIqeTqzsjn5K2x3REJMNjU3/KU/Bkq96Zi3CknaiDTaC/Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@oxlint/binding-openharmony-arm64@1.82.0': + resolution: {integrity: sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@oxlint/binding-win32-arm64-msvc@1.79.0': resolution: {integrity: sha512-NAgZr9Qp8nIA9rpo0JEvwiabTF/2UVqBNnupBG9X4kxXcQoScJUTi+qHhvabb9s/thgj5wQ4XcIaJvb+ZMgoKw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@oxlint/binding-win32-arm64-msvc@1.82.0': + resolution: {integrity: sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@oxlint/binding-win32-ia32-msvc@1.79.0': resolution: {integrity: sha512-+KyXjIvcpaXmWW/j9NNY5yWjrIVxaX18VyIheQy3jwc2GSYgpCr7MGI/HxIGQ/shAL5IWEKbhsqoMpAO5Stiog==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] + '@oxlint/binding-win32-ia32-msvc@1.82.0': + resolution: {integrity: sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + '@oxlint/binding-win32-x64-msvc@1.79.0': resolution: {integrity: sha512-mEelcCMMBS57sIXh2veGMNy+pQwuGtcMxHxGIZWQ5Ba9pJ5jCCUFOZB9E2JhBaxGsURe+WGe0zJp4RVre52gpQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@oxlint/binding-win32-x64-msvc@1.82.0': + resolution: {integrity: sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxlint/plugins@1.68.0': resolution: {integrity: sha512-titLmukUt/h8ho7Svlf0xSBjoy2ccZKrXjpXpZCj+v6V4CJccC2KyP45BLSCMx8YIpifMyiDyUptM4+5sruKbQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -4211,6 +4339,10 @@ packages: resolution: {integrity: sha512-S0uyoxakDINJ4DPgqxGlEEvrdSMeQb7Z2lKVjxoY2gwsbZbfg2Xr8Klfeo5ZeraHmmdBCELFUHkSe6KEmBpMvg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@oxlint/plugins@1.82.0': + resolution: {integrity: sha512-IDF4UXBNOaCeLoEpVxf5Bg85KOWdeuBEkbo1rGzS2vXcbD+GT1asBSeeTC+5BgW6NFTHVByd6N5giHsFPGtwAA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@peculiar/asn1-schema@2.8.0': resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==} @@ -9276,6 +9408,19 @@ packages: vite-plus: optional: true + oxlint@1.82.0: + resolution: {integrity: sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2001' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -14691,64 +14836,123 @@ snapshots: '@oxlint/binding-android-arm-eabi@1.79.0': optional: true + '@oxlint/binding-android-arm-eabi@1.82.0': + optional: true + '@oxlint/binding-android-arm64@1.79.0': optional: true + '@oxlint/binding-android-arm64@1.82.0': + optional: true + '@oxlint/binding-darwin-arm64@1.79.0': optional: true + '@oxlint/binding-darwin-arm64@1.82.0': + optional: true + '@oxlint/binding-darwin-x64@1.79.0': optional: true + '@oxlint/binding-darwin-x64@1.82.0': + optional: true + '@oxlint/binding-freebsd-x64@1.79.0': optional: true + '@oxlint/binding-freebsd-x64@1.82.0': + optional: true + '@oxlint/binding-linux-arm-gnueabihf@1.79.0': optional: true + '@oxlint/binding-linux-arm-gnueabihf@1.82.0': + optional: true + '@oxlint/binding-linux-arm-musleabihf@1.79.0': optional: true + '@oxlint/binding-linux-arm-musleabihf@1.82.0': + optional: true + '@oxlint/binding-linux-arm64-gnu@1.79.0': optional: true + '@oxlint/binding-linux-arm64-gnu@1.82.0': + optional: true + '@oxlint/binding-linux-arm64-musl@1.79.0': optional: true + '@oxlint/binding-linux-arm64-musl@1.82.0': + optional: true + '@oxlint/binding-linux-ppc64-gnu@1.79.0': optional: true + '@oxlint/binding-linux-ppc64-gnu@1.82.0': + optional: true + '@oxlint/binding-linux-riscv64-gnu@1.79.0': optional: true + '@oxlint/binding-linux-riscv64-gnu@1.82.0': + optional: true + '@oxlint/binding-linux-riscv64-musl@1.79.0': optional: true + '@oxlint/binding-linux-riscv64-musl@1.82.0': + optional: true + '@oxlint/binding-linux-s390x-gnu@1.79.0': optional: true + '@oxlint/binding-linux-s390x-gnu@1.82.0': + optional: true + '@oxlint/binding-linux-x64-gnu@1.79.0': optional: true + '@oxlint/binding-linux-x64-gnu@1.82.0': + optional: true + '@oxlint/binding-linux-x64-musl@1.79.0': optional: true + '@oxlint/binding-linux-x64-musl@1.82.0': + optional: true + '@oxlint/binding-openharmony-arm64@1.79.0': optional: true + '@oxlint/binding-openharmony-arm64@1.82.0': + optional: true + '@oxlint/binding-win32-arm64-msvc@1.79.0': optional: true + '@oxlint/binding-win32-arm64-msvc@1.82.0': + optional: true + '@oxlint/binding-win32-ia32-msvc@1.79.0': optional: true + '@oxlint/binding-win32-ia32-msvc@1.82.0': + optional: true + '@oxlint/binding-win32-x64-msvc@1.79.0': optional: true + '@oxlint/binding-win32-x64-msvc@1.82.0': + optional: true + '@oxlint/plugins@1.68.0': {} '@oxlint/plugins@1.79.0': {} + '@oxlint/plugins@1.82.0': {} + '@peculiar/asn1-schema@2.8.0': dependencies: '@peculiar/utils': 2.0.3 @@ -16453,7 +16657,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e): + alchemy@2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f): dependencies: '@alchemy.run/cloudflare-runtime': 2.0.0-beta.76(@distilled.cloud/cloudflare@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)))(@effect/platform-bun@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6))(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(rolldown@1.2.5)(typescript@7.0.2) '@alchemy.run/floci': 2.0.0-beta.76(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) @@ -20533,6 +20737,30 @@ snapshots: oxlint-tsgolint: 7.0.2001 vite-plus: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + oxlint@1.82.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.82.0 + '@oxlint/binding-android-arm64': 1.82.0 + '@oxlint/binding-darwin-arm64': 1.82.0 + '@oxlint/binding-darwin-x64': 1.82.0 + '@oxlint/binding-freebsd-x64': 1.82.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.82.0 + '@oxlint/binding-linux-arm-musleabihf': 1.82.0 + '@oxlint/binding-linux-arm64-gnu': 1.82.0 + '@oxlint/binding-linux-arm64-musl': 1.82.0 + '@oxlint/binding-linux-ppc64-gnu': 1.82.0 + '@oxlint/binding-linux-riscv64-gnu': 1.82.0 + '@oxlint/binding-linux-riscv64-musl': 1.82.0 + '@oxlint/binding-linux-s390x-gnu': 1.82.0 + '@oxlint/binding-linux-x64-gnu': 1.82.0 + '@oxlint/binding-linux-x64-musl': 1.82.0 + '@oxlint/binding-openharmony-arm64': 1.82.0 + '@oxlint/binding-win32-arm64-msvc': 1.82.0 + '@oxlint/binding-win32-ia32-msvc': 1.82.0 + '@oxlint/binding-win32-x64-msvc': 1.82.0 + oxlint-tsgolint: 7.0.2001 + vite-plus: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + p-cancelable@2.1.1: {} p-limit@2.3.0: diff --git a/tools/oxlint/anti-slop/effect/index.ts b/tools/oxlint/anti-slop/effect/index.ts new file mode 100644 index 000000000000..37247862ce43 --- /dev/null +++ b/tools/oxlint/anti-slop/effect/index.ts @@ -0,0 +1,13 @@ +import { eslintCompatPlugin } from "@oxlint/plugins"; + +import { noServiceConstructorImportsRule } from "./rules/no-service-constructor-imports.ts"; + +/** Opt-in Oxlint rules for Effect service and Layer architecture. */ +const antiSlopEffectPlugin = eslintCompatPlugin({ + meta: { name: "anti-slop-effect" }, + rules: { + "no-service-constructor-imports": noServiceConstructorImportsRule, + }, +}); + +export default antiSlopEffectPlugin; diff --git a/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts b/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts new file mode 100644 index 000000000000..55cefb7e7b4c --- /dev/null +++ b/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts @@ -0,0 +1,52 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +const SERVICE_CONSTRUCTOR_NAME = /^make[A-Z]/u; +const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/u; + +function isProjectLocalImport(source: string): boolean { + return source.startsWith("./") || source.startsWith("../"); +} + +function getImportedName(specifier: ESTree.ImportSpecifier): string { + if (specifier.imported.type === "Identifier") return specifier.imported.name; + return specifier.imported.value; +} + +/** Keep dependency-bearing Effect service constructors local to their owning capability modules. */ +export const noServiceConstructorImportsRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow project-local make imports outside test and spec files.", + }, + messages: { + serviceConstructorImport: + 'Do not import Effect service constructor "{{name}}" into runtime code. Import the owning Layer, yield the contextual service, and allow its requirements to propagate to the composition root.', + }, + }, + create(context) { + const isTestFile = TEST_FILE.test(context.filename.replaceAll("\\", "/")); + + return { + ImportDeclaration(node) { + if (isTestFile || !isProjectLocalImport(node.source.value)) return; + + for (const specifier of node.specifiers) { + if (specifier.type !== "ImportSpecifier") continue; + + const importedName = getImportedName(specifier); + if (!SERVICE_CONSTRUCTOR_NAME.test(importedName)) continue; + + context.report({ + node: specifier, + messageId: "serviceConstructorImport", + data: { name: importedName }, + }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/index.ts b/tools/oxlint/anti-slop/index.ts new file mode 100644 index 000000000000..2b4ae2223e33 --- /dev/null +++ b/tools/oxlint/anti-slop/index.ts @@ -0,0 +1,41 @@ +import { eslintCompatPlugin } from "@oxlint/plugins"; + +import { noChainedTypeAssertionsRule } from "./rules/no-chained-type-assertions.ts"; +import { noConditionalEmptyObjectSpreadRule } from "./rules/no-conditional-empty-object-spread.ts"; +import { noKnownValueWideningRule } from "./rules/no-known-value-widening.ts"; +import { noModuleMockingRule } from "./rules/no-module-mocking.ts"; +import { noObjectParametersRule } from "./rules/no-object-parameters.ts"; +import { noReflectApplyRule } from "./rules/no-reflect-apply.ts"; +import { noReflectGetRule } from "./rules/no-reflect-get.ts"; +import { noRuntimeTypeofRule } from "./rules/no-runtime-typeof.ts"; +import { noForbiddenTermInSymbolNamesRule } from "./rules/no-shape-in-symbol-names.ts"; +import { noUnknownParametersRule } from "./rules/no-unknown-parameters.ts"; +import { noUnknownReturnsRule } from "./rules/no-unknown-returns.ts"; +import { noUnknownTypeAliasesRule } from "./rules/no-unknown-type-aliases.ts"; +import { noUnsafeDictionaryTypeRule } from "./rules/no-unsafe-dictionary-type.ts"; +import { noWidenThenAssertRule } from "./rules/no-widen-then-assert.ts"; +import { requireSafetyCommentForTypeAssertionRule } from "./rules/require-safety-comment-for-type-assertion.ts"; + +/** Generic Oxlint rules that reject low-evidence and low-signal implementation patterns. */ +const antiSlopPlugin = eslintCompatPlugin({ + meta: { name: "anti-slop" }, + rules: { + "no-chained-type-assertions": noChainedTypeAssertionsRule, + "no-conditional-empty-object-spread": noConditionalEmptyObjectSpreadRule, + "no-known-value-widening": noKnownValueWideningRule, + "no-module-mocking": noModuleMockingRule, + "no-object-parameters": noObjectParametersRule, + "no-reflect-apply": noReflectApplyRule, + "no-reflect-get": noReflectGetRule, + "no-runtime-typeof": noRuntimeTypeofRule, + "no-unsafe-dictionary-type": noUnsafeDictionaryTypeRule, + "no-shape-in-symbol-names": noForbiddenTermInSymbolNamesRule, + "no-unknown-parameters": noUnknownParametersRule, + "no-unknown-returns": noUnknownReturnsRule, + "no-unknown-type-aliases": noUnknownTypeAliasesRule, + "no-widen-then-assert": noWidenThenAssertRule, + "require-safety-comment-for-type-assertion": requireSafetyCommentForTypeAssertionRule, + }, +}); + +export default antiSlopPlugin; diff --git a/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts new file mode 100644 index 000000000000..0d118527804c --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts @@ -0,0 +1,77 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +type TypeAssertionExpression = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +function isTypeAssertionExpression(node: ESTree.Node): node is TypeAssertionExpression { + return node.type === "TSAsExpression" || node.type === "TSTypeAssertion"; +} + +function unwrapParenthesizedExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isConstAssertion(node: TypeAssertionExpression): boolean { + const { typeAnnotation } = node; + return ( + typeAnnotation.type === "TSTypeReference" && + typeAnnotation.typeName.type === "Identifier" && + typeAnnotation.typeName.name === "const" + ); +} + +function isOutermostAssertionInChain(node: TypeAssertionExpression): boolean { + let current: ESTree.Expression = node; + let parent = node.parent; + + while (parent.type === "ParenthesizedExpression" && parent.expression === current) { + current = parent; + parent = parent.parent; + } + + return !isTypeAssertionExpression(parent) || parent.expression !== current; +} + +function isForbiddenAssertionChain(node: TypeAssertionExpression): boolean { + let assertionCount = 0; + let hasNonConstAssertion = false; + let current: ESTree.Expression = node; + + while (isTypeAssertionExpression(current)) { + assertionCount += 1; + hasNonConstAssertion ||= !isConstAssertion(current); + current = unwrapParenthesizedExpression(current.expression); + } + + return assertionCount > 1 && hasNonConstAssertion; +} + +/** Disallow nested TypeScript type assertions, while permitting chains made only of const assertions. */ +export const noChainedTypeAssertionsRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow chained TypeScript as and angle-bracket assertions, including parenthesized chains.", + }, + messages: { + chained: + "This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.", + }, + }, + createOnce(context) { + const checkTypeAssertion = (node: TypeAssertionExpression) => { + if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return; + context.report({ node, messageId: "chained" }); + }; + + return { + TSAsExpression: checkTypeAssertion, + TSTypeAssertion: checkTypeAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts new file mode 100644 index 000000000000..ae7248d36e54 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts @@ -0,0 +1,49 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +function unwrapParentheses(node: ESTree.Expression): ESTree.Expression { + let current = node; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isEmptyObjectExpression(node: ESTree.Expression): boolean { + return node.type === "ObjectExpression" && node.properties.length === 0; +} + +function isConditionalEmptyObjectSpread(node: ESTree.Expression): boolean { + const conditional = unwrapParentheses(node); + return ( + conditional.type === "ConditionalExpression" && + (isEmptyObjectExpression(conditional.consequent) || + isEmptyObjectExpression(conditional.alternate)) + ); +} + +/** Ban conditional empty-object spreads without changing their omission semantics. */ +export const noConditionalEmptyObjectSpreadRule = defineRule({ + meta: { + type: "suggestion", + docs: { + description: + "Disallow object spreads that conditionally spread an empty object to omit fields.", + }, + messages: { + avoid: + "This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present.", + }, + }, + createOnce(context) { + return { + SpreadElement(node) { + if (node.parent.type !== "ObjectExpression") return; + + if (isConditionalEmptyObjectSpread(node.argument)) { + context.report({ node, messageId: "avoid" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-known-value-widening.ts b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts new file mode 100644 index 000000000000..2a6806c69946 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts @@ -0,0 +1,247 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyWideningTarget, + createTypeEnvironment, + isKnownEvidenceExpression, + type TypeEnvironment, + type WideningTarget, +} from "../shared/dictionary-types.ts"; + +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +type FunctionExpression = ESTree.ArrowFunctionExpression | ESTree.Function; + +function unwrapExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSSatisfiesExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current; +} + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + if (variable.defs.length !== 1) return null; + const [definition] = variable.defs; + return definition?.type === "Variable" && definition.node.type === "VariableDeclarator" + ? definition.node + : null; +} + +function isStableConstVariable(variable: Variable, declarator: ESTree.VariableDeclarator): boolean { + return ( + declarator.parent.type === "VariableDeclaration" && + declarator.parent.kind === "const" && + variable.references.every((reference) => reference.init || !reference.isWrite()) + ); +} + +function hasKnownEvidence( + sourceCode: SourceCode, + expression: ESTree.Expression, + visitedVariables = new Set(), +): boolean { + if (isKnownEvidenceExpression(expression)) return true; + const unwrapped = unwrapExpression(expression); + if (unwrapped.type !== "Identifier") return false; + const variable = resolveVariable(sourceCode, unwrapped); + if (variable === null || visitedVariables.has(variable)) return false; + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.init === null || + !isStableConstVariable(variable, declarator) + ) { + return false; + } + visitedVariables.add(variable); + return hasKnownEvidence(sourceCode, declarator.init, visitedVariables); +} + +function annotationTarget( + annotation: ESTree.TSTypeAnnotation | null | undefined, + environment: TypeEnvironment, +): WideningTarget | null { + return annotation === null || annotation === undefined + ? null + : classifyWideningTarget(annotation.typeAnnotation, environment); +} + +function enclosingFunction(node: ESTree.Node): FunctionExpression | null { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if ( + current.type === "ArrowFunctionExpression" || + current.type === "FunctionDeclaration" || + current.type === "FunctionExpression" + ) { + return current; + } + current = current.parent; + } + return null; +} + +function sourceKeyName(sourceCode: SourceCode, key: ESTree.PropertyKey): string { + if (key.type === "Identifier" || key.type === "PrivateIdentifier") return key.name; + if (key.type === "Literal") return String(key.value); + return sourceCode.getText(key); +} + +function functionName(sourceCode: SourceCode, owner: FunctionExpression | null): string { + if (owner === null) return "anonymous function"; + if (owner.id !== null) return owner.id.name; + const parent = owner.parent; + if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier") + return parent.id.name; + if (parent.type === "MethodDefinition") return sourceKeyName(sourceCode, parent.key); + return "anonymous function"; +} + +function isEmptyObjectExpression(expression: ESTree.Expression): boolean { + const unwrapped = unwrapExpression(expression); + return unwrapped.type === "ObjectExpression" && unwrapped.properties.length === 0; +} + +function isDictionaryAccumulatorTarget(destination: WideningTarget): boolean { + return destination.kind === "open dictionary" || destination.kind === "generic container"; +} + +function hasParentAssertion(node: ESTree.Node): boolean { + return node.parent?.type === "TSAsExpression" || node.parent?.type === "TSTypeAssertion"; +} + +/** Detect sound syntactic cases where a known value is explicitly widened and loses evidence. */ +export const noKnownValueWideningRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow syntactically established values from flowing into explicitly broad or anonymous target types that discard useful evidence.", + }, + messages: { + widening: + "The explicit {{target}} type on {{subject}} discards known type evidence. Keep inference, validate with `satisfies`, or use a named owner contract.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + + const reportFlow = ( + expression: ESTree.Expression, + destination: WideningTarget | null, + subject: string, + ) => { + if (destination === null) return; + if ( + isDictionaryAccumulatorTarget(destination) && + isEmptyObjectExpression(expression) + ) { + return; + } + if (!hasKnownEvidence(context.sourceCode, expression)) return; + context.report({ + node: expression, + messageId: "widening", + data: { subject, target: destination.kind }, + }); + }; + + const targetFromAnnotation = (annotation: ESTree.TSTypeAnnotation | null | undefined) => + environment === null ? null : annotationTarget(annotation, environment); + + return { + Program(node) { + environment = createTypeEnvironment(node); + }, + VariableDeclarator(node) { + if (node.init === null || node.id.type !== "Identifier") return; + reportFlow( + node.init, + targetFromAnnotation(node.id.typeAnnotation), + `binding \`${node.id.name}\``, + ); + }, + PropertyDefinition(node) { + if (node.value === null) return; + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ); + }, + AccessorProperty(node) { + if (node.value === null) return; + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ); + }, + AssignmentExpression(node) { + if (node.operator !== "=" || node.left.type !== "Identifier") return; + const variable = resolveVariable(context.sourceCode, node.left); + if (variable === null) return; + const declarator = variableDeclarator(variable); + if (declarator === null || declarator.id.type !== "Identifier") return; + reportFlow( + node.right, + targetFromAnnotation(declarator.id.typeAnnotation), + `binding \`${declarator.id.name}\``, + ); + }, + ReturnStatement(node) { + if (node.argument === null) return; + const owner = enclosingFunction(node); + reportFlow( + node.argument, + targetFromAnnotation(owner?.returnType), + `return value of \`${functionName(context.sourceCode, owner)}\``, + ); + }, + ArrowFunctionExpression(node) { + if (node.body.type === "BlockStatement") return; + reportFlow( + node.body, + targetFromAnnotation(node.returnType), + `return value of \`${functionName(context.sourceCode, node)}\``, + ); + }, + TSAsExpression(node) { + if (environment === null || hasParentAssertion(node)) return; + reportFlow( + node.expression, + classifyWideningTarget(node.typeAnnotation, environment), + "assertion", + ); + }, + TSTypeAssertion(node) { + if (environment === null || hasParentAssertion(node)) return; + reportFlow( + node.expression, + classifyWideningTarget(node.typeAnnotation, environment), + "assertion", + ); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-module-mocking.ts b/tools/oxlint/anti-slop/rules/no-module-mocking.ts new file mode 100644 index 000000000000..d6fb5b45c034 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-module-mocking.ts @@ -0,0 +1,91 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +const moduleMockMethods = new Set(["doMock", "mock", "unstable_mockModule"]); + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function importedName(node: ESTree.Node): string | null { + if (node.type !== "ImportSpecifier") return null; + return node.imported.type === "Identifier" ? node.imported.name : node.imported.value; +} + +function isTestFrameworkObject( + sourceCode: SourceCode, + expression: ESTree.Expression, +): expression is ESTree.IdentifierReference { + if (expression.type !== "Identifier") return false; + if ( + (expression.name === "vi" || expression.name === "jest") && + sourceCode.isGlobalReference(expression) + ) { + return true; + } + + const variable = resolveVariable(sourceCode, expression); + if (variable === null || variable.defs.length === 0) { + return expression.name === "vi" || expression.name === "jest"; + } + return variable.defs.some((definition) => { + if (definition.type !== "ImportBinding" || definition.parent?.type !== "ImportDeclaration") { + return false; + } + const source = definition.parent.source.value; + const name = importedName(definition.node); + return (source === "vitest" && name === "vi") || (source === "@jest/globals" && name === "jest"); + }); +} + +function moduleMockCall(sourceCode: SourceCode, callee: ESTree.Expression): boolean { + if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false; + if (!isTestFrameworkObject(sourceCode, callee.object)) return false; + const property = callee.property; + const method = callee.computed + ? property.type === "Literal" && + (property.value === "doMock" || + property.value === "mock" || + property.value === "unstable_mockModule") + ? property.value + : null + : property.type === "Identifier" + ? property.name + : null; + return method !== null && moduleMockMethods.has(method); +} + +/** Ban test framework module mocking in favor of real dependency seams. */ +export const noModuleMockingRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Vitest and Jest module mocking; tests must replace dependencies through real interfaces.", + }, + messages: { + moduleMock: + "Replace module mocking with dependency injection through a real interface, service layer, or faithful test implementation.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (moduleMockCall(context.sourceCode, node.callee)) { + context.report({ node, messageId: "moduleMock" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-object-parameters.ts b/tools/oxlint/anti-slop/rules/no-object-parameters.ts new file mode 100644 index 000000000000..29b990f3395a --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-object-parameters.ts @@ -0,0 +1,126 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.ts"; + +type Parameter = ESTree.ParamPattern; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === "TSParameterProperty") { + return parameterAnnotation(parameter.parameter); + } + if (parameter.type === "RestElement") { + return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument); + } + if (parameter.type === "AssignmentPattern") { + return parameter.typeAnnotation ?? parameter.left.typeAnnotation; + } + return parameter.typeAnnotation; +} + +function parameterName(parameter: Parameter, sourceCode: SourceCode): string { + return parameter.type === "Identifier" + ? parameter.name + : sourceCode.getText(parameter).replace(/\s*:\s*object\s*$/u, ""); +} + +/** Ban the broad object type on function inputs, including local aliases to object. */ +export const noObjectParametersRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow object function parameters; inputs must use an owner-provided type and be parsed at their boundary.", + }, + messages: { + objectParameter: + "Parameter `{{parameter}}` uses the broad `object` type. Accept a named owner type; parse external input at its boundary before calling this function.", + }, + }, + createOnce(context) { + const aliases = new Map(); + + const resolvesToObject = ( + type: ESTree.TSType, + shadowedAliases: ReadonlySet, + visited = new Set(), + ): boolean => { + if (type.type === "TSObjectKeyword") return true; + if (type.type === "TSParenthesizedType") + return resolvesToObject(type.typeAnnotation, shadowedAliases, visited); + if (type.type === "TSUnionType") { + return type.types.some((member) => + resolvesToObject(member, shadowedAliases, visited), + ); + } + if ( + type.type !== "TSTypeReference" || + type.typeName.type !== "Identifier" || + (type.typeArguments !== null && + type.typeArguments !== undefined && + type.typeArguments.params.length > 0) || + visited.has(type.typeName.name) || + shadowedAliases.has(type.typeName.name) + ) { + return false; + } + const alias = aliases.get(type.typeName.name); + if (alias === undefined) return false; + const nextVisited = new Set(visited); + nextVisited.add(type.typeName.name); + return resolvesToObject(alias, shadowedAliases, nextVisited); + }; + + const checkParameters = (node: ParameterOwner) => { + const shadowedAliases = lexicalTypeParameterNames( + node, + context.sourceCode.visitorKeys, + ); + for (const parameter of node.params) { + const annotation = parameterAnnotation(parameter); + if (annotation === null || annotation === undefined) continue; + if (!resolvesToObject(annotation.typeAnnotation, shadowedAliases)) continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "objectParameter", + data: { parameter: parameterName(parameter, context.sourceCode) }, + }); + } + }; + + return { + Program(node) { + aliases.clear(); + for (const statement of node.body) { + const declaration = + statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if ( + declaration?.type === "TSTypeAliasDeclaration" && + (declaration.typeParameters === null || declaration.typeParameters === undefined) + ) { + aliases.set(declaration.id.name, declaration.typeAnnotation); + } + } + }, + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-reflect-apply.ts b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts new file mode 100644 index 000000000000..2cc30451bda6 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts @@ -0,0 +1,28 @@ +import { defineRule } from "@oxlint/plugins"; + +import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts"; + +/** Ban Reflect.apply, which bypasses ordinary typed function calls. */ +export const noReflectApplyRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Reflect.apply; call typed functions directly or model dynamic dispatch behind an interface.", + }, + messages: { + reflectApply: + "Replace `Reflect.apply` with a typed function call. Model dynamic dispatch behind a named interface.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "apply")) { + context.report({ node, messageId: "reflectApply" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-reflect-get.ts b/tools/oxlint/anti-slop/rules/no-reflect-get.ts new file mode 100644 index 000000000000..cf630ecc005a --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-get.ts @@ -0,0 +1,28 @@ +import { defineRule } from "@oxlint/plugins"; + +import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts"; + +/** Ban Reflect.get, which bypasses ordinary property access and useful type evidence. */ +export const noReflectGetRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Reflect.get; use typed property access or parse dynamic input into a domain type.", + }, + messages: { + reflectGet: + "Replace `Reflect.get` with typed property access. Parse dynamic input into a named domain type before reading it.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "get")) { + context.report({ node, messageId: "reflectGet" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts new file mode 100644 index 000000000000..6a25c2475d9a --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts @@ -0,0 +1,67 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +type RuntimeFunction = ESTree.ArrowFunctionExpression | ESTree.Function; + +function isRuntimeFunction(node: ESTree.Node): node is RuntimeFunction { + return ( + node.type === "ArrowFunctionExpression" || + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" + ); +} + +function isInsideTypeGuard(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (isRuntimeFunction(current)) { + return current.returnType?.typeAnnotation.type === "TSTypePredicate"; + } + current = current.parent; + } + return false; +} + +/** Disallow runtime typeof checks that narrow unparsed values instead of decoding them. */ +export const noRuntimeTypeofRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow runtime typeof checks; external values must be decoded into meaningful types at their I/O boundary.", + }, + messages: { + runtimeTypeof: + "A `typeof` check narrows a representation without establishing its contract. Parse input at its I/O boundary, then branch on the domain value.", + }, + schema: [ + { + type: "object", + properties: { + allowInTypeGuards: { type: "boolean" }, + }, + additionalProperties: false, + }, + ], + defaultOptions: [{ allowInTypeGuards: false }], + }, + createOnce(context) { + return { + UnaryExpression(node) { + const option = context.options?.[0]; + const allowInTypeGuards = + typeof option === "object" && + option !== null && + !Array.isArray(option) && + option.allowInTypeGuards === true; + if ( + node.operator === "typeof" && + (!allowInTypeGuards || !isInsideTypeGuard(node)) + ) { + context.report({ node, messageId: "runtimeTypeof" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts new file mode 100644 index 000000000000..afc00dd41245 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts @@ -0,0 +1,39 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +const FORBIDDEN_SYMBOL_NAME = "shape"; + +function containsForbiddenSymbolName(name: string): boolean { + return name.toLowerCase().includes(FORBIDDEN_SYMBOL_NAME); +} + +/** Ban the case-insensitive substring "shape" in every JavaScript and TypeScript symbol name. */ +export const noForbiddenTermInSymbolNamesRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + 'Disallow the case-insensitive substring "shape" in JavaScript, TypeScript, private, and JSX symbol names.', + }, + messages: { + forbiddenSymbolName: + 'Rename symbol "{{name}}" for its domain role; "shape" describes structure rather than ownership.', + }, + }, + createOnce(context) { + const reportForbiddenSymbolName = (node: ESTree.Node & { name: string }) => { + if (!containsForbiddenSymbolName(node.name)) return; + context.report({ + node, + messageId: "forbiddenSymbolName", + data: { name: node.name }, + }); + }; + + return { + Identifier: reportForbiddenSymbolName, + PrivateIdentifier: reportForbiddenSymbolName, + JSXIdentifier: reportForbiddenSymbolName, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts new file mode 100644 index 000000000000..cdc6c23519ea --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts @@ -0,0 +1,83 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +type Parameter = ESTree.ParamPattern; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === "TSParameterProperty") { + return parameterAnnotation(parameter.parameter); + } + if (parameter.type === "RestElement") { + return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument); + } + if (parameter.type === "AssignmentPattern") { + return parameter.typeAnnotation ?? parameter.left.typeAnnotation; + } + return parameter.typeAnnotation; +} + +function parameterName(parameter: Parameter, sourceText: string): string { + if (parameter.type === "TSParameterProperty") { + return parameterName(parameter.parameter, sourceText); + } + if (parameter.type === "AssignmentPattern") { + return parameterName(parameter.left, sourceText); + } + if (parameter.type === "RestElement") { + return parameterName(parameter.argument, sourceText); + } + return parameter.type === "Identifier" + ? parameter.name + : sourceText.replace(/\s*:\s*unknown\s*$/u, ""); +} + +/** Disallow unknown inputs except explicitly named error-cause enrichment. */ +export const noUnknownParametersRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow explicitly unknown function parameters except `cause`; decode unknown input at its I/O boundary instead.", + }, + messages: { + unknownParameter: + "Parameter `{{parameter}}` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.", + }, + }, + createOnce(context) { + const checkParameters = (node: ParameterOwner) => { + for (const parameter of node.params) { + const annotation = parameterAnnotation(parameter); + if (annotation?.typeAnnotation.type !== "TSUnknownKeyword") continue; + const name = parameterName(parameter, context.sourceCode.getText(parameter)); + if (name === "cause") continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "unknownParameter", + data: { parameter: name }, + }); + } + }; + + return { + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-returns.ts b/tools/oxlint/anti-slop/rules/no-unknown-returns.ts new file mode 100644 index 000000000000..4b16d6ef3d78 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-returns.ts @@ -0,0 +1,115 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.ts"; + +type FunctionWithReturnType = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +function referencedAliasName(type: ESTree.TSType): string | null { + if (type.type === "TSParenthesizedType") return referencedAliasName(type.typeAnnotation); + if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null; + return type.typeArguments === null || + type.typeArguments === undefined || + type.typeArguments.params.length === 0 + ? type.typeName.name + : null; +} + +/** Ban function contracts that return unknown instead of a parsed domain type. */ +export const noUnknownReturnsRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow functions whose explicit return contract is unknown or Promise.", + }, + messages: { + unknownReturn: + "This function exposes `unknown` to its caller. Parse the value at its boundary and return a named domain type.", + }, + }, + createOnce(context) { + const aliases = new Map(); + + const resolvesToUnknown = ( + type: ESTree.TSType, + shadowedAliases: ReadonlySet, + visited = new Set(), + ): boolean => { + if (type.type === "TSUnknownKeyword") return true; + if (type.type === "TSParenthesizedType") { + return resolvesToUnknown(type.typeAnnotation, shadowedAliases, visited); + } + if (type.type === "TSUnionType") { + return type.types.some((member) => + resolvesToUnknown(member, shadowedAliases, visited), + ); + } + if ( + type.type === "TSTypeReference" && + type.typeName.type === "Identifier" && + (type.typeName.name === "Promise" || type.typeName.name === "PromiseLike") + ) { + const value = type.typeArguments?.params[0]; + return value !== undefined && resolvesToUnknown(value, shadowedAliases, visited); + } + const name = referencedAliasName(type); + if (name === null || visited.has(name) || shadowedAliases.has(name)) return false; + const alias = aliases.get(name); + if ( + alias === undefined || + (alias.typeParameters !== null && alias.typeParameters !== undefined) + ) { + return false; + } + const nextVisited = new Set(visited); + nextVisited.add(name); + return resolvesToUnknown(alias.typeAnnotation, shadowedAliases, nextVisited); + }; + + const checkReturnType = (node: FunctionWithReturnType) => { + const annotation = node.returnType; + if (annotation === null || annotation === undefined) return; + if ( + !resolvesToUnknown( + annotation.typeAnnotation, + lexicalTypeParameterNames(node, context.sourceCode.visitorKeys), + ) + ) { + return; + } + context.report({ node: annotation.typeAnnotation, messageId: "unknownReturn" }); + }; + + return { + Program(node) { + aliases.clear(); + for (const statement of node.body) { + const declaration = + statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if (declaration?.type === "TSTypeAliasDeclaration") { + aliases.set(declaration.id.name, declaration); + } + } + }, + ArrowFunctionExpression: checkReturnType, + FunctionDeclaration: checkReturnType, + FunctionExpression: checkReturnType, + TSCallSignatureDeclaration: checkReturnType, + TSConstructSignatureDeclaration: checkReturnType, + TSConstructorType: checkReturnType, + TSDeclareFunction: checkReturnType, + TSEmptyBodyFunctionExpression: checkReturnType, + TSFunctionType: checkReturnType, + TSMethodSignature: checkReturnType, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts new file mode 100644 index 000000000000..3e328fdfc914 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts @@ -0,0 +1,70 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +function referencedAliasName(type: ESTree.TSType): string | null { + if (type.type === "TSParenthesizedType") return referencedAliasName(type.typeAnnotation); + if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null; + return type.typeArguments === null || + type.typeArguments === undefined || + type.typeArguments.params.length === 0 + ? type.typeName.name + : null; +} + +/** Ban named aliases that merely conceal TypeScript's unknown top type. */ +export const noUnknownTypeAliasesRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow type aliases whose resolved type is unknown; unknown must remain visible at an allowed boundary.", + }, + messages: { + unknownAlias: + "Type alias `{{alias}}` hides `unknown`. Keep `unknown` explicit at the parsing boundary or on an allowed `cause` field; otherwise use the parsed owner type.", + }, + }, + createOnce(context) { + const aliases = new Map(); + + const resolvesToUnknown = (type: ESTree.TSType, visited = new Set()): boolean => { + if (type.type === "TSUnknownKeyword") return true; + if (type.type === "TSParenthesizedType") + return resolvesToUnknown(type.typeAnnotation, visited); + const name = referencedAliasName(type); + if (name === null || visited.has(name)) return false; + const alias = aliases.get(name); + if ( + alias === undefined || + (alias.typeParameters !== null && alias.typeParameters !== undefined) + ) { + return false; + } + const nextVisited = new Set(visited); + nextVisited.add(name); + return resolvesToUnknown(alias.typeAnnotation, nextVisited); + }; + + return { + Program(node) { + aliases.clear(); + for (const statement of node.body) { + const declaration = + statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if (declaration?.type === "TSTypeAliasDeclaration") { + aliases.set(declaration.id.name, declaration); + } + } + for (const alias of aliases.values()) { + if (!resolvesToUnknown(alias.typeAnnotation, new Set([alias.id.name]))) continue; + context.report({ + node: alias.id, + messageId: "unknownAlias", + data: { alias: alias.id.name }, + }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts new file mode 100644 index 000000000000..8c45eed27628 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts @@ -0,0 +1,134 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyUnsafeDictionary, + classifyUnsafeDictionaryValue, + createTypeEnvironment, + type TypeEnvironment, +} from "../shared/dictionary-types.ts"; + +import type { ESTree } from "@oxlint/plugins"; + +const typeNodeKinds: ReadonlySet = new Set([ + "JSDocNonNullableType", + "JSDocNullableType", + "JSDocUnknownType", + "TSAnyKeyword", + "TSArrayType", + "TSBigIntKeyword", + "TSBooleanKeyword", + "TSConditionalType", + "TSConstructorType", + "TSFunctionType", + "TSImportType", + "TSIndexedAccessType", + "TSInferType", + "TSIntersectionType", + "TSIntrinsicKeyword", + "TSLiteralType", + "TSMappedType", + "TSNamedTupleMember", + "TSNeverKeyword", + "TSNullKeyword", + "TSNumberKeyword", + "TSObjectKeyword", + "TSParenthesizedType", + "TSStringKeyword", + "TSSymbolKeyword", + "TSTemplateLiteralType", + "TSThisType", + "TSTupleType", + "TSTypeLiteral", + "TSTypeOperator", + "TSTypePredicate", + "TSTypeQuery", + "TSTypeReference", + "TSUndefinedKeyword", + "TSUnionType", + "TSUnknownKeyword", + "TSVoidKeyword", +]); + +function isTypeNode(node: ESTree.Node): node is ESTree.TSType { + return typeNodeKinds.has(node.type); +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isInsideTypeAliasDeclaration(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (current.type === "TSTypeAliasDeclaration") return true; + current = current.parent; + } + return false; +} + +function isPlainAliasConsumerUse(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (node.type !== "TSTypeReference" || node.typeArguments?.params.length) return false; + const name = typeReferenceName(node); + return name !== null && environment.aliases.has(name) && !isInsideTypeAliasDeclaration(node); +} + +function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (isPlainAliasConsumerUse(node, environment)) return false; + if (classifyUnsafeDictionary(node, environment) === null) return false; + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (isTypeNode(current) && classifyUnsafeDictionary(current, environment) !== null) + return false; + current = current.parent; + } + return true; +} + +/** Disallow object-dictionary contracts whose direct value type is an unsafe escape hatch. */ +export const noUnsafeDictionaryTypeRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow object-dictionary contracts whose direct value type is unknown, any, object, {}, or a union/alias containing one of those escape hatches.", + }, + messages: { + unsafeDictionary: + "This dictionary's {{value}} value type gives callers no concrete value contract. Use an owner/schema-derived value type; parse external payloads before insertion.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + const report = (node: ESTree.Node, value: string) => { + context.report({ node, messageId: "unsafeDictionary", data: { value } }); + }; + const reportIfUnsafe = (node: ESTree.TSType) => { + if (environment === null || !shouldReportType(node, environment)) return; + const unsafe = classifyUnsafeDictionary(node, environment); + if (unsafe === null) return; + report(node, unsafe.unsafeValue); + }; + + return { + Program(node) { + environment = createTypeEnvironment(node); + }, + TSTypeReference: reportIfUnsafe, + TSTypeLiteral: reportIfUnsafe, + TSMappedType: reportIfUnsafe, + TSIndexSignature(node) { + if ( + environment === null || + node.typeAnnotation === null || + node.parent.type === "TSTypeLiteral" + ) + return; + const unsafe = classifyUnsafeDictionaryValue( + node.typeAnnotation.typeAnnotation, + environment, + ); + if (unsafe !== null) report(node, unsafe.unsafeValue); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts new file mode 100644 index 000000000000..c5e07f7fc55a --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts @@ -0,0 +1,366 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree, Variable } from "@oxlint/plugins"; + +type BroadTypeKind = "top" | "object" | "record"; + +type KnownValueEvidence = { + readonly type: ESTree.TSType | null; +}; + +const functionBoundaryTypes = new Set([ + "ArrowFunctionExpression", + "FunctionDeclaration", + "FunctionExpression", + "TSDeclareFunction", + "TSEmptyBodyFunctionExpression", +]); + +function unwrapExpressionParentheses(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") current = current.expression; + return current; +} + +function unwrapTypeParentheses(type: ESTree.TSType): ESTree.TSType { + let current = type; + while (current.type === "TSParenthesizedType") current = current.typeAnnotation; + return current; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isUnknownOrAnyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + return unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword"; +} + +function isBroadRecordKeyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if ( + unwrapped.type === "TSStringKeyword" || + unwrapped.type === "TSNumberKeyword" || + unwrapped.type === "TSSymbolKeyword" + ) { + return true; + } + if (unwrapped.type === "TSUnionType") return unwrapped.types.every(isBroadRecordKeyType); + return unwrapped.type === "TSTypeReference" && typeReferenceName(unwrapped) === "PropertyKey"; +} + +function isBroadRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + + if (unwrapped.type === "TSTypeReference") { + if (typeReferenceName(unwrapped) === "Readonly") { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isBroadRecordType(inner); + } + + if (typeReferenceName(unwrapped) !== "Record") return false; + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && + parameters[0] !== undefined && + parameters[1] !== undefined && + isBroadRecordKeyType(parameters[0]) && + isUnknownOrAnyType(parameters[1]) + ); + } + + if (unwrapped.type !== "TSTypeLiteral" || unwrapped.members.length !== 1) return false; + const [member] = unwrapped.members; + const [parameter] = member?.type === "TSIndexSignature" ? member.parameters : []; + return ( + member?.type === "TSIndexSignature" && + member.parameters.length === 1 && + parameter !== undefined && + isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) && + isUnknownOrAnyType(member.typeAnnotation.typeAnnotation) + ); +} + +function broadTypeKind(type: ESTree.TSType): BroadTypeKind | null { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword") return "top"; + if (unwrapped.type === "TSObjectKeyword") return "object"; + return isBroadRecordType(unwrapped) ? "record" : null; +} + +function assertedExpression( + node: ESTree.TSAsExpression | ESTree.TSTypeAssertion, +): ESTree.Expression { + return unwrapExpressionParentheses(node.expression); +} + +function assertionFromExpression( + expression: ESTree.Expression, +): ESTree.TSAsExpression | ESTree.TSTypeAssertion | null { + const unwrapped = unwrapExpressionParentheses(expression); + return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion" + ? unwrapped + : null; +} + +function normalizedTypeText(sourceText: string, type: ESTree.TSType): string { + return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, ""); +} + +function typesHaveSameSyntax( + sourceText: string, + left: ESTree.TSType | null, + right: ESTree.TSType, +): boolean { + return ( + left !== null && + normalizedTypeText(sourceText, unwrapTypeParentheses(left)) === + normalizedTypeText(sourceText, unwrapTypeParentheses(right)) + ); +} + +function isDefinitelyObjectType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + switch (unwrapped.type) { + case "TSArrayType": + case "TSConstructorType": + case "TSFunctionType": + case "TSMappedType": + case "TSObjectKeyword": + case "TSTupleType": + return true; + case "TSTypeLiteral": + return unwrapped.members.length > 0; + case "TSIntersectionType": + return unwrapped.types.every(isDefinitelyObjectType); + case "TSTypeOperator": + return unwrapped.operator === "readonly" && isDefinitelyObjectType(unwrapped.typeAnnotation); + default: + return false; + } +} + +function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type !== "TSIndexSignature"); + } + + if (unwrapped.type !== "TSTypeReference") return false; + if (typeReferenceName(unwrapped) === "Readonly") { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isDefinitelyNarrowerRecordType(inner); + } + if (typeReferenceName(unwrapped) !== "Record") return false; + + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && parameters[1] !== undefined && !isUnknownOrAnyType(parameters[1]) + ); +} + +function functionBoundary(node: ESTree.Node): ESTree.Node | null { + let current = node.parent; + while (current !== null && current.type !== "Program") { + if (functionBoundaryTypes.has(current.type)) return current; + current = current.parent; + } + return null; +} + +function resolvedVariableForIdentifier( + scopes: readonly { + readonly references: readonly { + readonly identifier: ESTree.Node; + readonly resolved: Variable | null; + }[]; + }[], + identifier: ESTree.IdentifierReference, +): Variable | null { + for (const scope of scopes) { + const reference = scope.references.find( + (candidate) => + candidate.identifier.start === identifier.start && + candidate.identifier.end === identifier.end, + ); + if (reference !== undefined) return reference.resolved; + } + return null; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + for (const definition of variable.defs) { + if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") { + return definition.node; + } + } + return null; +} + +function knownValueEvidence( + expression: ESTree.Expression, + scopes: Parameters[0], + boundary: ESTree.Node | null, + visitedVariables: ReadonlySet, +): KnownValueEvidence | null { + const unwrapped = unwrapExpressionParentheses(expression); + + if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") { + if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null; + return { type: unwrapped.typeAnnotation }; + } + + if (unwrapped.type === "Literal" || unwrapped.type === "TemplateLiteral") { + return { type: null }; + } + + if ( + unwrapped.type === "ArrayExpression" || + unwrapped.type === "ArrowFunctionExpression" || + unwrapped.type === "ClassExpression" || + unwrapped.type === "FunctionExpression" || + unwrapped.type === "NewExpression" || + unwrapped.type === "ObjectExpression" + ) { + return { type: null }; + } + + if (unwrapped.type !== "Identifier") return null; + const variable = resolvedVariableForIdentifier(scopes, unwrapped); + if (variable === null || visitedVariables.has(variable)) return null; + + const annotatedIdentifier = variable.identifiers.find( + (identifier) => identifier.typeAnnotation !== null && identifier.typeAnnotation !== undefined, + ); + const annotation = annotatedIdentifier?.typeAnnotation?.typeAnnotation; + if (annotation !== undefined && annotatedIdentifier !== undefined) { + if (functionBoundary(annotatedIdentifier) !== boundary || broadTypeKind(annotation) !== null) { + return null; + } + return { type: annotation }; + } + + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== "VariableDeclaration" || + declarator.parent.kind !== "const" || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) || + functionBoundary(declarator) !== boundary + ) { + return null; + } + + return knownValueEvidence( + declarator.init, + scopes, + boundary, + new Set([...visitedVariables, variable]), + ); +} + +function widenedBinding( + variable: Variable, + scopes: Parameters[0], +): { + readonly broadKind: BroadTypeKind; + readonly evidence: KnownValueEvidence; + readonly declaredAt: number; + readonly boundary: ESTree.Node | null; +} | null { + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== "VariableDeclaration" || + declarator.parent.kind !== "const" || + declarator.id.type !== "Identifier" || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) + ) { + return null; + } + + const boundary = functionBoundary(declarator); + const declaredType = declarator.id.typeAnnotation?.typeAnnotation; + const initializerAssertion = assertionFromExpression(declarator.init); + const initializerBroadKind = + initializerAssertion === null ? null : broadTypeKind(initializerAssertion.typeAnnotation); + const declaredBroadKind = declaredType === undefined ? null : broadTypeKind(declaredType); + const broadKind = declaredBroadKind ?? initializerBroadKind; + if (broadKind === null) return null; + + const originalExpression = + initializerAssertion !== null && initializerBroadKind !== null + ? assertedExpression(initializerAssertion) + : declarator.init; + const evidence = knownValueEvidence(originalExpression, scopes, boundary, new Set([variable])); + return evidence === null ? null : { broadKind, evidence, declaredAt: declarator.end, boundary }; +} + +function assertionIsNarrower( + sourceText: string, + broadKind: BroadTypeKind, + evidence: KnownValueEvidence, + assertedType: ESTree.TSType, +): boolean { + if (broadTypeKind(assertedType) !== null) return false; + if (broadKind === "top") return true; + if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true; + if (broadKind === "object") return isDefinitelyObjectType(assertedType); + return isDefinitelyNarrowerRecordType(assertedType); +} + +/** Detect immutable local bindings that erase a known type and are later asserted back to a narrower type. */ +export const noWidenThenAssertRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.", + }, + messages: { + widenThenAssert: + 'Binding "{{name}}" discards type evidence and later recreates it with an assertion. Keep the precise type from initialization through use; parse boundary input once.', + }, + }, + createOnce(context) { + let scopes: Parameters[0] = []; + + const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => { + const expression = assertedExpression(node); + if (expression.type !== "Identifier") return; + + const variable = resolvedVariableForIdentifier(scopes, expression); + if (variable === null) return; + const widened = widenedBinding(variable, scopes); + if ( + widened === null || + node.start <= widened.declaredAt || + functionBoundary(node) !== widened.boundary || + !assertionIsNarrower( + context.sourceCode.text, + widened.broadKind, + widened.evidence, + node.typeAnnotation, + ) + ) { + return; + } + + context.report({ + node, + messageId: "widenThenAssert", + data: { name: expression.name }, + }); + }; + + return { + Program() { + scopes = context.sourceCode.scopeManager.scopes; + }, + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts new file mode 100644 index 000000000000..f1a2ffcf9450 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts @@ -0,0 +1,62 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +type TypeAssertion = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +const commentOwnerKinds = new Set([ + "ExpressionStatement", + "PropertyDefinition", + "ReturnStatement", + "ThrowStatement", + "VariableDeclaration", +]); + +function isConstAssertion(node: TypeAssertion): boolean { + return ( + node.typeAnnotation.type === "TSTypeReference" && + node.typeAnnotation.typeName.type === "Identifier" && + node.typeAnnotation.typeName.name === "const" + ); +} + +function hasSafetyComment(sourceCode: SourceCode, node: TypeAssertion): boolean { + let current: ESTree.Node = node; + while (true) { + if ( + sourceCode + .getCommentsBefore(current) + .some((comment) => comment.end <= node.start && /\bSAFETY\s*:/u.test(comment.value)) + ) { + return true; + } + if (commentOwnerKinds.has(current.type) || current.parent.type === "Program") return false; + current = current.parent; + } +} + +/** Require every non-const type assertion to state the invariant TypeScript cannot express. */ +export const requireSafetyCommentForTypeAssertionRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Require a nearby SAFETY comment for every TypeScript type assertion except const assertions.", + }, + messages: { + missingSafetyComment: + "This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.", + }, + }, + createOnce(context) { + const checkAssertion = (node: TypeAssertion) => { + if (isConstAssertion(node) || hasSafetyComment(context.sourceCode, node)) return; + context.report({ node, messageId: "missingSafetyComment" }); + }; + + return { + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/shared/dictionary-types.ts b/tools/oxlint/anti-slop/shared/dictionary-types.ts new file mode 100644 index 000000000000..865170047f33 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/dictionary-types.ts @@ -0,0 +1,502 @@ +import type { ESTree } from "@oxlint/plugins"; + +const BUILT_INS = new Set([ + "Record", + "Readonly", + "Partial", + "Required", + "Pick", + "Omit", + "PropertyKey", + "NonNullable", +]); +const TRANSPARENT_WRAPPERS = new Set(["Readonly", "Partial", "Required", "NonNullable"]); + +type TypeAliasEnvironment = ReadonlyMap; + +type ResolvedType = { + readonly type: ESTree.TSType; + readonly substitutions: TypeAliasEnvironment; +}; + +export type UnsafeDictionary = { + readonly kind: "unsafe-dictionary"; + readonly unsafeValue: "any" | "empty-object" | "object" | "union" | "unknown"; +}; + +export type WideningTargetKind = + | "anonymous object" + | "generic container" + | "object" + | "open dictionary" + | "unknown"; + +export type WideningTarget = { + readonly kind: WideningTargetKind; +}; + +export type TypeEnvironment = { + readonly aliases: ReadonlyMap; + readonly interfaces: ReadonlyMap; + readonly shadowedBuiltIns: ReadonlySet; +}; + +function declaredStatement(statement: ESTree.Statement): ESTree.Node | null { + return statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? (statement.declaration ?? null) + : statement; +} + +export function createTypeEnvironment(program: ESTree.Program): TypeEnvironment { + const aliases = new Map(); + const interfaces = new Map(); + const shadowedBuiltIns = new Set(); + + for (const statement of program.body) { + const declaration = declaredStatement(statement); + if (declaration?.type === "ImportDeclaration") { + for (const specifier of declaration.specifiers) { + if (BUILT_INS.has(specifier.local.name)) shadowedBuiltIns.add(specifier.local.name); + } + continue; + } + + if (declaration?.type === "TSTypeAliasDeclaration") { + const existing = aliases.get(declaration.id.name); + if (existing === undefined) aliases.set(declaration.id.name, declaration); + else shadowedBuiltIns.add(declaration.id.name); + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if (declaration?.type === "TSInterfaceDeclaration") { + const declarations = interfaces.get(declaration.id.name) ?? []; + declarations.push(declaration); + interfaces.set(declaration.id.name, declarations); + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if (declaration?.type === "TSEnumDeclaration") { + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if ( + (declaration?.type === "ClassDeclaration" || + declaration?.type === "FunctionDeclaration") && + declaration.id !== null + ) { + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + } + } + + return { aliases, interfaces, shadowedBuiltIns }; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isBuiltIn(name: string, environment: TypeEnvironment): boolean { + return BUILT_INS.has(name) && !environment.shadowedBuiltIns.has(name); +} + +function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean { + const unwrapped = unwrapTransparentType(type); + return ( + unwrapped.type === "TSTypeReference" && + typeReferenceName(unwrapped) === name && + (unwrapped.typeArguments === null || + unwrapped.typeArguments === undefined || + unwrapped.typeArguments.params.length === 0) + ); +} + +function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType { + let current = type; + while ( + current.type === "TSParenthesizedType" || + (current.type === "TSTypeOperator" && current.operator === "readonly") + ) { + current = current.typeAnnotation; + } + return current; +} + +function isNeverType(type: ESTree.TSType): boolean { + return unwrapTransparentType(type).type === "TSNeverKeyword"; +} + +function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean { + return ( + member.type === "TSPropertySignature" && + member.optional === true && + member.typeAnnotation !== null && + member.typeAnnotation !== undefined && + isNeverType(member.typeAnnotation.typeAnnotation) + ); +} + +function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean { + return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember); +} + +function isEffectivelyEmptyInterface( + declarations: readonly ESTree.TSInterfaceDeclaration[], +): boolean { + if (declarations.length !== 1) return false; + const [type] = declarations; + return ( + type !== undefined && + type.extends.length === 0 && + (type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember)) + ); +} + +function resolvedSubstitutionArgument( + type: ESTree.TSType, + base: TypeAliasEnvironment, + resolving: ReadonlySet = new Set(), +): ESTree.TSType { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type !== "TSTypeReference") return type; + const name = typeReferenceName(unwrapped); + if (name === null || resolving.has(name)) return type; + const substitution = base.get(name); + if (substitution === undefined) return type; + const nextResolving = new Set(resolving); + nextResolving.add(name); + return resolvedSubstitutionArgument(substitution, base, nextResolving); +} + +function aliasSubstitution( + alias: ESTree.TSTypeAliasDeclaration, + type: ESTree.TSTypeReference, + base: TypeAliasEnvironment, +): TypeAliasEnvironment | null { + const parameters = alias.typeParameters?.params ?? []; + const arguments_ = type.typeArguments?.params ?? []; + const next = new Map(base); + for (const [index, parameter] of parameters.entries()) { + const argument = arguments_[index] ?? parameter.default; + if (argument === null || argument === undefined) return null; + next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next)); + } + return next; +} + +function unsafeDirectValue( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): UnsafeDictionary["unsafeValue"] | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return "unknown"; + if (unwrapped.type === "TSAnyKeyword") return "any"; + if (unwrapped.type === "TSObjectKeyword") return "object"; + if (unwrapped.type === "TSTypeLiteral" && isEffectivelyEmptyTypeLiteral(unwrapped)) + return "empty-object"; + if (unwrapped.type === "TSUnionType") { + return unwrapped.types.some( + (member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null, + ) + ? "union" + : null; + } + if (unwrapped.type === "TSIntersectionType") { + const unsafeMembers = unwrapped.types.map((member) => + unsafeDirectValue(member, environment, substitutions, resolvingAliases), + ); + if (unsafeMembers.includes("any")) return "any"; + return unsafeMembers.length > 0 && unsafeMembers.every((member) => member !== null) + ? unsafeMembers[0] + : null; + } + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases); + } + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : unsafeDirectValue(substitution, environment, substitutions, resolvingAliases); + } + const interfaceDeclarations = environment.interfaces.get(name); + if (interfaceDeclarations !== undefined) { + return isEffectivelyEmptyInterface(interfaceDeclarations) ? "empty-object" : null; + } + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +function dictionaryValueTypes( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): readonly ResolvedType[] { + const unwrapped = unwrapTransparentType(type); + + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.flatMap((member): readonly ResolvedType[] => + member.type === "TSIndexSignature" && member.typeAnnotation !== null + ? [{ type: member.typeAnnotation.typeAnnotation, substitutions }] + : [], + ); + } + + if (unwrapped.type === "TSMappedType") { + return unwrapped.typeAnnotation === null + ? [] + : [{ type: unwrapped.typeAnnotation, substitutions }]; + } + + if (unwrapped.type !== "TSTypeReference") return []; + const name = typeReferenceName(unwrapped); + if (name === null) return []; + + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? [] + : dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases); + } + + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? [] + : dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases); + } + + if (name === "Record" && isBuiltIn(name, environment)) { + const value = unwrapped.typeArguments?.params[1] ?? null; + return value === null ? [] : [{ type: value, substitutions }]; + } + + if ((name === "Pick" || name === "Omit") && isBuiltIn(name, environment)) { + const source = unwrapped.typeArguments?.params[0]; + return source === undefined + ? [] + : dictionaryValueTypes(source, environment, substitutions, resolvingAliases); + } + + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return []; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return []; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +export function classifyUnsafeDictionaryValue( + valueType: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set()); + return unsafeValue === null ? null : { kind: "unsafe-dictionary", unsafeValue }; +} + +export function classifyUnsafeDictionary( + type: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) { + const unsafeValue = unsafeDirectValue( + valueType.type, + environment, + valueType.substitutions, + new Set(), + ); + if (unsafeValue !== null) return { kind: "unsafe-dictionary", unsafeValue }; + } + return null; +} + +function resolvesToDictionary( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): boolean { + return dictionaryValueTypes(type, environment, substitutions, resolvingAliases).length > 0; +} + +export function classifyWideningTarget( + type: ESTree.TSType, + environment: TypeEnvironment, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; + if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type === "TSIndexSignature") + ? { kind: "open dictionary" } + : unwrapped.members.length > 0 + ? { kind: "anonymous object" } + : null; + } + if (unwrapped.type === "TSMappedType") return { kind: "open dictionary" }; + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment); + } + if (name === "Record" && isBuiltIn(name, environment)) return { kind: "open dictionary" }; + const alias = environment.aliases.get(name); + if (alias === undefined) return null; + if ((alias.typeParameters?.params.length ?? 0) > 0) { + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + return substitutions !== null && + resolvesToDictionary(alias.typeAnnotation, environment, substitutions, new Set([name])) + ? { kind: "generic container" } + : null; + } + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + if (substitutions === null) return null; + const resolved = classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + substitutions, + new Set([name]), + ); + return resolved; +} + +function isBroadMappedKey( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, +): boolean { + const unwrapped = unwrapTransparentType(type); + if ( + unwrapped.type === "TSStringKeyword" || + unwrapped.type === "TSNumberKeyword" || + unwrapped.type === "TSSymbolKeyword" + ) { + return true; + } + if (unwrapped.type === "TSUnionType") { + return unwrapped.types.every((member) => + isBroadMappedKey(member, environment, substitutions), + ); + } + if (unwrapped.type !== "TSTypeReference") return false; + const name = typeReferenceName(unwrapped); + if (name === null) return false; + const substitution = substitutions.get(name); + if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) { + return isBroadMappedKey(substitution, environment, substitutions); + } + return name === "PropertyKey" && isBuiltIn(name, environment); +} + +function classifyAliasBroadTarget( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; + if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type === "TSIndexSignature") + ? { kind: "open dictionary" } + : null; + } + if (unwrapped.type === "TSMappedType") { + return isBroadMappedKey(unwrapped.constraint, environment, substitutions) + ? { kind: "open dictionary" } + : null; + } + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : classifyAliasBroadTarget( + substitution, + environment, + substitutions, + resolvingAliases, + ); + } + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases); + } + if (name === "Record" && isBuiltIn(name, environment)) { + return { kind: "open dictionary" }; + } + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + nextSubstitutions, + nextResolving, + ); +} + +export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current.type === "ObjectExpression" && current.properties.length > 0; +} + +export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" || + current.type === "TSSatisfiesExpression" + ) { + current = current.expression; + } + if (current.type === "ObjectExpression") return true; + return ( + current.type === "ArrayExpression" || + current.type === "ArrowFunctionExpression" || + current.type === "ClassExpression" || + current.type === "FunctionExpression" || + current.type === "NewExpression" || + current.type === "Literal" || + current.type === "TemplateLiteral" || + current.type === "UnaryExpression" + ); +} diff --git a/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts new file mode 100644 index 000000000000..7cdb18c911c9 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts @@ -0,0 +1,61 @@ +import type { ESTree } from "@oxlint/plugins"; + +type VisitorKeys = Readonly>; + +function isNode(value: unknown): value is ESTree.Node { + return ( + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string" + ); +} + +function collectInferTypeParameterNames( + node: ESTree.Node, + visitorKeys: VisitorKeys, + names: Set, +): void { + if (node.type === "TSInferType") names.add(node.typeParameter.name.name); + const record = node as unknown as Readonly>; + for (const key of visitorKeys[node.type] ?? []) { + const value = record[key]; + if (isNode(value)) { + collectInferTypeParameterNames(value, visitorKeys, names); + continue; + } + if (!Array.isArray(value)) continue; + for (const child of value) { + if (isNode(child)) collectInferTypeParameterNames(child, visitorKeys, names); + } + } +} + +/** Collect type binders that are in scope at a node and can shadow module aliases. */ +export function lexicalTypeParameterNames( + node: ESTree.Node, + visitorKeys: VisitorKeys, +): ReadonlySet { + const names = new Set(); + let descendant: ESTree.Node = node; + let current: ESTree.Node | null = node; + while (current !== null && current.type !== "Program") { + if ("typeParameters" in current) { + for (const parameter of current.typeParameters?.params ?? []) { + names.add(parameter.name.name); + } + } + if ( + current.type === "TSMappedType" && + (descendant === current.nameType || descendant === current.typeAnnotation) + ) { + names.add(current.key.name); + } + if (current.type === "TSConditionalType" && descendant === current.trueType) { + collectInferTypeParameterNames(current.extendsType, visitorKeys, names); + } + descendant = current; + current = current.parent; + } + return names; +} diff --git a/tools/oxlint/anti-slop/shared/reflect-method.ts b/tools/oxlint/anti-slop/shared/reflect-method.ts new file mode 100644 index 000000000000..39bc218c3bbf --- /dev/null +++ b/tools/oxlint/anti-slop/shared/reflect-method.ts @@ -0,0 +1,35 @@ +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function isGlobalReflect(sourceCode: SourceCode, expression: ESTree.Expression): boolean { + if (expression.type !== "Identifier" || expression.name !== "Reflect") return false; + if (sourceCode.isGlobalReference(expression)) return true; + const variable = resolveVariable(sourceCode, expression); + return variable === null || variable.defs.length === 0; +} + +/** Reports whether a call target names one method on the global Reflect object. */ +export function isGlobalReflectMethodCall( + sourceCode: SourceCode, + callee: ESTree.Expression, + methodName: string, +): boolean { + if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false; + if (!isGlobalReflect(sourceCode, callee.object)) return false; + const property = callee.property; + return callee.computed + ? property.type === "Literal" && property.value === methodName + : property.type === "Identifier" && property.name === methodName; +} diff --git a/vite.config.ts b/vite.config.ts index 258555e04b5a..848a5602aab9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -31,6 +31,19 @@ export default defineConfig({ }, fmt: { ignorePatterns: [ + ".agent/**", + ".agents/**", + ".claude/**", + ".codex/**", + ".continue/**", + ".cursor/**", + ".gemini/**", + ".opencode/**", + ".pi/**", + ".roo/**", + ".windsurf/**", + "tools/oxlint/anti-slop/**", + ".repos/**", ".alchemy", "dist", @@ -56,6 +69,19 @@ export default defineConfig({ }, lint: { ignorePatterns: [ + ".agent/**", + ".agents/**", + ".claude/**", + ".codex/**", + ".continue/**", + ".cursor/**", + ".gemini/**", + ".opencode/**", + ".pi/**", + ".roo/**", + ".windsurf/**", + "tools/oxlint/anti-slop/**", + ".repos", ".repos/**", "dist", @@ -69,13 +95,33 @@ export default defineConfig({ "apps/mobile/uniwind-types.d.ts", ], plugins: ["eslint", "oxc", "react", "unicorn", "typescript"], - jsPlugins: ["./oxlint-plugin-t3code/index.ts"], + jsPlugins: [ + "./oxlint-plugin-t3code/index.ts", + { name: "anti-slop", specifier: "./tools/oxlint/anti-slop/index.ts" }, + { name: "anti-slop-effect", specifier: "./tools/oxlint/anti-slop/effect/index.ts" }, + ], categories: { correctness: "warn", suspicious: "warn", perf: "warn", }, rules: { + "anti-slop/no-chained-type-assertions": "error", + "anti-slop/no-conditional-empty-object-spread": "error", + "anti-slop/no-known-value-widening": "error", + "anti-slop/no-module-mocking": "error", + "anti-slop/no-object-parameters": "error", + "anti-slop/no-reflect-apply": "error", + "anti-slop/no-reflect-get": "error", + "anti-slop/no-runtime-typeof": "error", + "anti-slop/no-unsafe-dictionary-type": "error", + "anti-slop/no-shape-in-symbol-names": "error", + "anti-slop/no-unknown-parameters": "error", + "anti-slop/no-unknown-returns": "error", + "anti-slop/no-unknown-type-aliases": "error", + "anti-slop/no-widen-then-assert": "error", + "anti-slop/require-safety-comment-for-type-assertion": "error", + "anti-slop-effect/no-service-constructor-imports": "error", "unicorn/no-array-sort": "off", "unicorn/consistent-function-scoping": "off", "oxc/no-map-spread": "off", From 30de69d9543fe1a84d74cba75f8f01c5a2626f74 Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Sat, 12 Sep 2026 15:18:23 -0400 Subject: [PATCH 06/15] chore: remove unrelated anti-slop tooling from Devin PR --- package.json | 2 - pnpm-lock.yaml | 232 +------- tools/oxlint/anti-slop/effect/index.ts | 13 - .../rules/no-service-constructor-imports.ts | 52 -- tools/oxlint/anti-slop/index.ts | 41 -- .../rules/no-chained-type-assertions.ts | 77 --- .../no-conditional-empty-object-spread.ts | 49 -- .../rules/no-known-value-widening.ts | 247 --------- .../anti-slop/rules/no-module-mocking.ts | 91 ---- .../anti-slop/rules/no-object-parameters.ts | 126 ----- .../anti-slop/rules/no-reflect-apply.ts | 28 - .../oxlint/anti-slop/rules/no-reflect-get.ts | 28 - .../anti-slop/rules/no-runtime-typeof.ts | 67 --- .../rules/no-shape-in-symbol-names.ts | 39 -- .../anti-slop/rules/no-unknown-parameters.ts | 83 --- .../anti-slop/rules/no-unknown-returns.ts | 115 ---- .../rules/no-unknown-type-aliases.ts | 70 --- .../rules/no-unsafe-dictionary-type.ts | 134 ----- .../anti-slop/rules/no-widen-then-assert.ts | 366 ------------- ...quire-safety-comment-for-type-assertion.ts | 62 --- .../anti-slop/shared/dictionary-types.ts | 502 ------------------ .../shared/lexical-type-parameters.ts | 61 --- .../oxlint/anti-slop/shared/reflect-method.ts | 35 -- vite.config.ts | 48 +- 24 files changed, 3 insertions(+), 2565 deletions(-) delete mode 100644 tools/oxlint/anti-slop/effect/index.ts delete mode 100644 tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts delete mode 100644 tools/oxlint/anti-slop/index.ts delete mode 100644 tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts delete mode 100644 tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts delete mode 100644 tools/oxlint/anti-slop/rules/no-known-value-widening.ts delete mode 100644 tools/oxlint/anti-slop/rules/no-module-mocking.ts delete mode 100644 tools/oxlint/anti-slop/rules/no-object-parameters.ts delete mode 100644 tools/oxlint/anti-slop/rules/no-reflect-apply.ts delete mode 100644 tools/oxlint/anti-slop/rules/no-reflect-get.ts delete mode 100644 tools/oxlint/anti-slop/rules/no-runtime-typeof.ts delete mode 100644 tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts delete mode 100644 tools/oxlint/anti-slop/rules/no-unknown-parameters.ts delete mode 100644 tools/oxlint/anti-slop/rules/no-unknown-returns.ts delete mode 100644 tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts delete mode 100644 tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts delete mode 100644 tools/oxlint/anti-slop/rules/no-widen-then-assert.ts delete mode 100644 tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts delete mode 100644 tools/oxlint/anti-slop/shared/dictionary-types.ts delete mode 100644 tools/oxlint/anti-slop/shared/lexical-type-parameters.ts delete mode 100644 tools/oxlint/anti-slop/shared/reflect-method.ts diff --git a/package.json b/package.json index 499301e90e49..dd1a106a0409 100644 --- a/package.json +++ b/package.json @@ -50,10 +50,8 @@ }, "devDependencies": { "@effect/tsgo": "catalog:", - "@oxlint/plugins": "1.82.0", "@types/node": "catalog:", "knip": "6.34.0", - "oxlint": "1.82.0", "typescript": "catalog:", "vite-plus": "catalog:" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1ed998070c4..9f175605d8bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,18 +114,12 @@ importers: '@effect/tsgo': specifier: 'catalog:' version: 0.41.0 - '@oxlint/plugins': - specifier: 1.82.0 - version: 1.82.0 '@types/node': specifier: 24.12.4 version: 24.12.4 knip: specifier: 6.34.0 version: 6.34.0 - oxlint: - specifier: 1.82.0 - version: 1.82.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) typescript: specifier: 'catalog:' version: 7.0.2 @@ -787,7 +781,7 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.76 - version: 2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f) + version: 2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc version: 1.0.0-rc.5-ab785fc(8ab70e2706da13c78d64d8a92fef1884) @@ -4093,84 +4087,42 @@ packages: cpu: [arm] os: [android] - '@oxlint/binding-android-arm-eabi@1.82.0': - resolution: {integrity: sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - '@oxlint/binding-android-arm64@1.79.0': resolution: {integrity: sha512-KqqnOtAVgNsPPF0YSodkFZA1O80jcKoCZCTu3bgsszxA+MrMP9TLzfXitKjEj1FmrPprKDMdRDMmY3weESO9sg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-android-arm64@1.82.0': - resolution: {integrity: sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - '@oxlint/binding-darwin-arm64@1.79.0': resolution: {integrity: sha512-BVC2nsMzqQzRDPc5RhixkZ+m1p7iH4bxRRvqkbwDXX0PlQKm1BPy8J8cRjnAFafOq2QzI+BfO3vE8w2GZ3CBag==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-arm64@1.82.0': - resolution: {integrity: sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - '@oxlint/binding-darwin-x64@1.79.0': resolution: {integrity: sha512-p6Lm+snmhGuLKL1+CpCV8L6ijkE/qJzK2H2jG9+eKJT0n31RbY4FLsdhexekgP3bLpw4Kgde+9DZuDZQ4yIInA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-darwin-x64@1.82.0': - resolution: {integrity: sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - '@oxlint/binding-freebsd-x64@1.79.0': resolution: {integrity: sha512-qDMm0dXZnoHyRqSL4N4xUq82T4sqK5cbKSjvd/dF/YbMUXc2R1wEPf+vmA5S0qUmi0nwXfNbjXBtZaIqzQLIMg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-freebsd-x64@1.82.0': - resolution: {integrity: sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.79.0': resolution: {integrity: sha512-2od7s0nuKPzqyUZAWk9KkCyGg7eI9dwFPZg+20lB15fKFkVZ0c9ZFxqPfiBAyDTlTkh9stPI0t+JlPCqMbItVA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-gnueabihf@1.82.0': - resolution: {integrity: sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.79.0': resolution: {integrity: sha512-ZOQUjkzDnvlhSE3+tWC3YXx94MMl+sYMlwH+u1+YGApGHOJP/YAc8ZBRFOXZ6eOBmxtXAWuS/fBcdZr8qqNO1A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.82.0': - resolution: {integrity: sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.79.0': resolution: {integrity: sha512-lu158FR4nGqGeRS3BQvtG85wRgU/Fy4MD5Cxp1hzJXizGiLo6u2742wJSCDKh8cFcZntvX7fcxlq4mMmfryH1g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4178,13 +4130,6 @@ packages: os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-gnu@1.82.0': - resolution: {integrity: sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.79.0': resolution: {integrity: sha512-mbpKQeE2aflTjddaHK7MP8KP/OFbUM++lt5M635ENM8IyIdK0jm2t9pb+2v9mVVIvhF6TqA4l7F79Pll1mi+uw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4192,13 +4137,6 @@ packages: os: [linux] libc: [musl] - '@oxlint/binding-linux-arm64-musl@1.82.0': - resolution: {integrity: sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.79.0': resolution: {integrity: sha512-WpGNua7gaxaHnpSDeog2ji8IDHn/QLPl9LPzwkR/FvVv58vT5BcXjRXnU+wbu3N75cpeha8CdC7ho/U2OIsB4g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4206,13 +4144,6 @@ packages: os: [linux] libc: [glibc] - '@oxlint/binding-linux-ppc64-gnu@1.82.0': - resolution: {integrity: sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.79.0': resolution: {integrity: sha512-tK1E93A5LVzISg4ngpKJnfTs7EqtIUceGI7MQ4GyDjJiLi8wPCkEyKlj2xkyKWZ1yzkDJyLHTBJ5/iFWRdnJvg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4220,13 +4151,6 @@ packages: os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.82.0': - resolution: {integrity: sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.79.0': resolution: {integrity: sha512-qhQvUIrngXivA2A9pQ+xPCychztn/5qUv7yS3gDwXv3w7Rag+eTeeXWmRyx+t7XsW5x6LuY/8AsTq36UgFIblg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4234,13 +4158,6 @@ packages: os: [linux] libc: [musl] - '@oxlint/binding-linux-riscv64-musl@1.82.0': - resolution: {integrity: sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.79.0': resolution: {integrity: sha512-sv6AaVgU/eE6u+6WFiQVDcPPwTxP6IJMSB9k701W2r/r6Tx465e8vPvVyRxquNH4Vy6KwRNu90mVbxXJN8+5gg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4248,13 +4165,6 @@ packages: os: [linux] libc: [glibc] - '@oxlint/binding-linux-s390x-gnu@1.82.0': - resolution: {integrity: sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.79.0': resolution: {integrity: sha512-iFZL02deziHslb3jEX9KdqlAkYoo4fGyotchKDzdfK1f5mxlIBeiQeHhvK3iFpuEJSB4ma/qeFn9oxPiwnhUPQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4262,13 +4172,6 @@ packages: os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.82.0': - resolution: {integrity: sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.79.0': resolution: {integrity: sha512-3DtZR2raqObnh7wXZoFYFd0Fw7skBvcb3f7A+/lkEiDuh8hrE6vv9b/62Qxao1a9/OeHLw/FcXlXzgsW9wTRFg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4276,61 +4179,30 @@ packages: os: [linux] libc: [musl] - '@oxlint/binding-linux-x64-musl@1.82.0': - resolution: {integrity: sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - '@oxlint/binding-openharmony-arm64@1.79.0': resolution: {integrity: sha512-Oatt4GuA1WJkqzk2ozx4HrWROOi7opV3AKDw/U8qDIqeTqzsjn5K2x3REJMNjU3/KU/Bkq96Zi3CknaiDTaC/Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-openharmony-arm64@1.82.0': - resolution: {integrity: sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.79.0': resolution: {integrity: sha512-NAgZr9Qp8nIA9rpo0JEvwiabTF/2UVqBNnupBG9X4kxXcQoScJUTi+qHhvabb9s/thgj5wQ4XcIaJvb+ZMgoKw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-arm64-msvc@1.82.0': - resolution: {integrity: sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.79.0': resolution: {integrity: sha512-+KyXjIvcpaXmWW/j9NNY5yWjrIVxaX18VyIheQy3jwc2GSYgpCr7MGI/HxIGQ/shAL5IWEKbhsqoMpAO5Stiog==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.82.0': - resolution: {integrity: sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - '@oxlint/binding-win32-x64-msvc@1.79.0': resolution: {integrity: sha512-mEelcCMMBS57sIXh2veGMNy+pQwuGtcMxHxGIZWQ5Ba9pJ5jCCUFOZB9E2JhBaxGsURe+WGe0zJp4RVre52gpQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.82.0': - resolution: {integrity: sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - '@oxlint/plugins@1.68.0': resolution: {integrity: sha512-titLmukUt/h8ho7Svlf0xSBjoy2ccZKrXjpXpZCj+v6V4CJccC2KyP45BLSCMx8YIpifMyiDyUptM4+5sruKbQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -4339,10 +4211,6 @@ packages: resolution: {integrity: sha512-S0uyoxakDINJ4DPgqxGlEEvrdSMeQb7Z2lKVjxoY2gwsbZbfg2Xr8Klfeo5ZeraHmmdBCELFUHkSe6KEmBpMvg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@oxlint/plugins@1.82.0': - resolution: {integrity: sha512-IDF4UXBNOaCeLoEpVxf5Bg85KOWdeuBEkbo1rGzS2vXcbD+GT1asBSeeTC+5BgW6NFTHVByd6N5giHsFPGtwAA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@peculiar/asn1-schema@2.8.0': resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==} @@ -9408,19 +9276,6 @@ packages: vite-plus: optional: true - oxlint@1.82.0: - resolution: {integrity: sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - oxlint-tsgolint: '>=7.0.2001' - vite-plus: '*' - peerDependenciesMeta: - oxlint-tsgolint: - optional: true - vite-plus: - optional: true - p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -14836,123 +14691,64 @@ snapshots: '@oxlint/binding-android-arm-eabi@1.79.0': optional: true - '@oxlint/binding-android-arm-eabi@1.82.0': - optional: true - '@oxlint/binding-android-arm64@1.79.0': optional: true - '@oxlint/binding-android-arm64@1.82.0': - optional: true - '@oxlint/binding-darwin-arm64@1.79.0': optional: true - '@oxlint/binding-darwin-arm64@1.82.0': - optional: true - '@oxlint/binding-darwin-x64@1.79.0': optional: true - '@oxlint/binding-darwin-x64@1.82.0': - optional: true - '@oxlint/binding-freebsd-x64@1.79.0': optional: true - '@oxlint/binding-freebsd-x64@1.82.0': - optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.79.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.82.0': - optional: true - '@oxlint/binding-linux-arm-musleabihf@1.79.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.82.0': - optional: true - '@oxlint/binding-linux-arm64-gnu@1.79.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.82.0': - optional: true - '@oxlint/binding-linux-arm64-musl@1.79.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.82.0': - optional: true - '@oxlint/binding-linux-ppc64-gnu@1.79.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.82.0': - optional: true - '@oxlint/binding-linux-riscv64-gnu@1.79.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.82.0': - optional: true - '@oxlint/binding-linux-riscv64-musl@1.79.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.82.0': - optional: true - '@oxlint/binding-linux-s390x-gnu@1.79.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.82.0': - optional: true - '@oxlint/binding-linux-x64-gnu@1.79.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.82.0': - optional: true - '@oxlint/binding-linux-x64-musl@1.79.0': optional: true - '@oxlint/binding-linux-x64-musl@1.82.0': - optional: true - '@oxlint/binding-openharmony-arm64@1.79.0': optional: true - '@oxlint/binding-openharmony-arm64@1.82.0': - optional: true - '@oxlint/binding-win32-arm64-msvc@1.79.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.82.0': - optional: true - '@oxlint/binding-win32-ia32-msvc@1.79.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.82.0': - optional: true - '@oxlint/binding-win32-x64-msvc@1.79.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.82.0': - optional: true - '@oxlint/plugins@1.68.0': {} '@oxlint/plugins@1.79.0': {} - '@oxlint/plugins@1.82.0': {} - '@peculiar/asn1-schema@2.8.0': dependencies: '@peculiar/utils': 2.0.3 @@ -16657,7 +16453,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.76(197b68e0d20fdf20cf352009eca17c9f): + alchemy@2.0.0-beta.76(b3825b36417e56a486ccb5f22a7f3d7e): dependencies: '@alchemy.run/cloudflare-runtime': 2.0.0-beta.76(@distilled.cloud/cloudflare@1.0.0-rc.8(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)))(@effect/platform-bun@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-rc.112(bufferutil@4.1.0)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(redis@6.2.1)(utf-8-validate@6.0.6))(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2))(rolldown@1.2.5)(typescript@7.0.2) '@alchemy.run/floci': 2.0.0-beta.76(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) @@ -20737,30 +20533,6 @@ snapshots: oxlint-tsgolint: 7.0.2001 vite-plus: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) - oxlint@1.82.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): - optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.82.0 - '@oxlint/binding-android-arm64': 1.82.0 - '@oxlint/binding-darwin-arm64': 1.82.0 - '@oxlint/binding-darwin-x64': 1.82.0 - '@oxlint/binding-freebsd-x64': 1.82.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.82.0 - '@oxlint/binding-linux-arm-musleabihf': 1.82.0 - '@oxlint/binding-linux-arm64-gnu': 1.82.0 - '@oxlint/binding-linux-arm64-musl': 1.82.0 - '@oxlint/binding-linux-ppc64-gnu': 1.82.0 - '@oxlint/binding-linux-riscv64-gnu': 1.82.0 - '@oxlint/binding-linux-riscv64-musl': 1.82.0 - '@oxlint/binding-linux-s390x-gnu': 1.82.0 - '@oxlint/binding-linux-x64-gnu': 1.82.0 - '@oxlint/binding-linux-x64-musl': 1.82.0 - '@oxlint/binding-openharmony-arm64': 1.82.0 - '@oxlint/binding-win32-arm64-msvc': 1.82.0 - '@oxlint/binding-win32-ia32-msvc': 1.82.0 - '@oxlint/binding-win32-x64-msvc': 1.82.0 - oxlint-tsgolint: 7.0.2001 - vite-plus: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) - p-cancelable@2.1.1: {} p-limit@2.3.0: diff --git a/tools/oxlint/anti-slop/effect/index.ts b/tools/oxlint/anti-slop/effect/index.ts deleted file mode 100644 index 37247862ce43..000000000000 --- a/tools/oxlint/anti-slop/effect/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { eslintCompatPlugin } from "@oxlint/plugins"; - -import { noServiceConstructorImportsRule } from "./rules/no-service-constructor-imports.ts"; - -/** Opt-in Oxlint rules for Effect service and Layer architecture. */ -const antiSlopEffectPlugin = eslintCompatPlugin({ - meta: { name: "anti-slop-effect" }, - rules: { - "no-service-constructor-imports": noServiceConstructorImportsRule, - }, -}); - -export default antiSlopEffectPlugin; diff --git a/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts b/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts deleted file mode 100644 index 55cefb7e7b4c..000000000000 --- a/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { defineRule } from "@oxlint/plugins"; - -import type { ESTree } from "@oxlint/plugins"; - -const SERVICE_CONSTRUCTOR_NAME = /^make[A-Z]/u; -const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/u; - -function isProjectLocalImport(source: string): boolean { - return source.startsWith("./") || source.startsWith("../"); -} - -function getImportedName(specifier: ESTree.ImportSpecifier): string { - if (specifier.imported.type === "Identifier") return specifier.imported.name; - return specifier.imported.value; -} - -/** Keep dependency-bearing Effect service constructors local to their owning capability modules. */ -export const noServiceConstructorImportsRule = defineRule({ - meta: { - type: "problem", - docs: { - description: - "Disallow project-local make imports outside test and spec files.", - }, - messages: { - serviceConstructorImport: - 'Do not import Effect service constructor "{{name}}" into runtime code. Import the owning Layer, yield the contextual service, and allow its requirements to propagate to the composition root.', - }, - }, - create(context) { - const isTestFile = TEST_FILE.test(context.filename.replaceAll("\\", "/")); - - return { - ImportDeclaration(node) { - if (isTestFile || !isProjectLocalImport(node.source.value)) return; - - for (const specifier of node.specifiers) { - if (specifier.type !== "ImportSpecifier") continue; - - const importedName = getImportedName(specifier); - if (!SERVICE_CONSTRUCTOR_NAME.test(importedName)) continue; - - context.report({ - node: specifier, - messageId: "serviceConstructorImport", - data: { name: importedName }, - }); - } - }, - }; - }, -}); diff --git a/tools/oxlint/anti-slop/index.ts b/tools/oxlint/anti-slop/index.ts deleted file mode 100644 index 2b4ae2223e33..000000000000 --- a/tools/oxlint/anti-slop/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { eslintCompatPlugin } from "@oxlint/plugins"; - -import { noChainedTypeAssertionsRule } from "./rules/no-chained-type-assertions.ts"; -import { noConditionalEmptyObjectSpreadRule } from "./rules/no-conditional-empty-object-spread.ts"; -import { noKnownValueWideningRule } from "./rules/no-known-value-widening.ts"; -import { noModuleMockingRule } from "./rules/no-module-mocking.ts"; -import { noObjectParametersRule } from "./rules/no-object-parameters.ts"; -import { noReflectApplyRule } from "./rules/no-reflect-apply.ts"; -import { noReflectGetRule } from "./rules/no-reflect-get.ts"; -import { noRuntimeTypeofRule } from "./rules/no-runtime-typeof.ts"; -import { noForbiddenTermInSymbolNamesRule } from "./rules/no-shape-in-symbol-names.ts"; -import { noUnknownParametersRule } from "./rules/no-unknown-parameters.ts"; -import { noUnknownReturnsRule } from "./rules/no-unknown-returns.ts"; -import { noUnknownTypeAliasesRule } from "./rules/no-unknown-type-aliases.ts"; -import { noUnsafeDictionaryTypeRule } from "./rules/no-unsafe-dictionary-type.ts"; -import { noWidenThenAssertRule } from "./rules/no-widen-then-assert.ts"; -import { requireSafetyCommentForTypeAssertionRule } from "./rules/require-safety-comment-for-type-assertion.ts"; - -/** Generic Oxlint rules that reject low-evidence and low-signal implementation patterns. */ -const antiSlopPlugin = eslintCompatPlugin({ - meta: { name: "anti-slop" }, - rules: { - "no-chained-type-assertions": noChainedTypeAssertionsRule, - "no-conditional-empty-object-spread": noConditionalEmptyObjectSpreadRule, - "no-known-value-widening": noKnownValueWideningRule, - "no-module-mocking": noModuleMockingRule, - "no-object-parameters": noObjectParametersRule, - "no-reflect-apply": noReflectApplyRule, - "no-reflect-get": noReflectGetRule, - "no-runtime-typeof": noRuntimeTypeofRule, - "no-unsafe-dictionary-type": noUnsafeDictionaryTypeRule, - "no-shape-in-symbol-names": noForbiddenTermInSymbolNamesRule, - "no-unknown-parameters": noUnknownParametersRule, - "no-unknown-returns": noUnknownReturnsRule, - "no-unknown-type-aliases": noUnknownTypeAliasesRule, - "no-widen-then-assert": noWidenThenAssertRule, - "require-safety-comment-for-type-assertion": requireSafetyCommentForTypeAssertionRule, - }, -}); - -export default antiSlopPlugin; diff --git a/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts deleted file mode 100644 index 0d118527804c..000000000000 --- a/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { defineRule } from "@oxlint/plugins"; -import type { ESTree } from "@oxlint/plugins"; - -type TypeAssertionExpression = ESTree.TSAsExpression | ESTree.TSTypeAssertion; - -function isTypeAssertionExpression(node: ESTree.Node): node is TypeAssertionExpression { - return node.type === "TSAsExpression" || node.type === "TSTypeAssertion"; -} - -function unwrapParenthesizedExpression(expression: ESTree.Expression): ESTree.Expression { - let current = expression; - while (current.type === "ParenthesizedExpression") { - current = current.expression; - } - return current; -} - -function isConstAssertion(node: TypeAssertionExpression): boolean { - const { typeAnnotation } = node; - return ( - typeAnnotation.type === "TSTypeReference" && - typeAnnotation.typeName.type === "Identifier" && - typeAnnotation.typeName.name === "const" - ); -} - -function isOutermostAssertionInChain(node: TypeAssertionExpression): boolean { - let current: ESTree.Expression = node; - let parent = node.parent; - - while (parent.type === "ParenthesizedExpression" && parent.expression === current) { - current = parent; - parent = parent.parent; - } - - return !isTypeAssertionExpression(parent) || parent.expression !== current; -} - -function isForbiddenAssertionChain(node: TypeAssertionExpression): boolean { - let assertionCount = 0; - let hasNonConstAssertion = false; - let current: ESTree.Expression = node; - - while (isTypeAssertionExpression(current)) { - assertionCount += 1; - hasNonConstAssertion ||= !isConstAssertion(current); - current = unwrapParenthesizedExpression(current.expression); - } - - return assertionCount > 1 && hasNonConstAssertion; -} - -/** Disallow nested TypeScript type assertions, while permitting chains made only of const assertions. */ -export const noChainedTypeAssertionsRule = defineRule({ - meta: { - type: "problem", - docs: { - description: - "Disallow chained TypeScript as and angle-bracket assertions, including parenthesized chains.", - }, - messages: { - chained: - "This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.", - }, - }, - createOnce(context) { - const checkTypeAssertion = (node: TypeAssertionExpression) => { - if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return; - context.report({ node, messageId: "chained" }); - }; - - return { - TSAsExpression: checkTypeAssertion, - TSTypeAssertion: checkTypeAssertion, - }; - }, -}); diff --git a/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts deleted file mode 100644 index ae7248d36e54..000000000000 --- a/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { defineRule } from "@oxlint/plugins"; -import type { ESTree } from "@oxlint/plugins"; - -function unwrapParentheses(node: ESTree.Expression): ESTree.Expression { - let current = node; - while (current.type === "ParenthesizedExpression") { - current = current.expression; - } - return current; -} - -function isEmptyObjectExpression(node: ESTree.Expression): boolean { - return node.type === "ObjectExpression" && node.properties.length === 0; -} - -function isConditionalEmptyObjectSpread(node: ESTree.Expression): boolean { - const conditional = unwrapParentheses(node); - return ( - conditional.type === "ConditionalExpression" && - (isEmptyObjectExpression(conditional.consequent) || - isEmptyObjectExpression(conditional.alternate)) - ); -} - -/** Ban conditional empty-object spreads without changing their omission semantics. */ -export const noConditionalEmptyObjectSpreadRule = defineRule({ - meta: { - type: "suggestion", - docs: { - description: - "Disallow object spreads that conditionally spread an empty object to omit fields.", - }, - messages: { - avoid: - "This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present.", - }, - }, - createOnce(context) { - return { - SpreadElement(node) { - if (node.parent.type !== "ObjectExpression") return; - - if (isConditionalEmptyObjectSpread(node.argument)) { - context.report({ node, messageId: "avoid" }); - } - }, - }; - }, -}); diff --git a/tools/oxlint/anti-slop/rules/no-known-value-widening.ts b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts deleted file mode 100644 index 2a6806c69946..000000000000 --- a/tools/oxlint/anti-slop/rules/no-known-value-widening.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { defineRule } from "@oxlint/plugins"; - -import { - classifyWideningTarget, - createTypeEnvironment, - isKnownEvidenceExpression, - type TypeEnvironment, - type WideningTarget, -} from "../shared/dictionary-types.ts"; - -import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; - -type FunctionExpression = ESTree.ArrowFunctionExpression | ESTree.Function; - -function unwrapExpression(expression: ESTree.Expression): ESTree.Expression { - let current = expression; - while ( - current.type === "ParenthesizedExpression" || - current.type === "TSAsExpression" || - current.type === "TSSatisfiesExpression" || - current.type === "TSTypeAssertion" || - current.type === "TSNonNullExpression" - ) { - current = current.expression; - } - return current; -} - -function resolveVariable( - sourceCode: SourceCode, - identifier: ESTree.IdentifierReference, -): Variable | null { - let scope: Scope | null = sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(identifier.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - -function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { - if (variable.defs.length !== 1) return null; - const [definition] = variable.defs; - return definition?.type === "Variable" && definition.node.type === "VariableDeclarator" - ? definition.node - : null; -} - -function isStableConstVariable(variable: Variable, declarator: ESTree.VariableDeclarator): boolean { - return ( - declarator.parent.type === "VariableDeclaration" && - declarator.parent.kind === "const" && - variable.references.every((reference) => reference.init || !reference.isWrite()) - ); -} - -function hasKnownEvidence( - sourceCode: SourceCode, - expression: ESTree.Expression, - visitedVariables = new Set(), -): boolean { - if (isKnownEvidenceExpression(expression)) return true; - const unwrapped = unwrapExpression(expression); - if (unwrapped.type !== "Identifier") return false; - const variable = resolveVariable(sourceCode, unwrapped); - if (variable === null || visitedVariables.has(variable)) return false; - const declarator = variableDeclarator(variable); - if ( - declarator === null || - declarator.init === null || - !isStableConstVariable(variable, declarator) - ) { - return false; - } - visitedVariables.add(variable); - return hasKnownEvidence(sourceCode, declarator.init, visitedVariables); -} - -function annotationTarget( - annotation: ESTree.TSTypeAnnotation | null | undefined, - environment: TypeEnvironment, -): WideningTarget | null { - return annotation === null || annotation === undefined - ? null - : classifyWideningTarget(annotation.typeAnnotation, environment); -} - -function enclosingFunction(node: ESTree.Node): FunctionExpression | null { - let current: ESTree.Node | null = node.parent; - while (current !== null && current.type !== "Program") { - if ( - current.type === "ArrowFunctionExpression" || - current.type === "FunctionDeclaration" || - current.type === "FunctionExpression" - ) { - return current; - } - current = current.parent; - } - return null; -} - -function sourceKeyName(sourceCode: SourceCode, key: ESTree.PropertyKey): string { - if (key.type === "Identifier" || key.type === "PrivateIdentifier") return key.name; - if (key.type === "Literal") return String(key.value); - return sourceCode.getText(key); -} - -function functionName(sourceCode: SourceCode, owner: FunctionExpression | null): string { - if (owner === null) return "anonymous function"; - if (owner.id !== null) return owner.id.name; - const parent = owner.parent; - if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier") - return parent.id.name; - if (parent.type === "MethodDefinition") return sourceKeyName(sourceCode, parent.key); - return "anonymous function"; -} - -function isEmptyObjectExpression(expression: ESTree.Expression): boolean { - const unwrapped = unwrapExpression(expression); - return unwrapped.type === "ObjectExpression" && unwrapped.properties.length === 0; -} - -function isDictionaryAccumulatorTarget(destination: WideningTarget): boolean { - return destination.kind === "open dictionary" || destination.kind === "generic container"; -} - -function hasParentAssertion(node: ESTree.Node): boolean { - return node.parent?.type === "TSAsExpression" || node.parent?.type === "TSTypeAssertion"; -} - -/** Detect sound syntactic cases where a known value is explicitly widened and loses evidence. */ -export const noKnownValueWideningRule = defineRule({ - meta: { - type: "problem", - docs: { - description: - "Disallow syntactically established values from flowing into explicitly broad or anonymous target types that discard useful evidence.", - }, - messages: { - widening: - "The explicit {{target}} type on {{subject}} discards known type evidence. Keep inference, validate with `satisfies`, or use a named owner contract.", - }, - }, - createOnce(context) { - let environment: TypeEnvironment | null = null; - - const reportFlow = ( - expression: ESTree.Expression, - destination: WideningTarget | null, - subject: string, - ) => { - if (destination === null) return; - if ( - isDictionaryAccumulatorTarget(destination) && - isEmptyObjectExpression(expression) - ) { - return; - } - if (!hasKnownEvidence(context.sourceCode, expression)) return; - context.report({ - node: expression, - messageId: "widening", - data: { subject, target: destination.kind }, - }); - }; - - const targetFromAnnotation = (annotation: ESTree.TSTypeAnnotation | null | undefined) => - environment === null ? null : annotationTarget(annotation, environment); - - return { - Program(node) { - environment = createTypeEnvironment(node); - }, - VariableDeclarator(node) { - if (node.init === null || node.id.type !== "Identifier") return; - reportFlow( - node.init, - targetFromAnnotation(node.id.typeAnnotation), - `binding \`${node.id.name}\``, - ); - }, - PropertyDefinition(node) { - if (node.value === null) return; - reportFlow( - node.value, - targetFromAnnotation(node.typeAnnotation), - `property \`${sourceKeyName(context.sourceCode, node.key)}\``, - ); - }, - AccessorProperty(node) { - if (node.value === null) return; - reportFlow( - node.value, - targetFromAnnotation(node.typeAnnotation), - `property \`${sourceKeyName(context.sourceCode, node.key)}\``, - ); - }, - AssignmentExpression(node) { - if (node.operator !== "=" || node.left.type !== "Identifier") return; - const variable = resolveVariable(context.sourceCode, node.left); - if (variable === null) return; - const declarator = variableDeclarator(variable); - if (declarator === null || declarator.id.type !== "Identifier") return; - reportFlow( - node.right, - targetFromAnnotation(declarator.id.typeAnnotation), - `binding \`${declarator.id.name}\``, - ); - }, - ReturnStatement(node) { - if (node.argument === null) return; - const owner = enclosingFunction(node); - reportFlow( - node.argument, - targetFromAnnotation(owner?.returnType), - `return value of \`${functionName(context.sourceCode, owner)}\``, - ); - }, - ArrowFunctionExpression(node) { - if (node.body.type === "BlockStatement") return; - reportFlow( - node.body, - targetFromAnnotation(node.returnType), - `return value of \`${functionName(context.sourceCode, node)}\``, - ); - }, - TSAsExpression(node) { - if (environment === null || hasParentAssertion(node)) return; - reportFlow( - node.expression, - classifyWideningTarget(node.typeAnnotation, environment), - "assertion", - ); - }, - TSTypeAssertion(node) { - if (environment === null || hasParentAssertion(node)) return; - reportFlow( - node.expression, - classifyWideningTarget(node.typeAnnotation, environment), - "assertion", - ); - }, - }; - }, -}); diff --git a/tools/oxlint/anti-slop/rules/no-module-mocking.ts b/tools/oxlint/anti-slop/rules/no-module-mocking.ts deleted file mode 100644 index d6fb5b45c034..000000000000 --- a/tools/oxlint/anti-slop/rules/no-module-mocking.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { defineRule } from "@oxlint/plugins"; - -import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; - -const moduleMockMethods = new Set(["doMock", "mock", "unstable_mockModule"]); - -function resolveVariable( - sourceCode: SourceCode, - identifier: ESTree.IdentifierReference, -): Variable | null { - let scope: Scope | null = sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(identifier.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - -function importedName(node: ESTree.Node): string | null { - if (node.type !== "ImportSpecifier") return null; - return node.imported.type === "Identifier" ? node.imported.name : node.imported.value; -} - -function isTestFrameworkObject( - sourceCode: SourceCode, - expression: ESTree.Expression, -): expression is ESTree.IdentifierReference { - if (expression.type !== "Identifier") return false; - if ( - (expression.name === "vi" || expression.name === "jest") && - sourceCode.isGlobalReference(expression) - ) { - return true; - } - - const variable = resolveVariable(sourceCode, expression); - if (variable === null || variable.defs.length === 0) { - return expression.name === "vi" || expression.name === "jest"; - } - return variable.defs.some((definition) => { - if (definition.type !== "ImportBinding" || definition.parent?.type !== "ImportDeclaration") { - return false; - } - const source = definition.parent.source.value; - const name = importedName(definition.node); - return (source === "vitest" && name === "vi") || (source === "@jest/globals" && name === "jest"); - }); -} - -function moduleMockCall(sourceCode: SourceCode, callee: ESTree.Expression): boolean { - if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false; - if (!isTestFrameworkObject(sourceCode, callee.object)) return false; - const property = callee.property; - const method = callee.computed - ? property.type === "Literal" && - (property.value === "doMock" || - property.value === "mock" || - property.value === "unstable_mockModule") - ? property.value - : null - : property.type === "Identifier" - ? property.name - : null; - return method !== null && moduleMockMethods.has(method); -} - -/** Ban test framework module mocking in favor of real dependency seams. */ -export const noModuleMockingRule = defineRule({ - meta: { - type: "problem", - docs: { - description: - "Disallow Vitest and Jest module mocking; tests must replace dependencies through real interfaces.", - }, - messages: { - moduleMock: - "Replace module mocking with dependency injection through a real interface, service layer, or faithful test implementation.", - }, - }, - createOnce(context) { - return { - CallExpression(node) { - if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; - if (moduleMockCall(context.sourceCode, node.callee)) { - context.report({ node, messageId: "moduleMock" }); - } - }, - }; - }, -}); diff --git a/tools/oxlint/anti-slop/rules/no-object-parameters.ts b/tools/oxlint/anti-slop/rules/no-object-parameters.ts deleted file mode 100644 index 29b990f3395a..000000000000 --- a/tools/oxlint/anti-slop/rules/no-object-parameters.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { defineRule } from "@oxlint/plugins"; - -import type { ESTree, SourceCode } from "@oxlint/plugins"; - -import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.ts"; - -type Parameter = ESTree.ParamPattern; -type ParameterOwner = - | ESTree.ArrowFunctionExpression - | ESTree.Function - | ESTree.TSCallSignatureDeclaration - | ESTree.TSConstructSignatureDeclaration - | ESTree.TSConstructorType - | ESTree.TSFunctionType - | ESTree.TSMethodSignature; - -function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { - if (parameter.type === "TSParameterProperty") { - return parameterAnnotation(parameter.parameter); - } - if (parameter.type === "RestElement") { - return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument); - } - if (parameter.type === "AssignmentPattern") { - return parameter.typeAnnotation ?? parameter.left.typeAnnotation; - } - return parameter.typeAnnotation; -} - -function parameterName(parameter: Parameter, sourceCode: SourceCode): string { - return parameter.type === "Identifier" - ? parameter.name - : sourceCode.getText(parameter).replace(/\s*:\s*object\s*$/u, ""); -} - -/** Ban the broad object type on function inputs, including local aliases to object. */ -export const noObjectParametersRule = defineRule({ - meta: { - type: "problem", - docs: { - description: - "Disallow object function parameters; inputs must use an owner-provided type and be parsed at their boundary.", - }, - messages: { - objectParameter: - "Parameter `{{parameter}}` uses the broad `object` type. Accept a named owner type; parse external input at its boundary before calling this function.", - }, - }, - createOnce(context) { - const aliases = new Map(); - - const resolvesToObject = ( - type: ESTree.TSType, - shadowedAliases: ReadonlySet, - visited = new Set(), - ): boolean => { - if (type.type === "TSObjectKeyword") return true; - if (type.type === "TSParenthesizedType") - return resolvesToObject(type.typeAnnotation, shadowedAliases, visited); - if (type.type === "TSUnionType") { - return type.types.some((member) => - resolvesToObject(member, shadowedAliases, visited), - ); - } - if ( - type.type !== "TSTypeReference" || - type.typeName.type !== "Identifier" || - (type.typeArguments !== null && - type.typeArguments !== undefined && - type.typeArguments.params.length > 0) || - visited.has(type.typeName.name) || - shadowedAliases.has(type.typeName.name) - ) { - return false; - } - const alias = aliases.get(type.typeName.name); - if (alias === undefined) return false; - const nextVisited = new Set(visited); - nextVisited.add(type.typeName.name); - return resolvesToObject(alias, shadowedAliases, nextVisited); - }; - - const checkParameters = (node: ParameterOwner) => { - const shadowedAliases = lexicalTypeParameterNames( - node, - context.sourceCode.visitorKeys, - ); - for (const parameter of node.params) { - const annotation = parameterAnnotation(parameter); - if (annotation === null || annotation === undefined) continue; - if (!resolvesToObject(annotation.typeAnnotation, shadowedAliases)) continue; - context.report({ - node: annotation.typeAnnotation, - messageId: "objectParameter", - data: { parameter: parameterName(parameter, context.sourceCode) }, - }); - } - }; - - return { - Program(node) { - aliases.clear(); - for (const statement of node.body) { - const declaration = - statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; - if ( - declaration?.type === "TSTypeAliasDeclaration" && - (declaration.typeParameters === null || declaration.typeParameters === undefined) - ) { - aliases.set(declaration.id.name, declaration.typeAnnotation); - } - } - }, - ArrowFunctionExpression: checkParameters, - FunctionDeclaration: checkParameters, - FunctionExpression: checkParameters, - TSCallSignatureDeclaration: checkParameters, - TSConstructSignatureDeclaration: checkParameters, - TSConstructorType: checkParameters, - TSDeclareFunction: checkParameters, - TSEmptyBodyFunctionExpression: checkParameters, - TSFunctionType: checkParameters, - TSMethodSignature: checkParameters, - }; - }, -}); diff --git a/tools/oxlint/anti-slop/rules/no-reflect-apply.ts b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts deleted file mode 100644 index 2cc30451bda6..000000000000 --- a/tools/oxlint/anti-slop/rules/no-reflect-apply.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { defineRule } from "@oxlint/plugins"; - -import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts"; - -/** Ban Reflect.apply, which bypasses ordinary typed function calls. */ -export const noReflectApplyRule = defineRule({ - meta: { - type: "problem", - docs: { - description: - "Disallow Reflect.apply; call typed functions directly or model dynamic dispatch behind an interface.", - }, - messages: { - reflectApply: - "Replace `Reflect.apply` with a typed function call. Model dynamic dispatch behind a named interface.", - }, - }, - createOnce(context) { - return { - CallExpression(node) { - if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; - if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "apply")) { - context.report({ node, messageId: "reflectApply" }); - } - }, - }; - }, -}); diff --git a/tools/oxlint/anti-slop/rules/no-reflect-get.ts b/tools/oxlint/anti-slop/rules/no-reflect-get.ts deleted file mode 100644 index cf630ecc005a..000000000000 --- a/tools/oxlint/anti-slop/rules/no-reflect-get.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { defineRule } from "@oxlint/plugins"; - -import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts"; - -/** Ban Reflect.get, which bypasses ordinary property access and useful type evidence. */ -export const noReflectGetRule = defineRule({ - meta: { - type: "problem", - docs: { - description: - "Disallow Reflect.get; use typed property access or parse dynamic input into a domain type.", - }, - messages: { - reflectGet: - "Replace `Reflect.get` with typed property access. Parse dynamic input into a named domain type before reading it.", - }, - }, - createOnce(context) { - return { - CallExpression(node) { - if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; - if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "get")) { - context.report({ node, messageId: "reflectGet" }); - } - }, - }; - }, -}); diff --git a/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts deleted file mode 100644 index 6a25c2475d9a..000000000000 --- a/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { defineRule } from "@oxlint/plugins"; - -import type { ESTree } from "@oxlint/plugins"; - -type RuntimeFunction = ESTree.ArrowFunctionExpression | ESTree.Function; - -function isRuntimeFunction(node: ESTree.Node): node is RuntimeFunction { - return ( - node.type === "ArrowFunctionExpression" || - node.type === "FunctionDeclaration" || - node.type === "FunctionExpression" - ); -} - -function isInsideTypeGuard(node: ESTree.Node): boolean { - let current: ESTree.Node | null = node.parent; - while (current !== null && current.type !== "Program") { - if (isRuntimeFunction(current)) { - return current.returnType?.typeAnnotation.type === "TSTypePredicate"; - } - current = current.parent; - } - return false; -} - -/** Disallow runtime typeof checks that narrow unparsed values instead of decoding them. */ -export const noRuntimeTypeofRule = defineRule({ - meta: { - type: "problem", - docs: { - description: - "Disallow runtime typeof checks; external values must be decoded into meaningful types at their I/O boundary.", - }, - messages: { - runtimeTypeof: - "A `typeof` check narrows a representation without establishing its contract. Parse input at its I/O boundary, then branch on the domain value.", - }, - schema: [ - { - type: "object", - properties: { - allowInTypeGuards: { type: "boolean" }, - }, - additionalProperties: false, - }, - ], - defaultOptions: [{ allowInTypeGuards: false }], - }, - createOnce(context) { - return { - UnaryExpression(node) { - const option = context.options?.[0]; - const allowInTypeGuards = - typeof option === "object" && - option !== null && - !Array.isArray(option) && - option.allowInTypeGuards === true; - if ( - node.operator === "typeof" && - (!allowInTypeGuards || !isInsideTypeGuard(node)) - ) { - context.report({ node, messageId: "runtimeTypeof" }); - } - }, - }; - }, -}); diff --git a/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts deleted file mode 100644 index afc00dd41245..000000000000 --- a/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { defineRule } from "@oxlint/plugins"; -import type { ESTree } from "@oxlint/plugins"; - -const FORBIDDEN_SYMBOL_NAME = "shape"; - -function containsForbiddenSymbolName(name: string): boolean { - return name.toLowerCase().includes(FORBIDDEN_SYMBOL_NAME); -} - -/** Ban the case-insensitive substring "shape" in every JavaScript and TypeScript symbol name. */ -export const noForbiddenTermInSymbolNamesRule = defineRule({ - meta: { - type: "problem", - docs: { - description: - 'Disallow the case-insensitive substring "shape" in JavaScript, TypeScript, private, and JSX symbol names.', - }, - messages: { - forbiddenSymbolName: - 'Rename symbol "{{name}}" for its domain role; "shape" describes structure rather than ownership.', - }, - }, - createOnce(context) { - const reportForbiddenSymbolName = (node: ESTree.Node & { name: string }) => { - if (!containsForbiddenSymbolName(node.name)) return; - context.report({ - node, - messageId: "forbiddenSymbolName", - data: { name: node.name }, - }); - }; - - return { - Identifier: reportForbiddenSymbolName, - PrivateIdentifier: reportForbiddenSymbolName, - JSXIdentifier: reportForbiddenSymbolName, - }; - }, -}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts deleted file mode 100644 index cdc6c23519ea..000000000000 --- a/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { defineRule } from "@oxlint/plugins"; -import type { ESTree } from "@oxlint/plugins"; - -type Parameter = ESTree.ParamPattern; -type ParameterOwner = - | ESTree.ArrowFunctionExpression - | ESTree.Function - | ESTree.TSCallSignatureDeclaration - | ESTree.TSConstructSignatureDeclaration - | ESTree.TSConstructorType - | ESTree.TSFunctionType - | ESTree.TSMethodSignature; - -function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { - if (parameter.type === "TSParameterProperty") { - return parameterAnnotation(parameter.parameter); - } - if (parameter.type === "RestElement") { - return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument); - } - if (parameter.type === "AssignmentPattern") { - return parameter.typeAnnotation ?? parameter.left.typeAnnotation; - } - return parameter.typeAnnotation; -} - -function parameterName(parameter: Parameter, sourceText: string): string { - if (parameter.type === "TSParameterProperty") { - return parameterName(parameter.parameter, sourceText); - } - if (parameter.type === "AssignmentPattern") { - return parameterName(parameter.left, sourceText); - } - if (parameter.type === "RestElement") { - return parameterName(parameter.argument, sourceText); - } - return parameter.type === "Identifier" - ? parameter.name - : sourceText.replace(/\s*:\s*unknown\s*$/u, ""); -} - -/** Disallow unknown inputs except explicitly named error-cause enrichment. */ -export const noUnknownParametersRule = defineRule({ - meta: { - type: "problem", - docs: { - description: - "Disallow explicitly unknown function parameters except `cause`; decode unknown input at its I/O boundary instead.", - }, - messages: { - unknownParameter: - "Parameter `{{parameter}}` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.", - }, - }, - createOnce(context) { - const checkParameters = (node: ParameterOwner) => { - for (const parameter of node.params) { - const annotation = parameterAnnotation(parameter); - if (annotation?.typeAnnotation.type !== "TSUnknownKeyword") continue; - const name = parameterName(parameter, context.sourceCode.getText(parameter)); - if (name === "cause") continue; - context.report({ - node: annotation.typeAnnotation, - messageId: "unknownParameter", - data: { parameter: name }, - }); - } - }; - - return { - ArrowFunctionExpression: checkParameters, - FunctionDeclaration: checkParameters, - FunctionExpression: checkParameters, - TSCallSignatureDeclaration: checkParameters, - TSConstructSignatureDeclaration: checkParameters, - TSConstructorType: checkParameters, - TSDeclareFunction: checkParameters, - TSEmptyBodyFunctionExpression: checkParameters, - TSFunctionType: checkParameters, - TSMethodSignature: checkParameters, - }; - }, -}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-returns.ts b/tools/oxlint/anti-slop/rules/no-unknown-returns.ts deleted file mode 100644 index 4b16d6ef3d78..000000000000 --- a/tools/oxlint/anti-slop/rules/no-unknown-returns.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { defineRule } from "@oxlint/plugins"; - -import type { ESTree } from "@oxlint/plugins"; - -import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.ts"; - -type FunctionWithReturnType = - | ESTree.ArrowFunctionExpression - | ESTree.Function - | ESTree.TSCallSignatureDeclaration - | ESTree.TSConstructSignatureDeclaration - | ESTree.TSConstructorType - | ESTree.TSFunctionType - | ESTree.TSMethodSignature; - -function referencedAliasName(type: ESTree.TSType): string | null { - if (type.type === "TSParenthesizedType") return referencedAliasName(type.typeAnnotation); - if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null; - return type.typeArguments === null || - type.typeArguments === undefined || - type.typeArguments.params.length === 0 - ? type.typeName.name - : null; -} - -/** Ban function contracts that return unknown instead of a parsed domain type. */ -export const noUnknownReturnsRule = defineRule({ - meta: { - type: "problem", - docs: { - description: - "Disallow functions whose explicit return contract is unknown or Promise.", - }, - messages: { - unknownReturn: - "This function exposes `unknown` to its caller. Parse the value at its boundary and return a named domain type.", - }, - }, - createOnce(context) { - const aliases = new Map(); - - const resolvesToUnknown = ( - type: ESTree.TSType, - shadowedAliases: ReadonlySet, - visited = new Set(), - ): boolean => { - if (type.type === "TSUnknownKeyword") return true; - if (type.type === "TSParenthesizedType") { - return resolvesToUnknown(type.typeAnnotation, shadowedAliases, visited); - } - if (type.type === "TSUnionType") { - return type.types.some((member) => - resolvesToUnknown(member, shadowedAliases, visited), - ); - } - if ( - type.type === "TSTypeReference" && - type.typeName.type === "Identifier" && - (type.typeName.name === "Promise" || type.typeName.name === "PromiseLike") - ) { - const value = type.typeArguments?.params[0]; - return value !== undefined && resolvesToUnknown(value, shadowedAliases, visited); - } - const name = referencedAliasName(type); - if (name === null || visited.has(name) || shadowedAliases.has(name)) return false; - const alias = aliases.get(name); - if ( - alias === undefined || - (alias.typeParameters !== null && alias.typeParameters !== undefined) - ) { - return false; - } - const nextVisited = new Set(visited); - nextVisited.add(name); - return resolvesToUnknown(alias.typeAnnotation, shadowedAliases, nextVisited); - }; - - const checkReturnType = (node: FunctionWithReturnType) => { - const annotation = node.returnType; - if (annotation === null || annotation === undefined) return; - if ( - !resolvesToUnknown( - annotation.typeAnnotation, - lexicalTypeParameterNames(node, context.sourceCode.visitorKeys), - ) - ) { - return; - } - context.report({ node: annotation.typeAnnotation, messageId: "unknownReturn" }); - }; - - return { - Program(node) { - aliases.clear(); - for (const statement of node.body) { - const declaration = - statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; - if (declaration?.type === "TSTypeAliasDeclaration") { - aliases.set(declaration.id.name, declaration); - } - } - }, - ArrowFunctionExpression: checkReturnType, - FunctionDeclaration: checkReturnType, - FunctionExpression: checkReturnType, - TSCallSignatureDeclaration: checkReturnType, - TSConstructSignatureDeclaration: checkReturnType, - TSConstructorType: checkReturnType, - TSDeclareFunction: checkReturnType, - TSEmptyBodyFunctionExpression: checkReturnType, - TSFunctionType: checkReturnType, - TSMethodSignature: checkReturnType, - }; - }, -}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts deleted file mode 100644 index 3e328fdfc914..000000000000 --- a/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { defineRule } from "@oxlint/plugins"; - -import type { ESTree } from "@oxlint/plugins"; - -function referencedAliasName(type: ESTree.TSType): string | null { - if (type.type === "TSParenthesizedType") return referencedAliasName(type.typeAnnotation); - if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null; - return type.typeArguments === null || - type.typeArguments === undefined || - type.typeArguments.params.length === 0 - ? type.typeName.name - : null; -} - -/** Ban named aliases that merely conceal TypeScript's unknown top type. */ -export const noUnknownTypeAliasesRule = defineRule({ - meta: { - type: "problem", - docs: { - description: - "Disallow type aliases whose resolved type is unknown; unknown must remain visible at an allowed boundary.", - }, - messages: { - unknownAlias: - "Type alias `{{alias}}` hides `unknown`. Keep `unknown` explicit at the parsing boundary or on an allowed `cause` field; otherwise use the parsed owner type.", - }, - }, - createOnce(context) { - const aliases = new Map(); - - const resolvesToUnknown = (type: ESTree.TSType, visited = new Set()): boolean => { - if (type.type === "TSUnknownKeyword") return true; - if (type.type === "TSParenthesizedType") - return resolvesToUnknown(type.typeAnnotation, visited); - const name = referencedAliasName(type); - if (name === null || visited.has(name)) return false; - const alias = aliases.get(name); - if ( - alias === undefined || - (alias.typeParameters !== null && alias.typeParameters !== undefined) - ) { - return false; - } - const nextVisited = new Set(visited); - nextVisited.add(name); - return resolvesToUnknown(alias.typeAnnotation, nextVisited); - }; - - return { - Program(node) { - aliases.clear(); - for (const statement of node.body) { - const declaration = - statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; - if (declaration?.type === "TSTypeAliasDeclaration") { - aliases.set(declaration.id.name, declaration); - } - } - for (const alias of aliases.values()) { - if (!resolvesToUnknown(alias.typeAnnotation, new Set([alias.id.name]))) continue; - context.report({ - node: alias.id, - messageId: "unknownAlias", - data: { alias: alias.id.name }, - }); - } - }, - }; - }, -}); diff --git a/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts deleted file mode 100644 index 8c45eed27628..000000000000 --- a/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { defineRule } from "@oxlint/plugins"; - -import { - classifyUnsafeDictionary, - classifyUnsafeDictionaryValue, - createTypeEnvironment, - type TypeEnvironment, -} from "../shared/dictionary-types.ts"; - -import type { ESTree } from "@oxlint/plugins"; - -const typeNodeKinds: ReadonlySet = new Set([ - "JSDocNonNullableType", - "JSDocNullableType", - "JSDocUnknownType", - "TSAnyKeyword", - "TSArrayType", - "TSBigIntKeyword", - "TSBooleanKeyword", - "TSConditionalType", - "TSConstructorType", - "TSFunctionType", - "TSImportType", - "TSIndexedAccessType", - "TSInferType", - "TSIntersectionType", - "TSIntrinsicKeyword", - "TSLiteralType", - "TSMappedType", - "TSNamedTupleMember", - "TSNeverKeyword", - "TSNullKeyword", - "TSNumberKeyword", - "TSObjectKeyword", - "TSParenthesizedType", - "TSStringKeyword", - "TSSymbolKeyword", - "TSTemplateLiteralType", - "TSThisType", - "TSTupleType", - "TSTypeLiteral", - "TSTypeOperator", - "TSTypePredicate", - "TSTypeQuery", - "TSTypeReference", - "TSUndefinedKeyword", - "TSUnionType", - "TSUnknownKeyword", - "TSVoidKeyword", -]); - -function isTypeNode(node: ESTree.Node): node is ESTree.TSType { - return typeNodeKinds.has(node.type); -} - -function typeReferenceName(type: ESTree.TSTypeReference): string | null { - return type.typeName.type === "Identifier" ? type.typeName.name : null; -} - -function isInsideTypeAliasDeclaration(node: ESTree.Node): boolean { - let current: ESTree.Node | null = node.parent; - while (current !== null && current.type !== "Program") { - if (current.type === "TSTypeAliasDeclaration") return true; - current = current.parent; - } - return false; -} - -function isPlainAliasConsumerUse(node: ESTree.TSType, environment: TypeEnvironment): boolean { - if (node.type !== "TSTypeReference" || node.typeArguments?.params.length) return false; - const name = typeReferenceName(node); - return name !== null && environment.aliases.has(name) && !isInsideTypeAliasDeclaration(node); -} - -function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean { - if (isPlainAliasConsumerUse(node, environment)) return false; - if (classifyUnsafeDictionary(node, environment) === null) return false; - let current: ESTree.Node | null = node.parent; - while (current !== null && current.type !== "Program") { - if (isTypeNode(current) && classifyUnsafeDictionary(current, environment) !== null) - return false; - current = current.parent; - } - return true; -} - -/** Disallow object-dictionary contracts whose direct value type is an unsafe escape hatch. */ -export const noUnsafeDictionaryTypeRule = defineRule({ - meta: { - type: "problem", - docs: { - description: - "Disallow object-dictionary contracts whose direct value type is unknown, any, object, {}, or a union/alias containing one of those escape hatches.", - }, - messages: { - unsafeDictionary: - "This dictionary's {{value}} value type gives callers no concrete value contract. Use an owner/schema-derived value type; parse external payloads before insertion.", - }, - }, - createOnce(context) { - let environment: TypeEnvironment | null = null; - const report = (node: ESTree.Node, value: string) => { - context.report({ node, messageId: "unsafeDictionary", data: { value } }); - }; - const reportIfUnsafe = (node: ESTree.TSType) => { - if (environment === null || !shouldReportType(node, environment)) return; - const unsafe = classifyUnsafeDictionary(node, environment); - if (unsafe === null) return; - report(node, unsafe.unsafeValue); - }; - - return { - Program(node) { - environment = createTypeEnvironment(node); - }, - TSTypeReference: reportIfUnsafe, - TSTypeLiteral: reportIfUnsafe, - TSMappedType: reportIfUnsafe, - TSIndexSignature(node) { - if ( - environment === null || - node.typeAnnotation === null || - node.parent.type === "TSTypeLiteral" - ) - return; - const unsafe = classifyUnsafeDictionaryValue( - node.typeAnnotation.typeAnnotation, - environment, - ); - if (unsafe !== null) report(node, unsafe.unsafeValue); - }, - }; - }, -}); diff --git a/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts deleted file mode 100644 index c5e07f7fc55a..000000000000 --- a/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts +++ /dev/null @@ -1,366 +0,0 @@ -import { defineRule } from "@oxlint/plugins"; -import type { ESTree, Variable } from "@oxlint/plugins"; - -type BroadTypeKind = "top" | "object" | "record"; - -type KnownValueEvidence = { - readonly type: ESTree.TSType | null; -}; - -const functionBoundaryTypes = new Set([ - "ArrowFunctionExpression", - "FunctionDeclaration", - "FunctionExpression", - "TSDeclareFunction", - "TSEmptyBodyFunctionExpression", -]); - -function unwrapExpressionParentheses(expression: ESTree.Expression): ESTree.Expression { - let current = expression; - while (current.type === "ParenthesizedExpression") current = current.expression; - return current; -} - -function unwrapTypeParentheses(type: ESTree.TSType): ESTree.TSType { - let current = type; - while (current.type === "TSParenthesizedType") current = current.typeAnnotation; - return current; -} - -function typeReferenceName(type: ESTree.TSTypeReference): string | null { - return type.typeName.type === "Identifier" ? type.typeName.name : null; -} - -function isUnknownOrAnyType(type: ESTree.TSType): boolean { - const unwrapped = unwrapTypeParentheses(type); - return unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword"; -} - -function isBroadRecordKeyType(type: ESTree.TSType): boolean { - const unwrapped = unwrapTypeParentheses(type); - if ( - unwrapped.type === "TSStringKeyword" || - unwrapped.type === "TSNumberKeyword" || - unwrapped.type === "TSSymbolKeyword" - ) { - return true; - } - if (unwrapped.type === "TSUnionType") return unwrapped.types.every(isBroadRecordKeyType); - return unwrapped.type === "TSTypeReference" && typeReferenceName(unwrapped) === "PropertyKey"; -} - -function isBroadRecordType(type: ESTree.TSType): boolean { - const unwrapped = unwrapTypeParentheses(type); - - if (unwrapped.type === "TSTypeReference") { - if (typeReferenceName(unwrapped) === "Readonly") { - const [inner] = unwrapped.typeArguments?.params ?? []; - return inner !== undefined && isBroadRecordType(inner); - } - - if (typeReferenceName(unwrapped) !== "Record") return false; - const parameters = unwrapped.typeArguments?.params ?? []; - return ( - parameters.length === 2 && - parameters[0] !== undefined && - parameters[1] !== undefined && - isBroadRecordKeyType(parameters[0]) && - isUnknownOrAnyType(parameters[1]) - ); - } - - if (unwrapped.type !== "TSTypeLiteral" || unwrapped.members.length !== 1) return false; - const [member] = unwrapped.members; - const [parameter] = member?.type === "TSIndexSignature" ? member.parameters : []; - return ( - member?.type === "TSIndexSignature" && - member.parameters.length === 1 && - parameter !== undefined && - isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) && - isUnknownOrAnyType(member.typeAnnotation.typeAnnotation) - ); -} - -function broadTypeKind(type: ESTree.TSType): BroadTypeKind | null { - const unwrapped = unwrapTypeParentheses(type); - if (unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword") return "top"; - if (unwrapped.type === "TSObjectKeyword") return "object"; - return isBroadRecordType(unwrapped) ? "record" : null; -} - -function assertedExpression( - node: ESTree.TSAsExpression | ESTree.TSTypeAssertion, -): ESTree.Expression { - return unwrapExpressionParentheses(node.expression); -} - -function assertionFromExpression( - expression: ESTree.Expression, -): ESTree.TSAsExpression | ESTree.TSTypeAssertion | null { - const unwrapped = unwrapExpressionParentheses(expression); - return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion" - ? unwrapped - : null; -} - -function normalizedTypeText(sourceText: string, type: ESTree.TSType): string { - return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, ""); -} - -function typesHaveSameSyntax( - sourceText: string, - left: ESTree.TSType | null, - right: ESTree.TSType, -): boolean { - return ( - left !== null && - normalizedTypeText(sourceText, unwrapTypeParentheses(left)) === - normalizedTypeText(sourceText, unwrapTypeParentheses(right)) - ); -} - -function isDefinitelyObjectType(type: ESTree.TSType): boolean { - const unwrapped = unwrapTypeParentheses(type); - switch (unwrapped.type) { - case "TSArrayType": - case "TSConstructorType": - case "TSFunctionType": - case "TSMappedType": - case "TSObjectKeyword": - case "TSTupleType": - return true; - case "TSTypeLiteral": - return unwrapped.members.length > 0; - case "TSIntersectionType": - return unwrapped.types.every(isDefinitelyObjectType); - case "TSTypeOperator": - return unwrapped.operator === "readonly" && isDefinitelyObjectType(unwrapped.typeAnnotation); - default: - return false; - } -} - -function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean { - const unwrapped = unwrapTypeParentheses(type); - if (unwrapped.type === "TSTypeLiteral") { - return unwrapped.members.some((member) => member.type !== "TSIndexSignature"); - } - - if (unwrapped.type !== "TSTypeReference") return false; - if (typeReferenceName(unwrapped) === "Readonly") { - const [inner] = unwrapped.typeArguments?.params ?? []; - return inner !== undefined && isDefinitelyNarrowerRecordType(inner); - } - if (typeReferenceName(unwrapped) !== "Record") return false; - - const parameters = unwrapped.typeArguments?.params ?? []; - return ( - parameters.length === 2 && parameters[1] !== undefined && !isUnknownOrAnyType(parameters[1]) - ); -} - -function functionBoundary(node: ESTree.Node): ESTree.Node | null { - let current = node.parent; - while (current !== null && current.type !== "Program") { - if (functionBoundaryTypes.has(current.type)) return current; - current = current.parent; - } - return null; -} - -function resolvedVariableForIdentifier( - scopes: readonly { - readonly references: readonly { - readonly identifier: ESTree.Node; - readonly resolved: Variable | null; - }[]; - }[], - identifier: ESTree.IdentifierReference, -): Variable | null { - for (const scope of scopes) { - const reference = scope.references.find( - (candidate) => - candidate.identifier.start === identifier.start && - candidate.identifier.end === identifier.end, - ); - if (reference !== undefined) return reference.resolved; - } - return null; -} - -function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { - for (const definition of variable.defs) { - if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") { - return definition.node; - } - } - return null; -} - -function knownValueEvidence( - expression: ESTree.Expression, - scopes: Parameters[0], - boundary: ESTree.Node | null, - visitedVariables: ReadonlySet, -): KnownValueEvidence | null { - const unwrapped = unwrapExpressionParentheses(expression); - - if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") { - if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null; - return { type: unwrapped.typeAnnotation }; - } - - if (unwrapped.type === "Literal" || unwrapped.type === "TemplateLiteral") { - return { type: null }; - } - - if ( - unwrapped.type === "ArrayExpression" || - unwrapped.type === "ArrowFunctionExpression" || - unwrapped.type === "ClassExpression" || - unwrapped.type === "FunctionExpression" || - unwrapped.type === "NewExpression" || - unwrapped.type === "ObjectExpression" - ) { - return { type: null }; - } - - if (unwrapped.type !== "Identifier") return null; - const variable = resolvedVariableForIdentifier(scopes, unwrapped); - if (variable === null || visitedVariables.has(variable)) return null; - - const annotatedIdentifier = variable.identifiers.find( - (identifier) => identifier.typeAnnotation !== null && identifier.typeAnnotation !== undefined, - ); - const annotation = annotatedIdentifier?.typeAnnotation?.typeAnnotation; - if (annotation !== undefined && annotatedIdentifier !== undefined) { - if (functionBoundary(annotatedIdentifier) !== boundary || broadTypeKind(annotation) !== null) { - return null; - } - return { type: annotation }; - } - - const declarator = variableDeclarator(variable); - if ( - declarator === null || - declarator.parent.type !== "VariableDeclaration" || - declarator.parent.kind !== "const" || - declarator.init === null || - variable.references.some((reference) => reference.isWrite() && !reference.init) || - functionBoundary(declarator) !== boundary - ) { - return null; - } - - return knownValueEvidence( - declarator.init, - scopes, - boundary, - new Set([...visitedVariables, variable]), - ); -} - -function widenedBinding( - variable: Variable, - scopes: Parameters[0], -): { - readonly broadKind: BroadTypeKind; - readonly evidence: KnownValueEvidence; - readonly declaredAt: number; - readonly boundary: ESTree.Node | null; -} | null { - const declarator = variableDeclarator(variable); - if ( - declarator === null || - declarator.parent.type !== "VariableDeclaration" || - declarator.parent.kind !== "const" || - declarator.id.type !== "Identifier" || - declarator.init === null || - variable.references.some((reference) => reference.isWrite() && !reference.init) - ) { - return null; - } - - const boundary = functionBoundary(declarator); - const declaredType = declarator.id.typeAnnotation?.typeAnnotation; - const initializerAssertion = assertionFromExpression(declarator.init); - const initializerBroadKind = - initializerAssertion === null ? null : broadTypeKind(initializerAssertion.typeAnnotation); - const declaredBroadKind = declaredType === undefined ? null : broadTypeKind(declaredType); - const broadKind = declaredBroadKind ?? initializerBroadKind; - if (broadKind === null) return null; - - const originalExpression = - initializerAssertion !== null && initializerBroadKind !== null - ? assertedExpression(initializerAssertion) - : declarator.init; - const evidence = knownValueEvidence(originalExpression, scopes, boundary, new Set([variable])); - return evidence === null ? null : { broadKind, evidence, declaredAt: declarator.end, boundary }; -} - -function assertionIsNarrower( - sourceText: string, - broadKind: BroadTypeKind, - evidence: KnownValueEvidence, - assertedType: ESTree.TSType, -): boolean { - if (broadTypeKind(assertedType) !== null) return false; - if (broadKind === "top") return true; - if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true; - if (broadKind === "object") return isDefinitelyObjectType(assertedType); - return isDefinitelyNarrowerRecordType(assertedType); -} - -/** Detect immutable local bindings that erase a known type and are later asserted back to a narrower type. */ -export const noWidenThenAssertRule = defineRule({ - meta: { - type: "problem", - docs: { - description: - "Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.", - }, - messages: { - widenThenAssert: - 'Binding "{{name}}" discards type evidence and later recreates it with an assertion. Keep the precise type from initialization through use; parse boundary input once.', - }, - }, - createOnce(context) { - let scopes: Parameters[0] = []; - - const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => { - const expression = assertedExpression(node); - if (expression.type !== "Identifier") return; - - const variable = resolvedVariableForIdentifier(scopes, expression); - if (variable === null) return; - const widened = widenedBinding(variable, scopes); - if ( - widened === null || - node.start <= widened.declaredAt || - functionBoundary(node) !== widened.boundary || - !assertionIsNarrower( - context.sourceCode.text, - widened.broadKind, - widened.evidence, - node.typeAnnotation, - ) - ) { - return; - } - - context.report({ - node, - messageId: "widenThenAssert", - data: { name: expression.name }, - }); - }; - - return { - Program() { - scopes = context.sourceCode.scopeManager.scopes; - }, - TSAsExpression: checkAssertion, - TSTypeAssertion: checkAssertion, - }; - }, -}); diff --git a/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts deleted file mode 100644 index f1a2ffcf9450..000000000000 --- a/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { defineRule } from "@oxlint/plugins"; - -import type { ESTree, SourceCode } from "@oxlint/plugins"; - -type TypeAssertion = ESTree.TSAsExpression | ESTree.TSTypeAssertion; - -const commentOwnerKinds = new Set([ - "ExpressionStatement", - "PropertyDefinition", - "ReturnStatement", - "ThrowStatement", - "VariableDeclaration", -]); - -function isConstAssertion(node: TypeAssertion): boolean { - return ( - node.typeAnnotation.type === "TSTypeReference" && - node.typeAnnotation.typeName.type === "Identifier" && - node.typeAnnotation.typeName.name === "const" - ); -} - -function hasSafetyComment(sourceCode: SourceCode, node: TypeAssertion): boolean { - let current: ESTree.Node = node; - while (true) { - if ( - sourceCode - .getCommentsBefore(current) - .some((comment) => comment.end <= node.start && /\bSAFETY\s*:/u.test(comment.value)) - ) { - return true; - } - if (commentOwnerKinds.has(current.type) || current.parent.type === "Program") return false; - current = current.parent; - } -} - -/** Require every non-const type assertion to state the invariant TypeScript cannot express. */ -export const requireSafetyCommentForTypeAssertionRule = defineRule({ - meta: { - type: "problem", - docs: { - description: - "Require a nearby SAFETY comment for every TypeScript type assertion except const assertions.", - }, - messages: { - missingSafetyComment: - "This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.", - }, - }, - createOnce(context) { - const checkAssertion = (node: TypeAssertion) => { - if (isConstAssertion(node) || hasSafetyComment(context.sourceCode, node)) return; - context.report({ node, messageId: "missingSafetyComment" }); - }; - - return { - TSAsExpression: checkAssertion, - TSTypeAssertion: checkAssertion, - }; - }, -}); diff --git a/tools/oxlint/anti-slop/shared/dictionary-types.ts b/tools/oxlint/anti-slop/shared/dictionary-types.ts deleted file mode 100644 index 865170047f33..000000000000 --- a/tools/oxlint/anti-slop/shared/dictionary-types.ts +++ /dev/null @@ -1,502 +0,0 @@ -import type { ESTree } from "@oxlint/plugins"; - -const BUILT_INS = new Set([ - "Record", - "Readonly", - "Partial", - "Required", - "Pick", - "Omit", - "PropertyKey", - "NonNullable", -]); -const TRANSPARENT_WRAPPERS = new Set(["Readonly", "Partial", "Required", "NonNullable"]); - -type TypeAliasEnvironment = ReadonlyMap; - -type ResolvedType = { - readonly type: ESTree.TSType; - readonly substitutions: TypeAliasEnvironment; -}; - -export type UnsafeDictionary = { - readonly kind: "unsafe-dictionary"; - readonly unsafeValue: "any" | "empty-object" | "object" | "union" | "unknown"; -}; - -export type WideningTargetKind = - | "anonymous object" - | "generic container" - | "object" - | "open dictionary" - | "unknown"; - -export type WideningTarget = { - readonly kind: WideningTargetKind; -}; - -export type TypeEnvironment = { - readonly aliases: ReadonlyMap; - readonly interfaces: ReadonlyMap; - readonly shadowedBuiltIns: ReadonlySet; -}; - -function declaredStatement(statement: ESTree.Statement): ESTree.Node | null { - return statement.type === "ExportNamedDeclaration" || - statement.type === "ExportDefaultDeclaration" - ? (statement.declaration ?? null) - : statement; -} - -export function createTypeEnvironment(program: ESTree.Program): TypeEnvironment { - const aliases = new Map(); - const interfaces = new Map(); - const shadowedBuiltIns = new Set(); - - for (const statement of program.body) { - const declaration = declaredStatement(statement); - if (declaration?.type === "ImportDeclaration") { - for (const specifier of declaration.specifiers) { - if (BUILT_INS.has(specifier.local.name)) shadowedBuiltIns.add(specifier.local.name); - } - continue; - } - - if (declaration?.type === "TSTypeAliasDeclaration") { - const existing = aliases.get(declaration.id.name); - if (existing === undefined) aliases.set(declaration.id.name, declaration); - else shadowedBuiltIns.add(declaration.id.name); - if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); - continue; - } - - if (declaration?.type === "TSInterfaceDeclaration") { - const declarations = interfaces.get(declaration.id.name) ?? []; - declarations.push(declaration); - interfaces.set(declaration.id.name, declarations); - if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); - continue; - } - - if (declaration?.type === "TSEnumDeclaration") { - if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); - continue; - } - - if ( - (declaration?.type === "ClassDeclaration" || - declaration?.type === "FunctionDeclaration") && - declaration.id !== null - ) { - if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); - } - } - - return { aliases, interfaces, shadowedBuiltIns }; -} - -function typeReferenceName(type: ESTree.TSTypeReference): string | null { - return type.typeName.type === "Identifier" ? type.typeName.name : null; -} - -function isBuiltIn(name: string, environment: TypeEnvironment): boolean { - return BUILT_INS.has(name) && !environment.shadowedBuiltIns.has(name); -} - -function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean { - const unwrapped = unwrapTransparentType(type); - return ( - unwrapped.type === "TSTypeReference" && - typeReferenceName(unwrapped) === name && - (unwrapped.typeArguments === null || - unwrapped.typeArguments === undefined || - unwrapped.typeArguments.params.length === 0) - ); -} - -function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType { - let current = type; - while ( - current.type === "TSParenthesizedType" || - (current.type === "TSTypeOperator" && current.operator === "readonly") - ) { - current = current.typeAnnotation; - } - return current; -} - -function isNeverType(type: ESTree.TSType): boolean { - return unwrapTransparentType(type).type === "TSNeverKeyword"; -} - -function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean { - return ( - member.type === "TSPropertySignature" && - member.optional === true && - member.typeAnnotation !== null && - member.typeAnnotation !== undefined && - isNeverType(member.typeAnnotation.typeAnnotation) - ); -} - -function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean { - return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember); -} - -function isEffectivelyEmptyInterface( - declarations: readonly ESTree.TSInterfaceDeclaration[], -): boolean { - if (declarations.length !== 1) return false; - const [type] = declarations; - return ( - type !== undefined && - type.extends.length === 0 && - (type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember)) - ); -} - -function resolvedSubstitutionArgument( - type: ESTree.TSType, - base: TypeAliasEnvironment, - resolving: ReadonlySet = new Set(), -): ESTree.TSType { - const unwrapped = unwrapTransparentType(type); - if (unwrapped.type !== "TSTypeReference") return type; - const name = typeReferenceName(unwrapped); - if (name === null || resolving.has(name)) return type; - const substitution = base.get(name); - if (substitution === undefined) return type; - const nextResolving = new Set(resolving); - nextResolving.add(name); - return resolvedSubstitutionArgument(substitution, base, nextResolving); -} - -function aliasSubstitution( - alias: ESTree.TSTypeAliasDeclaration, - type: ESTree.TSTypeReference, - base: TypeAliasEnvironment, -): TypeAliasEnvironment | null { - const parameters = alias.typeParameters?.params ?? []; - const arguments_ = type.typeArguments?.params ?? []; - const next = new Map(base); - for (const [index, parameter] of parameters.entries()) { - const argument = arguments_[index] ?? parameter.default; - if (argument === null || argument === undefined) return null; - next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next)); - } - return next; -} - -function unsafeDirectValue( - type: ESTree.TSType, - environment: TypeEnvironment, - substitutions: TypeAliasEnvironment, - resolvingAliases: ReadonlySet, -): UnsafeDictionary["unsafeValue"] | null { - const unwrapped = unwrapTransparentType(type); - if (unwrapped.type === "TSUnknownKeyword") return "unknown"; - if (unwrapped.type === "TSAnyKeyword") return "any"; - if (unwrapped.type === "TSObjectKeyword") return "object"; - if (unwrapped.type === "TSTypeLiteral" && isEffectivelyEmptyTypeLiteral(unwrapped)) - return "empty-object"; - if (unwrapped.type === "TSUnionType") { - return unwrapped.types.some( - (member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null, - ) - ? "union" - : null; - } - if (unwrapped.type === "TSIntersectionType") { - const unsafeMembers = unwrapped.types.map((member) => - unsafeDirectValue(member, environment, substitutions, resolvingAliases), - ); - if (unsafeMembers.includes("any")) return "any"; - return unsafeMembers.length > 0 && unsafeMembers.every((member) => member !== null) - ? unsafeMembers[0] - : null; - } - if (unwrapped.type !== "TSTypeReference") return null; - const name = typeReferenceName(unwrapped); - if (name === null) return null; - if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { - const wrapped = unwrapped.typeArguments?.params[0]; - return wrapped === undefined - ? null - : unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases); - } - const substitution = substitutions.get(name); - if (substitution !== undefined) { - return isUnappliedReferenceTo(substitution, name) - ? null - : unsafeDirectValue(substitution, environment, substitutions, resolvingAliases); - } - const interfaceDeclarations = environment.interfaces.get(name); - if (interfaceDeclarations !== undefined) { - return isEffectivelyEmptyInterface(interfaceDeclarations) ? "empty-object" : null; - } - const alias = environment.aliases.get(name); - if (alias === undefined || resolvingAliases.has(name)) return null; - const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); - if (nextSubstitutions === null) return null; - const nextResolving = new Set(resolvingAliases); - nextResolving.add(name); - return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); -} - -function dictionaryValueTypes( - type: ESTree.TSType, - environment: TypeEnvironment, - substitutions: TypeAliasEnvironment, - resolvingAliases: ReadonlySet, -): readonly ResolvedType[] { - const unwrapped = unwrapTransparentType(type); - - if (unwrapped.type === "TSTypeLiteral") { - return unwrapped.members.flatMap((member): readonly ResolvedType[] => - member.type === "TSIndexSignature" && member.typeAnnotation !== null - ? [{ type: member.typeAnnotation.typeAnnotation, substitutions }] - : [], - ); - } - - if (unwrapped.type === "TSMappedType") { - return unwrapped.typeAnnotation === null - ? [] - : [{ type: unwrapped.typeAnnotation, substitutions }]; - } - - if (unwrapped.type !== "TSTypeReference") return []; - const name = typeReferenceName(unwrapped); - if (name === null) return []; - - const substitution = substitutions.get(name); - if (substitution !== undefined) { - return isUnappliedReferenceTo(substitution, name) - ? [] - : dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases); - } - - if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { - const wrapped = unwrapped.typeArguments?.params[0]; - return wrapped === undefined - ? [] - : dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases); - } - - if (name === "Record" && isBuiltIn(name, environment)) { - const value = unwrapped.typeArguments?.params[1] ?? null; - return value === null ? [] : [{ type: value, substitutions }]; - } - - if ((name === "Pick" || name === "Omit") && isBuiltIn(name, environment)) { - const source = unwrapped.typeArguments?.params[0]; - return source === undefined - ? [] - : dictionaryValueTypes(source, environment, substitutions, resolvingAliases); - } - - const alias = environment.aliases.get(name); - if (alias === undefined || resolvingAliases.has(name)) return []; - const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); - if (nextSubstitutions === null) return []; - const nextResolving = new Set(resolvingAliases); - nextResolving.add(name); - return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); -} - -export function classifyUnsafeDictionaryValue( - valueType: ESTree.TSType, - environment: TypeEnvironment, -): UnsafeDictionary | null { - const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set()); - return unsafeValue === null ? null : { kind: "unsafe-dictionary", unsafeValue }; -} - -export function classifyUnsafeDictionary( - type: ESTree.TSType, - environment: TypeEnvironment, -): UnsafeDictionary | null { - for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) { - const unsafeValue = unsafeDirectValue( - valueType.type, - environment, - valueType.substitutions, - new Set(), - ); - if (unsafeValue !== null) return { kind: "unsafe-dictionary", unsafeValue }; - } - return null; -} - -function resolvesToDictionary( - type: ESTree.TSType, - environment: TypeEnvironment, - substitutions: TypeAliasEnvironment, - resolvingAliases: ReadonlySet, -): boolean { - return dictionaryValueTypes(type, environment, substitutions, resolvingAliases).length > 0; -} - -export function classifyWideningTarget( - type: ESTree.TSType, - environment: TypeEnvironment, -): WideningTarget | null { - const unwrapped = unwrapTransparentType(type); - if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; - if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; - if (unwrapped.type === "TSTypeLiteral") { - return unwrapped.members.some((member) => member.type === "TSIndexSignature") - ? { kind: "open dictionary" } - : unwrapped.members.length > 0 - ? { kind: "anonymous object" } - : null; - } - if (unwrapped.type === "TSMappedType") return { kind: "open dictionary" }; - if (unwrapped.type !== "TSTypeReference") return null; - const name = typeReferenceName(unwrapped); - if (name === null) return null; - if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { - const wrapped = unwrapped.typeArguments?.params[0]; - return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment); - } - if (name === "Record" && isBuiltIn(name, environment)) return { kind: "open dictionary" }; - const alias = environment.aliases.get(name); - if (alias === undefined) return null; - if ((alias.typeParameters?.params.length ?? 0) > 0) { - const substitutions = aliasSubstitution(alias, unwrapped, new Map()); - return substitutions !== null && - resolvesToDictionary(alias.typeAnnotation, environment, substitutions, new Set([name])) - ? { kind: "generic container" } - : null; - } - const substitutions = aliasSubstitution(alias, unwrapped, new Map()); - if (substitutions === null) return null; - const resolved = classifyAliasBroadTarget( - alias.typeAnnotation, - environment, - substitutions, - new Set([name]), - ); - return resolved; -} - -function isBroadMappedKey( - type: ESTree.TSType, - environment: TypeEnvironment, - substitutions: TypeAliasEnvironment, -): boolean { - const unwrapped = unwrapTransparentType(type); - if ( - unwrapped.type === "TSStringKeyword" || - unwrapped.type === "TSNumberKeyword" || - unwrapped.type === "TSSymbolKeyword" - ) { - return true; - } - if (unwrapped.type === "TSUnionType") { - return unwrapped.types.every((member) => - isBroadMappedKey(member, environment, substitutions), - ); - } - if (unwrapped.type !== "TSTypeReference") return false; - const name = typeReferenceName(unwrapped); - if (name === null) return false; - const substitution = substitutions.get(name); - if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) { - return isBroadMappedKey(substitution, environment, substitutions); - } - return name === "PropertyKey" && isBuiltIn(name, environment); -} - -function classifyAliasBroadTarget( - type: ESTree.TSType, - environment: TypeEnvironment, - substitutions: TypeAliasEnvironment, - resolvingAliases: ReadonlySet, -): WideningTarget | null { - const unwrapped = unwrapTransparentType(type); - if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; - if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; - if (unwrapped.type === "TSTypeLiteral") { - return unwrapped.members.some((member) => member.type === "TSIndexSignature") - ? { kind: "open dictionary" } - : null; - } - if (unwrapped.type === "TSMappedType") { - return isBroadMappedKey(unwrapped.constraint, environment, substitutions) - ? { kind: "open dictionary" } - : null; - } - if (unwrapped.type !== "TSTypeReference") return null; - const name = typeReferenceName(unwrapped); - if (name === null) return null; - const substitution = substitutions.get(name); - if (substitution !== undefined) { - return isUnappliedReferenceTo(substitution, name) - ? null - : classifyAliasBroadTarget( - substitution, - environment, - substitutions, - resolvingAliases, - ); - } - if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { - const wrapped = unwrapped.typeArguments?.params[0]; - return wrapped === undefined - ? null - : classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases); - } - if (name === "Record" && isBuiltIn(name, environment)) { - return { kind: "open dictionary" }; - } - const alias = environment.aliases.get(name); - if (alias === undefined || resolvingAliases.has(name)) return null; - const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); - if (nextSubstitutions === null) return null; - const nextResolving = new Set(resolvingAliases); - nextResolving.add(name); - return classifyAliasBroadTarget( - alias.typeAnnotation, - environment, - nextSubstitutions, - nextResolving, - ); -} - -export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean { - let current = expression; - while ( - current.type === "ParenthesizedExpression" || - current.type === "TSAsExpression" || - current.type === "TSTypeAssertion" || - current.type === "TSNonNullExpression" - ) { - current = current.expression; - } - return current.type === "ObjectExpression" && current.properties.length > 0; -} - -export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean { - let current = expression; - while ( - current.type === "ParenthesizedExpression" || - current.type === "TSAsExpression" || - current.type === "TSTypeAssertion" || - current.type === "TSNonNullExpression" || - current.type === "TSSatisfiesExpression" - ) { - current = current.expression; - } - if (current.type === "ObjectExpression") return true; - return ( - current.type === "ArrayExpression" || - current.type === "ArrowFunctionExpression" || - current.type === "ClassExpression" || - current.type === "FunctionExpression" || - current.type === "NewExpression" || - current.type === "Literal" || - current.type === "TemplateLiteral" || - current.type === "UnaryExpression" - ); -} diff --git a/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts deleted file mode 100644 index 7cdb18c911c9..000000000000 --- a/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { ESTree } from "@oxlint/plugins"; - -type VisitorKeys = Readonly>; - -function isNode(value: unknown): value is ESTree.Node { - return ( - typeof value === "object" && - value !== null && - "type" in value && - typeof value.type === "string" - ); -} - -function collectInferTypeParameterNames( - node: ESTree.Node, - visitorKeys: VisitorKeys, - names: Set, -): void { - if (node.type === "TSInferType") names.add(node.typeParameter.name.name); - const record = node as unknown as Readonly>; - for (const key of visitorKeys[node.type] ?? []) { - const value = record[key]; - if (isNode(value)) { - collectInferTypeParameterNames(value, visitorKeys, names); - continue; - } - if (!Array.isArray(value)) continue; - for (const child of value) { - if (isNode(child)) collectInferTypeParameterNames(child, visitorKeys, names); - } - } -} - -/** Collect type binders that are in scope at a node and can shadow module aliases. */ -export function lexicalTypeParameterNames( - node: ESTree.Node, - visitorKeys: VisitorKeys, -): ReadonlySet { - const names = new Set(); - let descendant: ESTree.Node = node; - let current: ESTree.Node | null = node; - while (current !== null && current.type !== "Program") { - if ("typeParameters" in current) { - for (const parameter of current.typeParameters?.params ?? []) { - names.add(parameter.name.name); - } - } - if ( - current.type === "TSMappedType" && - (descendant === current.nameType || descendant === current.typeAnnotation) - ) { - names.add(current.key.name); - } - if (current.type === "TSConditionalType" && descendant === current.trueType) { - collectInferTypeParameterNames(current.extendsType, visitorKeys, names); - } - descendant = current; - current = current.parent; - } - return names; -} diff --git a/tools/oxlint/anti-slop/shared/reflect-method.ts b/tools/oxlint/anti-slop/shared/reflect-method.ts deleted file mode 100644 index 39bc218c3bbf..000000000000 --- a/tools/oxlint/anti-slop/shared/reflect-method.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; - -function resolveVariable( - sourceCode: SourceCode, - identifier: ESTree.IdentifierReference, -): Variable | null { - let scope: Scope | null = sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(identifier.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - -function isGlobalReflect(sourceCode: SourceCode, expression: ESTree.Expression): boolean { - if (expression.type !== "Identifier" || expression.name !== "Reflect") return false; - if (sourceCode.isGlobalReference(expression)) return true; - const variable = resolveVariable(sourceCode, expression); - return variable === null || variable.defs.length === 0; -} - -/** Reports whether a call target names one method on the global Reflect object. */ -export function isGlobalReflectMethodCall( - sourceCode: SourceCode, - callee: ESTree.Expression, - methodName: string, -): boolean { - if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false; - if (!isGlobalReflect(sourceCode, callee.object)) return false; - const property = callee.property; - return callee.computed - ? property.type === "Literal" && property.value === methodName - : property.type === "Identifier" && property.name === methodName; -} diff --git a/vite.config.ts b/vite.config.ts index 848a5602aab9..258555e04b5a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -31,19 +31,6 @@ export default defineConfig({ }, fmt: { ignorePatterns: [ - ".agent/**", - ".agents/**", - ".claude/**", - ".codex/**", - ".continue/**", - ".cursor/**", - ".gemini/**", - ".opencode/**", - ".pi/**", - ".roo/**", - ".windsurf/**", - "tools/oxlint/anti-slop/**", - ".repos/**", ".alchemy", "dist", @@ -69,19 +56,6 @@ export default defineConfig({ }, lint: { ignorePatterns: [ - ".agent/**", - ".agents/**", - ".claude/**", - ".codex/**", - ".continue/**", - ".cursor/**", - ".gemini/**", - ".opencode/**", - ".pi/**", - ".roo/**", - ".windsurf/**", - "tools/oxlint/anti-slop/**", - ".repos", ".repos/**", "dist", @@ -95,33 +69,13 @@ export default defineConfig({ "apps/mobile/uniwind-types.d.ts", ], plugins: ["eslint", "oxc", "react", "unicorn", "typescript"], - jsPlugins: [ - "./oxlint-plugin-t3code/index.ts", - { name: "anti-slop", specifier: "./tools/oxlint/anti-slop/index.ts" }, - { name: "anti-slop-effect", specifier: "./tools/oxlint/anti-slop/effect/index.ts" }, - ], + jsPlugins: ["./oxlint-plugin-t3code/index.ts"], categories: { correctness: "warn", suspicious: "warn", perf: "warn", }, rules: { - "anti-slop/no-chained-type-assertions": "error", - "anti-slop/no-conditional-empty-object-spread": "error", - "anti-slop/no-known-value-widening": "error", - "anti-slop/no-module-mocking": "error", - "anti-slop/no-object-parameters": "error", - "anti-slop/no-reflect-apply": "error", - "anti-slop/no-reflect-get": "error", - "anti-slop/no-runtime-typeof": "error", - "anti-slop/no-unsafe-dictionary-type": "error", - "anti-slop/no-shape-in-symbol-names": "error", - "anti-slop/no-unknown-parameters": "error", - "anti-slop/no-unknown-returns": "error", - "anti-slop/no-unknown-type-aliases": "error", - "anti-slop/no-widen-then-assert": "error", - "anti-slop/require-safety-comment-for-type-assertion": "error", - "anti-slop-effect/no-service-constructor-imports": "error", "unicorn/no-array-sort": "off", "unicorn/consistent-function-scoping": "off", "oxc/no-map-spread": "off", From d0766ae8f67522824f30cbee2a5911792f41f294 Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Sat, 12 Sep 2026 15:25:33 -0400 Subject: [PATCH 07/15] test(devin): remove redundant coverage and reuse existing suites --- apps/mobile/src/lib/modelOptions.test.ts | 47 +--- .../src/provider/Drivers/DevinDriver.test.ts | 207 ++++++++---------- .../src/provider/Layers/DevinAdapter.test.ts | 13 +- .../src/provider/Layers/DevinProvider.test.ts | 10 - .../provider/Layers/ProviderRegistry.test.ts | 15 +- .../src/provider/acp/DevinModels.test.ts | 42 +--- .../server/src/provider/testUtils/devinCli.ts | 3 +- apps/web/src/modelSelection.test.ts | 51 +---- 8 files changed, 114 insertions(+), 274 deletions(-) diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 01e62fe16cbd..1d47bb32029a 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -198,7 +198,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", @@ -227,7 +228,7 @@ describe("mobile model options", () => { providers: [ { instanceId: selection.instanceId, - driver: "antigravity", + driver, displayName: "Google Work", enabled: true, installed: true, @@ -259,7 +260,7 @@ describe("mobile model options", () => { subtitle: "Google", providerKey: "google_work", providerLabel: "Google Work", - providerDriver: "antigravity", + providerDriver: driver, isDefault: false, isLegacy: true, isUnavailable: true, @@ -284,7 +285,7 @@ describe("mobile model options", () => { expect(missing).toMatchObject({ label: selection.model, providerLabel: "Google Work", - providerDriver: "antigravity", + providerDriver: driver, isUnavailable: true, capabilities: null, }); @@ -311,7 +312,7 @@ describe("mobile model options", () => { providers: [], settings: { providerInstances: { - [selection.instanceId]: { driver: "antigravity", displayName: "Google Work" }, + [selection.instanceId]: { driver, displayName: "Google Work" }, }, }, } as unknown as ServerConfig; @@ -319,11 +320,11 @@ describe("mobile model options", () => { expect(resolveDefaultableModelSelection(missingStatusConfig, selection)).toBe(selection); expect(isModelSelectionUnavailable(missingStatusConfig, selection)).toBe(true); expect(getModelSelectionUnavailableReason(missingStatusConfig, selection)).toBe( - "Antigravity model unavailable. Set up Antigravity on web or desktop, or choose another model.", + `${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, @@ -331,7 +332,7 @@ 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); @@ -414,33 +415,3 @@ describe("mobile model options", () => { ).toBeNull(); }); }); - -describe("Devin account model selection", () => { - it("preserves SWE-2 and marks it unavailable after an account catalog change", () => { - const selection = { instanceId: ProviderInstanceId.make("devin_work"), model: "swe-2-high" }; - const config = { - providers: [ - { - instanceId: selection.instanceId, - driver: "devin", - displayName: "Devin Work", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - models: [{ slug: "swe-1.6", name: "SWE-1.6", capabilities: {} }], - }, - ], - } as unknown as ServerConfig; - expect(resolveSelectableModelSelection(config, selection)).toBe(selection); - expect(resolveDefaultableModelSelection(config, selection)).toBe(selection); - expect(isModelSelectionUnavailable(config, selection)).toBe(true); - expect(getModelSelectionUnavailableReason(config, selection)).toBe( - "Devin model unavailable. Set up Devin on web or desktop, or choose another model.", - ); - expect( - buildModelOptions(config, selection).find( - (option) => option.selection.model === selection.model, - ), - ).toMatchObject({ selection, isUnavailable: true, providerDriver: "devin" }); - }); -}); diff --git a/apps/server/src/provider/Drivers/DevinDriver.test.ts b/apps/server/src/provider/Drivers/DevinDriver.test.ts index 0682d745811a..3d9db9d78d0c 100644 --- a/apps/server/src/provider/Drivers/DevinDriver.test.ts +++ b/apps/server/src/provider/Drivers/DevinDriver.test.ts @@ -8,13 +8,11 @@ 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 { mergeProviderSnapshot } from "../Layers/ProviderRegistry.ts"; import { DevinDriver } from "./DevinDriver.ts"; import { MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER } from "../ProviderDriver.ts"; import { makeDevinCli as makeHarness, devinTestLayer as layer, - encodeDevinProviders as encodeProviders, encodeDevinSkills, } from "../testUtils/devinCli.ts"; const threadId = ThreadId.make("devin-thread"); @@ -93,120 +91,93 @@ it.effect("keeps skills discoverable when ACP commands arrive before the workspa }).pipe(Effect.provide(driverLayer)), ); -it.effect( - "replaces cached Devin variants with family models and clears account 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_AUTH_STATUS_FILE: statusFile, - 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: [], - }); - if (!instance.snapshotForCwd) throw new Error("Devin must expose workspace metadata."); - const snapshot = yield* instance.snapshot.refresh; - expect(() => encodeProviders([snapshot])).not.toThrow(); - expect(snapshot.instanceId).toBe(instanceId); - expect(snapshot.supportsTextGeneration).toBe(false); - const skillsFile = path.join(h.root, "devin-test-skills.json"); - yield* fs.writeFileString( - skillsFile, - encodeDevinSkills([ - { - name: "visual-check", - description: "Check the browser.", - display_name: "Visual check", - base_dir: path.join(h.root, ".devin", "skills", "visual-check"), - triggers: ["user"], - errors: [], - }, - ]), - ); - const workspace = yield* instance.snapshotForCwd(h.root); - expect(workspace.skills.map((skill) => skill.name)).toEqual(["visual-check"]); - expect(yield* fs.exists(h.launchLog)).toBe(false); - const cached = { - ...snapshot, - models: snapshot.models.map((model) => ({ ...model, slug: `${model.slug}-old` })), - }; - expect(mergeProviderSnapshot(cached, snapshot).models).toEqual(snapshot.models); - const withoutTraits = { - ...snapshot, - models: snapshot.models.map((model) => ({ - ...model, - capabilities: { optionDescriptors: [] }, - })), - }; - expect(mergeProviderSnapshot(snapshot, withoutTraits).models).toEqual(withoutTraits.models); - const title = yield* instance.textGeneration - .generateThreadTitle({ - cwd: h.root, - message: "Title", - modelSelection: { instanceId, model: "devin-test" }, - }) - .pipe(Effect.result); - expect(title._tag).toBe("Failure"); - 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(() => encodeProviders([activeSnapshot])).not.toThrow(); - 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.models).toEqual(snapshot.models); - 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); - yield* fs.writeFileString(statusFile, "Not logged in."); - yield* instance.snapshot.refresh; - const signedOut = yield* instance.snapshotForCwd(h.root); - expect(signedOut.auth.status).toBe("unauthenticated"); - expect(mergeProviderSnapshot(snapshot, signedOut).models).toEqual(signedOut.models); - 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 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_AUTH_STATUS_FILE: statusFile, + 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: [], + }); + 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, + encodeDevinSkills([ + { + name: "visual-check", + description: "Check the browser.", + display_name: "Visual check", + base_dir: path.join(h.root, ".devin", "skills", "visual-check"), + triggers: ["user"], + errors: [], + }, + ]), + ); + const workspace = yield* instance.snapshotForCwd(h.root); + expect(workspace.skills.map((skill) => skill.name)).toEqual(["visual-check"]); + 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.models).toEqual(snapshot.models); + 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); + yield* fs.writeFileString(statusFile, "Not logged in."); + 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)), ); diff --git a/apps/server/src/provider/Layers/DevinAdapter.test.ts b/apps/server/src/provider/Layers/DevinAdapter.test.ts index 9470a49a59f1..d114d774a529 100644 --- a/apps/server/src/provider/Layers/DevinAdapter.test.ts +++ b/apps/server/src/provider/Layers/DevinAdapter.test.ts @@ -195,20 +195,11 @@ it.effect("dispatches a graphical skill pick as a native command with its argume ]), ); yield* h.start(); - for (const [input, text] of [ - ["$visual-check http://localhost:5173", "/visual-check http://localhost:5173"], - ["$visual-check", "/visual-check"], - ["/visual-check", "/visual-check"], - ]) { - yield* h.adapter.sendTurn({ threadId, input }); - const request = (yield* h.requests).findLast((entry) => entry.method === "session/prompt"); - expect(request?.params?.prompt).toEqual([{ type: "text", text }]); - } const config = yield* ServerConfig; const attachmentId = "devin-thread-11111111-1111-4111-8111-111111111111"; yield* h.adapter.sendTurn({ threadId, - input: "$visual-check", + input: "$visual-check inspect this", attachments: [ { type: "file", id: attachmentId, name: "page.txt", mimeType: "text/plain", sizeBytes: 1 }, ], @@ -217,7 +208,7 @@ it.effect("dispatches a graphical skill pick as a native command with its argume expect(withFile?.params?.prompt).toEqual([ { type: "text", - text: `/visual-check Attached file: ${path.join(config.attachmentsDir, `${attachmentId}.txt`)}`, + text: `/visual-check inspect this Attached file: ${path.join(config.attachmentsDir, `${attachmentId}.txt`)}`, }, ]); expect( diff --git a/apps/server/src/provider/Layers/DevinProvider.test.ts b/apps/server/src/provider/Layers/DevinProvider.test.ts index 67f33e24a528..b1f9adcdf06c 100644 --- a/apps/server/src/provider/Layers/DevinProvider.test.ts +++ b/apps/server/src/provider/Layers/DevinProvider.test.ts @@ -4,16 +4,6 @@ import * as FileSystem from "effect/FileSystem"; import { checkDevinProviderStatus } from "./DevinProvider.ts"; import { makeDevinCli as makeHarness, devinTestLayer as layer } from "../testUtils/devinCli.ts"; -it.effect("reports signed-out status without creating an ACP session", () => - Effect.gen(function* () { - const h = yield* makeHarness(); - const snapshot = yield* checkDevinProviderStatus(h.settings, h.environment); - expect(snapshot.auth.status).toBe("unauthenticated"); - const fs = yield* FileSystem.FileSystem; - expect(yield* fs.exists(h.requestLog)).toBe(false); - }).pipe(Effect.provide(layer)), -); - it.effect("uses the supplied environment to check sign-in and discover models before a chat", () => Effect.gen(function* () { const h = yield* makeHarness(); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 5a3980ae0869..7a783c11dead 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1066,10 +1066,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); }); - describe("Antigravity model inventories", () => { + describe.each(["antigravity", "devin"])("%s model inventories", (driver) => { const previousProvider = { instanceId: ProviderInstanceId.make("antigravity-personal"), - driver: ProviderDriverKind.make("antigravity"), + driver: ProviderDriverKind.make(driver), status: "ready", enabled: true, installed: true, @@ -1120,7 +1120,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te for (const installed of [false, true]) { const pendingProvider = { ...previousProvider, - status: "warning", + status: driver === "devin" && installed ? "error" : "warning", installed, auth: { status: "unknown" }, checkedAt: "2026-09-02T00:01:00.000Z", @@ -1286,7 +1286,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 +1333,13 @@ 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, + ); }); it.effect("does not run provider probes during layer construction", () => diff --git a/apps/server/src/provider/acp/DevinModels.test.ts b/apps/server/src/provider/acp/DevinModels.test.ts index 7f75eddbd68a..cd7ca4302200 100644 --- a/apps/server/src/provider/acp/DevinModels.test.ts +++ b/apps/server/src/provider/acp/DevinModels.test.ts @@ -98,11 +98,6 @@ it.each([ suffixes: ["High", "Low", "Minimal", "Medium"], expected: ["Minimal", "Low", "Medium", "High"], }, - { - family: "GLM-5.2", - suffixes: ["High", "Max", "No Thinking"], - expected: ["None", "High", "Max"], - }, { family: "Inkling", suffixes: ["X-High", "Max", "None", "Medium", "High", "Low"], @@ -180,18 +175,9 @@ it("exposes every catalog variant through independent thinking, speed, and conte .toSorted(), ); } - expect( - resolveDevinModel(catalog, { - model: "claude-opus-5", - options: [ - { id: "reasoningEffort", value: "high" }, - { id: "fastMode", value: true }, - ], - }), - ).toBe("claude-opus-5-high-fast"); }); -it("rejects unavailable combinations and preserves exact custom IDs and unfamiliar variants", () => { +it("rejects unavailable combinations and preserves exact native and custom IDs", () => { expect( resolveDevinModel(catalog, { model: "swe-2", @@ -203,17 +189,6 @@ it("rejects unavailable combinations and preserves exact custom IDs and unfamili ).toBeUndefined(); expect(resolveDevinModel(catalog, { model: "swe-2-medium" })).toBe("swe-2-medium"); expect(resolveDevinModel(catalog, { model: "custom-model" })).toBe("custom-model"); - expect( - devinModels({ - families: [ - { - slug: "future", - family_label: "Future", - variants: [{ model_uid: "future-special", label: "Future Special" }], - }, - ], - }).map((model) => model.slug), - ).toEqual(["future-special"]); }); it.each( @@ -300,27 +275,12 @@ it("groups Fusion by lead and sidekick and resolves every thinking and speed com lead: { id: "claude-opus-5", name: "Claude Opus 5" }, sidekick: { id: "swe-2-high", name: "SWE-2 High" }, }); - expect(first.capabilities?.optionDescriptors).toEqual([ - { - id: "reasoningEffort", - label: "Thinking level", - type: "select", - currentValue: "medium", - options: [ - { id: "medium", label: "Medium" }, - { id: "high", label: "High" }, - ], - }, - { id: "fastMode", label: "Fast mode", type: "boolean", currentValue: false }, - ]); expect( resolveDevinModel(fusionCatalog, { model: first.slug, options: [{ id: "reasoningEffort", value: "max" }], }), ).toBeUndefined(); - for (const native of fusionCatalog.families.at(-1)!.variants) - expect(resolveDevinModel(fusionCatalog, { model: native.model_uid })).toBe(native.model_uid); }); it("preserves unfamiliar Fusion pairings as exact model choices", () => { diff --git a/apps/server/src/provider/testUtils/devinCli.ts b/apps/server/src/provider/testUtils/devinCli.ts index 400a6cff263d..e8d51948aabf 100644 --- a/apps/server/src/provider/testUtils/devinCli.ts +++ b/apps/server/src/provider/testUtils/devinCli.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { DevinSettings, ServerProviders } from "@t3tools/contracts"; +import { DevinSettings } from "@t3tools/contracts"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -12,7 +12,6 @@ import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; const encodeString = Schema.encodeSync(Schema.fromJsonString(Schema.String)); export const decodeDevinSettings = Schema.decodeSync(DevinSettings); -export const encodeDevinProviders = Schema.encodeSync(Schema.toCodecJson(ServerProviders)); export const encodeDevinSkills = Schema.encodeSync(DevinSkillCatalog); const Request = Schema.Struct({ result: Schema.optional( diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 9f5ce7c11d60..0ecdae41e219 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -350,6 +350,7 @@ describe("instance-scoped model selection", () => { availableModel: "gemini-3.1-pro", missingModel: "gemini-3.1-pro-high", }, + { driverName: "devin", availableModel: "swe-2-medium", missingModel: "swe-2-high" }, ])("$driverName catalog gaps", ({ driverName, availableModel, missingModel }) => { it("preserves a selected model when a catalog refresh no longer contains it", () => { const providers = [ @@ -914,53 +915,3 @@ describe("resolvePlanAgentHealPatch", () => { ).toEqual({ sourceControlWriterModelSelection: healed }); }); }); - -describe("Devin account model selection", () => { - const driver = ProviderDriverKind.make("devin"); - const instanceId = ProviderInstanceId.make("devin_work"); - const selection = createModelSelection(instanceId, "swe-2-high"); - - it("keeps the thread model unavailable until the account catalog restores it", () => { - for (const models of [[], ["swe-1.6"], ["swe-2-high"]]) { - const providers = [provider({ provider: driver, instanceId, models })]; - const state = deriveEffectiveComposerModelState({ - draft: null, - providers, - selectedProvider: driver, - selectedInstanceId: instanceId, - threadModelSelection: selection, - projectModelSelection: null, - settings: DEFAULT_UNIFIED_SETTINGS, - }); - expect(state.selectedModel).toBe(selection.model); - const options = getCustomModelOptionsByInstance( - DEFAULT_UNIFIED_SETTINGS, - providers, - instanceId, - state.selectedModel, - ).get(instanceId); - expect( - options?.find((option) => option.slug === selection.model)?.isUnavailable === true, - ).toBe(!models.includes(selection.model)); - } - }); - - it("excludes Devin from background generation even when its account has models", () => { - const providers = [ - { ...provider({ instanceId: "codex", models: ["gpt-5.6-luna"] }), enabled: false }, - { - ...provider({ provider: driver, instanceId, models: [selection.model] }), - supportsTextGeneration: false, - }, - ]; - expect(resolveAppModelSelectionState(DEFAULT_UNIFIED_SETTINGS, providers)).toEqual( - NO_PROVIDER_MODEL_SELECTION, - ); - expect( - resolveAppModelSelectionState( - { ...DEFAULT_UNIFIED_SETTINGS, textGenerationModelSelection: selection }, - providers, - ), - ).toEqual(NO_PROVIDER_MODEL_SELECTION); - }); -}); From d968c4ed314bf6d040c7d17f63445b6a47bfe826 Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Sat, 12 Sep 2026 15:33:00 -0400 Subject: [PATCH 08/15] test(devin): consolidate provider coverage around driver and adapter flows --- .../src/provider/Drivers/DevinDriver.test.ts | 66 ++- .../src/provider/Drivers/DevinSkills.test.ts | 100 ----- .../src/provider/Layers/DevinAdapter.test.ts | 126 +++--- .../src/provider/Layers/DevinProvider.test.ts | 44 -- .../src/provider/acp/DevinAcpSupport.test.ts | 36 +- .../src/provider/acp/DevinModels.test.ts | 385 ++++++------------ .../server/src/provider/testUtils/devinCli.ts | 49 ++- 7 files changed, 270 insertions(+), 536 deletions(-) delete mode 100644 apps/server/src/provider/Drivers/DevinSkills.test.ts delete mode 100644 apps/server/src/provider/Layers/DevinProvider.test.ts diff --git a/apps/server/src/provider/Drivers/DevinDriver.test.ts b/apps/server/src/provider/Drivers/DevinDriver.test.ts index 3d9db9d78d0c..42353bf392f8 100644 --- a/apps/server/src/provider/Drivers/DevinDriver.test.ts +++ b/apps/server/src/provider/Drivers/DevinDriver.test.ts @@ -13,7 +13,7 @@ import { MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER } from "../ProviderDriver.ts"; import { makeDevinCli as makeHarness, devinTestLayer as layer, - encodeDevinSkills, + devinTestSkills, } from "../testUtils/devinCli.ts"; const threadId = ThreadId.make("devin-thread"); const instanceId = ProviderInstanceId.make("devin-account"); @@ -43,6 +43,7 @@ it.effect("bounds workspace metadata and evicts commands with the oldest skills" 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); @@ -71,26 +72,6 @@ it.effect("bounds workspace metadata and evicts commands with the oldest skills" }).pipe(Effect.provide(driverLayer)), ); -it.effect("keeps skills discoverable when ACP commands arrive before the workspace probe", () => - Effect.gen(function* () { - const h = yield* makeHarness({ T3_DEVIN_AUTH_STATUS: "Logged in (via Devin)." }); - const instance = yield* DevinDriver.create({ - instanceId, - displayName: "Devin test account", - enabled: true, - config: h.settings, - environment: [], - }); - yield* instance.snapshot.refresh; - yield* instance.adapter.startSession({ threadId, cwd: h.root, runtimeMode: "full-access" }); - expect((yield* instance.snapshot.getSnapshot).workspaceSnapshots).toEqual([]); - if (!instance.snapshotForCwd) throw new Error("Devin must expose workspace metadata."); - const discovered = yield* instance.snapshotForCwd(h.root); - expect(discovered.slashCommands.some((command) => command.name === "plan")).toBe(true); - expect(discovered.workspaceSnapshots?.map((workspace) => workspace.cwd)).toEqual([h.root]); - }).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; @@ -105,7 +86,6 @@ it.effect("refreshes account models and workspace skills and clears metadata aft const h = yield* makeHarness({ T3_ACP_DEVIN: "1", T3_ACP_DEVIN_STALE_MODELS: "1", - T3_DEVIN_AUTH_STATUS_FILE: statusFile, T3_DEVIN_MODELS_FILE: modelsFile, }); const instance = yield* DevinDriver.create({ @@ -113,27 +93,33 @@ it.effect("refreshes account models and workspace skills and clears metadata aft displayName: "Devin test account", enabled: true, config: { ...h.settings, customModels: ["custom-devin-model"] }, - environment: [], + 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, - encodeDevinSkills([ - { - name: "visual-check", - description: "Check the browser.", - display_name: "Visual check", - base_dir: path.join(h.root, ".devin", "skills", "visual-check"), - triggers: ["user"], - errors: [], - }, - ]), - ); + yield* fs.writeFileString(skillsFile, devinTestSkills); const workspace = yield* instance.snapshotForCwd(h.root); - expect(workspace.skills.map((skill) => skill.name)).toEqual(["visual-check"]); + 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", @@ -160,7 +146,13 @@ it.effect("refreshes account models and workspace skills and clears metadata aft 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) diff --git a/apps/server/src/provider/Drivers/DevinSkills.test.ts b/apps/server/src/provider/Drivers/DevinSkills.test.ts deleted file mode 100644 index 47f0b8d761ab..000000000000 --- a/apps/server/src/provider/Drivers/DevinSkills.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import { discoverDevinSkills, prepareDevinSkillPrompt } from "./DevinSkills.ts"; -import { makeDevinCli, devinTestLayer, encodeDevinSkills } from "../testUtils/devinCli.ts"; - -const skill = (name: string, triggers = ["user", "model"]) => ({ - name, - description: `Use ${name}.`, - base_dir: `/skills/${name}`, - display_name: name, - triggers, - errors: [], -}); - -it.effect( - "uses the CLI catalog for each workspace, including invocation policy and display names", - () => - Effect.gen(function* () { - const h = yield* makeDevinCli(); - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.writeFileString( - path.join(h.root, "devin-test-skills.json"), - encodeDevinSkills([ - { ...skill("visual-check", ["user"]), display_name: "Visual check" }, - skill("internal", ["model"]), - { ...skill("broken"), errors: ["Invalid frontmatter"] }, - { ...skill("builtin-command"), base_dir: "" }, - ]), - ); - const skills = yield* discoverDevinSkills(h.settings, h.environment, h.root); - expect(skills).toEqual([ - expect.objectContaining({ name: "broken", enabled: false }), - expect.objectContaining({ name: "internal", enabled: true, 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-project-" }); - expect(yield* discoverDevinSkills(h.settings, h.environment, other)).toEqual([]); - }).pipe(Effect.provide(devinTestLayer)), -); - -it.effect( - "invokes a picked user-only skill while retaining arguments and literal dollar tokens", - () => - Effect.gen(function* () { - const h = yield* makeDevinCli(); - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.writeFileString( - path.join(h.root, "devin-test-skills.json"), - encodeDevinSkills([skill("visual-check", ["user"]), skill("internal", ["model"])]), - ); - for (const [input, expected] 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"], - ] as const) { - expect(yield* prepareDevinSkillPrompt(input, h.settings, h.environment, h.root)).toBe( - expected, - ); - } - const multiple = yield* prepareDevinSkillPrompt( - "$visual-check $visual-check", - h.settings, - h.environment, - h.root, - ).pipe(Effect.result); - expect(multiple._tag).toBe("Failure"); - }).pipe(Effect.provide(devinTestLayer)), -); - -it.effect("surfaces failed discovery so it can be retried, without probing ordinary prompts", () => - Effect.gen(function* () { - const h = yield* makeDevinCli({ T3_DEVIN_FAIL_SKILLS: "1" }); - expect(yield* prepareDevinSkillPrompt("Say hello", h.settings, h.environment, h.root)).toBe( - "Say hello", - ); - expect(yield* prepareDevinSkillPrompt("Print $HOME", h.settings, h.environment, h.root)).toBe( - "Print $HOME", - ); - const failed = yield* discoverDevinSkills(h.settings, h.environment, h.root).pipe( - Effect.result, - ); - expect(failed._tag).toBe("Failure"); - }).pipe(Effect.provide(devinTestLayer)), -); diff --git a/apps/server/src/provider/Layers/DevinAdapter.test.ts b/apps/server/src/provider/Layers/DevinAdapter.test.ts index d114d774a529..687cdf1537f9 100644 --- a/apps/server/src/provider/Layers/DevinAdapter.test.ts +++ b/apps/server/src/provider/Layers/DevinAdapter.test.ts @@ -25,7 +25,7 @@ import { makeDevinCli, devinTestLayer as layer, decodeDevinLaunch as decodeLaunch, - encodeDevinSkills, + devinTestSkills, } from "../testUtils/devinCli.ts"; const threadId = ThreadId.make("devin-thread"); @@ -55,6 +55,22 @@ const makeHarness = Effect.fn("makeDevinAdapterHarness")(function* ( return { ...cli, adapter, events, approval, 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(); @@ -101,21 +117,7 @@ it.effect("connects T3 tools with isolated credentials and restores tool roots o const h = yield* makeHarness(); const fs = yield* FileSystem.FileSystem; const config = yield* ServerConfig; - yield* 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)), - ); + yield* registerT3Tools; const session = yield* h.start(); const firstDirectory = (yield* h.requests).find((request) => request.method === "session/new") ?.params?.additionalDirectories?.[1]; @@ -181,20 +183,30 @@ it.effect("dispatches a graphical skill pick as a native command with its argume const h = yield* makeHarness(); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* fs.writeFileString( - path.join(h.root, "devin-test-skills.json"), - encodeDevinSkills([ - { - name: "visual-check", - description: "Check a browser page.", - display_name: "Visual check", - base_dir: path.join(h.root, ".devin", "skills", "visual-check"), - triggers: ["user"], - errors: [], - }, - ]), - ); + 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({ @@ -211,6 +223,12 @@ it.effect("dispatches a graphical skill pick as a native command with its argume 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", @@ -223,21 +241,7 @@ 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* 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"]), - agentDeviceEnvironment: {}, - }), - ), - () => Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)), - ); + 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); @@ -315,7 +319,16 @@ it.effect("applies family thinking choices and enters and leaves plan mode", () options: [{ id: "reasoningEffort", value: "high" }], }, }); - yield* h.adapter.sendTurn({ threadId, input: "Implement it", interactionMode: "default" }); + 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 @@ -324,7 +337,7 @@ it.effect("applies family thinking choices and enters and leaves plan mode", () request.method === "session/set_config_option" && request.params?.configId === "model", ) .map((request) => request.params?.value), - ).toEqual(["devin-test-high"]); + ).toEqual(["devin-test-high", "devin-test-low"]); expect( requests .filter((request) => request.method === "session/set_mode") @@ -393,27 +406,6 @@ it.effect("cancels while waiting for permission and returns the session to ready }).pipe(Effect.provide(layer)), ); -it.effect("switches between explicit models and maps ACP usage updates", () => - Effect.gen(function* () { - const h = yield* makeHarness({ T3_ACP_DEVIN: "1" }); - yield* h.start("devin-test-high"); - yield* h.adapter.sendTurn({ - threadId, - input: "Use the low variant", - 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 }, - }); - expect( - (yield* h.requests) - .filter((request) => request.method === "session/set_config_option") - .map((request) => request.params?.value), - ).toEqual(["devin-test-high", "devin-test-low"]); - }).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" }); diff --git a/apps/server/src/provider/Layers/DevinProvider.test.ts b/apps/server/src/provider/Layers/DevinProvider.test.ts deleted file mode 100644 index b1f9adcdf06c..000000000000 --- a/apps/server/src/provider/Layers/DevinProvider.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import { checkDevinProviderStatus } from "./DevinProvider.ts"; -import { makeDevinCli as makeHarness, devinTestLayer as layer } from "../testUtils/devinCli.ts"; - -it.effect("uses the supplied environment to check sign-in and discover models before a chat", () => - Effect.gen(function* () { - const h = yield* makeHarness(); - const environment = { - ...h.environment, - T3_DEVIN_AUTH_STATUS: "Logged in (via Devin).", - }; - const snapshot = yield* checkDevinProviderStatus(h.settings, environment); - expect(snapshot.auth.status).toBe("authenticated"); - expect(snapshot.status).toBe("ready"); - expect(snapshot.models.map((model) => [model.slug, model.name])).toEqual([ - ["devin-test", "Devin Test"], - ]); - const fs = yield* FileSystem.FileSystem; - expect(yield* fs.exists(h.requestLog)).toBe(false); - const signedOut = yield* checkDevinProviderStatus(h.settings, { - ...environment, - T3_DEVIN_AUTH_STATUS: "Not logged in.", - }); - expect(signedOut.auth.status).toBe("unauthenticated"); - expect(signedOut.models).toEqual([]); - }).pipe(Effect.provide(layer)), -); - -it.effect("keeps authenticated status and reports catalog failures", () => - Effect.gen(function* () { - const h = yield* makeHarness(); - const snapshot = yield* checkDevinProviderStatus(h.settings, { - ...h.environment, - T3_DEVIN_AUTH_STATUS: "Logged in (via Devin).", - T3_DEVIN_FAIL_MODELS: "1", - }); - expect(snapshot.auth.status).toBe("authenticated"); - expect(snapshot.version).toBe("3000.5.20"); - expect(snapshot.status).toBe("warning"); - expect(snapshot.message).toContain("Could not load Devin models"); - }).pipe(Effect.provide(layer)), -); diff --git a/apps/server/src/provider/acp/DevinAcpSupport.test.ts b/apps/server/src/provider/acp/DevinAcpSupport.test.ts index 0371a5855982..0ffbb8f81516 100644 --- a/apps/server/src/provider/acp/DevinAcpSupport.test.ts +++ b/apps/server/src/provider/acp/DevinAcpSupport.test.ts @@ -8,7 +8,6 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { - applyDevinMode, devinMode, checkDevinExecutable, makeDevinAcpRuntime, @@ -88,25 +87,16 @@ it.effect("rejects a Desktop version response before starting ACP", () => }).pipe(Effect.provide(layer)), ); -it.effect("maps auto and edit approval policies to the advertised Devin modes", () => - Effect.gen(function* () { - const cli = yield* makeHarness(); - const runtime = yield* makeDevinAcpRuntime(cli.settings, cli.environment, { - cwd: cli.root, - clientInfo: { name: "t3-code-test", version: "0.0.0" }, - }); - const started = yield* runtime.start(); - const modes = (yield* runtime.getModeState)?.availableModes ?? []; - expect(modes.map((mode) => mode.id)).toContain("smart"); - expect(modes.map((mode) => mode.id)).not.toContain("normal"); - yield* applyDevinMode(runtime, started.sessionId, "auto"); - yield* applyDevinMode(runtime, started.sessionId, "auto-accept-edits"); - yield* applyDevinMode(runtime, started.sessionId, "approval-required"); - expect( - (yield* cli.requests) - .filter((request) => request.method === "session/set_mode") - .map((request) => request.params?.modeId), - ).toEqual(["smart", "accept-edits", "normal"]); - expect(devinMode("auto", undefined, [])).toBe("normal"); - }).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/DevinModels.test.ts b/apps/server/src/provider/acp/DevinModels.test.ts index cd7ca4302200..030eed27f94f 100644 --- a/apps/server/src/provider/acp/DevinModels.test.ts +++ b/apps/server/src/provider/acp/DevinModels.test.ts @@ -1,57 +1,26 @@ import { expect, it } from "@effect/vitest"; -import { buildProviderOptionSelectionsFromDescriptors } from "@t3tools/shared/model"; import { devinModels, resolveDevinModel } from "./DevinModels.ts"; -const catalog = { - families: [ - { - slug: "swe-2", - family_label: "SWE-2", - variants: [ - { model_uid: "swe-2-high", label: "SWE-2 High" }, - { model_uid: "swe-2-medium", label: "SWE-2 Medium" }, - { model_uid: "swe-2-max", label: "SWE-2 Max" }, - ], - }, - { - slug: "swe-1.7", - family_label: "SWE-1.7", - variants: [ - { model_uid: "swe-1-7", label: "SWE-1.7 Max" }, - { model_uid: "swe-1-7-medium", label: "SWE-1.7 Medium" }, - ], - }, - { - slug: "claude-opus-5", - family_label: "Claude Opus 5", - variants: [ - { model_uid: "claude-opus-5-medium", label: "Claude Opus 5 Medium" }, - { model_uid: "claude-opus-5-high", label: "Claude Opus 5 High" }, - { model_uid: "claude-opus-5-medium-fast", label: "Claude Opus 5 Medium Fast" }, - { model_uid: "claude-opus-5-high-fast", label: "Claude Opus 5 High Fast" }, - ], - }, - { - slug: "claude-opus-4.6", - family_label: "Claude Opus 4.6", - variants: [ - { model_uid: "MODEL_CLAUDE_4_6_OPUS", label: "Claude Opus 4.6" }, - { model_uid: "MODEL_CLAUDE_4_6_OPUS_THINKING", label: "Claude Opus 4.6 Thinking" }, - { model_uid: "MODEL_CLAUDE_4_6_OPUS_1M", label: "Claude Opus 4.6 1M" }, - { model_uid: "MODEL_CLAUDE_4_6_OPUS_THINKING_1M", label: "Claude Opus 4.6 Thinking 1M" }, - ], - }, +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("orders family thinking choices from lowest to highest and keeps the CLI's initial choice", () => { +it("groups variants into independent controls, including a single remaining thinking level", () => { const models = devinModels(catalog); - expect(models.map((model) => model.name)).toEqual([ - "SWE-2", - "SWE-1.7", - "Claude Opus 5", - "Claude Opus 4.6", - ]); + expect(models.map((model) => model.slug)).toEqual(["opus", "swe"]); expect(models[0]?.capabilities?.optionDescriptors).toEqual([ { id: "reasoningEffort", @@ -61,233 +30,149 @@ it("orders family thinking choices from lowest to highest and keeps the CLI's in options: [ { id: "medium", label: "Medium" }, { id: "high", label: "High" }, - { id: "max", label: "Max" }, ], }, + { id: "fastMode", label: "Fast mode", type: "boolean", currentValue: false }, ]); - expect(resolveDevinModel(catalog, { model: "swe-2" })).toBe("swe-2-high"); - expect( - resolveDevinModel(catalog, { - model: "swe-2", - options: [{ id: "reasoningEffort", value: "max" }], - }), - ).toBe("swe-2-max"); - expect( - resolveDevinModel(catalog, { - model: "swe-1.7", - options: [{ id: "reasoningEffort", value: "max" }], - }), - ).toBe("swe-1-7"); + 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([ - { - family: "GPT-5.6 Sol", - suffixes: [ - "Medium Thinking", - "No Thinking", - "Max Thinking", - "XHigh Thinking", - "High Thinking", - "Low Thinking", - ], - expected: ["None", "Low", "Medium", "High", "XHigh", "Max"], - }, - { - family: "Gemini 3 Flash", - suffixes: ["High", "Low", "Minimal", "Medium"], - expected: ["Minimal", "Low", "Medium", "High"], - }, - { - family: "Inkling", - suffixes: ["X-High", "Max", "None", "Medium", "High", "Low"], - expected: ["None", "Low", "Medium", "High", "XHigh", "Max"], - }, -])( - "orders $family thinking labels independently of the CLI's order", - ({ family, suffixes, expected }) => { - const models = devinModels({ - families: [ - { - slug: family, - family_label: family, - variants: suffixes.map((suffix, index) => ({ - model_uid: `native-${index}`, - label: `${family} ${suffix}`, - })), - }, - ], - }); - const thinking = models[0]?.capabilities?.optionDescriptors?.find( - (option) => option.id === "reasoningEffort", - ); - expect(thinking?.type === "select" && thinking.options.map((option) => option.label)).toEqual( - expected, - ); - }, -); - -it.each(catalog.families)( - "keeps $slug menus stable when the CLI changes its default variant", - (family) => { - const original = devinModels({ families: [family] })[0]; - const choices = (model: typeof original) => - model?.capabilities?.optionDescriptors?.map((descriptor) => - descriptor.type === "select" - ? { id: descriptor.id, options: descriptor.options } - : { id: descriptor.id }, - ); - for (let offset = 1; offset < family.variants.length; offset++) { - const variants = [...family.variants.slice(offset), ...family.variants.slice(0, offset)]; - const reorderedCatalog = { families: [{ ...family, variants }] }; - const reordered = devinModels(reorderedCatalog)[0]; - expect(choices(reordered)).toEqual(choices(original)); - const options = - buildProviderOptionSelectionsFromDescriptors(reordered?.capabilities?.optionDescriptors) ?? - []; - expect(resolveDevinModel(reorderedCatalog, { model: family.slug, options })).toBe( - variants[0]?.model_uid, - ); - } - }, -); - -it("exposes every catalog variant through independent thinking, speed, and context controls", () => { - for (const model of devinModels(catalog)) { - const descriptors = model.capabilities?.optionDescriptors ?? []; - const combinations = descriptors.reduce>>( - (selections, descriptor) => - selections.flatMap((selection) => - (descriptor.type === "boolean" - ? [false, true] - : descriptor.options.map((option) => option.id) - ).map((value) => [...selection, { id: descriptor.id, value }]), - ), - [[]], - ); - const resolved = combinations.map((options) => - resolveDevinModel(catalog, { model: model.slug, options }), - ); - expect(resolved.toSorted()).toEqual( - catalog.families - .find((family) => family.slug === model.slug) - ?.variants.map((variant) => variant.model_uid) - .toSorted(), - ); - } -}); - -it("rejects unavailable combinations and preserves exact native and custom IDs", () => { + { 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: "swe-2", - options: [{ id: "reasoningEffort", value: "low" }], + model: "opus", + options: [ + { id: "reasoningEffort", value: effort }, + { id: "fastMode", value: fast }, + ], }), - ).toBeUndefined(); - expect( - resolveDevinModel(catalog, { model: "swe-2", options: [{ id: "fastMode", value: true }] }), - ).toBeUndefined(); - expect(resolveDevinModel(catalog, { model: "swe-2-medium" })).toBe("swe-2-medium"); - expect(resolveDevinModel(catalog, { model: "custom-model" })).toBe("custom-model"); + ).toBe(expected); }); -it.each( - [ - [{ model_uid: "future", label: "Future Special" }], - [ - { model_uid: "future-high", label: "Future High" }, - { model_uid: "future-medium-fast", label: "Future Medium Fast" }, - ], - [ - { model_uid: "future-none", label: "Future None" }, - { model_uid: "future-no-thinking", label: "Future No Thinking" }, +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}`, + })), + }, ], - ].map((variants) => ({ variants })), -)("keeps ambiguous or incomplete families selectable by exact ID: %j", ({ variants }) => { - const catalog = { families: [{ slug: "future", family_label: "Future", variants }] }; - const models = devinModels(catalog); - expect(models.map((model) => model.slug)).toEqual(variants.map((variant) => variant.model_uid)); - for (const model of models) - expect(resolveDevinModel(catalog, { model: model.slug })).toBe(model.slug); + }); + 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("keeps the remaining thinking level selectable after an account catalog contracts", () => { - const models = devinModels({ +it("maps thinking and context controls to opaque native IDs", () => { + const contextCatalog = { families: [ { - slug: "swe-2", - family_label: "SWE-2", - variants: [{ model_uid: "swe-2-high", label: "SWE-2 High" }], + 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(models[0]?.capabilities?.optionDescriptors?.[0]).toMatchObject({ - id: "reasoningEffort", - currentValue: "high", - options: [{ id: "high", label: "High" }], - }); + }; + 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"); }); -const fusionCatalog = { - families: [ - ...catalog.families, - { - slug: "fusion", - family_label: "Fusion", - variants: catalog.families[2]!.variants.flatMap((lead) => - catalog.families[0]!.variants.map((sidekick) => ({ - model_uid: `fusion-${lead.model_uid}-sidekick-${sidekick.model_uid}`, - label: `Fusion (${lead.label} + ${sidekick.label})`, - })), - ), - }, - ], -}; +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 sidekick and resolves every thinking and speed combination", () => { - const models = devinModels(fusionCatalog).filter((model) => model.slug.startsWith("fusion/")); - expect(models.map((model) => model.name)).toEqual([ - "Fusion (Claude Opus 5 + SWE-2 High)", - "Fusion (Claude Opus 5 + SWE-2 Medium)", - "Fusion (Claude Opus 5 + SWE-2 Max)", +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)", ]); - const resolved = models.flatMap((model) => - ["medium", "high"].flatMap((effort) => - [false, true].map((fastMode) => - resolveDevinModel(fusionCatalog, { - model: model.slug, - options: [ - { id: "reasoningEffort", value: effort }, - { id: "fastMode", value: fastMode }, - ], - }), - ), - ), - ); - expect(resolved.toSorted()).toEqual( - fusionCatalog.families - .at(-1) - ?.variants.map((v) => v.model_uid) - .toSorted(), - ); - const first = models[0]!; - expect(first.fusion).toEqual({ - lead: { id: "claude-opus-5", name: "Claude Opus 5" }, - sidekick: { id: "swe-2-high", name: "SWE-2 High" }, + 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: first.slug, - options: [{ id: "reasoningEffort", value: "max" }], + model: pairings[0]!.slug, + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ], }), - ).toBeUndefined(); -}); - -it("preserves unfamiliar Fusion pairings as exact model choices", () => { - const variant = { model_uid: "fusion-future", label: "Fusion (Future Lead + Future Sidekick)" }; - const future = { families: [{ slug: "fusion", family_label: "Fusion", variants: [variant] }] }; - expect(devinModels(future).map((model) => [model.slug, model.name])).toEqual([ - [variant.model_uid, variant.label], - ]); - expect(resolveDevinModel(future, { model: variant.model_uid })).toBe(variant.model_uid); + ).toBe("pair-fast-high"); + expect(resolveDevinModel(fusionCatalog, { model: pairings[1]!.slug })).toBe("pair-other"); }); diff --git a/apps/server/src/provider/testUtils/devinCli.ts b/apps/server/src/provider/testUtils/devinCli.ts index e8d51948aabf..8a665d816438 100644 --- a/apps/server/src/provider/testUtils/devinCli.ts +++ b/apps/server/src/provider/testUtils/devinCli.ts @@ -12,7 +12,40 @@ import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; const encodeString = Schema.encodeSync(Schema.fromJsonString(Schema.String)); export const decodeDevinSettings = Schema.decodeSync(DevinSettings); -export const encodeDevinSkills = Schema.encodeSync(DevinSkillCatalog); +export const devinTestSkills = Schema.encodeSync(DevinSkillCatalog)([ + { + name: "visual-check", + 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({ @@ -28,17 +61,6 @@ const Request = Schema.Struct({ cwd: Schema.optional(Schema.String), additionalDirectories: Schema.optional(Schema.Array(Schema.String)), workspaceDirs: Schema.optional(Schema.Array(Schema.String)), - serverId: Schema.optional(Schema.String), - mcpServers: Schema.optional( - Schema.Array( - Schema.Struct({ - type: Schema.String, - name: Schema.String, - url: Schema.String, - headers: Schema.Array(Schema.Struct({ name: Schema.String, value: Schema.String })), - }), - ), - ), modeId: Schema.optional(Schema.String), configId: Schema.optional(Schema.String), value: Schema.optional(Schema.String), @@ -52,7 +74,6 @@ export const decodeDevinLaunch = Schema.decodeSync( Schema.fromJsonString( Schema.Struct({ args: Schema.Array(Schema.String), - cwd: Schema.String, device: Schema.optional(Schema.String), }), ), @@ -94,7 +115,6 @@ if (matches("auth", "status")) { process.exit(0); } if (matches("models", "list", "--format", "json")) { - if (process.env.T3_DEVIN_FAIL_MODELS === "1") process.exit(1); 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"}, @@ -103,7 +123,6 @@ if (matches("models", "list", "--format", "json")) { process.exit(0); } if (matches("skills", "list", "--json")) { - if (process.env.T3_DEVIN_FAIL_SKILLS === "1") process.exit(1); 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") : "[]"); From 9205023ff2ca77c9e361de4b837024e92c87cac3 Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Sat, 12 Sep 2026 20:24:09 -0400 Subject: [PATCH 09/15] feat(devin): add Fusion model pairing support - Add Fusion model selection across web and mobile - Generalize provider model policies for instance catalogs and exact options - Preserve unavailable model selections across provider refreshes --- README.md | 5 +- .../features/threads/FusionModelEditor.tsx | 110 +++++++++ .../src/features/threads/ThreadComposer.tsx | 4 +- .../features/threads/ThreadSettingsSheet.tsx | 115 ++++++--- .../threads/fusion-model-options.test.ts | 46 ++++ .../features/threads/fusion-model-options.ts | 40 ++++ apps/mobile/src/lib/modelOptions.test.ts | 30 ++- apps/mobile/src/lib/modelOptions.ts | 42 ++-- apps/mobile/src/lib/providerOptions.test.ts | 2 +- apps/mobile/src/lib/providerOptions.ts | 9 +- .../provider/Layers/AntigravityProvider.ts | 1 + .../src/provider/Layers/DevinProvider.ts | 5 + .../src/provider/Layers/OpenCodeProvider.ts | 6 + .../provider/Layers/ProviderRegistry.test.ts | 222 ++++++++++-------- .../src/provider/Layers/ProviderRegistry.ts | 9 +- .../src/provider/acp/AcpSessionRuntime.ts | 13 +- .../src/provider/acp/DevinAcpSupport.test.ts | 42 +++- .../src/provider/acp/DevinAcpSupport.ts | 67 ++---- apps/server/src/provider/providerSnapshot.ts | 6 +- .../web/src/components/ChatView.logic.test.ts | 2 +- apps/web/src/components/ChatView.logic.ts | 2 +- apps/web/src/components/chat/ChatComposer.tsx | 4 + .../components/chat/ModelPickerContent.tsx | 8 +- .../components/chat/ProviderModelPicker.tsx | 10 +- apps/web/src/components/chat/TraitsPicker.tsx | 16 +- .../chat/composerProviderState.test.tsx | 14 +- .../components/chat/composerProviderState.tsx | 16 +- .../src/components/chat/fusionModelPicker.ts | 2 +- .../src/components/chat/providerIconUtils.ts | 1 + .../settings/ProjectDefaultsSettings.tsx | 1 + .../components/settings/SettingsPanels.tsx | 1 + apps/web/src/composerDraftStore.ts | 19 +- apps/web/src/modelSelection.test.ts | 29 +++ apps/web/src/modelSelection.ts | 13 +- docs/user/install.md | 1 + docs/user/providers-devin.md | 2 +- packages/contracts/src/server.ts | 31 +++ .../react-native-shiki-engine@0.3.12.patch | 11 + pnpm-lock.yaml | 5 +- pnpm-workspace.yaml | 1 + 40 files changed, 705 insertions(+), 258 deletions(-) create mode 100644 apps/mobile/src/features/threads/FusionModelEditor.tsx create mode 100644 apps/mobile/src/features/threads/fusion-model-options.test.ts create mode 100644 apps/mobile/src/features/threads/fusion-model-options.ts create mode 100644 patches/react-native-shiki-engine@0.3.12.patch 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/features/threads/FusionModelEditor.tsx b/apps/mobile/src/features/threads/FusionModelEditor.tsx new file mode 100644 index 000000000000..bdc807fe22c6 --- /dev/null +++ b/apps/mobile/src/features/threads/FusionModelEditor.tsx @@ -0,0 +1,110 @@ +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) ?? available[0]; + if (!selected?.fusion) { + return No Fusion pairings available.; + } + 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/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index df4af2f2c8fa..a066077e567b 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -501,12 +501,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const providerOptionDescriptors = useMemo( () => resolveProviderOptionDescriptors({ - provider: currentModelOption?.providerDriver, + modelPolicy: currentModelOption?.modelPolicy, capabilities: currentModelOption?.capabilities, selections: currentModelSelection.options, }), [ - currentModelOption?.providerDriver, + currentModelOption?.modelPolicy, currentModelOption?.capabilities, currentModelSelection.options, ], diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 667784ed869d..0b35c539d300 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; @@ -429,7 +434,7 @@ function ThreadSettingsSessionProvider( ? getProviderOptionDescriptors({ caps: pendingModel.capabilities, selections: pendingModel.selection.options, - preserveUnavailableSelections: pendingModel.providerDriver === "devin", + preserveUnavailableSelections: pendingModel.modelPolicy?.optionSelection === "exact", }) : [] : props.optionDescriptors, @@ -440,20 +445,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(); + props.onSelectModel(model); } - void Haptics.selectionAsync(); - props.onSelectModel(pendingModel); - } - return true; - }, [pendingModel, props.onSelectModel, props.providerGroups]); + return true; + }, + [pendingModel, props.onSelectModel, props.providerGroups], + ); const applyOptionChange = useCallback( (id: string, value: string | boolean) => { @@ -596,10 +604,17 @@ function ThreadSettingsModelListRow(props: { readonly isLast: boolean; }) { const session = useThreadSettingsSession(); - const onPress = useCallback( - () => session.pressModel(props.option), - [props.option, session.pressModel], - ); + 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 { + session.pressModel(props.option); + } + }, [navigation, props.option, session]); return ( !model.isLegacy || session.isDisplayed(model)); - const visibleModels = catalogModels.filter((model) => - modelMatchesCatalogQuery({ - model, - providerLabel: group.providerLabel, - query: session.searchQuery, - }), + const visibleModels = collapseFusionOptions( + catalogModels.filter((model) => + modelMatchesCatalogQuery({ + model, + providerLabel: group.providerLabel, + query: session.searchQuery, + }), + ), + session.isDisplayed, ); if (visibleModels.length === 0) { return []; @@ -685,7 +703,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, @@ -972,6 +993,7 @@ function ThreadSettingsChoiceContent(props: { type ThreadSettingsPickerStackParams = { ThreadSettingsModels: undefined; + ThreadSettingsFusion: { readonly providerKey: string; readonly initialKey: string }; ThreadSettingsChoice: ThreadSettingsSubmenuPage & { readonly title: string }; }; @@ -1192,6 +1214,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>(); @@ -1245,6 +1297,11 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation) component={ThreadSettingsModelsScreen} options={{ headerBackVisible: false, title: "Thread settings" }} /> + resolveProviderOptionDescriptors({ - provider: flow.selectedModelOption?.providerDriver, + modelPolicy: flow.selectedModelOption?.modelPolicy, capabilities: flow.selectedModelOption?.capabilities, selections: flow.selectedModel?.options, }), [ flow.selectedModel?.options, flow.selectedModelOption?.capabilities, - flow.selectedModelOption?.providerDriver, + flow.selectedModelOption?.modelPolicy, ], ); 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 1d47bb32029a..54ddcc072a6a 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -1,6 +1,11 @@ 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, @@ -238,6 +243,29 @@ describe("mobile model options", () => { ], } as unknown as ServerConfig; + 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 }], diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 6bbea5882e1e..4d8bb443d356 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; }; @@ -72,10 +76,9 @@ export function getModelSelectionUnavailableReason( const provider = config.providers.find( (candidate) => candidate.instanceId === selection.instanceId, ); - const driver = - provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver; + const instanceConfig = config.settings?.providerInstances[selection.instanceId]; if ( - (driver === "antigravity" || driver === "devin") && + resolveProviderModelPolicy(provider ?? instanceConfig).catalogScope === "instance" && (!provider || !provider.enabled || !provider.installed || @@ -83,7 +86,10 @@ export function getModelSelectionUnavailableReason( provider.availability === "unavailable" || !provider.models.some((model) => model.slug === selection.model)) ) { - const name = driver === "devin" ? "Devin" : "Antigravity"; + 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; @@ -97,7 +103,7 @@ export function isModelSelectionUnavailable( } /** - * Keep Antigravity and Devin 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. */ @@ -111,9 +117,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" || driver === "devin") { + if ( + resolveProviderModelPolicy(provider ?? config.settings?.providerInstances[selection.instanceId]) + .catalogScope === "instance" + ) { return selection; } return provider && @@ -125,7 +132,7 @@ export function resolveSelectableModelSelection( } /** - * Reject legacy models for implicit defaults, except Antigravity and Devin 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. */ @@ -139,8 +146,7 @@ export function resolveDefaultableModelSelection( } const provider = config.providers.find((candidate) => candidate.instanceId === usable.instanceId); const model = provider?.models.find((candidate) => candidate.slug === usable.model); - return provider?.driver !== "antigravity" && - provider?.driver !== "devin" && + return resolveProviderModelPolicy(provider).catalogScope !== "instance" && model?.isLegacy === true ? null : usable; @@ -173,8 +179,7 @@ export function buildModelOptions( !provider.enabled || !provider.installed || provider.auth.status === "unauthenticated" || - ((provider.driver === "antigravity" || provider.driver === "devin") && - provider.availability === "unavailable") + provider.availability === "unavailable" ) { continue; } @@ -184,14 +189,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, @@ -210,7 +219,7 @@ export function buildModelOptions( options.set(key, { ...existing, selection: - existing.providerDriver === "antigravity" || existing.providerDriver === "devin" + existing.modelPolicy?.catalogScope === "instance" ? fallbackModelSelection : normalizeSelectionOptions(fallbackModelSelection, existing.capabilities), }); @@ -242,6 +251,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 4e7a5d65b72b..7b05fc52010d 100644 --- a/apps/mobile/src/lib/providerOptions.test.ts +++ b/apps/mobile/src/lib/providerOptions.test.ts @@ -65,7 +65,7 @@ describe("mobile provider options", () => { it("keeps a removed Devin thinking choice visible until the user picks an available level", () => { const descriptors = resolveProviderOptionDescriptors({ - provider: "devin", + modelPolicy: { optionSelection: "exact" }, capabilities: { optionDescriptors: [ { diff --git a/apps/mobile/src/lib/providerOptions.ts b/apps/mobile/src/lib/providerOptions.ts index cea6cb1bc608..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,17 +10,17 @@ import { } from "@t3tools/shared/model"; export function resolveProviderOptionDescriptors(input: { - readonly provider?: string; + 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.provider === "devin", + preserveUnavailableSelections: input.modelPolicy?.optionSelection === "exact", }); } 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/DevinProvider.ts b/apps/server/src/provider/Layers/DevinProvider.ts index b3cbd9c6a2a7..8c5af654c0b7 100644 --- a/apps/server/src/provider/Layers/DevinProvider.ts +++ b/apps/server/src/provider/Layers/DevinProvider.ts @@ -35,6 +35,11 @@ export const buildDevinProviderSnapshot = Effect.fn("buildDevinProviderSnapshot" probe, }), supportsTextGeneration: false, + modelPolicy: { + catalogScope: "instance" as const, + preserveUnavailableModels: true, + optionSelection: "exact" as const, + }, }; }); 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 7a783c11dead..8964d1b7e612 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1066,118 +1066,123 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); }); - describe.each(["antigravity", "devin"])("%s model inventories", (driver) => { - const previousProvider = { - instanceId: ProviderInstanceId.make("antigravity-personal"), - driver: ProviderDriverKind.make(driver), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-09-02T00:00:00.000Z", - version: "0.1.3", - models: [ - { - slug: "gemini-3.1-pro-high", - name: "Gemini 3.1 Pro High", - isCustom: false, - capabilities: null, - }, - { - slug: "gemini-3-flash", - name: "Gemini 3 Flash", - isCustom: false, - capabilities: null, - }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; + describe.each(["antigravity", "devin", "test-account-provider"])( + "%s model inventories", + (driver) => { + const previousProvider = { + instanceId: ProviderInstanceId.make("antigravity-personal"), + driver: ProviderDriverKind.make(driver), + modelPolicy: + driver === "test-account-provider" ? { catalogScope: "instance" } : undefined, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-09-02T00:00:00.000Z", + version: "0.1.3", + models: [ + { + slug: "gemini-3.1-pro-high", + name: "Gemini 3.1 Pro High", + isCustom: false, + capabilities: null, + }, + { + slug: "gemini-3-flash", + name: "Gemini 3 Flash", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; - it("removes unavailable models after a successful refresh", () => { - for (const status of ["ready", "warning"] as const) { - const refreshedProvider = { - ...previousProvider, - status, - checkedAt: "2026-09-02T00:01:00.000Z", - models: [previousProvider.models[1]], - } satisfies ServerProvider; - const afterRefresh = mergeProviderSnapshot(previousProvider, refreshedProvider); + it("removes unavailable models after a successful refresh", () => { + for (const status of ["ready", "warning"] as const) { + const refreshedProvider = { + ...previousProvider, + status, + checkedAt: "2026-09-02T00:01:00.000Z", + models: [previousProvider.models[1]], + } satisfies ServerProvider; + const afterRefresh = mergeProviderSnapshot(previousProvider, refreshedProvider); - assert.deepStrictEqual(afterRefresh.models, refreshedProvider.models); + assert.deepStrictEqual(afterRefresh.models, refreshedProvider.models); - const afterFailure = mergeProviderSnapshot(afterRefresh, { - ...refreshedProvider, - status: "error", - auth: { status: "unknown" }, - models: [], - }); - assert.deepStrictEqual(afterFailure.models, refreshedProvider.models); - } - }); + const afterFailure = mergeProviderSnapshot(afterRefresh, { + ...refreshedProvider, + status: "error", + auth: { status: "unknown" }, + models: [], + }); + assert.deepStrictEqual(afterFailure.models, refreshedProvider.models); + } + }); - it("keeps cached models during health checks and temporary failures", () => { - for (const installed of [false, true]) { - const pendingProvider = { - ...previousProvider, - status: driver === "devin" && installed ? "error" : "warning", - installed, - auth: { status: "unknown" }, - checkedAt: "2026-09-02T00:01:00.000Z", - version: installed ? previousProvider.version : null, - models: [], - } satisfies ServerProvider; + it("keeps cached models during health checks and temporary failures", () => { + for (const installed of [false, true]) { + const pendingProvider = { + ...previousProvider, + status: driver !== "antigravity" && installed ? "error" : "warning", + installed, + auth: { status: "unknown" }, + checkedAt: "2026-09-02T00:01:00.000Z", + version: installed ? previousProvider.version : null, + models: [], + } satisfies ServerProvider; + + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, pendingProvider).models, + previousProvider.models, + ); + } - assert.deepStrictEqual( - mergeProviderSnapshot(previousProvider, pendingProvider).models, - previousProvider.models, - ); - } + for (const authStatus of ["unknown", "authenticated"] as const) { + const failedProvider = { + ...previousProvider, + status: "error", + auth: { status: authStatus }, + checkedAt: "2026-09-02T00:02:00.000Z", + models: [], + } satisfies ServerProvider; - for (const authStatus of ["unknown", "authenticated"] as const) { - const failedProvider = { + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, failedProvider).models, + previousProvider.models, + ); + } + }); + + it("clears models after sign-out, disable, uninstall, or an empty successful refresh", () => { + const emptyProvider = { ...previousProvider, - status: "error", - auth: { status: authStatus }, - checkedAt: "2026-09-02T00:02:00.000Z", + checkedAt: "2026-09-02T00:01:00.000Z", models: [], } satisfies ServerProvider; - - assert.deepStrictEqual( - mergeProviderSnapshot(previousProvider, failedProvider).models, - previousProvider.models, - ); - } - }); - - it("clears models after sign-out, disable, uninstall, or an empty successful refresh", () => { - const emptyProvider = { - ...previousProvider, - checkedAt: "2026-09-02T00:01:00.000Z", - models: [], - } satisfies ServerProvider; - const clearedProviders = [ - { ...emptyProvider, status: "warning", auth: { status: "unauthenticated" } }, - { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } }, - { ...emptyProvider, status: "disabled", enabled: false }, - { ...emptyProvider, status: "error", enabled: false }, - { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } }, - emptyProvider, - ] satisfies ReadonlyArray; - - for (const provider of clearedProviders) { - const afterRemoval = mergeProviderSnapshot(previousProvider, provider); - assert.deepStrictEqual(afterRemoval.models, []); - - const afterFailure = mergeProviderSnapshot(afterRemoval, { - ...emptyProvider, - status: "error", - auth: { status: "unknown" }, - }); - assert.deepStrictEqual(afterFailure.models, []); - } - }); - }); + const clearedProviders = [ + { ...emptyProvider, status: "warning", auth: { status: "unauthenticated" } }, + { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } }, + { ...emptyProvider, status: "disabled", enabled: false }, + { ...emptyProvider, status: "error", enabled: false }, + { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } }, + emptyProvider, + ] satisfies ReadonlyArray; + + for (const provider of clearedProviders) { + const afterRemoval = mergeProviderSnapshot(previousProvider, provider); + assert.deepStrictEqual(afterRemoval.models, []); + + const afterFailure = mergeProviderSnapshot(afterRemoval, { + ...emptyProvider, + status: "error", + auth: { status: "unknown" }, + }); + assert.deepStrictEqual(afterFailure.models, []); + } + }); + }, + ); describe("Antigravity saved account", () => { const signedIn = { @@ -1340,6 +1345,17 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ).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", () => diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 93d79df5d0d0..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, @@ -101,18 +102,18 @@ export function upsertProviderWorkspaceSnapshot( const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); const isCodex = provider.driver === ProviderDriverKind.make("codex"); - const isDevin = provider.driver === ProviderDriverKind.make("devin"); + const isInstanceCatalog = resolveProviderModelPolicy(provider).catalogScope === "instance"; if ( !isAntigravity && !isCodex && - !isDevin && + !isInstanceCatalog && provider.driver !== ProviderDriverKind.make("opencode") ) { return true; } if ( - (isAntigravity || isCodex || isDevin) && + (isAntigravity || isCodex || isInstanceCatalog) && (!provider.enabled || provider.auth.status === "unauthenticated") ) { return false; @@ -153,7 +154,7 @@ const mergeProviderModels = ( const mergedModels = nextModels.map((model) => { const previousModel = previousBySlug.get(model.slug); if ( - provider.driver === "devin" || + resolveProviderModelPolicy(provider).optionSelection === "exact" || !previousModel || hasModelCapabilities(model) || !hasModelCapabilities(previousModel) diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 5c63b3afafdc..981944ddb965 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -87,6 +87,8 @@ export interface AcpSessionRuntimeOptions { /** 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; @@ -651,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( @@ -1058,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 index 0ffbb8f81516..f0a0b9e82b04 100644 --- a/apps/server/src/provider/acp/DevinAcpSupport.test.ts +++ b/apps/server/src/provider/acp/DevinAcpSupport.test.ts @@ -1,7 +1,6 @@ import * as FileSystem from "effect/FileSystem"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -54,7 +53,7 @@ it.effect("passes resolved Windows command-shim shell options to the process lau }), ); -it.effect("selects an account model after ACP refreshes its stale catalog", () => +it.effect("selects an account model even when ACP never refreshes its stale catalog", () => Effect.gen(function* () { const h = yield* makeHarness({ T3_ACP_DEVIN: "1", T3_ACP_DEVIN_STALE_MODELS: "1" }); const runtime = yield* makeDevinAcpRuntime(h.settings, h.environment, { @@ -62,11 +61,7 @@ it.effect("selects an account model after ACP refreshes its stale catalog", () = clientInfo: { name: "t3-code-test", version: "0.0.0" }, }); yield* runtime.start(); - const selection = yield* runtime - .setModel("devin-test-high") - .pipe(Effect.forkScoped({ startImmediately: true })); - yield* runtime.request("_test/refresh-models", {}); - yield* Fiber.join(selection); + yield* runtime.setModel("devin-test-high"); const model = (yield* runtime.getConfigOptions).find((option) => option.id === "model"); expect(model?.currentValue).toBe("devin-test-high"); expect( @@ -77,6 +72,39 @@ it.effect("selects an account model after ACP refreshes its stale catalog", () = }).pipe(Effect.provide(layer)), ); +it.effect("sends an unlisted Fusion ID and retains Devin's confirmed selection", () => + Effect.gen(function* () { + const h = yield* makeHarness({ T3_ACP_DEVIN: "1" }); + const runtime = yield* makeDevinAcpRuntime(h.settings, h.environment, { + cwd: h.root, + clientInfo: { name: "t3-code-test", version: "0.0.0" }, + }); + const modelId = "fusion-gpt-5-6-sol-max-sidekick-swe-2-high"; + 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"); diff --git a/apps/server/src/provider/acp/DevinAcpSupport.ts b/apps/server/src/provider/acp/DevinAcpSupport.ts index 8dc6855501c6..e39589ef4fe8 100644 --- a/apps/server/src/provider/acp/DevinAcpSupport.ts +++ b/apps/server/src/provider/acp/DevinAcpSupport.ts @@ -9,8 +9,6 @@ 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 Stream from "effect/Stream"; -import * as SubscriptionRef from "effect/SubscriptionRef"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -129,18 +127,6 @@ const readDevinModelCatalog = Effect.fn("readDevinModelCatalog")(function* ( export const readDevinModels = (settings: DevinSettings, environment: NodeJS.ProcessEnv) => readDevinModelCatalog(settings, environment).pipe(Effect.map(devinModels)); -function includesModel(options: ReadonlyArray, model: string) { - const config = options.find((option) => option.id === "model"); - return ( - config?.type === "select" && - config.options.some((entry) => - "value" in entry - ? entry.value === model - : entry.options.some((option) => option.value === model), - ) - ); -} - export const makeDevinAcpRuntime = Effect.fn("makeDevinAcpRuntime")(function* ( settings: DevinSettings, environment: NodeJS.ProcessEnv, @@ -158,56 +144,31 @@ export const makeDevinAcpRuntime = Effect.fn("makeDevinAcpRuntime")(function* ( 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 modelUpdates = yield* SubscriptionRef.make< - ReadonlyArray - >([]); - yield* runtime.handleSessionUpdate((notification) => - notification.update.sessionUpdate === "config_option_update" - ? runtime.getConfigOptions.pipe( - Effect.flatMap((options) => SubscriptionRef.set(modelUpdates, options)), - ) - : Effect.void, - ); const getCatalog = readDevinModelCatalog(settings, environment).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.timeout("10 seconds"), Effect.mapError((cause) => EffectAcpErrors.AcpRequestError.internalError(cause.message)), ); - const setModel = Effect.fn("DevinAcpRuntime.setModel")(function* (modelId: string) { - yield* runtime.start(); - if (!includesModel(yield* runtime.getConfigOptions, modelId)) { - // An upgrade can make the CLI catalog newer than the initial ACP config. - // Only wait for models the account actually offers, while consuming ACP updates. - const catalog = yield* getCatalog; - if ( - catalog.families.some((family) => - family.variants.some((variant) => variant.model_uid === modelId), - ) - ) { - yield* SubscriptionRef.changes(modelUpdates).pipe( - Stream.filter((options) => includesModel(options, modelId)), - Stream.take(1), - Stream.runDrain, - Effect.timeoutOrElse({ - duration: "10 seconds", - orElse: () => - Effect.fail( - EffectAcpErrors.AcpRequestError.invalidParams( - `Devin has not made model ${modelId} available to this session yet. Try again after refreshing provider status.`, - ), - ), - }), - ); - } - } - return yield* runtime.setModel(modelId); - }); + // 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 { diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index d53367a90640..def0aa40d307 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -193,6 +193,7 @@ export function buildBooleanOptionDescriptor(input: { export function buildServerProvider(input: { driver?: ProviderDriverKind; presentation: ServerProviderPresentation; + modelPolicy?: ServerProvider["modelPolicy"]; enabled: boolean; checkedAt: string; models: ReadonlyArray; @@ -207,7 +208,7 @@ export function buildServerProvider(input: { checkedAt: input.checkedAt, }) : undefined; - return { + const snapshot = { displayName: input.presentation.displayName, ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}), ...(typeof input.presentation.showInteractionModeToggle === "boolean" @@ -231,7 +232,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/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 4cc968f7952a..d5a12f86c087 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1424,7 +1424,7 @@ describe("resolveComposerProviderSelection", () => { expect(getAntigravitySendBlockReason(provider, "gpt-model")).toBeNull(); }); - it.each(["antigravity", "devin"])( + it.each(["antigravity", "devin", "test-account-provider"])( "keeps a missing %s instance pinned to its account", (driver) => { const missingInstanceId = ProviderInstanceId.make(`${driver}_work`); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 578a081bd278..165e1a63fe12 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -558,7 +558,7 @@ export function resolveComposerProviderSelection(input: { : null; // Missing metadata must not move account-bound history into another account. const requiresExactInstance = - (input.lockedProvider === "antigravity" || input.lockedProvider === "devin") && + input.lockedProvider != null && input.lockedInstanceId != null && lockedContinuationGroupKey === null; const compatibleEntries = input.entries.filter( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7ecb5e2dc635..687fb1d08789 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1849,6 +1849,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) provider: selectedProvider, model: selectedModel, models: selectedProviderModels, + modelPolicy: selectedProviderStatus?.modelPolicy, promptInjectionState: composerPromptInjectionState, modelOptions: composerModelOptions?.[selectedInstanceId], planModeEnabled: settings.planModeEnabled, @@ -1860,6 +1861,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModel, selectedProvider, selectedProviderModels, + selectedProviderStatus?.modelPolicy, settings.planModeEnabled, ], ); @@ -2255,6 +2257,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ...(routeKind === "draft" && draftId ? { draftId } : {}), model: selectedModel, models: selectedProviderModels, + modelPolicy: selectedProviderStatus?.modelPolicy, modelOptions: composerModelOptions?.[selectedInstanceId], prompt, onPromptChange: setPromptFromTraits, @@ -2267,6 +2270,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ...(routeKind === "draft" && draftId ? { draftId } : {}), model: selectedModel, models: selectedProviderModels, + modelPolicy: selectedProviderStatus?.modelPolicy, modelOptions: composerModelOptions?.[selectedInstanceId], prompt, onPromptChange: setPromptFromTraits, diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index a7c58a87e66a..1c07b6355dac 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, @@ -51,6 +52,7 @@ import { import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; type ModelPickerItem = { + isFusionGroup?: boolean; fusion?: ModelEsque["fusion"]; slug: string; name: string; @@ -95,9 +97,7 @@ export function shouldIncludeModelPickerOption(input: { if (isProviderInstancePickerReady(input.entry)) return true; return ( input.entry.enabled && - (input.entry.driverKind === "opencode" || - input.entry.driverKind === "antigravity" || - input.entry.driverKind === "devin") && + resolveProviderModelPolicy(input.entry.snapshot).preserveUnavailableModels === true && input.entry.instanceId === input.activeInstanceId && input.option.slug === input.activeModel && input.option.isUnavailable === true @@ -951,7 +951,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (!model) { return null; } - if (model.fusion && model.name === "Fusion") { + if (model.fusion && model.isFusionGroup) { const 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 4eb8e7cd0fc7..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,9 +79,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { model: props.model, options: selectedInstanceOptions, }) ?? - (activeEntry?.driverKind === "opencode" || - activeEntry?.driverKind === "antigravity" || - activeEntry?.driverKind === "devin" + (resolveProviderModelPolicy(activeEntry?.snapshot).preserveUnavailableModels ? undefined : selectedInstanceOptions[0]); const triggerTitle = selectedModel?.fusion @@ -228,8 +227,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { => @@ -218,6 +222,7 @@ function getSelectedTraits( function getTraitsSectionVisibility(input: { provider: ProviderDriverKind; + modelPolicy?: ServerProvider["modelPolicy"]; models: ReadonlyArray; model: string | null | undefined; prompt: string; @@ -233,6 +238,7 @@ function getTraitsSectionVisibility(input: { input.modelOptions, input.allowPromptInjectedEffort ?? true, input.planModeEnabled, + input.modelPolicy, ); const showEffort = selected.primarySelectDescriptor !== null; @@ -260,6 +266,7 @@ function getTraitsSectionVisibility(input: { export function shouldRenderTraitsControls(input: { provider: ProviderDriverKind; + modelPolicy?: ServerProvider["modelPolicy"]; models: ReadonlyArray; model: string | null | undefined; prompt: string; @@ -272,6 +279,7 @@ export function shouldRenderTraitsControls(input: { export interface TraitsMenuContentProps { provider: ProviderDriverKind; + modelPolicy?: ServerProvider["modelPolicy"]; instanceId?: ProviderInstanceId; models: ReadonlyArray; model: string | null | undefined; @@ -287,6 +295,7 @@ export interface TraitsMenuContentProps { export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ provider, + modelPolicy, instanceId, models, model, @@ -327,6 +336,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ modelIsUnavailable, } = getTraitsSectionVisibility({ provider, + modelPolicy, models, model, prompt, @@ -539,6 +549,7 @@ export function buildTraitsTriggerDisplay(input: { export const TraitsPicker = memo(function TraitsPicker({ provider, + modelPolicy, instanceId, models, model, @@ -562,6 +573,7 @@ export const TraitsPicker = memo(function TraitsPicker({ const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = getTraitsSectionVisibility({ provider, + modelPolicy, models, model, prompt, @@ -572,6 +584,7 @@ export const TraitsPicker = memo(function TraitsPicker({ if ( !shouldRenderTraitsControls({ provider, + modelPolicy, models, model, prompt, @@ -651,6 +664,7 @@ export const TraitsPicker = memo(function TraitsPicker({ { }); }); -it("preserves unavailable Devin thinking and speed choices for provider validation", () => { +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("devin"), + 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, diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index 19089f1ae8ee..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,6 +98,7 @@ function resolveComposerOptionSelections( provider: ProviderDriverKind, modelOptions: ReadonlyArray | null | undefined, planModeEnabled: boolean, + modelPolicy: ServerProvider["modelPolicy"], ): { caps: ModelCapabilities; selections: ReadonlyArray | undefined; @@ -102,7 +107,7 @@ function resolveComposerOptionSelections( return { caps, selections: - provider === "devin" + resolveProviderModelPolicy({ driver: provider, modelPolicy }).optionSelection === "exact" ? (modelOptions ?? undefined) : withImplicitFastModeDefault(caps, modelOptions), }; @@ -116,6 +121,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com modelOptions, promptInjectionState = "none", planModeEnabled, + modelPolicy, } = input; if (provider === "opencode") { const normalizedModel = normalizeModelSlug(model, provider); @@ -138,11 +144,13 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com provider, modelOptions, planModeEnabled, + modelPolicy, ); const descriptors = getProviderOptionDescriptors({ caps, selections, - preserveUnavailableSelections: provider === "devin", + preserveUnavailableSelections: + resolveProviderModelPolicy({ driver: provider, modelPolicy }).optionSelection === "exact", }); const primarySelectDescriptor = descriptors.find( (descriptor): descriptor is Extract<(typeof descriptors)[number], { type: "select" }> => @@ -188,6 +196,7 @@ function renderTraitsControl( planModeEnabled, size, hidden, + modelPolicy, triggerVariant, triggerClassName, isComposerOwned, @@ -199,11 +208,13 @@ function renderTraitsControl( provider, modelOptions, planModeEnabled, + modelPolicy, ); if ( !hasTarget || !shouldRenderTraitsControls({ provider, + modelPolicy, models, model, modelOptions: resolvedModelOptions, @@ -216,6 +227,7 @@ function renderTraitsControl( return ( }; export type ModelEsque = { + isFusionGroup?: boolean; fusion?: ServerProviderModel["fusion"]; slug: string; name: string; diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index bb2c3e99bbb8..162cf21b2280 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -198,6 +198,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting {!mixedModel ? ( provider.instanceId === input.selectedInstanceId, + ); + const isInstanceCatalog = + resolveProviderModelPolicy(selectedSnapshot ?? { driver: input.selectedProvider }) + .catalogScope === "instance" || + (input.selectedInstanceId != null && !selectedSnapshot); const baseModelCandidate = input.threadModelSelection?.model ?? input.projectModelSelection?.model ?? null; const preserveThreadModel = @@ -1189,10 +1197,7 @@ export function deriveEffectiveComposerModelState(input: { ) : null) ?? // Account catalogs have no static model or cross-account fallback. - ((input.selectedProvider === "antigravity" || input.selectedProvider === "devin") && - input.selectedInstanceId - ? "" - : null) ?? + (isInstanceCatalog && input.selectedInstanceId ? "" : null) ?? resolveAppModelSelection( input.selectedProvider, input.settings, @@ -1209,7 +1214,7 @@ export function deriveEffectiveComposerModelState(input: { ? input.draft?.modelSelectionByProvider?.[input.selectedInstanceId] : undefined; const legacySelection = - (input.selectedProvider === "antigravity" || input.selectedProvider === "devin") && + isInstanceCatalog && input.selectedInstanceId && input.selectedInstanceId !== defaultInstanceIdForDriver(input.selectedProvider) ? undefined @@ -1226,9 +1231,7 @@ export function deriveEffectiveComposerModelState(input: { activeSelection.model, { preserveUnavailableSelection: true }, ) ?? - (input.selectedProvider === "antigravity" || input.selectedProvider === "devin" - ? "" - : null) ?? + (isInstanceCatalog ? "" : null) ?? resolveAppModelSelection( input.selectedProvider, input.settings, diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 0ecdae41e219..313c4fdee424 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -66,6 +66,35 @@ function settingsWithProviderInstances(): UnifiedSettings { } describe("instance-scoped model selection", () => { + it("uses advertised catalog policy for an unknown driver", () => { + const instanceId = ProviderInstanceId.make("test-account"); + const snapshot: ServerProvider = { + ...provider({ instanceId, provider: ProviderDriverKind.make("test-driver") }), + modelPolicy: { catalogScope: "instance", preserveUnavailableModels: true }, + }; + const entry = deriveProviderInstanceEntries([snapshot])[0]!; + const settings = settingsWithProviderInstances(); + expect(getAppModelOptionsForInstance(settings, entry, "retired-model")).toEqual([ + { slug: "retired-model", name: "retired-model", isCustom: false, isUnavailable: true }, + ]); + expect( + resolveAppModelSelectionForInstance(instanceId, settings, [snapshot], "retired-model", { + preserveUnavailableSelection: true, + }), + ).toBe("retired-model"); + expect( + deriveEffectiveComposerModelState({ + draft: null, + providers: [snapshot], + selectedProvider: snapshot.driver, + selectedInstanceId: instanceId, + threadModelSelection: null, + projectModelSelection: null, + settings, + }).selectedModel, + ).toBe(""); + }); + it("preserves server-provided legacy model metadata", () => { const baseProvider = provider({ instanceId: "claudeAgent", diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 040bc5cd1199..9170968b2dba 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -1,4 +1,5 @@ import { + resolveProviderModelPolicy, ANTIGRAVITY_DEFAULT_MODEL, DEFAULT_TEXT_GENERATION_MODEL, DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, @@ -98,8 +99,9 @@ function appendUnavailableDynamicModelSelection( provider: ProviderDriverKind, selectedModel: string | null | undefined, hiddenModels: ReadonlyArray, + modelPolicy: ServerProvider["modelPolicy"], ): AppModelOption[] { - if (provider !== "opencode" && provider !== "antigravity" && provider !== "devin") return options; + if (!modelPolicy?.preserveUnavailableModels) return options; const slug = normalizeCustomModelSlug(selectedModel); if (!slug) return options; if (provider === "antigravity" && slug === ANTIGRAVITY_DEFAULT_MODEL) return options; @@ -223,6 +225,9 @@ function getAppModelOptions( provider, selectedModel, preferences.hiddenModels, + resolveProviderModelPolicy( + providers.find((entry) => entry.instanceId === defaultInstanceId) ?? { driver: provider }, + ), ); } @@ -271,6 +276,7 @@ export function getAppModelOptionsForInstance( entry.driverKind, selectedModel, preferences.hiddenModels, + resolveProviderModelPolicy(entry.snapshot), ); } @@ -310,9 +316,7 @@ export function resolveAppModelSelectionForInstance( } if ( resolutionOptions?.preserveUnavailableSelection && - (entry.driverKind === "opencode" || - entry.driverKind === "antigravity" || - entry.driverKind === "devin") + resolveProviderModelPolicy(entry.snapshot).preserveUnavailableModels ) { const unavailableSelection = normalizeCustomModelSlug(selectedModel); const hiddenModels = readInstanceModelPreferences(settings, entry.instanceId).hiddenModels; @@ -436,6 +440,7 @@ export function resolveAppModelSelectionState( provider, model, models: entry.models, + modelPolicy: resolveProviderModelPolicy(entry.snapshot), modelOptions: selectedEntry ? selection.options : undefined, planModeEnabled: settings.planModeEnabled, }); diff --git a/docs/user/install.md b/docs/user/install.md index 65b52ebf0cb9..371e28071892 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -74,6 +74,7 @@ computer. | Cursor | Install [Cursor CLI](https://cursor.com/cli), then run `agent login`. | | Grok Build | Install [Grok Build CLI](https://x.ai/cli), then run `grok login`. | | OpenCode | Install [OpenCode](https://opencode.ai), then run `opencode auth login`. | +| Devin | Install [Devin CLI](https://docs.devin.ai/cli), then run `devin auth login`. | | Antigravity | Install and sign in with Google from T3 Code's provider settings. | Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set its diff --git a/docs/user/providers-devin.md b/docs/user/providers-devin.md index 823a584ac709..9c5161b9c8e0 100644 --- a/docs/user/providers-devin.md +++ b/docs/user/providers-devin.md @@ -6,7 +6,7 @@ If `devin` launches Devin Desktop, set **Binary path** to the actual CLI executa Available models load from `devin models list --format json` when provider status refreshes, before you start a chat. Refresh **Settings → Providers** after changing your Devin subscription. Choose a model family, then set its thinking level and, where available, Fast mode or context window. Workspace commands load when the session opens. -For [Fusion](https://docs.devin.ai/cli/fusion), use Devin CLI 3000.10.20 or newer with a paid plan, then refresh provider status. On web and desktop, choose **Fusion** in the model picker, select a lead and sidekick, then choose **Use Fusion**. Set the lead's thinking level and Fast mode with the composer controls. Mobile lists the available pairings directly. Switch to a regular model to leave Fusion in the same thread. +For [Fusion](https://docs.devin.ai/cli/fusion), use Devin CLI 3000.10.20 or newer with a paid plan, then refresh provider status. Choose **Fusion** in the model picker, select a lead and sidekick, then choose **Use Fusion**. Set the lead's thinking level and Fast mode with the composer controls. Switch to a regular model to leave Fusion in the same thread. Select a model from your account's catalog. Saved threads resume their Devin session, and model changes apply within the same thread. A temporary refresh failure keeps the last available model list; signing out clears it. diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c42fc6d3d010..764e4c825949 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -210,6 +210,15 @@ export const ServerProvider = Schema.Struct({ requiresNewThreadForModelChange: Schema.optional(Schema.Boolean), supportsConversationRollback: Schema.optional(Schema.Boolean), supportsTextGeneration: Schema.optional(Schema.Boolean), + // Instance catalogs cannot borrow models from another account or static defaults. + modelPolicy: Schema.optional( + Schema.Struct({ + catalogScope: Schema.optional(Schema.Literal("instance")), + preserveUnavailableModels: Schema.optional(Schema.Boolean), + // Exact options select native variants; never coerce a saved choice or inject defaults. + optionSelection: Schema.optional(Schema.Literal("exact")), + }), + ), setup: Schema.optional( Schema.Struct({ canAuthenticate: Schema.Boolean, @@ -245,6 +254,28 @@ export const ServerProvider = Schema.Struct({ }); export type ServerProvider = typeof ServerProvider.Type; +const LEGACY_MODEL_POLICIES = new Map>([ + ["antigravity", { catalogScope: "instance", preserveUnavailableModels: true }], + ["opencode", { preserveUnavailableModels: true }], + [ + "devin", + { catalogScope: "instance", preserveUnavailableModels: true, optionSelection: "exact" }, + ], +]); + +/** Older environments lack policy metadata. Keep their behavior at this compatibility boundary. */ +export function resolveProviderModelPolicy( + provider: + | { + readonly driver?: string; + readonly modelPolicy?: ServerProvider["modelPolicy"]; + } + | null + | undefined, +): NonNullable { + return provider?.modelPolicy ?? LEGACY_MODEL_POLICIES.get(provider?.driver ?? "") ?? {}; +} + // Provider status kinds grow over time (ServerProviderState, // ServerProviderAuthStatus, ServerProviderVersionAdvisoryStatus, // ServerProviderUpdateStatus); an older client must not fail the whole config diff --git a/patches/react-native-shiki-engine@0.3.12.patch b/patches/react-native-shiki-engine@0.3.12.patch new file mode 100644 index 000000000000..0ccd7e62afd8 --- /dev/null +++ b/patches/react-native-shiki-engine@0.3.12.patch @@ -0,0 +1,11 @@ +diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt +--- a/android/CMakeLists.txt ++++ b/android/CMakeLists.txt +@@ -8,5 +8,7 @@ find_library(LOG_LIB log) + find_library(ONIG_LIB onig + PATHS ${CMAKE_CURRENT_SOURCE_DIR}/src/main/jniLibs/${ANDROID_ABI} ++ # Never link a host library into the Android binary. ++ NO_DEFAULT_PATH + NO_CMAKE_FIND_ROOT_PATH + REQUIRED + ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f175605d8bf..0a449e885fc9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,7 @@ patchedDependencies: react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 react-native-reanimated@4.5.1: a23baea5d82cbf1254720110aef6671e39d57a425f8be446756f91ab806eb72c react-native-screens@4.26.2: 8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d + react-native-shiki-engine@0.3.12: c56bf1178f00be1dc733db0315d41c4db69a10a67639ec37fd0775b5cdb0b249 uniwind@1.11.0: 17d92be2eec71bb6396b402e8d034968e54b28746876d7977cb3139655f42b90 importers: @@ -448,7 +449,7 @@ importers: version: 4.26.2(patch_hash=8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-shiki-engine: specifier: ^0.3.12 - version: 0.3.12(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.3.12(patch_hash=c56bf1178f00be1dc733db0315d41c4db69a10a67639ec37fd0775b5cdb0b249)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-svg: specifier: 15.15.4 version: 15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -20988,7 +20989,7 @@ snapshots: react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-shiki-engine@0.3.12(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-shiki-engine@0.3.12(patch_hash=c56bf1178f00be1dc733db0315d41c4db69a10a67639ec37fd0775b5cdb0b249)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@shikijs/types': 4.3.0 '@shikijs/vscode-textmate': 10.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8a4bf972f82f..4d05a5d0f69c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -182,6 +182,7 @@ patchedDependencies: # Preserve the final layout frame. Backport of [#10171](https://github.com/software-mansion/react-native-reanimated/pull/10171). react-native-reanimated@4.5.1: patches/react-native-reanimated@4.5.1.patch react-native-screens@4.26.2: patches/react-native-screens@4.26.2.patch + react-native-shiki-engine@0.3.12: patches/react-native-shiki-engine@0.3.12.patch uniwind@1.11.0: patches/uniwind@1.11.0.patch peerDependencyRules: From 97f0dbee8dc2a3f944eeb7adc52fcd95f72818a7 Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Sat, 12 Sep 2026 21:02:59 -0400 Subject: [PATCH 10/15] fix(devin): honor catalog identifiers and bound Fusion animation --- apps/mobile/src/lib/modelOptions.test.ts | 33 +++++++++++++++++++ apps/mobile/src/lib/modelOptions.ts | 19 ++++++++--- .../src/provider/Drivers/DevinSkills.ts | 3 +- .../server/src/provider/testUtils/devinCli.ts | 2 +- apps/web/src/components/chat/FusionWave.css | 3 +- 5 files changed, 52 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 54ddcc072a6a..2a437ddc4ddd 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -243,6 +243,39 @@ 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 option = buildModelOptions(aliasConfig, aliasSelection).find( + (candidate) => candidate.selection.model === aliasSelection.model, + ); + 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("honors an unknown driver's advertised instance catalog", () => { const advertisedConfig = { ...config, diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 4d8bb443d356..7fb069d0ed46 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -84,7 +84,9 @@ export function getModelSelectionUnavailableReason( !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( /^./, @@ -145,7 +147,9 @@ 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); + const model = provider?.models.find( + (candidate) => candidate.slug === usable.model || candidate.aliases?.includes(usable.model), + ); return resolveProviderModelPolicy(provider).catalogScope !== "instance" && model?.isLegacy === true ? null @@ -229,7 +233,9 @@ export function buildModelOptions( ); const instanceConfig = config?.settings?.providerInstances[fallbackModelSelection.instanceId]; const model = provider?.models.find( - (candidate) => candidate.slug === fallbackModelSelection.model, + (candidate) => + candidate.slug === fallbackModelSelection.model || + candidate.aliases?.includes(fallbackModelSelection.model), ); const providerDriver = provider?.driver ?? instanceConfig?.driver ?? fallbackModelSelection.instanceId; @@ -240,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, diff --git a/apps/server/src/provider/Drivers/DevinSkills.ts b/apps/server/src/provider/Drivers/DevinSkills.ts index 766d6e0ea159..804be3c8293b 100644 --- a/apps/server/src/provider/Drivers/DevinSkills.ts +++ b/apps/server/src/provider/Drivers/DevinSkills.ts @@ -38,7 +38,8 @@ export const discoverDevinSkills = Effect.fn("discoverDevinSkills")(function* ( return catalog .filter((skill) => skill.base_dir.length > 0) .map((skill): ServerProviderSkill => ({ - name: skill.name, + // 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"), diff --git a/apps/server/src/provider/testUtils/devinCli.ts b/apps/server/src/provider/testUtils/devinCli.ts index 8a665d816438..61626e6fce06 100644 --- a/apps/server/src/provider/testUtils/devinCli.ts +++ b/apps/server/src/provider/testUtils/devinCli.ts @@ -14,7 +14,7 @@ const encodeString = Schema.encodeSync(Schema.fromJsonString(Schema.String)); export const decodeDevinSettings = Schema.decodeSync(DevinSettings); export const devinTestSkills = Schema.encodeSync(DevinSkillCatalog)([ { - name: "visual-check", + name: "Visual audit", display_name: "Visual check", description: "Check a page.", base_dir: "/skills/visual-check", diff --git a/apps/web/src/components/chat/FusionWave.css b/apps/web/src/components/chat/FusionWave.css index 48a48748c228..239461b03b36 100644 --- a/apps/web/src/components/chat/FusionWave.css +++ b/apps/web/src/components/chat/FusionWave.css @@ -1,5 +1,6 @@ .fusion-wave-packets { - animation: fusion-wave-flow 5s linear infinite; + /* 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 { From d1525e20bcee96372b09c3c063992640dbc76dad Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Sat, 12 Sep 2026 21:03:00 -0400 Subject: [PATCH 11/15] fix(settings): preserve disabled auto-pull during migration --- apps/server/src/serverSettings.test.ts | 8 +++++++- apps/server/src/serverSettings.ts | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 0f0c722402b6..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"; @@ -1312,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", @@ -1329,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 ( @@ -1344,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; @@ -1359,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 a97ff3cfac40..874b974fd32a 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -478,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); From 2b02734c3913f14c58313dea0609667eed38fec0 Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Sat, 12 Sep 2026 22:09:26 -0400 Subject: [PATCH 12/15] fix(devin): deduplicate aliases and bound auth status matching --- apps/mobile/src/lib/modelOptions.test.ts | 34 +++++++++++++++++-- apps/mobile/src/lib/modelOptions.ts | 18 +++++----- .../src/provider/Drivers/DevinDriver.test.ts | 16 +++++---- .../src/provider/Layers/DevinProvider.ts | 4 +-- 4 files changed, 51 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 2a437ddc4ddd..4f820036e6e0 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -255,9 +255,10 @@ describe("mobile model options", () => { expect(getModelSelectionUnavailableReason(aliasConfig, aliasSelection)).toBeNull(); expect(resolveSelectableModelSelection(aliasConfig, aliasSelection)).toBe(aliasSelection); expect(resolveDefaultableModelSelection(aliasConfig, aliasSelection)).toBe(aliasSelection); - const option = buildModelOptions(aliasConfig, aliasSelection).find( - (candidate) => candidate.selection.model === aliasSelection.model, - ); + 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); @@ -276,6 +277,33 @@ describe("mobile model options", () => { ).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, diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 7fb069d0ed46..ae2c453a98c4 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -217,7 +217,15 @@ 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, { @@ -228,15 +236,7 @@ export function buildModelOptions( : 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 || - candidate.aliases?.includes(fallbackModelSelection.model), - ); const providerDriver = provider?.driver ?? instanceConfig?.driver ?? fallbackModelSelection.instanceId; const providerLabel = providerDisplayLabel({ diff --git a/apps/server/src/provider/Drivers/DevinDriver.test.ts b/apps/server/src/provider/Drivers/DevinDriver.test.ts index 42353bf392f8..14045dce7286 100644 --- a/apps/server/src/provider/Drivers/DevinDriver.test.ts +++ b/apps/server/src/provider/Drivers/DevinDriver.test.ts @@ -164,12 +164,14 @@ it.effect("refreshes account models and workspace skills and clears metadata aft ]); yield* fs.writeFileString(modelsFile, catalog); expect((yield* instance.snapshot.refresh).models).toEqual(snapshot.models); - yield* fs.writeFileString(statusFile, "Not logged in."); - 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); + 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)), ); diff --git a/apps/server/src/provider/Layers/DevinProvider.ts b/apps/server/src/provider/Layers/DevinProvider.ts index 8c5af654c0b7..fd706b3bc312 100644 --- a/apps/server/src/provider/Layers/DevinProvider.ts +++ b/apps/server/src/provider/Layers/DevinProvider.ts @@ -73,7 +73,7 @@ export const checkDevinProviderStatus = Effect.fn("checkDevinProviderStatus")(fu } const auth = yield* runDevinCommand(settings, environment, ["auth", "status"]); const output = `${auth.stdout}\n${auth.stderr}`; - if (/not logged in|not authenticated/i.test(output)) { + if (/\b(?:not logged in|not authenticated|unauthenticated)\b/i.test(output)) { return { installed: true, version, @@ -83,7 +83,7 @@ export const checkDevinProviderStatus = Effect.fn("checkDevinProviderStatus")(fu "Run devin auth login with the configured CLI on this environment, then refresh provider status.", } satisfies ProviderProbeResult; } - if (auth.code === 0 && /logged in|authenticated/i.test(output)) { + if (auth.code === 0 && /\b(?:logged in|authenticated)\b/i.test(output)) { return { installed: true, version, From 19b726d71709454f0296ef23c52eda010dc8bdad Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Sun, 13 Sep 2026 16:32:58 -0400 Subject: [PATCH 13/15] fix(devin): use the official Cognition mark across clients - Replace embedded PNG assets with inline SVG paths - Remove the unused mobile Devin asset --- apps/mobile/assets/devin.png | Bin 2352 -> 0 bytes apps/mobile/src/components/ProviderIcon.tsx | 12 ++++++------ apps/web/src/components/Icons.tsx | 16 +++++++--------- 3 files changed, 13 insertions(+), 15 deletions(-) delete mode 100644 apps/mobile/assets/devin.png diff --git a/apps/mobile/assets/devin.png b/apps/mobile/assets/devin.png deleted file mode 100644 index 5bae1f3dcd66071bec4763c0101ef377307923a7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2352 zcmeH|=Tno37RKL%ydX_lkd-1JC4h(_R1pYO2$B#WNbibXl_qF{^spdBrK-4#ltha3 z01MJ2bO9+!mrw+0(jf>dA{W2xhx-TIPy5Vs&Y3x1&df9On-hn}nQ(Ima{vIqjWIQ{ zI-c}jb%N!%Ux#L>0RZ$q-ke};;%9!m`B(oY@c&GJoq~UMeD6b`70w!P<9GfEaXADd zl7GN_p*-tEww=EZ1VlJ`zf&4m)v-o4^^390;mMO+ihSo!<(9>{;7fAe$P1EcK_n|< zoatrr06T-|sII^AHwKnd>T(~~P*?vbFW&o1TPt}u@;W^+^3TlH?wpLOPfu4mAKCx_ zn2s^hCy+AMUhdjDV&D-QRE#MZwm19Hqg@{N*rx_w2RF-s+m)=1R9CE-O^#0f5^*EE zDnGj7Kii63bjHQSF!Z!a0Gie1T|LnY_$|i$9}}e39>0&#pz7IpJfDh>O;6GkPy6z9 z!talc>)B^URdd9nN68LOi}ov7Xpd(^NI}$~&F}h*Gc}@c$vuT3nVkptName~VLxLv z{Sf%Yn6_)JOg>sINis>+SIhyqbQfx`J{_q}y3IKt-7DgW2ya_j71ZM)GCZoVaR~O< zWY>7f4^}|hn-y=a7a4c64UGNGmj0H?JLwJbGetX)rx~m;zcL<;@U{v&j-zj$Fvj^y z!GNFfS--^9Rft}Kkz3~KY9#oDx>c5RP1Oa`b+ZAQ@___OQ@1!wFV6|$gTQqg=Na8$ zaLUrz&F&tgxJudZWA|Dh=y1NXf3qRsiPTd9_^G&iCdj(%C8csBUp~SF%eHY*T_Al^ zPZ1ND?lhNE6r)t6DH@(c0ZQvz+}wyxj&658n~|YoNqLuic3EGJr6q$*Sov1RjAc&% z7kR=r9)XDH^@lv|;qWc=XU2(rf$^#o9fp-omJKc(uXauCajv%SBe(rZsC%pw($czNqgo_NO)*UCQ)oVg220(&#}7 z+`(wL=bSDw$gf{P+(q8>GaogvZ#>WYFA0`04aLsvD`~)x?ya4;ucmU2;s(A6pfdE} z)*u?S{=D93dFkYI^q63eR^S7XR!OrAfbPk?ka}8TXSu3t0rPxsT8q)YQd}a!(1+^oKG6v#*z;;wOc$cwk{P@Sxw4aAVuJ zJcd{D6mz}6xV@-;I7hT z8w)x2DS|=~r-s2gg3!Lfy-1~DP*_l?!1hpnD1iWGnimIkUmlaCKrO<9+<)Y$UG;z4 zE)AlzU9s4t*%FOJ?N}I?0lFTc+q-hTqD#~!Vc=aB_1`lebRYwRUR{u(w&lC3!I@5h zs;3338%(j;-|Q!axF`FU8+(b>XVoGXdmedx!i&LICt}-RSF_arJcpQZCb=L!@{eh` zTOtEOc3R8_yIOP;gkA?I%{);l!&_{v5!swCTpbM!_liqm@(~ldoA%h!^vT)G1GZPM z673YFL#IvcL=3=9YAh7nU~j1xsdc-wS)acW7iW?`%Zu8TWy8pQxQ&!Ygo>{4rRWm6 zF-_KXP1vl0G#u_H;m6Lty`#AAm9Q>}5t>c)kyU@xL9TxhqQdZI|BDlm6s?-0q=&NR zjwYz~%oHiT8K;3J&-v(><~7QJlGG_b5;LZEa3$FI{kF@hwS)|wiuSRh^brp+mXrEW zFicIf_G?Xq??~>21PioBqyLW$^zeYW-Gv-<-)q2 z%vr?5^7up)|BDDOoo332%3|^D-W1pfN4Q)cjjoJ$q8HWCiwZf1y86zL468z_Ov9r0 zLnI-a9PcjAHT@5w)}f%wF~#}EG-sp9&{V@48i$OX8~f032*{9f_ks1^YAjZeNdDQ> z+Lbj>+snIMJ05!kmkYUj(F4_3+0*=Oe2ZX_$TsG+!VGJxbcDY}L$1|ZK%4&QLKBL6Y(yKvf6nGBh+t@Z2SVT^G`r3P-~ F{{nupE}8%U diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 767581f81a98..7da354542504 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -26,14 +26,14 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + // Cognition mark from https://cognition.com/icon.svg. if (props.provider === "devin") { return ( - + + + + + ); } diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 0787a7434896..98f2dc096dd9 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -769,17 +769,15 @@ export const PiAgentIcon: Icon = ({ className, ...props }) => ( ); -// Cognition mark from the Devin documentation favicon. +// Cognition mark from https://cognition.com/icon.svg. export const DevinIcon: Icon = (props) => ( -
-
- { + if (!value) return; + const slug = + role === "lead" + ? findFusionLeadPairing(props.models, value, pairing.sidekick.id)?.slug + : value; + if (slug) setSelectedSlug(slug); + }} > - {pairing.lead.name} - - - {leads.map((lead) => ( - - {lead.name} - - ))} - - - - Lead - -
-
- + - {pairing.sidekick.name} - - - {sidekicks.map((sidekick) => ( - - {sidekick.name} - - ))} - - - - Sidekick - -
+ {role === "lead" ? "Lead" : "Sidekick"} + +
+ ))}
) : (

diff --git a/apps/web/src/components/chat/FusionWave.tsx b/apps/web/src/components/chat/FusionWave.tsx index 8a6d3ddebbaa..2dc1bb45b685 100644 --- a/apps/web/src/components/chat/FusionWave.tsx +++ b/apps/web/src/components/chat/FusionWave.tsx @@ -52,28 +52,25 @@ export function FusionWave({ animated = false }: { animated?: boolean }) { className="fusion-wave pointer-events-none absolute inset-0 size-full" > - - - - - - - - + {( + [ + ["upper", "#3969CA"], + ["lower", "#21C19A"], + ] as const + ).map(([side, color]) => ( + + + + + ))} {animated && ( diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 1c07b6355dac..5045593c1f07 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -9,8 +9,7 @@ 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 { ChevronRightIcon, SearchIcon, StarIcon } from "lucide-react"; -import { DevinIcon } from "../Icons"; +import { ChevronRightIcon, SearchIcon } from "lucide-react"; import { FusionModelPicker } from "./FusionModelPicker"; import { collapseFusionModels } from "./fusionModelPicker"; import { ModelListRow } from "./ModelListRow"; @@ -42,7 +41,7 @@ import { import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; -import { Tooltip, TooltipPopup, TooltipTrigger, TooltipProvider } from "../ui/tooltip"; +import { TooltipProvider } from "../ui/tooltip"; import { Button } from "../ui/button"; import { isProviderInstancePickerReady, @@ -184,7 +183,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { instanceId: ProviderInstanceId; model: string; } | null>(() => - activeModel?.fusion ? { instanceId: props.activeInstanceId, model: activeModel.slug } : null, + activeModel?.fusion && !activeModel.isUnavailable + ? { instanceId: props.activeInstanceId, model: activeModel.slug } + : null, ); const activeModelSlug = activeModel?.slug ?? (props.model === ANTIGRAVITY_DEFAULT_MODEL ? "" : props.model); @@ -569,9 +570,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const handleModelSelect = useCallback( (modelSlug: string, instanceId: ProviderInstanceId) => { - const option = modelOptionsByInstance - .get(instanceId) - ?.find((model) => model.slug === modelSlug); + const options = modelOptionsByInstance.get(instanceId); + const entry = entryByInstanceId.get(instanceId); + if (!options || !entry || getModelDisabledReason?.(instanceId, modelSlug)) return; + const option = options.find((model) => model.slug === modelSlug); if ( option?.fusion && !option.isUnavailable && @@ -580,17 +582,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { setFusionSelection({ instanceId, model: modelSlug }); return; } - if (getModelDisabledReason?.(instanceId, modelSlug)) { - return; - } - const options = modelOptionsByInstance.get(instanceId); - if (!options) { - return; - } - const entry = entryByInstanceId.get(instanceId); - if (!entry) { - return; - } // `resolveSelectableModel` uses the driver kind for normalization // (slug casing etc.). Custom instances share their driver's // normalization rules, so pass the driver kind here. @@ -951,59 +942,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (!model) { return null; } - if (model.fusion && model.isFusionGroup) { - const isFavorite = favoritesSet.has( - providerModelKey(model.instanceId, model.slug), - ); - return ( - -

-
Fusion
-
- - - - } - > - {model.fusion.lead.name} + {model.fusion.sidekick.name} - - - {model.instanceDisplayName} · {model.fusion.lead.name} +{" "} - {model.fusion.sidekick.name} - - -
-
- - - - ); - } const disabledReason = getModelDisabledReason?.(model.instanceId, model.slug) ?? null; return ( @@ -1013,7 +951,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), From d90daf9f2e5d1c54bd0da1d29356cdaa4be46857 Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Mon, 14 Sep 2026 21:46:46 -0400 Subject: [PATCH 15/15] fix(web): preserve model picker focus around fusion selection - Re-measure model picker state when returning from fusion selection - Stabilize keyboard handling and thread settings callbacks --- .../features/threads/ThreadSettingsSheet.tsx | 30 +++---- .../components/chat/ModelPickerContent.tsx | 84 ++++++++++--------- 2 files changed, 59 insertions(+), 55 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 0b35c539d300..15fe43bf1f1b 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -404,6 +404,7 @@ const ThreadSettingsSessionContext = createContext(null); const [searchQuery, setSearchQuery] = useState(""); @@ -456,11 +457,11 @@ function ThreadSettingsSessionProvider( return false; } void Haptics.selectionAsync(); - props.onSelectModel(model); + onSelectModel(model); } return true; }, - [pendingModel, props.onSelectModel, props.providerGroups], + [pendingModel, onSelectModel, props.providerGroups], ); const applyOptionChange = useCallback( @@ -475,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) => { @@ -603,7 +604,7 @@ function ThreadSettingsModelListRow(props: { readonly isFirst: boolean; readonly isLast: boolean; }) { - const session = useThreadSettingsSession(); + const { pressModel, isDisplayed } = useThreadSettingsSession(); const navigation = useNavigation>(); const onPress = useCallback(() => { if (props.option.fusion && !props.option.isUnavailable) { @@ -612,9 +613,9 @@ function ThreadSettingsModelListRow(props: { initialKey: props.option.key, }); } else { - session.pressModel(props.option); + pressModel(props.option); } - }, [navigation, props.option, session]); + }, [navigation, props.option, pressModel]); return ( ); } @@ -630,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 ( @@ -651,6 +652,7 @@ function ThreadSettingsProviderListHeader(props: { function useThreadSettingsCatalogItems( session: ThreadSettingsSessionValue, ): ReadonlyArray { + const { isDisplayed } = session; return useMemo( () => session.providerGroups.flatMap((group) => { @@ -660,7 +662,7 @@ 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)); + : group.models.filter((model) => !model.isLegacy || isDisplayed(model)); const visibleModels = collapseFusionOptions( catalogModels.filter((model) => modelMatchesCatalogQuery({ @@ -669,7 +671,7 @@ function useThreadSettingsCatalogItems( query: session.searchQuery, }), ), - session.isDisplayed, + isDisplayed, ); if (visibleModels.length === 0) { return []; @@ -715,7 +717,7 @@ function useThreadSettingsCatalogItems( }), [ session.isApplied, - session.isDisplayed, + isDisplayed, session.providerExpansionOverrides, session.providerFilter, session.providerGroups, diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 5045593c1f07..520ea1e404c8 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -8,7 +8,16 @@ 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"; @@ -187,6 +196,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ? { 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 @@ -252,6 +262,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ); useLayoutEffect(() => { + if (isFusionPickerOpen) return; focusSearchInput(); const frame = window.requestAnimationFrame(() => { focusSearchInput(); @@ -263,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 @@ -708,50 +719,44 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { [favoritesSet, modelJumpLabelByKey], ); - useEffect(() => { - const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (fusionSelection || 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, - fusionSelection, - ]); + }, []); + // 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(); @@ -761,7 +766,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { window.cancelAnimationFrame(frame); window.cancelAnimationFrame(nestedFrame); }; - }, [filteredItemKeys, updateModelListScrollFades]); + }, [filteredItemKeys, isFusionPickerOpen, updateModelListScrollFades]); if (fusionSelection) { const instanceId = fusionSelection.instanceId; @@ -778,10 +783,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { models={models} model={fusionSelection.model} providerName={entryByInstanceId.get(instanceId)?.displayName ?? "Devin"} - onBack={() => { - setFusionSelection(null); - window.requestAnimationFrame(focusSearchInput); - }} + onBack={() => setFusionSelection(null)} onSelect={(model) => onInstanceModelChange(instanceId, model)} /> );