From 9af6bbfb0ce7fd39048a40120d3aa9cfa231ab80 Mon Sep 17 00:00:00 2001 From: Adam Higgins Date: Fri, 11 Sep 2026 11:06:46 +0100 Subject: [PATCH 1/7] feat(providers): add Devin CLI provider via ACP Add a first-class Devin provider so T3 Code can run Devin's SWE models through the local `devin` CLI's ACP server. - Contracts: `DevinSettings` (enabled, binaryPath, customModels), provider kind, "adaptive" default model, "swe-1-6-fast" text-generation default. - `DevinAcpSupport` builds `devin --permission-mode acp` spawn args, maps T3 runtime modes onto Devin's session modes (accept-edits, smart, ask, plan, bypass), and applies model selection through the session's `model` config option. - `DevinAdapter` runs sessions over `AcpSessionRuntime`: prompt streaming, steering, interrupt/stop, permission requests via `request.opened`, and `session/elicitation` mapped to user-input questions (form mode only; URL-mode elicitations are cancelled). - `DevinProvider` probes `devin --version` / `auth status` and discovers models via `devin models list --format json` with a static SWE fallback catalog; `devin update` provides maintenance. - `DevinDriver` registers the provider (multi-instance capable) and `DevinTextGeneration` reuses the ACP runtime in `ask` mode for commit messages, PR content, branch names, and thread titles. - Web/mobile: provider icon, driver metadata, settings search, composer draft persistence, and status ordering. Docs: providers-devin page. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/mobile/src/components/ProviderIcon.tsx | 13 + apps/mobile/src/lib/modelOptions.ts | 1 + .../src/provider/Drivers/DevinDriver.ts | 186 +++ .../src/provider/Layers/DevinAdapter.test.ts | 335 +++++ .../src/provider/Layers/DevinAdapter.ts | 1087 +++++++++++++++++ .../src/provider/Layers/DevinProvider.test.ts | 89 ++ .../src/provider/Layers/DevinProvider.ts | 471 +++++++ .../src/provider/Services/DevinAdapter.ts | 17 + .../src/provider/acp/DevinAcpSupport.test.ts | 189 +++ .../src/provider/acp/DevinAcpSupport.ts | 240 ++++ apps/server/src/provider/builtInDrivers.ts | 3 + .../src/provider/providerStatusCache.ts | 1 + apps/server/src/serverSettings.test.ts | 3 + apps/server/src/serverSettings.ts | 11 +- .../src/textGeneration/DevinTextGeneration.ts | 271 ++++ .../src/textGeneration/TextGeneration.ts | 8 +- apps/web/src/components/Icons.tsx | 15 + .../src/components/chat/providerIconUtils.ts | 2 + .../settings/ProviderModelsSection.tsx | 1 + .../components/settings/providerDriverMeta.ts | 9 + .../src/components/settings/settingsSearch.ts | 4 +- apps/web/src/composerDraftStore.ts | 12 +- docs/README.md | 2 +- docs/user/install.md | 4 +- docs/user/permission-modes.md | 4 + docs/user/providers-devin.md | 21 + packages/contracts/src/model.ts | 5 + packages/contracts/src/settings.ts | 36 + 28 files changed, 3029 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/provider/Drivers/DevinDriver.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/Services/DevinAdapter.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/textGeneration/DevinTextGeneration.ts create mode 100644 docs/user/providers-devin.md diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 374738d0aeca..eeef465e118c 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -37,6 +37,19 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "devin") { + return ( + + + + ); + } + if (props.provider === "grok") { const fill = isDarkMode ? "#F5F5F5" : "#0F0F0F"; return ( diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 4e8295733141..7345ebb6de25 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -36,6 +36,7 @@ function providerDisplayLabel(provider: { if (provider.displayName) return provider.displayName; if (provider.driver === "codex") return "Codex"; if (provider.driver === "claudeAgent") return "Claude"; + if (provider.driver === "devin") return "Devin"; return provider.instanceId; } diff --git a/apps/server/src/provider/Drivers/DevinDriver.ts b/apps/server/src/provider/Drivers/DevinDriver.ts new file mode 100644 index 000000000000..b8afcc1151b8 --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinDriver.ts @@ -0,0 +1,186 @@ +/** + * DevinDriver — built-in driver for the Devin CLI via ACP. + * + * @module DevinDriver + */ +import { DevinSettings, ProviderDriverKind } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { HttpClient } from "effect/unstable/http"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import * as ServerSettingsService from "../../serverSettings.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeDevinAdapter } from "../Layers/DevinAdapter.ts"; +import { + buildInitialDevinProviderSnapshot, + checkDevinProviderStatus, + enrichDevinSnapshot, +} 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 { withInstanceIdentity } from "./instanceIdentity.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeCachedProviderMaintenanceResolution, + makeManualOnlyProviderMaintenanceCapabilities, + makeProviderMaintenanceCapabilities, + type ProviderMaintenanceCapabilitiesResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +import { makeDevinTextGeneration } from "../../textGeneration/DevinTextGeneration.ts"; +const decodeDevinSettings = Schema.decodeSync(DevinSettings); + +const DRIVER_KIND = ProviderDriverKind.make("devin"); +// devin updates itself (`devin update`), so the resolved executable is its +// own updater. No executable means nothing to update, not "whatever is on +// PATH". +const UPDATE: ProviderMaintenanceCapabilitiesResolver = { + resolve: (context) => + Effect.succeed( + context + ? makeProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + updateExecutable: context.resolvedCommandPath, + updateArgs: ["update"], + updateLockKey: "devin", + platform: context.platform, + }) + : makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), + ), +}; + +export type DevinDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService.ServerSettingsService; + +export const DevinDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Devin", + supportsMultipleInstances: true, + }, + configSchema: DevinSettings, + defaultConfig: (): DevinSettings => decodeDevinSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService.ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies DevinSettings; + const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( + resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ); + + const adapter = yield* makeDevinAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeDevinTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkDevinProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + resolveMaintenance, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialDevinProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + // Model catalog comes from `devin models list --format json` during + // provider checks; enrichment only republishes version advisories. + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + resolveMaintenance().pipe( + Effect.flatMap((maintenanceCapabilities) => + enrichDevinSnapshot({ + settings: settings.provider, + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + stampIdentity, + httpClient, + }), + ), + ), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Devin snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; 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..f3d4a0a365f2 --- /dev/null +++ b/apps/server/src/provider/Layers/DevinAdapter.test.ts @@ -0,0 +1,335 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { + ApprovalRequestId, + DevinSettings, + ProviderDriverKind, + type ProviderRuntimeEvent, + ThreadId, + ProviderInstanceId, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import type { DevinAdapterShape } from "../Services/DevinAdapter.ts"; +import { makeDevinAdapter } from "./DevinAdapter.ts"; +import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; +const decodeDevinSettings = Schema.decodeSync(DevinSettings); + +// Test-local service tag so the rest of the file can keep using `yield* DevinAdapter`. +class DevinAdapter extends Context.Service()( + "t3/provider/Layers/DevinAdapter.test/DevinAdapter", +) {} + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); + +async function makeMockAgentWrapper(extraEnv?: Record) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-mock-")); + return writeFakeCli({ + directory: dir, + name: "fake-devin", + env: extraEnv ?? {}, + source: execScriptSource({ scriptPath: mockAgentPath }), + }); +} + +async function makeProbeWrapper( + requestLogPath: string, + argvLogPath: string, + extraEnv?: Record, +) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-probe-")); + return writeFakeCli({ + directory: dir, + name: "fake-devin", + env: { T3_ACP_REQUEST_LOG_PATH: requestLogPath, ...extraEnv }, + source: execScriptSource({ scriptPath: mockAgentPath, argvLogPath }), + }); +} + +async function readArgvLog(filePath: string) { + const raw = await NodeFSP.readFile(filePath, "utf8"); + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => line.split("\t").filter((token) => token.length > 0)); +} + +async function readJsonLines(filePath: string) { + const raw = await NodeFSP.readFile(filePath, "utf8"); + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + +// Tests mutate `ServerSettingsService` mid-flight (setting +// `providers.devin.binaryPath` to a mock ACP wrapper); see the equivalent +// resolver comment in CursorAdapter.test.ts. +const makeResolveDevinSettings = Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + return yield* Effect.succeed( + serverSettings.getSettings.pipe( + Effect.map((snapshot) => snapshot.providers.devin), + Effect.orDie, + ), + ); +}); + +const devinAdapterTestLayer = it.layer( + Layer.effect( + DevinAdapter, + Effect.gen(function* () { + const devinConfig = decodeDevinSettings({}); + const resolveSettings = yield* makeResolveDevinSettings; + return yield* makeDevinAdapter(devinConfig, { resolveSettings }); + }), + ).pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-devin-adapter-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), +); + +devinAdapterTestLayer("DevinAdapter", (it) => { + it.effect( + "starts a session, applies the selected model, and maps the prompt flow to runtime events", + () => + Effect.gen(function* () { + const adapter = yield* DevinAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("devin-mock-thread"); + const workspace = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-workspace-")), + ); + const requestLogPath = NodePath.join(workspace, "requests.ndjson"); + const argvLogPath = NodePath.join(workspace, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* settings.updateSettings({ providers: { devin: { binaryPath: wrapperPath } } }); + + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: workspace, + runtimeMode: "full-access", + modelSelection: { + instanceId: ProviderInstanceId.make("devin"), + model: "composer-2", + }, + }); + + assert.equal(session.provider, "devin"); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + + yield* adapter.sendTurn({ + threadId, + input: "hello mock", + attachments: [], + }); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const types = runtimeEvents.map((event) => event.type); + for (const t of [ + "session.started", + "session.state.changed", + "thread.started", + "turn.started", + "content.delta", + "turn.completed", + ] as const) { + assert.include(types, t); + } + + const delta = runtimeEvents.find((event) => event.type === "content.delta"); + if (delta?.type === "content.delta") { + assert.equal(delta.payload.delta, "hello from mock"); + } + + yield* adapter.stopSession(threadId); + + // full-access spawns `devin --permission-mode bypass acp`. + const argv = yield* Effect.promise(() => readArgvLog(argvLogPath)); + assert.deepStrictEqual(argv, [["--permission-mode", "bypass", "acp"]]); + + // Model selection goes through the model config option. The mock has + // no `bypass` mode, so full-access leaves the session mode alone. + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const configRequests = requests + .filter((entry) => entry.method === "session/set_config_option") + .map((entry) => { + const params = entry.params as { configId: string; value: unknown }; + return { configId: params.configId, value: params.value }; + }); + assert.includeDeepMembers(configRequests, [{ configId: "model", value: "composer-2" }]); + }), + ); + + it.effect("maps approval-required to --permission-mode normal at spawn", () => + Effect.gen(function* () { + const adapter = yield* DevinAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("devin-approval-thread"); + const workspace = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-approval-")), + ); + const argvLogPath = NodePath.join(workspace, "argv.txt"); + const requestLogPath = NodePath.join(workspace, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* settings.updateSettings({ providers: { devin: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: workspace, + runtimeMode: "approval-required", + }); + yield* adapter.stopSession(threadId); + + const argv = yield* Effect.promise(() => readArgvLog(argvLogPath)); + assert.deepStrictEqual(argv, [["--permission-mode", "normal", "acp"]]); + + // The mock starts in the read-only `ask` mode; approval-required must + // restore the least-privileged writable mode so work can proceed. + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const modeRequests = requests + .filter((entry) => entry.method === "session/set_config_option") + .map((entry) => { + const params = entry.params as { configId: string; value: unknown }; + return { configId: params.configId, value: params.value }; + }) + .filter((params) => params.configId === "mode"); + assert.deepStrictEqual(modeRequests, [{ configId: "mode", value: "code" }]); + }), + ); + + it.effect("routes plan turns to the agent's plan/architect mode", () => + Effect.gen(function* () { + const adapter = yield* DevinAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("devin-plan-thread"); + const workspace = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-plan-")), + ); + const requestLogPath = NodePath.join(workspace, "requests.ndjson"); + const argvLogPath = NodePath.join(workspace, "argv.txt"); + yield* Effect.promise(() => NodeFSP.writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + yield* settings.updateSettings({ providers: { devin: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: workspace, + runtimeMode: "auto-accept-edits", + }); + yield* adapter.sendTurn({ + threadId, + input: "plan this out", + attachments: [], + interactionMode: "plan", + }); + yield* adapter.stopSession(threadId); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const modeRequests = requests + .filter((entry) => entry.method === "session/set_config_option") + .map((entry) => { + const params = entry.params as { configId: string; value: unknown }; + return { configId: params.configId, value: params.value }; + }) + .filter((params) => params.configId === "mode"); + // Session start maps auto-accept-edits -> code; the plan turn then + // maps to the mock's architect mode. + assert.deepStrictEqual(modeRequests, [ + { configId: "mode", value: "code" }, + { configId: "mode", value: "architect" }, + ]); + }), + ); + + it.effect("surfaces a permission request and resolves it on respondToRequest", () => + Effect.gen(function* () { + const adapter = yield* DevinAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("devin-permission-thread"); + + const wrapperPath = yield* Effect.promise(() => + makeMockAgentWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + yield* settings.updateSettings({ providers: { devin: { binaryPath: wrapperPath } } }); + + const approvalRequestedFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event): event is Extract => + event.type === "request.opened" && event.threadId === threadId, + ), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "read package.json", attachments: [] }) + .pipe(Effect.forkChild); + + const approvalEvents = yield* Fiber.join(approvalRequestedFiber); + const approvalRequest = Array.from(approvalEvents)[0]; + assert.isDefined(approvalRequest); + assert.equal(approvalRequest.payload.requestType, "exec_command_approval"); + + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(approvalRequest.requestId)), + "accept", + ); + + const turn = yield* Fiber.join(sendTurnFiber); + assert.equal(turn.threadId, threadId); + yield* adapter.stopSession(threadId); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/DevinAdapter.ts b/apps/server/src/provider/Layers/DevinAdapter.ts new file mode 100644 index 000000000000..2d1cac9933ab --- /dev/null +++ b/apps/server/src/provider/Layers/DevinAdapter.ts @@ -0,0 +1,1087 @@ +/** + * DevinAdapterLive — Devin CLI (`devin acp`) via ACP. + * + * @module DevinAdapterLive + */ + +import { + ApprovalRequestId, + type DevinSettings, + type ProviderOptionSelection, + EventId, + type ProviderApprovalDecision, + type ProviderInteractionMode, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type RuntimeMode, + type ThreadId, + TurnId, + type UserInputQuestion, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Crypto from "effect/Crypto"; +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 Option from "effect/Option"; +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 SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * 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 { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + applyDevinAcpModelSelection, + makeDevinAcpRuntime, + resolveDevinModeId, +} from "../acp/DevinAcpSupport.ts"; +import { type DevinAdapterShape } from "../Services/DevinAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +const PROVIDER = ProviderDriverKind.make("devin"); +const DEVIN_RESUME_VERSION = 1 as const; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface DevinAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + /** + * Selections are honored when `modelSelection.instanceId` matches this value. + * Defaults to the legacy built-in instance id (`devin`). + */ + readonly instanceId?: ProviderInstanceId; + /** + * Optional per-session settings resolver. See `CursorAdapterLiveOptions` + * for the rationale — tests that mutate settings mid-flight pass a resolver + * so the spawn closure isn't stale. + */ + readonly resolveSettings?: Effect.Effect; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; + readonly kind: string | "unknown"; +} + +interface PendingUserInput { + readonly answers: Deferred.Deferred; +} + +interface DevinSessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + /** Number of sendTurn prompts currently in flight or being prepared. + * >0 means a turn is actively running, so a new sendTurn is a steer that + * continues it, and only the last remaining prompt settles the turn. */ + promptsInFlight: number; + stopped: boolean; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingApprovals.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function settlePendingUserInputsAsEmptyAnswers( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingUserInputs.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseDevinResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== DEVIN_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +function applyRequestedSessionConfiguration(input: { + readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode | undefined; + readonly modelSelection: + | { + readonly model: string; + readonly options?: ReadonlyArray | null | undefined; + } + | undefined; + readonly mapError: (context: { + readonly cause: EffectAcpErrors.AcpError; + readonly method: "session/set_config_option" | "session/set_mode"; + }) => E; +}): Effect.Effect { + return Effect.gen(function* () { + if (input.modelSelection) { + yield* applyDevinAcpModelSelection({ + runtime: input.runtime, + model: input.modelSelection.model, + selections: input.modelSelection.options, + mapError: ({ cause }) => + input.mapError({ + cause, + method: "session/set_config_option", + }), + }); + } + + const requestedModeId = resolveDevinModeId({ + interactionMode: input.interactionMode, + runtimeMode: input.runtimeMode, + modeState: yield* input.runtime.getModeState, + }); + if (!requestedModeId) { + return; + } + + yield* input.runtime.setMode(requestedModeId).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + method: "session/set_mode", + }), + ), + ); + }); +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always"); + if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) { + return allowAlwaysOption.optionId.trim(); + } + + const allowOnceOption = request.options.find((option) => option.kind === "allow_once"); + if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) { + return allowOnceOption.optionId.trim(); + } + + return undefined; +} + +/** + * Map a `session/elicitation` form request to user-input questions. Enum + * properties become fixed choices; everything else takes a custom answer. + * URL-mode elicitations cannot be answered in the chat UI. + */ +function toElicitationContentValue( + value: unknown, +): string | number | boolean | ReadonlyArray | undefined { + if (typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (Array.isArray(value)) { + const strings = value.filter((entry): entry is string => typeof entry === "string"); + return strings.length === value.length ? strings : undefined; + } + return undefined; +} + +function extractElicitationQuestions( + params: Extract, +): ReadonlyArray { + const properties = params.requestedSchema.properties ?? {}; + return Object.entries(properties).map(([id, property]) => { + const options = + "enum" in property && Array.isArray(property.enum) + ? property.enum + .filter((value): value is string => typeof value === "string") + .map((value) => ({ label: value, description: value })) + : []; + return { + id, + header: property.title?.trim() || id, + question: property.description?.trim() || property.title?.trim() || params.message, + options, + allowCustomAnswer: true, + multiSelect: property.type === "array", + }; + }); +} + +export function makeDevinAdapter(devinSettings: DevinSettings, options?: DevinAdapterLiveOptions) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("devin"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate Devin runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const mapRequestFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Devin ACP event.", + cause, + }), + ), + ); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const logNative = (threadId: ThreadId, method: string, payload: unknown) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }); + + const emitPlanUpdate = ( + ctx: DevinSessionContext, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + ) => + Effect.gen(function* () { + const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload, + source: "acp.jsonrpc", + method: "session/update", + rawPayload, + }), + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: DevinSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: DevinAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const devinModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const pendingApprovals = new Map(); + const pendingUserInputs = new Map(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + let ctx!: DevinSessionContext; + + const resumeSessionId = parseDevinResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + const effectiveDevinSettings = options?.resolveSettings + ? yield* options.resolveSettings + : devinSettings; + + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const acp = yield* makeDevinAcpRuntime({ + devinSettings: effectiveDevinSettings, + ...(options?.environment || mcpSession?.agentDeviceEnvironment + ? { + environment: McpProviderSession.withAgentDeviceEnvironment( + options?.environment ?? process.env, + mcpSession, + ), + } + : {}), + childProcessSpawner, + cwd, + runtimeMode: input.runtimeMode, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const started = yield* Effect.gen(function* () { + yield* acp.handleElicitation((params) => + mapRequestFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, "session/elicitation", params); + if (params.mode !== "form") { + return { action: { action: "cancel" as const } }; + } + const questions = extractElicitationQuestions(params); + if (questions.length === 0) { + return { action: { action: "cancel" as const } }; + } + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const answers = yield* Deferred.make(); + pendingUserInputs.set(requestId, { answers }); + yield* offerRuntimeEvent({ + type: "user-input.requested", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + payload: { questions }, + raw: { + source: "acp.jsonrpc", + method: "session/elicitation", + payload: params, + }, + }); + const resolved = yield* Deferred.await(answers); + pendingUserInputs.delete(requestId); + yield* offerRuntimeEvent({ + type: "user-input.resolved", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + payload: { answers: resolved }, + }); + if (Object.keys(resolved).length === 0) { + return { action: { action: "cancel" as const } }; + } + const content: Record> = + {}; + for (const [key, value] of Object.entries(resolved)) { + const coerced = toElicitationContentValue(value); + if (coerced !== undefined) { + content[key] = coerced; + } + } + return { + action: { + action: "accept" as const, + content, + }, + }; + }), + ), + ); + yield* acp.handleRequestPermission((params) => + mapRequestFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, "session/request_permission", params); + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { + decision, + kind: permissionRequest.kind, + }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + return { + outcome: + resolved === "cancel" + ? ({ outcome: "cancelled" } as const) + : { + outcome: "selected" as const, + optionId: acpPermissionOutcome(resolved), + }, + }; + }), + ), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + yield* applyRequestedSessionConfiguration({ + runtime: acp, + runtimeMode: input.runtimeMode, + interactionMode: undefined, + modelSelection: devinModelSelection, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: devinModelSelection?.model, + threadId: input.threadId, + resumeCursor: { + schemaVersion: DEVIN_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + ctx = { + threadId: input.threadId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + promptsInFlight: 0, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + switch (event._tag) { + case "EventStreamBarrier": + yield* Deferred.succeed(event.acknowledge, undefined); + return; + case "ModeChanged": + return; + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* emitPlanUpdate(ctx, event.payload, event.rawPayload); + return; + case "ToolCallUpdated": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Devin runtime notification.", { cause }), + ), + // Fork into the session scope, not the calling fiber — see the + // equivalent comment in CursorAdapter for why `forkIn` is + // required here. + Effect.forkIn(ctx.scope), + ); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Devin ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: DevinAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + // A sendTurn while a prompt is in flight is a steer: the agent folds + // the new prompt into the ongoing work, so the active turn id is + // reused instead of opening a new turn. + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + // Count this prompt immediately so a superseded in-flight prompt + // resolving from here on does not settle the turn; the matching + // decrement is the `ensuring` below. + ctx.promptsInFlight += 1; + + return yield* Effect.gen(function* () { + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const model = turnModelSelection?.model ?? ctx.session.model; + yield* applyRequestedSessionConfiguration({ + runtime: ctx.acp, + runtimeMode: ctx.session.runtimeMode, + interactionMode: input.interactionMode, + modelSelection: + model === undefined + ? undefined + : { + model, + options: turnModelSelection?.options, + }, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + ctx.activeTurnId = turnId; + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model }, + }); + } + + const promptParts: Array = []; + const rawPrompt = input.input?.trim() ?? ""; + if (rawPrompt) { + promptParts.push({ type: "text", text: rawPrompt }); + } + if (input.attachments && input.attachments.length > 0) { + for (const attachment of input.attachments) { + // Devin ingests images only. Generic files reach the agent + // through the path line ProviderService puts in the prompt. + if (attachment.type !== "image") { + continue; + } + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + promptParts.push({ + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }); + } + } + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + // ACP has no system-message field; keep runtime context separate from the user's text. + const result = yield* ctx.acp + .prompt({ + prompt: [ + ...promptParts, + { + type: "text", + text: buildRuntimeInstructions({ harness: "Devin", model }), + }, + ], + }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + yield* ctx.acp.drainEvents; + + const turnRecord = ctx.turns.find((turn) => turn.id === turnId); + if (turnRecord) { + turnRecord.items.push({ prompt: promptParts, result }); + } else { + ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + model, + }; + + // Only the last remaining prompt settles the turn — a steer- + // superseded prompt resolving (usually cancelled) while another is + // in flight or pending must leave the merged turn running. + if (ctx.promptsInFlight === 1) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason ?? null, + }, + }); + } + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }).pipe( + Effect.ensuring( + Effect.sync(() => { + ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); + }), + ), + ); + }); + + const interruptTurn: DevinAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + }); + + const respondToRequest: DevinAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: DevinAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/elicitation", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.answers, answers); + }); + + const readThread: DevinAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: DevinAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + const nextLength = Math.max(0, ctx.turns.length - numTurns); + ctx.turns.splice(nextLength); + return { threadId, turns: ctx.turns }; + }); + + const stopSession: DevinAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: DevinAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: DevinAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: DevinAdapterShape["stopAll"] = () => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( + Effect.catch((cause) => + Effect.logError("Failed to emit Devin session shutdown event.", { cause }), + ), + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies DevinAdapterShape; + }); +} 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..ef89d659960f --- /dev/null +++ b/apps/server/src/provider/Layers/DevinProvider.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { devinModelsFromCatalog, parseDevinAuthStatus } from "./DevinProvider.ts"; + +const SAMPLE_MODELS_JSON = { + families: [ + { + family_uid: "swe-2", + slug: "swe-2", + aliases: ["swe"], + variants: [ + { model_uid: "swe-2-high", label: "SWE-2 High", is_new: true }, + { model_uid: "swe-2-medium", label: "SWE-2 Medium" }, + { model_uid: "swe-2-max", label: "SWE-2 Max" }, + ], + }, + { + family_uid: "devin-core", + slug: "devin-core", + aliases: ["adaptive"], + variants: [{ model_uid: "adaptive", label: "Adaptive" }], + }, + ], +}; + +describe("devinModelsFromCatalog", () => { + it("flattens families into ServerProviderModel entries", () => { + const models = devinModelsFromCatalog(SAMPLE_MODELS_JSON); + expect(models.map((model) => model.slug)).toEqual([ + "swe-2-high", + "swe-2-medium", + "swe-2-max", + "adaptive", + ]); + + const sweHigh = models[0]; + expect(sweHigh).toMatchObject({ + name: "SWE-2 High", + subProvider: "swe-2", + aliases: ["swe"], + badge: "new", + isCustom: false, + isDefault: false, + }); + }); + + it("marks adaptive as the default model", () => { + const models = devinModelsFromCatalog(SAMPLE_MODELS_JSON); + expect(models.find((model) => model.slug === "adaptive")?.isDefault).toBe(true); + }); + + it("returns an empty list for missing or malformed input", () => { + expect(devinModelsFromCatalog(undefined)).toEqual([]); + expect(devinModelsFromCatalog({})).toEqual([]); + expect( + devinModelsFromCatalog({ families: [{ variants: [{ model_uid: " ", label: "x" }] }] }), + ).toEqual([]); + }); +}); + +describe("parseDevinAuthStatus", () => { + it("marks a logged-in CLI as authenticated", () => { + const result = parseDevinAuthStatus({ + code: 0, + stdout: + "Logged in (via Devin).\n Name: adam\n Email: adam@example.com\n", + stderr: "", + }); + expect(result.status).toBe("ready"); + expect(result.auth.status).toBe("authenticated"); + expect(result.auth.email).toBe("adam@example.com"); + }); + + it("marks a logged-out CLI as unauthenticated", () => { + const result = parseDevinAuthStatus({ + code: 1, + stdout: "", + stderr: "Not logged in.", + }); + expect(result.status).toBe("error"); + expect(result.auth.status).toBe("unauthenticated"); + }); + + it("treats an unrecognized zero-exit output as ready with unknown auth", () => { + const result = parseDevinAuthStatus({ code: 0, stdout: "something else", stderr: "" }); + expect(result.status).toBe("ready"); + expect(result.auth.status).toBe("unknown"); + }); +}); diff --git a/apps/server/src/provider/Layers/DevinProvider.ts b/apps/server/src/provider/Layers/DevinProvider.ts new file mode 100644 index 000000000000..d03fad692c68 --- /dev/null +++ b/apps/server/src/provider/Layers/DevinProvider.ts @@ -0,0 +1,471 @@ +/** + * DevinProvider — status + model-catalog snapshot for the Devin CLI. + * + * Probes `devin --version`, `devin auth status`, and + * `devin models list --format json`. ACP `session/new` exposes the same model + * list as a `model` config option, but spawning a session just to read the + * catalog would boot the agent on every health refresh — the JSON listing is + * cheaper and needs no session. + * + * @module DevinProvider + */ +import type { + DevinSettings, + ModelCapabilities, + ServerProvider, + ServerProviderAuth, + ServerProviderModel, + ServerProviderState, +} from "@t3tools/contracts"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type CommandResult, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +const PROVIDER_KIND = ProviderDriverKind.make("devin"); + +const DEVIN_PRESENTATION = { + displayName: "Devin", + showInteractionModeToggle: true, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = { optionDescriptors: [] }; + +const ABOUT_TIMEOUT_MS = 10_000; +const MODELS_TIMEOUT_MS = 15_000; +const DEVIN_CLI_INSTALLATION_DOCS_URL = "https://devin.ai/cli"; + +function joinProviderMessages(...messages: ReadonlyArray): string | undefined { + const parts: Array = []; + for (const message of messages) { + const trimmed = message?.trim(); + if (trimmed) { + parts.push(trimmed); + } + } + return parts.length > 0 ? parts.join(" ") : undefined; +} + +function buildDevinCliCommandMissingMessage(binaryPath: string): string { + return [ + `Devin CLI command \`${binaryPath}\` was not found.`, + `Install the Devin CLI, make sure \`${binaryPath}\` is on PATH, then restart T3 Code.`, + `See ${DEVIN_CLI_INSTALLATION_DOCS_URL}.`, + ].join(" "); +} + +// ── `devin models list --format json` ──────────────────────────────────── + +interface DevinModelVariant { + readonly model_uid: string; + readonly label: string; + readonly is_new?: boolean; + readonly is_beta?: boolean; +} + +interface DevinModelFamily { + readonly family_uid?: string; + readonly slug?: string; + readonly aliases?: ReadonlyArray; + readonly variants?: ReadonlyArray; +} + +interface DevinModelsListJson { + readonly families?: ReadonlyArray; +} + +function parseDevinModelsJson(raw: string): DevinModelsListJson | undefined { + const trimmed = raw.trim(); + if (!trimmed.startsWith("{")) { + return undefined; + } + try { + const parsed = JSON.parse(trimmed) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return undefined; + } + return parsed as DevinModelsListJson; + } catch { + return undefined; + } +} + +/** `adaptive` is Devin's recommended auto-router and the picker default. */ +const DEVIN_DEFAULT_MODEL_SLUG = "adaptive"; + +export function devinModelsFromCatalog( + parsed: DevinModelsListJson | undefined, +): ReadonlyArray { + const models: ServerProviderModel[] = []; + for (const family of parsed?.families ?? []) { + const familySlug = typeof family.slug === "string" ? family.slug.trim() : ""; + const familyAliases = (family.aliases ?? []).filter( + (alias): alias is string => typeof alias === "string" && alias.trim().length > 0, + ); + for (const variant of family.variants ?? []) { + const slug = typeof variant.model_uid === "string" ? variant.model_uid.trim() : ""; + if (!slug) continue; + const name = + typeof variant.label === "string" && variant.label.trim() ? variant.label.trim() : slug; + models.push({ + slug, + name, + ...(familySlug ? { subProvider: familySlug } : {}), + ...(familyAliases.length > 0 ? { aliases: [...familyAliases] } : {}), + ...(variant.is_new === true ? { badge: "new" as const } : {}), + isCustom: false, + isDefault: slug === DEVIN_DEFAULT_MODEL_SLUG, + capabilities: null, + }); + } + } + return models; +} + +/** Static fallback so the picker is never empty when discovery fails. */ +const DEVIN_FALLBACK_MODELS: ReadonlyArray = [ + "adaptive", + "swe-2-high", + "swe-2-medium", + "swe-2-max", + "swe-1-7", + "swe-1-7-lightning", + "swe-1-6-fast", +].map((slug) => ({ + slug, + name: slug, + isCustom: false, + isDefault: slug === DEVIN_DEFAULT_MODEL_SLUG, + capabilities: null, +})); + +function getDevinFallbackModels(devinSettings: DevinSettings): ReadonlyArray { + return providerModelsFromSettings( + DEVIN_FALLBACK_MODELS, + devinSettings.customModels, + EMPTY_CAPABILITIES, + ); +} + +// ── `devin auth status` ────────────────────────────────────────────────── + +export interface DevinProbeResult { + readonly version: string | null; + readonly status: Exclude; + readonly auth: ServerProviderAuth; + readonly message?: string; +} + +export function parseDevinAuthStatus( + result: CommandResult, +): Pick { + const combined = `${result.stdout}\n${result.stderr}`; + const lower = combined.toLowerCase(); + + if ( + lower.includes("not logged in") || + lower.includes("not authenticated") || + lower.includes("no credentials") + ) { + return { + status: "error", + auth: { status: "unauthenticated" }, + message: "Devin CLI is not authenticated. Run `devin auth login` and try again.", + }; + } + + if (lower.includes("logged in")) { + const emailMatch = + result.stdout.match(/^\s*Email:\s+(.+)$/m) ?? result.stdout.match(/^\s*Name:\s+(.+)$/m); + const email = emailMatch?.[1]?.trim(); + return { + status: "ready", + auth: { + status: "authenticated", + ...(email ? { email } : {}), + }, + }; + } + + if (result.code === 0) { + return { status: "ready", auth: { status: "unknown" } }; + } + return { + status: "warning", + auth: { status: "unknown" }, + message: "Could not verify Devin CLI authentication status.", + }; +} + +const runDevinCommand = ( + devinSettings: DevinSettings, + args: ReadonlyArray, + environment?: NodeJS.ProcessEnv, +) => + Effect.gen(function* () { + const spawnCommand = yield* resolveSpawnCommand( + devinSettings.binaryPath || "devin", + args, + environment ? { env: environment } : {}, + ); + return yield* spawnAndCollect( + devinSettings.binaryPath || "devin", + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(environment ? { env: environment } : { extendEnv: true }), + shell: spawnCommand.shell, + }), + ); + }).pipe(Effect.scoped); + +export function buildDevinProviderSnapshot(input: { + readonly checkedAt: string; + readonly devinSettings: DevinSettings; + readonly parsed: DevinProbeResult; + readonly discoveredModels?: ReadonlyArray; + readonly discoveryWarning?: string; +}): ServerProviderDraft { + const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); + return buildServerProvider({ + driver: PROVIDER_KIND, + presentation: DEVIN_PRESENTATION, + enabled: input.devinSettings.enabled, + checkedAt: input.checkedAt, + models: providerModelsFromSettings( + input.discoveredModels ?? [], + input.devinSettings.customModels, + EMPTY_CAPABILITIES, + ), + probe: { + installed: true, + version: input.parsed.version, + status: + input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status, + auth: input.parsed.auth, + ...(message ? { message } : {}), + }, + }); +} + +export const checkDevinProviderStatus = Effect.fn("checkDevinProviderStatus")(function* ( + devinSettings: DevinSettings, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = getDevinFallbackModels(devinSettings); + + if (!devinSettings.enabled) { + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Devin is disabled in T3 Code settings.", + }, + }); + } + + const probeFailure = (message: string, installed = true): ServerProviderDraft => + buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: devinSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed, + version: null, + status: "error", + auth: { status: "unknown" }, + message, + }, + }); + + // Version probe first — it proves the binary exists and is executable. + const versionProbe = yield* runDevinCommand(devinSettings, ["--version"], environment).pipe( + Effect.timeoutOption(ABOUT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + yield* Effect.logWarning("Devin CLI health check failed.", { errorTag: error._tag }); + return probeFailure( + isCommandMissingCause(error) + ? buildDevinCliCommandMissingMessage(devinSettings.binaryPath || "devin") + : "Failed to execute Devin CLI health check.", + !isCommandMissingCause(error), + ); + } + if (Option.isNone(versionProbe.success)) { + return probeFailure("Devin CLI is installed but timed out while running `devin --version`."); + } + const version = parseGenericCliVersion( + `${versionProbe.success.value.stdout}\n${versionProbe.success.value.stderr}`, + ); + + // Auth probe. + const authProbe = yield* runDevinCommand(devinSettings, ["auth", "status"], environment).pipe( + Effect.timeoutOption(ABOUT_TIMEOUT_MS), + Effect.result, + ); + + const parsed: DevinProbeResult = (() => { + if (Result.isFailure(authProbe)) { + return { + version, + status: "warning", + auth: { status: "unknown" }, + message: "Could not run `devin auth status`.", + }; + } + if (Option.isNone(authProbe.success)) { + return { + version, + status: "warning", + auth: { status: "unknown" }, + message: "Devin CLI timed out while running `devin auth status`.", + }; + } + const auth = parseDevinAuthStatus(authProbe.success.value); + return { version, ...auth }; + })(); + + // Model catalog — skipped when unauthenticated since the CLI cannot list + // anything useful then. + let discoveredModels = Option.none>(); + let discoveryWarning: string | undefined; + if (parsed.auth.status !== "unauthenticated") { + const modelsExit = yield* Effect.exit( + runDevinCommand(devinSettings, ["models", "list", "--format", "json"], environment).pipe( + Effect.timeoutOption(MODELS_TIMEOUT_MS), + ), + ); + if (Exit.isFailure(modelsExit)) { + yield* Effect.logWarning("Devin model discovery failed", { + errorTag: causeErrorTag(modelsExit.cause), + }); + discoveryWarning = "Devin model discovery failed."; + } else if (Option.isNone(modelsExit.value)) { + discoveryWarning = "Devin model discovery timed out."; + } else { + const models = devinModelsFromCatalog(parseDevinModelsJson(modelsExit.value.value.stdout)); + if (models.length === 0) { + discoveryWarning = "Devin model discovery returned no models."; + } else { + discoveredModels = Option.some(models); + } + } + } + + return buildDevinProviderSnapshot({ + checkedAt, + devinSettings, + parsed, + discoveredModels: Option.getOrElse( + Option.filter(discoveredModels, (models) => models.length > 0), + () => fallbackModels, + ), + ...(discoveryWarning ? { discoveryWarning } : {}), + }); +}); + +export const buildInitialDevinProviderSnapshot = ( + devinSettings: DevinSettings, +): Effect.Effect => + Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = getDevinFallbackModels(devinSettings); + + if (!devinSettings.enabled) { + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Devin is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Devin CLI availability...", + }, + }); + }); + +/** + * Background maintenance enrichment: republishes update/version advisory + * metadata without re-running model discovery. + */ +export const enrichDevinSnapshot = (input: { + readonly settings: DevinSettings; + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { settings, snapshot, publishSnapshot } = input; + const stampIdentity = input.stampIdentity ?? ((value) => value); + + if (!settings.enabled || snapshot.auth.status === "unauthenticated") { + return Effect.void; + } + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => + publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)), + ), + Effect.catchCause((cause) => + Effect.logWarning("Devin version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }).pipe(Effect.asVoid), + ), + ); +}; diff --git a/apps/server/src/provider/Services/DevinAdapter.ts b/apps/server/src/provider/Services/DevinAdapter.ts new file mode 100644 index 000000000000..be32a25cd1aa --- /dev/null +++ b/apps/server/src/provider/Services/DevinAdapter.ts @@ -0,0 +1,17 @@ +/** + * DevinAdapter — shape type for the Devin provider adapter. + * + * Mirrors {@link ./CursorAdapter.ts}: the driver model + * ({@link ../Drivers/DevinDriver.ts}) bundles one adapter per instance as a + * captured closure, so only the shape interface lives here. + * + * @module DevinAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * DevinAdapterShape — per-instance Devin adapter contract. Carries a branded + * driver kind as the nominal discriminant. + */ +export interface DevinAdapterShape extends ProviderAdapterShape {} 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..84c8ae82f988 --- /dev/null +++ b/apps/server/src/provider/acp/DevinAcpSupport.test.ts @@ -0,0 +1,189 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import type { AcpSessionModeState } from "./AcpRuntimeModel.ts"; +import { + applyDevinAcpModelSelection, + buildDevinAcpSpawnInput, + resolveDevinModeId, +} from "./DevinAcpSupport.ts"; + +describe("buildDevinAcpSpawnInput", () => { + it("builds the default Devin ACP command", () => { + assert.deepStrictEqual(buildDevinAcpSpawnInput(undefined, "/tmp/project"), { + command: "devin", + args: ["acp"], + cwd: "/tmp/project", + }); + }); + + it("honors the configured binary path", () => { + assert.deepStrictEqual( + buildDevinAcpSpawnInput({ binaryPath: "/usr/local/bin/devin" }, "/tmp/project"), + { + command: "/usr/local/bin/devin", + args: ["acp"], + cwd: "/tmp/project", + }, + ); + }); + + it.each([ + ["approval-required", ["--permission-mode", "normal", "acp"]], + ["auto-accept-edits", ["--permission-mode", "accept-edits", "acp"]], + ["auto", ["--permission-mode", "smart", "acp"]], + ["full-access", ["--permission-mode", "bypass", "acp"]], + ] as const)("maps %s to %j", (runtimeMode, args) => { + assert.deepStrictEqual( + buildDevinAcpSpawnInput(undefined, "/tmp/project", undefined, runtimeMode).args, + args, + ); + }); +}); + +const DEVIN_MODES: AcpSessionModeState["availableModes"] = [ + { id: "accept-edits", name: "Code" }, + { id: "smart", name: "Smart" }, + { id: "ask", name: "Ask" }, + { id: "plan", name: "Plan" }, + { id: "bypass", name: "Bypass Permissions" }, +]; + +function modeState(currentModeId: string): AcpSessionModeState { + return { currentModeId, availableModes: DEVIN_MODES }; +} + +describe("resolveDevinModeId", () => { + it("maps plan interaction mode to Devin's plan mode", () => { + assert.strictEqual( + resolveDevinModeId({ + interactionMode: "plan", + runtimeMode: "full-access", + modeState: modeState("accept-edits"), + }), + "plan", + ); + }); + + it.each([ + ["auto-accept-edits", "accept-edits"], + ["auto", "smart"], + ["full-access", "bypass"], + ] as const)("maps %s to %s", (runtimeMode, expected) => { + assert.strictEqual( + resolveDevinModeId({ + interactionMode: "default", + runtimeMode, + modeState: modeState("accept-edits"), + }), + expected, + ); + }); + + it("leaves approval-required untouched while a writable mode is active", () => { + assert.isUndefined( + resolveDevinModeId({ + interactionMode: "default", + runtimeMode: "approval-required", + modeState: modeState("accept-edits"), + }), + ); + }); + + it("escapes a read-only mode for approval-required after a plan turn", () => { + assert.strictEqual( + resolveDevinModeId({ + interactionMode: "default", + runtimeMode: "approval-required", + modeState: modeState("plan"), + }), + "accept-edits", + ); + }); + + it("returns undefined when no mode state is known", () => { + assert.isUndefined( + resolveDevinModeId({ + interactionMode: "plan", + runtimeMode: "full-access", + modeState: undefined, + }), + ); + }); +}); + +describe("applyDevinAcpModelSelection", () => { + it.effect("applies the model through the model config option", () => + Effect.gen(function* () { + const calls: Array< + | { readonly type: "model"; readonly value: string } + | { readonly type: "config"; readonly configId: string; readonly value: string | boolean } + > = []; + + const runtime = { + getConfigOptions: Effect.succeed([ + { + id: "model", + name: "Model", + category: "model", + type: "select" as const, + currentValue: "adaptive", + options: [{ value: "adaptive", name: "Adaptive" }], + }, + { + id: "mode", + name: "Mode", + category: "mode", + type: "select" as const, + currentValue: "accept-edits", + options: [{ value: "accept-edits", name: "Code" }], + }, + ]), + setModel: (value: string) => + Effect.sync(() => { + calls.push({ type: "model", value }); + }), + setConfigOption: (configId: string, value: string | boolean) => + Effect.sync(() => { + calls.push({ type: "config", configId, value }); + }), + }; + + yield* applyDevinAcpModelSelection({ + runtime, + model: "swe-2-high", + selections: [{ id: "mode", value: "plan" }], + mapError: ({ cause }) => cause, + }); + + assert.deepStrictEqual(calls, [ + { type: "model", value: "swe-2-high" }, + { type: "config", configId: "mode", value: "plan" }, + ]); + }), + ); + + it.effect("skips set_model when no model is selected", () => + Effect.gen(function* () { + let called = false; + const runtime = { + getConfigOptions: Effect.succeed([] as ReadonlyArray), + setModel: () => + Effect.sync(() => { + called = true; + }), + setConfigOption: () => Effect.void, + }; + + yield* applyDevinAcpModelSelection({ + runtime, + model: undefined, + selections: [], + mapError: ({ cause }) => cause, + }); + + assert.isFalse(called); + }), + ); +}); diff --git a/apps/server/src/provider/acp/DevinAcpSupport.ts b/apps/server/src/provider/acp/DevinAcpSupport.ts new file mode 100644 index 000000000000..ed34f11615d1 --- /dev/null +++ b/apps/server/src/provider/acp/DevinAcpSupport.ts @@ -0,0 +1,240 @@ +/** + * DevinAcpSupport — spawn and session helpers for `devin acp`. + * + * Devin's ACP server exposes a merged "Session Mode" selector (accept-edits, + * smart, ask, plan, bypass) plus a `model` config option. The CLI's most + * restrictive writable permission mode (`normal`) is not in that selector — + * it is only reachable through the `--permission-mode` spawn flag, so T3's + * "approval-required" maps to the flag at spawn and to `accept-edits` (the + * least-privileged writable ACP mode) when the session mode must be restored + * in-session. + * + * @module DevinAcpSupport + */ +import { + type DevinSettings, + type ProviderOptionSelection, + type RuntimeMode, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +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 type { AcpSessionModeState } from "./AcpRuntimeModel.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +type DevinAcpRuntimeDevinSettings = Pick; + +/** + * T3 runtime mode → Devin `--permission-mode`. `normal` auto-approves + * read-only work and prompts for risky actions; `bypass` auto-approves all. + */ +function devinAcpPermissionArgs(runtimeMode?: RuntimeMode): ReadonlyArray { + switch (runtimeMode) { + case "approval-required": + return ["--permission-mode", "normal"]; + case "auto-accept-edits": + return ["--permission-mode", "accept-edits"]; + case "auto": + return ["--permission-mode", "smart"]; + case "full-access": + return ["--permission-mode", "bypass"]; + default: + return []; + } +} + +export interface DevinAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly devinSettings: DevinAcpRuntimeDevinSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; + readonly runtimeMode?: RuntimeMode; +} + +export interface DevinAcpModelSelectionErrorContext { + readonly cause: EffectAcpErrors.AcpError; + readonly step: "set-config-option" | "set-model"; + readonly configId?: string; +} + +// Devin reads and writes the workspace itself; T3 does not proxy fs or +// terminal I/O for it. +export const DEVIN_ACP_CLIENT_CAPABILITIES = { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, +} satisfies NonNullable; + +export function buildDevinAcpSpawnInput( + devinSettings: DevinAcpRuntimeDevinSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, + runtimeMode?: RuntimeMode, +): AcpSessionRuntime.AcpSpawnInput { + return { + command: devinSettings?.binaryPath || "devin", + args: [...devinAcpPermissionArgs(runtimeMode), "acp"], + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +export const makeDevinAcpRuntime = ( + input: DevinAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildDevinAcpSpawnInput( + input.devinSettings, + input.cwd, + input.environment, + input.runtimeMode, + ), + authMethodId: "devin-browser", + clientCapabilities: DEVIN_ACP_CLIENT_CAPABILITIES, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +const DEVIN_PLAN_MODE_ALIASES = ["plan", "architect"]; +const DEVIN_READ_ONLY_MODE_IDS = new Set(["plan", "ask"]); +// First match in the session's advertised modes wins. +const DEVIN_MODE_BY_RUNTIME_MODE: Partial>> = { + "auto-accept-edits": ["accept-edits", "code"], + auto: ["smart"], + "full-access": ["bypass"], +}; + +function normalizeModeSearchText(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function findModeByAliases( + modes: ReadonlyArray<{ readonly id: string; readonly name: string }>, + aliases: ReadonlyArray, +): { readonly id: string } | undefined { + const normalizedAliases = aliases.map((alias) => alias.toLowerCase()); + for (const alias of normalizedAliases) { + const exact = modes.find( + (mode) => mode.id.toLowerCase() === alias || mode.name.toLowerCase() === alias, + ); + if (exact) return exact; + } + for (const alias of normalizedAliases) { + const partial = modes.find((mode) => + normalizeModeSearchText(`${mode.id} ${mode.name}`).includes(alias), + ); + if (partial) return partial; + } + return undefined; +} + +/** + * Resolve the Devin session mode for a turn. Returns `undefined` when the + * current mode already expresses the requested posture, leaving it alone. + */ +export function resolveDevinModeId(input: { + readonly interactionMode: "default" | "plan" | undefined; + readonly runtimeMode: RuntimeMode; + readonly modeState: AcpSessionModeState | undefined; +}): string | undefined { + const modeState = input.modeState; + if (!modeState) { + return undefined; + } + + if (input.interactionMode === "plan") { + return findModeByAliases(modeState.availableModes, DEVIN_PLAN_MODE_ALIASES)?.id; + } + + const preferredIds = DEVIN_MODE_BY_RUNTIME_MODE[input.runtimeMode]; + if (preferredIds !== undefined) { + return findModeByAliases(modeState.availableModes, preferredIds)?.id; + } + + // approval-required: `normal` is spawn-flag only. If an earlier plan/ask + // turn left a read-only mode active, restore the least-privileged writable + // mode so the agent can keep working under supervision. + if (DEVIN_READ_ONLY_MODE_IDS.has(modeState.currentModeId)) { + return ( + findModeByAliases(modeState.availableModes, ["accept-edits", "code"])?.id ?? + modeState.availableModes.find((mode) => !DEVIN_READ_ONLY_MODE_IDS.has(mode.id))?.id + ); + } + return undefined; +} + +interface DevinAcpModelSelectionRuntime { + readonly getConfigOptions: AcpSessionRuntime.AcpSessionRuntime["Service"]["getConfigOptions"]; + readonly setConfigOption: ( + configId: string, + value: string | boolean, + ) => Effect.Effect; + readonly setModel: (model: string) => Effect.Effect; +} + +export function applyDevinAcpModelSelection(input: { + readonly runtime: DevinAcpModelSelectionRuntime; + readonly model: string | null | undefined; + readonly selections: ReadonlyArray | null | undefined; + readonly mapError: (context: DevinAcpModelSelectionErrorContext) => E; +}): Effect.Effect { + return Effect.gen(function* () { + const model = input.model?.trim(); + if (model) { + yield* input.runtime.setModel(model).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + step: "set-model", + }), + ), + ); + } + + // Devin's session config surface is just `model` + `mode`, but apply any + // selection that happens to match an advertised config option so future + // Devin options light up without an adapter change. + const configOptions = yield* input.runtime.getConfigOptions; + for (const selection of input.selections ?? []) { + const option = configOptions.find((candidate) => candidate.id === selection.id); + if (!option || option.id === "model") continue; + yield* input.runtime + .setConfigOption( + option.id, + typeof selection.value === "boolean" ? selection.value : String(selection.value), + ) + .pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + step: "set-config-option", + configId: option.id, + }), + ), + ); + } + }); +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 60e3402eed42..8630252ec2d6 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -23,6 +23,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; +import { DevinDriver, type DevinDriverEnv } from "./Drivers/DevinDriver.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"; @@ -37,6 +38,7 @@ export type BuiltInDriversEnv = | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv + | DevinDriverEnv | GrokDriverEnv | OpenCodeDriverEnv | AntigravityDriverEnv; @@ -50,6 +52,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray = [ "codex", "claudeAgent", "cursor", + "devin", "grok", "opencode", "antigravity", diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 5d2571e72dc3..cb5ca264dd5d 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1037,6 +1037,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { cursor: { enabled: false, }, + devin: { + enabled: false, + }, grok: { enabled: false, }, diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 0b64d445adf8..5b523742154a 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -258,6 +258,7 @@ const PersistedOptionalProviderSettings = Schema.Struct({ providers: Schema.optionalKey( Schema.Struct({ cursor: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), + devin: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), grok: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), opencode: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), }), @@ -286,6 +287,7 @@ function restoreUsedProviders( instanceId, instance.enabled === undefined && (instance.driver === "cursor" || + instance.driver === "devin" || instance.driver === "grok" || instance.driver === "opencode") && usedProviderInstances.has(instanceId) @@ -302,6 +304,10 @@ function restoreUsedProviders( ...settings.providers.cursor, enabled: persisted.providers?.cursor?.enabled ?? usedProviders.has("cursor"), }, + devin: { + ...settings.providers.devin, + enabled: persisted.providers?.devin?.enabled ?? usedProviders.has("devin"), + }, grok: { ...settings.providers.grok, enabled: persisted.providers?.grok?.enabled ?? usedProviders.has("grok"), @@ -361,6 +367,7 @@ const PERSISTED_SERVER_SETTINGS_DEFAULTS = { providers: { ...DEFAULT_SERVER_SETTINGS.providers, cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: undefined }, + devin: { ...DEFAULT_SERVER_SETTINGS.providers.devin, enabled: undefined }, grok: { ...DEFAULT_SERVER_SETTINGS.providers.grok, enabled: undefined }, opencode: { ...DEFAULT_SERVER_SETTINGS.providers.opencode, enabled: undefined }, }, @@ -472,13 +479,13 @@ const make = Effect.gen(function* () { provider_name AS "providerName", provider_instance_id AS "providerInstanceId" FROM projection_thread_sessions - WHERE provider_name IN ('cursor', 'grok', 'opencode') + WHERE provider_name IN ('cursor', 'devin', 'grok', 'opencode') UNION SELECT DISTINCT provider_name AS "providerName", provider_instance_id AS "providerInstanceId" FROM provider_session_runtime - WHERE provider_name IN ('cursor', 'grok', 'opencode') + WHERE provider_name IN ('cursor', 'devin', 'grok', 'opencode') `.pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/textGeneration/DevinTextGeneration.ts b/apps/server/src/textGeneration/DevinTextGeneration.ts new file mode 100644 index 000000000000..425605eb478c --- /dev/null +++ b/apps/server/src/textGeneration/DevinTextGeneration.ts @@ -0,0 +1,271 @@ +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { type DevinSettings, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { TextGenerationError } from "@t3tools/contracts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import { + applyDevinAcpModelSelection, + makeDevinAcpRuntime, +} from "../provider/acp/DevinAcpSupport.ts"; + +const DEVIN_TIMEOUT_MS = 180_000; + +const isTextGenerationError = Schema.is(TextGenerationError); + +/** + * Build a Devin text-generation closure bound to a specific `DevinSettings` + * payload. See `makeCursorTextGeneration` for the overall per-instance + * rationale. + */ +export const makeDevinTextGeneration = Effect.fn("makeDevinTextGeneration")(function* ( + devinSettings: DevinSettings, + environment?: NodeJS.ProcessEnv, +) { + const crypto = yield* Crypto.Crypto; + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const resolvedEnvironment = environment ?? process.env; + + const runDevinJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const outputRef = yield* Ref.make(""); + const runtime = yield* makeDevinAcpRuntime({ + devinSettings, + environment: resolvedEnvironment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + yield* runtime.start(); + // `ask` keeps text generation read-only regardless of the session's + // permission posture. + yield* Effect.ignore(runtime.setMode("ask")); + yield* applyDevinAcpModelSelection({ + runtime, + model: modelSelection.model, + selections: modelSelection.options, + mapError: ({ cause, configId, step }) => + new TextGenerationError({ + operation, + detail: + step === "set-config-option" + ? `Failed to set Devin ACP config option "${configId}" for text generation.` + : "Failed to set Devin ACP model for text generation.", + cause, + }), + }); + + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(DEVIN_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Devin request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Devin ACP request failed.", + cause, + }), + ), + ); + + const rawResult = (yield* Ref.get(outputRef)).trim(); + if (!rawResult) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Devin ACP request was cancelled." + : "Devin returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(rawResult)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Devin returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Devin ACP text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("DevinTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + + const generated = yield* runDevinJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("DevinTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + changeRequestTemplate: input.changeRequestTemplate, + policy: input.policy, + }); + + const generated = yield* runDevinJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("DevinTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runDevinJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("DevinTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + + const generated = yield* runDevinJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 84730d639ac6..3031570932ec 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "devin" + | "grok" + | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index b13040152e18..ed122b6f9275 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -233,6 +233,21 @@ export const CursorIcon: Icon = ({ className, ...props }) => ( ); +export const DevinIcon: Icon = ({ className, ...props }) => ( + + + +); + export const GrokIcon: Icon = ({ className, ...props }) => ( [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, + [ProviderDriverKind.make("devin")]: DevinIcon, [ProviderDriverKind.make("grok")]: GrokIcon, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, }; diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 505a86ff0eac..c62ca9db4b54 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -27,6 +27,7 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: PartialChecking... ) : providerState === "disabled" ? ( - Disabled + onEnable ? ( + + ) : ( + Disabled + ) ) : providerState === "attention" ? ( {summary.headline} ) : ( diff --git a/apps/web/src/onboarding/providerReadiness.logic.ts b/apps/web/src/onboarding/providerReadiness.logic.ts index c9d0f910ef53..b18e675d3040 100644 --- a/apps/web/src/onboarding/providerReadiness.logic.ts +++ b/apps/web/src/onboarding/providerReadiness.logic.ts @@ -1,6 +1,7 @@ import { ClaudeSettings, CodexSettings, + DevinSettings, type ExecutionEnvironmentPlatformOs, type ServerProvider, type ServerSettings, @@ -10,6 +11,7 @@ import * as Schema from "effect/Schema"; const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); +const decodeDevinSettings = Schema.decodeUnknownOption(DevinSettings); const SAFE_SHELL_BINARY_PATTERN = /^[A-Za-z0-9_./:\\-]+$/; function quoteProviderBinary( @@ -85,6 +87,10 @@ const NATIVE_INSTALL_COMMANDS = { windows: "irm https://chatgpt.com/codex/install.ps1 | iex", posix: "curl -fsSL https://chatgpt.com/codex/install.sh | sh", }, + devin: { + windows: "irm https://static.devin.ai/cli/setup.ps1 | iex", + posix: "curl -fsSL https://cli.devin.ai/install.sh | bash", + }, } as const; /** @@ -125,5 +131,13 @@ export function resolveOnboardingProviderLoginCommand( return `${quoteProviderBinary(binaryPath, "codex", platform)} login`; } + if (provider.driver === "devin") { + const config = decodeDevinSettings( + instance ? (instance.config ?? {}) : settings.providers.devin, + ); + const binaryPath = Option.isSome(config) ? config.value.binaryPath : "devin"; + return `${quoteProviderBinary(binaryPath, "devin", platform)} auth login`; + } + return provider.driver; } From 30e3e3aee7831cb0fd170ebd6a367f2294d31562 Mon Sep 17 00:00:00 2001 From: Adam Higgins Date: Fri, 11 Sep 2026 13:18:45 +0100 Subject: [PATCH 3/7] fix(web,mobile): use the real Devin mark for the provider icon Replace the placeholder "D" letterform with the official Devin mark (three interlocking hexagons, 24x24, currentColor) sourced from the published vector used by @lobehub/icons. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/mobile/src/components/ProviderIcon.tsx | 14 +++++++++++++- apps/web/src/components/Icons.tsx | 9 ++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index eeef465e118c..f257c42d4f7e 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -44,7 +44,19 @@ export function ProviderIcon(props: ProviderIconProps) { fillRule="evenodd" clipRule="evenodd" fill={mono} - d="M4 4h7.2c4.75 0 8.8 3.58 8.8 8s-4.05 8-8.8 8H4V4Zm4.2 4.4v7.2h3a3.6 3.6 0 0 0 0-7.2h-3Z" + d="M2.033 9.867l2.554 1.483a.589.589 0 00.592 0l2.554-1.483.01-.008a.608.608 0 00.11-.084l.013-.015a.631.631 0 00.076-.1c.003-.005.008-.01.01-.016a.558.558 0 00.052-.125l.007-.028a.611.611 0 00.019-.14V7.868c0-.572.307-1.105.8-1.392a1.595 1.595 0 011.598 0l1.277.742a.54.54 0 00.129.053l.028.01c.044.01.088.015.133.016h.006l.013-.002a.587.587 0 00.27-.074l.011-.004 2.554-1.483a.596.596 0 00.297-.516V2.253a.595.595 0 00-.297-.516L12.293.257a.587.587 0 00-.591 0L9.148 1.737l-.01.01a.609.609 0 00-.109.083l-.014.015a.632.632 0 00-.076.1c-.003.005-.008.01-.01.016a.57.57 0 00-.052.124l-.007.028a.612.612 0 00-.018.14v1.483c0 .572-.307 1.105-.8 1.393a1.597 1.597 0 01-1.599 0l-1.276-.742a.603.603 0 00-.13-.053l-.028-.008a.658.658 0 00-.133-.018h-.02a.57.57 0 00-.269.074c-.003.002-.008.002-.012.005L2.033 5.872a.596.596 0 00-.297.515v2.966c0 .213.113.41.297.515z" + /> + + ); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index ed122b6f9275..0835fdc5d18d 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -238,13 +238,12 @@ export const DevinIcon: Icon = ({ className, ...props }) => ( {...props} viewBox="0 0 24 24" fill="none" + fillRule="evenodd" className={cn("fill-[#0F0F0F] dark:fill-[#F5F5F5]", className)} > - + + + ); From 71be7798c16d15236a951d5689662c3dcf15d6db Mon Sep 17 00:00:00 2001 From: Adam Higgins Date: Fri, 11 Sep 2026 13:21:04 +0100 Subject: [PATCH 4/7] fix(server): stop tagging Devin model rows with the family subProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker's display-name helper strips a leading subProvider qualifier from the model name. Devin variant labels are already fully qualified ("Inkling High", "SWE-2 Max"), so tagging them with the family slug made rows render as just the effort suffix ("High") with "Devin · inkling" as the subtitle. Drop subProvider from the catalog rows. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/server/src/provider/Layers/DevinProvider.test.ts | 6 ++++-- apps/server/src/provider/Layers/DevinProvider.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/DevinProvider.test.ts b/apps/server/src/provider/Layers/DevinProvider.test.ts index ef89d659960f..bc0149e2e50e 100644 --- a/apps/server/src/provider/Layers/DevinProvider.test.ts +++ b/apps/server/src/provider/Layers/DevinProvider.test.ts @@ -33,15 +33,17 @@ describe("devinModelsFromCatalog", () => { "adaptive", ]); - const sweHigh = models[0]; + const sweHigh = models[0]!; expect(sweHigh).toMatchObject({ name: "SWE-2 High", - subProvider: "swe-2", aliases: ["swe"], badge: "new", isCustom: false, isDefault: false, }); + // Family slug must not become `subProvider` — the picker strips that + // prefix from the label, leaving bare effort names like "High". + expect(sweHigh.subProvider).toBeUndefined(); }); it("marks adaptive as the default model", () => { diff --git a/apps/server/src/provider/Layers/DevinProvider.ts b/apps/server/src/provider/Layers/DevinProvider.ts index d03fad692c68..9b635bff82ae 100644 --- a/apps/server/src/provider/Layers/DevinProvider.ts +++ b/apps/server/src/provider/Layers/DevinProvider.ts @@ -118,19 +118,21 @@ export function devinModelsFromCatalog( ): ReadonlyArray { const models: ServerProviderModel[] = []; for (const family of parsed?.families ?? []) { - const familySlug = typeof family.slug === "string" ? family.slug.trim() : ""; const familyAliases = (family.aliases ?? []).filter( (alias): alias is string => typeof alias === "string" && alias.trim().length > 0, ); for (const variant of family.variants ?? []) { const slug = typeof variant.model_uid === "string" ? variant.model_uid.trim() : ""; if (!slug) continue; + // Variant labels are already fully qualified ("Inkling High", + // "SWE-2 Max"), so no `subProvider` — the picker strips a leading + // subProvider from the name, which would leave only the effort + // suffix ("High") as the row title. const name = typeof variant.label === "string" && variant.label.trim() ? variant.label.trim() : slug; models.push({ slug, name, - ...(familySlug ? { subProvider: familySlug } : {}), ...(familyAliases.length > 0 ? { aliases: [...familyAliases] } : {}), ...(variant.is_new === true ? { badge: "new" as const } : {}), isCustom: false, From 607bbaa80779459715c4a3b610433826ce2c3aec Mon Sep 17 00:00:00 2001 From: Adam Higgins Date: Fri, 11 Sep 2026 13:44:41 +0100 Subject: [PATCH 5/7] feat(providers): group Devin model variants into effort/speed/context options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devin encodes effort, speed, and context in the model uid itself (`claude-opus-5-high-fast`, `glm-5-2-max-1m`), so the picker showed one row per variant — ~215 entries — instead of one row per model with selectable axes like other providers. `devinModelCatalog` now parses each variant uid right-to-left (context, speed, effort suffixes, `-`/`_` separators) and groups variants by base into a single `ServerProviderModel` per family with `effort`/`speed`/ `context` option descriptors. Bare variants contribute a "Default" effort choice; variant uids are kept as aliases so stored flat selections still resolve. The adapter folds dim selections back into a concrete advertised uid by parsed-dims matching rather than string composition, so irregular uids (`MODEL_GPT_5_2_LOW`, `claude-5-fable-*`) and missing combinations relax instead of failing the turn. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/provider/Layers/DevinProvider.test.ts | 35 +- .../src/provider/Layers/DevinProvider.ts | 107 ++--- .../src/provider/acp/DevinAcpSupport.ts | 36 +- .../src/provider/devinModelCatalog.test.ts | 256 +++++++++++ apps/server/src/provider/devinModelCatalog.ts | 435 ++++++++++++++++++ .../settings/ProviderModelsSection.tsx | 5 +- 6 files changed, 789 insertions(+), 85 deletions(-) create mode 100644 apps/server/src/provider/devinModelCatalog.test.ts create mode 100644 apps/server/src/provider/devinModelCatalog.ts diff --git a/apps/server/src/provider/Layers/DevinProvider.test.ts b/apps/server/src/provider/Layers/DevinProvider.test.ts index bc0149e2e50e..f60ec809e4c9 100644 --- a/apps/server/src/provider/Layers/DevinProvider.test.ts +++ b/apps/server/src/provider/Layers/DevinProvider.test.ts @@ -6,6 +6,7 @@ const SAMPLE_MODELS_JSON = { families: [ { family_uid: "swe-2", + family_label: "SWE-2", slug: "swe-2", aliases: ["swe"], variants: [ @@ -16,34 +17,42 @@ const SAMPLE_MODELS_JSON = { }, { family_uid: "devin-core", + family_label: "Adaptive", slug: "devin-core", - aliases: ["adaptive"], variants: [{ model_uid: "adaptive", label: "Adaptive" }], }, ], }; describe("devinModelsFromCatalog", () => { - it("flattens families into ServerProviderModel entries", () => { + it("groups effort variants into one row per family", () => { const models = devinModelsFromCatalog(SAMPLE_MODELS_JSON); - expect(models.map((model) => model.slug)).toEqual([ - "swe-2-high", - "swe-2-medium", - "swe-2-max", - "adaptive", - ]); + expect(models.map((model) => model.slug)).toEqual(["swe-2", "adaptive"]); - const sweHigh = models[0]!; - expect(sweHigh).toMatchObject({ - name: "SWE-2 High", - aliases: ["swe"], + const swe2 = models[0]!; + expect(swe2).toMatchObject({ + name: "SWE-2", badge: "new", isCustom: false, isDefault: false, }); // Family slug must not become `subProvider` — the picker strips that // prefix from the label, leaving bare effort names like "High". - expect(sweHigh.subProvider).toBeUndefined(); + expect(swe2.subProvider).toBeUndefined(); + // Variant uids stay reachable as aliases so stored flat selections like + // `swe-2-high` still resolve to the grouped row. + expect(swe2.aliases).toEqual(["swe", "swe-2-high", "swe-2-medium", "swe-2-max"]); + + const effortDescriptor = swe2.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "effort", + ); + expect(effortDescriptor).toMatchObject({ + type: "select", + label: "Reasoning", + }); + expect( + effortDescriptor?.type === "select" ? effortDescriptor.options.map((o) => o.id) : [], + ).toEqual(["medium", "high", "max"]); }); it("marks adaptive as the default model", () => { diff --git a/apps/server/src/provider/Layers/DevinProvider.ts b/apps/server/src/provider/Layers/DevinProvider.ts index 9b635bff82ae..fe3d0f1bf201 100644 --- a/apps/server/src/provider/Layers/DevinProvider.ts +++ b/apps/server/src/provider/Layers/DevinProvider.ts @@ -76,23 +76,9 @@ function buildDevinCliCommandMissingMessage(binaryPath: string): string { // ── `devin models list --format json` ──────────────────────────────────── -interface DevinModelVariant { - readonly model_uid: string; - readonly label: string; - readonly is_new?: boolean; - readonly is_beta?: boolean; -} +import { devinModelsFromCatalog, type DevinModelsListJson } from "../devinModelCatalog.ts"; -interface DevinModelFamily { - readonly family_uid?: string; - readonly slug?: string; - readonly aliases?: ReadonlyArray; - readonly variants?: ReadonlyArray; -} - -interface DevinModelsListJson { - readonly families?: ReadonlyArray; -} +export { devinModelsFromCatalog }; function parseDevinModelsJson(raw: string): DevinModelsListJson | undefined { const trimmed = raw.trim(); @@ -110,56 +96,45 @@ function parseDevinModelsJson(raw: string): DevinModelsListJson | undefined { } } -/** `adaptive` is Devin's recommended auto-router and the picker default. */ -const DEVIN_DEFAULT_MODEL_SLUG = "adaptive"; - -export function devinModelsFromCatalog( - parsed: DevinModelsListJson | undefined, -): ReadonlyArray { - const models: ServerProviderModel[] = []; - for (const family of parsed?.families ?? []) { - const familyAliases = (family.aliases ?? []).filter( - (alias): alias is string => typeof alias === "string" && alias.trim().length > 0, - ); - for (const variant of family.variants ?? []) { - const slug = typeof variant.model_uid === "string" ? variant.model_uid.trim() : ""; - if (!slug) continue; - // Variant labels are already fully qualified ("Inkling High", - // "SWE-2 Max"), so no `subProvider` — the picker strips a leading - // subProvider from the name, which would leave only the effort - // suffix ("High") as the row title. - const name = - typeof variant.label === "string" && variant.label.trim() ? variant.label.trim() : slug; - models.push({ - slug, - name, - ...(familyAliases.length > 0 ? { aliases: [...familyAliases] } : {}), - ...(variant.is_new === true ? { badge: "new" as const } : {}), - isCustom: false, - isDefault: slug === DEVIN_DEFAULT_MODEL_SLUG, - capabilities: null, - }); - } - } - return models; -} - -/** Static fallback so the picker is never empty when discovery fails. */ -const DEVIN_FALLBACK_MODELS: ReadonlyArray = [ - "adaptive", - "swe-2-high", - "swe-2-medium", - "swe-2-max", - "swe-1-7", - "swe-1-7-lightning", - "swe-1-6-fast", -].map((slug) => ({ - slug, - name: slug, - isCustom: false, - isDefault: slug === DEVIN_DEFAULT_MODEL_SLUG, - capabilities: null, -})); +/** + * Static fallback so the picker is never empty when discovery fails. Uses + * the same grouped shape as live discovery: one row per base with effort + * descriptors. + */ +const DEVIN_FALLBACK_MODELS: ReadonlyArray = devinModelsFromCatalog({ + families: [ + { + family_uid: "adaptive", + family_label: "Adaptive", + variants: [{ model_uid: "adaptive" }], + }, + { + family_uid: "swe-2", + family_label: "SWE-2", + aliases: ["swe"], + variants: [ + { model_uid: "swe-2-high" }, + { model_uid: "swe-2-medium" }, + { model_uid: "swe-2-max" }, + ], + }, + { + family_uid: "swe-1.7", + family_label: "SWE-1.7", + variants: [{ model_uid: "swe-1-7" }, { model_uid: "swe-1-7-medium" }], + }, + { + family_uid: "swe-1.7-lightning", + family_label: "SWE-1.7 Lightning", + variants: [{ model_uid: "swe-1-7-lightning" }, { model_uid: "swe-1-7-lightning-medium" }], + }, + { + family_uid: "swe-1.6-fast", + family_label: "SWE-1.6 Fast", + variants: [{ model_uid: "swe-1-6-fast" }], + }, + ], +}); function getDevinFallbackModels(devinSettings: DevinSettings): ReadonlyArray { return providerModelsFromSettings( diff --git a/apps/server/src/provider/acp/DevinAcpSupport.ts b/apps/server/src/provider/acp/DevinAcpSupport.ts index ed34f11615d1..e622b0c616f4 100644 --- a/apps/server/src/provider/acp/DevinAcpSupport.ts +++ b/apps/server/src/provider/acp/DevinAcpSupport.ts @@ -25,7 +25,9 @@ import type * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import type { AcpSessionModeState } from "./AcpRuntimeModel.ts"; +import { collectSessionConfigOptionValues } from "./AcpRuntimeModel.ts"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import { isDevinModelDimOptionId, resolveDevinModelUid } from "../devinModelCatalog.ts"; type DevinAcpRuntimeDevinSettings = Pick; @@ -202,9 +204,33 @@ export function applyDevinAcpModelSelection(input: { readonly mapError: (context: DevinAcpModelSelectionErrorContext) => E; }): Effect.Effect { return Effect.gen(function* () { + // Devin's session config surface is just `model` + `mode`. The picker + // groups variants into one row per family with effort/speed/context + // options, so resolve those dims back to a concrete advertised uid + // before applying the model. + const configOptions = yield* input.runtime.getConfigOptions; + const modelOption = + configOptions.find((candidate) => candidate.category === "model") ?? + configOptions.find((candidate) => candidate.id === "model"); + const advertisedModelValues = modelOption ? collectSessionConfigOptionValues(modelOption) : []; + const model = input.model?.trim(); if (model) { - yield* input.runtime.setModel(model).pipe( + const resolvedModel = + advertisedModelValues.length > 0 + ? resolveDevinModelUid({ + model, + selections: input.selections, + advertisedValues: advertisedModelValues, + currentValue: + modelOption && "currentValue" in modelOption + ? typeof modelOption.currentValue === "string" + ? modelOption.currentValue + : undefined + : undefined, + }) + : model; + yield* input.runtime.setModel(resolvedModel).pipe( Effect.mapError((cause) => input.mapError({ cause, @@ -214,11 +240,11 @@ export function applyDevinAcpModelSelection(input: { ); } - // Devin's session config surface is just `model` + `mode`, but apply any - // selection that happens to match an advertised config option so future - // Devin options light up without an adapter change. - const configOptions = yield* input.runtime.getConfigOptions; + // Model-dim selections are already folded into the uid; apply any other + // selection that matches an advertised config option so future Devin + // options light up without an adapter change. for (const selection of input.selections ?? []) { + if (isDevinModelDimOptionId(selection.id)) continue; const option = configOptions.find((candidate) => candidate.id === selection.id); if (!option || option.id === "model") continue; yield* input.runtime diff --git a/apps/server/src/provider/devinModelCatalog.test.ts b/apps/server/src/provider/devinModelCatalog.test.ts new file mode 100644 index 000000000000..774b42d9763a --- /dev/null +++ b/apps/server/src/provider/devinModelCatalog.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + devinModelsFromCatalog, + normalizeDevinModelId, + parseDevinModelUid, + resolveDevinModelUid, +} from "./devinModelCatalog.ts"; + +describe("parseDevinModelUid", () => { + it("parses effort, speed, and context suffixes", () => { + expect(parseDevinModelUid("claude-opus-5-low-fast")).toEqual({ + base: "claude-opus-5", + effort: "low", + speed: "fast", + context: undefined, + }); + expect(parseDevinModelUid("glm-5-2-max-1m")).toEqual({ + base: "glm-5-2", + effort: "max", + speed: undefined, + context: "1m", + }); + expect(parseDevinModelUid("gpt-5-6-sol-none-priority")).toEqual({ + base: "gpt-5-6-sol", + effort: "none", + speed: "priority", + context: undefined, + }); + }); + + it("parses underscore-separated uids", () => { + expect(parseDevinModelUid("MODEL_GPT_5_2_LOW")).toEqual({ + base: "model-gpt-5-2", + effort: "low", + speed: undefined, + context: undefined, + }); + expect(parseDevinModelUid("MODEL_CLAUDE_4_5_OPUS_THINKING")).toEqual({ + base: "model-claude-4-5-opus", + effort: "thinking", + speed: undefined, + context: undefined, + }); + }); + + it("leaves bare uids untouched", () => { + for (const uid of ["adaptive", "kimi-k2-6", "swe-1-7-lightning", "MODEL_PRIVATE_11"]) { + const dims = parseDevinModelUid(uid); + expect(dims.effort).toBeUndefined(); + expect(dims.speed).toBeUndefined(); + expect(dims.context).toBeUndefined(); + expect(dims.base).toBe(normalizeDevinModelId(uid)); + } + }); +}); + +describe("devinModelsFromCatalog grouping", () => { + it("emits a Speed select for fast variants", () => { + const models = devinModelsFromCatalog({ + families: [ + { + family_label: "Claude Opus 5", + family_uid: "claude-opus-5", + variants: [ + { model_uid: "claude-opus-5-low" }, + { model_uid: "claude-opus-5-high" }, + { model_uid: "claude-opus-5-low-fast" }, + { model_uid: "claude-opus-5-high-fast" }, + ], + }, + ], + }); + const opus = models[0]!; + expect(opus.slug).toBe("claude-opus-5"); + expect(opus.name).toBe("Claude Opus 5"); + const descriptors = opus.capabilities?.optionDescriptors ?? []; + const speed = descriptors.find((d) => d.id === "speed"); + expect(speed?.type === "select" ? speed.options.map((o) => o.id) : []).toEqual([ + "standard", + "fast", + ]); + const effort = descriptors.find((d) => d.id === "effort"); + expect(effort?.type === "select" ? effort.options.map((o) => o.id) : []).toEqual([ + "low", + "high", + ]); + }); + + it("emits a Default effort choice and Context select for bare-variant families", () => { + const models = devinModelsFromCatalog({ + families: [ + { + family_label: "GLM-5.2", + family_uid: "glm-5.2", + variants: [ + { model_uid: "glm-5-2" }, + { model_uid: "glm-5-2-max" }, + { model_uid: "glm-5-2-1m" }, + { model_uid: "glm-5-2-max-1m" }, + { model_uid: "glm-5-2-none" }, + { model_uid: "glm-5-2-none-1m" }, + ], + }, + ], + }); + const glm = models[0]!; + const descriptors = glm.capabilities?.optionDescriptors ?? []; + const effort = descriptors.find((d) => d.id === "effort"); + expect(effort?.type === "select" ? effort.options.map((o) => o.id) : []).toEqual([ + "default", + "none", + "max", + ]); + const context = descriptors.find((d) => d.id === "context"); + expect(context?.type === "select" ? context.options.map((o) => o.id) : []).toEqual([ + "200k", + "1m", + ]); + }); + + it("splits families whose variants parse to different bases", () => { + const models = devinModelsFromCatalog({ + families: [ + { + family_label: "Claude Sonnet 4.5", + family_uid: "claude-sonnet-4.5", + variants: [ + { model_uid: "MODEL_PRIVATE_2", label: "Claude Sonnet 4.5" }, + { model_uid: "MODEL_PRIVATE_3", label: "Claude Sonnet 4.5 Thinking" }, + ], + }, + ], + }); + expect(models.map((m) => m.slug)).toEqual(["model-private-2", "model-private-3"]); + expect(models.map((m) => m.name)).toEqual(["Claude Sonnet 4.5", "Claude Sonnet 4.5 Thinking"]); + }); +}); + +describe("resolveDevinModelUid", () => { + const advertised = [ + "swe-2-high", + "swe-2-medium", + "swe-2-max", + "claude-opus-5-low", + "claude-opus-5-high", + "claude-opus-5-low-fast", + "claude-opus-5-high-fast", + "glm-5-2", + "glm-5-2-max", + "glm-5-2-1m", + "adaptive", + ]; + + it("passes through exact uid matches", () => { + expect(resolveDevinModelUid({ model: "swe-2-high", advertisedValues: advertised })).toBe( + "swe-2-high", + ); + expect(resolveDevinModelUid({ model: "swe-1-6-fast", advertisedValues: advertised })).toBe( + "swe-1-6-fast", + ); + }); + + it("resolves effort selections to concrete uids", () => { + expect( + resolveDevinModelUid({ + model: "swe-2", + selections: [{ id: "effort", value: "max" }], + advertisedValues: advertised, + }), + ).toBe("swe-2-max"); + }); + + it("combines effort and speed selections", () => { + expect( + resolveDevinModelUid({ + model: "claude-opus-5", + selections: [ + { id: "effort", value: "low" }, + { id: "speed", value: "fast" }, + ], + advertisedValues: advertised, + }), + ).toBe("claude-opus-5-low-fast"); + expect( + resolveDevinModelUid({ + model: "claude-opus-5", + selections: [ + { id: "effort", value: "high" }, + { id: "speed", value: "standard" }, + ], + advertisedValues: advertised, + }), + ).toBe("claude-opus-5-high"); + }); + + it("resolves the default effort to the bare variant", () => { + expect( + resolveDevinModelUid({ + model: "glm-5-2", + selections: [{ id: "effort", value: "default" }], + advertisedValues: advertised, + }), + ).toBe("glm-5-2"); + expect( + resolveDevinModelUid({ + model: "glm-5-2", + selections: [ + { id: "effort", value: "default" }, + { id: "context", value: "1m" }, + ], + advertisedValues: advertised, + }), + ).toBe("glm-5-2-1m"); + }); + + it("keeps the session's current value when no dims are selected", () => { + expect( + resolveDevinModelUid({ + model: "swe-2", + advertisedValues: advertised, + currentValue: "swe-2-high", + }), + ).toBe("swe-2-high"); + }); + + it("relaxes to a matching effort when speed is unavailable", () => { + expect( + resolveDevinModelUid({ + model: "swe-2", + selections: [ + { id: "effort", value: "max" }, + { id: "speed", value: "fast" }, + ], + advertisedValues: advertised, + }), + ).toBe("swe-2-max"); + }); + + it("does not let sibling families leak into each other", () => { + const values = ["swe-1-7", "swe-1-7-medium", "swe-1-7-lightning", "swe-1-7-lightning-medium"]; + expect( + resolveDevinModelUid({ + model: "swe-1-7", + selections: [{ id: "effort", value: "medium" }], + advertisedValues: values, + }), + ).toBe("swe-1-7-medium"); + expect( + resolveDevinModelUid({ + model: "swe-1-7-lightning", + advertisedValues: values, + }), + ).toBe("swe-1-7-lightning"); + }); +}); diff --git a/apps/server/src/provider/devinModelCatalog.ts b/apps/server/src/provider/devinModelCatalog.ts new file mode 100644 index 000000000000..9e8e9b9fea4c --- /dev/null +++ b/apps/server/src/provider/devinModelCatalog.ts @@ -0,0 +1,435 @@ +/** + * devinModelCatalog — pure parsing/grouping helpers for Devin's model surface. + * + * Devin's CLI encodes reasoning effort, speed tier, and context window in the + * model uid itself (`claude-opus-5-high-fast`, `glm-5-2-max-1m`, + * `MODEL_GPT_5_2_LOW`). The picker should still present one row per family + * with option descriptors, matching how Codex/Claude expose effort and fast + * mode. This module owns both directions: + * + * - `devinModelsFromCatalog` groups `devin models list --format json` into + * per-base rows with `effort` / `speed` / `context` option descriptors. + * - `resolveDevinModelUid` turns `{ model: , options }` back into a + * concrete advertised uid for `session/set_config_option`. + * + * @module devinModelCatalog + */ +import type { + ProviderOptionChoice, + ProviderOptionSelection, + ServerProviderModel, +} from "@t3tools/contracts"; + +// ── uid suffix grammar ─────────────────────────────────────────────────── +// +// Wire order is -[-][-]; separators may be `-` +// or `_` (`MODEL_GPT_5_2_LOW`). Suffixes strip right-to-left: context, then +// speed, then effort. + +const EFFORT_TOKENS = new Set([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "thinking", +]); +const SPEED_TOKENS = new Set(["fast", "priority"]); +const CONTEXT_TOKENS = new Set(["1m"]); + +export interface DevinModelDims { + /** Normalized base id (lowercase, `-` separators). */ + readonly base: string; + readonly effort: string | undefined; + readonly speed: string | undefined; + readonly context: string | undefined; +} + +export function normalizeDevinModelId(value: string): string { + return value.trim().toLowerCase().replaceAll("_", "-"); +} + +export function parseDevinModelUid(uid: string): DevinModelDims { + const tokens = normalizeDevinModelId(uid).split("-").filter(Boolean); + let effort: string | undefined; + let speed: string | undefined; + let context: string | undefined; + while (tokens.length > 1) { + const last = tokens[tokens.length - 1]!; + if (context === undefined && CONTEXT_TOKENS.has(last)) { + context = last; + tokens.pop(); + continue; + } + if (speed === undefined && SPEED_TOKENS.has(last)) { + speed = last; + tokens.pop(); + continue; + } + if (effort === undefined && EFFORT_TOKENS.has(last)) { + effort = last; + tokens.pop(); + continue; + } + break; + } + return { base: tokens.join("-"), effort, speed, context }; +} + +// ── picker rows ────────────────────────────────────────────────────────── + +export const DEVIN_EFFORT_OPTION_ID = "effort"; +export const DEVIN_SPEED_OPTION_ID = "speed"; +export const DEVIN_CONTEXT_OPTION_ID = "context"; + +/** Selection values that mean "the provider default" for an axis. */ +const DEFAULT_EFFORT_VALUE = "default"; +const STANDARD_SPEED_VALUE = "standard"; +const STANDARD_CONTEXT_VALUE = "200k"; + +const EFFORT_ORDER = [ + DEFAULT_EFFORT_VALUE, + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "thinking", +] as const; + +const EFFORT_LABELS: Record = { + [DEFAULT_EFFORT_VALUE]: "Default", + none: "None", + minimal: "Minimal", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "X-High", + max: "Max", + thinking: "Thinking", +}; + +const SPEED_LABELS: Record = { + [STANDARD_SPEED_VALUE]: "Standard", + fast: "Fast", + priority: "Priority", +}; + +const CONTEXT_LABELS: Record = { + [STANDARD_CONTEXT_VALUE]: "Standard", + "1m": "1M", +}; + +interface DevinModelVariant { + readonly model_uid: string; + readonly label?: string; + readonly is_new?: boolean; + readonly is_beta?: boolean; +} + +interface DevinModelFamily { + readonly family_uid?: string; + readonly family_label?: string; + readonly slug?: string; + readonly aliases?: ReadonlyArray; + readonly variants?: ReadonlyArray; +} + +export interface DevinModelsListJson { + readonly families?: ReadonlyArray; +} + +/** `adaptive` is Devin's recommended auto-router and the picker default. */ +const DEVIN_DEFAULT_MODEL_SLUG = "adaptive"; + +function effortChoice(effort: string, isDefault: boolean): ProviderOptionChoice { + return { + id: effort, + label: EFFORT_LABELS[effort] ?? effort, + ...(isDefault ? { isDefault: true } : {}), + }; +} + +/** + * One row per (family, base) group. Variants sharing a base become option + * descriptor choices: bare variants contribute a "Default" effort choice, + * `-fast`/`-priority` a Speed select, and `-1m` a Context select. Variant + * uids land in `aliases` so stored flat selections like `swe-2-high` still + * resolve to the grouped row. + */ +export function devinModelsFromCatalog( + parsed: DevinModelsListJson | undefined, +): ReadonlyArray { + const models: ServerProviderModel[] = []; + const seenSlugs = new Set(); + + for (const family of parsed?.families ?? []) { + const familyLabel = + typeof family.family_label === "string" && family.family_label.trim() + ? family.family_label.trim() + : undefined; + const familyAliases = (family.aliases ?? []).filter( + (alias): alias is string => typeof alias === "string" && alias.trim().length > 0, + ); + + // Group variants by parsed base; several bases in one family (e.g. + // MODEL_PRIVATE_* under claude-sonnet-4.5) are genuinely distinct models. + const groups = new Map< + string, + { + uids: Array; + dims: Array; + label: string | undefined; + isNew: boolean; + } + >(); + for (const variant of family.variants ?? []) { + const uid = typeof variant.model_uid === "string" ? variant.model_uid.trim() : ""; + if (!uid) continue; + const dims = parseDevinModelUid(uid); + const variantLabel = + typeof variant.label === "string" && variant.label.trim() + ? variant.label.trim() + : undefined; + const group = groups.get(dims.base); + if (group) { + group.uids.push(uid); + group.dims.push(dims); + group.isNew ||= variant.is_new === true; + } else { + groups.set(dims.base, { + uids: [uid], + dims: [dims], + label: variantLabel, + isNew: variant.is_new === true, + }); + } + } + + for (const [base, group] of groups) { + if (seenSlugs.has(base)) continue; + seenSlugs.add(base); + + const efforts = new Set(); + const speeds = new Set(); + const contexts = new Set(); + for (const dims of group.dims) { + if (dims.effort) efforts.add(dims.effort); + if (dims.speed) speeds.add(dims.speed); + if (dims.context) contexts.add(dims.context); + } + const hasBare = group.dims.some( + (dims) => + dims.effort === undefined && dims.speed === undefined && dims.context === undefined, + ); + + const optionDescriptors: Array<{ + id: string; + label: string; + type: "select"; + options: Array; + }> = []; + + if (efforts.size > 0 || hasBare) { + const options: Array = []; + if (hasBare) { + options.push(effortChoice(DEFAULT_EFFORT_VALUE, true)); + } + for (const effort of EFFORT_ORDER) { + if (effort === DEFAULT_EFFORT_VALUE || !efforts.has(effort)) continue; + options.push(effortChoice(effort, false)); + } + if (options.length > 1) { + optionDescriptors.push({ + id: DEVIN_EFFORT_OPTION_ID, + label: "Reasoning", + type: "select", + options, + }); + } + } + + if (speeds.size > 0) { + const options: Array = []; + const hasStandard = group.dims.some((dims) => dims.speed === undefined); + if (hasStandard) { + options.push({ + id: STANDARD_SPEED_VALUE, + label: SPEED_LABELS[STANDARD_SPEED_VALUE]!, + isDefault: true, + }); + } + for (const speed of ["fast", "priority"]) { + if (!speeds.has(speed)) continue; + options.push({ + id: speed, + label: SPEED_LABELS[speed]!, + ...(hasStandard ? {} : { isDefault: true }), + }); + } + if (options.length > 1) { + optionDescriptors.push({ + id: DEVIN_SPEED_OPTION_ID, + label: "Speed", + type: "select", + options, + }); + } + } + + if (contexts.size > 0) { + const options: Array = []; + const hasStandard = group.dims.some((dims) => dims.context === undefined); + if (hasStandard) { + options.push({ + id: STANDARD_CONTEXT_VALUE, + label: CONTEXT_LABELS[STANDARD_CONTEXT_VALUE]!, + isDefault: true, + }); + } + for (const context of ["1m"]) { + if (!contexts.has(context)) continue; + options.push({ + id: context, + label: CONTEXT_LABELS[context]!, + ...(hasStandard ? {} : { isDefault: true }), + }); + } + if (options.length > 1) { + optionDescriptors.push({ + id: DEVIN_CONTEXT_OPTION_ID, + label: "Context", + type: "select", + options, + }); + } + } + + models.push({ + slug: base, + name: + groups.size === 1 + ? (familyLabel ?? group.label ?? base) + : (group.label ?? familyLabel ?? base), + ...(familyAliases.length > 0 || group.uids.length > 1 + ? { aliases: [...familyAliases, ...group.uids.filter((uid) => uid !== base)] } + : {}), + ...(group.isNew ? { badge: "new" as const } : {}), + isCustom: false, + isDefault: base === DEVIN_DEFAULT_MODEL_SLUG, + capabilities: optionDescriptors.length > 0 ? { optionDescriptors } : null, + }); + } + } + return models; +} + +// ── selection → concrete uid ───────────────────────────────────────────── + +const DEVIN_DIM_OPTION_IDS = new Set([ + DEVIN_EFFORT_OPTION_ID, + DEVIN_SPEED_OPTION_ID, + DEVIN_CONTEXT_OPTION_ID, +]); + +/** Option ids that fold into the model uid rather than a config option. */ +export function isDevinModelDimOptionId(id: string): boolean { + return DEVIN_DIM_OPTION_IDS.has(id); +} + +function wantedDims(selections: ReadonlyArray | null | undefined): { + effort: string | undefined; + speed: string | undefined; + context: string | undefined; + hasAny: boolean; +} { + let effort: string | undefined; + let speed: string | undefined; + let context: string | undefined; + for (const selection of selections ?? []) { + if (selection.id === DEVIN_EFFORT_OPTION_ID && typeof selection.value === "string") { + effort = selection.value === DEFAULT_EFFORT_VALUE ? undefined : selection.value; + } else if (selection.id === DEVIN_SPEED_OPTION_ID && typeof selection.value === "string") { + speed = selection.value === STANDARD_SPEED_VALUE ? undefined : selection.value; + } else if (selection.id === DEVIN_CONTEXT_OPTION_ID && typeof selection.value === "string") { + context = selection.value === STANDARD_CONTEXT_VALUE ? undefined : selection.value; + } + } + return { + effort, + speed, + context, + hasAny: effort !== undefined || speed !== undefined || context !== undefined, + }; +} + +/** + * Resolve a grouped selection (`model` = base id + effort/speed/context + * options) to a concrete advertised uid. Resolution matches on parsed dims + * rather than string composition so irregular uids (`MODEL_GPT_5_2_LOW`, + * `claude-5-fable-*`) still land. Exact uid matches and unknown models pass + * through untouched. + */ +export function resolveDevinModelUid(input: { + readonly model: string; + readonly selections?: ReadonlyArray | null | undefined; + readonly advertisedValues: ReadonlyArray; + readonly currentValue?: string | undefined; +}): string { + const model = input.model.trim(); + if (!model) return model; + + const wanted = wantedDims(input.selections); + // A base id like `swe-1-7` is often *also* an advertised uid (the family's + // default-effort variant), so the exact-match pass-through only applies + // when no dims were selected — otherwise the dims must drive resolution. + if (!wanted.hasAny && input.advertisedValues.includes(model)) return model; + + const wantedBase = normalizeDevinModelId(model); + const candidates = input.advertisedValues.filter( + (uid) => parseDevinModelUid(uid).base === wantedBase, + ); + if (candidates.length === 0) return model; + + if (!wanted.hasAny) { + // No dims selected: keep the session's current value when it already + // belongs to this family, else the bare variant, else first advertised. + if (input.currentValue && candidates.includes(input.currentValue)) { + return input.currentValue; + } + const bare = candidates.find((uid) => { + const dims = parseDevinModelUid(uid); + return dims.effort === undefined && dims.speed === undefined && dims.context === undefined; + }); + return bare ?? candidates[0]!; + } + + const strict = candidates.find((uid) => { + const dims = parseDevinModelUid(uid); + return ( + dims.effort === wanted.effort && + dims.speed === wanted.speed && + dims.context === wanted.context + ); + }); + if (strict) return strict; + + // Relax: drop context, then speed, then accept the bare variant, then + // first advertised — never fail the turn over a missing variant. + const withoutContext = candidates.find((uid) => { + const dims = parseDevinModelUid(uid); + return dims.effort === wanted.effort && dims.speed === wanted.speed; + }); + if (withoutContext) return withoutContext; + const effortOnly = candidates.find((uid) => parseDevinModelUid(uid).effort === wanted.effort); + if (effortOnly) return effortOnly; + const bare = candidates.find((uid) => { + const dims = parseDevinModelUid(uid); + return dims.effort === undefined && dims.speed === undefined && dims.context === undefined; + }); + return bare ?? candidates[0]!; +} diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index c62ca9db4b54..1d151730abf9 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -48,7 +48,10 @@ function describeModelCapabilities(model: ServerProviderModel): string[] { descriptor.id === "fastMode" || (descriptor.id === "serviceTier" && descriptor.type === "select" && - descriptor.options.some((option) => option.id === "fast" || option.label === "Fast")), + descriptor.options.some((option) => option.id === "fast" || option.label === "Fast")) || + (descriptor.id === "speed" && + descriptor.type === "select" && + descriptor.options.some((option) => option.id === "fast" || option.id === "priority")), ); if (hasFastMode) labels.push("Fast mode"); if (descriptors.some((descriptor) => descriptor.id === "thinking")) labels.push("Thinking"); From 443a729b0d0419c3b52626cdf302ba40e2d71f9e Mon Sep 17 00:00:00 2001 From: Adam Higgins Date: Fri, 11 Sep 2026 14:00:01 +0100 Subject: [PATCH 6/7] fix(providers): drop stale Devin model rows after a successful refresh The registry's snapshot merge retained all previously seen models for every driver except codex/antigravity/opencode, so Devin's old per-variant rows survived each probe alongside the grouped family rows. Give Devin the same rule: a ready probe's discovered list replaces the inventory, while warning/error/disabled/unauthenticated snapshots keep the last good catalog. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../provider/Layers/ProviderRegistry.test.ts | 87 +++++++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 18 +++- 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 988c89e1e679..a96115d5d6a9 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -732,6 +732,92 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); }); + it("drops stale Devin variant rows missing from a successful refresh", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("devin"), + driver: ProviderDriverKind.make("devin"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-09-11T00:00:00.000Z", + version: "3000.10.21", + models: [ + { + slug: "swe-2-high", + name: "SWE-2 High", + isCustom: false, + capabilities: null, + }, + { + slug: "swe-2-medium", + name: "SWE-2 Medium", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-09-11T00:01:00.000Z", + models: [ + { + slug: "swe-2", + name: "SWE-2", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("effort", "Reasoning", [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ]), + ], + }), + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...refreshedProvider.models, + ]); + }); + + it("retains Devin models when model discovery degrades to a warning", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("devin"), + driver: ProviderDriverKind.make("devin"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-09-11T00:00:00.000Z", + version: "3000.10.21", + models: [ + { + slug: "swe-2", + name: "SWE-2", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + status: "warning", + checkedAt: "2026-09-11T00:01:00.000Z", + models: [], + message: "Devin model discovery failed.", + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...previousProvider.models, + ]); + }); + it("retains stale OpenCode models when a refresh fails", () => { const previousProvider = { instanceId: ProviderInstanceId.make("opencode"), @@ -2615,6 +2701,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..3d8de62ded0f 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; @@ -121,8 +127,14 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => const isPendingInitialProbe = provider.enabled && !provider.installed && provider.status === "warning"; const didInstalledProviderProbeFail = provider.installed && provider.status === "error"; + // Devin reports a failed `models list` as a warning on an installed CLI — + // keep the last good catalog until discovery succeeds again. + const isDevinDiscoveryDegraded = isDevin && provider.installed && provider.status === "warning"; return ( - isPendingAntigravityAuthentication || isPendingInitialProbe || didInstalledProviderProbeFail + isPendingAntigravityAuthentication || + isPendingInitialProbe || + didInstalledProviderProbeFail || + isDevinDiscoveryDegraded ); }; From a485b5176f3776ab34a13c73250ce5d2daae91f1 Mon Sep 17 00:00:00 2001 From: Adam Higgins Date: Fri, 11 Sep 2026 14:08:41 +0100 Subject: [PATCH 7/7] feat(providers): group Devin families with opaque variant uids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Families like gpt-5.1 and claude-sonnet-4.5 carry their effort tiers in uids that share no base (`MODEL_PRIVATE_12..15`, `MODEL_PRIVATE_2/3`), so uid-suffix parsing left them as separate rows. Variants in one family are the same model by definition — when the uids can't prove it, parse the label remainder ("GPT-5.1 Low Thinking" → low) and emit a single effort select whose choices carry the concrete uid. The resolver now passes a dim selection that is itself an advertised model value straight through, so opaque families resolve without a shared base. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/provider/devinModelCatalog.test.ts | 28 +- apps/server/src/provider/devinModelCatalog.ts | 262 +++++++++++++----- 2 files changed, 213 insertions(+), 77 deletions(-) diff --git a/apps/server/src/provider/devinModelCatalog.test.ts b/apps/server/src/provider/devinModelCatalog.test.ts index 774b42d9763a..d7066e4904d5 100644 --- a/apps/server/src/provider/devinModelCatalog.test.ts +++ b/apps/server/src/provider/devinModelCatalog.test.ts @@ -119,12 +119,13 @@ describe("devinModelsFromCatalog grouping", () => { ]); }); - it("splits families whose variants parse to different bases", () => { + it("groups families whose opaque uids share no base via label dims", () => { const models = devinModelsFromCatalog({ families: [ { family_label: "Claude Sonnet 4.5", family_uid: "claude-sonnet-4.5", + slug: "claude-sonnet-4.5", variants: [ { model_uid: "MODEL_PRIVATE_2", label: "Claude Sonnet 4.5" }, { model_uid: "MODEL_PRIVATE_3", label: "Claude Sonnet 4.5 Thinking" }, @@ -132,8 +133,19 @@ describe("devinModelsFromCatalog grouping", () => { }, ], }); - expect(models.map((m) => m.slug)).toEqual(["model-private-2", "model-private-3"]); - expect(models.map((m) => m.name)).toEqual(["Claude Sonnet 4.5", "Claude Sonnet 4.5 Thinking"]); + expect(models).toHaveLength(1); + const row = models[0]!; + expect(row.slug).toBe("MODEL_PRIVATE_2"); + expect(row.name).toBe("Claude Sonnet 4.5"); + const effort = row.capabilities?.optionDescriptors?.find((d) => d.id === "effort"); + expect(effort?.type === "select" ? effort.options.map((o) => o.id) : []).toEqual([ + "MODEL_PRIVATE_2", + "MODEL_PRIVATE_3", + ]); + expect(effort?.type === "select" ? effort.options.map((o) => o.label) : []).toEqual([ + "Default", + "Thinking", + ]); }); }); @@ -152,6 +164,16 @@ describe("resolveDevinModelUid", () => { "adaptive", ]; + it("passes through uid-valued dim selections from opaque families", () => { + expect( + resolveDevinModelUid({ + model: "MODEL_PRIVATE_14", + selections: [{ id: "effort", value: "MODEL_PRIVATE_12" }], + advertisedValues: ["MODEL_PRIVATE_12", "MODEL_PRIVATE_13", "MODEL_PRIVATE_14"], + }), + ).toBe("MODEL_PRIVATE_12"); + }); + it("passes through exact uid matches", () => { expect(resolveDevinModelUid({ model: "swe-2-high", advertisedValues: advertised })).toBe( "swe-2-high", diff --git a/apps/server/src/provider/devinModelCatalog.ts b/apps/server/src/provider/devinModelCatalog.ts index 9e8e9b9fea4c..077581a7f3e8 100644 --- a/apps/server/src/provider/devinModelCatalog.ts +++ b/apps/server/src/provider/devinModelCatalog.ts @@ -8,8 +8,8 @@ * mode. This module owns both directions: * * - `devinModelsFromCatalog` groups `devin models list --format json` into - * per-base rows with `effort` / `speed` / `context` option descriptors. - * - `resolveDevinModelUid` turns `{ model: , options }` back into a + * per-family rows with `effort` / `speed` / `context` option descriptors. + * - `resolveDevinModelUid` turns `{ model: , options }` back into a * concrete advertised uid for `session/set_config_option`. * * @module devinModelCatalog @@ -146,6 +146,72 @@ export interface DevinModelsListJson { /** `adaptive` is Devin's recommended auto-router and the picker default. */ const DEVIN_DEFAULT_MODEL_SLUG = "adaptive"; +interface ParsedVariant { + readonly uid: string; + readonly label: string | undefined; + readonly dims: DevinModelDims; + readonly isNew: boolean; +} + +function effortRank(effort: string): number { + const index = EFFORT_ORDER.indexOf(effort as (typeof EFFORT_ORDER)[number]); + return index === -1 ? EFFORT_ORDER.length : index; +} + +/** + * Label-derived dims for families whose variant uids are opaque + * (`MODEL_PRIVATE_*`): the variant label minus the family label carries the + * effort words — "GPT-5.1 Low Thinking" → `low`, "… No Thinking" → `none`, + * "… Thinking" → `thinking`. Returns `undefined` when the label does not + * extend the family label so callers fall back to uid parsing. + */ +export function parseDevinVariantLabel( + familyLabel: string | undefined, + variantLabel: string | undefined, +): DevinModelDims | undefined { + if (!familyLabel || !variantLabel) return undefined; + const trimmedFamily = familyLabel.trim(); + const trimmedVariant = variantLabel.trim(); + if ( + !trimmedVariant.toLowerCase().startsWith(trimmedFamily.toLowerCase()) || + trimmedVariant.length === trimmedFamily.length + ) { + return undefined; + } + let rest = trimmedVariant.slice(trimmedFamily.length).trim().toLowerCase(); + + let context: string | undefined; + let speed: string | undefined; + for (const token of rest.split(/\s+/).reverse()) { + if (context === undefined && CONTEXT_TOKENS.has(token)) { + context = token; + rest = rest.slice(0, rest.length - token.length).trim(); + continue; + } + if (speed === undefined && SPEED_TOKENS.has(token)) { + speed = token; + rest = rest.slice(0, rest.length - token.length).trim(); + continue; + } + break; + } + + let effort: string | undefined; + if (rest === "no thinking") { + effort = "none"; + } else if (rest === "thinking") { + effort = "thinking"; + } else { + const stripped = rest.replace(/\s*thinking$/, ""); + if (stripped !== rest && EFFORT_TOKENS.has(stripped)) { + effort = stripped; + } else if (EFFORT_TOKENS.has(rest)) { + effort = rest; + } + } + return { base: "", effort, speed, context }; +} + function effortChoice(effort: string, isDefault: boolean): ProviderOptionChoice { return { id: effort, @@ -155,11 +221,12 @@ function effortChoice(effort: string, isDefault: boolean): ProviderOptionChoice } /** - * One row per (family, base) group. Variants sharing a base become option - * descriptor choices: bare variants contribute a "Default" effort choice, - * `-fast`/`-priority` a Speed select, and `-1m` a Context select. Variant - * uids land in `aliases` so stored flat selections like `swe-2-high` still - * resolve to the grouped row. + * One row per family. Variants sharing a uid base become `effort`/`speed`/ + * `context` option descriptors (token-valued choices resolved by dims match); + * families whose uids share no base (`MODEL_PRIVATE_*` under gpt-5.1) emit a + * single effort select whose choices carry the concrete uid, which + * `resolveDevinModelUid` passes through. Variant uids land in `aliases` so + * stored flat selections like `swe-2-high` still resolve to the family row. */ export function devinModelsFromCatalog( parsed: DevinModelsListJson | undefined, @@ -172,69 +239,55 @@ export function devinModelsFromCatalog( typeof family.family_label === "string" && family.family_label.trim() ? family.family_label.trim() : undefined; - const familyAliases = (family.aliases ?? []).filter( - (alias): alias is string => typeof alias === "string" && alias.trim().length > 0, - ); - - // Group variants by parsed base; several bases in one family (e.g. - // MODEL_PRIVATE_* under claude-sonnet-4.5) are genuinely distinct models. - const groups = new Map< - string, - { - uids: Array; - dims: Array; - label: string | undefined; - isNew: boolean; - } - >(); + const familySlug = typeof family.slug === "string" ? family.slug.trim() : ""; + const familyAliases = [ + ...(family.aliases ?? []).filter( + (alias): alias is string => typeof alias === "string" && alias.trim().length > 0, + ), + ...(familySlug ? [familySlug] : []), + ]; + + const variants: Array = []; for (const variant of family.variants ?? []) { const uid = typeof variant.model_uid === "string" ? variant.model_uid.trim() : ""; if (!uid) continue; - const dims = parseDevinModelUid(uid); - const variantLabel = + const label = typeof variant.label === "string" && variant.label.trim() ? variant.label.trim() : undefined; - const group = groups.get(dims.base); - if (group) { - group.uids.push(uid); - group.dims.push(dims); - group.isNew ||= variant.is_new === true; - } else { - groups.set(dims.base, { - uids: [uid], - dims: [dims], - label: variantLabel, - isNew: variant.is_new === true, - }); - } + variants.push({ uid, label, dims: parseDevinModelUid(uid), isNew: variant.is_new === true }); } - - for (const [base, group] of groups) { - if (seenSlugs.has(base)) continue; - seenSlugs.add(base); - + if (variants.length === 0) continue; + + const distinctBases = new Set(variants.map((variant) => variant.dims.base)); + const isNew = variants.some((variant) => variant.isNew); + const optionDescriptors: Array<{ + id: string; + label: string; + type: "select"; + options: Array; + }> = []; + let slug: string; + + if (distinctBases.size <= 1) { + // Shared uid base — multi-axis dims from uid suffixes. + slug = variants[0]!.dims.base; const efforts = new Set(); const speeds = new Set(); const contexts = new Set(); - for (const dims of group.dims) { - if (dims.effort) efforts.add(dims.effort); - if (dims.speed) speeds.add(dims.speed); - if (dims.context) contexts.add(dims.context); + for (const variant of variants) { + if (variant.dims.effort) efforts.add(variant.dims.effort); + if (variant.dims.speed) speeds.add(variant.dims.speed); + if (variant.dims.context) contexts.add(variant.dims.context); } - const hasBare = group.dims.some( - (dims) => - dims.effort === undefined && dims.speed === undefined && dims.context === undefined, + const hasBare = variants.some( + (variant) => + variant.dims.effort === undefined && + variant.dims.speed === undefined && + variant.dims.context === undefined, ); - const optionDescriptors: Array<{ - id: string; - label: string; - type: "select"; - options: Array; - }> = []; - - if (efforts.size > 0 || hasBare) { + if (efforts.size > 0 || (hasBare && variants.length > 1)) { const options: Array = []; if (hasBare) { options.push(effortChoice(DEFAULT_EFFORT_VALUE, true)); @@ -255,7 +308,7 @@ export function devinModelsFromCatalog( if (speeds.size > 0) { const options: Array = []; - const hasStandard = group.dims.some((dims) => dims.speed === undefined); + const hasStandard = variants.some((variant) => variant.dims.speed === undefined); if (hasStandard) { options.push({ id: STANDARD_SPEED_VALUE, @@ -283,7 +336,7 @@ export function devinModelsFromCatalog( if (contexts.size > 0) { const options: Array = []; - const hasStandard = group.dims.some((dims) => dims.context === undefined); + const hasStandard = variants.some((variant) => variant.dims.context === undefined); if (hasStandard) { options.push({ id: STANDARD_CONTEXT_VALUE, @@ -308,22 +361,72 @@ export function devinModelsFromCatalog( }); } } - - models.push({ - slug: base, - name: - groups.size === 1 - ? (familyLabel ?? group.label ?? base) - : (group.label ?? familyLabel ?? base), - ...(familyAliases.length > 0 || group.uids.length > 1 - ? { aliases: [...familyAliases, ...group.uids.filter((uid) => uid !== base)] } - : {}), - ...(group.isNew ? { badge: "new" as const } : {}), - isCustom: false, - isDefault: base === DEVIN_DEFAULT_MODEL_SLUG, - capabilities: optionDescriptors.length > 0 ? { optionDescriptors } : null, - }); + } else { + // Opaque uids — one effort select with uid-valued choices, ordered by + // effort rank. `resolveDevinModelUid` passes a uid-valued selection + // straight through, so no shared base is needed. + const byVariant = variants.map((variant) => ({ + ...variant, + labelDims: parseDevinVariantLabel(familyLabel, variant.label), + })); + const isBare = (variant: (typeof byVariant)[number]) => + variant.labelDims?.effort === undefined && + variant.labelDims?.speed === undefined && + variant.labelDims?.context === undefined; + const defaultVariant = + byVariant.find(isBare) ?? + byVariant.find((variant) => variant.labelDims?.effort === "medium") ?? + byVariant[0]!; + slug = defaultVariant.uid; + + const options = byVariant + .toSorted( + (left, right) => + effortRank(left.labelDims?.effort ?? DEFAULT_EFFORT_VALUE) - + effortRank(right.labelDims?.effort ?? DEFAULT_EFFORT_VALUE), + ) + .map((variant) => { + const suffix = + familyLabel && + variant.label !== undefined && + variant.label.toLowerCase().startsWith(familyLabel.toLowerCase()) + ? variant.label.slice(familyLabel.length).trim() + : ""; + return { + id: variant.uid, + label: + suffix !== "" + ? suffix + : EFFORT_LABELS[variant.labelDims?.effort ?? DEFAULT_EFFORT_VALUE]!, + ...(variant.uid === defaultVariant.uid ? { isDefault: true } : {}), + }; + }); + if (options.length > 1) { + optionDescriptors.push({ + id: DEVIN_EFFORT_OPTION_ID, + label: "Reasoning", + type: "select", + options, + }); + } } + + if (seenSlugs.has(slug)) continue; + seenSlugs.add(slug); + const aliases = [...familyAliases, ...variants.map((variant) => variant.uid)].filter( + (alias) => alias !== slug, + ); + models.push({ + slug, + name: familyLabel ?? slug, + ...(aliases.length > 0 ? { aliases } : {}), + ...(isNew ? { badge: "new" as const } : {}), + isCustom: false, + isDefault: + slug === DEVIN_DEFAULT_MODEL_SLUG || + variants.some((v) => v.uid === DEVIN_DEFAULT_MODEL_SLUG), + capabilities: optionDescriptors.length > 0 ? { optionDescriptors } : null, + }); } return models; } @@ -383,6 +486,17 @@ export function resolveDevinModelUid(input: { const model = input.model.trim(); if (!model) return model; + // Opaque families (no shared uid base, e.g. `MODEL_PRIVATE_*`) put the + // concrete uid in the dim choice id — a selection that is itself an + // advertised value wins outright. + const direct = (input.selections ?? []).find( + (selection) => + isDevinModelDimOptionId(selection.id) && + typeof selection.value === "string" && + input.advertisedValues.includes(selection.value), + ); + if (direct) return direct.value as string; + const wanted = wantedDims(input.selections); // A base id like `swe-1-7` is often *also* an advertised uid (the family's // default-effort variant), so the exact-match pass-through only applies