diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 374738d0aeca..f257c42d4f7e 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -37,6 +37,31 @@ 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..f60ec809e4c9 --- /dev/null +++ b/apps/server/src/provider/Layers/DevinProvider.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { devinModelsFromCatalog, parseDevinAuthStatus } from "./DevinProvider.ts"; + +const SAMPLE_MODELS_JSON = { + families: [ + { + family_uid: "swe-2", + family_label: "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", + family_label: "Adaptive", + slug: "devin-core", + variants: [{ model_uid: "adaptive", label: "Adaptive" }], + }, + ], +}; + +describe("devinModelsFromCatalog", () => { + it("groups effort variants into one row per family", () => { + const models = devinModelsFromCatalog(SAMPLE_MODELS_JSON); + expect(models.map((model) => model.slug)).toEqual(["swe-2", "adaptive"]); + + 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(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", () => { + 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..fe3d0f1bf201 --- /dev/null +++ b/apps/server/src/provider/Layers/DevinProvider.ts @@ -0,0 +1,448 @@ +/** + * 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` ──────────────────────────────────── + +import { devinModelsFromCatalog, type DevinModelsListJson } from "../devinModelCatalog.ts"; + +export { devinModelsFromCatalog }; + +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; + } +} + +/** + * 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( + 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/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 ); }; 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..e622b0c616f4 --- /dev/null +++ b/apps/server/src/provider/acp/DevinAcpSupport.ts @@ -0,0 +1,266 @@ +/** + * 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 { collectSessionConfigOptionValues } from "./AcpRuntimeModel.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import { isDevinModelDimOptionId, resolveDevinModelUid } from "../devinModelCatalog.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* () { + // 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) { + 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, + step: "set-model", + }), + ), + ); + } + + // 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 + .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 { + 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("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" }, + ], + }, + ], + }); + 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", + ]); + }); +}); + +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 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", + ); + 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..077581a7f3e8 --- /dev/null +++ b/apps/server/src/provider/devinModelCatalog.ts @@ -0,0 +1,549 @@ +/** + * 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-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 + */ +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"; + +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, + label: EFFORT_LABELS[effort] ?? effort, + ...(isDefault ? { isDefault: true } : {}), + }; +} + +/** + * 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, +): 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 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 label = + typeof variant.label === "string" && variant.label.trim() + ? variant.label.trim() + : undefined; + variants.push({ uid, label, dims: parseDevinModelUid(uid), isNew: variant.is_new === true }); + } + 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 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 = variants.some( + (variant) => + variant.dims.effort === undefined && + variant.dims.speed === undefined && + variant.dims.context === undefined, + ); + + if (efforts.size > 0 || (hasBare && variants.length > 1)) { + 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 = variants.some((variant) => variant.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 = variants.some((variant) => variant.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, + }); + } + } + } 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; +} + +// ── 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; + + // 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 + // 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/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index a4b260f43bf6..de27b127309a 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -37,6 +37,7 @@ const BUILT_IN_DRIVER_ORDER: 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..0835fdc5d18d 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -233,6 +233,20 @@ 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/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 5c6517e154a1..baadc7be2165 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -60,6 +60,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { connectPairing } from "../../connection/onboarding"; import { getProviderSummary } from "../settings/providerStatus"; import { getDriverOption } from "../settings/providerDriverMeta"; +import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { TerminalViewport } from "../ThreadTerminalDrawer"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; import { ClaudeAI, OpenAI } from "../Icons"; @@ -606,7 +607,7 @@ function PairingForm({ // ── Step 3: agents ─────────────────────────────────────────── -const PRIMARY_AGENT_DRIVERS = ["claudeAgent", "codex"] as const; +const PRIMARY_AGENT_DRIVERS = ["claudeAgent", "codex", "devin"] as const; type OnboardingAgentDriver = (typeof PRIMARY_AGENT_DRIVERS)[number]; /** Setup values stay fixed while provider probes refresh the surrounding cards. */ @@ -675,6 +676,7 @@ function ConnectedAgentsStep({ reportFailure: false, }); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const updateSettings = useUpdateEnvironmentSettings(environmentId); const [terminalSession, setTerminalSession] = useState(null); // Re-probe on entry so freshly installed CLIs show up without a manual @@ -685,6 +687,30 @@ function ConnectedAgentsStep({ const byDriver = useMemo(() => selectOnboardingProvidersByDriver(providers), [providers]); + // Opt-in drivers (Devin) arrive disabled by default; the card's Enable + // button flips the instance envelope, which is authoritative over the + // legacy `providers..enabled` flag. + const enableProvider = (provider: ServerProvider, driver: OnboardingAgentDriver) => { + if (serverConfig === null) return; + const settings = serverConfig.settings; + const existing = settings.providerInstances?.[provider.instanceId]; + const legacyConfig = (settings.providers as Record)[ + driver + ]; + const { enabled: _legacyEnabled, ...legacyConfigRest } = legacyConfig ?? {}; + updateSettings({ + providerInstances: { + ...settings.providerInstances, + [provider.instanceId]: { + ...existing, + driver: ProviderDriverKind.make(driver), + enabled: true, + config: existing?.config ?? legacyConfigRest, + }, + }, + }); + }; + const primaryAgents = PRIMARY_AGENT_DRIVERS.map((driver) => ({ driver, provider: byDriver.get(driver), @@ -700,6 +726,7 @@ function ConnectedAgentsStep({ provider={provider} terminalOpen={terminalSession?.driver === driver} terminalAvailable={serverConfig !== null} + onEnable={provider === undefined ? undefined : () => enableProvider(provider, driver)} onOpenTerminal={() => { if (provider === undefined || serverConfig === null) return; setTerminalSession({ @@ -742,12 +769,14 @@ function AgentCard({ provider, terminalOpen, terminalAvailable, + onEnable, onOpenTerminal, }: { readonly driver: OnboardingAgentDriver; readonly provider: ServerProvider | undefined; readonly terminalOpen: boolean; readonly terminalAvailable: boolean; + readonly onEnable: (() => void) | undefined; readonly onOpenTerminal: () => void; }) { const meta = getDriverOption(ProviderDriverKind.make(driver)); @@ -777,7 +806,13 @@ function AgentCard({ ) : providerState === "checking" ? ( Checking... ) : providerState === "disabled" ? ( - Disabled + onEnable ? ( + + ) : ( + Disabled + ) ) : providerState === "attention" ? ( {summary.headline} ) : ( diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 505a86ff0eac..1d151730abf9 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: Partial 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"); diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 4bf4da3919ba..66200f17e4d0 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -3,6 +3,7 @@ import { ClaudeSettings, CodexSettings, CursorSettings, + DevinSettings, GrokSettings, OpenCodeSettings, ProviderDriverKind, @@ -12,6 +13,7 @@ import { AntigravityIcon, ClaudeAI, CursorIcon, + DevinIcon, GrokIcon, type Icon, OpenAI, @@ -63,6 +65,13 @@ const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ badgeLabel: "Early Access", settingsSchema: CursorSettings, }, + { + value: ProviderDriverKind.make("devin"), + label: "Devin", + icon: DevinIcon, + badgeLabel: "Early Access", + settingsSchema: DevinSettings, + }, { value: ProviderDriverKind.make("grok"), label: "Grok", diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index f56579990714..7ba12e734ad3 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -230,7 +230,7 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "provider-update-checks", title: "Provider update checks", to: "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/settings/general", - searchTerms: ["installed cli versions newer available codex claude cursor grok opencode"], + searchTerms: ["installed cli versions newer available codex claude cursor devin grok opencode"], }, { id: "continue-threads-after-server-update", @@ -377,7 +377,7 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Providers", to: "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/settings/providers", searchTerms: [ - "agents cli codex claude cursor grok opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory endpoint arguments environment variables display name accent color custom favorite hidden auto compact", + "agents cli codex claude cursor devin grok opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory endpoint arguments environment variables display name accent color custom favorite hidden auto compact", ], }, { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 8f4ad48e16ab..c31d497b3466 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -973,7 +973,7 @@ function normalizeProviderModelOptions( ): ProviderOptionSelectionsByProvider | null { const candidate = value && typeof value === "object" ? (value as Record) : null; const result: ProviderOptionSelectionsByProvider = {}; - for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const providerKey of ["codex", "claudeAgent", "cursor", "devin", "opencode"] as const) { const selections = coerceProviderOptionSelections(candidate?.[providerKey]); if (selections) { result[providerKey] = selections; @@ -1132,7 +1132,7 @@ function legacyToModelSelectionByProvider( ): Partial> { const result: Partial> = {}; if (modelOptions) { - for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const provider of ["codex", "claudeAgent", "cursor", "devin", "opencode"] as const) { const options = modelOptions[provider]; if (options && options.length > 0) { const driverKind = ProviderDriverKind.make(provider); @@ -3089,7 +3089,13 @@ const composerDraftStore = create()( } const base = existing ?? createEmptyThreadDraft(); const nextMap = { ...base.modelSelectionByProvider }; - for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const provider of [ + "codex", + "claudeAgent", + "cursor", + "devin", + "opencode", + ] as const) { if (!modelOptions || !(provider in modelOptions)) continue; const opts = modelOptions[provider]; const driverKind = ProviderDriverKind.make(provider); 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; } diff --git a/docs/README.md b/docs/README.md index 4691e6f83c8e..60fcbc505709 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,7 @@ - [Remote access](./user/remote-access.md) - [Running in the background](./user/background-service.md) - [Updating T3 Code](./user/updating.md) -- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) +- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [Devin](./user/providers-devin.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) --- diff --git a/docs/user/install.md b/docs/user/install.md index 17e9291bf1a0..084f39b23871 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -94,8 +94,8 @@ base URL. Mark secret values as sensitive; after saving, T3 Code does not displa their original values. For provider-specific setup and accounts, see [Codex](./providers-codex.md), -[Claude](./providers-claude.md), [OpenCode](./providers-opencode.md), and -[Antigravity](./providers-antigravity.md). +[Claude](./providers-claude.md), [Devin](./providers-devin.md), +[OpenCode](./providers-opencode.md), and [Antigravity](./providers-antigravity.md). ## Next steps diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 3d37850c5e20..47db0811142d 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -28,4 +28,8 @@ actions still require approval. Antigravity can still send native approval requests in **Full access**. It only offers remembered approvals for actions that support them. +Devin maps the modes onto its session modes: **Supervised** keeps Devin's default policy and prompts +for risky actions, **Auto-accept edits** runs Code, **Auto** runs Smart, and **Full access** runs +Bypass Permissions. + See the [provider guides](./install.md#providers) for setup and provider-specific limits. diff --git a/docs/user/providers-devin.md b/docs/user/providers-devin.md new file mode 100644 index 000000000000..1be9cb0c4d9b --- /dev/null +++ b/docs/user/providers-devin.md @@ -0,0 +1,21 @@ +# Devin + +Install and authenticate the Devin CLI (`devin auth login`) on the machine +running your environment, then enable it in **Settings > Providers**. See +[provider setup](./install.md#providers). T3 Code talks to `devin acp`, so any +account-signed Devin CLI works — including SWE models such as `swe-2-high`. + +## Models + +The model list comes from `devin models list` for the signed-in account. +**Adaptive** is the default and routes between models automatically. After +changing login or team model access, use **Refresh provider status** in +**Settings > Providers**. + +## Approvals + +Devin follows the shared [permission modes](./permission-modes.md), mapped onto +Devin's session modes. **Supervised** runs Devin's default permission policy +and prompts for risky actions; **Auto-accept edits** maps to Code, **Auto** to +Smart, and **Full access** to Bypass Permissions. Plan turns run in Plan mode +and the previous mode is restored afterwards. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index bce1a766bc9b..5688477fd461 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -148,6 +148,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const DEVIN_DRIVER_KIND = ProviderDriverKind.make("devin"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -173,6 +174,8 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [DEVIN_DRIVER_KIND]: "Devin", }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index dd6136461fc1..3b42ae01d20d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -847,6 +847,34 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const DevinSettings = makeProviderSettingsSchema( + { + // Off by default (like Cursor and Grok): users opt in from Settings. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("devin").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Devin CLI binary.", + providerSettingsForm: { + placeholder: "devin", + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type DevinSettings = typeof DevinSettings.Type; + /** * A read-only quota source outside this environment's provider CLIs. The * only kind today is a CLIProxyAPI hub, whose management API reports the @@ -1067,6 +1095,7 @@ export const ServerSettings = Schema.Struct({ grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), antigravity: AntigravitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + devin: DevinSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob @@ -1239,6 +1268,12 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), }); +const DevinSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), @@ -1301,6 +1336,7 @@ export const ServerSettingsPatch = Schema.Struct({ grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), antigravity: Schema.optionalKey(AntigravitySettingsPatch), + devin: Schema.optionalKey(DevinSettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual