diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 5eb69627f58d..22a6e0c4c377 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -59,6 +59,17 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "devin") { + return ( + + + + ); + } + // codex (and unknown drivers) return ( diff --git a/apps/server/src/provider/Drivers/DevinDriver.test.ts b/apps/server/src/provider/Drivers/DevinDriver.test.ts new file mode 100644 index 000000000000..110482c003d4 --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinDriver.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; +import { ProviderDriverKind } from "@t3tools/contracts"; + +import { BUILT_IN_DRIVERS } from "../builtInDrivers.ts"; +import { DevinDriver } from "./DevinDriver.ts"; + +const isDevinConfig = Schema.is(DevinDriver.configSchema); + +describe("DevinDriver", () => { + it("is registered as a built-in driver", () => { + expect(BUILT_IN_DRIVERS.includes(DevinDriver)).toBe(true); + }); + + it("has the devin driver kind", () => { + expect(DevinDriver.driverKind).toBe(ProviderDriverKind.make("devin")); + }); + + it("exposes the DevinSettings schema and a valid default config", () => { + const defaults = DevinDriver.defaultConfig(); + expect(isDevinConfig(defaults)).toBe(true); + expect(defaults.enabled).toBe(false); + expect(defaults.binaryPath).toBe("devin"); + }); + + it("supports multiple instances", () => { + expect(DevinDriver.metadata.supportsMultipleInstances).toBe(true); + }); +}); diff --git a/apps/server/src/provider/Drivers/DevinDriver.ts b/apps/server/src/provider/Drivers/DevinDriver.ts new file mode 100644 index 000000000000..5ed398075e25 --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinDriver.ts @@ -0,0 +1,164 @@ +import { DevinSettings, ProviderDriverKind } 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 { + buildInitialDevinProviderSnapshot, + checkDevinProviderStatus, + enrichDevinSnapshot, +} from "../Layers/DevinProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + type ProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { withInstanceIdentity } from "./instanceIdentity.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +import { resolveDevinRuntimeProfile } from "./DevinProfile.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; + +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 path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const serverConfig = yield* ServerConfig; + const { cwd } = serverConfig; + const baseEnv = mergeProviderInstanceEnvironment(environment); + const resolvedProfile = yield* resolveDevinRuntimeProfile({ + settings: config, + environment: baseEnv, + }); + const continuationIdentity: ProviderContinuationIdentity = { + driverKind: DRIVER_KIND, + continuationKey: resolvedProfile.identity, + }; + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies DevinSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: resolvedProfile.environment, + }); + + const adapter = yield* makeDevinAdapter(effectiveConfig, { + environment: resolvedProfile.environment, + instanceId, + attachmentsDir: serverConfig.attachmentsDir, + }); + const textGeneration = yield* makeDevinTextGeneration( + effectiveConfig, + resolvedProfile.environment, + ); + + const checkProvider = checkDevinProviderStatus( + effectiveConfig, + resolvedProfile.environment, + cwd, + ).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + 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/DevinProfile.ts b/apps/server/src/provider/Drivers/DevinProfile.ts new file mode 100644 index 000000000000..4ca0f9d74b36 --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinProfile.ts @@ -0,0 +1,90 @@ +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 interface ResolvedDevinRuntimeProfile { + /** Environment passed to every Devin process for this instance. */ + readonly environment: NodeJS.ProcessEnv; + /** Absolute path to the Devin config file, if configured. */ + readonly configPath?: string; + /** Stable continuation identity derived from the resolved profile. */ + readonly identity: string; +} + +const DEVIN_HOME_ENV = "DEVIN_HOME"; +const DEVIN_CONFIG_ENV = "DEVIN_CONFIG"; + +function resolveHomePath(path: Path.Path, value: string): string { + const expanded = value.trim() ? expandHomePath(value.trim()) : NodeOS.homedir(); + return path.resolve(expanded); +} + +function resolveConfigPath(path: Path.Path, value: string): string { + return path.resolve(expandHomePath(value.trim())); +} + +function buildProfileIdentity(input: { + readonly settings: DevinSettings; + readonly resolvedHomePath: string | undefined; + readonly resolvedConfigPath: string | undefined; + readonly environmentNames: ReadonlyArray; +}): string { + const parts = [ + `devin`, + `binary=${input.settings.binaryPath.trim()}`, + `home=${input.resolvedHomePath || "default"}`, + `config=${input.resolvedConfigPath || ""}`, + `agent=${input.settings.agentType.trim() || "default"}`, + `sandbox=${input.settings.sandbox}`, + `trust=${input.settings.respectWorkspaceTrust}`, + ...input.environmentNames.map((name) => `env:${name}`), + ]; + return parts.join("\0"); +} + +export const resolveDevinRuntimeProfile = Effect.fn("resolveDevinRuntimeProfile")( + function* (input: { + readonly settings: DevinSettings; + readonly environment?: NodeJS.ProcessEnv; + }): Effect.fn.Return { + const path = yield* Path.Path; + const settings = input.settings; + const baseEnv = input.environment ?? process.env; + + const resolvedHomePath = settings.homePath.trim() + ? resolveHomePath(path, settings.homePath) + : undefined; + + const resolvedConfigPath = settings.configPath.trim() + ? resolveConfigPath(path, settings.configPath) + : undefined; + + const next: NodeJS.ProcessEnv = { ...baseEnv }; + if (resolvedHomePath) { + next[DEVIN_HOME_ENV] = resolvedHomePath; + } + if (resolvedConfigPath) { + next[DEVIN_CONFIG_ENV] = resolvedConfigPath; + } + + const environmentNames = Object.entries(input.environment ?? {}) + .filter(([name]) => name.startsWith("DEVIN_") || name.startsWith("XDG_")) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, value]) => `${name}=${value ?? ""}`); + + return { + environment: next, + ...(resolvedConfigPath ? { configPath: resolvedConfigPath } : {}), + identity: buildProfileIdentity({ + settings, + resolvedHomePath, + resolvedConfigPath, + environmentNames, + }), + }; + }, +); 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..b62ea27f7cab --- /dev/null +++ b/apps/server/src/provider/Layers/DevinAdapter.test.ts @@ -0,0 +1,25 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { describe, expect } from "vite-plus/test"; +import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; + +import { makeDevinAdapter } from "../Layers/DevinAdapter.ts"; +import { DevinDriver } from "../Drivers/DevinDriver.ts"; + +describe("DevinAdapter", () => { + it.effect("can be constructed and reports no sessions initially", () => + Effect.gen(function* () { + const adapter = yield* makeDevinAdapter(DevinDriver.defaultConfig(), { + environment: process.env, + instanceId: ProviderInstanceId.make("devin-adapter-test"), + }); + + const has = yield* adapter.hasSession(ThreadId.make("unknown-thread")); + expect(has).toBe(false); + + const sessions = yield* adapter.listSessions(); + expect(sessions).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/Layers/DevinAdapter.ts b/apps/server/src/provider/Layers/DevinAdapter.ts new file mode 100644 index 000000000000..459993ea7fc4 --- /dev/null +++ b/apps/server/src/provider/Layers/DevinAdapter.ts @@ -0,0 +1,1238 @@ +import { + ApprovalRequestId, + type ChatAttachment, + type DevinSettings, + EventId, + ProviderDriverKind, + ProviderInstanceId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderSendTurnInput, + type ProviderSessionStartInput, + type ProviderTurnStartResult, + type ProviderUserInputAnswers, + RuntimeRequestId, + ThreadId, + TurnId, + type UserInputQuestion, +} from "@t3tools/contracts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Result from "effect/Result"; +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 * as NodeURL from "node:url"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; + +import * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { + type AcpParsedSessionEvent, + type AcpPermissionRequest, + parsePermissionRequest, +} from "../acp/AcpRuntimeModel.ts"; +import { + applyDevinAcpModelSelection, + buildDevinModelsFromSessionModelState, + currentDevinModelIdFromSessionSetup, + DEVIN_DEFAULT_MODEL_SLUG_PUBLIC, + makeDevinAcpRuntime, + resolveDevinAcpBaseModelId, + resolveDevinAcpMode, +} from "../acp/DevinAcpSupport.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { type ProviderThreadSnapshot } from "../Services/ProviderAdapter.ts"; +import { type DevinAdapterShape } from "../Services/DevinAdapter.ts"; + +const PROVIDER = ProviderDriverKind.make("devin"); +const DEVIN_PROMPT_TIMEOUT_MS = 600_000; + +interface DevinPendingApproval { + readonly request: EffectAcpSchema.RequestPermissionRequest; + readonly decision: Deferred.Deferred; + readonly turnId: TurnId | undefined; + readonly runtimeRequestId: RuntimeRequestId; + readonly permissionRequest: AcpPermissionRequest; + readonly detail: string; +} + +interface DevinPendingUserInput { + readonly request: EffectAcpSchema.ElicitationRequest; + readonly answers: Deferred.Deferred; + readonly turnId: TurnId | undefined; + readonly runtimeRequestId: RuntimeRequestId; +} + +interface DevinSessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly acpSessionId: string; + notificationFiber: Fiber.Fiber | undefined; + currentModelId: string | undefined; + protocolMap: Map; + activeItemId: string | undefined; + activeTurnId: TurnId | undefined; + turns: Array<{ id: TurnId; items: Array }>; + stopped: boolean; + pendingApprovals: Map; + pendingUserInputs: Map; +} + +export interface DevinAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly instanceId?: ProviderInstanceId; + readonly attachmentsDir?: string; +} + +export function makeDevinAdapter(devinSettings: DevinSettings, options?: DevinAdapterLiveOptions) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("devin"); + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + + const sessions = new Map(); + const pendingApprovalsByRequestId = new Map< + ApprovalRequestId, + DevinPendingApproval & { readonly threadId: ThreadId } + >(); + const pendingUserInputsByRequestId = new Map< + ApprovalRequestId, + DevinPendingUserInput & { readonly threadId: ThreadId } + >(); + 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 offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const mapAcpCallbackFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Devin ACP callback.", + cause, + }), + ), + ); + + const selectDevinAutoPermissionOptionId = ( + request: EffectAcpSchema.RequestPermissionRequest, + ): string | undefined => { + const allowAlways = request.options.find((option) => option.kind === "allow_always"); + if (allowAlways?.optionId.trim()) { + return allowAlways.optionId.trim(); + } + const allowOnce = request.options.find((option) => option.kind === "allow_once"); + return allowOnce?.optionId.trim() ?? request.options[0]?.optionId.trim(); + }; + + const selectDevinPermissionOptionId = ( + request: EffectAcpSchema.RequestPermissionRequest, + decision: ProviderApprovalDecision, + ): string | undefined => { + switch (decision) { + case "acceptAlways": + return request.options.find((option) => option.kind === "allow_always")?.optionId; + case "acceptForSession": + case "accept": + return ( + request.options.find((option) => option.kind === "allow_once")?.optionId ?? + request.options.find((option) => option.kind === "allow_always")?.optionId + ); + case "decline": + return ( + request.options.find((option) => option.kind === "reject_once")?.optionId ?? + request.options.find((option) => option.kind === "reject_always")?.optionId + ); + case "cancel": + return undefined; + } + }; + + const elicitQuestionsFromRequest = ( + request: Extract, + ): Array => { + const properties = request.requestedSchema.properties ?? {}; + return Object.entries(properties).map(([id, property]) => { + const options: Array<{ readonly label: string; readonly description: string }> = []; + if ("enum" in property && Array.isArray(property.enum)) { + for (const value of property.enum) { + const label = String(value); + options.push({ label, description: label }); + } + } else if ("oneOf" in property && Array.isArray(property.oneOf)) { + for (const option of property.oneOf) { + options.push({ label: option.title, description: option.title }); + } + } else if (property.type === "boolean") { + options.push({ label: "Yes", description: "Yes" }, { label: "No", description: "No" }); + } + const header = property.title?.trim() || id; + const question = property.description?.trim() || header; + return { + id, + header, + question, + options, + multiSelect: property.type === "array", + } satisfies UserInputQuestion; + }); + }; + + const buildAutoElicitationContent = ( + request: Extract, + ): Record => { + const properties = request.requestedSchema.properties ?? {}; + const content: Record = {}; + for (const [id, property] of Object.entries(properties)) { + if ("default" in property && property.default !== undefined && property.default !== null) { + content[id] = property.default as EffectAcpSchema.ElicitationContentValue; + continue; + } + switch (property.type) { + case "string": { + if ("enum" in property && Array.isArray(property.enum) && property.enum.length > 0) { + content[id] = property.enum[0]!; + } else if ( + "oneOf" in property && + Array.isArray(property.oneOf) && + property.oneOf.length > 0 + ) { + content[id] = property.oneOf[0]!.const; + } else { + content[id] = ""; + } + break; + } + case "integer": + case "number": { + content[id] = 0; + break; + } + case "boolean": { + content[id] = false; + break; + } + case "array": { + if ("default" in property && Array.isArray(property.default)) { + content[id] = property.default as ReadonlyArray; + } else { + content[id] = []; + } + break; + } + default: + content[id] = ""; + } + } + return content; + }; + + const contentBlockForAttachment = (attachment: ChatAttachment) => + Effect.gen(function* () { + const attachmentsDir = options?.attachmentsDir; + if (attachmentsDir === undefined) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "attachmentsDir is not configured; cannot send attachments.", + }); + } + const attachmentPath = resolveAttachmentPath({ + attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + switch (attachment.type) { + case "image": { + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + return { + type: "image" as const, + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + } satisfies EffectAcpSchema.ContentBlock; + } + case "file": + return { + type: "resource_link" as const, + name: attachment.name, + mimeType: attachment.mimeType, + size: attachment.sizeBytes, + uri: NodeURL.pathToFileURL(attachmentPath).href, + } satisfies EffectAcpSchema.ContentBlock; + default: + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Unsupported Devin attachment type '${attachment.type}'.`, + }); + } + }); + + 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 stopSessionInternal = (ctx: DevinSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) { + return; + } + ctx.stopped = true; + + for (const [requestId, pending] of ctx.pendingApprovals) { + yield* Deferred.succeed(pending.decision, "cancel" as const).pipe(Effect.ignore); + pendingApprovalsByRequestId.delete(requestId); + } + ctx.pendingApprovals.clear(); + + for (const [requestId, pending] of ctx.pendingUserInputs) { + const answers: ProviderUserInputAnswers = {}; + yield* Deferred.succeed(pending.answers, answers).pipe(Effect.ignore); + pendingUserInputsByRequestId.delete(requestId); + } + ctx.pendingUserInputs.clear(); + + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + if (sessions.get(ctx.threadId) === ctx) { + sessions.delete(ctx.threadId); + } + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.exited", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + payload: {}, + }); + }); + + const handleParsedEvent = (ctx: DevinSessionContext, event: AcpParsedSessionEvent) => + Effect.gen(function* () { + const stamp = yield* makeEventStamp(); + const turnId = ctx.activeTurnId; + const activeTurn = turnId ? ctx.turns.find((turn) => turn.id === turnId) : undefined; + + const appendToActiveTurn = (item: unknown) => { + if (activeTurn) { + activeTurn.items.push(item); + } + }; + + switch (event._tag) { + case "ModeChanged": + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + payload: { state: "ready", reason: `Mode ${event.modeId}` }, + }); + return; + case "AssistantItemStarted": + ctx.activeItemId = event.itemId; + appendToActiveTurn(event); + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + ctx.activeItemId = undefined; + appendToActiveTurn(event); + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "ContentDelta": + appendToActiveTurn(event); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + case "ToolCallUpdated": + appendToActiveTurn(event); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "PlanUpdated": + appendToActiveTurn(event); + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId, + payload: event.payload, + source: "acp.jsonrpc", + method: "session/update", + rawPayload: event.rawPayload, + }), + ); + return; + case "ConfigOptionsChanged": + case "AvailableCommandsChanged": + return; + } + }); + + const startSession = (input: ProviderSessionStartInput) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider && 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 resolvedConfigPath = devinSettings.configPath.trim() + ? path.resolve(expandHomePath(devinSettings.configPath)) + : undefined; + + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + + const resumeSessionId = + typeof input.resumeCursor === "string" && input.resumeCursor.trim().length > 0 + ? input.resumeCursor.trim() + : undefined; + + const acp = yield* makeDevinAcpRuntime({ + devinSettings: { + binaryPath: devinSettings.binaryPath, + agentType: devinSettings.agentType, + sandbox: devinSettings.sandbox, + respectWorkspaceTrust: devinSettings.respectWorkspaceTrust, + launchArgs: devinSettings.launchArgs, + resolvedConfigPath, + }, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + runtimeMode: input.runtimeMode, + clientInfo: { name: "t3-code", version: "0.0.0" }, + }).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, + }), + ), + ); + + // Handlers are registered before `acp.start()` so permission and + // elicitation requests that arrive during startup have a handler. + // They look up the live session context once it is published. + const pendingApprovals = new Map(); + const pendingUserInputs = new Map(); + + const handleRequestPermissionCallback = ( + request: EffectAcpSchema.RequestPermissionRequest, + ) => + mapAcpCallbackFailure( + Effect.gen(function* () { + const ctx = sessions.get(input.threadId); + const turnId = ctx?.activeTurnId; + + if (input.runtimeMode === "full-access") { + const autoOptionId = selectDevinAutoPermissionOptionId(request); + if (autoOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoOptionId, + }, + }; + } + } + + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + const permissionRequest = parsePermissionRequest(request); + const detail = + permissionRequest.detail ?? + (typeof request.sessionId === "string" + ? `Session ${request.sessionId}` + : "[unknown]"); + const approvalContext = { + request, + decision, + turnId, + runtimeRequestId, + permissionRequest, + detail, + }; + pendingApprovals.set(requestId, approvalContext); + if (ctx) { + ctx.pendingApprovals.set(requestId, approvalContext); + } + pendingApprovalsByRequestId.set(requestId, { + ...approvalContext, + threadId: input.threadId, + }); + + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + detail, + args: request, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: request, + }), + ); + + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + ctx?.pendingApprovals.delete(requestId); + pendingApprovalsByRequestId.delete(requestId); + + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + + const optionId = selectDevinPermissionOptionId(request, resolved); + if (resolved === "cancel" || optionId === undefined) { + return { outcome: { outcome: "cancelled" as const } }; + } + return { + outcome: { + outcome: "selected" as const, + optionId, + }, + }; + }), + ); + + const handleElicitationCallback = (request: EffectAcpSchema.ElicitationRequest) => + mapAcpCallbackFailure( + Effect.gen(function* () { + const ctx = sessions.get(input.threadId); + const turnId = ctx?.activeTurnId; + + if (input.runtimeMode === "full-access") { + if (request.mode === "url") { + return { action: { action: "decline" as const } }; + } + return { + action: { + action: "accept" as const, + content: buildAutoElicitationContent(request), + }, + }; + } + + if (request.mode === "url") { + return { action: { action: "decline" as const } }; + } + + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const answers = yield* Deferred.make(); + const userInputContext = { + request, + answers, + turnId, + runtimeRequestId, + }; + pendingUserInputs.set(requestId, userInputContext); + if (ctx) { + ctx.pendingUserInputs.set(requestId, userInputContext); + } + pendingUserInputsByRequestId.set(requestId, { + ...userInputContext, + threadId: input.threadId, + }); + + const questions = elicitQuestionsFromRequest(request); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "user-input.requested", + ...stamp, + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + payload: { questions }, + }); + + const resolved = yield* Deferred.await(answers); + pendingUserInputs.delete(requestId); + ctx?.pendingUserInputs.delete(requestId); + pendingUserInputsByRequestId.delete(requestId); + + const content = resolved as Record; + const resolvedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "user-input.resolved", + ...resolvedStamp, + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + payload: { answers: content }, + }); + + return { + action: { + action: "accept" as const, + content, + }, + }; + }), + ); + + yield* acp.handleRequestPermission(handleRequestPermissionCallback); + yield* acp.handleElicitation(handleElicitationCallback); + + const started = yield* acp.start().pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + + const modelState = started.sessionSetupResult.models; + const { protocolMap } = buildDevinModelsFromSessionModelState(modelState); + let currentModelId = currentDevinModelIdFromSessionSetup(started.sessionSetupResult); + + const initialModelId = resolveDevinAcpBaseModelId(devinModelSelection?.model); + const initialReasoningEffort = getModelSelectionStringOptionValue( + devinModelSelection, + "reasoningEffort", + ); + + if ( + initialModelId !== DEVIN_DEFAULT_MODEL_SLUG_PUBLIC || + initialReasoningEffort !== undefined + ) { + const next = yield* applyDevinAcpModelSelection({ + runtime: acp, + protocolMap, + currentModelId, + requestedModelId: initialModelId, + requestedReasoningEffort: initialReasoningEffort, + mapError: (context) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: context.step, + detail: context.cause.message, + cause: context.cause, + }), + }); + currentModelId = next; + } + + const modeState = started.sessionSetupResult.modes ?? (yield* acp.getModeState); + const desiredMode = resolveDevinAcpMode( + input.runtimeMode, + modeState?.availableModes, + "default", + ); + if (desiredMode) { + yield* acp.setMode(desiredMode).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/set_config_option", + detail: cause.message, + cause, + }), + ), + ); + } + + const createdAt = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd: input.cwd, + model: devinModelSelection?.model, + threadId: input.threadId, + resumeCursor: started.sessionId, + activeTurnId: undefined, + createdAt, + updatedAt: createdAt, + }; + + const ctx: DevinSessionContext = { + threadId: input.threadId, + session, + scope: sessionScope, + acp, + acpSessionId: started.sessionId, + notificationFiber: undefined, + currentModelId, + protocolMap, + activeItemId: undefined, + activeTurnId: undefined, + turns: [], + stopped: false, + pendingApprovals, + pendingUserInputs, + }; + + const nf = yield* Stream.runForEach(acp.getEvents(), (event) => + event._tag === "EventStreamBarrier" ? Effect.void : handleParsedEvent(ctx, event), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Devin runtime notification.", { cause }), + ), + Effect.forkIn(sessionScope), + ); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.started", + ...stamp, + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + const stamp2 = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...stamp2, + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready" }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const getSession = (threadId: ThreadId, _operation: string) => { + const ctx = sessions.get(threadId); + if (!ctx) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }), + ); + } + if (ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + }), + ); + } + return Effect.succeed(ctx); + }; + + const sendTurn = (input: ProviderSendTurnInput) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* getSession(input.threadId, "sendTurn"); + + if (!input.input?.trim() && (input.attachments ?? []).length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "input is required and must be non-empty when no attachments are provided.", + }); + } + + if (ctx.activeTurnId !== undefined) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Cannot start a new turn while another turn is active.", + }); + } + + const turnId = TurnId.make(yield* randomUUIDv4); + + const devinModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + + if (devinModelSelection) { + const requestedModelId = resolveDevinAcpBaseModelId(devinModelSelection.model); + const requestedReasoningEffort = getModelSelectionStringOptionValue( + devinModelSelection, + "reasoningEffort", + ); + const next = yield* applyDevinAcpModelSelection({ + runtime: ctx.acp, + protocolMap: ctx.protocolMap, + currentModelId: ctx.currentModelId, + requestedModelId, + requestedReasoningEffort, + mapError: (context) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: context.step, + detail: context.cause.message, + cause: context.cause, + }), + }); + ctx.currentModelId = next; + } + + const modeState = yield* ctx.acp.getModeState; + const desiredMode = resolveDevinAcpMode( + ctx.session.runtimeMode, + modeState?.availableModes, + input.interactionMode === "plan" ? "plan" : "default", + ); + if (desiredMode) { + yield* ctx.acp.setMode(desiredMode).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/set_config_option", + detail: cause.message, + cause, + }), + ), + ); + } + + const prompt: Array = []; + const text = input.input?.trim(); + if (text) { + prompt.push({ type: "text", text }); + } + for (const attachment of input.attachments ?? []) { + prompt.push(yield* contentBlockForAttachment(attachment)); + } + + const stamp = yield* makeEventStamp(); + ctx.activeTurnId = turnId; + ctx.session = { ...ctx.session, status: "running", activeTurnId: turnId }; + ctx.turns.push({ id: turnId, items: [] }); + ctx.turns.at(-1)?.items.push({ role: "user", content: prompt }); + + yield* offerRuntimeEvent({ + type: "turn.started", + ...stamp, + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model: ctx.currentModelId }, + }); + + const promptFiber = yield* ctx.acp.prompt({ prompt }).pipe( + Effect.timeoutOption(DEVIN_PROMPT_TIMEOUT_MS), + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + Effect.forkIn(ctx.scope), + ); + + yield* Effect.gen(function* () { + const promptResult = yield* Fiber.join(promptFiber).pipe(Effect.result); + const stamp2 = yield* makeEventStamp(); + if (Result.isSuccess(promptResult)) { + yield* Option.match(promptResult.success, { + onNone: () => + offerRuntimeEvent({ + type: "turn.completed", + ...stamp2, + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { state: "failed" }, + }), + onSome: (response) => { + const isCancelled = response.stopReason === "cancelled"; + return offerRuntimeEvent({ + type: "turn.completed", + ...stamp2, + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: isCancelled ? "cancelled" : "completed", + ...(response.stopReason ? { stopReason: response.stopReason } : {}), + }, + }); + }, + }); + } else { + const error = promptResult.failure; + const message = error.message; + yield* offerRuntimeEvent({ + type: "turn.completed", + ...stamp2, + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: "failed", + errorMessage: message, + }, + }); + } + const turn = ctx.turns.find((t) => t.id === turnId); + if (turn) { + if (Result.isSuccess(promptResult)) { + Option.match(promptResult.success, { + onNone: () => { + turn.items.push({ state: "timeout" }); + }, + onSome: (response) => { + turn.items.push(response); + }, + }); + } else { + const error = promptResult.failure; + turn.items.push({ state: "failed", errorMessage: error.message }); + } + } + + if (!ctx.stopped && ctx.activeTurnId === turnId) { + ctx.activeTurnId = undefined; + ctx.session = { ...ctx.session, status: "ready", activeTurnId: undefined }; + } + }).pipe(Effect.forkIn(ctx.scope)); + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.acpSessionId, + } satisfies ProviderTurnStartResult; + }), + ); + + const interruptTurn = (threadId: ThreadId, turnId?: TurnId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* getSession(threadId, "interruptTurn"); + if (turnId !== undefined && ctx.activeTurnId !== turnId) { + return; + } + if (ctx.activeTurnId === undefined) { + return; + } + yield* ctx.acp.cancel.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/cancel", + detail: cause.message, + cause, + }), + ), + ); + ctx.activeTurnId = undefined; + ctx.session = { ...ctx.session, status: "ready", activeTurnId: undefined }; + }), + ); + + const respondToRequest = ( + _threadId: ThreadId, + _requestId: ApprovalRequestId, + _decision: ProviderApprovalDecision, + ) => + Effect.gen(function* () { + const pending = pendingApprovalsByRequestId.get(_requestId); + if (!pending || pending.threadId !== _threadId) { + return; + } + const ctx = sessions.get(_threadId); + if (ctx?.stopped) { + pendingApprovalsByRequestId.delete(_requestId); + return; + } + yield* Deferred.succeed(pending.decision, _decision).pipe(Effect.ignore); + }); + + const respondToUserInput = ( + _threadId: ThreadId, + _requestId: ApprovalRequestId, + _answers: ProviderUserInputAnswers, + ) => + Effect.gen(function* () { + const pending = pendingUserInputsByRequestId.get(_requestId); + if (!pending || pending.threadId !== _threadId) { + return; + } + const ctx = sessions.get(_threadId); + if (ctx?.stopped) { + pendingUserInputsByRequestId.delete(_requestId); + return; + } + yield* Deferred.succeed(pending.answers, _answers).pipe(Effect.ignore); + }); + + const stopSession = (threadId: ThreadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* getSession(threadId, "stopSession"); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions = () => + Effect.sync(() => Array.from(sessions.values()).map((ctx) => ctx.session)); + + const hasSession = (threadId: ThreadId) => + Effect.sync(() => { + const ctx = sessions.get(threadId); + return ctx !== undefined && !ctx.stopped; + }); + + const readThread = (threadId: ThreadId) => + Effect.gen(function* () { + const ctx = yield* getSession(threadId, "readThread"); + return { + threadId, + turns: ctx.turns, + } satisfies ProviderThreadSnapshot; + }); + + const rollbackThread = (threadId: ThreadId, numTurns: number) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* getSession(threadId, "rollbackThread"); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + if (numTurns > ctx.turns.length) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: `Cannot roll back ${numTurns} turns; only ${ctx.turns.length} turns exist.`, + }); + } + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "Devin ACP sessions do not support provider-side rollback yet.", + }); + }), + ); + + const stopAll = () => + Effect.gen(function* () { + const snapshot = Array.from(sessions.values()); + for (const ctx of snapshot) { + if (ctx.stopped) { + continue; + } + if (sessions.get(ctx.threadId) !== ctx) { + continue; + } + yield* withThreadLock( + ctx.threadId, + Effect.gen(function* () { + if (sessions.get(ctx.threadId) !== ctx) { + return; + } + yield* stopSessionInternal(ctx); + }), + ); + } + }); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + 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..5143e96226c4 --- /dev/null +++ b/apps/server/src/provider/Layers/DevinProvider.test.ts @@ -0,0 +1,148 @@ +// @effect-diagnostics nodeBuiltinImport:off - resolves the mock ACP agent script path relative to this test file. +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +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 { + buildInitialDevinProviderSnapshot, + checkDevinProviderStatus, + parseDevinModelsListJson, +} from "./DevinProvider.ts"; + +const decodeDevinSettings = Schema.decodeSync(DevinSettings); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); + +const SAMPLE_MODELS_LIST_JSON = JSON.stringify({ + families: [ + { + family_label: "Claude Sonnet 4.6", + family_uid: "claude-sonnet-4.6", + slug: "claude-sonnet-4.6", + aliases: ["sonnet"], + variants: [ + { + model_uid: "claude-sonnet-4-6", + label: "Claude Sonnet 4.6", + is_new: false, + is_beta: false, + }, + { + model_uid: "claude-sonnet-4-6-thinking", + label: "Claude Sonnet 4.6 Thinking", + is_new: true, + is_beta: false, + }, + ], + }, + ], +}); + +describe("parseDevinModelsListJson", () => { + it("flattens family variants into provider models with aliases and new badges", () => { + const models = parseDevinModelsListJson(SAMPLE_MODELS_LIST_JSON); + expect(models).toHaveLength(2); + expect(models.map((model) => [model.slug, model.name, model.badge, model.aliases])).toEqual([ + [ + "claude-sonnet-4-6", + "Claude Sonnet 4.6", + undefined, + ["sonnet", "claude-sonnet-4.6", "claude-sonnet-4-6"], + ], + [ + "claude-sonnet-4-6-thinking", + "Claude Sonnet 4.6 Thinking", + "new", + ["sonnet", "claude-sonnet-4.6", "claude-sonnet-4-6-thinking"], + ], + ]); + }); + + it("returns an empty array for invalid JSON", () => { + expect(parseDevinModelsListJson("not json")).toEqual([]); + }); + + it("returns an empty array for unexpected shapes", () => { + expect(parseDevinModelsListJson(JSON.stringify({ families: "nope" }))).toEqual([]); + }); +}); + +describe("buildInitialDevinProviderSnapshot", () => { + it.effect("returns a disabled snapshot by default", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialDevinProviderSnapshot(decodeDevinSettings({})); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + }), + ); +}); + +it.layer(NodeServices.layer)("checkDevinProviderStatus", (it) => { + const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`; + + const writeFakeDevinCli = (input: { + readonly modelsOutput: string; + readonly acpWorks: boolean; + }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-devin-probe-" }); + const modelsPath = path.join(dir, "models.json"); + yield* fs.writeFileString(modelsPath, input.modelsOutput); + const devinPath = path.join(dir, "devin"); + const mockAgentPath = NodePath.resolve(__dirname, "../../../scripts/acp-mock-agent.ts"); + yield* fs.writeFileString( + devinPath, + [ + "#!/bin/sh", + 'case "$1" in', + ' --version) printf "devin 3000.6.12\\n"; exit 0;;', + ` models) shift; if [ "$1" = "list" ] && [ "$2" = "--format" ] && [ "$3" = "json" ]; then cat ${shellQuote(modelsPath)}; exit 0; fi; exit 1;;`, + input.acpWorks + ? ` acp) exec ${shellQuote(process.execPath)} ${shellQuote(mockAgentPath)};;` + : " acp) exit 1;;", + "esac", + "exit 1", + "", + ].join("\n"), + ); + yield* fs.chmod(devinPath, 0o755); + return devinPath; + }); + + it.effect( + "reports ready with CLI-discovered models when ACP initialize does not advertise models", + () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const devinPath = yield* writeFakeDevinCli({ + modelsOutput: SAMPLE_MODELS_LIST_JSON, + acpWorks: false, + }); + return yield* checkDevinProviderStatus( + decodeDevinSettings({ enabled: true, binaryPath: devinPath }), + process.env, + ); + }), + ); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.installed).toBe(true); + expect(snapshot.version).toBe("3000.6.12"); + expect(snapshot.auth).toEqual({ status: "authenticated" }); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "default", + "claude-sonnet-4-6", + "claude-sonnet-4-6-thinking", + ]); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/DevinProvider.ts b/apps/server/src/provider/Layers/DevinProvider.ts new file mode 100644 index 000000000000..bf8e9d94477c --- /dev/null +++ b/apps/server/src/provider/Layers/DevinProvider.ts @@ -0,0 +1,514 @@ +import { + type DevinSettings, + type ModelCapabilities, + type ServerProvider, + type ServerProviderAuth, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Path from "effect/Path"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HttpClient } from "effect/unstable/http"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { sessionModelStateFromInitialize } from "../acp/AcpRuntimeModel.ts"; +import { + buildDevinModelsFromSessionModelState, + DEVIN_DEFAULT_MODEL_SLUG_PUBLIC, + makeDevinAcpRuntime, + resolveDevinAcpBaseModelId, + type DevinAcpRuntimeSettings, +} from "../acp/DevinAcpSupport.ts"; +import { + resolveDevinRuntimeProfile, + type ResolvedDevinRuntimeProfile, +} from "../Drivers/DevinProfile.ts"; + +const DEVIN_PRESENTATION = { + displayName: "Devin", + badgeLabel: "Early Access", + showInteractionModeToggle: true, +} as const; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const DEVIN_ACP_INITIALIZE_TIMEOUT_MS = 8_000; +const DEVIN_MODELS_LIST_TIMEOUT_MS = 10_000; + +const DEVIN_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: DEVIN_DEFAULT_MODEL_SLUG_PUBLIC, + name: "Devin Default", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +function devinModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = DEVIN_BUILT_IN_MODELS, +): ReadonlyArray { + return providerModelsFromSettings(builtInModels, customModels ?? [], 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...", + }, + }); + }); +} + +const runDevinCliCommand = ( + devinSettings: DevinSettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv, + cwd: string, +) => + Effect.gen(function* () { + const command = devinSettings.binaryPath.trim() || "devin"; + const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + cwd, + }), + ); + }); + +const discoverDevinModelsViaAcpInitialize = ( + devinSettings: DevinSettings, + resolvedProfile: ResolvedDevinRuntimeProfile, + cwd: string, +): Effect.Effect< + ReadonlyArray, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto +> => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acp = yield* makeDevinAcpRuntime({ + childProcessSpawner, + devinSettings: { + binaryPath: devinSettings.binaryPath, + agentType: devinSettings.agentType, + sandbox: devinSettings.sandbox, + respectWorkspaceTrust: devinSettings.respectWorkspaceTrust, + launchArgs: devinSettings.launchArgs, + resolvedConfigPath: resolvedProfile.configPath, + } satisfies DevinAcpRuntimeSettings, + environment: resolvedProfile.environment, + cwd, + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + const initialized = yield* acp.initialize(); + return buildDevinModelsFromSessionModelState(sessionModelStateFromInitialize(initialized)) + .models; + }).pipe( + Effect.scoped, + Effect.orElseSucceed(() => []), + Effect.timeoutOption(DEVIN_ACP_INITIALIZE_TIMEOUT_MS), + Effect.map((option) => Option.getOrElse(option, () => [])), + ); + +interface DevinModelsListVariant { + readonly model_uid: string; + readonly label: string; + readonly is_new?: boolean | undefined; + readonly is_beta?: boolean | undefined; +} + +interface DevinModelsListFamily { + readonly family_uid: string; + readonly family_label: string; + readonly slug: string; + readonly aliases?: ReadonlyArray | undefined; + readonly variants: ReadonlyArray; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isStringArray(value: unknown): value is ReadonlyArray { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function isModelsListVariant(value: unknown): value is DevinModelsListVariant { + if (!isRecord(value)) return false; + const modelUid = nonEmptyString(value.model_uid); + const label = nonEmptyString(value.label); + if (modelUid === undefined || label === undefined) return false; + const isNew = value.is_new; + const isBeta = value.is_beta; + if (isNew !== undefined && typeof isNew !== "boolean") return false; + if (isBeta !== undefined && typeof isBeta !== "boolean") return false; + return true; +} + +function isModelsListFamily(value: unknown): value is DevinModelsListFamily { + if (!isRecord(value)) return false; + if ( + nonEmptyString(value.family_uid) === undefined || + nonEmptyString(value.family_label) === undefined || + nonEmptyString(value.slug) === undefined + ) { + return false; + } + const aliases = value.aliases; + const variants = value.variants; + if (!Array.isArray(variants) || !variants.every(isModelsListVariant)) return false; + if (aliases !== undefined && !isStringArray(aliases)) return false; + return true; +} + +function buildDevinModelAliases( + variant: DevinModelsListVariant, + family: DevinModelsListFamily, +): ReadonlyArray | undefined { + const seen = new Set(); + const aliases: Array = []; + const push = (value: string | undefined) => { + const trimmed = value?.trim(); + if (!trimmed || seen.has(trimmed)) return; + seen.add(trimmed); + aliases.push(trimmed); + }; + if (family.aliases) { + for (const alias of family.aliases) { + push(alias); + } + } + push(family.slug); + push(family.family_uid); + push(variant.model_uid); + return aliases.length > 0 ? aliases : undefined; +} + +export function parseDevinModelsListJson(raw: string): ReadonlyArray { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!isRecord(parsed) || !Array.isArray(parsed.families)) return []; + + const models: Array = []; + const seen = new Set(); + for (const familyCandidate of parsed.families) { + if (!isModelsListFamily(familyCandidate)) continue; + const family = familyCandidate; + for (const variant of family.variants) { + const slug = resolveDevinAcpBaseModelId(variant.model_uid); + if (seen.has(slug)) continue; + seen.add(slug); + models.push({ + slug, + name: variant.label, + isCustom: false, + ...(variant.is_new ? { badge: "new" as const } : {}), + aliases: buildDevinModelAliases(variant, family), + capabilities: EMPTY_CAPABILITIES, + }); + } + } + return models; +} + +const discoverDevinModelsViaModelsList = ( + devinSettings: DevinSettings, + environment: NodeJS.ProcessEnv, + cwd: string, +) => + Effect.gen(function* () { + const listResult = yield* runDevinCliCommand( + devinSettings, + ["models", "list", "--format", "json"], + environment, + cwd, + ).pipe(Effect.timeoutOption(DEVIN_MODELS_LIST_TIMEOUT_MS), Effect.result); + + if (Result.isFailure(listResult)) { + yield* Effect.logWarning("Devin models list probe failed.", { + errorTag: listResult.failure._tag, + }); + return []; + } + + if (Option.isNone(listResult.success)) { + yield* Effect.logWarning("Devin models list probe timed out."); + return []; + } + + const listOutput = listResult.success.value; + if (listOutput.code !== 0) { + yield* Effect.logWarning("Devin models list probe exited with a non-zero status.", { + exitCode: listOutput.code, + stdoutLength: listOutput.stdout.length, + stderrLength: listOutput.stderr.length, + }); + return []; + } + + const models = parseDevinModelsListJson(listOutput.stdout); + if (models.length === 0) { + yield* Effect.logWarning("Devin models list probe returned no models."); + } + return models; + }).pipe(Effect.orElseSucceed(() => [])); + +export const checkDevinProviderStatus = Effect.fn("checkDevinProviderStatus")(function* ( + devinSettings: DevinSettings, + environment: NodeJS.ProcessEnv = process.env, + cwd?: string, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Path.Path +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const resolvedProfile = yield* resolveDevinRuntimeProfile({ + settings: devinSettings, + environment, + }); + const workCwd = cwd ?? process.cwd(); + 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* runDevinCliCommand( + devinSettings, + ["--version"], + resolvedProfile.environment, + workCwd, + ).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 acpExit = yield* discoverDevinModelsViaAcpInitialize( + devinSettings, + resolvedProfile, + workCwd, + ).pipe(Effect.exit); + const acpModels = Exit.isSuccess(acpExit) ? acpExit.value : []; + + const cliExit = + acpModels.length === 0 + ? yield* discoverDevinModelsViaModelsList( + devinSettings, + resolvedProfile.environment, + workCwd, + ).pipe(Effect.exit) + : Exit.succeed([]); + const cliModels = Exit.isSuccess(cliExit) ? cliExit.value : []; + + const discoveredModels = acpModels.length > 0 ? acpModels : cliModels; + const discoveredViaCli = acpModels.length === 0 && cliModels.length > 0; + const modelDiscoveryFailed = discoveredModels.length === 0; + + if (modelDiscoveryFailed) { + yield* Effect.logWarning( + "Devin ACP initialize and models list probe failed or returned no models.", + { + acpErrorTag: Exit.isFailure(acpExit) + ? (acpExit.cause as { _tag?: string })?._tag + : "NoModels", + cliErrorTag: Exit.isFailure(cliExit) + ? (cliExit.cause as { _tag?: string })?._tag + : "NoModels", + }, + ); + } + + const auth: ServerProviderAuth = discoveredViaCli + ? { status: "authenticated" } + : { status: "unknown" }; + + const mergedBuiltInModels = (() => { + const builtIn = [...DEVIN_BUILT_IN_MODELS]; + const seen = new Set(builtIn.map((m) => m.slug)); + for (const model of discoveredModels) { + if (!seen.has(model.slug)) { + seen.add(model.slug); + builtIn.push(model); + } + } + return builtIn; + })(); + const models = devinModelsFromSettings(devinSettings.customModels, mergedBuiltInModels); + + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: devinSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version, + status: modelDiscoveryFailed ? "warning" : "ready", + auth, + ...(modelDiscoveryFailed + ? { + message: + "Devin CLI is installed but ACP initialize did not advertise models. Model options may be incomplete.", + } + : {}), + }, + }); +}); + +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/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 08650758c308..178385e3d72a 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2216,6 +2216,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "claudeAgent", "codex", "cursor", + "devin", "grok", "opencode", ]); diff --git a/apps/server/src/provider/Services/DevinAdapter.ts b/apps/server/src/provider/Services/DevinAdapter.ts new file mode 100644 index 000000000000..3a4fd43f1b4c --- /dev/null +++ b/apps/server/src/provider/Services/DevinAdapter.ts @@ -0,0 +1,14 @@ +/** + * DevinAdapter — shape type for the Devin provider adapter. + * + * The driver model ({@link ../Drivers/DevinDriver}) bundles one adapter per + * instance as a captured closure, so this module only retains the shape + * interface as a naming anchor for the driver bundle. + */ +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/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index c680143e4053..2266644e726a 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -108,6 +108,16 @@ export type AcpParsedSessionEvent = readonly itemId?: string; readonly text: string; readonly rawPayload: unknown; + } + | { + readonly _tag: "AvailableCommandsChanged"; + readonly availableCommands: ReadonlyArray; + readonly rawPayload: unknown; + } + | { + readonly _tag: "ConfigOptionsChanged"; + readonly configOptions: ReadonlyArray; + readonly rawPayload: unknown; }; type AcpSessionSetupResponse = @@ -838,6 +848,24 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat } break; } + case "available_commands_update": { + if (Array.isArray(upd.availableCommands) && upd.availableCommands.length > 0) { + events.push({ + _tag: "AvailableCommandsChanged", + availableCommands: upd.availableCommands, + rawPayload: params, + }); + } + break; + } + case "config_option_update": { + events.push({ + _tag: "ConfigOptionsChanged", + configOptions: Array.isArray(upd.configOptions) ? upd.configOptions : [], + rawPayload: params, + }); + break; + } default: break; } diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.test.ts b/apps/server/src/provider/acp/AcpSessionRuntime.test.ts new file mode 100644 index 000000000000..48e18f7a28fa --- /dev/null +++ b/apps/server/src/provider/acp/AcpSessionRuntime.test.ts @@ -0,0 +1,67 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { describe, expect } from "vite-plus/test"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const mockAgentCommand = "node"; +const mockAgentArgs = [mockAgentPath]; + +describe("AcpSessionRuntime on-demand authentication", () => { + it.effect("skips authenticate during start and authenticates on first prompt failure", () => { + const requestEvents: Array = []; + + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + const started = yield* runtime.start(); + + expect(started.sessionId).toBe("mock-session-1"); + + const promptError = yield* runtime + .prompt({ prompt: [{ type: "text", text: "hi" }] }) + .pipe(Effect.flip); + + expect(promptError._tag).toBe("AcpRequestError"); + + const methods = requestEvents.map((event) => event.method); + // With on-demand auth, start() must not call authenticate. + expect(methods.indexOf("authenticate")).toBeGreaterThan(methods.indexOf("session/new")); + + // The authenticate request must have been issued after the failed prompt. + const authenticateEvents = requestEvents.filter( + (event) => event.method === "authenticate" && event.status === "started", + ); + expect(authenticateEvents.length).toBe(1); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_FAIL_PROMPT: "1", + }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + authenticationMode: "on-demand", + isAuthenticationFailure: () => true, + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); +}); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 14ef1540fb8d..713553847426 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -77,6 +77,18 @@ export interface AcpSessionRuntimeOptions { readonly version: string; }; readonly authMethodId: string; + /** + * When `startup` (default) the runtime calls `authenticate` during + * `start()`. When `on-demand` the call is deferred until a prompt returns + * an authentication failure, so saved credentials can proceed without an + * explicit login step. + */ + readonly authenticationMode?: "startup" | "on-demand"; + /** + * Optional predicate to identify an authentication failure. Defaults to + * treating AcpRequestError with code -32000 as an auth error. + */ + readonly isAuthenticationFailure?: (error: EffectAcpErrors.AcpError) => boolean; readonly mcpServers?: ReadonlyArray; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { @@ -205,6 +217,8 @@ export class AcpSessionRuntime extends Context.Service< readonly getModeState: Effect.Effect; /** Latest configuration options observed from session setup and configuration writes. */ readonly getConfigOptions: Effect.Effect>; + /** Latest available slash commands observed from session setup and `session/update` notifications. */ + readonly getAvailableCommands: Effect.Effect>; /** * Sends a prompt turn to the active session. `options.dispatched` settles once the * `session/prompt` RPC is registered as the active prompt, so a caller that forks this @@ -313,16 +327,40 @@ export const make = ( ); const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); + const availableCommandsRef = yield* Ref.make>( + [], + ); const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); const promptSerializationSemaphore = yield* Semaphore.make(1); + const startupUpdateSemaphore = yield* Semaphore.make(1); const activePromptFiberRef = yield* Ref.make< Option.Option> >(Option.none()); const sessionLoadGateRef = yield* Ref.make>(Option.none()); + const pendingStartupUpdatesRef = yield* Ref.make< + ReadonlyArray + >([]); const logRequest = (event: AcpSessionRequestLogEvent) => options.requestLogger ? options.requestLogger(event) : Effect.void; + const defaultIsAuthenticationFailure = (error: EffectAcpErrors.AcpError): boolean => + error._tag === "AcpRequestError" && error.code === -32000; + + const isAuthenticationFailure = (error: EffectAcpErrors.AcpError): boolean => + options.isAuthenticationFailure?.(error) ?? defaultIsAuthenticationFailure(error); + + const isStateBearingSessionUpdate = ( + notification: EffectAcpSchema.SessionNotification, + ): boolean => { + const update = notification.update; + return ( + update.sessionUpdate === "available_commands_update" || + update.sessionUpdate === "config_option_update" || + update.sessionUpdate === "current_mode_update" + ); + }; + const runLoggedRequest = ( method: string, payload: unknown, @@ -390,40 +428,50 @@ export const make = ( const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); yield* acp.handleSessionUpdate((notification) => - Effect.gen(function* () { - const gate = yield* Ref.get(sessionLoadGateRef); - if (Option.isSome(gate) && gate.value.active) { - const lastActivityAtMillis = yield* Clock.currentTimeMillis; - yield* Ref.set( - sessionLoadGateRef, - Option.some({ - ...gate.value, - lastActivityAtMillis, - }), - ); - return; - } - if (sessionUpdateIsReplay(notification)) { - return; - } - const startState = yield* Ref.get(startStateRef); - // One runtime projects one root ACP session. Child-session updates need - // explicit lineage routing and must never be flattened into this stream. - if ( - startState._tag !== "Started" || - notification.sessionId !== startState.result.sessionId - ) { - return; - } - yield* handleSessionUpdate({ - queue: eventQueue, - modeStateRef, - toolCallsRef, - assistantSegmentRef, - assistantItemRuntimeId, - params: notification, - }); - }), + startupUpdateSemaphore.withPermit( + Effect.gen(function* () { + const gate = yield* Ref.get(sessionLoadGateRef); + if (Option.isSome(gate) && gate.value.active) { + const lastActivityAtMillis = yield* Clock.currentTimeMillis; + yield* Ref.set( + sessionLoadGateRef, + Option.some({ + ...gate.value, + lastActivityAtMillis, + }), + ); + return; + } + if (sessionUpdateIsReplay(notification)) { + return; + } + const startState = yield* Ref.get(startStateRef); + // One runtime projects one root ACP session. Child-session updates need + // explicit lineage routing and must never be flattened into this stream. + if ( + startState._tag !== "Started" || + notification.sessionId !== startState.result.sessionId + ) { + // Retain state-bearing updates during startup so session setup + // can replay them once the session id is known. + if (startState._tag === "Starting" && isStateBearingSessionUpdate(notification)) { + yield* Ref.update(pendingStartupUpdatesRef, (pending) => [...pending, notification]); + } + return; + } + yield* applySessionUpdate({ + queue: eventQueue, + modeStateRef, + configOptionsRef, + availableCommandsRef, + toolCallsRef, + assistantSegmentRef, + assistantItemRuntimeId, + sessionId: startState.result.sessionId, + params: notification, + }); + }), + ), ); const initializeClientCapabilities = { fs: { @@ -568,11 +616,13 @@ export const make = ( methodId: options.authMethodId, } satisfies EffectAcpSchema.AuthenticateRequest; - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); + if (options.authenticationMode !== "on-demand") { + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + } let sessionId: string; let sessionSetupResult: @@ -669,6 +719,7 @@ export const make = ( yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); + yield* Ref.set(availableCommandsRef, availableCommandsFromSetup(sessionSetupResult)); const nextState = { sessionId, @@ -679,6 +730,29 @@ export const make = ( return nextState; }); + const applyPendingStartupUpdates = (started: AcpStartedState): Effect.Effect => + Ref.modify(pendingStartupUpdatesRef, (pending) => [pending, []]).pipe( + Effect.flatMap((pending) => + pending.length === 0 + ? Effect.void + : Effect.forEach(pending, (notification) => + notification.sessionId === started.sessionId + ? applySessionUpdate({ + queue: eventQueue, + modeStateRef, + configOptionsRef, + availableCommandsRef, + toolCallsRef, + assistantSegmentRef, + assistantItemRuntimeId, + sessionId: started.sessionId, + params: notification, + }) + : Effect.void, + ).pipe(Effect.asVoid), + ), + ); + const start = Effect.gen(function* () { const deferred = yield* Deferred.make< AcpSessionRuntimeStartResult, @@ -694,8 +768,12 @@ export const make = ( return [ startOnce.pipe( Effect.tap((result) => - Ref.set(startStateRef, { _tag: "Started", result }).pipe( - Effect.andThen(Deferred.succeed(deferred, result)), + startupUpdateSemaphore.withPermit( + Effect.gen(function* () { + yield* applyPendingStartupUpdates(result); + yield* Ref.set(startStateRef, { _tag: "Started", result }); + yield* Deferred.succeed(deferred, result); + }), ), ), Effect.onError((cause) => @@ -740,6 +818,7 @@ export const make = ( }), getModeState: Ref.get(modeStateRef), getConfigOptions: Ref.get(configOptionsRef), + getAvailableCommands: Ref.get(availableCommandsRef), prompt: (payload, promptOptions?) => promptSerializationSemaphore.withPermit( Effect.gen(function* () { @@ -755,31 +834,57 @@ export const make = ( const cancelledResponse = { stopReason: "cancelled", } satisfies EffectAcpSchema.PromptResponse; - const promptRpcFiber = yield* runLoggedRequest( - "session/prompt", - requestPayload, - acp.agent.prompt(requestPayload), - ).pipe(Effect.forkIn(runtimeScope)); - yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber)); - if (promptOptions?.dispatched) { - yield* Deferred.succeed(promptOptions.dispatched, undefined); - } - return yield* Fiber.join(promptRpcFiber).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.succeed(cancelledResponse) - : Effect.failCause(cause), - ), - Effect.ensuring( + + const promptOnce = Effect.gen(function* () { + const promptRpcFiber = yield* runLoggedRequest( + "session/prompt", + requestPayload, + acp.agent.prompt(requestPayload), + ).pipe(Effect.forkIn(runtimeScope)); + yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber)); + if (promptOptions?.dispatched) { + yield* Deferred.succeed(promptOptions.dispatched, undefined); + } + return yield* Fiber.join(promptRpcFiber).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.succeed(cancelledResponse) + : Effect.failCause(cause), + ), + Effect.ensuring( + Effect.gen(function* () { + yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore); + yield* Ref.set(activePromptFiberRef, Option.none()); + }), + ), + Effect.tap(() => + closeActiveAssistantSegment({ + queue: eventQueue, + assistantSegmentRef, + }), + ), + ); + }); + + return yield* promptOnce.pipe( + Effect.catchIf(isAuthenticationFailure, (error) => Effect.gen(function* () { - yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore); - yield* Ref.set(activePromptFiberRef, Option.none()); - }), - ), - Effect.tap(() => - closeActiveAssistantSegment({ - queue: eventQueue, - assistantSegmentRef, + yield* Effect.logWarning( + "ACP prompt rejected, performing on-demand authentication", + { + errorTag: error._tag, + errorMessage: error.message, + }, + ); + const authenticatePayload = { + methodId: options.authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + return yield* promptOnce; }), ), ); @@ -855,6 +960,18 @@ function sessionConfigOptionsFromSetup( return response?.configOptions ?? []; } +function availableCommandsFromSetup( + response: unknown, +): ReadonlyArray { + if (response === null || typeof response !== "object" || Array.isArray(response)) { + return []; + } + const commands = (response as { readonly availableCommands?: unknown }).availableCommands; + return Array.isArray(commands) + ? (commands as ReadonlyArray) + : []; +} + function configOptionCurrentValueMatches( configOption: EffectAcpSchema.SessionConfigOption, value: string | boolean, @@ -869,22 +986,31 @@ function configOptionCurrentValueMatches( return currentValue.trim() === String(value).trim(); } -const handleSessionUpdate = ({ +const applySessionUpdate = ({ queue, modeStateRef, + configOptionsRef, + availableCommandsRef, toolCallsRef, assistantSegmentRef, assistantItemRuntimeId, + sessionId, params, }: { readonly queue: Queue.Queue; readonly modeStateRef: Ref.Ref; + readonly configOptionsRef: Ref.Ref>; + readonly availableCommandsRef: Ref.Ref>; readonly toolCallsRef: Ref.Ref>; readonly assistantSegmentRef: Ref.Ref; readonly assistantItemRuntimeId: string; + readonly sessionId: string; readonly params: EffectAcpSchema.SessionNotification; }): Effect.Effect => Effect.gen(function* () { + if (params.sessionId !== sessionId) { + return; + } const parsed = parseSessionUpdateEvent(params); if (parsed.modeId) { yield* Ref.update(modeStateRef, (current) => @@ -892,6 +1018,14 @@ const handleSessionUpdate = ({ ); } for (const event of parsed.events) { + if (event._tag === "ConfigOptionsChanged") { + yield* Ref.set(configOptionsRef, event.configOptions); + continue; + } + if (event._tag === "AvailableCommandsChanged") { + yield* Ref.set(availableCommandsRef, event.availableCommands); + continue; + } if (event._tag === "ToolCallUpdated") { yield* closeActiveAssistantSegment({ queue, @@ -941,7 +1075,7 @@ const handleSessionUpdate = ({ const itemId = yield* ensureActiveAssistantSegment({ queue, assistantSegmentRef, - sessionId: params.sessionId, + sessionId, assistantItemRuntimeId, }); yield* Queue.offer(queue, { diff --git a/apps/server/src/provider/acp/DevinAcpSupport.ts b/apps/server/src/provider/acp/DevinAcpSupport.ts new file mode 100644 index 000000000000..37f7762e3239 --- /dev/null +++ b/apps/server/src/provider/acp/DevinAcpSupport.ts @@ -0,0 +1,455 @@ +import { + ProviderDriverKind, + type DevinSettings, + type ModelCapabilities, + type RuntimeMode, + type ServerProviderModel, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; +import { createModelCapabilities, normalizeModelSlug } from "@t3tools/shared/model"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const DEVIN_DRIVER_KIND = ProviderDriverKind.make("devin"); +const DEVIN_DEFAULT_MODEL_SLUG = "default"; +const DEVIN_PERMISSION_MODE_ENV = "DEVIN_PERMISSION_MODE"; + +/** + * T3's built-in default Devin model slug. Selecting it means "use whatever model + * the active Devin session is currently configured for". + */ +export const DEVIN_DEFAULT_MODEL_SLUG_PUBLIC = DEVIN_DEFAULT_MODEL_SLUG; + +export interface DevinAcpRuntimeSettings extends Pick< + DevinSettings, + "binaryPath" | "agentType" | "sandbox" | "respectWorkspaceTrust" | "launchArgs" +> { + /** Absolute path to a Devin config file, if configured. */ + readonly resolvedConfigPath?: string | undefined; +} + +interface DevinModeAliasSet { + readonly primary: string; + readonly aliases: ReadonlyArray; +} + +const DEVIN_MODE_ALIASES: { + readonly [K in "normal" | "acceptEdits" | "smart" | "plan" | "bypass"]: DevinModeAliasSet; +} = { + normal: { primary: "normal", aliases: ["normal"] }, + acceptEdits: { + primary: "accept-edits", + aliases: ["accept-edits", "accept edits", "accept_edits"], + }, + smart: { primary: "smart", aliases: ["smart"] }, + plan: { primary: "plan", aliases: ["plan"] }, + bypass: { primary: "bypass", aliases: ["bypass", "dangerous", "yolo"] }, +}; + +export function resolveDevinAcpPermissionMode( + runtimeMode: RuntimeMode, + interactionMode: "default" | "plan" = "default", +): string | undefined { + if (interactionMode === "plan") { + return DEVIN_MODE_ALIASES.plan.primary; + } + switch (runtimeMode) { + case "approval-required": + return DEVIN_MODE_ALIASES.normal.primary; + case "auto-accept-edits": + return DEVIN_MODE_ALIASES.acceptEdits.primary; + case "auto": + return DEVIN_MODE_ALIASES.smart.primary; + case "full-access": + return DEVIN_MODE_ALIASES.bypass.primary; + default: + return undefined; + } +} + +export function resolveDevinAcpMode( + runtimeMode: RuntimeMode, + availableModes: ReadonlyArray<{ readonly id: string; readonly name: string }> | undefined, + interactionMode: "default" | "plan" = "default", +): string | undefined { + const desired = resolveDevinAcpPermissionMode(runtimeMode, interactionMode); + if (!desired || !availableModes || availableModes.length === 0) { + return undefined; + } + const desiredNormalized = desired.toLowerCase().replace(/[\s_-]+/g, "-"); + const desiredAliases = new Set(); + for (const [, aliasSet] of Object.entries(DEVIN_MODE_ALIASES)) { + if (aliasSet.primary.toLowerCase().replace(/[\s_-]+/g, "-") === desiredNormalized) { + for (const alias of aliasSet.aliases) { + desiredAliases.add(alias.toLowerCase().replace(/[\s_-]+/g, "-")); + } + } + } + desiredAliases.add(desiredNormalized); + + for (const mode of availableModes) { + const id = mode.id + .trim() + .toLowerCase() + .replace(/[\s_-]+/g, "-"); + if (desiredAliases.has(id)) return mode.id; + const name = mode.name + .trim() + .toLowerCase() + .replace(/[\s_-]+/g, "-"); + if (desiredAliases.has(name)) return mode.id; + } + return undefined; +} + +function devinAcpPermissionArgs(runtimeMode?: RuntimeMode): ReadonlyArray { + if (!runtimeMode) return []; + const mode = resolveDevinAcpPermissionMode(runtimeMode); + return mode ? ["--permission-mode", mode] : []; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function buildDevinAcpSpawnInput( + settings: DevinAcpRuntimeSettings, + cwd: string, + environment?: NodeJS.ProcessEnv, + runtimeMode?: RuntimeMode, +): AcpSessionRuntime.AcpSpawnInput { + const globalArgs: Array = []; + if (settings.resolvedConfigPath) { + globalArgs.push("--config", settings.resolvedConfigPath); + } + if (settings.sandbox) { + globalArgs.push("--sandbox"); + } + globalArgs.push("--respect-workspace-trust", String(settings.respectWorkspaceTrust)); + + const acpArgs: Array = ["acp"]; + if (settings.agentType.trim()) { + acpArgs.push("--agent-type", settings.agentType.trim()); + } + + const safeLaunchArgs = tokenizeCliArgs(settings.launchArgs); + + const env: NodeJS.ProcessEnv = { ...environment }; + if (runtimeMode) { + const mode = resolveDevinAcpPermissionMode(runtimeMode); + if (mode) { + env[DEVIN_PERMISSION_MODE_ENV] = mode; + } + } + + return { + command: settings.binaryPath.trim() || "devin", + args: [...globalArgs, ...devinAcpPermissionArgs(runtimeMode), ...acpArgs, ...safeLaunchArgs], + cwd, + env, + }; +} + +export interface DevinAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly devinSettings: DevinAcpRuntimeSettings; + readonly environment?: NodeJS.ProcessEnv; + readonly runtimeMode?: RuntimeMode; + readonly interactionMode?: "default" | "plan"; +} + +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({ + spawn: buildDevinAcpSpawnInput( + input.devinSettings, + input.cwd, + input.environment, + input.runtimeMode, + ), + cwd: input.cwd, + clientInfo: input.clientInfo, + authMethodId: "devin", + authenticationMode: "on-demand", + ...(input.resumeSessionId !== undefined ? { resumeSessionId: input.resumeSessionId } : {}), + ...(input.sessionLoadTimeout !== undefined + ? { sessionLoadTimeout: input.sessionLoadTimeout } + : {}), + ...(input.sessionLoadReplayIdleGap !== undefined + ? { sessionLoadReplayIdleGap: input.sessionLoadReplayIdleGap } + : {}), + ...(input.mcpServers !== undefined ? { mcpServers: input.mcpServers } : {}), + ...(input.isAuthenticationFailure !== undefined + ? { isAuthenticationFailure: input.isAuthenticationFailure } + : {}), + ...(input.requestLogger !== undefined ? { requestLogger: input.requestLogger } : {}), + ...(input.protocolLogging !== undefined ? { protocolLogging: input.protocolLogging } : {}), + }).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 trimmed = model?.trim(); + if (!trimmed || trimmed === DEVIN_DEFAULT_MODEL_SLUG) { + return DEVIN_DEFAULT_MODEL_SLUG; + } + const base = trimmed.includes("[") ? trimmed.slice(0, trimmed.indexOf("[")) : trimmed; + return normalizeModelSlug(base, DEVIN_DRIVER_KIND) ?? base; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" ? value.trim() || undefined : undefined; +} + +function reasoningValuesFromMeta(meta: Record | null | undefined): + | { + options: ReadonlyArray<{ value: string; label: string; isDefault?: boolean }>; + currentValue: string | undefined; + } + | undefined { + if (!meta) return undefined; + + const advertisedOptions = Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : []; + if (advertisedOptions.length > 0) { + const seen = new Set(); + const options: Array<{ value: string; label: string; isDefault?: boolean }> = []; + for (const entry of advertisedOptions) { + if (!isRecord(entry)) continue; + const rawValue = nonEmptyString(entry.value); + const rawId = nonEmptyString(entry.id); + const value = rawValue ?? rawId; + if (!value || seen.has(value)) continue; + seen.add(value); + options.push({ + value, + label: nonEmptyString(entry.label) ?? value, + ...(entry.default === true || entry.isDefault === true ? { isDefault: true } : {}), + }); + } + const current = nonEmptyString(meta.reasoningEffort); + const currentValue = + current && options.some((o) => o.value === current) ? current : options[0]?.value; + return { options, currentValue }; + } + + const effort = nonEmptyString(meta.reasoningEffort); + if (effort) { + return { + options: [{ value: effort, label: effort }], + currentValue: effort, + }; + } + + return undefined; +} + +export function buildDevinModelCapabilities(model: EffectAcpSchema.ModelInfo): ModelCapabilities { + const meta = model._meta; + const reasoning = reasoningValuesFromMeta(meta); + + if (reasoning && reasoning.options.length > 0) { + const choices = reasoning.options.map((option) => ({ + id: option.value, + label: option.label, + ...(option.isDefault ? { isDefault: true } : {}), + })); + return createModelCapabilities({ + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: choices, + ...(reasoning.currentValue ? { currentValue: reasoning.currentValue } : {}), + }, + ], + }); + } + + const supportsReasoning = meta && (meta.supportsReasoning === true || meta.reasoning === true); + if (supportsReasoning) { + return createModelCapabilities({ + optionDescriptors: [ + { + id: "reasoning", + label: "Reasoning", + type: "boolean", + currentValue: meta.reasoning === true, + }, + ], + }); + } + + return createModelCapabilities({ optionDescriptors: [] }); +} + +function buildDevinModelSlug(model: EffectAcpSchema.ModelInfo): string { + const name = model.name.trim(); + if (!name) { + return `devin-protocol:${model.modelId.trim()}`; + } + return resolveDevinAcpBaseModelId(name); +} + +export function buildDevinModelsFromSessionModelState( + modelState: EffectAcpSchema.SessionModelState | null | undefined, + protocolValues?: Map, +): { + models: ReadonlyArray; + protocolMap: Map; +} { + if (!modelState || modelState.availableModels.length === 0) { + return { models: [], protocolMap: new Map() }; + } + + const currentModelId = modelState.currentModelId.trim(); + const seen = new Map(); + const models: Array = []; + const protocolMap = protocolValues ? new Map(protocolValues) : new Map(); + + for (const model of modelState.availableModels) { + const protocolValue = model.modelId.trim(); + const slug = buildDevinModelSlug(model); + if (seen.has(slug)) continue; + seen.set(slug, protocolValue); + protocolMap.set(slug, protocolValue); + models.push({ + slug, + name: model.name.trim() || slug, + isCustom: false, + ...(protocolValue === currentModelId ? { isDefault: true } : {}), + capabilities: buildDevinModelCapabilities(model), + }); + } + + return { models, protocolMap }; +} + +export function currentDevinModelIdFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): string | undefined { + return sessionSetupResult.models?.currentModelId?.trim() || undefined; +} + +export function currentDevinReasoningEffortFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): string | undefined { + const modelState = sessionSetupResult.models; + if (!modelState) return undefined; + const currentModelId = modelState.currentModelId.trim(); + const currentModel = modelState.availableModels.find( + (model) => model.modelId.trim() === currentModelId, + ); + return reasoningValuesFromMeta(currentModel?._meta)?.currentValue; +} + +function findDevinReasoningConfigOption( + configOptions: ReadonlyArray, +): EffectAcpSchema.SessionConfigOption | undefined { + return configOptions.find((option) => { + const id = option.id.trim().toLowerCase(); + const name = option.name.trim().toLowerCase(); + const category = (option.category ?? "").trim().toLowerCase(); + return ( + category === "model_option" && + (id === "reasoning" || + id === "reasoning_effort" || + id === "effort" || + name === "reasoning" || + name === "reasoning effort" || + name === "effort" || + name.includes("reasoning")) + ); + }); +} + +export interface DevinAcpModelSelectionErrorContext { + readonly cause: EffectAcpErrors.AcpError; + readonly step: "set-model" | "set-config-option"; + readonly configId?: string; +} + +export function applyDevinAcpModelSelection(input: { + readonly runtime: Pick< + AcpSessionRuntime.AcpSessionRuntime["Service"], + "setModel" | "setConfigOption" | "getConfigOptions" | "setSessionModel" + >; + readonly protocolMap: ReadonlyMap; + readonly currentModelId: string | undefined; + readonly requestedModelId: string | undefined; + readonly requestedReasoningEffort?: string | undefined; + readonly mapError: (context: DevinAcpModelSelectionErrorContext) => E; +}): Effect.Effect { + return Effect.gen(function* () { + const requestedModelId = + input.requestedModelId === DEVIN_DEFAULT_MODEL_SLUG ? undefined : input.requestedModelId; + const modelChanged = + requestedModelId !== undefined && requestedModelId !== input.currentModelId; + + const targetProtocolValue = + requestedModelId === undefined + ? input.currentModelId + : (input.protocolMap.get(requestedModelId) ?? + (input.protocolMap.size === 0 ? requestedModelId : undefined)); + + if (modelChanged && targetProtocolValue !== undefined) { + yield* input.runtime + .setModel(targetProtocolValue) + .pipe(Effect.mapError((cause) => input.mapError({ cause, step: "set-model" }))); + } + + const reasoningProvided = input.requestedReasoningEffort !== undefined; + if (reasoningProvided) { + const configOptions = yield* input.runtime.getConfigOptions; + const reasoningOption = findDevinReasoningConfigOption(configOptions); + const reasoningValue = input.requestedReasoningEffort!.trim(); + if (reasoningOption) { + yield* input.runtime.setConfigOption(reasoningOption.id, reasoningValue).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + step: "set-config-option", + configId: reasoningOption.id, + }), + ), + ); + } else if (targetProtocolValue) { + yield* input.runtime + .setSessionModel(targetProtocolValue, { reasoningEffort: reasoningValue }) + .pipe(Effect.mapError((cause) => input.mapError({ cause, step: "set-config-option" }))); + } + } + + return targetProtocolValue ?? input.currentModelId; + }); +} 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 diff --git a/apps/server/src/textGeneration/DevinTextGeneration.ts b/apps/server/src/textGeneration/DevinTextGeneration.ts new file mode 100644 index 000000000000..309709c1c948 --- /dev/null +++ b/apps/server/src/textGeneration/DevinTextGeneration.ts @@ -0,0 +1,288 @@ +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { type DevinSettings, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { expandHomePath } from "../pathExpansion.ts"; + +import { TextGenerationError } from "@t3tools/contracts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import { + applyDevinAcpModelSelection, + makeDevinAcpRuntime, + resolveDevinAcpBaseModelId, +} from "../provider/acp/DevinAcpSupport.ts"; + +const DEVIN_TIMEOUT_MS = 180_000; + +const isTextGenerationError = Schema.is(TextGenerationError); + +/** + * Build a Devin text-generation closure bound to a specific `DevinSettings` + * payload. Mirrors the Cursor/Codex text-generation flow over ACP. + */ +export const makeDevinTextGeneration = Effect.fn("makeDevinTextGeneration")(function* ( + devinSettings: DevinSettings, + environment?: NodeJS.ProcessEnv, +) { + const crypto = yield* Crypto.Crypto; + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const resolvedEnvironment = environment ?? process.env; + + const runDevinJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const outputRef = yield* Ref.make(""); + const runtime = yield* makeDevinAcpRuntime({ + devinSettings: { + binaryPath: devinSettings.binaryPath, + agentType: devinSettings.agentType, + sandbox: devinSettings.sandbox, + respectWorkspaceTrust: devinSettings.respectWorkspaceTrust, + launchArgs: devinSettings.launchArgs, + resolvedConfigPath: devinSettings.configPath?.trim() + ? path.resolve(expandHomePath(devinSettings.configPath.trim())) + : undefined, + }, + environment: resolvedEnvironment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + yield* runtime.start(); + yield* Effect.ignore(runtime.setMode("ask")); + yield* applyDevinAcpModelSelection({ + runtime, + protocolMap: new Map(), + currentModelId: undefined, + requestedModelId: resolveDevinAcpBaseModelId(modelSelection.model), + requestedReasoningEffort: getModelSelectionStringOptionValue( + modelSelection, + "reasoningEffort", + ), + mapError: ({ cause, step, configId }) => + new TextGenerationError({ + operation, + detail: + step === "set-config-option" + ? `Failed to set Devin ACP config option "${configId ?? "reasoning"}" for text generation.` + : "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 Agent request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Devin ACP request failed.", + cause, + }), + ), + ); + + const rawResult = (yield* Ref.get(outputRef)).trim(); + if (!rawResult) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Devin ACP request was cancelled." + : "Devin Agent returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(rawResult)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Devin 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/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index cc9b2e3926f3..7b12df6868ac 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "devin" + | "grok" + | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index cd0854e176b7..61417d472695 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -214,6 +214,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 32535d05d2f3..329fd5a33feb 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"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { [ProviderDriverKind.make("codex")]: OpenAI, @@ -7,6 +7,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("devin")]: DevinIcon, }; export type ModelEsque = { 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/session-logic.ts b/apps/web/src/session-logic.ts index 2766e852c818..0c748d6f0526 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -61,6 +61,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/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 6610a265d617..ca07bd1cb252 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"); +const DEVIN_DRIVER_KIND = ProviderDriverKind.make("devin"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -154,6 +155,8 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [DEVIN_DRIVER_KIND]: "Devin", }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index bd594f1a308d..9ddcb3d7f74e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -637,6 +637,87 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const DevinSettings = makeProviderSettingsSchema( + { + // Off by default like Cursor, Grok, and OpenCode. Users opt in from Settings. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("devin").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Devin CLI binary.", + providerSettingsForm: { placeholder: "devin", clearWhenEmpty: "omit" }, + }), + ), + homePath: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Home path", + description: "Custom Devin home and config directory.", + providerSettingsForm: { placeholder: "~/.devin", clearWhenEmpty: "omit" }, + }), + ), + configPath: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Config path", + description: "Absolute path to a Devin configuration file.", + providerSettingsForm: { placeholder: "/absolute/path/devin.json", clearWhenEmpty: "omit" }, + }), + ), + agentType: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Agent type", + description: "Optional agent type passed to Devin.", + providerSettingsForm: { placeholder: "default", clearWhenEmpty: "omit" }, + }), + ), + sandbox: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ + title: "Sandbox", + description: "Run the Devin CLI in a sandbox.", + providerSettingsForm: { hidden: false }, + }), + ), + respectWorkspaceTrust: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ + title: "Respect workspace trust", + description: "Respect T3 Code workspace trust settings when running Devin.", + providerSettingsForm: { hidden: false }, + }), + ), + launchArgs: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Launch arguments", + description: "Additional CLI arguments passed to Devin on session start.", + providerSettingsForm: { placeholder: "--some-flag", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: [ + "binaryPath", + "homePath", + "configPath", + "agentType", + "sandbox", + "respectWorkspaceTrust", + "launchArgs", + ], + }, +); +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(""))), @@ -804,6 +885,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 @@ -957,6 +1039,18 @@ 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), + configPath: Schema.optionalKey(TrimmedString), + agentType: Schema.optionalKey(TrimmedString), + sandbox: Schema.optionalKey(Schema.Boolean), + respectWorkspaceTrust: Schema.optionalKey(Schema.Boolean), + launchArgs: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), @@ -1001,6 +1095,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