From 19f2030c584b0c2d50372e95005b11d176cd5ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Fran=C3=A7a?= Date: Tue, 11 Aug 2026 18:57:43 -0300 Subject: [PATCH 01/18] feat: add Devin provider support T3 Code now supports Devin as a first-class provider alongside Codex, Claude, Cursor, Grok, and OpenCode. Server changes add the Devin driver, ACP adapter and runtime, provider snapshot, text generation, and usage transcript support. Web and contract changes add the Devin icon, settings, model selection, and usage attribution. Docs are updated with a Devin provider guide and related internals references. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 7 +- .../src/provider/Drivers/DevinBinary.ts | 32 + .../src/provider/Drivers/DevinDriver.ts | 178 ++ apps/server/src/provider/Drivers/DevinHome.ts | 59 + .../src/provider/Layers/DevinAdapter.ts | 1697 +++++++++++++++++ .../src/provider/Layers/DevinProvider.test.ts | 560 ++++++ .../src/provider/Layers/DevinProvider.ts | 808 ++++++++ .../src/provider/Services/DevinAdapter.ts | 7 + .../src/provider/acp/AcpNativeLogging.ts | 12 +- .../src/provider/acp/AcpRuntimeModel.test.ts | 47 +- .../src/provider/acp/AcpRuntimeModel.ts | 43 +- .../src/provider/acp/AcpSessionRuntime.ts | 35 +- .../src/provider/acp/DevinAcpSupport.test.ts | 249 +++ .../src/provider/acp/DevinAcpSupport.ts | 298 +++ apps/server/src/provider/builtInDrivers.ts | 3 + .../src/provider/providerStatusCache.test.ts | 145 +- .../src/provider/providerStatusCache.ts | 13 +- .../src/textGeneration/DevinTextGeneration.ts | 268 +++ apps/server/src/usage/UsageService.ts | 10 +- .../server/src/usage/usageTranscriptReader.ts | 8 + .../server/src/usage/usageTranscripts.test.ts | 47 +- apps/server/src/usage/usageTranscripts.ts | 60 +- apps/web/src/components/Icons.tsx | 11 + .../src/components/chat/providerIconUtils.ts | 3 +- .../settings/ProviderModelsSection.tsx | 22 +- .../settings/ProviderSettingsForm.test.ts | 24 + .../settings/ProviderSettingsForm.tsx | 39 + .../components/settings/providerDriverMeta.ts | 18 +- .../src/components/usage/usageProviders.ts | 14 +- apps/web/src/session-logic.ts | 12 +- docs/README.md | 2 +- docs/internals/glossary.md | 2 +- docs/internals/overview.md | 10 +- docs/internals/providers.md | 4 +- docs/user/install.md | 17 +- docs/user/providers-devin.md | 111 ++ packages/contracts/src/model.ts | 5 + packages/contracts/src/server.ts | 1 + packages/contracts/src/settings.ts | 124 +- packages/contracts/src/usage.ts | 2 +- 40 files changed, 4948 insertions(+), 59 deletions(-) create mode 100644 apps/server/src/provider/Drivers/DevinBinary.ts create mode 100644 apps/server/src/provider/Drivers/DevinDriver.ts create mode 100644 apps/server/src/provider/Drivers/DevinHome.ts create mode 100644 apps/server/src/provider/Layers/DevinAdapter.ts create mode 100644 apps/server/src/provider/Layers/DevinProvider.test.ts create mode 100644 apps/server/src/provider/Layers/DevinProvider.ts create mode 100644 apps/server/src/provider/Services/DevinAdapter.ts create mode 100644 apps/server/src/provider/acp/DevinAcpSupport.test.ts create mode 100644 apps/server/src/provider/acp/DevinAcpSupport.ts create mode 100644 apps/server/src/textGeneration/DevinTextGeneration.ts create mode 100644 docs/user/providers-devin.md diff --git a/README.md b/README.md index c2349e72860a..187c0a8d2e4e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). -Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them. +Works with your subscriptions on Claude Code, Codex, Cursor, Devin, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them. ## "Wait, what are you selling me?" @@ -13,11 +13,12 @@ We wanted something performant, remote-ready, and truly open. If we ever go the ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use: +> T3 Code currently supports Codex, Claude, Cursor, Devin, Grok Build and OpenCode. Install and authenticate at least one provider before use: > > - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` > - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` > - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login` +> - Devin: install the [Devin CLI](https://devin.ai) and follow the Devin CLI documentation to authenticate > - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login` > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` @@ -72,7 +73,7 @@ Full docs live in [docs/](./docs). There's no docs site yet. - [Remote access from a phone or another machine](./docs/user/remote-access.md) - [Keeping app and server in sync](./docs/user/updating.md) - [Source control integrations](./docs/user/source-control.md) -- Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md) +- Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md) · [Devin](./docs/user/providers-devin.md) - Linux: [run T3 Code as a background service](./docs/user/background-service.md) Building from source? Start at [docs/internals/overview.md](./docs/internals/overview.md). diff --git a/apps/server/src/provider/Drivers/DevinBinary.ts b/apps/server/src/provider/Drivers/DevinBinary.ts new file mode 100644 index 000000000000..eec288e92c83 --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinBinary.ts @@ -0,0 +1,32 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { isCommandAvailable } from "@t3tools/shared/shell"; + +const DEFAULT_DEVIN_BINARIES = ["devin", "devin-desktop"] as const; + +export const resolveEffectiveDevinBinary = Effect.fn("resolveEffectiveDevinBinary")(function* ( + binaryPath: string | null | undefined, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return { + const configured = (binaryPath ?? "").trim() || "devin"; + + const env = environment ?? process.env; + + if (!DEFAULT_DEVIN_BINARIES.includes(configured as (typeof DEFAULT_DEVIN_BINARIES)[number])) { + return configured; + } + + if (yield* isCommandAvailable(configured, { env })) { + return configured; + } + + for (const candidate of DEFAULT_DEVIN_BINARIES) { + if (candidate === configured) continue; + if (yield* isCommandAvailable(candidate, { env })) { + return candidate; + } + } + + return configured; +}); diff --git a/apps/server/src/provider/Drivers/DevinDriver.ts b/apps/server/src/provider/Drivers/DevinDriver.ts new file mode 100644 index 000000000000..e2d8250acbf1 --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinDriver.ts @@ -0,0 +1,178 @@ +import { DevinSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +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 * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeDevinTextGeneration } from "../../textGeneration/DevinTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeDevinAdapter } from "../Layers/DevinAdapter.ts"; +import { resolveEffectiveDevinBinary } from "./DevinBinary.ts"; +import { makeDevinEnvironment } from "./DevinHome.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 type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeDevinSettings = Schema.decodeSync(DevinSettings); + +const DRIVER_KIND = ProviderDriverKind.make("devin"); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), +); + +export type DevinDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +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 httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + + const resolvedBinary = yield* resolveEffectiveDevinBinary(config.binaryPath, processEnv); + const devinEnv = yield* makeDevinEnvironment(config, processEnv); + + const effectiveConfig = { + ...config, + enabled, + binaryPath: resolvedBinary, + } satisfies DevinSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeDevinAdapter(effectiveConfig, { + environment: devinEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeDevinTextGeneration(effectiveConfig, devinEnv); + + const checkProvider = checkDevinProviderStatus(effectiveConfig, devinEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialDevinProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichDevinSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + 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/Drivers/DevinHome.ts b/apps/server/src/provider/Drivers/DevinHome.ts new file mode 100644 index 000000000000..2aa7ddcaded4 --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinHome.ts @@ -0,0 +1,59 @@ +import * as NodeOS from "node:os"; + +import { type DevinSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; + +import { expandHomePath } from "../../pathExpansion.ts"; + +export const DEVIN_DEFAULT_HOME_DIR = ".devin"; +export const DEVIN_USAGE_TRANSCRIPT_NAME = "t3code-usage.jsonl"; + +export const resolveDevinHomePath = Effect.fn("resolveDevinHomePath")(function* ( + config: Pick, +): Effect.fn.Return { + const path = yield* Path.Path; + const homePath = config.homePath.trim(); + const expanded = + homePath.length > 0 + ? expandHomePath(homePath) + : path.join(NodeOS.homedir(), DEVIN_DEFAULT_HOME_DIR); + return path.resolve(expanded); +}); + +export const makeDevinEnvironment = Effect.fn("makeDevinEnvironment")(function* ( + config: Pick, + baseEnv?: NodeJS.ProcessEnv, +): Effect.fn.Return { + const resolvedBaseEnv = baseEnv ?? process.env; + const homePath = config.homePath.trim(); + if (homePath.length === 0) return resolvedBaseEnv; + const resolvedHomePath = yield* resolveDevinHomePath(config); + return { + ...resolvedBaseEnv, + DEVIN_HOME: resolvedHomePath, + }; +}); + +export const makeDevinContinuationGroupKey = Effect.fn("makeDevinContinuationGroupKey")(function* ( + config: Pick, +): Effect.fn.Return { + const resolvedHomePath = yield* resolveDevinHomePath(config); + return `devin:home:${resolvedHomePath}`; +}); + +export const makeDevinCapabilitiesCacheKey = Effect.fn("makeDevinCapabilitiesCacheKey")(function* ( + config: Pick, + cwd?: string, +): Effect.fn.Return { + const resolvedHomePath = yield* resolveDevinHomePath(config); + return `${config.binaryPath}\0${resolvedHomePath}\0${cwd ?? ""}`; +}); + +export const resolveDevinUsageTranscriptPath = Effect.fn("resolveDevinUsageTranscriptPath")( + function* (config: Pick): Effect.fn.Return { + const path = yield* Path.Path; + const homePath = yield* resolveDevinHomePath(config); + return path.join(homePath, DEVIN_USAGE_TRANSCRIPT_NAME); + }, +); diff --git a/apps/server/src/provider/Layers/DevinAdapter.ts b/apps/server/src/provider/Layers/DevinAdapter.ts new file mode 100644 index 000000000000..c53c041d001b --- /dev/null +++ b/apps/server/src/provider/Layers/DevinAdapter.ts @@ -0,0 +1,1697 @@ +import { + ApprovalRequestId, + type DevinSettings, + EventId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type ThreadId, + type ThreadTokenUsageSnapshot, + type UsageTokenTotals, + TurnId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Console from "effect/Console"; +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 Ref from "effect/Ref"; +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 * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + type ProviderAdapterError, + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { 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, type AcpParsedSessionEvent } from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + applyDevinAcpModelSelection, + currentDevinAcpModelSelection, + makeDevinAcpRuntime, + resolveDevinAcpBaseModelId, + resolveDevinAcpModelSelection, +} from "../acp/DevinAcpSupport.ts"; +import { resolveDevinUsageTranscriptPath } from "../Drivers/DevinHome.ts"; +import { type DevinAdapterShape } from "../Services/DevinAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); +const encodeUnknownJsonString = Schema.encodeUnknownEffect(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; + readonly instanceId?: ProviderInstanceId; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; +} + +type PendingUserInputResolution = + | { readonly _tag: "answered"; readonly answers: ProviderUserInputAnswers } + | { readonly _tag: "cancelled" }; + +interface PendingUserInput { + readonly resolution: Deferred.Deferred; +} + +interface DevinSessionContext { + readonly threadId: ThreadId; + readonly acpSessionId: string; + session: ProviderSession; + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + /** Turns already interrupted; late prompt RPCs must not resurrect them. */ + interruptedTurnIds: Set; + /** 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; + currentModelId: string | undefined; + currentReasoningValue: string | undefined; + stopped: boolean; + lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; + lastAcpUsage: EffectAcpSchema.Usage | undefined; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingApprovals.values()), + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); +} + +function settlePendingUserInputsAsCancelled( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingUserInputs.values()), + (pending) => Deferred.succeed(pending.resolution, { _tag: "cancelled" }).pipe(Effect.ignore), + { discard: true }, + ); +} + +function appendPromptResultToTurn( + ctx: DevinSessionContext, + turnId: TurnId, + promptParts: ReadonlyArray, + result: EffectAcpSchema.PromptResponse, +): void { + const existingTurnRecord = ctx.turns.find((turn) => turn.id === turnId); + ctx.turns = existingTurnRecord + ? ctx.turns.map((turn) => + turn.id === turnId + ? { ...turn, items: [...turn.items, { prompt: promptParts, result }] } + : turn, + ) + : [...ctx.turns, { id: turnId, items: [{ prompt: promptParts, result }] }]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const resolveNotificationTurnId = (ctx: DevinSessionContext): TurnId | undefined => + ctx.activeTurnId; + +const resolveCallbackTurnId = (ctx: DevinSessionContext): TurnId | undefined => ctx.activeTurnId; + +const resolveSessionCallbackTurnId = ( + sessions: ReadonlyMap, + threadId: ThreadId, +): TurnId | undefined => { + const ctx = sessions.get(threadId); + return ctx ? resolveCallbackTurnId(ctx) : undefined; +}; + +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 selectPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + const kind = + decision === "acceptForSession" + ? "allow_always" + : decision === "accept" + ? "allow_once" + : "reject_once"; + const option = request.options.find((entry) => entry.kind === kind); + return option?.optionId.trim() || undefined; +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + return ( + selectPermissionOptionId(request, "acceptForSession") ?? + selectPermissionOptionId(request, "accept") + ); +} + +function completedStopReasonFromPromptResponse( + response: EffectAcpSchema.PromptResponse | undefined, +): EffectAcpSchema.StopReason | null { + return response?.stopReason ?? null; +} + +export function devinPromptSettlementBelongsToContext(input: { + readonly liveAcpSessionId: string; + readonly expectedAcpSessionId: string; + readonly liveActiveTurnId: TurnId | undefined; + readonly liveSessionActiveTurnId: TurnId | undefined; + readonly turnId: TurnId; +}): boolean { + return ( + input.liveAcpSessionId === input.expectedAcpSessionId && + (input.liveActiveTurnId === input.turnId || input.liveSessionActiveTurnId === input.turnId) + ); +} + +function finiteNonNegativeInteger(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + const int = Math.floor(value); + return int >= 0 ? int : undefined; +} + +function finiteNonNegativeDelta( + current: number | null | undefined, + previous: number | null | undefined, +): number { + const delta = (current ?? 0) - (previous ?? 0); + return Number.isFinite(delta) && delta > 0 ? Math.floor(delta) : 0; +} + +function devinUsageDeltaTotals( + current: EffectAcpSchema.Usage, + previous: EffectAcpSchema.Usage | undefined, +): UsageTokenTotals { + const inputTokens = finiteNonNegativeDelta(current.inputTokens, previous?.inputTokens); + const cachedReadTokens = finiteNonNegativeDelta( + current.cachedReadTokens, + previous?.cachedReadTokens, + ); + const cachedWriteTokens = finiteNonNegativeDelta( + current.cachedWriteTokens, + previous?.cachedWriteTokens, + ); + const outputTokens = finiteNonNegativeDelta(current.outputTokens, previous?.outputTokens); + const thoughtTokens = finiteNonNegativeDelta(current.thoughtTokens, previous?.thoughtTokens); + + // Input tokens reported by Devin are inclusive of any cached read and write + // tokens, matching the other transcript parsers. + const uncachedInputTokens = Math.max(0, inputTokens - cachedReadTokens - cachedWriteTokens); + + return { + uncachedInputTokens, + cachedInputTokens: cachedReadTokens, + cacheCreationTokens: cachedWriteTokens, + outputTokens, + reasoningTokens: Math.min(outputTokens, thoughtTokens), + }; +} + +interface DevinUsageTranscriptRecord { + readonly timestamp: string; + readonly sessionId: string; + readonly turnId: string; + readonly model: string; + readonly totals: UsageTokenTotals; + readonly reportedCostUsd: number | null; +} + +function writeDevinUsageTranscriptLine( + config: Pick, + record: DevinUsageTranscriptRecord, +): Effect.Effect { + let filePath: string | undefined; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + filePath = yield* resolveDevinUsageTranscriptPath(config); + const line = yield* encodeUnknownJsonString({ + type: "devin_usage", + ...record, + }).pipe( + Effect.map((json) => `${json}\n`), + Effect.orElseSucceed(() => "[unserializable]\n"), + ); + const dir = path.dirname(filePath); + yield* fileSystem.makeDirectory(dir, { recursive: true }); + yield* fileSystem.writeFileString(filePath, line, { flag: "a" }); + }).pipe( + Effect.catch((error) => + Effect.logWarning("Failed to append Devin usage transcript", { + filePath: filePath ?? "[unknown]", + error: error.message, + }), + ), + Effect.catchCause(() => Effect.void), + ); +} + +function makeDevinTokenUsageSnapshot( + usage: EffectAcpSchema.Usage | null | undefined, + previous: ThreadTokenUsageSnapshot | undefined, +): ThreadTokenUsageSnapshot | undefined { + if (!usage) { + return undefined; + } + + const inputTokens = finiteNonNegativeInteger(usage.inputTokens); + const outputTokens = finiteNonNegativeInteger(usage.outputTokens); + const totalTokens = finiteNonNegativeInteger(usage.totalTokens); + const usedTokens = + totalTokens !== undefined && totalTokens > 0 + ? totalTokens + : (inputTokens ?? 0) + (outputTokens ?? 0); + + if (usedTokens <= 0) { + return undefined; + } + + const cachedReadTokens = finiteNonNegativeInteger(usage.cachedReadTokens); + const thoughtTokens = finiteNonNegativeInteger(usage.thoughtTokens); + const previousUsedTokens = previous?.usedTokens ?? 0; + const previousInputTokens = previous?.inputTokens ?? 0; + const previousOutputTokens = previous?.outputTokens ?? 0; + const previousCachedInputTokens = previous?.cachedInputTokens ?? 0; + const previousReasoningOutputTokens = previous?.reasoningOutputTokens ?? 0; + + return buildThreadTokenUsageSnapshot({ + usedTokens, + inputTokens, + outputTokens, + cachedReadTokens, + thoughtTokens, + previous, + }); +} + +function makeDevinTokenUsageSnapshotFromUsageUpdate( + usage: Extract, + previous: ThreadTokenUsageSnapshot | undefined, +): ThreadTokenUsageSnapshot | undefined { + const usedTokens = + usage.used > 0 ? usage.used : (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0); + + if (usedTokens <= 0) { + return undefined; + } + + return buildThreadTokenUsageSnapshot({ + usedTokens, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cachedReadTokens: usage.cachedReadTokens, + thoughtTokens: undefined, + previous, + }); +} + +function buildThreadTokenUsageSnapshot(input: { + readonly usedTokens: number; + readonly inputTokens: number | undefined; + readonly outputTokens: number | undefined; + readonly cachedReadTokens: number | undefined; + readonly thoughtTokens: number | undefined; + readonly previous: ThreadTokenUsageSnapshot | undefined; +}): ThreadTokenUsageSnapshot { + const previousUsedTokens = input.previous?.usedTokens ?? 0; + const previousInputTokens = input.previous?.inputTokens ?? 0; + const previousOutputTokens = input.previous?.outputTokens ?? 0; + const previousCachedInputTokens = input.previous?.cachedInputTokens ?? 0; + const previousReasoningOutputTokens = input.previous?.reasoningOutputTokens ?? 0; + + return { + usedTokens: input.usedTokens, + totalProcessedTokens: input.usedTokens, + lastUsedTokens: Math.max(0, input.usedTokens - previousUsedTokens), + ...(input.inputTokens !== undefined && input.inputTokens > 0 + ? { + inputTokens: input.inputTokens, + lastInputTokens: Math.max(0, input.inputTokens - previousInputTokens), + } + : {}), + ...(input.outputTokens !== undefined && input.outputTokens > 0 + ? { + outputTokens: input.outputTokens, + lastOutputTokens: Math.max(0, input.outputTokens - previousOutputTokens), + } + : {}), + ...(input.cachedReadTokens !== undefined && input.cachedReadTokens > 0 + ? { + cachedInputTokens: input.cachedReadTokens, + lastCachedInputTokens: Math.max(0, input.cachedReadTokens - previousCachedInputTokens), + } + : {}), + ...(input.thoughtTokens !== undefined && input.thoughtTokens > 0 + ? { + reasoningOutputTokens: input.thoughtTokens, + lastReasoningOutputTokens: Math.max( + 0, + input.thoughtTokens - previousReasoningOutputTokens, + ), + } + : {}), + }; +} + +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 mapAcpCallbackFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Devin ACP callback.", + 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 settlePromptInFlight = ( + threadId: ThreadId, + turnId: TurnId, + expectedAcpSessionId: string, + options?: { + readonly errorMessage?: string; + readonly completedStopReason?: EffectAcpSchema.StopReason | null; + readonly emitTurnCompletion?: boolean; + /** Interrupt/cancel: drop every outstanding prompt slot and settle once. */ + readonly settleAllPrompts?: boolean; + }, + ) => + Effect.gen(function* () { + const liveCtx = sessions.get(threadId); + if (!liveCtx) { + return; + } + const settlementBelongsToLiveContext = devinPromptSettlementBelongsToContext({ + liveAcpSessionId: liveCtx.acpSessionId, + expectedAcpSessionId, + liveActiveTurnId: liveCtx.activeTurnId, + liveSessionActiveTurnId: liveCtx.session.activeTurnId, + turnId, + }); + if (!settlementBelongsToLiveContext) { + // interruptTurn already consumed every prompt slot for this turn. A + // late prompt result must neither emit a second terminal event nor + // consume a slot belonging to a newer turn on the same ACP session. + if ( + liveCtx.acpSessionId !== expectedAcpSessionId || + liveCtx.interruptedTurnIds.has(turnId) + ) { + return; + } + if (options?.emitTurnCompletion !== false) { + if (options?.errorMessage !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (options?.completedStopReason !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + } + return; + } + let settleTurnId = turnId; + if (options?.settleAllPrompts) { + liveCtx.promptsInFlight = 0; + if (liveCtx.activeTurnId !== turnId && liveCtx.session.activeTurnId !== turnId) { + const fallbackTurnId = liveCtx.activeTurnId ?? liveCtx.session.activeTurnId; + if (!fallbackTurnId) { + if (liveCtx.session.status === "running" || liveCtx.session.status === "connecting") { + const updatedAt = yield* nowIso; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + return; + } + settleTurnId = fallbackTurnId; + } + } else { + const remainingPrompts = Math.max(0, liveCtx.promptsInFlight - 1); + if ( + remainingPrompts > 0 || + liveCtx.activeTurnId !== settleTurnId || + liveCtx.session.activeTurnId !== settleTurnId + ) { + liveCtx.promptsInFlight = remainingPrompts; + return; + } + liveCtx.promptsInFlight = remainingPrompts; + } + const updatedAt = yield* nowIso; + const canEmitTurnCompletion = + liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; + const shouldEmitFailedTurn = options?.errorMessage !== undefined && canEmitTurnCompletion; + const shouldEmitCompletedTurn = + options?.completedStopReason !== undefined && canEmitTurnCompletion; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + if (options?.emitTurnCompletion === false) { + return; + } + if (shouldEmitFailedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (shouldEmitCompletedTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId, + turnId: settleTurnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + }); + + 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, + ); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to write native Devin notification log.", { + cause, + threadId, + method, + }), + ), + ); + + const emitPlanUpdate = ( + ctx: DevinSessionContext, + turnId: TurnId | undefined, + stamp: { readonly eventId: EventId; readonly createdAt: string }, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${turnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload, + source: "acp.jsonrpc", + method, + 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* settlePendingUserInputsAsCancelled(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), + ); + + const resumeSessionId = parseDevinResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const acp = yield* makeDevinAcpRuntime({ + devinSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + ...(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.handleRequestPermission((params) => + mapAcpCallbackFailure( + 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(); + const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); + pendingApprovals.set(requestId, { decision }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + 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, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + const selectedOptionId = + resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved); + return { + outcome: selectedOptionId + ? { + outcome: "selected" as const, + optionId: selectedOptionId, + } + : ({ outcome: "cancelled" } as const), + }; + }), + ), + ); + const started = yield* acp.start(); + yield* Effect.logInfo("[DevinAdapter] session/new result", started.sessionSetupResult); + return started; + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + const requestedStartModel = resolveDevinAcpModelSelection(devinModelSelection); + const boundModel = yield* applyDevinAcpModelSelection({ + runtime: acp, + current: currentDevinAcpModelSelection(started.sessionSetupResult), + requested: requestedStartModel, + configOptions: started.sessionSetupResult.configOptions ?? [], + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_config_option", cause), + }); + + const now = yield* nowIso; + const sessionModel = + boundModel?.familySlug ?? resolveDevinAcpBaseModelId(devinModelSelection?.model); + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + ...(sessionModel ? { model: sessionModel } : {}), + threadId: input.threadId, + resumeCursor: { + schemaVersion: DEVIN_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + const ctx: DevinSessionContext = { + threadId: input.threadId, + acpSessionId: started.sessionId, + session, + sessionSetupResult: started.sessionSetupResult, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + interruptedTurnIds: new Set(), + promptsInFlight: 0, + currentModelId: boundModel?.familySlug, + currentReasoningValue: boundModel?.reasoningValue, + stopped: false, + lastKnownTokenUsage: undefined, + lastAcpUsage: undefined, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + if (event._tag === "EventStreamBarrier") { + yield* Deferred.succeed(event.acknowledge, undefined); + return; + } + if ( + event._tag === "PlanUpdated" || + event._tag === "ToolCallUpdated" || + event._tag === "ContentDelta" + ) { + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + } + + if (event._tag === "ModeChanged") { + return; + } + + const notificationTurnId = resolveNotificationTurnId(ctx); + if ( + notificationTurnId === undefined || + ctx.interruptedTurnIds.has(notificationTurnId) + ) { + return; + } + const stamp = yield* makeEventStamp(); + + switch (event._tag) { + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* emitPlanUpdate( + ctx, + notificationTurnId, + stamp, + event.payload, + event.rawPayload, + "session/update", + ); + return; + case "ToolCallUpdated": + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + case "UsageUpdated": { + const tokenUsage = makeDevinTokenUsageSnapshotFromUsageUpdate( + event, + ctx.lastKnownTokenUsage, + ); + if (!tokenUsage) { + return; + } + ctx.lastKnownTokenUsage = tokenUsage; + yield* offerRuntimeEvent({ + type: "thread.token-usage.updated", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: notificationTurnId, + payload: { usage: tokenUsage }, + }); + return; + } + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Devin runtime notification.", { + cause, + }), + ), + Effect.forkChild, + ); + + 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 prepared = yield* withThreadLock( + input.threadId, + 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; decremented on + // preparation failure here, and after the prompt below otherwise. + ctx.promptsInFlight += 1; + // Bind the turn id before cooperative yields so interruptTurn can + // settle this prompt even if stop arrives during preparation. + ctx.activeTurnId = turnId; + ctx.session = { + ...ctx.session, + status: steeringTurnId === undefined ? "connecting" : "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + return yield* Effect.gen(function* () { + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId + ? input.modelSelection + : undefined; + const requestedTurnModel = resolveDevinAcpModelSelection(turnModelSelection); + const currentModel = yield* applyDevinAcpModelSelection({ + runtime: ctx.acp, + current: + ctx.currentModelId === undefined + ? undefined + : { + familySlug: ctx.currentModelId, + reasoningValue: ctx.currentReasoningValue, + }, + requested: requestedTurnModel, + configOptions: ctx.sessionSetupResult.configOptions ?? [], + mapError: (cause) => + mapAcpToAdapterError( + PROVIDER, + input.threadId, + "session/set_config_option", + cause, + ), + }); + + const text = input.input?.trim(); + const imagePromptParts = yield* Effect.forEach( + input.attachments ?? [], + (attachment) => + Effect.gen(function* () { + 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, + }), + ), + ); + return { + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + } satisfies EffectAcpSchema.ContentBlock; + }), + ); + const promptParts: Array = [ + ...(text ? [{ type: "text" as const, text }] : []), + ...imagePromptParts, + ]; + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + ctx.currentModelId = currentModel?.familySlug; + ctx.currentReasoningValue = currentModel?.reasoningValue; + const displayModel = + (turnModelSelection?.model ?? currentModel?.familySlug) + ? resolveDevinAcpBaseModelId( + turnModelSelection?.model ?? currentModel?.familySlug ?? undefined, + ) + : undefined; + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + if (ctx.interruptedTurnIds.has(turnId)) { + yield* settlePromptInFlight(input.threadId, turnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + emitTurnCompletion: false, + settleAllPrompts: true, + }); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Devin prompt was interrupted during preparation.", + }); + } + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + ...(displayModel ? { model: displayModel } : {}), + }; + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: displayModel ? { model: displayModel } : {}, + }); + } + + return { + acp: ctx.acp, + acpSessionId: ctx.acpSessionId, + displayModel, + promptParts, + turnId, + }; + }).pipe( + Effect.tapCause(() => + Effect.gen(function* () { + const liveCtx = sessions.get(input.threadId); + if (!liveCtx) { + return; + } + yield* settlePromptInFlight(input.threadId, turnId, liveCtx.acpSessionId, { + errorMessage: "Devin prompt preparation failed.", + emitTurnCompletion: false, + }); + }), + ), + ); + }), + ); + const promptSettled = yield* Ref.make(false); + const promptRpcSucceeded = yield* Ref.make(false); + const promptResultRef = yield* Ref.make( + undefined, + ); + + const promptFailureMessageRef = yield* Ref.make(undefined); + + return yield* Effect.gen(function* () { + const result = yield* prepared.acp + .prompt({ + prompt: prepared.promptParts, + }) + .pipe( + Effect.tap((promptResult) => + Effect.all([ + Ref.set(promptRpcSucceeded, true), + Ref.set(promptResultRef, promptResult), + ]), + ), + Effect.tapError((error) => + Ref.set( + promptFailureMessageRef, + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message, + ).pipe(Effect.andThen(prepared.acp.drainEvents)), + ), + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + return yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: "Devin session changed before the turn completed.", + settleAllPrompts: true, + }, + ); + yield* Ref.set(promptSettled, true); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Devin session changed before the turn completed.", + }); + } + // Keep prompt settlement atomic with respect to Stop and steering. + // interruptTurn marks its target before waiting for this lock, so + // cancellation can still win while queued ACP events are drained. + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + yield* prepared.acp.drainEvents; + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + + appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); + + const usage = result.usage; + const tokenUsage = usage + ? makeDevinTokenUsageSnapshot(usage, ctx.lastKnownTokenUsage) + : undefined; + if (tokenUsage && usage) { + ctx.lastKnownTokenUsage = tokenUsage; + yield* offerRuntimeEvent({ + type: "thread.token-usage.updated", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: prepared.turnId, + payload: { usage: tokenUsage }, + }); + + const deltaTotals = devinUsageDeltaTotals(usage, ctx.lastAcpUsage); + const totalDeltaTokens = + deltaTotals.uncachedInputTokens + + deltaTotals.cachedInputTokens + + deltaTotals.cacheCreationTokens + + deltaTotals.outputTokens; + + if (totalDeltaTokens > 0) { + ctx.lastAcpUsage = usage; + const observedAt = yield* nowIso; + const usageModel = prepared.displayModel ?? ctx.session.model ?? "adaptive"; + yield* writeDevinUsageTranscriptLine(devinSettings, { + timestamp: observedAt, + sessionId: ctx.acpSessionId, + turnId: prepared.turnId, + model: usageModel, + totals: deltaTotals, + reportedCostUsd: null, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + } + } + + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: prepared.turnId, + updatedAt: yield* nowIso, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const remainingPrompts = Math.max(0, ctx.promptsInFlight - 1); + ctx.promptsInFlight = remainingPrompts; + + // Only the last remaining prompt settles the turn. A steer- + // superseded prompt resolving while another is in flight or + // pending must leave the merged turn running. + if ( + remainingPrompts === 0 && + ctx.activeTurnId === prepared.turnId && + ctx.session.activeTurnId === prepared.turnId + ) { + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + const completedAt = yield* nowIso; + const { activeTurnId: _completedTurnId, ...readySession } = ctx.session; + ctx.activeTurnId = undefined; + ctx.session = { + ...readySession, + status: "ready", + updatedAt: completedAt, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const completedStopReason = completedStopReasonFromPromptResponse(result); + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId: prepared.turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: completedStopReason, + }, + }); + ctx.interruptedTurnIds.delete(prepared.turnId); + yield* Ref.set(promptSettled, true); + } else if (remainingPrompts > 0) { + yield* Ref.set(promptSettled, true); + } + + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }), + ); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + if (yield* Ref.get(promptSettled)) { + return; + } + + if (yield* Ref.get(promptRpcSucceeded)) { + const promptResult = yield* Ref.get(promptResultRef); + if (promptResult === undefined) { + return; + } + yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: "Devin session changed before the turn completed.", + settleAllPrompts: true, + }, + ); + return; + } + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + return; + } + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + return; + } + appendPromptResultToTurn( + ctx, + prepared.turnId, + prepared.promptParts, + promptResult, + ); + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + completedStopReason: completedStopReasonFromPromptResponse(promptResult), + }, + ); + }), + ); + return; + } + + const errorMessage = yield* Ref.get(promptFailureMessageRef); + yield* withThreadLock( + input.threadId, + settlePromptInFlight(input.threadId, prepared.turnId, prepared.acpSessionId, { + errorMessage: errorMessage ?? "Devin prompt request failed.", + }), + ); + }).pipe(Effect.catch(() => Effect.void)), + ), + ); + }); + + const interruptTurn: DevinAdapterShape["interruptTurn"] = (threadId, turnId) => + Effect.gen(function* () { + const observed = yield* Effect.sync(() => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return { + _tag: "Proceed" as const, + acpSessionId: undefined, + interruptedTurnId: turnId, + }; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return { _tag: "Ignore" as const }; + } + const interruptedTurnId = turnId ?? activeTurnId; + if (interruptedTurnId !== undefined) { + ctx.interruptedTurnIds.add(interruptedTurnId); + } + return { + _tag: "Proceed" as const, + acpSessionId: ctx.acpSessionId, + interruptedTurnId, + }; + }); + if (observed._tag === "Ignore") { + return; + } + + yield* withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (observed.acpSessionId !== undefined && ctx.acpSessionId !== observed.acpSessionId) { + return; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return; + } + if ( + observed.interruptedTurnId !== undefined && + activeTurnId !== undefined && + activeTurnId !== observed.interruptedTurnId + ) { + return; + } + const interruptedTurnId = + observed.interruptedTurnId ?? turnId ?? activeTurnId ?? ctx.session.activeTurnId; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + if (interruptedTurnId) { + ctx.interruptedTurnIds.add(interruptedTurnId); + yield* settlePromptInFlight(threadId, interruptedTurnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + settleAllPrompts: true, + }); + } else if ( + ctx.promptsInFlight > 0 || + ctx.session.status === "running" || + ctx.session.status === "connecting" + ) { + const updatedAt = yield* nowIso; + ctx.promptsInFlight = 0; + ctx.activeTurnId = undefined; + const { activeTurnId: _activeTurnId, ...readySession } = ctx.session; + ctx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + }), + ); + }); + + 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: "user-input", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.resolution, { + _tag: "answered", + 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* () { + yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "Devin ACP sessions do not support provider-side rollback yet.", + }); + }); + + 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(Array.from(sessions.values()), stopSessionInternal, { + discard: true, + }); + + yield* Effect.addFinalizer(() => + Effect.ignore(stopAll()).pipe( + 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..61a4a71d3fcf --- /dev/null +++ b/apps/server/src/provider/Layers/DevinProvider.test.ts @@ -0,0 +1,560 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { DevinSettings } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import { + buildInitialDevinProviderSnapshot, + checkDevinProviderStatus, + parseDevinModelsList, +} from "./DevinProvider.ts"; + +const decodeDevinSettings = Schema.decodeSync(DevinSettings); + +function isWindows(platform: string) { + return platform === "win32"; +} + +function makeMockDevinScript( + fs: FileSystem.FileSystem, + path: Path.Path, + dir: string, + content: string, + platform: string, +) { + const devinPath = isWindows(platform) ? path.join(dir, "devin.cmd") : path.join(dir, "devin"); + return Effect.gen(function* () { + yield* fs.writeFileString(devinPath, content); + if (!isWindows(platform)) { + yield* fs.chmod(devinPath, 0o755); + } + return devinPath; + }); +} + +function mockVersionScript(platform: string, secretStderr: string, exitCode: number) { + if (isWindows(platform)) { + return `@echo off\necho ${secretStderr} >&2\nexit /b ${exitCode}\n`; + } + return ["#!/bin/sh", `printf "%s\\n" "${secretStderr}" >&2`, `exit ${exitCode}`, ""].join("\n"); +} + +function mockModelsListScript(platform: string) { + if (isWindows(platform)) { + return [ + "@echo off", + 'if "%1" == "models" if "%2" == "list" if "%3" == "--format" if "%4" == "json" (', + ' echo [{"family_label": "Adaptive", "slug": "adaptive"}]', + " exit /b 0", + ")", + "echo devin-cli 0.0.99", + "exit /b 0", + "", + ].join("\n"); + } + return [ + "#!/bin/sh", + 'if [ "$1" = "models" ] && [ "$2" = "list" ] && [ "$3" = "--format" ] && [ "$4" = "json" ]; then', + ' printf "[{\\\"family_label\\\": \\\"Adaptive\\\", \\\"slug\\\": \\\"adaptive\\\"}]\\n"', + " exit 0", + "fi", + 'printf "devin-cli 0.0.99\\n"', + "exit 0", + "", + ].join("\n"); +} + +describe("buildInitialDevinProviderSnapshot", () => { + it.effect("returns a disabled snapshot when settings.enabled is false", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialDevinProviderSnapshot( + decodeDevinSettings({ enabled: false }), + ); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.installed).toBe(false); + expect(snapshot.message).toContain("disabled"); + }), + ); + + it.effect("returns a pending snapshot by default", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialDevinProviderSnapshot(decodeDevinSettings({})); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.version).toBeNull(); + expect(snapshot.message).toContain("Checking Devin"); + expect(snapshot.requiresNewThreadForModelChange).toBe(true); + }), + ); +}); + +it.layer(NodeServices.layer)("checkDevinProviderStatus", (it) => { + it.effect("reports the binary as missing when the binary path does not resolve", () => + Effect.gen(function* () { + const snapshot = yield* checkDevinProviderStatus( + decodeDevinSettings({ + enabled: true, + binaryPath: "/definitely/not/installed/devin-binary", + }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toMatch(/not installed|not on PATH|Failed to execute/); + }), + ); + + it.effect("reports an installed CLI as unhealthy when --version exits non-zero", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const secretStderr = "broken devin install: secret-token-value"; + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-devin-version-", + }); + const devinPath = yield* makeMockDevinScript( + fs, + path, + dir, + mockVersionScript(platform, secretStderr, 2), + platform, + ); + + return yield* checkDevinProviderStatus( + decodeDevinSettings({ enabled: true, binaryPath: devinPath }), + ); + }), + ); + + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toBe("Devin CLI is installed but failed to run."); + expect(snapshot.message).not.toContain(secretStderr); + }), + ); + + it.effect("discovers models via `devin models list` and reports ready", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-devin-models-", + }); + const devinPath = yield* makeMockDevinScript( + fs, + path, + dir, + mockModelsListScript(platform), + platform, + ); + + return yield* checkDevinProviderStatus( + decodeDevinSettings({ enabled: true, binaryPath: devinPath }), + ); + }), + ); + + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("ready"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["adaptive"]); + }), + ); + + it.effect("falls back to built-in models when `devin models list` fails", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-devin-fail-", + }); + const devinPath = yield* makeMockDevinScript( + fs, + path, + dir, + mockVersionScript(platform, "", 0), + platform, + ); + + return yield* checkDevinProviderStatus( + decodeDevinSettings({ enabled: true, binaryPath: devinPath }), + ); + }), + ); + + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("ready"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["adaptive"]); + }), + ); +}); + +it.layer(NodeServices.layer)("parseDevinModelsList", (it) => { + it("parses model families from `devin models list` headers, skipping reasoning variants", () => { + const output = [ + "Claude Opus 4.7 (claude-opus-4.7)", + " claude-opus-4-7-medium Claude Opus 4.7 Medium [1M context, $5 / MTok]", + " claude-opus-4-7-high Claude Opus 4.7 High [1M context, $5 / MTok]", + "", + "Claude Opus 4.8 (claude-opus-4.8)", + " claude-opus-4-8-low Claude Opus 4.8 Low [1M context, $5 / MTok]", + " claude-opus-4-8-high Claude Opus 4.8 High [1M context, $5 / MTok]", + "", + "Adaptive (adaptive)", + " aliases: swe, opencode", + " adaptive Adaptive [$0.5 / MTok]", + ].join("\n"); + + const models = parseDevinModelsList(output); + expect( + models.map((m) => ({ + slug: m.slug, + name: m.name, + })), + ).toEqual([ + { slug: "claude-opus-4-7", name: "Claude Opus 4.7" }, + { slug: "claude-opus-4-8", name: "Claude Opus 4.8" }, + { slug: "adaptive", name: "Adaptive" }, + ]); + }); + + it("returns an empty array when output has no models", () => { + expect(parseDevinModelsList("No models available.")).toEqual([]); + }); + + it("deduplicates family slugs after normalization", () => { + const output = ["Claude Opus 4.7 (claude-opus-4.7)", "Claude Opus 4.7 (claude-opus-4-7)"].join( + "\n", + ); + + const models = parseDevinModelsList(output); + expect(models).toHaveLength(1); + expect(models[0]?.slug).toBe("claude-opus-4-7"); + }); + + it("parses JSON output, returning one model per family", () => { + const output = JSON.stringify({ + families: [ + { + family_label: "Claude Opus 4.7", + family_uid: "claude-opus-4.7", + slug: "claude-opus-4.7", + variants: [{ model_uid: "claude-opus-4-7-medium" }], + }, + { + family_label: "Claude Opus 4.8", + family_uid: "claude-opus-4.8", + slug: "claude-opus-4.8", + variants: [{ model_uid: "claude-opus-4-8-low" }], + }, + { + family_label: "Adaptive", + family_uid: "adaptive", + slug: "adaptive", + aliases: ["swe", "opencode"], + variants: [{ model_uid: "adaptive" }], + }, + ], + }); + + const models = parseDevinModelsList(output); + expect( + models.map((m) => ({ + slug: m.slug, + name: m.name, + })), + ).toEqual([ + { slug: "adaptive", name: "Adaptive" }, + { slug: "claude-opus-4-7", name: "Claude Opus 4.7" }, + { slug: "claude-opus-4-8", name: "Claude Opus 4.8" }, + ]); + }); + + it("still accepts a plain JSON array as fallback", () => { + const output = JSON.stringify([ + { + family_label: "Adaptive", + family_uid: "adaptive", + slug: "adaptive", + variants: [{ model_uid: "adaptive" }], + }, + ]); + + const models = parseDevinModelsList(output); + expect(models.map((m) => m.slug)).toEqual(["adaptive"]); + }); + + it("groups flat JSON variants into one model per family", () => { + const output = JSON.stringify([ + { model_uid: "claude-opus-5-medium", label: "Claude Opus 5 Medium" }, + { model_uid: "claude-opus-5-low", label: "Claude Opus 5 Low" }, + { model_uid: "claude-opus-5-high", label: "Claude Opus 5 High" }, + { model_uid: "claude-opus-5-xhigh", label: "Claude Opus 5 XHigh" }, + { model_uid: "claude-opus-5-max", label: "Claude Opus 5 Max" }, + { model_uid: "claude-opus-5-low-fast", label: "Claude Opus 5 Low Fast" }, + { model_uid: "gpt-5-6-sol-none", label: "GPT-5.6 Sol No Thinking" }, + { model_uid: "gpt-5-6-sol-low", label: "GPT-5.6 Sol Low Thinking" }, + { model_uid: "gpt-5-6-sol-high", label: "GPT-5.6 Sol High Thinking" }, + { model_uid: "adaptive", label: "Adaptive" }, + ]); + + const models = parseDevinModelsList(output); + expect( + models.map((m) => ({ + slug: m.slug, + name: m.name, + })), + ).toEqual([ + { slug: "adaptive", name: "Adaptive" }, + { slug: "claude-opus-5", name: "Claude Opus 5" }, + { slug: "gpt-5-6-sol", name: "GPT-5.6 Sol" }, + ]); + }); + + it("uses family_uid when present in flat JSON variants", () => { + const output = JSON.stringify([ + { + model_uid: "claude-opus-5-medium", + label: "Claude Opus 5 Medium", + family_uid: "claude-opus-5", + family_label: "Claude Opus 5", + }, + { + model_uid: "claude-opus-5-low", + label: "Claude Opus 5 Low", + family_uid: "claude-opus-5", + family_label: "Claude Opus 5", + }, + ]); + + const models = parseDevinModelsList(output); + expect(models).toHaveLength(1); + expect(models[0]?.slug).toBe("claude-opus-5"); + expect(models[0]?.name).toBe("Claude Opus 5"); + }); + + it("groups flat JSON variants with compound family slugs", () => { + const output = JSON.stringify([ + { + model_uid: "swe-1-7-lightning-medium", + label: "SWE-1.7 Lightning Medium", + }, + { model_uid: "swe-1-7-lightning-max", label: "SWE-1.7 Lightning Max" }, + { model_uid: "glm-5-2-none", label: "GLM-5.2 No Thinking" }, + { model_uid: "glm-5-2-none-1m", label: "GLM-5.2 No Thinking 1M" }, + { model_uid: "glm-5-2-max", label: "GLM-5.2 Max" }, + ]); + + const models = parseDevinModelsList(output); + expect( + models.map((m) => ({ + slug: m.slug, + name: m.name, + })), + ).toEqual([ + { slug: "glm-5-2", name: "GLM-5.2" }, + { slug: "swe-1-7-lightning", name: "SWE-1.7 Lightning" }, + ]); + }); + + it("derives the family slug from the label for opaque legacy model ids", () => { + const output = JSON.stringify([ + { model_uid: "MODEL_PRIVATE_11", label: "Claude Haiku 4.5" }, + { model_uid: "MODEL_PRIVATE_2", label: "Claude Sonnet 4.5" }, + { model_uid: "MODEL_PRIVATE_3", label: "Claude Sonnet 4.5 Thinking" }, + { model_uid: "MODEL_GPT_5_2_LOW", label: "GPT-5.2 Low Thinking" }, + { model_uid: "MODEL_GPT_5_2_MEDIUM", label: "GPT-5.2 Medium Thinking" }, + { model_uid: "MODEL_CHAT_GPT_4_1_2025_04_14", label: "GPT-4.1" }, + { + model_uid: "MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL", + label: "Gemini 3 Flash Minimal", + }, + { + model_uid: "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH", + label: "Gemini 3 Flash High", + }, + { model_uid: "claude-5-fable-medium", label: "Claude Fable 5 Medium" }, + { model_uid: "claude-5-fable-low", label: "Claude Fable 5 Low" }, + ]); + + const models = parseDevinModelsList(output); + expect( + models.map((m) => ({ + slug: m.slug, + name: m.name, + })), + ).toEqual([ + { slug: "claude-fable-5", name: "Claude Fable 5" }, + { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, + { slug: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { slug: "gemini-3-flash", name: "Gemini 3 Flash" }, + { slug: "gpt-4-1", name: "GPT-4.1" }, + { slug: "gpt-5-2", name: "GPT-5.2" }, + ]); + }); + + it("sorts discovered models by name", () => { + const output = JSON.stringify([ + { model_uid: "claude-opus-5-medium", label: "Claude Opus 5 Medium" }, + { model_uid: "adaptive", label: "Adaptive" }, + { model_uid: "gpt-5-6-sol-low", label: "GPT-5.6 Sol Low Thinking" }, + ]); + + const models = parseDevinModelsList(output); + expect(models.map((m) => m.name)).toEqual(["Adaptive", "Claude Opus 5", "GPT-5.6 Sol"]); + }); + + it.effect("parses the real exported devin model list into families with reasoning options", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = process.cwd(); + const raw = yield* fs.readFileString(path.join(cwd, "devin-models-list.txt")); + const models = parseDevinModelsList(raw); + + expect(models.length).toBeLessThan(50); + const opus5 = models.find((m) => m.slug === "claude-opus-5"); + expect(opus5).toBeDefined(); + const reasoning = opus5?.capabilities?.optionDescriptors?.[0]; + expect(reasoning?.id).toBe("reasoning"); + expect(reasoning?.type).toBe("select"); + if (reasoning?.type === "select") { + expect(reasoning.options.some((o) => o.id === "medium")).toBe(true); + expect(reasoning.options.some((o) => o.id === "low-fast")).toBe(true); + } + }), + ); + + it("exposes a reasoning option descriptor with real model_uids", () => { + const output = JSON.stringify({ + families: [ + { + family_label: "Claude Opus 5", + family_uid: "claude-opus-5", + slug: "claude-opus-5", + variants: [ + { + model_uid: "claude-opus-5-medium", + label: "Claude Opus 5 Medium", + }, + { model_uid: "claude-opus-5-low", label: "Claude Opus 5 Low" }, + { model_uid: "claude-opus-5-high", label: "Claude Opus 5 High" }, + ], + }, + { + family_label: "Adaptive", + family_uid: "adaptive", + slug: "adaptive", + variants: [{ model_uid: "adaptive", label: "Adaptive" }], + }, + ], + }); + + const models = parseDevinModelsList(output); + const claudeOpus = models.find((m) => m.slug === "claude-opus-5"); + const adaptive = models.find((m) => m.slug === "adaptive"); + + expect(claudeOpus?.capabilities?.optionDescriptors).toHaveLength(1); + const reasoning = claudeOpus?.capabilities?.optionDescriptors?.[0]; + expect(reasoning?.id).toBe("reasoning"); + expect(reasoning?.type).toBe("select"); + expect( + reasoning && reasoning.type === "select" + ? reasoning.options.map((o) => ({ id: o.id, label: o.label })) + : [], + ).toEqual([ + { id: "medium", label: "Medium" }, + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ]); + + expect(adaptive?.capabilities?.optionDescriptors).toEqual([]); + }); + + it("uses the API variant labels for reasoning options", () => { + const output = JSON.stringify({ + families: [ + { + family_label: "SWE-1.7", + family_uid: "swe-1.7", + slug: "swe-1.7", + variants: [ + { model_uid: "swe-1-7", label: "SWE-1.7 Max" }, + { model_uid: "swe-1-7-medium", label: "SWE-1.7 Medium" }, + ], + }, + { + family_label: "SWE-1.7 Lightning", + family_uid: "swe-1.7-lightning", + slug: "swe-1.7-lightning", + variants: [ + { model_uid: "swe-1-7-lightning", label: "SWE-1.7 Lightning Max" }, + { + model_uid: "swe-1-7-lightning-medium", + label: "SWE-1.7 Lightning Medium", + }, + ], + }, + { + family_label: "GLM-5.2", + family_uid: "glm-5.2", + slug: "glm-5.2", + variants: [ + { model_uid: "glm-5-2", label: "GLM-5.2 High" }, + { model_uid: "glm-5-2-max", label: "GLM-5.2 Max" }, + { model_uid: "glm-5-2-none", label: "GLM-5.2 No Thinking" }, + { model_uid: "glm-5-2-none-1m", label: "GLM-5.2 No Thinking 1M" }, + ], + }, + ], + }); + + const models = parseDevinModelsList(output); + const swe = models.find((m) => m.slug === "swe-1-7"); + const sweLightning = models.find((m) => m.slug === "swe-1-7-lightning"); + const glm = models.find((m) => m.slug === "glm-5-2"); + + const reasoningOptions = (model: (typeof models)[number]) => + model?.capabilities?.optionDescriptors?.[0]?.type === "select" + ? model.capabilities.optionDescriptors[0].options.map((o) => ({ + id: o.id, + label: o.label, + })) + : []; + + expect(swe).toBeDefined(); + expect(sweLightning).toBeDefined(); + expect(glm).toBeDefined(); + expect(reasoningOptions(swe!)).toEqual([ + { id: "max", label: "Max" }, + { id: "medium", label: "Medium" }, + ]); + expect(reasoningOptions(sweLightning!)).toEqual([ + { id: "max", label: "Max" }, + { id: "medium", label: "Medium" }, + ]); + expect(reasoningOptions(glm!)).toEqual([ + { id: "high", label: "High" }, + { id: "max", label: "Max" }, + { id: "no-thinking", label: "No Thinking" }, + { id: "no-thinking-1m", label: "No Thinking 1M" }, + ]); + }); +}); diff --git a/apps/server/src/provider/Layers/DevinProvider.ts b/apps/server/src/provider/Layers/DevinProvider.ts new file mode 100644 index 000000000000..787b4431d3a0 --- /dev/null +++ b/apps/server/src/provider/Layers/DevinProvider.ts @@ -0,0 +1,808 @@ +import { + type DevinSettings, + type ModelCapabilities, + type ProviderOptionChoice, + type ProviderOptionDescriptor, + type ServerProvider, + type ServerProviderModel, +} 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 FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HttpClient } from "effect/unstable/http"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { ProviderAdapterProcessError } from "../Errors.ts"; +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { resolveDevinAcpBaseModelId } from "../acp/DevinAcpSupport.ts"; +import { resolveEffectiveDevinBinary } from "../Drivers/DevinBinary.ts"; + +const DEVIN_PRESENTATION = { + displayName: "Devin", + badgeLabel: "Beta", + showInteractionModeToggle: false, + requiresNewThreadForModelChange: true, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const DEVIN_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; + +const DEVIN_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "adaptive", + name: "Adaptive", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +export function buildInitialDevinProviderSnapshot( + devinSettings: DevinSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = devinModelsFromSettings(devinSettings.customModels); + + 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...", + }, + }); + }); +} + +function devinModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = DEVIN_BUILT_IN_MODELS, +): ReadonlyArray { + return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); +} + +interface DevinModelVariantJson { + readonly model_uid?: unknown; + readonly label?: unknown; + readonly family_label?: unknown; + readonly family_uid?: unknown; + readonly cost_summary?: unknown; + readonly max_context_tokens?: unknown; +} + +interface DevinModelFamilyJson { + readonly family_label?: unknown; + readonly family_uid?: unknown; + readonly slug?: unknown; + readonly variants?: unknown; +} + +const DEVIN_VARIANT_SUFFIXES = new Set([ + "none", + "low", + "medium", + "high", + "xhigh", + "x-high", + "max", + "minimal", + "thinking", + "fast", + "priority", + "1m", + "200k", + "1000k", + "1000000", +]); + +const DEVIN_VARIANT_NAME_TOKENS = new Set([...DEVIN_VARIANT_SUFFIXES]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isDevinVariantSuffixToken(token: string): boolean { + return DEVIN_VARIANT_SUFFIXES.has(token.toLowerCase()); +} + +function isDevinVariantNameToken(token: string): boolean { + return DEVIN_VARIANT_NAME_TOKENS.has(token.toLowerCase().replace(/[,]/g, "")); +} + +function isDevinFamilyRecord(value: unknown): value is DevinModelFamilyJson { + return isRecord(value) && Array.isArray((value as { variants?: unknown }).variants); +} + +function buildDevinVariantDescription(variant: DevinModelVariantJson): string | undefined { + const contextTokens = + typeof variant.max_context_tokens === "number" ? variant.max_context_tokens : undefined; + const costSummary = typeof variant.cost_summary === "string" ? variant.cost_summary : undefined; + + const parts: string[] = []; + if (contextTokens) { + parts.push(`${contextTokens >= 1000 ? `${contextTokens / 1000}K` : contextTokens} context`); + } + if (costSummary) { + parts.push(costSummary); + } + + return parts.length > 0 ? parts.join(", ") : undefined; +} + +function buildDevinFamilyDescription(family: DevinModelFamilyJson): string | undefined { + const variants = Array.isArray(family.variants) + ? (family.variants as ReadonlyArray) + : []; + const first = variants[0]; + if (!first) { + return undefined; + } + return buildDevinVariantDescription(first); +} + +function normalizeDevinFamilyUidForModelUid(familyUid: string): string { + return familyUid.replace(/\./g, "-").toLowerCase(); +} + +function resolveDevinVariantSuffix(familyUid: string, modelUid: string): string | undefined { + const normalizedFamilyUid = normalizeDevinFamilyUidForModelUid(familyUid); + const normalizedModelUid = modelUid.toLowerCase(); + if (normalizedModelUid === normalizedFamilyUid) { + return ""; + } + const prefix = `${normalizedFamilyUid}-`; + if (!normalizedModelUid.startsWith(prefix)) { + return undefined; + } + return normalizedModelUid.slice(prefix.length); +} + +function normalizeLabelForComparison(input: string): string { + return input + .toLowerCase() + .replace(/[.\-_]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function buildDevinVariantOptionLabel( + variantLabel: string | undefined, + familyName: string, +): string { + const normalizedFamily = normalizeLabelForComparison(familyName); + const label = variantLabel?.trim(); + if (!label) { + return "Default"; + } + + const tokens = label.split(/\s+/); + let consumedTokens = 0; + let consumed = ""; + + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]!; + const next = consumed ? `${consumed} ${token}` : token; + const normalizedNext = normalizeLabelForComparison(next); + if (normalizedFamily.startsWith(normalizedNext)) { + consumed = next; + consumedTokens = i + 1; + } else { + break; + } + } + + const rest = tokens.slice(consumedTokens).join(" "); + return rest.length > 0 ? rest : "Default"; +} + +function normalizeDevinReasoningValue(label: string): string { + return label + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .replace(/-+/g, "-"); +} + +function buildDevinReasoningDescriptor( + familyName: string, + familyUid: string, + variants: ReadonlyArray, +): ProviderOptionDescriptor | undefined { + const effectiveFamilyUid = familyUid; + const options: Array = []; + + for (const variant of variants) { + const modelUid = typeof variant.model_uid === "string" ? variant.model_uid.trim() : ""; + if (!modelUid) { + continue; + } + const suffix = resolveDevinVariantSuffix(effectiveFamilyUid, modelUid); + if (suffix === undefined && typeof variant.label !== "string") { + continue; + } + const label = buildDevinVariantOptionLabel( + typeof variant.label === "string" ? variant.label : undefined, + familyName, + ); + options.push({ + id: normalizeDevinReasoningValue(label), + label, + ...(options.length === 0 ? { isDefault: true } : {}), + }); + } + + if (options.length <= 1) { + return undefined; + } + + return { + id: "reasoning", + label: "Reasoning", + type: "select", + options, + }; +} + +function resolveDevinFamilyBaseSlug(rawSlug: string): string | undefined { + if (!rawSlug) { + return undefined; + } + + const normalized = rawSlug.replace(/\./g, "-"); + + const parts = normalized.split("-"); + while (parts.length > 1) { + const last = parts[parts.length - 1]; + if (!last || !isDevinVariantSuffixToken(last)) { + break; + } + parts.pop(); + } + return resolveDevinAcpBaseModelId(parts.join("-")); +} + +function resolveDevinFamilyNameFromLabel(label: string): string { + const tokens = label.split(/\s+/); + while (tokens.length > 1) { + const last = tokens[tokens.length - 1]; + if (!last || !isDevinVariantNameToken(last)) { + break; + } + tokens.pop(); + + // Drop a preceding "No" that was part of "No Thinking". + const prev = tokens[tokens.length - 1]; + if (prev && /^No$/i.test(prev) && last.toLowerCase() === "thinking") { + tokens.pop(); + } + } + + const name = tokens.join(" "); + return name.length > 0 ? name : label; +} + +function slugifyFamilyName(name: string): string { + return name + .toLowerCase() + .replace(/[.]/g, "-") + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-|-$/g, "") + .replace(/-+/g, "-"); +} + +function isOpaqueDevinModelId(modelUid: string): boolean { + return /^MODEL_/i.test(modelUid) || modelUid.includes("_"); +} + +function resolveDevinFamilySlugFromVariant(variant: DevinModelVariantJson): string | undefined { + const familyUid = typeof variant.family_uid === "string" ? variant.family_uid.trim() : ""; + const modelUid = typeof variant.model_uid === "string" ? variant.model_uid.trim() : ""; + const slug = + typeof (variant as { slug?: unknown }).slug === "string" + ? ((variant as { slug?: unknown }).slug as string).trim() + : ""; + + // Prefer explicit family or model slugs; only strip variants from raw model_uids. + const rawSlug = familyUid || slug || modelUid; + if (!rawSlug) { + return undefined; + } + + if (familyUid || slug) { + return resolveDevinFamilyBaseSlug(rawSlug); + } + + const fromModelUid = resolveDevinFamilyBaseSlug(modelUid); + const familyLabel = typeof variant.family_label === "string" ? variant.family_label.trim() : ""; + const label = typeof variant.label === "string" ? variant.label.trim() : ""; + + const baseName = familyLabel || (label ? resolveDevinFamilyNameFromLabel(label) : undefined); + const fromLabel = baseName ? resolveDevinAcpBaseModelId(slugifyFamilyName(baseName)) : undefined; + + // Opaque legacy ids like MODEL_PRIVATE_11 don't carry the family slug in the + // model_uid. Some informative ids like claude-5-fable-medium also don't match + // the canonical family slug (claude-fable-5), while labels like + // "Claude Fable 5 Medium" do. In those cases, derive the slug from the label. + if ( + fromLabel && + (fromModelUid === undefined || isOpaqueDevinModelId(modelUid) || fromLabel !== fromModelUid) + ) { + return fromLabel; + } + + return fromModelUid; +} + +function resolveDevinFamilyNameFromVariant(variant: DevinModelVariantJson): string | undefined { + const familyLabel = typeof variant.family_label === "string" ? variant.family_label.trim() : ""; + if (familyLabel) { + return familyLabel; + } + + const label = typeof variant.label === "string" ? variant.label.trim() : ""; + if (!label) { + return undefined; + } + + return resolveDevinFamilyNameFromLabel(label); +} + +export function deduplicateDevinProviderModels( + models: ReadonlyArray, +): ReadonlyArray { + const groups = new Map(); + + for (const model of models) { + const baseName = resolveDevinFamilyNameFromLabel(model.name); + const fromSlug = resolveDevinFamilyBaseSlug(model.slug); + const fromName = resolveDevinAcpBaseModelId(slugifyFamilyName(baseName)); + + const baseSlug = + fromName && + (fromSlug === undefined || isOpaqueDevinModelId(model.slug) || fromName !== fromSlug) + ? fromName + : fromSlug; + if (!baseSlug || groups.has(baseSlug)) { + continue; + } + + const deduplicated = { ...model, slug: baseSlug, name: baseName }; + groups.set(baseSlug, { name: baseName, model: deduplicated }); + } + + return Array.from(groups.values(), (group) => group.model).toSorted((left, right) => + left.name.localeCompare(right.name), + ); +} + +function parseDevinModelFamilyList( + families: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(); + const models: Array = []; + + for (const family of families) { + const familyRecord = family as DevinModelFamilyJson; + const rawSlug = + typeof familyRecord.slug === "string" + ? familyRecord.slug + : typeof familyRecord.family_uid === "string" + ? familyRecord.family_uid + : ""; + const familyName = + typeof familyRecord.family_label === "string" ? familyRecord.family_label : ""; + + if (!rawSlug) { + continue; + } + + const slug = resolveDevinAcpBaseModelId(rawSlug.replace(/\./g, "-")); + if (!slug || seen.has(slug)) { + continue; + } + seen.add(slug); + + const variants = Array.isArray(familyRecord.variants) + ? (familyRecord.variants as ReadonlyArray) + : []; + const description = buildDevinFamilyDescription(familyRecord); + const resolvedFamilyName = familyName.trim() || slug; + const reasoningDescriptor = buildDevinReasoningDescriptor( + resolvedFamilyName, + typeof familyRecord.family_uid === "string" ? familyRecord.family_uid : slug, + variants, + ); + + models.push({ + slug, + name: resolvedFamilyName, + ...(description ? { description } : {}), + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: reasoningDescriptor ? [reasoningDescriptor] : [], + }), + }); + } + + return models.toSorted((left, right) => left.name.localeCompare(right.name)); +} + +function parseDevinModelVariantList( + variants: ReadonlyArray, +): ReadonlyArray { + const groups = new Map< + string, + { + readonly name: string; + readonly familyUid: string | undefined; + readonly variants: Array; + } + >(); + + for (const variant of variants) { + const variantRecord = variant as DevinModelVariantJson; + const slug = resolveDevinFamilySlugFromVariant(variantRecord); + if (!slug) { + continue; + } + + const existing = groups.get(slug); + if (existing) { + existing.variants.push(variantRecord); + continue; + } + + const name = + resolveDevinFamilyNameFromVariant(variantRecord) || resolveDevinAcpBaseModelId(slug) || slug; + const familyUid = + typeof variantRecord.family_uid === "string" ? variantRecord.family_uid : undefined; + groups.set(slug, { name, familyUid, variants: [variantRecord] }); + } + + const models: Array = []; + for (const [slug, group] of groups) { + const firstVariant = group.variants[0]; + const description = firstVariant ? buildDevinVariantDescription(firstVariant) : undefined; + const familyUid = group.familyUid ?? slug; + const reasoningDescriptor = buildDevinReasoningDescriptor( + group.name, + familyUid, + group.variants, + ); + + models.push({ + slug, + name: group.name, + ...(description ? { description } : {}), + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: reasoningDescriptor ? [reasoningDescriptor] : [], + }), + }); + } + + return models.toSorted((left, right) => left.name.localeCompare(right.name)); +} + +function parseDevinModelsJson(output: string): ReadonlyArray { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + return []; + } + + const familyRecords = + parsed && + typeof parsed === "object" && + Array.isArray((parsed as { families?: unknown }).families) + ? (parsed as { families: ReadonlyArray }).families + : undefined; + + if (familyRecords && familyRecords.length > 0) { + if (isDevinFamilyRecord(familyRecords[0])) { + return parseDevinModelFamilyList(familyRecords as ReadonlyArray); + } + return parseDevinModelVariantList(familyRecords as ReadonlyArray); + } + + const topLevelArray = Array.isArray(parsed) ? parsed : undefined; + if (topLevelArray && topLevelArray.length > 0) { + if (isDevinFamilyRecord(topLevelArray[0])) { + return parseDevinModelFamilyList(topLevelArray as ReadonlyArray); + } + return parseDevinModelVariantList(topLevelArray as ReadonlyArray); + } + + return []; +} + +function parseDevinModelsText(output: string): ReadonlyArray { + const seen = new Set(); + const models: Array = []; + + for (const rawLine of output.split("\n")) { + const line = rawLine.replace(/\r/g, ""); + // Header lines group model families, e.g. "Claude Opus 4.7 (claude-opus-4.7)". + // The lines below a header are reasoning/variant entries that we skip so the + // picker only shows one model per family. + const familyMatch = line.match(/^(.+?)\s+\(([\w\d-]+(?:[._-][\w\d-]+)*)\)\s*$/); + if (!familyMatch) { + continue; + } + const rawSlug = familyMatch[2] ?? ""; + const familyName = familyMatch[1] ?? ""; + const slug = resolveDevinAcpBaseModelId(rawSlug.replace(/\./g, "-")); + if (!slug || seen.has(slug)) { + continue; + } + seen.add(slug); + models.push({ + slug, + name: familyName.trim() || slug, + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }); + } + + return models; +} + +export function parseDevinModelsList(output: string): ReadonlyArray { + const jsonModels = parseDevinModelsJson(output); + if (jsonModels.length > 0) { + return jsonModels; + } + return parseDevinModelsText(output); +} + +const runDevinModelsListCommand = ( + devinSettings: DevinSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const command = yield* resolveEffectiveDevinBinary(devinSettings.binaryPath, environment); + const spawnCommand = yield* resolveSpawnCommand( + command, + ["models", "list", "--format", "json"], + { + env: environment, + }, + ); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +const discoverDevinModelsViaModelsList = ( + devinSettings: DevinSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const result = yield* runDevinModelsListCommand(devinSettings, environment); + if (result.code !== 0) { + return yield* new ProviderAdapterProcessError({ + provider: "devin", + threadId: "probe", + detail: `Devin models list failed with exit code ${result.code}.`, + }); + } + const models = parseDevinModelsList(`${result.stdout}\n${result.stderr}`); + if (models.length === 0) { + return yield* new ProviderAdapterProcessError({ + provider: "devin", + threadId: "probe", + detail: "Devin models list returned no parseable models.", + }); + } + return models; + }); + +const runDevinVersionCommand = ( + devinSettings: DevinSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const command = yield* resolveEffectiveDevinBinary(devinSettings.binaryPath, environment); + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +export const checkDevinProviderStatus = Effect.fn("checkDevinProviderStatus")(function* ( + devinSettings: DevinSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path +> { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const fallbackModels = devinModelsFromSettings(devinSettings.customModels); + + 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 versionResult = yield* runDevinVersionCommand(devinSettings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning("Devin CLI health check failed.", { + errorTag: error._tag, + }); + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: devinSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Devin CLI (`devin`) is not installed or not on PATH." + : "Failed to execute Devin CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: devinSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Devin CLI is installed but timed out while running `devin --version`.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + yield* Effect.logWarning("Devin CLI version probe exited with a non-zero status.", { + exitCode: versionOutput.code, + stdoutLength: versionOutput.stdout.length, + stderrLength: versionOutput.stderr.length, + }); + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: devinSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Devin CLI is installed but failed to run.", + }, + }); + } + + const discoveredModels = yield* discoverDevinModelsViaModelsList(devinSettings, environment).pipe( + Effect.timeout(DEVIN_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + Effect.tapError((cause) => + Effect.logWarning("Devin model discovery via `devin models list` failed", { + errorTag: cause._tag, + }), + ), + Effect.orElseSucceed(() => [] as ReadonlyArray), + ); + const models = + discoveredModels.length > 0 + ? devinModelsFromSettings(devinSettings.customModels, discoveredModels) + : fallbackModels; + + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: devinSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + }, + }); +}); + +export const enrichDevinSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { snapshot, publishSnapshot } = input; + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("Devin version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/provider/Services/DevinAdapter.ts b/apps/server/src/provider/Services/DevinAdapter.ts new file mode 100644 index 000000000000..2aa623426808 --- /dev/null +++ b/apps/server/src/provider/Services/DevinAdapter.ts @@ -0,0 +1,7 @@ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * DevinAdapterShape — per-instance Devin adapter contract. + */ +export interface DevinAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AcpNativeLogging.ts b/apps/server/src/provider/acp/AcpNativeLogging.ts index 06bff3aa6113..2b8d5f02b853 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.ts @@ -16,7 +16,10 @@ function structuralMethod(value: string): string { function summarizePayload(payload: unknown): Readonly> { if (payload === null) return { valueType: "null" }; if (typeof payload === "string") { - return { valueType: "string", byteLength: new TextEncoder().encode(payload).byteLength }; + return { + valueType: "string", + byteLength: new TextEncoder().encode(payload).byteLength, + }; } if (payload instanceof Uint8Array) { return { valueType: "bytes", byteLength: payload.byteLength }; @@ -60,7 +63,11 @@ function formatProtocolLogPayload(event: EffectAcpProtocol.AcpProtocolLogEvent) return { direction: event.direction, stage: event.stage, - payload: summarizePayload(event.payload), + payload: + event.stage === "raw" && + (typeof event.payload === "string" || event.payload instanceof Uint8Array) + ? event.payload + : summarizePayload(event.payload), }; } @@ -76,6 +83,7 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" readonly payload: unknown; }) => Effect.gen(function* () { + yield* Effect.logDebug(`[ACP ${logInput.kind}]`, logInput.payload); if (!input.nativeEventLogger) return; const observedAt = DateTime.formatIso(yield* DateTime.now); yield* input.nativeEventLogger.write( diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index 7682c5f5f9cb..e35780fd6a00 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -299,7 +299,11 @@ describe("AcpRuntimeModel", () => { update: { sessionUpdate: "plan", entries: [ - { content: " Inspect state ", priority: "high", status: "completed" }, + { + content: " Inspect state ", + priority: "high", + status: "completed", + }, { content: "", priority: "medium", status: "in_progress" }, ], }, @@ -374,4 +378,45 @@ describe("AcpRuntimeModel", () => { }, }); }); + + it("projects ACP usage_update notifications into runtime events", () => { + const result = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { + sessionUpdate: "usage_update", + used: 21776, + size: 262000, + _meta: { + "cognition.ai/inputTokens": 21687, + "cognition.ai/outputTokens": 89, + "cognition.ai/cachedReadTokens": 448, + }, + }, + } satisfies EffectAcpSchema.SessionNotification); + + expect(result.events).toEqual([ + { + _tag: "UsageUpdated", + used: 21776, + size: 262000, + cost: null, + inputTokens: 21687, + outputTokens: 89, + cachedReadTokens: 448, + rawPayload: { + sessionId: "session-1", + update: { + sessionUpdate: "usage_update", + used: 21776, + size: 262000, + _meta: { + "cognition.ai/inputTokens": 21687, + "cognition.ai/outputTokens": 89, + "cognition.ai/cachedReadTokens": 448, + }, + }, + }, + }, + ]); + }); }); diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index e6bfc127e6e9..7c317d6d44d3 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -11,6 +11,12 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function finiteNonNegativeInteger(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + const int = Math.floor(value); + return int >= 0 ? int : undefined; +} + function isSessionModelState(value: unknown): value is EffectAcpSchema.SessionModelState { if (!isRecord(value) || typeof value.currentModelId !== "string") { return false; @@ -108,6 +114,16 @@ export type AcpParsedSessionEvent = readonly itemId?: string; readonly text: string; readonly rawPayload: unknown; + } + | { + readonly _tag: "UsageUpdated"; + readonly used: number; + readonly size: number; + readonly cost: number | null; + readonly inputTokens: number | undefined; + readonly outputTokens: number | undefined; + readonly cachedReadTokens: number | undefined; + readonly rawPayload: unknown; }; type AcpSessionSetupResponse = @@ -120,11 +136,22 @@ type AcpToolCallUpdate = Extract< { readonly sessionUpdate: "tool_call" | "tool_call_update" } >; +const MODEL_CONFIG_OPTION_IDS = new Set(["model", "models", "modelid", "modelids"]); + +function isModelConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { + if (option.category === "model") return true; + const id = option.id + .trim() + .toLowerCase() + .replace(/[\s_-]+/g, ""); + return MODEL_CONFIG_OPTION_IDS.has(id); +} + export function extractModelConfigId(sessionResponse: AcpSessionSetupResponse): string | undefined { const configOptions = sessionResponse.configOptions; if (!configOptions) return undefined; for (const opt of configOptions) { - if (opt.category === "model" && opt.id.trim().length > 0) { + if (isModelConfigOption(opt) && opt.id.trim().length > 0) { return opt.id.trim(); } } @@ -574,6 +601,20 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat } break; } + case "usage_update": { + const meta = isRecord(upd._meta) ? upd._meta : {}; + events.push({ + _tag: "UsageUpdated", + used: finiteNonNegativeInteger(upd.used) ?? 0, + size: finiteNonNegativeInteger(upd.size) ?? 0, + cost: upd.cost && typeof upd.cost.amount === "number" ? upd.cost.amount : null, + inputTokens: finiteNonNegativeInteger(meta["cognition.ai/inputTokens"]), + outputTokens: finiteNonNegativeInteger(meta["cognition.ai/outputTokens"]), + cachedReadTokens: finiteNonNegativeInteger(meta["cognition.ai/cachedReadTokens"]), + rawPayload: params, + }); + break; + } default: break; } diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 09fce6d56f9d..c5efacdcb1a8 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -289,9 +289,13 @@ export const make = ( }), ), ); - const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); + const assistantSegmentRef = yield* Ref.make({ + nextSegmentIndex: 0, + }); const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); - const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); + const startStateRef = yield* Ref.make({ + _tag: "NotStarted", + }); const promptSerializationSemaphore = yield* Semaphore.make(1); const activePromptFiberRef = yield* Ref.make< Option.Option> @@ -541,15 +545,24 @@ export const make = ( acp.agent.initialize(initializePayload), ); - const authenticatePayload = { - methodId: options.authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; - - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); + const authMethods = initializeResult.authMethods ?? []; + if (authMethods.length > 0) { + const requestedAuthMethodId = options.authMethodId; + const effectiveAuthMethodId = + authMethods.find((method) => method.id === requestedAuthMethodId)?.id ?? + authMethods[0]?.id; + if (effectiveAuthMethodId !== undefined) { + const authenticatePayload = { + methodId: effectiveAuthMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; + + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + } + } let sessionId: string; let sessionSetupResult: 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..12bedd3992ad --- /dev/null +++ b/apps/server/src/provider/acp/DevinAcpSupport.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { DevinSettings, ProviderInstanceId } from "@t3tools/contracts"; + +import { + applyDevinAcpModelSelection, + buildDevinAcpSpawnInput, + resolveDevinAcpBaseModelId, + resolveDevinAcpModelSelection, +} from "./DevinAcpSupport.ts"; + +const decodeDevinSettings = Schema.decodeSync(DevinSettings); + +describe("buildDevinAcpSpawnInput", () => { + it("spawns 'devin acp' with default settings", () => { + const settings = decodeDevinSettings({}); + const input = buildDevinAcpSpawnInput(settings, "/work"); + expect(input.command).toBe("devin"); + expect(input.args).toEqual(["acp"]); + expect(input.cwd).toBe("/work"); + expect(input.env).toEqual({}); + }); + + it("maps permissionMode to env var", () => { + const settings = decodeDevinSettings({ + permissionMode: "smart", + }); + const input = buildDevinAcpSpawnInput(settings, "/work"); + expect(input.command).toBe("devin"); + expect(input.args).toEqual(["acp"]); + expect(input.env).toMatchObject({ + DEVIN_PERMISSION_MODE: "smart", + }); + }); + + it("omits default permissionMode", () => { + const settings = decodeDevinSettings({ + permissionMode: "normal", + }); + const input = buildDevinAcpSpawnInput(settings, "/work"); + expect(input.env).toEqual({}); + }); + + it("uses the configured binaryPath", () => { + const settings = decodeDevinSettings({ + binaryPath: "/usr/local/bin/devin", + }); + const input = buildDevinAcpSpawnInput(settings, "/work"); + expect(input.command).toBe("/usr/local/bin/devin"); + }); + + it("merges environment variables", () => { + const env = { FOO: "bar" }; + const input = buildDevinAcpSpawnInput(decodeDevinSettings({}), "/work", env); + expect(input.env).toStrictEqual({ FOO: "bar" }); + expect(input.env).not.toBe(env); + }); +}); + +describe("resolveDevinAcpBaseModelId", () => { + it("trims and normalizes model slugs", () => { + expect(resolveDevinAcpBaseModelId(" opus ")).toBe("opus"); + }); + + it("falls back to 'adaptive' when blank", () => { + expect(resolveDevinAcpBaseModelId("")).toBe("adaptive"); + expect(resolveDevinAcpBaseModelId(undefined)).toBe("adaptive"); + }); +}); + +describe("resolveDevinAcpModelSelection", () => { + it("returns the family slug and reasoning option", () => { + const modelSelection = { + instanceId: ProviderInstanceId.make("devin"), + model: "claude-opus-5", + options: [{ id: "reasoning", value: "high" }], + }; + expect(resolveDevinAcpModelSelection(modelSelection)).toEqual({ + familySlug: "claude-opus-5", + reasoningValue: "high", + }); + }); + + it("falls back to the family slug when no reasoning option is set", () => { + const modelSelection = { + instanceId: ProviderInstanceId.make("devin"), + model: "claude-opus-5", + }; + expect(resolveDevinAcpModelSelection(modelSelection)).toEqual({ + familySlug: "claude-opus-5", + reasoningValue: undefined, + }); + }); +}); + +describe("applyDevinAcpModelSelection", () => { + it.effect("sets the model through the model config option", () => + Effect.gen(function* () { + const setModel = vi.fn().mockReturnValue(Effect.succeed(undefined)); + const setConfigOption = vi.fn().mockReturnValue(Effect.succeed({ configOptions: [] })); + + const result = yield* applyDevinAcpModelSelection({ + runtime: { setModel, setConfigOption }, + current: undefined, + requested: { familySlug: "claude-opus-5", reasoningValue: undefined }, + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "adaptive", + options: [ + { value: "adaptive", name: "Adaptive" }, + { value: "claude-opus-5", name: "Claude Opus 5" }, + ], + }, + ], + mapError: (cause) => cause, + }); + + expect(setModel).toHaveBeenCalledWith("claude-opus-5"); + expect(setConfigOption).not.toHaveBeenCalled(); + expect(result).toEqual({ + familySlug: "claude-opus-5", + reasoningValue: undefined, + }); + }), + ); + + it.effect("sets reasoning through the effort config option when present", () => + Effect.gen(function* () { + const setModel = vi.fn().mockReturnValue(Effect.succeed(undefined)); + const setConfigOption = vi.fn().mockReturnValue(Effect.succeed({ configOptions: [] })); + + const result = yield* applyDevinAcpModelSelection({ + runtime: { setModel, setConfigOption }, + current: undefined, + requested: { familySlug: "claude-opus-5", reasoningValue: "high" }, + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "adaptive", + options: [ + { value: "adaptive", name: "Adaptive" }, + { value: "claude-opus-5", name: "Claude Opus 5" }, + ], + }, + { + id: "effort", + name: "Effort", + category: "thought_level", + type: "select", + currentValue: "default", + options: [ + { value: "default", name: "Default" }, + { value: "high", name: "High" }, + ], + }, + ], + mapError: (cause) => cause, + }); + + expect(setModel).toHaveBeenCalledWith("claude-opus-5"); + expect(setConfigOption).toHaveBeenCalledWith("effort", "high"); + expect(result).toEqual({ + familySlug: "claude-opus-5", + reasoningValue: "high", + }); + }), + ); + + it.effect("falls back to a variant slug when the family slug is not in the model list", () => + Effect.gen(function* () { + const setModel = vi.fn().mockReturnValue(Effect.succeed(undefined)); + const setConfigOption = vi.fn().mockReturnValue(Effect.succeed({ configOptions: [] })); + + yield* applyDevinAcpModelSelection({ + runtime: { setModel, setConfigOption }, + current: undefined, + requested: { familySlug: "claude-opus-5", reasoningValue: "high" }, + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "adaptive", + options: [ + { value: "adaptive", name: "Adaptive" }, + { value: "claude-opus-5-high", name: "Claude Opus 5 High" }, + ], + }, + ], + mapError: (cause) => cause, + }); + + expect(setModel).toHaveBeenCalledWith("claude-opus-5-high"); + }), + ); + + it.effect("normalizes a legacy reasoning option value that contains the family slug", () => + Effect.gen(function* () { + const setModel = vi.fn().mockReturnValue(Effect.succeed(undefined)); + const setConfigOption = vi.fn().mockReturnValue(Effect.succeed({ configOptions: [] })); + + yield* applyDevinAcpModelSelection({ + runtime: { setModel, setConfigOption }, + current: undefined, + requested: { + familySlug: "swe-1-7", + reasoningValue: "swe-1-7-medium", + }, + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "adaptive", + options: [ + { value: "adaptive", name: "Adaptive" }, + { value: "swe-1-7", name: "SWE-1.7" }, + ], + }, + { + id: "effort", + name: "Effort", + category: "thought_level", + type: "select", + currentValue: "default", + options: [ + { value: "max", name: "Max" }, + { value: "medium", name: "Medium" }, + ], + }, + ], + mapError: (cause) => cause, + }); + + expect(setModel).toHaveBeenCalledWith("swe-1-7"); + expect(setConfigOption).toHaveBeenCalledWith("effort", "medium"); + }), + ); +}); diff --git a/apps/server/src/provider/acp/DevinAcpSupport.ts b/apps/server/src/provider/acp/DevinAcpSupport.ts new file mode 100644 index 000000000000..f9d0da871dfd --- /dev/null +++ b/apps/server/src/provider/acp/DevinAcpSupport.ts @@ -0,0 +1,298 @@ +import { type DevinSettings, type ModelSelection, ProviderDriverKind } from "@t3tools/contracts"; +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; +import { getModelSelectionStringOptionValue, normalizeModelSlug } from "@t3tools/shared/model"; +import * as Crypto from "effect/Crypto"; +import * as Console from "effect/Console"; +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 * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { collectSessionConfigOptionValues, findSessionConfigOption } from "./AcpRuntimeModel.ts"; +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const DEVIN_AUTH_METHOD_ID = "default"; +const DEVIN_DRIVER_KIND = ProviderDriverKind.make("devin"); + +const DEVIN_MODEL_CONFIG_OPTION_IDS = new Set(["model", "models", "modelid", "modelids"]); + +const DEVIN_REASONING_CONFIG_OPTION_IDS = new Set(["effort", "thought_level", "reasoning"]); + +type DevinAcpRuntimeDevinSettings = Pick< + DevinSettings, + "binaryPath" | "homePath" | "launchArgs" | "permissionMode" +>; + +export interface DevinAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly devinSettings: DevinAcpRuntimeDevinSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; +} + +export function buildDevinAcpSpawnInput( + devinSettings: DevinAcpRuntimeDevinSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + const args: string[] = ["acp", ...tokenizeCliArgs(devinSettings?.launchArgs)]; + + const env: NodeJS.ProcessEnv = { ...environment }; + const permissionMode = devinSettings?.permissionMode?.trim(); + if (permissionMode && permissionMode !== "normal") { + env.DEVIN_PERMISSION_MODE = permissionMode; + } + + return { + command: devinSettings?.binaryPath || "devin", + args, + cwd, + env, + }; +} + +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), + authMethodId: DEVIN_AUTH_METHOD_ID, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +export function resolveDevinAcpBaseModelId(model: string | null | undefined): string { + const base = model?.trim() || "adaptive"; + return normalizeModelSlug(base, DEVIN_DRIVER_KIND) ?? base; +} + +export interface DevinAcpModelSelection { + readonly familySlug: string; + readonly reasoningValue: string | undefined; +} + +export function resolveDevinAcpModelSelection( + modelSelection: ModelSelection | null | undefined, +): DevinAcpModelSelection | undefined { + if (!modelSelection) { + return undefined; + } + const familySlug = resolveDevinAcpBaseModelId(modelSelection.model); + const reasoningValue = getModelSelectionStringOptionValue(modelSelection, "reasoning"); + return { + familySlug, + reasoningValue: reasoningValue?.trim() || undefined, + }; +} + +function normalizeConfigIdToken(value: string): string { + return value.toLowerCase().replace(/[\s_-]+/g, ""); +} + +function isDevinModelConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { + if (option.category === "model") return true; + const id = normalizeConfigIdToken(option.id); + return DEVIN_MODEL_CONFIG_OPTION_IDS.has(id); +} + +function isDevinReasoningConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { + if (option.category === "thought_level") return true; + const id = normalizeConfigIdToken(option.id); + if (DEVIN_REASONING_CONFIG_OPTION_IDS.has(id)) return true; + if ( + option.category !== undefined && + option.category !== null && + option.category !== "model_config" + ) { + return false; + } + const name = normalizeConfigIdToken(option.name); + return /reasoning|effort|thinking/.test(name); +} + +function findDevinAcpModelConfigId( + configOptions: ReadonlyArray | null | undefined, +): string | undefined { + if (!configOptions) return undefined; + for (const option of configOptions) { + if (isDevinModelConfigOption(option)) { + return option.id; + } + } + return undefined; +} + +function findDevinAcpReasoningConfigId( + configOptions: ReadonlyArray | null | undefined, +): string | undefined { + if (!configOptions) return undefined; + for (const option of configOptions) { + if (isDevinReasoningConfigOption(option)) { + return option.id; + } + } + return undefined; +} + +function getConfigOptionCurrentValue( + configOptions: ReadonlyArray | null | undefined, + configId: string | undefined, +): string | undefined { + if (!configId || !configOptions) return undefined; + const option = findSessionConfigOption(configOptions, configId); + if (!option || option.type !== "select") return undefined; + return option.currentValue.trim() || undefined; +} + +export function currentDevinAcpModelSelection( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): DevinAcpModelSelection | undefined { + const configOptions = sessionSetupResult.configOptions; + const modelConfigId = findDevinAcpModelConfigId(configOptions); + const reasoningConfigId = findDevinAcpReasoningConfigId(configOptions); + const familySlug = getConfigOptionCurrentValue(configOptions, modelConfigId); + if (!familySlug) return undefined; + return { + familySlug, + reasoningValue: getConfigOptionCurrentValue(configOptions, reasoningConfigId), + }; +} + +export function applyDevinAcpModelSelection(input: { + readonly runtime: Pick< + AcpSessionRuntime.AcpSessionRuntime["Service"], + "setModel" | "setConfigOption" + >; + readonly current: DevinAcpModelSelection | undefined; + readonly requested: DevinAcpModelSelection | undefined; + readonly configOptions: ReadonlyArray; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect { + if (!input.requested) { + return Effect.succeed(input.current); + } + + const requested = input.requested; + const current = input.current; + const modelConfigId = findDevinAcpModelConfigId(input.configOptions); + const reasoningConfigId = findDevinAcpReasoningConfigId(input.configOptions); + + const requestedReasoning = + requested.reasoningValue === requested.familySlug || requested.reasoningValue === "default" + ? undefined + : requested.reasoningValue; + + const needsModelSwitch = !current || requested.familySlug !== current.familySlug; + const needsReasoningSwitch = + reasoningConfigId !== undefined && + requestedReasoning !== undefined && + requestedReasoning !== current?.reasoningValue; + + if (!needsModelSwitch && !needsReasoningSwitch) { + return Effect.succeed(current); + } + + return Effect.gen(function* () { + yield* Console.log("[DevinAcpSupport] applyDevinAcpModelSelection", { + requested, + current, + modelConfigId, + reasoningConfigId, + }); + + if (needsModelSwitch && modelConfigId !== undefined) { + const modelOption = findSessionConfigOption(input.configOptions, modelConfigId); + const allowedModelValues = modelOption ? collectSessionConfigOptionValues(modelOption) : []; + + const candidateModelValues = [requested.familySlug]; + if (requestedReasoning !== undefined) { + candidateModelValues.push(`${requested.familySlug}-${requestedReasoning}`); + candidateModelValues.push(`${requested.familySlug}/${requestedReasoning}`); + } + + const effectiveModel = + candidateModelValues.find((candidate) => allowedModelValues.includes(candidate)) ?? + allowedModelValues.find((value) => + candidateModelValues.some((candidate) => value.endsWith(`/${candidate}`)), + ); + + if (effectiveModel === undefined) { + return yield* Effect.fail( + input.mapError( + new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: `Invalid model value "${requested.familySlug}" for session config option "${modelConfigId}"`, + data: { + configId: modelConfigId, + receivedValue: requested.familySlug, + allowedValues: allowedModelValues, + }, + }), + ), + ); + } + yield* input.runtime.setModel(effectiveModel).pipe(Effect.mapError(input.mapError)); + } + + if (needsReasoningSwitch && reasoningConfigId !== undefined) { + const reasoningOption = findSessionConfigOption(input.configOptions, reasoningConfigId); + const allowedReasoningValues = reasoningOption + ? collectSessionConfigOptionValues(reasoningOption) + : []; + const effectiveReasoning = + requestedReasoning && allowedReasoningValues.length > 0 + ? (allowedReasoningValues.find( + (value) => requestedReasoning === value || requestedReasoning.endsWith(`-${value}`), + ) ?? requestedReasoning) + : requestedReasoning; + yield* input.runtime + .setConfigOption(reasoningConfigId, effectiveReasoning) + .pipe(Effect.mapError(input.mapError)); + } + + return requested; + }); +} + +export function buildDevinDiscoveredModelsFromSessionModelState( + modelState: EffectAcpSchema.SessionModelState | null | undefined, +): ReadonlyArray<{ slug: string; name: string }> { + if (!modelState || modelState.availableModels.length === 0) { + return []; + } + const seen = new Set(); + return modelState.availableModels + .map((model) => { + const slug = resolveDevinAcpBaseModelId(model.modelId); + if (!slug || seen.has(slug)) { + return undefined; + } + seen.add(slug); + return { + slug, + name: model.name.trim() || slug, + }; + }) + .filter((model): model is { slug: string; name: string } => model !== undefined); +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c..318d450edfd9 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 type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -36,6 +37,7 @@ export type BuiltInDriversEnv = | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv + | DevinDriverEnv | GrokDriverEnv | OpenCodeDriverEnv; @@ -48,6 +50,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-invalid-" }); + const tempDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-provider-cache-invalid-", + }); const cachePath = `${tempDir}/provider.json`; const secretCacheValue = "secret-cache-value"; yield* fs.writeFileString(cachePath, `{ "token": "${secretCacheValue}" }`); @@ -79,7 +82,9 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { it.effect("writes and reads provider status snapshots", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-cache-" }); + const tempDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-provider-cache-", + }); const codexProvider = makeProvider(CODEX_DRIVER); const claudeProvider = makeProvider(CLAUDE_AGENT_DRIVER, { status: "warning", @@ -268,4 +273,140 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { fallbackCodex, ); }); + + it("deduplicates Devin variant models cached before the family filter", () => { + const cachedDevin = makeProvider(DEVIN_DRIVER, { + models: [ + { + slug: "claude-opus-5-medium", + name: "Claude Opus 5 Medium", + isCustom: false, + capabilities: emptyCapabilities, + }, + { + slug: "claude-opus-5-low", + name: "Claude Opus 5 Low", + isCustom: false, + capabilities: emptyCapabilities, + }, + { + slug: "claude-opus-5-high", + name: "Claude Opus 5 High", + isCustom: false, + capabilities: emptyCapabilities, + }, + { + slug: "gpt-5-6-sol-none", + name: "GPT-5.6 Sol No Thinking", + isCustom: false, + capabilities: emptyCapabilities, + }, + { + slug: "gpt-5-6-sol-high", + name: "GPT-5.6 Sol High Thinking", + isCustom: false, + capabilities: emptyCapabilities, + }, + { + slug: "adaptive", + name: "Adaptive", + isCustom: false, + capabilities: emptyCapabilities, + }, + ], + }); + const fallbackDevin = makeProvider(DEVIN_DRIVER, { + models: [ + { + slug: "adaptive", + name: "Adaptive", + isCustom: false, + capabilities: emptyCapabilities, + }, + ], + }); + + const hydrated = hydrateCachedProvider({ + cachedProvider: cachedDevin, + fallbackProvider: fallbackDevin, + }); + + assert.deepStrictEqual( + hydrated.models.map((m) => ({ slug: m.slug, name: m.name })), + [ + { slug: "adaptive", name: "Adaptive" }, + { slug: "claude-opus-5", name: "Claude Opus 5" }, + { slug: "gpt-5-6-sol", name: "GPT-5.6 Sol" }, + ], + ); + }); + + it("deduplicates Devin legacy opaque model ids using the model name", () => { + const cachedDevin = makeProvider(DEVIN_DRIVER, { + models: [ + { + slug: "MODEL_PRIVATE_11", + name: "Claude Haiku 4.5", + isCustom: false, + capabilities: emptyCapabilities, + }, + { + slug: "MODEL_PRIVATE_2", + name: "Claude Sonnet 4.5", + isCustom: false, + capabilities: emptyCapabilities, + }, + { + slug: "MODEL_GPT_5_2_LOW", + name: "GPT-5.2 Low Thinking", + isCustom: false, + capabilities: emptyCapabilities, + }, + { + slug: "MODEL_GPT_5_2_MEDIUM", + name: "GPT-5.2 Medium Thinking", + isCustom: false, + capabilities: emptyCapabilities, + }, + { + slug: "claude-fable-5", + name: "Claude Fable 5", + isCustom: false, + capabilities: emptyCapabilities, + }, + ], + }); + const fallbackDevin = makeProvider(DEVIN_DRIVER, { + models: [ + { + slug: "adaptive", + name: "Adaptive", + isCustom: false, + capabilities: emptyCapabilities, + }, + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + isCustom: false, + capabilities: emptyCapabilities, + }, + ], + }); + + const hydrated = hydrateCachedProvider({ + cachedProvider: cachedDevin, + fallbackProvider: fallbackDevin, + }); + + assert.deepStrictEqual( + hydrated.models.map((m) => ({ slug: m.slug, name: m.name })), + [ + { slug: "adaptive", name: "Adaptive" }, + { slug: "claude-fable-5", name: "Claude Fable 5" }, + { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, + { slug: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { slug: "gpt-5-2", name: "GPT-5.2" }, + ], + ); + }); }); diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 2fe0424b4f57..bdc8dab30db9 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -1,5 +1,5 @@ import { - type ProviderDriverKind, + ProviderDriverKind, type ProviderInstanceId, type ServerProvider, ServerProvider as ServerProviderSchema, @@ -10,6 +10,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import { deduplicateDevinProviderModels } from "./Layers/DevinProvider.ts"; import { writeFileStringAtomically } from "../atomicWrite.ts"; const decodeProviderStatusCache = Schema.decodeUnknownEffect( @@ -57,9 +58,17 @@ export const hydrateCachedProvider = (input: { } const { message: _fallbackMessage, ...fallbackWithoutMessage } = input.fallbackProvider; + + const isDevin = input.cachedProvider.driver === ProviderDriverKind.make("devin"); + const cachedModels = isDevin + ? deduplicateDevinProviderModels(input.cachedProvider.models) + : input.cachedProvider.models; + const merged = mergeProviderModels(input.fallbackProvider.models, cachedModels); const hydratedProvider: ServerProvider = { ...fallbackWithoutMessage, - models: mergeProviderModels(input.fallbackProvider.models, input.cachedProvider.models), + models: isDevin + ? merged.toSorted((left, right) => left.name.localeCompare(right.name)) + : merged, installed: input.cachedProvider.installed, version: input.cachedProvider.version, status: input.cachedProvider.status, diff --git a/apps/server/src/textGeneration/DevinTextGeneration.ts b/apps/server/src/textGeneration/DevinTextGeneration.ts new file mode 100644 index 000000000000..f922d0ac3020 --- /dev/null +++ b/apps/server/src/textGeneration/DevinTextGeneration.ts @@ -0,0 +1,268 @@ +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 * as EffectAcpErrors from "effect-acp/errors"; + +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, + currentDevinAcpModelSelection, + makeDevinAcpRuntime, + resolveDevinAcpBaseModelId, + resolveDevinAcpModelSelection, +} from "../provider/acp/DevinAcpSupport.ts"; + +const DEVIN_TIMEOUT_MS = 180_000; + +const isTextGenerationError = Schema.is(TextGenerationError); + +export const makeDevinTextGeneration = Effect.fn("makeDevinTextGeneration")(function* ( + devinSettings: DevinSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const crypto = yield* Crypto.Crypto; + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + 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 resolvedModel = resolveDevinAcpBaseModelId(modelSelection.model); + const outputRef = yield* Ref.make(""); + const runtime = yield* makeDevinAcpRuntime({ + devinSettings, + environment, + 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* () { + const started = yield* runtime.start(); + yield* applyDevinAcpModelSelection({ + runtime, + current: currentDevinAcpModelSelection(started.sessionSetupResult), + requested: resolveDevinAcpModelSelection(modelSelection) ?? { + familySlug: resolvedModel, + reasoningValue: undefined, + }, + configOptions: started.sessionSetupResult.configOptions ?? [], + mapError: (cause) => + new TextGenerationError({ + operation, + detail: "Failed to set Devin ACP base 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 ACP request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Devin ACP request failed.", + cause, + }), + ), + ); + + const trimmed = (yield* Ref.get(outputRef)).trim(); + if (!trimmed) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Devin ACP request was cancelled." + : "Devin Agent returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(trimmed)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Devin Agent 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, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + + 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/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 2ad2a729ecb5..5a038f3152eb 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -37,6 +37,7 @@ import { ServerConfig } from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { resolveDevinHomePath } from "../provider/Drivers/DevinHome.ts"; import { UsageAggregator } from "./usageAggregation.ts"; import { parseRateTable, type RateTable } from "./usagePricing.ts"; import { @@ -217,10 +218,17 @@ export const make = Effect.gen(function* () { const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); + const devinHome = yield* resolveDevinHomePath(settings.providers.devin).pipe( + Effect.provideService(Path.Path, path), + ); return [ { provider: "claude" as const, dir: claudeDir }, - { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") }, + { + provider: "codex" as const, + dir: path.join(codexLayout.sharedHomePath, "sessions"), + }, + { provider: "devin" as const, dir: devinHome }, ]; }); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index c72f0c24db65..1deda4b4ca72 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -22,6 +22,7 @@ import { mightCarryUsage, parseClaudeLine, parseCodexLine, + parseDevinLine, type UsageRecord, } from "./usageTranscripts.ts"; @@ -129,6 +130,13 @@ export async function readTranscriptRecords( continue; } + if (provider === "devin") { + if (!mightCarryUsage(line, provider)) continue; + const record = parseDevinLine(line); + if (record !== null) records.push(record); + continue; + } + if (!mightCarryUsage(line, provider)) continue; const record = parseClaudeLine(line); if (record !== null) records.push(record); diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index 8f86a3d836bd..791fff0af7d0 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "@effect/vitest"; import { initialCodexScanState, + mightCarryUsage, parseClaudeLine, parseCodexLine, + parseDevinLine, totalTokens, } from "./usageTranscripts.ts"; @@ -71,7 +73,10 @@ describe("parseCodexLine", () => { const sessionMeta = JSON.stringify({ type: "session_meta", timestamp: "2026-08-01T05:17:41.289Z", - payload: { type: "session_meta", id: "019fbbc1-b12c-7360-a685-28c181f0025f" }, + payload: { + type: "session_meta", + id: "019fbbc1-b12c-7360-a685-28c181f0025f", + }, }); const turnContext = JSON.stringify({ type: "turn_context", @@ -158,7 +163,9 @@ describe("parseCodexLine", () => { ? {} : { source: { - subagent: { thread_spawn: { parent_thread_id: overrides.spawnParentId } }, + subagent: { + thread_spawn: { parent_thread_id: overrides.spawnParentId }, + }, }, }), }, @@ -249,3 +256,39 @@ describe("totalTokens", () => { ).toBe(100); }); }); + +describe("parseDevinLine", () => { + it("extracts token totals from a T3 Code devin_usage line", () => { + const line = JSON.stringify({ + type: "devin_usage", + timestamp: "2026-08-11T16:44:06.637Z", + sessionId: "aloud-lantana", + turnId: "af6deae3-30ba-42d4-aab7-c6908d04361d", + model: "swe-1-7", + totals: { + uncachedInputTokens: 21239, + cachedInputTokens: 448, + cacheCreationTokens: 0, + outputTokens: 89, + reasoningTokens: 0, + }, + reportedCostUsd: null, + }); + + expect(mightCarryUsage(line, "devin")).toBe(true); + + const record = parseDevinLine(line); + expect(record).not.toBeNull(); + expect(record?.provider).toBe("devin"); + expect(record?.model).toBe("swe-1-7"); + expect(record?.sessionId).toBe("aloud-lantana"); + expect(record?.totals).toEqual({ + uncachedInputTokens: 21239, + cachedInputTokens: 448, + cacheCreationTokens: 0, + outputTokens: 89, + reasoningTokens: 0, + }); + expect(record?.dedupeKey).toBe("aloud-lantana:af6deae3-30ba-42d4-aab7-c6908d04361d"); + }); +}); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 49f9a1935ccc..0965172e7c33 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -68,7 +68,9 @@ export function totalTokens(totals: UsageTokenTotals): number { * an order of magnitude. */ export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean { - return provider === "claude" ? line.includes('"usage"') : line.includes('"token_count"'); + if (provider === "claude") return line.includes('"usage"'); + if (provider === "devin") return line.includes('"devin_usage"'); + return line.includes('"token_count"'); } /* -------------------------------------------------------------------------- */ @@ -297,4 +299,60 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord }; } +/* -------------------------------------------------------------------------- */ +/* Devin */ +/* -------------------------------------------------------------------------- */ + +/** + * Parses one line from a Devin usage transcript written by T3 Code. + * + * Each line is an independent, delta-normalized record keyed by + * `sessionId:turnId` so the aggregator can de-duplicate retries. + */ +export function parseDevinLine(line: string): UsageRecord | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + if (record["type"] !== "devin_usage") return null; + + const timestampMs = parseTimestampMs(record["timestamp"]); + if (timestampMs === null) return null; + + const sessionId = typeof record["sessionId"] === "string" ? record["sessionId"] : ""; + const turnId = typeof record["turnId"] === "string" ? record["turnId"] : ""; + const model = typeof record["model"] === "string" ? record["model"] : ""; + if (model.length === 0) return null; + + const totals = record["totals"]; + if (typeof totals !== "object" || totals === null) return null; + const totalsRecord = totals as Record; + + const cost = record["reportedCostUsd"]; + + const result: UsageRecord = { + provider: "devin", + timestampMs, + model, + sessionId, + totals: { + uncachedInputTokens: int(totalsRecord["uncachedInputTokens"]), + cachedInputTokens: int(totalsRecord["cachedInputTokens"]), + cacheCreationTokens: int(totalsRecord["cacheCreationTokens"]), + outputTokens: int(totalsRecord["outputTokens"]), + reasoningTokens: int(totalsRecord["reasoningTokens"]), + }, + reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null, + dedupeKey: sessionId.length > 0 && turnId.length > 0 ? `${sessionId}:${turnId}` : null, + }; + + if (totalTokens(result.totals) === 0) return null; + return result; +} + export { EMPTY_TOTALS }; diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c519588..09f7bad39867 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -211,6 +211,17 @@ export const GrokIcon: Icon = ({ className, ...props }) => ( ); +export const DevinIcon: Icon = ({ className, ...props }) => ( + + + +); + export const TraeIcon: Icon = (props) => ( {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */} diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 842c616fe1fe..5a1b9cbcb945 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,5 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, DevinIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("devin")]: DevinIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 7ae26b278657..22c46ead914c 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -98,6 +98,7 @@ export function ProviderModelsSection({ onModelOrderChange, }: ProviderModelsSectionProps) { const [input, setInput] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); const [error, setError] = useState(null); const listRef = useRef(null); const hiddenModelSet = useMemo(() => new Set(hiddenModels), [hiddenModels]); @@ -110,6 +111,15 @@ export function ProviderModelsSection({ }); }, [favoriteModelSet, modelOrder, models]); + const filteredModels = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + if (!query) return orderedModels; + return orderedModels.filter( + (model) => + model.slug.toLowerCase().includes(query) || model.name.toLowerCase().includes(query), + ); + }, [orderedModels, searchQuery]); + const handleAdd = () => { const normalized = normalizeCustomModelSlug(input); if (!normalized) { @@ -190,8 +200,15 @@ export function ProviderModelsSection({
{models.length} model{models.length === 1 ? "" : "s"} available.
+ setSearchQuery(event.target.value)} + placeholder="Search models..." + className="mt-2" + spellCheck={false} + />
- {orderedModels.map((model, index) => { + {filteredModels.map((model, index) => { const caps = model.capabilities; const capLabels: string[] = []; const isHidden = !model.isCustom && hiddenModelSet.has(model.slug); @@ -257,6 +274,9 @@ export function ProviderModelsSection({
{model.slug} + {model.description ? ( +

{model.description}

+ ) : null} {capLabels.length > 0 ? (
{capLabels.map((label) => ( diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index ea8712a87eb5..a29b1b2acb98 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -22,6 +22,30 @@ describe("ProviderSettingsForm helpers", () => { ]); }); + it("derives Devin settings fields in the configured order", () => { + const devin = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("devin")]; + + expect(devin).toBeDefined(); + expect(deriveProviderSettingsFields(devin!).map((field) => field.key)).toEqual([ + "binaryPath", + "permissionMode", + ]); + + const permissionMode = deriveProviderSettingsFields(devin!).find( + (field) => field.key === "permissionMode", + ); + expect(permissionMode).toMatchObject({ + control: "select", + options: [ + { value: "normal", label: "Normal" }, + { value: "accept-edits", label: "Accept edits" }, + { value: "smart", label: "Smart" }, + { value: "dangerous", label: "Dangerous" }, + { value: "autonomous", label: "Autonomous" }, + ], + }); + }); + it("sources labels and descriptions from schema annotations", () => { const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; expect(opencode).toBeDefined(); diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index cd34bb35c6b2..f32de3c7ff99 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -12,6 +12,7 @@ import type { import { cn } from "../../lib/utils"; import { DraftInput } from "../ui/draft-input"; import { Input } from "../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; import type { ProviderClientDefinition } from "./providerDriverMeta"; @@ -24,6 +25,7 @@ export interface ProviderSettingsFieldModel { readonly placeholder?: string | undefined; readonly clearWhenEmpty: "omit" | "persist"; readonly defaultBooleanValue?: boolean | undefined; + readonly options?: ReadonlyArray<{ readonly value: string; readonly label: string }> | undefined; } function titleizeFieldKey(key: string): string { @@ -106,6 +108,7 @@ export function deriveProviderSettingsFields( ...(formAnnotation.control === "switch" ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) } : {}), + ...(formAnnotation.options !== undefined ? { options: formAnnotation.options } : {}), } satisfies ProviderSettingsFieldModel, ]; }); @@ -238,6 +241,42 @@ function ProviderSettingsFieldRow({ ); } + if (field.control === "select") { + const currentValue = readProviderConfigString(value, field.key); + const selectedOption = field.options?.find((option) => option.value === currentValue); + return ( + + + + ); + } + const type = field.control === "password" ? "password" : undefined; return ( diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d6807..63307994cbcb 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -2,12 +2,21 @@ import { ClaudeSettings, CodexSettings, CursorSettings, + DevinSettings, GrokSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + DevinIcon, + GrokIcon, + type Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -67,6 +76,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("devin"), + label: "Devin", + icon: DevinIcon, + badgeLabel: "Early Access", + settingsSchema: DevinSettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index f8b65877dcf4..9f38355c191f 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -1,33 +1,37 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { ClaudeAI, type Icon, OpenAI } from "../Icons"; +import { ClaudeAI, DevinIcon, type Icon, OpenAI } from "../Icons"; /** * Series and table order. The chart layers both providers from a shared zero * baseline, so this only fixes the reading order of legends, tables and hover * rows; it does not decide which series sits above the other. */ -export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; +export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "devin"]; export const PROVIDER_LABEL: Record = { claude: "Claude Code", codex: "Codex", + devin: "Devin", }; /** Claude's brand orange against a neutral white for Codex. */ export const PROVIDER_COLOR: Record = { claude: "#d97757", codex: "#e6e6e6", + devin: "#8a63d2", }; /** * Brand marks, reused from the provider picker. * - * These ship their own fills (`#d97757` for Claude, white on dark for OpenAI), - * which are the same colours as the chart bands, so swapping a colour dot for a - * mark keeps the series association intact rather than trading it away. + * These ship their own fills (`#d97757` for Claude, white on dark for OpenAI, + * the Devin brand mark for Devin), which are the same colours as the chart + * bands, so swapping a colour dot for a mark keeps the series association intact + * rather than trading it away. */ export const PROVIDER_MARK: Record = { claude: ClaudeAI, codex: OpenAI, + devin: DevinIcon, }; diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4d0a76cf133b..d8cc341d1df3 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -33,7 +33,11 @@ export const PROVIDER_OPTIONS: Array<{ pickerSidebarBadge?: "new" | "soon"; }> = [ { value: ProviderDriverKind.make("codex"), label: "Codex", available: true }, - { value: ProviderDriverKind.make("claudeAgent"), label: "Claude", available: true }, + { + value: ProviderDriverKind.make("claudeAgent"), + label: "Claude", + available: true, + }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", @@ -52,6 +56,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("devin"), + label: "Devin", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/docs/README.md b/docs/README.md index 51277fd73d28..078b6d998591 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) - [Background service (Linux)](./user/background-service.md) -- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) +- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [Devin](./user/providers-devin.md) Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index da16f74d339f..cad43cb64198 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -94,7 +94,7 @@ The live backend agent implementation and its event stream. The main service is #### Provider -The backend agent runtime that actually performs work. Five drivers ship built in: Codex, Claude, Cursor, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. +The backend agent runtime that actually performs work. Six drivers ship built in: Codex, Claude, Cursor, Devin, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. #### Session diff --git a/docs/internals/overview.md b/docs/internals/overview.md index b9454f7b58d0..f240ec9cdbb7 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -18,13 +18,13 @@ there, never in the client. ┌──────────────────▼─────────────────────────────┐ │ apps/server │ │ orchestration engine (event-sourced) │ -│ provider driver registry (5 built-in drivers) │ +│ provider driver registry (6 built-in drivers) │ │ checkpointing, VCS, terminals, filesystem │ └──────────────────┬─────────────────────────────┘ │ per-driver transport ┌──────────────────▼─────────────────────────────┐ -│ Agent CLIs: Codex, Claude, Cursor, Grok, │ -│ OpenCode │ +│ Agent CLIs: Codex, Claude, Cursor, Devin, │ +│ Grok, OpenCode │ └────────────────────────────────────────────────┘ ``` @@ -106,8 +106,8 @@ build production behavior on receipts. ## Provider drivers -Five drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: -Codex, Claude, Cursor, Grok, and OpenCode. A driver declares its kind and config schema and creates a +Six drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: +Codex, Claude, Cursor, Devin, Grok, and OpenCode. A driver declares its kind and config schema and creates a scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves an instance to its adapter, so `ProviderService` routes session and turn operations without knowing which agent is behind them. See [providers.md](./providers.md). diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03de..340148061f8e 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -7,13 +7,14 @@ orchestration layer does not know which one is behind a thread. ## Built-in drivers -[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries: +[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries: | Driver kind | Driver source | | ------------- | --------------------------------------- | | `codex` | [`Drivers/CodexDriver.ts`][codex] | | `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | | `cursor` | [`Drivers/CursorDriver.ts`][cursor] | +| `devin` | [`Drivers/DevinDriver.ts`][devin] | | `grok` | [`Drivers/GrokDriver.ts`][grok] | | `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | @@ -79,6 +80,7 @@ when a request opens (approval) or user input is requested, via [codex]: ../../apps/server/src/provider/Drivers/CodexDriver.ts [claude]: ../../apps/server/src/provider/Drivers/ClaudeDriver.ts [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts +[devin]: ../../apps/server/src/provider/Drivers/DevinDriver.ts [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts diff --git a/docs/user/install.md b/docs/user/install.md index fe0b418ca1e5..a0df1975ab88 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -46,13 +46,14 @@ yay -S t3code-bin T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want to use, then authenticate it. -| Provider | CLI | Default binary | Log in with | -| ---------- | ----------------------------------------------------- | -------------- | --------------------- | -| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | -| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | -| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | -| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | -| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | +| Provider | CLI | Default binary | Log in with | +| ---------- | ----------------------------------------------------- | -------------- | --------------------------- | +| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | +| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | +| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | +| Devin | [Devin CLI](https://devin.ai) | `devin` | see Devin CLI documentation | +| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | +| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. @@ -74,7 +75,7 @@ T3 Code. You can install T3 Code, open it, and add providers afterwards. A provi authenticated shows its status in **Settings** and fails at session start with the login command to run. -For multi-account setups, see [Codex](./providers-codex.md) and [Claude](./providers-claude.md). +For multi-account setups, see [Codex](./providers-codex.md), [Claude](./providers-claude.md), and [Devin](./providers-devin.md). ## Next Steps diff --git a/docs/user/providers-devin.md b/docs/user/providers-devin.md new file mode 100644 index 000000000000..bf5829d7a298 --- /dev/null +++ b/docs/user/providers-devin.md @@ -0,0 +1,111 @@ +# Devin + +This guide is for people who want to use Devin in T3 Code. For other providers, see +[Codex](./providers-codex.md) or [Claude](./providers-claude.md). For first-time setup, see +[Install T3 Code](./install.md). + +T3 Code talks to Devin through its ACP (Agent Client Protocol) interface. It needs a Devin CLI that +exposes `devin acp` on standard input/output. + +## I Only Use One Devin Account + +Use the default provider. + +In T3 Code Settings, your Devin provider can stay like this: + +```text +Display name: Devin +Binary path: devin +Home path: empty +Launch arguments: empty +``` + +An empty `Home path` means T3 Code uses Devin's default home directory (`~/.devin` on macOS and +Linux, and the equivalent Windows user profile path). T3 Code sets this as `DEVIN_HOME` when it +spawns the Devin process. + +## I Want Multiple Devin Accounts Or Presets + +Use a different `Home path` for each provider instance. Each home keeps its own Devin sessions and +Usage transcripts, so T3 Code treats them as separate Devin environments. + +Example: + +```text +Display name: Devin Work +Binary path: devin +Home path: ~/.devin_work +``` + +```text +Display name: Devin Personal +Binary path: devin +Home path: ~/.devin_personal +``` + +T3 Code expands `~` in the `Home path`, creates the directory if it does not exist, and sets +`DEVIN_HOME` before launching Devin. + +## Binary Path And The `devin-desktop` Fallback + +`Binary path` is the command T3 Code runs to start Devin ACP. If you leave it empty, T3 Code tries +`devin` first and then falls back to `devin-desktop` automatically if `devin` is not on `PATH`. + +Some installations expose the CLI as `devin-desktop` while still speaking ACP. T3 Code will use it +the same way, launching it as `devin-desktop acp` with the configured `Launch arguments`. + +If your Devin binary is in a non-standard location, set the full path: + +```text +Binary path: /opt/devin/bin/devin +``` + +## Launch Arguments + +`Launch arguments` are extra arguments passed to the Devin CLI after the `acp` subcommand. They are +tokenized the same way as a shell command, so quoted arguments are supported. + +Example: + +```text +Launch arguments: --verbose --log-level debug +``` + +This produces a command like: + +```text +devin acp --verbose --log-level debug +``` + +Do not put environment variable assignments in `Launch arguments`. Use the provider's +**Environment variables** section for those, and mark tokens or API keys as sensitive. + +## Authentication + +Devin's CLI authentication is handled by Devin itself, not by T3 Code. Run the login command that the +Devin CLI documentation recommends before you start a session, then confirm the provider status in +T3 Code Settings. + +If Devin uses an API key or base URL that needs to be per-provider, add those variables to the +provider's **Environment variables** section and mark the values as sensitive. T3 Code stores +sensitive values as server secrets and does not send them back to the app after saving. + +## Token Usage And Cost + +Devin token usage is reported in real time during a conversation and aggregated in **Usage**. + +- Real-time usage appears from Devin's ACP prompt responses. +- Aggregated usage is written to `/t3code-usage.jsonl` and scanned by the Usage page. +- Cost is estimated from LiteLLM rate data when Devin does not report a cost itself. Unpriced Devin + models appear as `unpriced` rather than inventing a rate. + +## Can I Switch Models In An Existing Thread? + +Yes, when the Devin session advertises the requested model. T3 Code sends a `session/set_model` ACP +request when the model picker changes. If Devin rejects the model, the request fails with the +provider error shown in the UI. + +## Can I Switch Accounts In An Existing Thread? + +No. Devin sessions are tied to the `Home path` they were created with. A different home is treated +as a different Devin environment, so existing threads cannot be moved to another Devin provider. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..bc392c10c10b 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,6 +132,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"); +export const DEVIN_DRIVER_KIND = ProviderDriverKind.make("devin"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -153,6 +154,7 @@ 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/server.ts b/packages/contracts/src/server.ts index d7bc4c5c1898..da82e6228079 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -66,6 +66,7 @@ export const ServerProviderModel = Schema.Struct({ name: TrimmedNonEmptyString, shortName: Schema.optional(TrimmedNonEmptyString), subProvider: Schema.optional(TrimmedNonEmptyString), + description: Schema.optional(TrimmedNonEmptyString), isCustom: Schema.Boolean, isDefault: Schema.optional(Schema.Boolean), isLegacy: Schema.optional(Schema.Boolean), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 388205649c85..658d29db87d4 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -71,7 +71,10 @@ export const DEFAULT_GLASS_OPACITY: GlassOpacity = 80; export const MIN_INTERFACE_FONT_SIZE = 12; export const MAX_INTERFACE_FONT_SIZE = 20; export const InterfaceFontSize = Schema.Int.check( - Schema.isBetween({ minimum: MIN_INTERFACE_FONT_SIZE, maximum: MAX_INTERFACE_FONT_SIZE }), + Schema.isBetween({ + minimum: MIN_INTERFACE_FONT_SIZE, + maximum: MAX_INTERFACE_FONT_SIZE, + }), ); export type InterfaceFontSize = typeof InterfaceFontSize.Type; export const DEFAULT_INTERFACE_FONT_SIZE: InterfaceFontSize = 16; @@ -79,7 +82,10 @@ export const DEFAULT_INTERFACE_FONT_SIZE: InterfaceFontSize = 16; export const MIN_PROMPT_FONT_SIZE = 12; export const MAX_PROMPT_FONT_SIZE = 20; export const PromptFontSize = Schema.Int.check( - Schema.isBetween({ minimum: MIN_PROMPT_FONT_SIZE, maximum: MAX_PROMPT_FONT_SIZE }), + Schema.isBetween({ + minimum: MIN_PROMPT_FONT_SIZE, + maximum: MAX_PROMPT_FONT_SIZE, + }), ); export type PromptFontSize = typeof PromptFontSize.Type; export const DEFAULT_PROMPT_FONT_SIZE: PromptFontSize = 14; @@ -87,7 +93,10 @@ export const DEFAULT_PROMPT_FONT_SIZE: PromptFontSize = 14; export const MIN_CODE_FONT_SIZE = 10; export const MAX_CODE_FONT_SIZE = 18; export const CodeFontSize = Schema.Int.check( - Schema.isBetween({ minimum: MIN_CODE_FONT_SIZE, maximum: MAX_CODE_FONT_SIZE }), + Schema.isBetween({ + minimum: MIN_CODE_FONT_SIZE, + maximum: MAX_CODE_FONT_SIZE, + }), ); export type CodeFontSize = typeof CodeFontSize.Type; export const DEFAULT_CODE_FONT_SIZE: CodeFontSize = 13; @@ -95,7 +104,10 @@ export const DEFAULT_CODE_FONT_SIZE: CodeFontSize = 13; export const MIN_TERMINAL_FONT_SIZE = 8; export const MAX_TERMINAL_FONT_SIZE = 20; export const TerminalFontSize = Schema.Int.check( - Schema.isBetween({ minimum: MIN_TERMINAL_FONT_SIZE, maximum: MAX_TERMINAL_FONT_SIZE }), + Schema.isBetween({ + minimum: MIN_TERMINAL_FONT_SIZE, + maximum: MAX_TERMINAL_FONT_SIZE, + }), ); export type TerminalFontSize = typeof TerminalFontSize.Type; export const DEFAULT_TERMINAL_FONT_SIZE: TerminalFontSize = 12; @@ -223,13 +235,19 @@ const makeBinaryPathSetting = (fallback: string) => Schema.withDecodingDefault(Effect.succeed(fallback)), ); -export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch"; +export interface ProviderSettingsFormSelectOption { + readonly value: string; + readonly label: string; +} + +export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch" | "select"; export interface ProviderSettingsFormAnnotation { readonly control?: ProviderSettingsFormControl | undefined; readonly placeholder?: string | undefined; readonly hidden?: boolean | undefined; readonly clearWhenEmpty?: "omit" | "persist" | undefined; + readonly options?: ReadonlyArray | undefined; } export interface ProviderSettingsFormSchemaAnnotation { @@ -337,7 +355,10 @@ export const ClaudeSettings = makeProviderSettingsSchema( title: "CLAUDE_CONFIG_DIR path", description: "Custom Claude home and config directory. Keeps .claude.json and .claude separate.", - providerSettingsForm: { placeholder: "~/.claude", clearWhenEmpty: "omit" }, + providerSettingsForm: { + placeholder: "~/.claude", + clearWhenEmpty: "omit", + }, }), ), customModels: Schema.Array(Schema.String).pipe( @@ -372,7 +393,10 @@ export const CursorSettings = makeProviderSettingsSchema( Schema.annotateKey({ title: "Binary path", description: "Path to the Cursor agent binary.", - providerSettingsForm: { placeholder: "cursor-agent", clearWhenEmpty: "omit" }, + providerSettingsForm: { + placeholder: "cursor-agent", + clearWhenEmpty: "omit", + }, }), ), apiEndpoint: TrimmedString.pipe( @@ -471,6 +495,81 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +const DevinPermissionMode = Schema.Literals([ + "normal", + "accept-edits", + "smart", + "dangerous", + "autonomous", +]); + +export const DevinSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("devin").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Devin CLI binary (or `devin-desktop` if installed).", + providerSettingsForm: { + placeholder: "devin or devin-desktop", + clearWhenEmpty: "omit", + }, + }), + ), + homePath: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Devin home path", + description: "Custom Devin home and config directory.", + providerSettingsForm: { + placeholder: "~/.devin", + clearWhenEmpty: "omit", + }, + }), + ), + launchArgs: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Launch arguments", + description: "Additional CLI arguments passed to `devin acp` on session start.", + providerSettingsForm: { + placeholder: "e.g. --verbose", + clearWhenEmpty: "omit", + }, + }), + ), + permissionMode: DevinPermissionMode.pipe( + Schema.withDecodingDefault(Effect.succeed("normal" as const)), + Schema.annotateKey({ + title: "Permission mode", + description: "Permission mode passed to `devin` via DEVIN_PERMISSION_MODE.", + providerSettingsForm: { + control: "select", + options: [ + { value: "normal", label: "Normal" }, + { value: "accept-edits", label: "Accept edits" }, + { value: "smart", label: "Smart" }, + { value: "dangerous", label: "Dangerous" }, + { value: "autonomous", label: "Autonomous" }, + ], + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "homePath", "launchArgs", "permissionMode"], + }, +); +export type DevinSettings = typeof DevinSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -601,6 +700,7 @@ export const ServerSettings = Schema.Struct({ cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.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 @@ -704,6 +804,15 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const DevinSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + homePath: Schema.optionalKey(TrimmedString), + launchArgs: Schema.optionalKey(TrimmedString), + permissionMode: Schema.optionalKey(DevinPermissionMode), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), @@ -744,6 +853,7 @@ export const ServerSettingsPatch = Schema.Struct({ cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), + devin: Schema.optionalKey(DevinSettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 1aa639fe4a00..661ab33fb259 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -23,7 +23,7 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; */ export const USAGE_CONTRACT_VERSION = 3 as const; -export const UsageProviderKind = Schema.Literals(["claude", "codex"]); +export const UsageProviderKind = Schema.Literals(["claude", "codex", "devin"]); export type UsageProviderKind = typeof UsageProviderKind.Type; /** From 80cab23943b0b612b7a4988c0c5b40cd0fb75757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Fran=C3=A7a?= Date: Tue, 11 Aug 2026 22:44:26 -0300 Subject: [PATCH 02/18] fix(server): improve Devin usage tracking and reasoning variant matching Track both last received ACP usage and last written usage separately to prevent duplicate transcript entries. Add `lastWrittenAcpUsage` to session context and only write deltas when usage increases. Capture usage from `UsageUpdated` events and merge with `PromptResponse` usage. Normalize reasoning variant matching to handle synonyms like "no-thinking"/"none" and "lightning-medium". Add variant expansion logic and tests for --- .../src/provider/Layers/DevinAdapter.ts | 49 +++++++- .../src/provider/acp/DevinAcpSupport.test.ts | 105 ++++++++++++++++++ .../src/provider/acp/DevinAcpSupport.ts | 55 ++++++++- 3 files changed, 197 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/DevinAdapter.ts b/apps/server/src/provider/Layers/DevinAdapter.ts index c53c041d001b..f9fd5918ec6a 100644 --- a/apps/server/src/provider/Layers/DevinAdapter.ts +++ b/apps/server/src/provider/Layers/DevinAdapter.ts @@ -124,7 +124,10 @@ interface DevinSessionContext { currentReasoningValue: string | undefined; stopped: boolean; lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; + /** Last usage received from ACP (PromptResponse or UsageUpdated). */ lastAcpUsage: EffectAcpSchema.Usage | undefined; + /** Last usage that was actually persisted to the Devin usage transcript. */ + lastWrittenAcpUsage: EffectAcpSchema.Usage | undefined; } function settlePendingApprovalsAsCancelled( @@ -272,6 +275,27 @@ function devinUsageDeltaTotals( }; } +function usageFromUsageUpdate( + event: Extract, +): EffectAcpSchema.Usage { + return { + inputTokens: event.inputTokens ?? event.used, + outputTokens: event.outputTokens ?? 0, + totalTokens: event.used, + cachedReadTokens: event.cachedReadTokens ?? null, + cachedWriteTokens: null, + thoughtTokens: null, + }; +} + +function isAcpUsageGreaterOrNew( + current: EffectAcpSchema.Usage | undefined, + next: EffectAcpSchema.Usage, +): boolean { + if (!current) return true; + return next.totalTokens > current.totalTokens; +} + interface DevinUsageTranscriptRecord { readonly timestamp: string; readonly sessionId: string; @@ -931,6 +955,7 @@ export function makeDevinAdapter(devinSettings: DevinSettings, options?: DevinAd stopped: false, lastKnownTokenUsage: undefined, lastAcpUsage: undefined, + lastWrittenAcpUsage: undefined, }; const nf = yield* Stream.runDrain( @@ -1030,6 +1055,12 @@ export function makeDevinAdapter(devinSettings: DevinSettings, options?: DevinAd return; } ctx.lastKnownTokenUsage = tokenUsage; + + const acpUsage = usageFromUsageUpdate(event); + if (isAcpUsageGreaterOrNew(ctx.lastAcpUsage, acpUsage)) { + ctx.lastAcpUsage = acpUsage; + } + yield* offerRuntimeEvent({ type: "thread.token-usage.updated", ...stamp, @@ -1331,11 +1362,15 @@ export function makeDevinAdapter(devinSettings: DevinSettings, options?: DevinAd appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); - const usage = result.usage; - const tokenUsage = usage - ? makeDevinTokenUsageSnapshot(usage, ctx.lastKnownTokenUsage) + const usage = result.usage ?? ctx.lastAcpUsage; + if (usage && isAcpUsageGreaterOrNew(ctx.lastAcpUsage, usage)) { + ctx.lastAcpUsage = usage; + } + + const tokenUsage = result.usage + ? makeDevinTokenUsageSnapshot(result.usage, ctx.lastKnownTokenUsage) : undefined; - if (tokenUsage && usage) { + if (tokenUsage && result.usage) { ctx.lastKnownTokenUsage = tokenUsage; yield* offerRuntimeEvent({ type: "thread.token-usage.updated", @@ -1345,8 +1380,10 @@ export function makeDevinAdapter(devinSettings: DevinSettings, options?: DevinAd turnId: prepared.turnId, payload: { usage: tokenUsage }, }); + } - const deltaTotals = devinUsageDeltaTotals(usage, ctx.lastAcpUsage); + if (usage) { + const deltaTotals = devinUsageDeltaTotals(usage, ctx.lastWrittenAcpUsage); const totalDeltaTokens = deltaTotals.uncachedInputTokens + deltaTotals.cachedInputTokens + @@ -1354,7 +1391,7 @@ export function makeDevinAdapter(devinSettings: DevinSettings, options?: DevinAd deltaTotals.outputTokens; if (totalDeltaTokens > 0) { - ctx.lastAcpUsage = usage; + ctx.lastWrittenAcpUsage = usage; const observedAt = yield* nowIso; const usageModel = prepared.displayModel ?? ctx.session.model ?? "adaptive"; yield* writeDevinUsageTranscriptLine(devinSettings, { diff --git a/apps/server/src/provider/acp/DevinAcpSupport.test.ts b/apps/server/src/provider/acp/DevinAcpSupport.test.ts index 12bedd3992ad..9032d6fac285 100644 --- a/apps/server/src/provider/acp/DevinAcpSupport.test.ts +++ b/apps/server/src/provider/acp/DevinAcpSupport.test.ts @@ -247,3 +247,108 @@ describe("applyDevinAcpModelSelection", () => { }), ); }); + +describe("Devin reasoning variant synonyms", () => { + it.effect("maps no-thinking to the none variant", () => + Effect.gen(function* () { + const setModel = vi.fn().mockReturnValue(Effect.succeed(undefined)); + const setConfigOption = vi.fn().mockReturnValue(Effect.succeed({ configOptions: [] })); + + yield* applyDevinAcpModelSelection({ + runtime: { setModel, setConfigOption }, + current: undefined, + requested: { familySlug: "glm-5-2", reasoningValue: "no-thinking" }, + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "adaptive", + options: [ + { value: "adaptive", name: "Adaptive" }, + { value: "glm-5-2", name: "GLM-5.2" }, + { value: "glm-5-2-none", name: "GLM-5.2 No Thinking" }, + { value: "glm-5-2-1m", name: "GLM-5.2 No Thinking 1M" }, + ], + }, + ], + mapError: (cause) => cause, + }); + + expect(setModel).toHaveBeenCalledWith("glm-5-2-none"); + expect(setConfigOption).not.toHaveBeenCalled(); + }), + ); + + it.effect("maps no-thinking-1m to the 1m variant", () => + Effect.gen(function* () { + const setModel = vi.fn().mockReturnValue(Effect.succeed(undefined)); + const setConfigOption = vi.fn().mockReturnValue(Effect.succeed({ configOptions: [] })); + + yield* applyDevinAcpModelSelection({ + runtime: { setModel, setConfigOption }, + current: undefined, + requested: { familySlug: "glm-5-2", reasoningValue: "no-thinking-1m" }, + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "adaptive", + options: [ + { value: "adaptive", name: "Adaptive" }, + { value: "glm-5-2", name: "GLM-5.2" }, + { value: "glm-5-2-none", name: "GLM-5.2 No Thinking" }, + { value: "glm-5-2-1m", name: "GLM-5.2 No Thinking 1M" }, + ], + }, + ], + mapError: (cause) => cause, + }); + + expect(setModel).toHaveBeenCalledWith("glm-5-2-1m"); + expect(setConfigOption).not.toHaveBeenCalled(); + }), + ); + + it.effect("maps lightning-medium to the lightning-medium variant", () => + Effect.gen(function* () { + const setModel = vi.fn().mockReturnValue(Effect.succeed(undefined)); + const setConfigOption = vi.fn().mockReturnValue(Effect.succeed({ configOptions: [] })); + + yield* applyDevinAcpModelSelection({ + runtime: { setModel, setConfigOption }, + current: undefined, + requested: { + familySlug: "swe-1-7", + reasoningValue: "lightning-medium", + }, + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "adaptive", + options: [ + { value: "adaptive", name: "Adaptive" }, + { value: "swe-1-7", name: "SWE-1.7" }, + { value: "swe-1-7-medium", name: "SWE-1.7 Medium" }, + { value: "swe-1-7-lightning", name: "SWE-1.7 Lightning" }, + { + value: "swe-1-7-lightning-medium", + name: "SWE-1.7 Lightning Medium", + }, + ], + }, + ], + mapError: (cause) => cause, + }); + + expect(setModel).toHaveBeenCalledWith("swe-1-7-lightning-medium"); + expect(setConfigOption).not.toHaveBeenCalled(); + }), + ); +}); diff --git a/apps/server/src/provider/acp/DevinAcpSupport.ts b/apps/server/src/provider/acp/DevinAcpSupport.ts index f9d0da871dfd..da027c2b01a7 100644 --- a/apps/server/src/provider/acp/DevinAcpSupport.ts +++ b/apps/server/src/provider/acp/DevinAcpSupport.ts @@ -103,6 +103,40 @@ export function resolveDevinAcpModelSelection( }; } +function normalizeDevinReasoningVariant(variant: string): string { + return variant + .toLowerCase() + .replace(/[.\/\s]+/g, "-") + .replace(/^-|-$/g, "") + .replace(/-+/g, "-"); +} + +function expandDevinReasoningVariants(reasoningValue: string | undefined): ReadonlyArray { + if (!reasoningValue) { + return []; + } + const normalized = normalizeDevinReasoningVariant(reasoningValue); + const variants = new Set([normalized]); + + // Common Devin reasoning labels do not always map 1:1 to ACP model slugs. + // Add synonyms so the picker can match the variant advertised by the agent. + if (normalized === "no-thinking" || normalized === "none") { + variants.add("none"); + variants.add("no-thinking"); + } + if (normalized === "no-thinking-1m" || normalized === "none-1m" || normalized === "1m") { + variants.add("1m"); + variants.add("none-1m"); + variants.add("no-thinking-1m"); + } + if (normalized === "fast" || normalized === "priority") { + variants.add("fast"); + variants.add("priority"); + } + + return Array.from(variants); +} + function normalizeConfigIdToken(value: string): string { return value.toLowerCase().replace(/[\s_-]+/g, ""); } @@ -225,17 +259,26 @@ export function applyDevinAcpModelSelection(input: { const modelOption = findSessionConfigOption(input.configOptions, modelConfigId); const allowedModelValues = modelOption ? collectSessionConfigOptionValues(modelOption) : []; - const candidateModelValues = [requested.familySlug]; + const variantCandidates: string[] = []; if (requestedReasoning !== undefined) { - candidateModelValues.push(`${requested.familySlug}-${requestedReasoning}`); - candidateModelValues.push(`${requested.familySlug}/${requestedReasoning}`); + for (const variant of expandDevinReasoningVariants(requestedReasoning)) { + variantCandidates.push(`${requested.familySlug}-${variant}`); + variantCandidates.push(`${requested.familySlug}/${variant}`); + } } + const baseCandidates = [requested.familySlug]; const effectiveModel = - candidateModelValues.find((candidate) => allowedModelValues.includes(candidate)) ?? + variantCandidates.find((candidate) => allowedModelValues.includes(candidate)) ?? allowedModelValues.find((value) => - candidateModelValues.some((candidate) => value.endsWith(`/${candidate}`)), - ); + variantCandidates.some( + (candidate) => + value.endsWith(`/${candidate}`) || + value.endsWith(`-${candidate}`) || + value === candidate, + ), + ) ?? + baseCandidates.find((candidate) => allowedModelValues.includes(candidate)); if (effectiveModel === undefined) { return yield* Effect.fail( From b117ec1668fc53a0e9035d578c18543ae1205e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Fran=C3=A7a?= Date: Tue, 11 Aug 2026 23:00:07 -0300 Subject: [PATCH 03/18] fix(devin): apply Macroscope review feedback and add mobile support - AcpNativeLogging: never emit raw ACP frames or payload debug logs; always summarize payloads before logging. - DevinAcpSupport: remove leftover Console.log in model selection. - DevinAdapter: remove Effect.logInfo of the raw session/new response. - DevinProvider: introduce ProviderProbeError and use it for devin models list failures instead of ProviderAdapterProcessError with a fabricated 'probe' threadId. - DevinProvider.test: add missing devin-models-list.txt fixture. - mobile: include 'devin' in usage provider labels/colors and model display labels. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/features/usage/usageProviders.ts | 4 +- apps/mobile/src/lib/modelOptions.ts | 1 + apps/server/src/provider/Errors.ts | 16 ++++ .../src/provider/Layers/DevinAdapter.ts | 1 - .../src/provider/Layers/DevinProvider.ts | 8 +- .../src/provider/acp/AcpNativeLogging.ts | 7 +- .../src/provider/acp/DevinAcpSupport.ts | 8 -- devin-models-list.txt | 92 +++++++++++++++++++ 8 files changed, 116 insertions(+), 21 deletions(-) create mode 100644 devin-models-list.txt diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index 3e2d027a9e35..971f63747a69 100644 --- a/apps/mobile/src/features/usage/usageProviders.ts +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -5,11 +5,12 @@ import { useColorScheme } from "react-native"; * Series and table order. The chart stacks providers from the bottom in this * order, so it also fixes which band sits on top of the bars. */ -export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; +export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "devin"]; export const PROVIDER_LABEL: Record = { claude: "Claude Code", codex: "Codex", + devin: "Devin", }; /** @@ -21,5 +22,6 @@ export function useProviderColors(): Record { return { claude: "#d97757", codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", + devin: "#8a63d2", }; } diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index cb7a8c4198ec..d46ec3c0ba97 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -35,6 +35,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/Errors.ts b/apps/server/src/provider/Errors.ts index 0cf1522399b4..db9bde8e51d7 100644 --- a/apps/server/src/provider/Errors.ts +++ b/apps/server/src/provider/Errors.ts @@ -85,6 +85,22 @@ export class ProviderAdapterProcessError extends Schema.TaggedErrorClass()( + "ProviderProbeError", + { + provider: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Provider probe error (${this.provider}): ${this.detail}`; + } +} + /** * ProviderValidationError - Invalid provider API input. */ diff --git a/apps/server/src/provider/Layers/DevinAdapter.ts b/apps/server/src/provider/Layers/DevinAdapter.ts index f9fd5918ec6a..170f812788b2 100644 --- a/apps/server/src/provider/Layers/DevinAdapter.ts +++ b/apps/server/src/provider/Layers/DevinAdapter.ts @@ -898,7 +898,6 @@ export function makeDevinAdapter(devinSettings: DevinSettings, options?: DevinAd ), ); const started = yield* acp.start(); - yield* Effect.logInfo("[DevinAdapter] session/new result", started.sessionSetupResult); return started; }).pipe( Effect.mapError((error) => diff --git a/apps/server/src/provider/Layers/DevinProvider.ts b/apps/server/src/provider/Layers/DevinProvider.ts index 787b4431d3a0..ae5461486e9f 100644 --- a/apps/server/src/provider/Layers/DevinProvider.ts +++ b/apps/server/src/provider/Layers/DevinProvider.ts @@ -20,7 +20,7 @@ import { HttpClient } from "effect/unstable/http"; import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { ProviderAdapterProcessError } from "../Errors.ts"; +import { ProviderProbeError } from "../Errors.ts"; import { buildServerProvider, isCommandMissingCause, @@ -628,17 +628,15 @@ const discoverDevinModelsViaModelsList = ( Effect.gen(function* () { const result = yield* runDevinModelsListCommand(devinSettings, environment); if (result.code !== 0) { - return yield* new ProviderAdapterProcessError({ + return yield* new ProviderProbeError({ provider: "devin", - threadId: "probe", detail: `Devin models list failed with exit code ${result.code}.`, }); } const models = parseDevinModelsList(`${result.stdout}\n${result.stderr}`); if (models.length === 0) { - return yield* new ProviderAdapterProcessError({ + return yield* new ProviderProbeError({ provider: "devin", - threadId: "probe", detail: "Devin models list returned no parseable models.", }); } diff --git a/apps/server/src/provider/acp/AcpNativeLogging.ts b/apps/server/src/provider/acp/AcpNativeLogging.ts index 2b8d5f02b853..e6a3307979b0 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.ts @@ -63,11 +63,7 @@ function formatProtocolLogPayload(event: EffectAcpProtocol.AcpProtocolLogEvent) return { direction: event.direction, stage: event.stage, - payload: - event.stage === "raw" && - (typeof event.payload === "string" || event.payload instanceof Uint8Array) - ? event.payload - : summarizePayload(event.payload), + payload: summarizePayload(event.payload), }; } @@ -83,7 +79,6 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" readonly payload: unknown; }) => Effect.gen(function* () { - yield* Effect.logDebug(`[ACP ${logInput.kind}]`, logInput.payload); if (!input.nativeEventLogger) return; const observedAt = DateTime.formatIso(yield* DateTime.now); yield* input.nativeEventLogger.write( diff --git a/apps/server/src/provider/acp/DevinAcpSupport.ts b/apps/server/src/provider/acp/DevinAcpSupport.ts index da027c2b01a7..a4167342267d 100644 --- a/apps/server/src/provider/acp/DevinAcpSupport.ts +++ b/apps/server/src/provider/acp/DevinAcpSupport.ts @@ -2,7 +2,6 @@ import { type DevinSettings, type ModelSelection, ProviderDriverKind } from "@t3 import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; import { getModelSelectionStringOptionValue, normalizeModelSlug } from "@t3tools/shared/model"; import * as Crypto from "effect/Crypto"; -import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; @@ -248,13 +247,6 @@ export function applyDevinAcpModelSelection(input: { } return Effect.gen(function* () { - yield* Console.log("[DevinAcpSupport] applyDevinAcpModelSelection", { - requested, - current, - modelConfigId, - reasoningConfigId, - }); - if (needsModelSwitch && modelConfigId !== undefined) { const modelOption = findSessionConfigOption(input.configOptions, modelConfigId); const allowedModelValues = modelOption ? collectSessionConfigOptionValues(modelOption) : []; diff --git a/devin-models-list.txt b/devin-models-list.txt new file mode 100644 index 000000000000..d87be0221bd0 --- /dev/null +++ b/devin-models-list.txt @@ -0,0 +1,92 @@ +{ + "families": [ + { + "family_label": "Claude Opus 5", + "family_uid": "claude-opus-5", + "slug": "claude-opus-5", + "variants": [ + { "model_uid": "claude-opus-5-medium", "label": "Claude Opus 5 Medium" }, + { "model_uid": "claude-opus-5-low-fast", "label": "Claude Opus 5 Low Fast" }, + { "model_uid": "claude-opus-5-high", "label": "Claude Opus 5 High" } + ] + }, + { + "family_label": "Claude Sonnet 4.5", + "family_uid": "claude-sonnet-4-5", + "slug": "claude-sonnet-4-5", + "variants": [ + { "model_uid": "claude-sonnet-4-5-medium", "label": "Claude Sonnet 4.5 Medium" } + ] + }, + { + "family_label": "Claude Fable 5", + "family_uid": "claude-fable-5", + "slug": "claude-fable-5", + "variants": [ + { "model_uid": "claude-fable-5-medium", "label": "Claude Fable 5 Medium" } + ] + }, + { + "family_label": "Gemini 3 Flash", + "family_uid": "gemini-3-flash", + "slug": "gemini-3-flash", + "variants": [ + { "model_uid": "gemini-3-flash-medium", "label": "Gemini 3 Flash Medium" } + ] + }, + { + "family_label": "GPT-4.1", + "family_uid": "gpt-4-1", + "slug": "gpt-4-1", + "variants": [ + { "model_uid": "gpt-4-1-medium", "label": "GPT-4.1 Medium" } + ] + }, + { + "family_label": "GPT-5.2", + "family_uid": "gpt-5-2", + "slug": "gpt-5-2", + "variants": [ + { "model_uid": "gpt-5-2-medium", "label": "GPT-5.2 Medium" }, + { "model_uid": "gpt-5-2-none", "label": "GPT-5.2 No Thinking" }, + { "model_uid": "gpt-5-2-1m", "label": "GPT-5.2 No Thinking 1M" } + ] + }, + { + "family_label": "SWE-1.7", + "family_uid": "swe-1.7", + "slug": "swe-1.7", + "variants": [ + { "model_uid": "swe-1-7", "label": "SWE-1.7 Max" }, + { "model_uid": "swe-1-7-medium", "label": "SWE-1.7 Medium" } + ] + }, + { + "family_label": "SWE-1.7 Lightning", + "family_uid": "swe-1.7-lightning", + "slug": "swe-1.7-lightning", + "variants": [ + { "model_uid": "swe-1-7-lightning", "label": "SWE-1.7 Lightning Max" }, + { "model_uid": "swe-1-7-lightning-medium", "label": "SWE-1.7 Lightning Medium" } + ] + }, + { + "family_label": "GLM-5.2", + "family_uid": "glm-5.2", + "slug": "glm-5.2", + "variants": [ + { "model_uid": "glm-5-2", "label": "GLM-5.2" }, + { "model_uid": "glm-5-2-none", "label": "GLM-5.2 No Thinking" }, + { "model_uid": "glm-5-2-1m", "label": "GLM-5.2 No Thinking 1M" } + ] + }, + { + "family_label": "Adaptive", + "family_uid": "adaptive", + "slug": "adaptive", + "variants": [ + { "model_uid": "adaptive", "label": "Adaptive" } + ] + } + ] +} From 71544c93fae2bdc3dd8dfda53a445b837f55c060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Fran=C3=A7a?= Date: Wed, 12 Aug 2026 08:15:55 -0300 Subject: [PATCH 04/18] fix(server,web,acp): apply Macroscope round-2 review feedback - DevinProvider: parse stdout alone, fallback to stderr for text output. - DevinDriver: derive continuation group key from resolved home path. - DevinHome: clear inherited DEVIN_HOME and always set resolved home path. - DevinAdapter: remove Console import/dead locals, fix usage input derivation and equal-total breakdown, validate prompt before model switch, fork ACP drain into sessionScope. - DevinAcpSupport: reuse AcpRuntimeModel config helpers, fail on missing model option, handle default reasoning and reason synonyms. - AcpSessionRuntime: keep auth method authoritative; fail when not advertised. - usageScanCache: accept 'devin' scan-cache entries. - ProviderModelsSection: use orderedModels index for move buttons. - DevinProvider.test: resolve fixture from import.meta.dirname. - Add focused tests for DevinAdapter, DevinHome, usage scan cache, and ACP auth. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/server/scripts/acp-mock-agent.ts | 39 +++- .../src/provider/Drivers/DevinDriver.ts | 15 +- .../src/provider/Drivers/DevinHome.test.ts | 66 ++++++ apps/server/src/provider/Drivers/DevinHome.ts | 2 - .../src/provider/Layers/DevinAdapter.test.ts | 147 ++++++++++++++ .../src/provider/Layers/DevinAdapter.ts | 80 ++++---- .../src/provider/Layers/DevinProvider.test.ts | 71 ++++++- .../src/provider/Layers/DevinProvider.ts | 3 +- .../provider/acp/AcpJsonRpcConnection.test.ts | 126 +++++++++++- .../src/provider/acp/AcpRuntimeModel.ts | 4 +- .../src/provider/acp/AcpSessionRuntime.ts | 33 +-- .../src/provider/acp/DevinAcpSupport.test.ts | 188 +++++++++++++++++- .../src/provider/acp/DevinAcpSupport.ts | 80 ++++++-- .../testFixtures/devin-models-list.txt | 2 +- apps/server/src/usage/usageScanCache.test.ts | 15 ++ apps/server/src/usage/usageScanCache.ts | 2 +- .../settings/ProviderModelsSection.tsx | 5 +- 17 files changed, 774 insertions(+), 104 deletions(-) create mode 100644 apps/server/src/provider/Drivers/DevinHome.test.ts create mode 100644 apps/server/src/provider/Layers/DevinAdapter.test.ts rename devin-models-list.txt => apps/server/src/provider/testFixtures/devin-models-list.txt (99%) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd8547..64b4b9f4c281 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -40,6 +40,17 @@ const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); +const advertisedAuthMethods = (() => { + const raw = process.env.T3_ACP_AUTH_METHODS; + if (!raw) { + return undefined; + } + try { + return JSON.parse(raw) as ReadonlyArray; + } catch { + return undefined; + } +})(); const permissionOptionIds = { allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once", allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always", @@ -218,7 +229,10 @@ function configOptions(): ReadonlyArray { { value: "default", name: "Auto" }, { value: "composer-2", name: "Composer 2" }, { value: "composer-2[fast=true]", name: "Composer 2 Fast" }, - { value: "gpt-5.3-codex[reasoning=medium,fast=false]", name: "Codex 5.3" }, + { + value: "gpt-5.3-codex[reasoning=medium,fast=false]", + name: "Codex 5.3", + }, ], }, ]; @@ -303,6 +317,7 @@ const program = Effect.gen(function* () { return { protocolVersion: 1, agentCapabilities: { loadSession: true }, + ...(advertisedAuthMethods !== undefined ? { authMethods: advertisedAuthMethods } : {}), }; }), ); @@ -674,13 +689,21 @@ const program = Effect.gen(function* () { ], }, options: [ - { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" }, + { + optionId: permissionOptionIds.allowOnce, + name: "Allow once", + kind: "allow_once", + }, { optionId: permissionOptionIds.allowAlways, name: "Allow always", kind: "allow_always", }, - { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" }, + { + optionId: permissionOptionIds.rejectOnce, + name: "Reject", + kind: "reject_once", + }, ], }); @@ -784,7 +807,10 @@ const program = Effect.gen(function* () { question: "Which scope should Grok use?", multiSelect: null, options: [ - { label: "Workspace", description: "Use the current workspace" }, + { + label: "Workspace", + description: "Use the current workspace", + }, { label: "Session", description: "Only use this session" }, ], }, @@ -869,7 +895,10 @@ const program = Effect.gen(function* () { sessionId: requestedSessionId, update: { sessionUpdate: "agent_message_chunk", - content: { type: "text", text: promptResponseText ?? "hello from mock" }, + content: { + type: "text", + text: promptResponseText ?? "hello from mock", + }, }, }); diff --git a/apps/server/src/provider/Drivers/DevinDriver.ts b/apps/server/src/provider/Drivers/DevinDriver.ts index e2d8250acbf1..5a2e634f1794 100644 --- a/apps/server/src/provider/Drivers/DevinDriver.ts +++ b/apps/server/src/provider/Drivers/DevinDriver.ts @@ -14,7 +14,7 @@ import { makeDevinTextGeneration } from "../../textGeneration/DevinTextGeneratio import { ProviderDriverError } from "../Errors.ts"; import { makeDevinAdapter } from "../Layers/DevinAdapter.ts"; import { resolveEffectiveDevinBinary } from "./DevinBinary.ts"; -import { makeDevinEnvironment } from "./DevinHome.ts"; +import { makeDevinContinuationGroupKey, makeDevinEnvironment } from "./DevinHome.ts"; import { buildInitialDevinProviderSnapshot, checkDevinProviderStatus, @@ -22,11 +22,7 @@ import { } 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 { type ProviderDriver, type ProviderInstance } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { @@ -95,10 +91,11 @@ export const DevinDriver: ProviderDriver = { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const processEnv = mergeProviderInstanceEnvironment(environment); - const continuationIdentity = defaultProviderContinuationIdentity({ + const continuationGroupKey = yield* makeDevinContinuationGroupKey(config); + const continuationIdentity = { driverKind: DRIVER_KIND, - instanceId, - }); + continuationKey: continuationGroupKey, + }; const stampIdentity = withInstanceIdentity({ instanceId, displayName, diff --git a/apps/server/src/provider/Drivers/DevinHome.test.ts b/apps/server/src/provider/Drivers/DevinHome.test.ts new file mode 100644 index 000000000000..1f6de00e089a --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinHome.test.ts @@ -0,0 +1,66 @@ +import * as NodeOS from "node:os"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; + +import { + makeDevinCapabilitiesCacheKey, + makeDevinContinuationGroupKey, + makeDevinEnvironment, + resolveDevinHomePath, +} from "./DevinHome.ts"; + +it.layer(NodeServices.layer)("DevinHome", (it) => { + describe("Devin home resolution", () => { + it.effect("uses ~/.devin when no Devin home override is configured", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const resolved = path.resolve(NodeOS.homedir(), ".devin"); + + expect(yield* resolveDevinHomePath({ homePath: "" })).toBe(resolved); + }), + ); + + it.effect("resolves configured Devin HOME and stamps continuation/cache keys with it", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const homePath = "~/.devin-work"; + const resolved = path.resolve(NodeOS.homedir(), ".devin-work"); + + expect(yield* resolveDevinHomePath({ homePath })).toBe(resolved); + expect((yield* makeDevinEnvironment({ homePath })).DEVIN_HOME).toBe(resolved); + expect(yield* makeDevinContinuationGroupKey({ homePath })).toBe(`devin:home:${resolved}`); + expect(yield* makeDevinCapabilitiesCacheKey({ binaryPath: "devin", homePath })).toBe( + `devin\0${resolved}\0`, + ); + }), + ); + + it.effect("clears an inherited DEVIN_HOME when no Devin home override is configured", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const resolved = path.resolve(NodeOS.homedir(), ".devin"); + + const env = yield* makeDevinEnvironment( + { homePath: "" }, + { ...process.env, DEVIN_HOME: "/some/other/home" }, + ); + + expect(env.DEVIN_HOME).toBe(resolved); + }), + ); + + it.effect("keeps continuation compatible across instances with the same Devin HOME", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const resolved = path.resolve(NodeOS.homedir(), ".devin"); + + expect(yield* makeDevinContinuationGroupKey({ homePath: "" })).toBe( + `devin:home:${resolved}`, + ); + }), + ); + }); +}); diff --git a/apps/server/src/provider/Drivers/DevinHome.ts b/apps/server/src/provider/Drivers/DevinHome.ts index 2aa7ddcaded4..0be436cb078d 100644 --- a/apps/server/src/provider/Drivers/DevinHome.ts +++ b/apps/server/src/provider/Drivers/DevinHome.ts @@ -26,8 +26,6 @@ export const makeDevinEnvironment = Effect.fn("makeDevinEnvironment")(function* baseEnv?: NodeJS.ProcessEnv, ): Effect.fn.Return { const resolvedBaseEnv = baseEnv ?? process.env; - const homePath = config.homePath.trim(); - if (homePath.length === 0) return resolvedBaseEnv; const resolvedHomePath = yield* resolveDevinHomePath(config); return { ...resolvedBaseEnv, 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..ee46a3ccad3c --- /dev/null +++ b/apps/server/src/provider/Layers/DevinAdapter.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as EffectAcpSchema from "effect-acp/schema"; + +import type { AcpParsedSessionEvent } from "../acp/AcpRuntimeModel.ts"; +import { isAcpUsageGreaterOrNew, usageFromUsageUpdate } from "./DevinAdapter.ts"; + +function makeUsageUpdatedEvent( + event: Partial> & { + readonly used: number; + }, +): Extract { + return { + _tag: "UsageUpdated", + used: event.used, + size: event.size ?? 0, + cost: event.cost ?? null, + inputTokens: event.inputTokens ?? undefined, + outputTokens: event.outputTokens ?? undefined, + cachedReadTokens: event.cachedReadTokens ?? undefined, + rawPayload: event.rawPayload ?? {}, + }; +} + +describe("usageFromUsageUpdate", () => { + it("keeps provided input and output tokens", () => { + const event = makeUsageUpdatedEvent({ + used: 21776, + inputTokens: 21687, + outputTokens: 89, + }); + expect(usageFromUsageUpdate(event)).toEqual({ + inputTokens: 21687, + outputTokens: 89, + totalTokens: 21776, + cachedReadTokens: null, + cachedWriteTokens: null, + thoughtTokens: null, + }); + }); + + it("derives input tokens from used and output when input is missing", () => { + const event = makeUsageUpdatedEvent({ + used: 21776, + outputTokens: 89, + }); + expect(usageFromUsageUpdate(event)).toEqual({ + inputTokens: 21687, + outputTokens: 89, + totalTokens: 21776, + cachedReadTokens: null, + cachedWriteTokens: null, + thoughtTokens: null, + }); + }); + + it("uses used as input when both breakdown fields are missing", () => { + const event = makeUsageUpdatedEvent({ + used: 100, + }); + expect(usageFromUsageUpdate(event)).toEqual({ + inputTokens: 100, + outputTokens: 0, + totalTokens: 100, + cachedReadTokens: null, + cachedWriteTokens: null, + thoughtTokens: null, + }); + }); + + it("does not subtract output when input is present", () => { + const event = makeUsageUpdatedEvent({ + used: 100, + inputTokens: 30, + outputTokens: 20, + }); + expect(usageFromUsageUpdate(event)).toEqual({ + inputTokens: 30, + outputTokens: 20, + totalTokens: 100, + cachedReadTokens: null, + cachedWriteTokens: null, + thoughtTokens: null, + }); + }); +}); + +describe("isAcpUsageGreaterOrNew", () => { + const baseUsage = { + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + cachedReadTokens: null, + cachedWriteTokens: null, + thoughtTokens: null, + } satisfies EffectAcpSchema.Usage; + + it("returns true when there is no current usage", () => { + expect(isAcpUsageGreaterOrNew(undefined, baseUsage)).toBe(true); + }); + + it("returns true when total tokens increased", () => { + expect(isAcpUsageGreaterOrNew(baseUsage, { ...baseUsage, totalTokens: 20 })).toBe(true); + }); + + it("returns false when total tokens decreased", () => { + expect(isAcpUsageGreaterOrNew(baseUsage, { ...baseUsage, totalTokens: 10 })).toBe(false); + }); + + it("returns false when everything is identical", () => { + expect(isAcpUsageGreaterOrNew(baseUsage, { ...baseUsage })).toBe(false); + }); + + it("returns true when total is equal but input tokens differ", () => { + expect(isAcpUsageGreaterOrNew(baseUsage, { ...baseUsage, inputTokens: 12 })).toBe(true); + }); + + it("returns true when total is equal but output tokens differ", () => { + expect(isAcpUsageGreaterOrNew(baseUsage, { ...baseUsage, outputTokens: 7 })).toBe(true); + }); + + it("returns true when total is equal but cached read tokens differ", () => { + expect( + isAcpUsageGreaterOrNew(baseUsage, { + ...baseUsage, + cachedReadTokens: 3, + }), + ).toBe(true); + }); + + it("returns true when total is equal but cached write tokens differ", () => { + expect( + isAcpUsageGreaterOrNew(baseUsage, { + ...baseUsage, + cachedWriteTokens: 2, + }), + ).toBe(true); + }); + + it("returns true when total is equal but thought tokens differ", () => { + expect( + isAcpUsageGreaterOrNew(baseUsage, { + ...baseUsage, + thoughtTokens: 1, + }), + ).toBe(true); + }); +}); diff --git a/apps/server/src/provider/Layers/DevinAdapter.ts b/apps/server/src/provider/Layers/DevinAdapter.ts index 170f812788b2..490adc26d5e3 100644 --- a/apps/server/src/provider/Layers/DevinAdapter.ts +++ b/apps/server/src/provider/Layers/DevinAdapter.ts @@ -17,7 +17,6 @@ import { import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; -import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -275,12 +274,15 @@ function devinUsageDeltaTotals( }; } -function usageFromUsageUpdate( +export function usageFromUsageUpdate( event: Extract, ): EffectAcpSchema.Usage { + const outputTokens = event.outputTokens ?? 0; return { - inputTokens: event.inputTokens ?? event.used, - outputTokens: event.outputTokens ?? 0, + inputTokens: + event.inputTokens ?? + (event.outputTokens !== undefined ? Math.max(0, event.used - outputTokens) : event.used), + outputTokens, totalTokens: event.used, cachedReadTokens: event.cachedReadTokens ?? null, cachedWriteTokens: null, @@ -288,12 +290,21 @@ function usageFromUsageUpdate( }; } -function isAcpUsageGreaterOrNew( +export function isAcpUsageGreaterOrNew( current: EffectAcpSchema.Usage | undefined, next: EffectAcpSchema.Usage, ): boolean { if (!current) return true; - return next.totalTokens > current.totalTokens; + if (next.totalTokens !== current.totalTokens) { + return next.totalTokens > current.totalTokens; + } + return ( + next.inputTokens !== current.inputTokens || + next.outputTokens !== current.outputTokens || + next.cachedReadTokens !== current.cachedReadTokens || + next.cachedWriteTokens !== current.cachedWriteTokens || + next.thoughtTokens !== current.thoughtTokens + ); } interface DevinUsageTranscriptRecord { @@ -357,11 +368,6 @@ function makeDevinTokenUsageSnapshot( const cachedReadTokens = finiteNonNegativeInteger(usage.cachedReadTokens); const thoughtTokens = finiteNonNegativeInteger(usage.thoughtTokens); - const previousUsedTokens = previous?.usedTokens ?? 0; - const previousInputTokens = previous?.inputTokens ?? 0; - const previousOutputTokens = previous?.outputTokens ?? 0; - const previousCachedInputTokens = previous?.cachedInputTokens ?? 0; - const previousReasoningOutputTokens = previous?.reasoningOutputTokens ?? 0; return buildThreadTokenUsageSnapshot({ usedTokens, @@ -1079,7 +1085,7 @@ export function makeDevinAdapter(devinSettings: DevinSettings, options?: DevinAd cause, }), ), - Effect.forkChild, + Effect.forkIn(sessionScope), ); ctx.notificationFiber = nf; @@ -1138,31 +1144,6 @@ export function makeDevinAdapter(devinSettings: DevinSettings, options?: DevinAd }; return yield* Effect.gen(function* () { - const turnModelSelection = - input.modelSelection?.instanceId === boundInstanceId - ? input.modelSelection - : undefined; - const requestedTurnModel = resolveDevinAcpModelSelection(turnModelSelection); - const currentModel = yield* applyDevinAcpModelSelection({ - runtime: ctx.acp, - current: - ctx.currentModelId === undefined - ? undefined - : { - familySlug: ctx.currentModelId, - reasoningValue: ctx.currentReasoningValue, - }, - requested: requestedTurnModel, - configOptions: ctx.sessionSetupResult.configOptions ?? [], - mapError: (cause) => - mapAcpToAdapterError( - PROVIDER, - input.threadId, - "session/set_config_option", - cause, - ), - }); - const text = input.input?.trim(); const imagePromptParts = yield* Effect.forEach( input.attachments ?? [], @@ -1210,6 +1191,31 @@ export function makeDevinAdapter(devinSettings: DevinSettings, options?: DevinAd }); } + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId + ? input.modelSelection + : undefined; + const requestedTurnModel = resolveDevinAcpModelSelection(turnModelSelection); + const currentModel = yield* applyDevinAcpModelSelection({ + runtime: ctx.acp, + current: + ctx.currentModelId === undefined + ? undefined + : { + familySlug: ctx.currentModelId, + reasoningValue: ctx.currentReasoningValue, + }, + requested: requestedTurnModel, + configOptions: ctx.sessionSetupResult.configOptions ?? [], + mapError: (cause) => + mapAcpToAdapterError( + PROVIDER, + input.threadId, + "session/set_config_option", + cause, + ), + }); + ctx.currentModelId = currentModel?.familySlug; ctx.currentReasoningValue = currentModel?.reasoningValue; const displayModel = diff --git a/apps/server/src/provider/Layers/DevinProvider.test.ts b/apps/server/src/provider/Layers/DevinProvider.test.ts index 61a4a71d3fcf..56d961a32903 100644 --- a/apps/server/src/provider/Layers/DevinProvider.test.ts +++ b/apps/server/src/provider/Layers/DevinProvider.test.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -68,6 +71,35 @@ function mockModelsListScript(platform: string) { ].join("\n"); } +function mockModelsListScriptWithStderrWarning(platform: string) { + const json = JSON.stringify([{ family_label: "Adaptive", slug: "adaptive" }]); + const warning = "warning: legacy flag ignored"; + if (isWindows(platform)) { + return [ + "@echo off", + 'if "%1" == "models" if "%2" == "list" if "%3" == "--format" if "%4" == "json" (', + ` echo ${json}`, + ` echo ${warning} 1>&2`, + " exit /b 0", + ")", + "echo devin-cli 0.0.99", + "exit /b 0", + "", + ].join("\n"); + } + return [ + "#!/bin/sh", + 'if [ "$1" = "models" ] && [ "$2" = "list" ] && [ "$3" = "--format" ] && [ "$4" = "json" ]; then', + ` printf "%s\\n" '${json}'`, + ` printf "%s\\n" '${warning}' >&2`, + " exit 0", + "fi", + 'printf "devin-cli 0.0.99\\n"', + "exit 0", + "", + ].join("\n"); +} + describe("buildInitialDevinProviderSnapshot", () => { it.effect("returns a disabled snapshot when settings.enabled is false", () => Effect.gen(function* () { @@ -174,6 +206,39 @@ it.layer(NodeServices.layer)("checkDevinProviderStatus", (it) => { }), ); + it.effect( + "discovers models when `devin models list` prints JSON to stdout and a warning to stderr", + () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-devin-models-stderr-", + }); + const devinPath = yield* makeMockDevinScript( + fs, + path, + dir, + mockModelsListScriptWithStderrWarning(platform), + platform, + ); + + return yield* checkDevinProviderStatus( + decodeDevinSettings({ enabled: true, binaryPath: devinPath }), + ); + }), + ); + + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("ready"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["adaptive"]); + }), + ); + it.effect("falls back to built-in models when `devin models list` fails", () => Effect.gen(function* () { const platform = yield* HostProcessPlatform; @@ -424,9 +489,9 @@ it.layer(NodeServices.layer)("parseDevinModelsList", (it) => { it.effect("parses the real exported devin model list into families with reasoning options", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = process.cwd(); - const raw = yield* fs.readFileString(path.join(cwd, "devin-models-list.txt")); + const raw = yield* fs.readFileString( + NodePath.join(import.meta.dirname, "../testFixtures/devin-models-list.txt"), + ); const models = parseDevinModelsList(raw); expect(models.length).toBeLessThan(50); diff --git a/apps/server/src/provider/Layers/DevinProvider.ts b/apps/server/src/provider/Layers/DevinProvider.ts index ae5461486e9f..063d51830d6a 100644 --- a/apps/server/src/provider/Layers/DevinProvider.ts +++ b/apps/server/src/provider/Layers/DevinProvider.ts @@ -633,7 +633,8 @@ const discoverDevinModelsViaModelsList = ( detail: `Devin models list failed with exit code ${result.code}.`, }); } - const models = parseDevinModelsList(`${result.stdout}\n${result.stderr}`); + const stdoutModels = parseDevinModelsList(result.stdout); + const models = stdoutModels.length > 0 ? stdoutModels : parseDevinModelsList(result.stderr); if (models.length === 0) { return yield* new ProviderProbeError({ provider: "devin", diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index b1ef0d3e5953..ecddc14236e3 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -626,7 +626,13 @@ describe("AcpSessionRuntime", () => { .trim() .split("\n") .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as { method?: string; params?: { value?: unknown } }); + .map( + (line) => + JSON.parse(line) as { + method?: string; + params?: { value?: unknown }; + }, + ); expect( recordedRequests.some( (message) => @@ -654,4 +660,122 @@ describe("AcpSessionRuntime", () => { Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), ); }); + + it.effect("skips authentication when the agent advertises no auth methods", () => { + const requestEvents: Array = []; + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + + expect(requestEvents.some((event) => event.method === "authenticate")).toBe(false); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); + + it.effect("authenticates with the requested method when it is advertised", () => { + const requestEvents: Array = []; + const advertisedAuthMethods = JSON.stringify([ + { id: "test", name: "Test" }, + { id: "other", name: "Other" }, + ]); + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + + const authenticateStarted = requestEvents.find( + (event) => event.method === "authenticate" && event.status === "started", + ); + expect(authenticateStarted?.payload).toMatchObject({ + methodId: "test", + }); + expect( + requestEvents.some( + (event) => event.method === "authenticate" && event.status === "succeeded", + ), + ).toBe(true); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_AUTH_METHODS: advertisedAuthMethods, + }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); + + it.effect("fails visibly when the requested auth method is not advertised", () => { + const requestEvents: Array = []; + const advertisedAuthMethods = JSON.stringify([ + { id: "other", name: "Other" }, + { id: "another", name: "Another" }, + ]); + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + const error = yield* runtime.start().pipe(Effect.flip); + + expect(error._tag).toBe("AcpRequestError"); + if (error._tag === "AcpRequestError") { + expect(error.code).toBe(-32602); + expect(error.message).toContain( + "Authentication method 'test' is not advertised by the agent", + ); + expect(error.message).toContain("other"); + expect(error.message).toContain("another"); + } + + expect(requestEvents.some((event) => event.method === "authenticate")).toBe(false); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_AUTH_METHODS: advertisedAuthMethods, + }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); }); diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index 7c317d6d44d3..44643c61ceaf 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -136,9 +136,9 @@ type AcpToolCallUpdate = Extract< { readonly sessionUpdate: "tool_call" | "tool_call_update" } >; -const MODEL_CONFIG_OPTION_IDS = new Set(["model", "models", "modelid", "modelids"]); +export const MODEL_CONFIG_OPTION_IDS = new Set(["model", "models", "modelid", "modelids"]); -function isModelConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { +export function isModelConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { if (option.category === "model") return true; const id = option.id .trim() diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index c5efacdcb1a8..c27d175d747d 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -548,20 +548,27 @@ export const make = ( const authMethods = initializeResult.authMethods ?? []; if (authMethods.length > 0) { const requestedAuthMethodId = options.authMethodId; - const effectiveAuthMethodId = - authMethods.find((method) => method.id === requestedAuthMethodId)?.id ?? - authMethods[0]?.id; - if (effectiveAuthMethodId !== undefined) { - const authenticatePayload = { - methodId: effectiveAuthMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; - - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); + const authMethod = authMethods.find((method) => method.id === requestedAuthMethodId); + if (authMethod === undefined) { + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: `Authentication method '${requestedAuthMethodId}' is not advertised by the agent. Available methods: ${authMethods.map((method) => method.id).join(", ")}`, + data: { + requestedAuthMethodId, + availableAuthMethodIds: authMethods.map((method) => method.id), + }, + }); } + + const authenticatePayload = { + methodId: authMethod.id, + } satisfies EffectAcpSchema.AuthenticateRequest; + + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); } let sessionId: string; diff --git a/apps/server/src/provider/acp/DevinAcpSupport.test.ts b/apps/server/src/provider/acp/DevinAcpSupport.test.ts index 9032d6fac285..b7b9557b1dd6 100644 --- a/apps/server/src/provider/acp/DevinAcpSupport.test.ts +++ b/apps/server/src/provider/acp/DevinAcpSupport.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import * as EffectAcpErrors from "effect-acp/errors"; import { DevinSettings, ProviderInstanceId } from "@t3tools/contracts"; import { @@ -97,7 +99,7 @@ describe("resolveDevinAcpModelSelection", () => { describe("applyDevinAcpModelSelection", () => { it.effect("sets the model through the model config option", () => Effect.gen(function* () { - const setModel = vi.fn().mockReturnValue(Effect.succeed(undefined)); + const setModel = vi.fn().mockReturnValue(Effect.void); const setConfigOption = vi.fn().mockReturnValue(Effect.succeed({ configOptions: [] })); const result = yield* applyDevinAcpModelSelection({ @@ -131,7 +133,7 @@ describe("applyDevinAcpModelSelection", () => { it.effect("sets reasoning through the effort config option when present", () => Effect.gen(function* () { - const setModel = vi.fn().mockReturnValue(Effect.succeed(undefined)); + const setModel = vi.fn().mockReturnValue(Effect.void); const setConfigOption = vi.fn().mockReturnValue(Effect.succeed({ configOptions: [] })); const result = yield* applyDevinAcpModelSelection({ @@ -176,7 +178,7 @@ describe("applyDevinAcpModelSelection", () => { it.effect("falls back to a variant slug when the family slug is not in the model list", () => Effect.gen(function* () { - const setModel = vi.fn().mockReturnValue(Effect.succeed(undefined)); + const setModel = vi.fn().mockReturnValue(Effect.void); const setConfigOption = vi.fn().mockReturnValue(Effect.succeed({ configOptions: [] })); yield* applyDevinAcpModelSelection({ @@ -205,7 +207,7 @@ describe("applyDevinAcpModelSelection", () => { it.effect("normalizes a legacy reasoning option value that contains the family slug", () => Effect.gen(function* () { - const setModel = vi.fn().mockReturnValue(Effect.succeed(undefined)); + const setModel = vi.fn().mockReturnValue(Effect.void); const setConfigOption = vi.fn().mockReturnValue(Effect.succeed({ configOptions: [] })); yield* applyDevinAcpModelSelection({ @@ -246,12 +248,184 @@ describe("applyDevinAcpModelSelection", () => { expect(setConfigOption).toHaveBeenCalledWith("effort", "medium"); }), ); + + it.effect("fails when no model config option is discovered and a model switch is needed", () => + Effect.gen(function* () { + const setModel = vi.fn().mockReturnValue(Effect.void); + const setConfigOption = vi.fn().mockReturnValue(Effect.succeed({ configOptions: [] })); + + const result = yield* applyDevinAcpModelSelection({ + runtime: { setModel, setConfigOption }, + current: undefined, + requested: { familySlug: "claude-opus-5", reasoningValue: "high" }, + configOptions: [ + { + id: "effort", + name: "Effort", + category: "thought_level", + type: "select", + currentValue: "default", + options: [ + { value: "default", name: "Default" }, + { value: "high", name: "High" }, + ], + }, + ], + mapError: (cause) => cause, + }).pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure._tag).toBe("AcpRequestError"); + expect(result.failure.message).toContain("no model session config option was found"); + } + expect(setModel).not.toHaveBeenCalled(); + expect(setConfigOption).not.toHaveBeenCalled(); + }), + ); + + it.effect("resets reasoning to default when the user requests the default reasoning value", () => + Effect.gen(function* () { + const setModel = vi.fn().mockReturnValue(Effect.void); + const setConfigOption = vi.fn().mockReturnValue(Effect.succeed({ configOptions: [] })); + + const result = yield* applyDevinAcpModelSelection({ + runtime: { setModel, setConfigOption }, + current: { familySlug: "claude-opus-5", reasoningValue: "high" }, + requested: { + familySlug: "claude-opus-5", + reasoningValue: "default", + }, + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "claude-opus-5", + options: [ + { value: "adaptive", name: "Adaptive" }, + { value: "claude-opus-5", name: "Claude Opus 5" }, + ], + }, + { + id: "effort", + name: "Effort", + category: "thought_level", + type: "select", + currentValue: "high", + options: [ + { value: "default", name: "Default" }, + { value: "high", name: "High" }, + ], + }, + ], + mapError: (cause) => cause, + }); + + expect(setModel).not.toHaveBeenCalled(); + expect(setConfigOption).toHaveBeenCalledWith("effort", "default"); + expect(result).toEqual({ + familySlug: "claude-opus-5", + reasoningValue: undefined, + }); + }), + ); + + it.effect("maps a reasoning synonym to an allowed reasoning config value", () => + Effect.gen(function* () { + const setModel = vi.fn().mockReturnValue(Effect.void); + const setConfigOption = vi.fn().mockReturnValue(Effect.succeed({ configOptions: [] })); + + const result = yield* applyDevinAcpModelSelection({ + runtime: { setModel, setConfigOption }, + current: undefined, + requested: { + familySlug: "claude-opus-5", + reasoningValue: "no-thinking", + }, + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "adaptive", + options: [ + { value: "adaptive", name: "Adaptive" }, + { value: "claude-opus-5", name: "Claude Opus 5" }, + ], + }, + { + id: "reasoning", + name: "Reasoning", + category: "thought_level", + type: "select", + currentValue: "low", + options: [ + { value: "none", name: "None" }, + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + ], + }, + ], + mapError: (cause) => cause, + }); + + expect(setModel).toHaveBeenCalledWith("claude-opus-5"); + expect(setConfigOption).toHaveBeenCalledWith("reasoning", "none"); + expect(result).toEqual({ + familySlug: "claude-opus-5", + reasoningValue: "none", + }); + }), + ); + + it.effect( + "uses the base model when default reasoning is requested and no reasoning config is present", + () => + Effect.gen(function* () { + const setModel = vi.fn().mockReturnValue(Effect.void); + const setConfigOption = vi.fn().mockReturnValue(Effect.succeed({ configOptions: [] })); + + const result = yield* applyDevinAcpModelSelection({ + runtime: { setModel, setConfigOption }, + current: undefined, + requested: { + familySlug: "claude-opus-5", + reasoningValue: "default", + }, + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "adaptive", + options: [ + { value: "adaptive", name: "Adaptive" }, + { value: "claude-opus-5", name: "Claude Opus 5" }, + ], + }, + ], + mapError: (cause) => cause, + }); + + expect(setModel).toHaveBeenCalledWith("claude-opus-5"); + expect(setConfigOption).not.toHaveBeenCalled(); + expect(result).toEqual({ + familySlug: "claude-opus-5", + reasoningValue: undefined, + }); + }), + ); }); describe("Devin reasoning variant synonyms", () => { it.effect("maps no-thinking to the none variant", () => Effect.gen(function* () { - const setModel = vi.fn().mockReturnValue(Effect.succeed(undefined)); + const setModel = vi.fn().mockReturnValue(Effect.void); const setConfigOption = vi.fn().mockReturnValue(Effect.succeed({ configOptions: [] })); yield* applyDevinAcpModelSelection({ @@ -283,7 +457,7 @@ describe("Devin reasoning variant synonyms", () => { it.effect("maps no-thinking-1m to the 1m variant", () => Effect.gen(function* () { - const setModel = vi.fn().mockReturnValue(Effect.succeed(undefined)); + const setModel = vi.fn().mockReturnValue(Effect.void); const setConfigOption = vi.fn().mockReturnValue(Effect.succeed({ configOptions: [] })); yield* applyDevinAcpModelSelection({ @@ -315,7 +489,7 @@ describe("Devin reasoning variant synonyms", () => { it.effect("maps lightning-medium to the lightning-medium variant", () => Effect.gen(function* () { - const setModel = vi.fn().mockReturnValue(Effect.succeed(undefined)); + const setModel = vi.fn().mockReturnValue(Effect.void); const setConfigOption = vi.fn().mockReturnValue(Effect.succeed({ configOptions: [] })); yield* applyDevinAcpModelSelection({ diff --git a/apps/server/src/provider/acp/DevinAcpSupport.ts b/apps/server/src/provider/acp/DevinAcpSupport.ts index a4167342267d..2154532f2eeb 100644 --- a/apps/server/src/provider/acp/DevinAcpSupport.ts +++ b/apps/server/src/provider/acp/DevinAcpSupport.ts @@ -9,14 +9,16 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; -import { collectSessionConfigOptionValues, findSessionConfigOption } from "./AcpRuntimeModel.ts"; +import { + collectSessionConfigOptionValues, + findSessionConfigOption, + isModelConfigOption, +} from "./AcpRuntimeModel.ts"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; const DEVIN_AUTH_METHOD_ID = "default"; const DEVIN_DRIVER_KIND = ProviderDriverKind.make("devin"); -const DEVIN_MODEL_CONFIG_OPTION_IDS = new Set(["model", "models", "modelid", "modelids"]); - const DEVIN_REASONING_CONFIG_OPTION_IDS = new Set(["effort", "thought_level", "reasoning"]); type DevinAcpRuntimeDevinSettings = Pick< @@ -140,10 +142,16 @@ function normalizeConfigIdToken(value: string): string { return value.toLowerCase().replace(/[\s_-]+/g, ""); } -function isDevinModelConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { - if (option.category === "model") return true; - const id = normalizeConfigIdToken(option.id); - return DEVIN_MODEL_CONFIG_OPTION_IDS.has(id); +function resolveDevinDefaultReasoningValue( + allowedReasoningValues: ReadonlyArray, +): string | undefined { + if (allowedReasoningValues.length === 0) { + return undefined; + } + const clearValue = ["default", "none", "no-thinking"].find((variant) => + allowedReasoningValues.includes(variant), + ); + return clearValue ?? allowedReasoningValues[0]; } function isDevinReasoningConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { @@ -166,7 +174,7 @@ function findDevinAcpModelConfigId( ): string | undefined { if (!configOptions) return undefined; for (const option of configOptions) { - if (isDevinModelConfigOption(option)) { + if (isModelConfigOption(option)) { return option.id; } } @@ -231,23 +239,43 @@ export function applyDevinAcpModelSelection(input: { const modelConfigId = findDevinAcpModelConfigId(input.configOptions); const reasoningConfigId = findDevinAcpReasoningConfigId(input.configOptions); + const requestedReasoningDefault = requested.reasoningValue === "default"; const requestedReasoning = - requested.reasoningValue === requested.familySlug || requested.reasoningValue === "default" + requested.reasoningValue === requested.familySlug || requestedReasoningDefault ? undefined : requested.reasoningValue; const needsModelSwitch = !current || requested.familySlug !== current.familySlug; const needsReasoningSwitch = reasoningConfigId !== undefined && - requestedReasoning !== undefined && - requestedReasoning !== current?.reasoningValue; + (requestedReasoningDefault || + (requestedReasoning !== undefined && requestedReasoning !== current?.reasoningValue)); if (!needsModelSwitch && !needsReasoningSwitch) { return Effect.succeed(current); } + let resultReasoningValue: string | undefined = requestedReasoningDefault + ? undefined + : requested.reasoningValue; + return Effect.gen(function* () { - if (needsModelSwitch && modelConfigId !== undefined) { + if (needsModelSwitch) { + if (modelConfigId === undefined) { + return yield* Effect.fail( + input.mapError( + new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: `Unable to set model: no model session config option was found`, + data: { + requestedModel: requested.familySlug, + configOptions: input.configOptions.map((o) => o.id), + }, + }), + ), + ); + } + const modelOption = findSessionConfigOption(input.configOptions, modelConfigId); const allowedModelValues = modelOption ? collectSessionConfigOptionValues(modelOption) : []; @@ -295,18 +323,30 @@ export function applyDevinAcpModelSelection(input: { const allowedReasoningValues = reasoningOption ? collectSessionConfigOptionValues(reasoningOption) : []; - const effectiveReasoning = - requestedReasoning && allowedReasoningValues.length > 0 - ? (allowedReasoningValues.find( - (value) => requestedReasoning === value || requestedReasoning.endsWith(`-${value}`), + const effectiveReasoning = requestedReasoningDefault + ? resolveDevinDefaultReasoningValue(allowedReasoningValues) + : requestedReasoning && allowedReasoningValues.length > 0 + ? (allowedReasoningValues.find((value) => + expandDevinReasoningVariants(requestedReasoning).some( + (variant) => variant === value || variant.endsWith(`-${value}`), + ), ) ?? requestedReasoning) : requestedReasoning; - yield* input.runtime - .setConfigOption(reasoningConfigId, effectiveReasoning) - .pipe(Effect.mapError(input.mapError)); + + if (effectiveReasoning !== undefined) { + yield* input.runtime + .setConfigOption(reasoningConfigId, effectiveReasoning) + .pipe(Effect.mapError(input.mapError)); + if (!requestedReasoningDefault) { + resultReasoningValue = effectiveReasoning; + } + } } - return requested; + return { + familySlug: requested.familySlug, + reasoningValue: resultReasoningValue, + }; }); } diff --git a/devin-models-list.txt b/apps/server/src/provider/testFixtures/devin-models-list.txt similarity index 99% rename from devin-models-list.txt rename to apps/server/src/provider/testFixtures/devin-models-list.txt index d87be0221bd0..2e32c1fe74b2 100644 --- a/devin-models-list.txt +++ b/apps/server/src/provider/testFixtures/devin-models-list.txt @@ -45,7 +45,7 @@ { "family_label": "GPT-5.2", "family_uid": "gpt-5-2", - "slug": "gpt-5-2", + "slug": "gpt-5.2", "variants": [ { "model_uid": "gpt-5-2-medium", "label": "GPT-5.2 Medium" }, { "model_uid": "gpt-5-2-none", "label": "GPT-5.2 No Thinking" }, diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 64673e96c090..c0d940b2fa61 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -50,6 +50,21 @@ describe("scan cache round trip", () => { expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl")); }); + it("restores devin records unchanged", () => { + const original: ScanCache = new Map(); + original.set("/devin/session.jsonl", { + size: 50, + mtimeMs: 100, + provider: "devin", + records: [record({ provider: "devin", model: "devin-preview" })], + }); + + const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); + + expect(restored.size).toBe(1); + expect(restored.get("/devin/session.jsonl")).toEqual(original.get("/devin/session.jsonl")); + }); + it("interns repeated model and session strings", () => { const encoded = encodeScanCache( cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" }), record()]]]), diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index cc15ee9cee62..a0d3ec643574 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -134,7 +134,7 @@ export function decodeScanCache(document: unknown): ScanCache { if (typeof raw !== "object" || raw === null) continue; const entry = raw as Partial; if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; - if (entry.p !== "claude" && entry.p !== "codex") continue; + if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "devin") continue; if (!isRecordArray(entry.r)) continue; const provider: UsageProviderKind = entry.p; diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 22c46ead914c..a83fa1035b42 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -213,8 +213,9 @@ export function ProviderModelsSection({ const capLabels: string[] = []; const isHidden = !model.isCustom && hiddenModelSet.has(model.slug); const isFavorite = favoriteModelSet.has(model.slug); - const previousModel = orderedModels[index - 1]; - const nextModel = orderedModels[index + 1]; + const orderedIndex = orderedModels.findIndex((ordered) => ordered.slug === model.slug); + const previousModel = orderedModels[orderedIndex - 1]; + const nextModel = orderedModels[orderedIndex + 1]; const canMoveUp = previousModel !== undefined && favoriteModelSet.has(previousModel.slug) === isFavorite; const canMoveDown = From ab6f2d85eab403e2aace1e35cf1bd37d28749ea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Fran=C3=A7a?= Date: Wed, 12 Aug 2026 10:59:02 -0300 Subject: [PATCH 05/18] fix(server): remove devin thread lock entries on session stop The Devin adapter's getThreadSemaphore inserted one Semaphore per threadId into threadLocksRef, but stopSessionInternal and stopAll never removed the entries. This caused unbounded memory growth for long-lived adapters. Remove the threadId from the map when the session stops so the lock table does not accumulate stale entries. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/server/src/provider/Layers/DevinAdapter.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/server/src/provider/Layers/DevinAdapter.ts b/apps/server/src/provider/Layers/DevinAdapter.ts index 490adc26d5e3..8f68eed9a44d 100644 --- a/apps/server/src/provider/Layers/DevinAdapter.ts +++ b/apps/server/src/provider/Layers/DevinAdapter.ts @@ -747,6 +747,11 @@ export function makeDevinAdapter(devinSettings: DevinSettings, options?: DevinAd } yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); sessions.delete(ctx.threadId); + yield* SynchronizedRef.update(threadLocksRef, (current) => { + const next = new Map(current); + next.delete(ctx.threadId); + return next; + }); yield* offerRuntimeEvent({ type: "session.exited", ...(yield* makeEventStamp()), From b7afac72c11fa68cf21c0d800d42b3e0aa1b9bf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Fran=C3=A7a?= Date: Wed, 12 Aug 2026 10:59:10 -0300 Subject: [PATCH 06/18] fix(web): show model description tooltip when description exists The hasDetails guard in ProviderModelsSection only checked capability labels and whether the model name differs from its slug, so models with only a description never triggered the info tooltip. Include a non-empty model.description in the guard so the tooltip renders. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/src/components/settings/ProviderModelsSection.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index a83fa1035b42..3a16074114a8 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -239,7 +239,10 @@ export function ProviderModelsSection({ ) { capLabels.push("Reasoning"); } - const hasDetails = capLabels.length > 0 || model.name !== model.slug; + const hasDetails = + capLabels.length > 0 || + model.name !== model.slug || + (model.description != null && model.description.length > 0); return (
Date: Wed, 12 Aug 2026 10:59:16 -0300 Subject: [PATCH 07/18] test(web): update Devin settings field order expectation deriveProviderSettingsFields now exposes binaryPath, homePath, launchArgs and permissionMode for the Devin provider, matching the schema order. Update the test expectation to match the visible fields. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/src/components/settings/ProviderSettingsForm.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index a29b1b2acb98..92b340be2d41 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -28,6 +28,8 @@ describe("ProviderSettingsForm helpers", () => { expect(devin).toBeDefined(); expect(deriveProviderSettingsFields(devin!).map((field) => field.key)).toEqual([ "binaryPath", + "homePath", + "launchArgs", "permissionMode", ]); From 02aa3f659fd93ee3390924420ce025f00878b76f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Fran=C3=A7a?= Date: Wed, 12 Aug 2026 12:41:11 -0300 Subject: [PATCH 08/18] feat(web,contracts): add Devin Desktop to editor picker Add Devin Desktop as an available editor option in the OpenInPicker menu and EDITORS registry. Include DevinIcon import, register "devin-desktop" command with "goto" launch style, and reformat EDITORS array for consistency. --- apps/web/src/components/chat/OpenInPicker.tsx | 7 ++ packages/contracts/src/editor.ts | 119 +++++++++++++++--- 2 files changed, 110 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 8b7a96880b81..b076ee1eb02b 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -9,6 +9,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu import { AntigravityIcon, CursorIcon, + DevinIcon, Icon, KiroIcon, TraeIcon, @@ -50,6 +51,12 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray; export const EditorId = Schema.Literals(EDITORS.map((e) => e.id)); From f379487adc58d5edba403513d88027ae0cf18551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Fran=C3=A7a?= Date: Thu, 13 Aug 2026 08:26:28 -0300 Subject: [PATCH 09/18] feat(marketing,server): add Devin to marketing page and improve adapter Add Devin logo SVG, display Devin alongside other AI tools in hero section and harness grid, update copy to include Devin in orchestration list. Adjust mobile layout to accommodate six harnesses. Refactor DevinAdapter to use Effect.fn wrapper, replace manual record guard with Schema-based DevinResume decoder, and improve type safety for resume parsing. --- apps/marketing/public/harnesses/devin.svg | 3 + apps/marketing/src/pages/index.astro | 40 +- .../src/provider/Layers/DevinAdapter.ts | 2345 ++++++++--------- .../Layers/DevinProvider.smoke.test.ts | 35 + .../src/provider/Layers/DevinProvider.test.ts | 109 +- .../src/provider/Layers/DevinProvider.ts | 198 +- .../src/provider/providerStatusCache.test.ts | 137 - .../src/provider/providerStatusCache.ts | 11 +- .../server/src/usage/usageTranscriptReader.ts | 54 +- apps/server/src/usage/usageTranscripts.ts | 269 +- docs/user/providers-devin.md | 11 +- 11 files changed, 1649 insertions(+), 1563 deletions(-) create mode 100644 apps/marketing/public/harnesses/devin.svg create mode 100644 apps/server/src/provider/Layers/DevinProvider.smoke.test.ts diff --git a/apps/marketing/public/harnesses/devin.svg b/apps/marketing/public/harnesses/devin.svg new file mode 100644 index 000000000000..adb472f66ad0 --- /dev/null +++ b/apps/marketing/public/harnesses/devin.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 20fae288279c..2e7aca1a5fc9 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -41,6 +41,9 @@ const mobileEndorsementRows = [ + + +
@@ -49,7 +52,7 @@ const mobileEndorsementRows = [

- Orchestrate Claude Code, Codex, OpenCode, Cursor, and Grok from one surface. + Orchestrate Claude Code, Codex, OpenCode, Cursor, Grok, and Devin from one surface. Bring your own subscription. Fork the whole thing.

@@ -228,6 +231,13 @@ const mobileEndorsementRows = [
grok login
+
+
+
+
Devin
+
devin auth login
+
+