From ddffa1a0cca802e5886f1a967376b440852e8625 Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Fri, 3 Jul 2026 19:48:31 -0400 Subject: [PATCH 01/10] Add Devin CLI provider --- apps/mobile/src/lib/threadActivity.ts | 13 +- apps/server/package.json | 3 +- apps/server/scripts/acp-mock-agent.ts | 80 + .../src/provider/Drivers/DevinDriver.ts | 186 ++ .../src/provider/Layers/CursorProvider.ts | 35 +- .../src/provider/Layers/DevinAdapter.test.ts | 1169 +++++++++++++ .../src/provider/Layers/DevinAdapter.ts | 229 +++ .../src/provider/Layers/DevinProvider.test.ts | 264 +++ .../src/provider/Layers/DevinProvider.ts | 291 ++++ .../server/src/provider/Layers/GrokAdapter.ts | 1490 +---------------- .../ProviderInstanceRegistryLive.test.ts | 62 +- .../provider/Layers/ProviderRegistry.test.ts | 60 + .../src/provider/Layers/ProviderRegistry.ts | 95 +- apps/server/src/provider/ProviderDriver.ts | 10 + .../ProviderModelDiscoveryCache.test.ts | 28 + .../provider/ProviderModelDiscoveryCache.ts | 38 + .../src/provider/Services/GrokAdapter.ts | 16 - .../server/src/provider/acp/AcpAdapterLive.ts | 980 +++++++++++ .../provider/acp/AcpAdapterRuntime.test.ts | 40 + .../src/provider/acp/AcpAdapterRuntime.ts | 906 ++++++++++ .../src/provider/acp/AcpRuntimeModel.test.ts | 65 + .../src/provider/acp/AcpRuntimeModel.ts | 60 +- ...tion.test.ts => AcpSessionRuntime.test.ts} | 65 + .../src/provider/acp/AcpSessionRuntime.ts | 74 +- .../src/provider/acp/DevinAcpSupport.test.ts | 330 ++++ .../src/provider/acp/DevinAcpSupport.ts | 763 +++++++++ .../src/provider/acp/DevinElicitation.test.ts | 134 ++ .../src/provider/acp/DevinElicitation.ts | 387 +++++ apps/server/src/provider/builtInDrivers.ts | 3 + .../src/provider/builtInProviderCatalog.ts | 2 + .../textGeneration/AcpJsonTextGeneration.ts | 275 +++ .../textGeneration/CursorTextGeneration.ts | 235 +-- .../src/textGeneration/DevinTextGeneration.ts | 54 + .../src/textGeneration/GrokTextGeneration.ts | 252 +-- .../src/textGeneration/TextGeneration.ts | 2 - apps/web/public/brand/devin-light.png | Bin 0 -> 2970 bytes apps/web/public/brand/devin.png | Bin 0 -> 2970 bytes apps/web/src/components/Icons.tsx | 19 + .../src/components/chat/providerIconUtils.ts | 3 +- .../settings/ProviderSettingsForm.test.ts | 27 + .../components/settings/providerDriverMeta.ts | 18 +- apps/web/src/pendingUserInput.test.ts | 48 + apps/web/src/pendingUserInput.ts | 16 +- apps/web/src/session-logic.test.ts | 42 + apps/web/src/session-logic.ts | 10 +- packages/contracts/src/model.ts | 5 + packages/contracts/src/providerRuntime.ts | 3 + packages/contracts/src/settings.test.ts | 21 + packages/contracts/src/settings.ts | 44 + 49 files changed, 6979 insertions(+), 1973 deletions(-) create mode 100644 apps/server/src/provider/Drivers/DevinDriver.ts create mode 100644 apps/server/src/provider/Layers/DevinAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/DevinAdapter.ts create mode 100644 apps/server/src/provider/Layers/DevinProvider.test.ts create mode 100644 apps/server/src/provider/Layers/DevinProvider.ts create mode 100644 apps/server/src/provider/ProviderModelDiscoveryCache.test.ts create mode 100644 apps/server/src/provider/ProviderModelDiscoveryCache.ts delete mode 100644 apps/server/src/provider/Services/GrokAdapter.ts create mode 100644 apps/server/src/provider/acp/AcpAdapterLive.ts create mode 100644 apps/server/src/provider/acp/AcpAdapterRuntime.test.ts create mode 100644 apps/server/src/provider/acp/AcpAdapterRuntime.ts rename apps/server/src/provider/acp/{AcpJsonRpcConnection.test.ts => AcpSessionRuntime.test.ts} (90%) create mode 100644 apps/server/src/provider/acp/DevinAcpSupport.test.ts create mode 100644 apps/server/src/provider/acp/DevinAcpSupport.ts create mode 100644 apps/server/src/provider/acp/DevinElicitation.test.ts create mode 100644 apps/server/src/provider/acp/DevinElicitation.ts create mode 100644 apps/server/src/textGeneration/AcpJsonTextGeneration.ts create mode 100644 apps/server/src/textGeneration/DevinTextGeneration.ts create mode 100644 apps/web/public/brand/devin-light.png create mode 100644 apps/web/public/brand/devin.png diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 9f79a90550d..b523a7278c9 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -195,14 +195,12 @@ function parseUserInputQuestions( }; }) .filter((option): option is UserInputQuestion["options"][number] => option !== null); - if (options.length === 0) { - return null; - } return { id: question.id, header: question.header, question: question.question, options, + required: question.required !== false, multiSelect: question.multiSelect === true, }; }) @@ -229,6 +227,10 @@ function resolvePendingUserInputAnswer( return normalizeDraftAnswer(draft?.selectedOptionLabel); } +function isRequiredQuestion(question: UserInputQuestion): boolean { + return question.required !== false; +} + function deriveWorkLogEntries( activities: ReadonlyArray, ): DerivedWorkLogEntry[] { @@ -1301,7 +1303,10 @@ export function buildPendingUserInputAnswers( for (const question of questions) { const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]); if (!answer) { - return null; + if (isRequiredQuestion(question)) { + return null; + } + continue; } answers[question.id] = answer; } diff --git a/apps/server/package.json b/apps/server/package.json index d0903c77d75..553e6699101 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -19,7 +19,8 @@ "build:bundle": "vp pack", "start": "node dist/bin.mjs", "typecheck": "tsgo --noEmit", - "test": "vp test run" + "test": "vp test run", + "test:devin": "vp test run src/provider/Layers/DevinAdapter.test.ts src/provider/Layers/DevinProvider.test.ts src/provider/acp/DevinAcpSupport.test.ts src/provider/acp/DevinElicitation.test.ts src/provider/acp/AcpAdapterRuntime.test.ts" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.170", diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd854..7cacd4b4808 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -19,6 +19,8 @@ const emitInterleavedAssistantToolCalls = const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; +const emitElicitation = process.env.T3_ACP_EMIT_ELICITATION === "1"; +const emitUrlElicitationComplete = process.env.T3_ACP_EMIT_URL_ELICITATION_COMPLETE === "1"; const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1"; const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; @@ -37,6 +39,7 @@ const emitOverlappingXAiPromptCompleteOutOfOrder = process.env.T3_ACP_EMIT_OVERLAPPING_XAI_PROMPT_COMPLETE_OUT_OF_ORDER === "1"; const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1"; const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; +const failModelConfigOption = process.env.T3_ACP_FAIL_MODEL_CONFIG_OPTION === "1"; const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); @@ -412,6 +415,15 @@ const program = Effect.gen(function* () { }, ); } + if (failModelConfigOption && request.configId === "model") { + return yield* AcpError.AcpRequestError.invalidParams( + "Mock invalid model params for session/set_config_option", + { + method: "session/set_config_option", + params: request, + }, + ); + } if (request.configId === "mode" && typeof request.value === "string") { currentModeId = request.value; } @@ -810,6 +822,74 @@ const program = Effect.gen(function* () { return { stopReason: "end_turn" }; } + if (emitElicitation) { + const result = yield* agent.client.elicit({ + sessionId: requestedSessionId, + mode: "form", + message: "Choose Devin options", + requestedSchema: { + type: "object", + title: "Devin options", + required: ["scope", "fast", "notes"], + properties: { + scope: { + type: "string", + title: "Scope", + description: "Which scope should Devin use?", + oneOf: [ + { const: "workspace", title: "Workspace" }, + { const: "session", title: "Session" }, + ], + }, + fast: { + type: "boolean", + title: "Fast mode", + description: "Use fast mode?", + }, + notes: { + type: "string", + title: "Notes", + description: "Any extra notes?", + }, + }, + }, + }); + if (result.action.action !== "accept" || !result.action.content) { + throw new Error("Expected accepted session/elicitation response."); + } + if ( + result.action.content.scope !== "workspace" || + result.action.content.fast !== true || + result.action.content.notes !== "Keep it focused" + ) { + throw new Error("Unexpected session/elicitation response content."); + } + + return { stopReason: "end_turn" }; + } + + if (emitUrlElicitationComplete) { + const elicitationId = "mock-url-elicitation-1"; + yield* Effect.gen(function* () { + yield* Effect.sleep("25 millis"); + yield* agent.client.elicitationComplete({ + elicitationId, + }); + }).pipe(Effect.forkChild); + const result = yield* agent.client.elicit({ + sessionId: requestedSessionId, + mode: "url", + elicitationId, + message: "Complete setup in Devin", + url: "https://example.com/devin/setup", + }); + if (result.action.action !== "accept") { + throw new Error("Expected accepted URL session/elicitation response."); + } + + return { stopReason: "end_turn" }; + } + if (emitForeignSessionUpdates) { yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, diff --git a/apps/server/src/provider/Drivers/DevinDriver.ts b/apps/server/src/provider/Drivers/DevinDriver.ts new file mode 100644 index 00000000000..e76240d95df --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinDriver.ts @@ -0,0 +1,186 @@ +import { DevinSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +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 { 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 { makeProviderModelDiscoveryCache } from "../ProviderModelDiscoveryCache.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, + type ProviderModelMergePolicy, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +import { isDevinAcpModelCoveredByBaseModelIds } from "../acp/DevinAcpSupport.ts"; + +const decodeDevinSettings = Schema.decodeSync(DevinSettings); + +const DRIVER_KIND = ProviderDriverKind.make("devin"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), +); + +export const DEVIN_MODEL_MERGE_POLICY: ProviderModelMergePolicy = { + shouldCarryPreviousModel: ({ previousModel, nextModelSlugs }) => { + if (nextModelSlugs.has(previousModel.slug)) { + return false; + } + return !isDevinAcpModelCoveredByBaseModelIds({ + modelId: previousModel.slug, + modelName: previousModel.name, + baseModelIds: nextModelSlugs, + }); + }, +}; + +export type DevinDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const DevinDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Devin", + supportsMultipleInstances: true, + }, + configSchema: DevinSettings, + defaultConfig: (): DevinSettings => decodeDevinSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies DevinSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + const modelDiscoveryCache = yield* makeProviderModelDiscoveryCache(); + + const adapter = yield* makeDevinAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + onSessionModelsDiscovered: modelDiscoveryCache.recordModels, + }); + const textGeneration = yield* makeDevinTextGeneration(effectiveConfig, processEnv); + + const checkProvider = modelDiscoveryCache.getModels.pipe( + Effect.flatMap((cachedDiscoveredModels) => + checkDevinProviderStatus(effectiveConfig, processEnv, { cachedDiscoveredModels }), + ), + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + 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, + }), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Devin snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + yield* modelDiscoveryCache.setRefresh(snapshot.refresh); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + modelMergePolicy: DEVIN_MODEL_MERGE_POLICY, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index cd9b93a4734..e0e23729495 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -45,6 +45,10 @@ import { type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; import * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + flattenSessionConfigSelectOptions, + type AcpSessionConfigSelectOptionValue, +} from "../acp/AcpRuntimeModel.ts"; import { CursorListAvailableModelsResponse } from "../acp/CursorAcpExtension.ts"; const decodeCursorListAvailableModelsResponse = Schema.decodeUnknownEffect( @@ -114,41 +118,12 @@ export function buildInitialCursorProviderSnapshot( }); } -interface CursorSessionSelectOption { - readonly value: string; - readonly name: string; -} - interface CursorAcpDiscoveredModel { readonly slug: string; readonly name: string; readonly capabilities: ModelCapabilities; } -function flattenSessionConfigSelectOptions( - configOption: EffectAcpSchema.SessionConfigOption | undefined, -): ReadonlyArray { - if (!configOption || configOption.type !== "select") { - return []; - } - return configOption.options.flatMap((entry) => - "value" in entry - ? [ - { - value: entry.value.trim(), - name: entry.name.trim(), - } satisfies CursorSessionSelectOption, - ] - : entry.options.map( - (option) => - ({ - value: option.value.trim(), - name: option.name.trim(), - }) satisfies CursorSessionSelectOption, - ), - ); -} - function normalizeCursorReasoningValue(value: string | null | undefined): string | undefined { const normalized = value?.trim().toLowerCase(); switch (normalized) { @@ -450,7 +425,7 @@ function normalizeCursorConfigOptionToken(value: string | null | undefined): str function findCursorSelectOptionValue( configOption: EffectAcpSchema.SessionConfigOption | undefined, - matcher: (option: CursorSessionSelectOption) => boolean, + matcher: (option: AcpSessionConfigSelectOptionValue) => boolean, ): string | undefined { return flattenSessionConfigSelectOptions(configOption).find(matcher)?.value; } 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 00000000000..e0baa4c7b0c --- /dev/null +++ b/apps/server/src/provider/Layers/DevinAdapter.test.ts @@ -0,0 +1,1169 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import { + ApprovalRequestId, + DevinSettings, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + TurnId, + type ProviderRuntimeEvent, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import { ServerConfig } from "../../config.ts"; +import { devinPromptSettlementBelongsToContext, makeDevinAdapter } from "./DevinAdapter.ts"; + +const decodeDevinSettings = Schema.decodeSync(DevinSettings); + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const mockAgentCommand = process.execPath; +const mockDevinEnvironments = new Map(); + +async function makeMockDevinWrapper(extraEnv?: Record) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-mock-")); + const wrapperPath = NodePath.join(dir, "fake-devin"); + mockDevinEnvironments.set(wrapperPath, extraEnv ?? {}); + return wrapperPath; +} + +function waitForFileContent( + filePath: string, + attempts = 40, + expectedContent?: string, +): Effect.Effect { + const readAttempt = (remainingAttempts: number): Effect.Effect => + Effect.gen(function* () { + if (remainingAttempts <= 0) { + return yield* Effect.die(new Error(`Timed out waiting for file content at ${filePath}`)); + } + const raw = yield* Effect.tryPromise(() => NodeFSP.readFile(filePath, "utf8")).pipe( + Effect.orElseSucceed(() => ""), + ); + if ( + raw.trim().length > 0 && + (expectedContent === undefined || raw.includes(expectedContent)) + ) { + return raw; + } + yield* Effect.sleep("25 millis"); + return yield* readAttempt(remainingAttempts - 1); + }); + return readAttempt(attempts); +} + +async function readJsonLines(filePath: string) { + const raw = await NodeFSP.readFile(filePath, "utf8"); + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + +const devinAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-devin-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +const makeTestAdapter = (binaryPath: string, options?: Parameters[1]) => + Effect.gen(function* () { + const realSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const extraEnvironment = mockDevinEnvironments.get(binaryPath); + const environment = + extraEnvironment || options?.environment + ? { ...extraEnvironment, ...options?.environment } + : undefined; + const adapterOptions = { + ...options, + ...(environment ? { environment } : {}), + } satisfies Parameters[1]; + const mockSpawner = ChildProcessSpawner.ChildProcessSpawner.of({ + ...realSpawner, + spawn: (command) => { + if (command._tag === "StandardCommand" && command.command === binaryPath) { + return realSpawner.spawn( + ChildProcess.make(mockAgentCommand, [mockAgentPath, ...command.args], command.options), + ); + } + return realSpawner.spawn(command); + }, + }); + + return yield* makeDevinAdapter(decodeDevinSettings({ binaryPath }), adapterOptions).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mockSpawner), + ); + }).pipe(Effect.orDie); + +it("requires a settlement to match the live Devin turn", () => { + const staleTurnId = TurnId.make("stale-turn"); + const replacementTurnId = TurnId.make("replacement-turn"); + + assert.isFalse( + devinPromptSettlementBelongsToContext({ + liveAcpSessionId: "session-1", + expectedAcpSessionId: "session-1", + liveActiveTurnId: replacementTurnId, + liveSessionActiveTurnId: replacementTurnId, + turnId: staleTurnId, + }), + ); + assert.isFalse( + devinPromptSettlementBelongsToContext({ + liveAcpSessionId: "replacement-session", + expectedAcpSessionId: "stale-session", + liveActiveTurnId: staleTurnId, + liveSessionActiveTurnId: staleTurnId, + turnId: staleTurnId, + }), + ); + assert.isTrue( + devinPromptSettlementBelongsToContext({ + liveAcpSessionId: "session-1", + expectedAcpSessionId: "session-1", + liveActiveTurnId: staleTurnId, + liveSessionActiveTurnId: staleTurnId, + turnId: staleTurnId, + }), + ); +}); + +it.layer(devinAdapterTestLayer)("DevinAdapterLive", (it) => { + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => + Effect.gen(function* () { + const threadId = ThreadId.make("devin-mock-thread"); + const wrapperPath = yield* Effect.promise(() => makeMockDevinWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("devin"), model: "composer-2" }, + }); + + assert.equal(session.provider, "devin"); + assert.equal(session.model, "composer-2"); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + + yield* adapter.sendTurn({ + threadId, + input: "hello devin", + attachments: [], + }); + + yield* Deferred.await(turnCompleted); + yield* Fiber.interrupt(runtimeEventsFiber); + const types = runtimeEvents.map((event) => event.type); + + assert.includeMembers(types, [ + "session.started", + "session.state.changed", + "thread.started", + "turn.started", + "turn.plan.updated", + "item.started", + "content.delta", + "turn.completed", + ] as const); + + const delta = runtimeEvents.find((event) => event.type === "content.delta"); + assert.isDefined(delta); + if (delta?.type === "content.delta") { + assert.equal(delta.payload.delta, "hello from mock"); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("reports discovered models from real ACP session startup", () => + Effect.gen(function* () { + const threadId = ThreadId.make("devin-session-model-discovery"); + const wrapperPath = yield* Effect.promise(() => makeMockDevinWrapper()); + const discovered = yield* Deferred.make>(); + const adapter = yield* makeTestAdapter(wrapperPath, { + onSessionModelsDiscovered: (models) => + Deferred.succeed(discovered, models).pipe(Effect.asVoid), + }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const models = yield* Deferred.await(discovered); + assert.includeMembers( + models.map((model) => model.slug), + ["auto", "composer-2", "codex-5-3"], + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("closes the ACP child process when a session stops", () => + Effect.gen(function* () { + const threadId = ThreadId.make("devin-stop-session-close"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-adapter-exit-log-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + + const wrapperPath = yield* Effect.promise(() => + makeMockDevinWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + yield* adapter.stopSession(threadId); + + assert.isFalse(yield* adapter.hasSession(threadId)); + const hostPlatform = yield* HostProcessPlatform; + if (hostPlatform !== "win32") { + const exitLog = yield* waitForFileContent(exitLogPath); + assert.include(exitLog, "SIGTERM"); + } + }), + ); + + it.effect("reports a Devin session running only while the prompt is in flight", () => + Effect.gen(function* () { + const threadId = ThreadId.make("devin-session-ready-after-prompt"); + const wrapperPath = yield* Effect.promise(() => + makeMockDevinWrapper({ + T3_ACP_EMIT_TOOL_CALLS: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const requestOpened = + yield* Deferred.make>(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "request.opened" + ? Deferred.succeed(requestOpened, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "check lifecycle", attachments: [] }) + .pipe(Effect.forkChild); + const requestOpenedEvent = yield* Deferred.await(requestOpened); + + const runningSessions = yield* adapter.listSessions(); + const runningSession = runningSessions.find((session) => session.threadId === threadId); + assert.equal(runningSession?.status, "running"); + assert.isDefined(runningSession?.activeTurnId); + + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(requestOpenedEvent.requestId)), + "accept", + ); + yield* Fiber.join(sendTurnFiber); + + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("handles ACP session elicitation requests", () => + Effect.gen(function* () { + const threadId = ThreadId.make("devin-session-elicitation"); + const wrapperPath = yield* Effect.promise(() => + makeMockDevinWrapper({ + T3_ACP_EMIT_ELICITATION: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const requested = + yield* Deferred.make>(); + const resolved = + yield* Deferred.make>(); + + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId)) { + return Effect.void; + } + if (event.type === "user-input.requested") { + return Deferred.succeed(requested, event).pipe(Effect.ignore); + } + if (event.type === "user-input.resolved") { + return Deferred.succeed(resolved, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "ask for Devin options", attachments: [] }) + .pipe(Effect.forkChild); + + const requestedEvent = yield* Deferred.await(requested); + assert.equal(requestedEvent.raw?.method, "session/elicitation"); + assert.deepEqual( + requestedEvent.payload.questions.map((question) => ({ + id: question.id, + question: question.question, + options: question.options.map((option) => option.label), + required: question.required, + multiSelect: question.multiSelect, + })), + [ + { + id: "scope", + question: "Which scope should Devin use?", + options: ["Workspace", "Session"], + required: true, + multiSelect: false, + }, + { + id: "fast", + question: "Use fast mode?", + options: ["Yes", "No"], + required: true, + multiSelect: false, + }, + { + id: "notes", + question: "Any extra notes?", + options: [], + required: true, + multiSelect: false, + }, + ], + ); + + const invalidError = yield* Effect.flip( + adapter.respondToUserInput( + threadId, + ApprovalRequestId.make(String(requestedEvent.requestId)), + { + scope: "Workspace", + fast: "Yes", + }, + ), + ); + assert.equal(invalidError._tag, "ProviderAdapterRequestError"); + if (invalidError._tag === "ProviderAdapterRequestError") { + assert.equal( + invalidError.detail, + "Invalid Devin elicitation response: missing required answers.", + ); + } + + yield* adapter.respondToUserInput( + threadId, + ApprovalRequestId.make(String(requestedEvent.requestId)), + { + scope: "Workspace", + fast: "Yes", + notes: "Keep it focused", + }, + ); + + const resolvedEvent = yield* Deferred.await(resolved); + assert.deepEqual(resolvedEvent.payload.answers, { + scope: "Workspace", + fast: "Yes", + notes: "Keep it focused", + }); + assert.equal(String(resolvedEvent.turnId), String(requestedEvent.turnId)); + yield* Fiber.join(sendTurnFiber); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("accepts URL elicitation completion notifications", () => + Effect.gen(function* () { + const threadId = ThreadId.make("devin-url-elicitation-complete"); + const wrapperPath = yield* Effect.promise(() => + makeMockDevinWrapper({ + T3_ACP_EMIT_URL_ELICITATION_COMPLETE: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "complete URL elicitation", + attachments: [], + }); + + assert.isDefined(turn.turnId); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("preserves an existing session when a restart fails after ACP start", () => + Effect.gen(function* () { + const threadId = ThreadId.make("devin-preserve-session-on-restart-failure"); + const wrapperPath = yield* Effect.promise(() => + makeMockDevinWrapper({ + T3_ACP_FAIL_MODEL_CONFIG_OPTION: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const firstSession = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const restartError = yield* Effect.flip( + adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("devin"), model: "composer-2" }, + }), + ); + const sessions = yield* adapter.listSessions(); + const preserved = sessions.find((session) => String(session.threadId) === String(threadId)); + + assert.equal(restartError._tag, "ProviderAdapterRequestError"); + assert.deepEqual(preserved?.resumeCursor, firstSession.resumeCursor); + assert.equal(preserved?.status, "ready"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("treats interrupting a missing session as a no-op", () => + Effect.gen(function* () { + const threadId = ThreadId.make("devin-interrupt-missing-session"); + const wrapperPath = yield* Effect.promise(() => makeMockDevinWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.interruptTurn(threadId).pipe(Effect.timeout("2 seconds")); + }), + ); + + it.effect("restores ready without completing an unstarted turn when preparation fails", () => + Effect.gen(function* () { + const threadId = ThreadId.make("devin-preparation-failure-while-connecting"); + const wrapperPath = yield* Effect.promise(() => makeMockDevinWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const error = yield* Effect.flip( + adapter.sendTurn({ + threadId, + input: "prepare invalid attachment", + attachments: [ + { + type: "image", + id: "missing-image", + name: "missing.png", + mimeType: "image/png", + sizeBytes: 1, + }, + ], + }), + ); + for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + const turnCompletedEvent = runtimeEvents.find( + (event): event is Extract => + event.type === "turn.completed", + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + assert.isUndefined(turnCompletedEvent); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("lets Stop unblock a fully silent Devin prompt and accept a follow-up turn", () => + Effect.gen(function* () { + const threadId = ThreadId.make("devin-stop-after-full-silence"); + const wrapperPath = yield* Effect.promise(() => + makeMockDevinWrapper({ + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + yield* Effect.gen(function* () { + yield* Effect.sleep("500 millis"); + yield* adapter.interruptTurn(threadId); + }).pipe(Effect.forkChild({ startImmediately: true })); + + yield* adapter.sendTurn({ + threadId, + input: "hang forever", + attachments: [], + }); + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + const cancelledEvents = runtimeEvents.filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + + assert.lengthOf(cancelledEvents, 1); + assert.equal(cancelledEvents[0]?.payload.state, "cancelled"); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + const followUpEventsBefore = runtimeEvents.length; + yield* adapter.sendTurn({ + threadId, + input: "continue after stop", + attachments: [], + }); + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + const followUpCompletedEvents = runtimeEvents + .slice(followUpEventsBefore) + .filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + assert.lengthOf(followUpCompletedEvents, 1); + assert.equal(followUpCompletedEvents[0]?.payload.state, "completed"); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect("does not let a cancelled prompt settlement consume the follow-up prompt slot", () => + Effect.gen(function* () { + const threadId = ThreadId.make("devin-cancelled-settlement-before-follow-up"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-cancel-race-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockDevinWrapper({ + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const firstTurnStarted = yield* Deferred.make(); + const twoTurnsCompleted = yield* Deferred.make(); + const completedCountRef = yield* Ref.make(0); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "turn.started" && event.turnId !== undefined) { + yield* Deferred.succeed(firstTurnStarted, event.turnId).pipe(Effect.ignore); + return; + } + if (event.type !== "turn.completed") { + return; + } + const completedCount = yield* Ref.updateAndGet(completedCountRef, (count) => count + 1); + if (completedCount === 2) { + yield* Deferred.succeed(twoTurnsCompleted, undefined); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const firstSendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "cancel this prompt", attachments: [] }) + .pipe(Effect.forkChild); + const firstTurnId = yield* Deferred.await(firstTurnStarted).pipe(Effect.timeout("2 seconds")); + yield* waitForFileContent(requestLogPath, 80, '"method":"session/prompt"'); + + yield* adapter.interruptTurn(threadId, firstTurnId).pipe(Effect.timeout("2 seconds")); + const followUp = yield* adapter + .sendTurn({ threadId, input: "complete the follow-up", attachments: [] }) + .pipe(Effect.timeout("2 seconds")); + yield* Fiber.join(firstSendTurnFiber).pipe(Effect.timeout("2 seconds")); + yield* Deferred.await(twoTurnsCompleted).pipe(Effect.timeout("2 seconds")); + + const turnCompletedEvents = runtimeEvents.filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + + assert.notEqual(String(followUp.turnId), String(firstTurnId)); + assert.deepEqual( + turnCompletedEvents.map((event) => [String(event.turnId), event.payload.state]), + [ + [String(firstTurnId), "cancelled"], + [String(followUp.turnId), "completed"], + ], + ); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect("drops late ACP notifications after a turn is cancelled", () => + Effect.gen(function* () { + const threadId = ThreadId.make("devin-drop-late-cancelled-notifications"); + const wrapperPath = yield* Effect.promise(() => + makeMockDevinWrapper({ + T3_ACP_HANG_PROMPT_FOREVER: "1", + T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL: "1", + }), + ); + const lateNativeUpdate = yield* Deferred.make(); + const adapter = yield* makeTestAdapter(wrapperPath, { + nativeEventLogger: { + filePath: "memory://devin-cancelled-native-events", + write: (record: unknown) => + JSON.stringify(record).includes("late after cancel") + ? Deferred.succeed(lateNativeUpdate, undefined).pipe(Effect.asVoid) + : Effect.void, + close: () => Effect.void, + }, + }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnStarted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.started" && + event.turnId !== undefined && + String(event.threadId) === String(threadId) + ? Deferred.succeed(turnStarted, event.turnId).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "cancel before the late update", attachments: [] }) + .pipe(Effect.forkChild); + const turnId = yield* Deferred.await(turnStarted).pipe(Effect.timeout("2 seconds")); + yield* adapter.interruptTurn(threadId, turnId).pipe(Effect.timeout("2 seconds")); + yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("2 seconds")); + yield* Deferred.await(lateNativeUpdate).pipe(Effect.timeout("2 seconds")); + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + const cancelledIndex = runtimeEvents.findIndex( + (event) => + event.type === "turn.completed" && + String(event.threadId) === String(threadId) && + String(event.turnId) === String(turnId) && + event.payload.state === "cancelled", + ); + const turnOutputTypes = new Set([ + "content.delta", + "item.started", + "item.updated", + "item.completed", + "turn.plan.updated", + ]); + const outputAfterCancellation = runtimeEvents + .slice(cancelledIndex + 1) + .filter( + (event) => String(event.threadId) === String(threadId) && turnOutputTypes.has(event.type), + ); + + assert.isAtLeast(cancelledIndex, 0); + assert.deepEqual(outputAfterCancellation, []); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect("settles the in-flight prompt before emitting completion", () => + Effect.gen(function* () { + const threadId = ThreadId.make("devin-completion-before-next-turn"); + const wrapperPath = yield* Effect.promise(() => makeMockDevinWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + const completedCountRef = yield* Ref.make(0); + const secondTurnCompleted = yield* Deferred.make(); + + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (event.type !== "turn.completed" || String(event.threadId) !== String(threadId)) { + return Effect.void; + } + + return Ref.modify(completedCountRef, (count) => { + const nextCount = count + 1; + return [nextCount, nextCount] as const; + }).pipe( + Effect.flatMap((count) => { + if (count === 1) { + return adapter + .sendTurn({ + threadId, + input: "second turn after completion", + attachments: [], + }) + .pipe(Effect.forkChild, Effect.asVoid); + } + if (count === 2) { + return Deferred.succeed(secondTurnCompleted, undefined).pipe(Effect.asVoid); + } + return Effect.void; + }), + ); + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId, + input: "first turn", + attachments: [], + }); + yield* Deferred.await(secondTurnCompleted); + + const completedCount = yield* Ref.get(completedCountRef); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + + assert.equal(completedCount, 2); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => + Effect.gen(function* () { + const threadId = ThreadId.make("devin-steer-thread"); + const wrapperPath = yield* Effect.promise(() => + makeMockDevinWrapper({ T3_ACP_PROMPT_DELAY_MS: "750" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const firstTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "run 5 commands", + attachments: [], + }) + .pipe(Effect.forkChild); + + yield* Effect.gen(function* () { + for (let attempt = 0; attempt < 100; attempt += 1) { + const sessions = yield* adapter.listSessions(); + const session = sessions.find((entry) => entry.threadId === threadId); + if (session?.activeTurnId !== undefined) { + return; + } + yield* Effect.sleep("20 millis"); + } + throw new Error("Timed out waiting for the first prompt to be in flight."); + }); + + const steeredTurn = yield* adapter.sendTurn({ + threadId, + input: "actually run 15", + attachments: [], + }); + const firstTurn = yield* Fiber.join(firstTurnFiber); + assert.equal(String(steeredTurn.turnId), String(firstTurn.turnId)); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const turnStartedEvents = runtimeEvents.filter((event) => event.type === "turn.started"); + const turnCompletedEvents = runtimeEvents.filter((event) => event.type === "turn.completed"); + + assert.equal(turnStartedEvents.length, 1); + assert.equal(String(turnStartedEvents[0]?.turnId), String(firstTurn.turnId)); + assert.equal(turnCompletedEvents.length, 1); + assert.equal(String(turnCompletedEvents[0]?.turnId), String(firstTurn.turnId)); + + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect("restores a Devin session to ready when the prompt RPC fails", () => + Effect.gen(function* () { + const threadId = ThreadId.make("devin-prompt-failure-ready"); + const wrapperPath = yield* Effect.promise(() => + makeMockDevinWrapper({ + T3_ACP_FAIL_PROMPT: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const error = yield* Effect.flip( + adapter.sendTurn({ + threadId, + input: "fail prompt", + attachments: [], + }), + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + const failedTurnCompleted = runtimeEvents.find( + (event) => event.type === "turn.completed" && event.threadId === threadId, + ); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + assert.equal(failedTurnCompleted?.type, "turn.completed"); + if (failedTurnCompleted?.type === "turn.completed") { + assert.equal(failedTurnCompleted.payload.state, "failed"); + assert.isString(failedTurnCompleted.payload.errorMessage); + } + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("ignores replayed session/load updates when resuming a Devin session", () => + Effect.gen(function* () { + const threadId = ThreadId.make("devin-load-replay-filter"); + const wrapperPath = yield* Effect.promise(() => + makeMockDevinWrapper({ + T3_ACP_EMIT_LOAD_REPLAY: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "mock-session-1" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "after resume", + attachments: [], + }); + + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + assert.isFalse( + runtimeEvents.some( + (event) => event.type === "item.completed" && event.payload.title === "Replay tool", + ), + ); + assert.isFalse( + runtimeEvents.some( + (event) => + event.type === "content.delta" && event.payload.delta === "replayed assistant text", + ), + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("responds to ACP approvals using provider-supplied option ids", () => + Effect.gen(function* () { + const threadId = ThreadId.make("devin-custom-approval-option-id"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockDevinWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_ALLOW_ONCE_OPTION_ID: "agent-defined-approval-id", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "request.opened" + ? adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + "accept", + ) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ threadId, input: "approve this", attachments: [] }); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isTrue( + requests.some( + (entry) => + !("method" in entry) && + typeof entry.result === "object" && + entry.result !== null && + "outcome" in entry.result && + typeof entry.result.outcome === "object" && + entry.result.outcome !== null && + "optionId" in entry.result.outcome && + entry.result.outcome.optionId === "agent-defined-approval-id", + ), + ); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("stopping a session settles pending approval waits", () => + Effect.gen(function* () { + const threadId = ThreadId.make("devin-stop-pending-approval"); + const requestOpened = yield* Deferred.make(); + const wrapperPath = yield* Effect.promise(() => + makeMockDevinWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (String(event.threadId) !== String(threadId) || event.type !== "request.opened") { + return Effect.void; + } + return Deferred.succeed(requestOpened, undefined).pipe(Effect.ignore); + }).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "run a tool call and then stop", + attachments: [], + }) + .pipe(Effect.forkChild); + + yield* Deferred.await(requestOpened); + yield* adapter.stopSession(threadId); + yield* Fiber.await(sendTurnFiber).pipe(Effect.timeout("2 seconds")); + + assert.equal(yield* adapter.hasSession(threadId), false); + + yield* Fiber.interrupt(eventsFiber); + }), + ); + + it.effect("continues streaming events when native notification logging fails", () => + Effect.gen(function* () { + const threadId = ThreadId.make("devin-native-log-failure"); + const wrapperPath = yield* Effect.promise(() => makeMockDevinWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath, { + nativeEventLogger: { + filePath: "memory://devin-native-events", + write: (record: unknown) => + typeof record === "object" && + record !== null && + "event" in record && + typeof record.event === "object" && + record.event !== null && + "kind" in record.event && + record.event.kind === "notification" + ? Effect.die(new Error("native log write failed")) + : Effect.void, + close: () => Effect.void, + }, + }); + const contentDelta = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "content.delta" ? Deferred.succeed(contentDelta, undefined) : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "keep streaming", attachments: [] }); + yield* Deferred.await(contentDelta); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/DevinAdapter.ts b/apps/server/src/provider/Layers/DevinAdapter.ts new file mode 100644 index 00000000000..58ce7d28d01 --- /dev/null +++ b/apps/server/src/provider/Layers/DevinAdapter.ts @@ -0,0 +1,229 @@ +import { + type ApprovalRequestId, + type DevinSettings, + ProviderDriverKind, + type ServerProviderModel, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { + acpPromptSettlementBelongsToContext, + handleAcpUserInputRequest, +} from "../acp/AcpAdapterRuntime.ts"; +import { makeAcpAdapterLive, type AcpAdapterLiveOptions } from "../acp/AcpAdapterLive.ts"; +import { + applyDevinAcpModelSelection, + applyDevinRequestedMode, + buildDevinDiscoveredModelsFromSessionSetup, + currentDevinModelIdFromSessionSetup, + makeDevinAcpRuntime, + resolveDevinAcpDisplayModelId, + resolveDevinAcpModelSelection, +} from "../acp/DevinAcpSupport.ts"; +import { makeDevinElicitationPrompt } from "../acp/DevinElicitation.ts"; +import { ProviderAdapterProcessError } from "../Errors.ts"; +import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = ProviderDriverKind.make("devin"); +const DEVIN_RESUME_VERSION = 1 as const; + +export interface DevinAdapterLiveOptions extends AcpAdapterLiveOptions { + readonly nativeEventLogger?: EventNdjsonLogger; + readonly onSessionModelsDiscovered?: ( + models: ReadonlyArray, + ) => Effect.Effect; +} + +export const devinPromptSettlementBelongsToContext = acpPromptSettlementBelongsToContext; + +export function makeDevinAdapter(devinSettings: DevinSettings, options?: DevinAdapterLiveOptions) { + return Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + return yield* makeAcpAdapterLive( + { + provider: PROVIDER, + providerLabel: "Devin", + resumeSchemaVersion: DEVIN_RESUME_VERSION, + readyReason: "Devin ACP session ready", + respondToUserInputMethod: "session/elicitation", + capabilities: { sessionModelSwitch: "in-session" }, + completedStopReasonFromPromptResponse: (response) => response?.stopReason ?? null, + makeAcpRuntime: (input) => + makeDevinAcpRuntime({ + devinSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd: input.cwd, + ...(input.resumeSessionId ? { resumeSessionId: input.resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(input.mcpServers ? { mcpServers: input.mcpServers } : {}), + ...input.acpNativeLoggers, + }).pipe( + Effect.provideService(Scope.Scope, input.sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ), + registerAcpCallbacks: (input) => + Effect.gen(function* () { + // URL-mode elicitations can be completed out-of-band by the agent + // (session/elicitation/complete), so track their request ids. + const urlElicitationRequestIds = new Map(); + yield* input.acp.handleElicitation((params) => { + const elicitationId = params.mode === "url" ? params.elicitationId : undefined; + return input.mapAcpCallbackFailure( + handleAcpUserInputRequest({ + provider: PROVIDER, + threadId: input.threadId, + method: "session/elicitation", + source: "acp.jsonrpc", + request: params, + prompt: { + ...makeDevinElicitationPrompt(params), + makeCancelledResponse: () => + ({ + action: { action: "cancel" }, + }) satisfies EffectAcpSchema.ElicitationResponse, + validateResponse: (response) => + response.action.action === "decline" + ? "Invalid Devin elicitation response: missing required answers." + : undefined, + }, + pendingUserInputs: input.pendingUserInputs, + ...(elicitationId !== undefined + ? { + onOpened: (requestId: ApprovalRequestId) => { + urlElicitationRequestIds.set(elicitationId, requestId); + }, + onSettled: () => { + urlElicitationRequestIds.delete(elicitationId); + }, + } + : {}), + resolveTurnId: input.resolveActiveTurnId, + makeRequestId: input.nextApprovalRequestId, + makeEventStamp: input.makeEventStamp, + offerRuntimeEvent: input.offerRuntimeEvent, + logNative: input.logNative, + }), + ); + }); + yield* input.acp.handleElicitationComplete((notification) => + Effect.suspend(() => { + const requestId = urlElicitationRequestIds.get(notification.elicitationId); + const pending = + requestId !== undefined ? input.pendingUserInputs.get(requestId) : undefined; + if (!pending) { + return Effect.void; + } + return Deferred.succeed(pending.resolution, { + _tag: "answered", + answers: {}, + response: { action: { action: "accept" } }, + }).pipe(Effect.asVoid); + }), + ); + }), + bindSessionModel: (input) => + Effect.gen(function* () { + const requestedStartModelId = input.modelSelection + ? resolveDevinAcpModelSelection({ + configOptions: input.sessionSetupResult.configOptions, + model: input.modelSelection.model, + selections: input.modelSelection.options, + }) + : undefined; + const sessionSetupModelId = currentDevinModelIdFromSessionSetup( + input.sessionSetupResult, + ); + const boundModelId = yield* applyDevinAcpModelSelection({ + runtime: input.acp, + currentModelId: sessionSetupModelId, + requestedModelId: requestedStartModelId, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); + yield* applyDevinRequestedMode({ + runtime: input.acp, + runtimeMode: input.runtimeMode, + interactionMode: undefined, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_mode", cause), + }); + const activeAcpModelId = boundModelId ?? sessionSetupModelId; + return { + currentModelId: activeAcpModelId, + displayModel: activeAcpModelId + ? resolveDevinAcpDisplayModelId( + input.sessionSetupResult.configOptions, + activeAcpModelId, + ) + : undefined, + }; + }), + prepareTurnModel: (input) => + Effect.gen(function* () { + const configOptions = yield* input.ctx.acp.getConfigOptions; + const requestedTurnModelId = input.modelSelection + ? resolveDevinAcpModelSelection({ + configOptions, + model: input.modelSelection.model, + selections: input.modelSelection.options, + }) + : undefined; + const currentModelId = yield* applyDevinAcpModelSelection({ + runtime: input.ctx.acp, + currentModelId: input.ctx.currentModelId, + requestedModelId: requestedTurnModelId, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); + yield* applyDevinRequestedMode({ + runtime: input.ctx.acp, + runtimeMode: input.ctx.session.runtimeMode, + interactionMode: input.interactionMode, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_mode", cause), + }); + return { + currentModelId, + displayModel: currentModelId + ? resolveDevinAcpDisplayModelId(configOptions, currentModelId) + : undefined, + }; + }), + }, + { + ...options, + afterSessionStarted: (input) => + Effect.gen(function* () { + const discoveredModels = buildDevinDiscoveredModelsFromSessionSetup( + input.sessionSetupResult, + ); + if (discoveredModels.length > 0) { + yield* (options?.onSessionModelsDiscovered?.(discoveredModels) ?? Effect.void).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to record Devin ACP session model discovery.", { + cause, + }), + ), + ); + } + yield* options?.afterSessionStarted?.(input) ?? Effect.void; + }), + }, + ); + }); +} 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 00000000000..957ab41dadf --- /dev/null +++ b/apps/server/src/provider/Layers/DevinProvider.test.ts @@ -0,0 +1,264 @@ +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 type * as EffectAcpSchema from "effect-acp/schema"; +import { DevinSettings } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import { buildDevinDiscoveredModelsFromSessionSetup } from "../acp/DevinAcpSupport.ts"; +import { checkDevinProviderStatus } from "./DevinProvider.ts"; + +const decodeDevinSettings = Schema.decodeSync(DevinSettings); +const mockAgentUrl = new URL("../../../scripts/acp-mock-agent.ts", import.meta.url); + +function quotePosixShell(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +const makeMockDevinCli = Effect.fn("makeMockDevinCli")(function* ( + prefix: string, + options?: { + readonly acp?: boolean; + }, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const hostPlatform = yield* HostProcessPlatform; + const isWin32 = hostPlatform === "win32"; + const dir = yield* fs.makeTempDirectoryScoped({ prefix }); + const devinPath = path.join(dir, isWin32 ? "devin.cmd" : "devin"); + const mockAgentPath = yield* path.fromFileUrl(mockAgentUrl); + const supportsAcp = options?.acp ?? true; + + yield* fs.writeFileString( + devinPath, + isWin32 + ? [ + "@echo off", + 'if "%~1"=="version" (', + " echo devin 1.2.3", + " exit /b 0", + ")", + ...(supportsAcp + ? [ + 'if "%~1"=="acp" (', + ` "${process.execPath}" "${mockAgentPath}"`, + " exit /b %ERRORLEVEL%", + ")", + ] + : []), + "echo unexpected Devin invocation: %* 1>&2", + "exit /b 7", + "", + ].join("\r\n") + : [ + "#!/bin/sh", + 'if [ "$1" = "version" ]; then', + ' printf "devin 1.2.3\\n"', + " exit 0", + "fi", + ...(supportsAcp + ? [ + 'if [ "$1" = "acp" ]; then', + ` exec ${quotePosixShell(process.execPath)} ${quotePosixShell(mockAgentPath)}`, + "fi", + ] + : []), + 'printf "unexpected Devin invocation: %s\\n" "$*" >&2', + "exit 7", + "", + ].join("\n"), + ); + yield* fs.chmod(devinPath, 0o755); + return devinPath; +}); + +describe("buildDevinDiscoveredModelsFromSessionSetup", () => { + it("builds Devin provider models from ACP model config options", () => { + const models = buildDevinDiscoveredModelsFromSessionSetup({ + sessionId: "session-1", + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "adaptive", + options: [ + { + group: "recommended", + name: "Recommended", + options: [ + { value: "adaptive", name: "Adaptive" }, + { value: "swe-1-6", name: "SWE-1.6" }, + { value: "swe-1-6-fast", name: "SWE-1.6 Fast" }, + { value: "MODEL_PRIVATE_11", name: "Private Model" }, + ], + }, + ], + }, + ], + models: { + currentModelId: "legacy-model-state", + availableModels: [{ modelId: "legacy-model-state", name: "Legacy" }], + }, + } satisfies EffectAcpSchema.NewSessionResponse); + + expect( + models.map(({ slug, name, isCustom, capabilities }) => ({ + slug, + name, + isCustom, + options: capabilities?.optionDescriptors?.map((descriptor) => descriptor.id) ?? [], + })), + ).toEqual([ + { slug: "adaptive", name: "Adaptive", isCustom: false, options: [] }, + { slug: "swe-1-6", name: "SWE-1.6", isCustom: false, options: ["fastMode"] }, + { slug: "private-model", name: "Private Model", isCustom: false, options: [] }, + ]); + }); + + it("collapses Devin thinking variants into provider option descriptors", () => { + const models = buildDevinDiscoveredModelsFromSessionSetup({ + sessionId: "session-1", + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "gpt-5-5-high-priority", + options: [ + { value: "gpt-5-5-low", name: "GPT-5.5 Low Thinking" }, + { value: "gpt-5-5-medium", name: "GPT-5.5 Medium Thinking" }, + { value: "gpt-5-5-high-priority", name: "GPT-5.5 High Thinking Fast" }, + { value: "glm-5-2-high", name: "GLM-5.2 High" }, + { value: "glm-5-2-high-1m", name: "GLM-5.2 High 1M" }, + ], + }, + ], + } satisfies EffectAcpSchema.NewSessionResponse); + + expect(models.map(({ slug, name }) => ({ slug, name }))).toEqual([ + { slug: "gpt-5-5", name: "GPT-5.5" }, + { slug: "glm-5-2", name: "GLM-5.2" }, + ]); + + const gpt = models.find((model) => model.slug === "gpt-5-5"); + expect(gpt?.capabilities?.optionDescriptors).toEqual([ + { + id: "reasoning", + label: "Thinking", + type: "select", + currentValue: "high", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + ], + }, + { + id: "fastMode", + label: "Fast Mode", + type: "boolean", + currentValue: true, + }, + ]); + + const glm = models.find((model) => model.slug === "glm-5-2"); + expect(glm?.capabilities?.optionDescriptors).toEqual([ + { + id: "contextWindow", + label: "Context Window", + type: "select", + currentValue: "default", + options: [ + { id: "default", label: "Default", isDefault: true }, + { id: "1m", label: "1M" }, + ], + }, + ]); + }); + + it("falls back to unstable ACP model state when no config selector is present", () => { + const models = buildDevinDiscoveredModelsFromSessionSetup({ + sessionId: "session-1", + models: { + currentModelId: "adaptive", + availableModels: [ + { modelId: " adaptive ", name: " Adaptive " }, + { modelId: "custom-devin-model", name: "Custom Devin Model" }, + ], + }, + } satisfies EffectAcpSchema.NewSessionResponse); + + expect(models.map(({ slug, name, isCustom }) => ({ slug, name, isCustom }))).toEqual([ + { slug: "adaptive", name: "Adaptive", isCustom: false }, + { slug: "custom-devin-model", name: "Custom Devin Model", isCustom: false }, + ]); + }); +}); + +it.layer(NodeServices.layer)("checkDevinProviderStatus", (it) => { + it.effect("reports models discovered through Devin ACP after `devin version` succeeds", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const devinPath = yield* makeMockDevinCli("t3code-devin-acp-"); + + return yield* checkDevinProviderStatus( + decodeDevinSettings({ enabled: true, binaryPath: devinPath }), + ); + }), + ); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.installed).toBe(true); + expect(snapshot.version).toBe("1.2.3"); + expect(snapshot.message).toBeUndefined(); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "auto", + "composer-2", + "codex-5-3", + ]); + }), + ); + + it.effect("uses cached real-session model discovery when ACP probing fails", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const devinPath = yield* makeMockDevinCli("t3code-devin-cached-", { acp: false }); + + return yield* checkDevinProviderStatus( + decodeDevinSettings({ enabled: true, binaryPath: devinPath }), + process.env, + { + cachedDiscoveredModels: [ + { + slug: "adaptive", + name: "Adaptive", + isCustom: false, + capabilities: null, + }, + { + slug: "gpt-5-5", + name: "GPT-5.5", + isCustom: false, + capabilities: null, + }, + ], + }, + ); + }), + ); + + expect(snapshot.status).toBe("warning"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["adaptive", "gpt-5-5"]); + expect(snapshot.message).toContain("last models discovered"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/DevinProvider.ts b/apps/server/src/provider/Layers/DevinProvider.ts new file mode 100644 index 00000000000..b3b75b67522 --- /dev/null +++ b/apps/server/src/provider/Layers/DevinProvider.ts @@ -0,0 +1,291 @@ +import { + type DevinSettings, + type ModelCapabilities, + ProviderDriverKind, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { discoverDevinModelsViaAcp } from "../acp/DevinAcpSupport.ts"; + +const DEVIN_PRESENTATION = { + displayName: "Devin", + badgeLabel: "Early Access", + showInteractionModeToggle: true, + requiresNewThreadForModelChange: false, +} as const; +const PROVIDER = ProviderDriverKind.make("devin"); +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const DEVIN_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; + +export function buildInitialDevinProviderSnapshot( + devinSettings: DevinSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = devinModelsFromSettings(devinSettings.customModels); + + if (!devinSettings.enabled) { + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Devin is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Devin CLI availability...", + }, + }); + }); +} + +function devinModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = [], +): ReadonlyArray { + return providerModelsFromSettings( + builtInModels, + PROVIDER, + customModels ?? [], + EMPTY_CAPABILITIES, + ); +} + +const runDevinVersionCommand = ( + devinSettings: DevinSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const command = devinSettings.binaryPath || "devin"; + const spawnCommand = yield* resolveSpawnCommand(command, ["version"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +export interface DevinProviderStatusOptions { + readonly cachedDiscoveredModels?: ReadonlyArray; +} + +export const checkDevinProviderStatus = Effect.fn("checkDevinProviderStatus")(function* ( + devinSettings: DevinSettings, + environment: NodeJS.ProcessEnv = process.env, + options?: DevinProviderStatusOptions, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const cachedModels = options?.cachedDiscoveredModels ?? []; + const fallbackModels = devinModelsFromSettings(devinSettings.customModels, cachedModels); + + if (!devinSettings.enabled) { + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Devin is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runDevinVersionCommand(devinSettings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning("Devin CLI health check failed.", { + errorTag: error._tag, + }); + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: devinSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Devin CLI (`devin`) is not installed or not on PATH." + : "Failed to execute Devin CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: devinSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Devin CLI is installed but timed out while running `devin version`.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + yield* Effect.logWarning("Devin CLI version probe exited with a non-zero status.", { + exitCode: versionOutput.code, + stdoutLength: versionOutput.stdout.length, + stderrLength: versionOutput.stderr.length, + }); + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: devinSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Devin CLI is installed but failed to run.", + }, + }); + } + + const buildDiscoveryFailureSnapshot = (message: string) => + buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: devinSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: cachedModels.length > 0 ? "warning" : "error", + auth: { status: "unknown" }, + message: + cachedModels.length > 0 + ? `${message} Showing the last models discovered from a Devin ACP session.` + : message, + }, + }); + + const discoveryExit = yield* discoverDevinModelsViaAcp(devinSettings, environment).pipe( + Effect.timeoutOption(DEVIN_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + Effect.exit, + ); + if (Exit.isFailure(discoveryExit)) { + yield* Effect.logWarning("Devin ACP model discovery failed", { + errorTag: causeErrorTag(discoveryExit.cause), + }); + return buildDiscoveryFailureSnapshot( + "Devin CLI is installed but ACP model discovery failed. Run `devin auth login`, then try again.", + ); + } + if (Option.isNone(discoveryExit.value)) { + yield* Effect.logWarning( + `Devin ACP model discovery timed out after ${DEVIN_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + ); + return buildDiscoveryFailureSnapshot( + `Devin CLI is installed but ACP model discovery timed out after ${DEVIN_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + ); + } + + const discoveredModels = discoveryExit.value.value; + if (discoveredModels.length === 0) { + return buildDiscoveryFailureSnapshot("Devin ACP model discovery returned no built-in models."); + } + const models = devinModelsFromSettings(devinSettings.customModels, discoveredModels); + + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: devinSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + }, + }); +}); + +export const enrichDevinSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { snapshot, publishSnapshot } = input; + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("Devin version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index c22b2180183..7c1cad075f0 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -1,58 +1,15 @@ -import { - ApprovalRequestId, - type GrokSettings, - EventId, - type ProviderApprovalDecision, - type ProviderRuntimeEvent, - type ProviderSession, - type ProviderUserInputAnswers, - ProviderDriverKind, - ProviderInstanceId, - RuntimeRequestId, - type ThreadId, - TurnId, -} from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; -import * as DateTime from "effect/DateTime"; -import * as Deferred from "effect/Deferred"; +import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as PubSub from "effect/PubSub"; -import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import * as Semaphore from "effect/Semaphore"; -import * as Stream from "effect/Stream"; -import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; -import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import { ServerConfig } from "../../config.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; -import { - ProviderAdapterProcessError, - ProviderAdapterRequestError, - ProviderAdapterSessionNotFoundError, - ProviderAdapterValidationError, -} from "../Errors.ts"; import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; -import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; import { - makeAcpAssistantItemEvent, - makeAcpContentDeltaEvent, - makeAcpPlanUpdatedEvent, - makeAcpRequestOpenedEvent, - makeAcpRequestResolvedEvent, - makeAcpToolCallEvent, -} from "../acp/AcpCoreRuntimeEvents.ts"; -import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; -import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; + acpPromptSettlementBelongsToContext, + handleAcpUserInputRequest, +} from "../acp/AcpAdapterRuntime.ts"; +import { makeAcpAdapterLive, type AcpAdapterLiveOptions } from "../acp/AcpAdapterLive.ts"; import { applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, @@ -65,538 +22,46 @@ import { makeXAiAskUserQuestionResponse, promptResponseHasMissingXAiStopReason, XAiAskUserQuestionRequest, + type XAiAskUserQuestionResponse, } from "../acp/XAiAcpExtension.ts"; -import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; -import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; - -const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); +import { ProviderAdapterProcessError } from "../Errors.ts"; +import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = ProviderDriverKind.make("grok"); const GROK_RESUME_VERSION = 1 as const; -function encodeJsonStringForDiagnostics(input: unknown): string | undefined { - const result = encodeUnknownJsonStringExit(input); - return Exit.isSuccess(result) ? result.value : undefined; -} - -export interface GrokAdapterLiveOptions { - readonly environment?: NodeJS.ProcessEnv; - readonly nativeEventLogPath?: string; +export interface GrokAdapterLiveOptions extends AcpAdapterLiveOptions { readonly nativeEventLogger?: EventNdjsonLogger; - readonly instanceId?: ProviderInstanceId; } -interface PendingApproval { - readonly decision: Deferred.Deferred; -} - -type PendingUserInputResolution = - | { readonly _tag: "answered"; readonly answers: ProviderUserInputAnswers } - | { readonly _tag: "cancelled" }; - -interface PendingUserInput { - readonly resolution: Deferred.Deferred; -} - -interface GrokSessionContext { - readonly threadId: ThreadId; - readonly acpSessionId: string; - session: ProviderSession; - readonly scope: Scope.Closeable; - readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; - notificationFiber: Fiber.Fiber | undefined; - readonly pendingApprovals: Map; - readonly pendingUserInputs: Map; - turns: Array<{ id: TurnId; items: Array }>; - lastPlanFingerprint: string | undefined; - activeTurnId: TurnId | undefined; - /** Turns already interrupted; late prompt RPCs must not resurrect them. */ - interruptedTurnIds: Set; - /** Number of sendTurn prompts currently in flight or being prepared. - * >0 means a turn is actively running, so a new sendTurn is a steer that - * continues it, and only the last remaining prompt settles the turn. */ - promptsInFlight: number; - currentModelId: string | undefined; - stopped: boolean; -} - -function settlePendingApprovalsAsCancelled( - pendingApprovals: ReadonlyMap, -): Effect.Effect { - return Effect.forEach( - Array.from(pendingApprovals.values()), - (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), - { discard: true }, - ); -} - -function settlePendingUserInputsAsCancelled( - pendingUserInputs: ReadonlyMap, -): Effect.Effect { - return Effect.forEach( - Array.from(pendingUserInputs.values()), - (pending) => Deferred.succeed(pending.resolution, { _tag: "cancelled" }).pipe(Effect.ignore), - { discard: true }, - ); -} - -function appendPromptResultToTurn( - ctx: GrokSessionContext, - turnId: TurnId, - promptParts: ReadonlyArray, - result: EffectAcpSchema.PromptResponse, -): void { - const existingTurnRecord = ctx.turns.find((turn) => turn.id === turnId); - ctx.turns = existingTurnRecord - ? ctx.turns.map((turn) => - turn.id === turnId - ? { ...turn, items: [...turn.items, { prompt: promptParts, result }] } - : turn, - ) - : [...ctx.turns, { id: turnId, items: [{ prompt: promptParts, result }] }]; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -const resolveNotificationTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId; - -const resolveCallbackTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId; - -const resolveSessionCallbackTurnId = ( - sessions: ReadonlyMap, - threadId: ThreadId, -): TurnId | undefined => { - const ctx = sessions.get(threadId); - return ctx ? resolveCallbackTurnId(ctx) : undefined; -}; - -function parseGrokResume(raw: unknown): { sessionId: string } | undefined { - if (!isRecord(raw)) return undefined; - if (raw.schemaVersion !== GROK_RESUME_VERSION) return undefined; - if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; - return { sessionId: raw.sessionId.trim() }; -} - -function selectPermissionOptionId( - request: EffectAcpSchema.RequestPermissionRequest, - decision: Exclude, -): string | undefined { - const kind = - decision === "acceptForSession" - ? "allow_always" - : decision === "accept" - ? "allow_once" - : "reject_once"; - const option = request.options.find((entry) => entry.kind === kind); - return option?.optionId.trim() || undefined; -} - -function selectAutoApprovedPermissionOption( - request: EffectAcpSchema.RequestPermissionRequest, -): string | undefined { - return ( - selectPermissionOptionId(request, "acceptForSession") ?? - selectPermissionOptionId(request, "accept") - ); -} - -function completedStopReasonFromPromptResponse( - response: EffectAcpSchema.PromptResponse | undefined, -): EffectAcpSchema.StopReason | null { - if (response === undefined || promptResponseHasMissingXAiStopReason(response)) { - return null; - } - return response.stopReason; -} - -export function grokPromptSettlementBelongsToContext(input: { - readonly liveAcpSessionId: string; - readonly expectedAcpSessionId: string; - readonly liveActiveTurnId: TurnId | undefined; - readonly liveSessionActiveTurnId: TurnId | undefined; - readonly turnId: TurnId; -}): boolean { - return ( - input.liveAcpSessionId === input.expectedAcpSessionId && - (input.liveActiveTurnId === input.turnId || input.liveSessionActiveTurnId === input.turnId) - ); -} +export const grokPromptSettlementBelongsToContext = acpPromptSettlementBelongsToContext; export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapterLiveOptions) { return Effect.gen(function* () { - const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("grok"); - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const serverConfig = yield* Effect.service(ServerConfig); - const crypto = yield* Crypto.Crypto; - const nativeEventLogger = - options?.nativeEventLogger ?? - (options?.nativeEventLogPath !== undefined - ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) - : undefined); - const managedNativeEventLogger = - options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; - const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); - - const sessions = new Map(); - const threadLocksRef = yield* SynchronizedRef.make(new Map()); - const runtimeEventPubSub = yield* PubSub.unbounded(); - const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - const randomUUIDv4 = crypto.randomUUIDv4.pipe( - Effect.mapError( - (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "crypto/randomUUIDv4", - detail: "Failed to generate Grok runtime identifier.", - cause, - }), - ), - ); - const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); - const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); - const mapAcpCallbackFailure = (effect: Effect.Effect) => - effect.pipe( - Effect.mapError( - (cause) => - new EffectAcpErrors.AcpTransportError({ - detail: "Failed to process Grok ACP callback.", - cause, - }), - ), - ); - - const offerRuntimeEvent = (event: ProviderRuntimeEvent) => - PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); - - const getThreadSemaphore = (threadId: string) => - SynchronizedRef.modifyEffect(threadLocksRef, (current) => { - const existing: Option.Option = Option.fromNullishOr( - current.get(threadId), - ); - return Option.match(existing, { - onNone: () => - Semaphore.make(1).pipe( - Effect.map((semaphore) => { - const next = new Map(current); - next.set(threadId, semaphore); - return [semaphore, next] as const; - }), - ), - onSome: (semaphore) => Effect.succeed([semaphore, current] as const), - }); - }); - - const withThreadLock = (threadId: string, effect: Effect.Effect) => - Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); - - const settlePromptInFlight = ( - threadId: ThreadId, - turnId: TurnId, - expectedAcpSessionId: string, - options?: { - readonly errorMessage?: string; - readonly completedStopReason?: EffectAcpSchema.StopReason | null; - readonly emitTurnCompletion?: boolean; - /** Interrupt/cancel: drop every outstanding prompt slot and settle once. */ - readonly settleAllPrompts?: boolean; - }, - ) => - Effect.gen(function* () { - const liveCtx = sessions.get(threadId); - if (!liveCtx) { - return; - } - const settlementBelongsToLiveContext = grokPromptSettlementBelongsToContext({ - liveAcpSessionId: liveCtx.acpSessionId, - expectedAcpSessionId, - liveActiveTurnId: liveCtx.activeTurnId, - liveSessionActiveTurnId: liveCtx.session.activeTurnId, - turnId, - }); - if (!settlementBelongsToLiveContext) { - // interruptTurn already consumed every prompt slot for this turn. A - // late prompt result must neither emit a second terminal event nor - // consume a slot belonging to a newer turn on the same ACP session. - if ( - liveCtx.acpSessionId !== expectedAcpSessionId || - liveCtx.interruptedTurnIds.has(turnId) - ) { - return; - } - if (options?.emitTurnCompletion !== false) { - if (options?.errorMessage !== undefined) { - yield* offerRuntimeEvent({ - type: "turn.completed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId, - turnId, - payload: { - state: "failed", - errorMessage: options.errorMessage, - }, - }); - } else if (options?.completedStopReason !== undefined) { - yield* offerRuntimeEvent({ - type: "turn.completed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId, - turnId, - payload: { - state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", - stopReason: options.completedStopReason ?? null, - }, - }); - } - } - return; - } - let settleTurnId = turnId; - if (options?.settleAllPrompts) { - liveCtx.promptsInFlight = 0; - if (liveCtx.activeTurnId !== turnId && liveCtx.session.activeTurnId !== turnId) { - const fallbackTurnId = liveCtx.activeTurnId ?? liveCtx.session.activeTurnId; - if (!fallbackTurnId) { - if (liveCtx.session.status === "running" || liveCtx.session.status === "connecting") { - const updatedAt = yield* nowIso; - const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; - liveCtx.activeTurnId = undefined; - liveCtx.session = { - ...readySession, - status: "ready", - updatedAt, - }; - } - return; - } - settleTurnId = fallbackTurnId; - } - } else { - const remainingPrompts = Math.max(0, liveCtx.promptsInFlight - 1); - if ( - remainingPrompts > 0 || - liveCtx.activeTurnId !== settleTurnId || - liveCtx.session.activeTurnId !== settleTurnId - ) { - liveCtx.promptsInFlight = remainingPrompts; - return; - } - liveCtx.promptsInFlight = remainingPrompts; - } - const updatedAt = yield* nowIso; - const canEmitTurnCompletion = - liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; - const shouldEmitFailedTurn = options?.errorMessage !== undefined && canEmitTurnCompletion; - const shouldEmitCompletedTurn = - options?.completedStopReason !== undefined && canEmitTurnCompletion; - const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; - liveCtx.activeTurnId = undefined; - liveCtx.session = { - ...readySession, - status: "ready", - updatedAt, - }; - if (options?.emitTurnCompletion === false) { - return; - } - if (shouldEmitFailedTurn) { - yield* offerRuntimeEvent({ - type: "turn.completed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId, - turnId: settleTurnId, - payload: { - state: "failed", - errorMessage: options.errorMessage, - }, - }); - } else if (shouldEmitCompletedTurn) { - yield* offerRuntimeEvent({ - type: "turn.completed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId, - turnId: settleTurnId, - payload: { - state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", - stopReason: options.completedStopReason ?? null, - }, - }); - } - }); - - const logNative = (threadId: ThreadId, method: string, payload: unknown) => - Effect.gen(function* () { - if (!nativeEventLogger) return; - const observedAt = yield* nowIso; - yield* nativeEventLogger.write( - { - observedAt, - event: { - id: yield* randomUUIDv4, - kind: "notification", - provider: PROVIDER, - createdAt: observedAt, - method, - threadId, - payload, - }, - }, - threadId, - ); - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Failed to write native Grok notification log.", { - cause, - threadId, - method, - }), - ), - ); - - const emitPlanUpdate = ( - ctx: GrokSessionContext, - turnId: TurnId | undefined, - stamp: { readonly eventId: EventId; readonly createdAt: string }, - payload: { - readonly explanation?: string | null; - readonly plan: ReadonlyArray<{ - readonly step: string; - readonly status: "pending" | "inProgress" | "completed"; - }>; - }, - rawPayload: unknown, - method: string, - ) => - Effect.gen(function* () { - const fingerprint = `${turnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; - if (ctx.lastPlanFingerprint === fingerprint) { - return; - } - ctx.lastPlanFingerprint = fingerprint; - yield* offerRuntimeEvent( - makeAcpPlanUpdatedEvent({ - stamp, - provider: PROVIDER, - threadId: ctx.threadId, - turnId, - payload, - source: "acp.jsonrpc", - method, - rawPayload, - }), - ); - }); - - const requireSession = ( - threadId: ThreadId, - ): Effect.Effect => { - const ctx = sessions.get(threadId); - if (!ctx || ctx.stopped) { - return Effect.fail( - new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), - ); - } - return Effect.succeed(ctx); - }; - - const stopSessionInternal = (ctx: GrokSessionContext) => - Effect.gen(function* () { - if (ctx.stopped) return; - ctx.stopped = true; - yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); - yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); - if (ctx.notificationFiber) { - yield* Fiber.interrupt(ctx.notificationFiber); - } - yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); - sessions.delete(ctx.threadId); - yield* offerRuntimeEvent({ - type: "session.exited", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: ctx.threadId, - payload: { exitKind: "graceful" }, - }); - }); - - const startSession: GrokAdapterShape["startSession"] = (input) => - withThreadLock( - input.threadId, - Effect.gen(function* () { - if (input.provider !== undefined && input.provider !== PROVIDER) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, - }); - } - if (!input.cwd?.trim()) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: "cwd is required and must be non-empty.", - }); - } - - const cwd = path.resolve(input.cwd.trim()); - const grokModelSelection = - input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; - const existing = sessions.get(input.threadId); - if (existing && !existing.stopped) { - yield* stopSessionInternal(existing); - } - - const pendingApprovals = new Map(); - const pendingUserInputs = new Map(); - const sessionScope = yield* Scope.make("sequential"); - let sessionScopeTransferred = false; - yield* Effect.addFinalizer(() => - sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), - ); - - const resumeSessionId = parseGrokResume(input.resumeCursor)?.sessionId; - const acpNativeLoggers = makeAcpNativeLoggers({ - nativeEventLogger, - provider: PROVIDER, - threadId: input.threadId, - }); - - const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); - const acp = yield* makeGrokAcpRuntime({ + return yield* makeAcpAdapterLive( + { + provider: PROVIDER, + providerLabel: "Grok", + resumeSchemaVersion: GROK_RESUME_VERSION, + readyReason: "Grok ACP session ready", + respondToUserInputMethod: "_x.ai/ask_user_question", + capabilities: { sessionModelSwitch: "in-session" }, + completedStopReasonFromPromptResponse: (response: EffectAcpSchema.PromptResponse) => + promptResponseHasMissingXAiStopReason(response) ? null : response.stopReason, + makeAcpRuntime: (input) => + makeGrokAcpRuntime({ grokSettings, ...(options?.environment ? { environment: options.environment } : {}), childProcessSpawner, - cwd, - ...(resumeSessionId ? { resumeSessionId } : {}), + cwd: input.cwd, + ...(input.resumeSessionId ? { resumeSessionId: input.resumeSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, - ...(mcpSession - ? { - mcpServers: [ - { - type: "http" as const, - name: "t3-code", - url: mcpSession.endpoint, - headers: [ - { - name: "Authorization", - value: mcpSession.authorizationHeader, - }, - ], - }, - ], - } - : {}), - ...acpNativeLoggers, + ...(input.mcpServers ? { mcpServers: input.mcpServers } : {}), + ...input.acpNativeLoggers, }).pipe( - Effect.provideService(Scope.Scope, sessionScope), + Effect.provideService(Scope.Scope, input.sessionScope), Effect.mapError( (cause) => new ProviderAdapterProcessError({ @@ -606,858 +71,71 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte cause, }), ), - ); - const started = yield* Effect.gen(function* () { - yield* Effect.forEach( - ["x.ai/ask_user_question", "_x.ai/ask_user_question"] as const, - (method) => - acp.handleExtRequest(method, XAiAskUserQuestionRequest, (params) => - mapAcpCallbackFailure( - Effect.gen(function* () { - yield* logNative(input.threadId, method, params); - const requestId = ApprovalRequestId.make(yield* randomUUIDv4); - const runtimeRequestId = RuntimeRequestId.make(requestId); - const resolution = yield* Deferred.make(); - const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); - pendingUserInputs.set(requestId, { resolution }); - yield* offerRuntimeEvent({ - type: "user-input.requested", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId, - requestId: runtimeRequestId, - payload: { questions: extractXAiAskUserQuestions(params) }, - raw: { - source: "acp.grok.extension", - method, - payload: params, - }, - }); - const resolved = yield* Deferred.await(resolution); - pendingUserInputs.delete(requestId); - const resolvedAnswers = resolved._tag === "answered" ? resolved.answers : {}; - yield* offerRuntimeEvent({ - type: "user-input.resolved", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId, - requestId: runtimeRequestId, - payload: { answers: resolvedAnswers }, - raw: { - source: "acp.grok.extension", - method, - payload: params, - }, - }); - switch (resolved._tag) { - case "answered": - return makeXAiAskUserQuestionResponse(params, resolved.answers); - case "cancelled": - return makeXAiAskUserQuestionCancelledResponse(); - } - }), - ), + ), + registerAcpCallbacks: (input) => + Effect.forEach( + ["x.ai/ask_user_question", "_x.ai/ask_user_question"] as const, + (method) => + input.acp.handleExtRequest(method, XAiAskUserQuestionRequest, (params) => + input.mapAcpCallbackFailure( + handleAcpUserInputRequest({ + provider: PROVIDER, + threadId: input.threadId, + method, + source: "acp.grok.extension", + request: params, + prompt: { + questions: extractXAiAskUserQuestions(params), + makeResponse: (answers) => makeXAiAskUserQuestionResponse(params, answers), + makeCancelledResponse: makeXAiAskUserQuestionCancelledResponse, + }, + pendingUserInputs: input.pendingUserInputs, + resolveTurnId: input.resolveActiveTurnId, + makeRequestId: input.nextApprovalRequestId, + makeEventStamp: input.makeEventStamp, + offerRuntimeEvent: input.offerRuntimeEvent, + logNative: input.logNative, + }), ), - { discard: true }, - ); - yield* acp.handleRequestPermission((params) => - mapAcpCallbackFailure( - Effect.gen(function* () { - yield* logNative(input.threadId, "session/request_permission", params); - if (input.runtimeMode === "full-access") { - const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); - if (autoApprovedOptionId !== undefined) { - return { - outcome: { - outcome: "selected" as const, - optionId: autoApprovedOptionId, - }, - }; - } - } - const permissionRequest = parsePermissionRequest(params); - const requestId = ApprovalRequestId.make(yield* randomUUIDv4); - const runtimeRequestId = RuntimeRequestId.make(requestId); - const decision = yield* Deferred.make(); - const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); - pendingApprovals.set(requestId, { decision }); - yield* offerRuntimeEvent( - makeAcpRequestOpenedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: input.threadId, - turnId, - requestId: runtimeRequestId, - permissionRequest, - detail: - permissionRequest.detail ?? - encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? - "[unserializable params]", - args: params, - source: "acp.jsonrpc", - method: "session/request_permission", - rawPayload: params, - }), - ); - const resolved = yield* Deferred.await(decision); - pendingApprovals.delete(requestId); - yield* offerRuntimeEvent( - makeAcpRequestResolvedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: input.threadId, - turnId, - requestId: runtimeRequestId, - permissionRequest, - decision: resolved, - }), - ); - const selectedOptionId = - resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved); - return { - outcome: selectedOptionId - ? { - outcome: "selected" as const, - optionId: selectedOptionId, - } - : ({ outcome: "cancelled" } as const), - }; - }), ), - ); - return yield* acp.start(); - }).pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), - ), - ); - - const requestedStartModelId = grokModelSelection?.model - ? resolveGrokAcpBaseModelId(grokModelSelection.model) - : undefined; - const boundModelId = yield* applyGrokAcpModelSelection({ - runtime: acp, - currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), - requestedModelId: requestedStartModelId, - mapError: (cause) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), - }); - - const now = yield* nowIso; - const session: ProviderSession = { - provider: PROVIDER, - providerInstanceId: boundInstanceId, - status: "ready", - runtimeMode: input.runtimeMode, - cwd, - ...(boundModelId ? { model: resolveGrokAcpBaseModelId(boundModelId) } : {}), - threadId: input.threadId, - resumeCursor: { - schemaVersion: GROK_RESUME_VERSION, - sessionId: started.sessionId, - }, - createdAt: now, - updatedAt: now, - }; - - const ctx: GrokSessionContext = { - threadId: input.threadId, - acpSessionId: started.sessionId, - session, - scope: sessionScope, - acp, - notificationFiber: undefined, - pendingApprovals, - pendingUserInputs, - turns: [], - lastPlanFingerprint: undefined, - activeTurnId: undefined, - interruptedTurnIds: new Set(), - promptsInFlight: 0, - currentModelId: boundModelId, - stopped: false, - }; - - const nf = yield* Stream.runDrain( - Stream.mapEffect(acp.getEvents(), (event) => - Effect.gen(function* () { - if (event._tag === "EventStreamBarrier") { - yield* Deferred.succeed(event.acknowledge, undefined); - return; - } - if ( - event._tag === "PlanUpdated" || - event._tag === "ToolCallUpdated" || - event._tag === "ContentDelta" - ) { - yield* logNative(ctx.threadId, "session/update", event.rawPayload); - } - - if (event._tag === "ModeChanged") { - return; - } - - const notificationTurnId = resolveNotificationTurnId(ctx); - if ( - notificationTurnId === undefined || - ctx.interruptedTurnIds.has(notificationTurnId) - ) { - return; - } - const stamp = yield* makeEventStamp(); - - switch (event._tag) { - case "AssistantItemStarted": - yield* offerRuntimeEvent( - makeAcpAssistantItemEvent({ - stamp, - provider: PROVIDER, - threadId: ctx.threadId, - turnId: notificationTurnId, - itemId: event.itemId, - lifecycle: "item.started", - }), - ); - return; - case "AssistantItemCompleted": - yield* offerRuntimeEvent( - makeAcpAssistantItemEvent({ - stamp, - provider: PROVIDER, - threadId: ctx.threadId, - turnId: notificationTurnId, - itemId: event.itemId, - lifecycle: "item.completed", - }), - ); - return; - case "PlanUpdated": - yield* emitPlanUpdate( - ctx, - notificationTurnId, - stamp, - event.payload, - event.rawPayload, - "session/update", - ); - return; - case "ToolCallUpdated": - yield* offerRuntimeEvent( - makeAcpToolCallEvent({ - stamp, - provider: PROVIDER, - threadId: ctx.threadId, - turnId: notificationTurnId, - toolCall: event.toolCall, - rawPayload: event.rawPayload, - }), - ); - return; - case "ContentDelta": - yield* offerRuntimeEvent( - makeAcpContentDeltaEvent({ - stamp, - provider: PROVIDER, - threadId: ctx.threadId, - turnId: notificationTurnId, - ...(event.itemId ? { itemId: event.itemId } : {}), - text: event.text, - rawPayload: event.rawPayload, - }), - ); - return; - } - }), - ), - ).pipe( - Effect.catch((cause) => - Effect.logError("Failed to process Grok runtime notification.", { cause }), - ), - Effect.forkChild, - ); - - ctx.notificationFiber = nf; - sessions.set(input.threadId, ctx); - sessionScopeTransferred = true; - - yield* offerRuntimeEvent({ - type: "session.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - payload: { resume: started.initializeResult }, - }); - yield* offerRuntimeEvent({ - type: "session.state.changed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - payload: { state: "ready", reason: "Grok ACP session ready" }, - }); - yield* offerRuntimeEvent({ - type: "thread.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - payload: { providerThreadId: started.sessionId }, - }); - - return session; - }).pipe(Effect.scoped), - ); - - const sendTurn: GrokAdapterShape["sendTurn"] = (input) => - Effect.gen(function* () { - const prepared = yield* withThreadLock( - input.threadId, + { discard: true }, + ), + bindSessionModel: (input) => Effect.gen(function* () { - const ctx = yield* requireSession(input.threadId); - // A sendTurn while a prompt is in flight is a steer: the agent - // folds the new prompt into the ongoing work, so the active turn - // id is reused instead of opening a new turn. - const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; - const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); - // Count this prompt immediately so a superseded in-flight prompt - // resolving from here on does not settle the turn; decremented on - // preparation failure here, and after the prompt below otherwise. - ctx.promptsInFlight += 1; - // Bind the turn id before cooperative yields so interruptTurn can - // settle this prompt even if stop arrives during preparation. - ctx.activeTurnId = turnId; - ctx.session = { - ...ctx.session, - status: steeringTurnId === undefined ? "connecting" : "running", - activeTurnId: turnId, - updatedAt: yield* nowIso, + const requestedStartModelId = input.modelSelection?.model + ? resolveGrokAcpBaseModelId(input.modelSelection.model) + : undefined; + const boundModelId = yield* applyGrokAcpModelSelection({ + runtime: input.acp, + currentModelId: currentGrokModelIdFromSessionSetup(input.sessionSetupResult), + requestedModelId: requestedStartModelId, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); + return { + currentModelId: boundModelId, + displayModel: boundModelId ? resolveGrokAcpBaseModelId(boundModelId) : undefined, }; - - return yield* Effect.gen(function* () { - const turnModelSelection = - input.modelSelection?.instanceId === boundInstanceId - ? input.modelSelection - : undefined; - const requestedTurnModelId = turnModelSelection?.model - ? resolveGrokAcpBaseModelId(turnModelSelection.model) - : undefined; - const currentModelId = yield* applyGrokAcpModelSelection({ - runtime: ctx.acp, - currentModelId: ctx.currentModelId, - requestedModelId: requestedTurnModelId, - mapError: (cause) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), - }); - - const text = input.input?.trim(); - const imagePromptParts = yield* Effect.forEach( - input.attachments ?? [], - (attachment) => - Effect.gen(function* () { - const attachmentPath = resolveAttachmentPath({ - attachmentsDir: serverConfig.attachmentsDir, - attachment, - }); - if (!attachmentPath) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/prompt", - detail: `Invalid attachment id '${attachment.id}'.`, - }); - } - const bytes = yield* fileSystem.readFile(attachmentPath).pipe( - Effect.mapError( - (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/prompt", - detail: cause.message, - cause, - }), - ), - ); - return { - type: "image", - data: Buffer.from(bytes).toString("base64"), - mimeType: attachment.mimeType, - } satisfies EffectAcpSchema.ContentBlock; - }), - ); - const promptParts: Array = [ - ...(text ? [{ type: "text" as const, text }] : []), - ...imagePromptParts, - ]; - - if (promptParts.length === 0) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: "Turn requires non-empty text or attachments.", - }); - } - - ctx.currentModelId = currentModelId; - const displayModel = currentModelId - ? resolveGrokAcpBaseModelId(currentModelId) - : undefined; - for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { - yield* Effect.yieldNow; - } - if (ctx.interruptedTurnIds.has(turnId)) { - yield* settlePromptInFlight(input.threadId, turnId, ctx.acpSessionId, { - completedStopReason: "cancelled", - emitTurnCompletion: false, - settleAllPrompts: true, - }); - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/prompt", - detail: "Grok prompt was interrupted during preparation.", - }); - } - if (steeringTurnId === undefined) { - ctx.lastPlanFingerprint = undefined; - } - ctx.session = { - ...ctx.session, - status: "running", - activeTurnId: turnId, - updatedAt: yield* nowIso, - ...(displayModel ? { model: displayModel } : {}), - }; - - if (steeringTurnId === undefined) { - yield* offerRuntimeEvent({ - type: "turn.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId, - payload: displayModel ? { model: displayModel } : {}, - }); - } - - return { - acp: ctx.acp, - acpSessionId: ctx.acpSessionId, - displayModel, - promptParts, - turnId, - }; - }).pipe( - Effect.tapCause(() => - Effect.gen(function* () { - const liveCtx = sessions.get(input.threadId); - if (!liveCtx) { - return; - } - yield* settlePromptInFlight(input.threadId, turnId, liveCtx.acpSessionId, { - errorMessage: "Grok prompt preparation failed.", - emitTurnCompletion: false, - }); - }), - ), - ); }), - ); - const promptSettled = yield* Ref.make(false); - const promptRpcSucceeded = yield* Ref.make(false); - const promptResultRef = yield* Ref.make( - undefined, - ); - - const promptFailureMessageRef = yield* Ref.make(undefined); - - return yield* Effect.gen(function* () { - const result = yield* prepared.acp - .prompt({ - prompt: prepared.promptParts, - }) - .pipe( - Effect.tap((promptResult) => - Effect.all([ - Ref.set(promptRpcSucceeded, true), - Ref.set(promptResultRef, promptResult), - ]), - ), - Effect.tapError((error) => - Ref.set( - promptFailureMessageRef, - mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message, - ).pipe(Effect.andThen(prepared.acp.drainEvents)), - ), - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), - ), - ); - - return yield* withThreadLock( - input.threadId, - Effect.gen(function* () { - const ctx = yield* requireSession(input.threadId); - if (ctx.acpSessionId !== prepared.acpSessionId) { - yield* settlePromptInFlight( - input.threadId, - prepared.turnId, - prepared.acpSessionId, - { - errorMessage: "Grok session changed before the turn completed.", - settleAllPrompts: true, - }, - ); - yield* Ref.set(promptSettled, true); - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/prompt", - detail: "Grok session changed before the turn completed.", - }); - } - // Keep prompt settlement atomic with respect to Stop and steering. - // interruptTurn marks its target before waiting for this lock, so - // cancellation can still win while queued ACP events are drained. - for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { - yield* Effect.yieldNow; - } - yield* prepared.acp.drainEvents; - if (ctx.interruptedTurnIds.has(prepared.turnId)) { - yield* Ref.set(promptSettled, true); - return { - threadId: input.threadId, - turnId: prepared.turnId, - resumeCursor: ctx.session.resumeCursor, - }; - } - - if ( - ctx.promptsInFlight <= 0 || - ctx.activeTurnId !== prepared.turnId || - ctx.session.activeTurnId !== prepared.turnId - ) { - yield* Ref.set(promptSettled, true); - return { - threadId: input.threadId, - turnId: prepared.turnId, - resumeCursor: ctx.session.resumeCursor, - }; - } - - appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); - ctx.session = { - ...ctx.session, - status: "running", - activeTurnId: prepared.turnId, - updatedAt: yield* nowIso, - ...(prepared.displayModel ? { model: prepared.displayModel } : {}), - }; - const remainingPrompts = Math.max(0, ctx.promptsInFlight - 1); - ctx.promptsInFlight = remainingPrompts; - - // Only the last remaining prompt settles the turn. A steer- - // superseded prompt resolving while another is in flight or - // pending must leave the merged turn running. - if ( - remainingPrompts === 0 && - ctx.activeTurnId === prepared.turnId && - ctx.session.activeTurnId === prepared.turnId - ) { - if (ctx.interruptedTurnIds.has(prepared.turnId)) { - yield* Ref.set(promptSettled, true); - return { - threadId: input.threadId, - turnId: prepared.turnId, - resumeCursor: ctx.session.resumeCursor, - }; - } - const completedAt = yield* nowIso; - const { activeTurnId: _completedTurnId, ...readySession } = ctx.session; - ctx.activeTurnId = undefined; - ctx.session = { - ...readySession, - status: "ready", - updatedAt: completedAt, - ...(prepared.displayModel ? { model: prepared.displayModel } : {}), - }; - const completedStopReason = completedStopReasonFromPromptResponse(result); - yield* offerRuntimeEvent({ - type: "turn.completed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId: prepared.turnId, - payload: { - state: result.stopReason === "cancelled" ? "cancelled" : "completed", - stopReason: completedStopReason, - }, - }); - ctx.interruptedTurnIds.delete(prepared.turnId); - yield* Ref.set(promptSettled, true); - } else if (remainingPrompts > 0) { - yield* Ref.set(promptSettled, true); - } - - return { - threadId: input.threadId, - turnId: prepared.turnId, - resumeCursor: ctx.session.resumeCursor, - }; - }), - ); - }).pipe( - Effect.ensuring( - Effect.gen(function* () { - if (yield* Ref.get(promptSettled)) { - return; - } - - if (yield* Ref.get(promptRpcSucceeded)) { - const promptResult = yield* Ref.get(promptResultRef); - if (promptResult === undefined) { - return; - } - yield* withThreadLock( - input.threadId, - Effect.gen(function* () { - const ctx = yield* requireSession(input.threadId); - if (ctx.acpSessionId !== prepared.acpSessionId) { - yield* settlePromptInFlight( - input.threadId, - prepared.turnId, - prepared.acpSessionId, - { - errorMessage: "Grok session changed before the turn completed.", - settleAllPrompts: true, - }, - ); - return; - } - if (ctx.interruptedTurnIds.has(prepared.turnId)) { - return; - } - if ( - ctx.promptsInFlight <= 0 || - ctx.activeTurnId !== prepared.turnId || - ctx.session.activeTurnId !== prepared.turnId - ) { - return; - } - appendPromptResultToTurn( - ctx, - prepared.turnId, - prepared.promptParts, - promptResult, - ); - yield* settlePromptInFlight( - input.threadId, - prepared.turnId, - prepared.acpSessionId, - { - completedStopReason: completedStopReasonFromPromptResponse(promptResult), - }, - ); - }), - ); - return; - } - - const errorMessage = yield* Ref.get(promptFailureMessageRef); - yield* withThreadLock( - input.threadId, - settlePromptInFlight(input.threadId, prepared.turnId, prepared.acpSessionId, { - errorMessage: errorMessage ?? "Grok prompt request failed.", - }), - ); - }).pipe(Effect.catch(() => Effect.void)), - ), - ); - }); - - const interruptTurn: GrokAdapterShape["interruptTurn"] = (threadId, turnId) => - Effect.gen(function* () { - const observed = yield* Effect.sync(() => { - const ctx = sessions.get(threadId); - if (!ctx || ctx.stopped) { + prepareTurnModel: (input) => + Effect.gen(function* () { + const requestedTurnModelId = input.modelSelection?.model + ? resolveGrokAcpBaseModelId(input.modelSelection.model) + : undefined; + const currentModelId = yield* applyGrokAcpModelSelection({ + runtime: input.ctx.acp, + currentModelId: input.ctx.currentModelId, + requestedModelId: requestedTurnModelId, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + }); return { - _tag: "Proceed" as const, - acpSessionId: undefined, - interruptedTurnId: turnId, + currentModelId, + displayModel: currentModelId ? resolveGrokAcpBaseModelId(currentModelId) : undefined, }; - } - const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; - if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { - return { _tag: "Ignore" as const }; - } - const interruptedTurnId = turnId ?? activeTurnId; - if (interruptedTurnId !== undefined) { - ctx.interruptedTurnIds.add(interruptedTurnId); - } - return { - _tag: "Proceed" as const, - acpSessionId: ctx.acpSessionId, - interruptedTurnId, - }; - }); - if (observed._tag === "Ignore") { - return; - } - - yield* withThreadLock( - threadId, - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - if (observed.acpSessionId !== undefined && ctx.acpSessionId !== observed.acpSessionId) { - return; - } - const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; - if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { - return; - } - if ( - observed.interruptedTurnId !== undefined && - activeTurnId !== undefined && - activeTurnId !== observed.interruptedTurnId - ) { - return; - } - const interruptedTurnId = - observed.interruptedTurnId ?? turnId ?? activeTurnId ?? ctx.session.activeTurnId; - yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); - yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); - yield* Effect.ignore( - ctx.acp.cancel.pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), - ), - ), - ); - if (interruptedTurnId) { - ctx.interruptedTurnIds.add(interruptedTurnId); - yield* settlePromptInFlight(threadId, interruptedTurnId, ctx.acpSessionId, { - completedStopReason: "cancelled", - settleAllPrompts: true, - }); - } else if ( - ctx.promptsInFlight > 0 || - ctx.session.status === "running" || - ctx.session.status === "connecting" - ) { - const updatedAt = yield* nowIso; - ctx.promptsInFlight = 0; - ctx.activeTurnId = undefined; - const { activeTurnId: _activeTurnId, ...readySession } = ctx.session; - ctx.session = { - ...readySession, - status: "ready", - updatedAt, - }; - } }), - ); - }); - - const respondToRequest: GrokAdapterShape["respondToRequest"] = ( - threadId, - requestId, - decision, - ) => - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - const pending = ctx.pendingApprovals.get(requestId); - if (!pending) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/request_permission", - detail: `Unknown pending approval request: ${requestId}`, - }); - } - yield* Deferred.succeed(pending.decision, decision); - }); - - const respondToUserInput: GrokAdapterShape["respondToUserInput"] = ( - threadId, - requestId, - answers, - ) => - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - const pending = ctx.pendingUserInputs.get(requestId); - if (!pending) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "_x.ai/ask_user_question", - detail: `Unknown pending user-input request: ${requestId}`, - }); - } - yield* Deferred.succeed(pending.resolution, { _tag: "answered", answers }); - }); - - const readThread: GrokAdapterShape["readThread"] = (threadId) => - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - return { threadId, turns: ctx.turns }; - }); - - const rollbackThread: GrokAdapterShape["rollbackThread"] = (threadId, numTurns) => - Effect.gen(function* () { - yield* requireSession(threadId); - if (!Number.isInteger(numTurns) || numTurns < 1) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "rollbackThread", - issue: "numTurns must be an integer >= 1.", - }); - } - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "thread/rollback", - detail: "Grok ACP sessions do not support provider-side rollback yet.", - }); - }); - - const stopSession: GrokAdapterShape["stopSession"] = (threadId) => - withThreadLock( - threadId, - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - yield* stopSessionInternal(ctx); - }), - ); - - const listSessions: GrokAdapterShape["listSessions"] = () => - Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); - - const hasSession: GrokAdapterShape["hasSession"] = (threadId) => - Effect.sync(() => { - const c = sessions.get(threadId); - return c !== undefined && !c.stopped; - }); - - const stopAll: GrokAdapterShape["stopAll"] = () => - Effect.forEach(Array.from(sessions.values()), stopSessionInternal, { discard: true }); - - yield* Effect.addFinalizer(() => - Effect.ignore(stopAll()).pipe( - Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), - Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), - ), + }, + options, ); - - const streamEvents = Stream.fromPubSub(runtimeEventPubSub); - - return { - provider: PROVIDER, - capabilities: { sessionModelSwitch: "in-session" }, - startSession, - sendTurn, - interruptTurn, - readThread, - rollbackThread, - respondToRequest, - respondToUserInput, - stopSession, - listSessions, - hasSession, - stopAll, - streamEvents, - } satisfies GrokAdapterShape; }); } diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index dbfa7faffea..e2df2038700 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -10,7 +10,7 @@ * * 2. **Many drivers, one registry** — the "all drivers slice" describe * block below configures one instance of every shipped driver - * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single + * (`codex`, `claudeAgent`, `cursor`, `devin`, `grok`, `opencode`) in a single * `ProviderInstanceConfigMap` and asserts the registry boots them all * without cross-contamination. This proves the driver SPI is uniform * across every provider — any driver plugs into the registry through @@ -18,8 +18,8 @@ * * Every instance in these tests is configured with `enabled: false` so the * provider-status checks short-circuit to pending/disabled snapshots - * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode` - * binaries. That keeps the assertions focused on registry routing + * without trying to spawn real `codex` / `claude` / `agent` / `devin` / + * `grok` / `opencode` binaries. That keeps the assertions focused on registry routing * behaviour rather than the runtime details of each provider. */ import { describe, expect, it } from "@effect/vitest"; @@ -28,6 +28,7 @@ import { type ClaudeSettings, type CodexSettings, type CursorSettings, + type DevinSettings, type GrokSettings, type OpenCodeSettings, ProviderDriverKind, @@ -36,6 +37,7 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../../config.ts"; @@ -43,12 +45,16 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; +import { DevinDriver } from "../Drivers/DevinDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; +const normalizedHomeGroupKey = (path: Path.Path, provider: string, homePath: string) => + `${provider}:home:${path.resolve(homePath)}`; + const TestHttpClientLive = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => @@ -89,6 +95,14 @@ const makeGrokConfig = (overrides: Partial): GrokSettings => ({ ...overrides, }); +const makeDevinConfig = (overrides: Partial): DevinSettings => ({ + enabled: false, + binaryPath: "devin", + configPath: "", + customModels: [], + ...overrides, +}); + const makeOpenCodeConfig = (overrides: Partial): OpenCodeSettings => ({ enabled: false, binaryPath: "opencode", @@ -118,6 +132,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { const personalId = ProviderInstanceId.make("codex_personal"); const workId = ProviderInstanceId.make("codex_work"); const codexDriverKind = ProviderDriverKind.make("codex"); + const path = yield* Path.Path; const configMap: ProviderInstanceConfigMap = { [personalId]: { @@ -172,14 +187,16 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { expect(personalSnapshot.driver).toBe(codexDriverKind); expect(personalSnapshot.enabled).toBe(false); expect(personalSnapshot.continuation?.groupKey).toBe( - "codex:home:/home/julius/.codex_personal", + normalizedHomeGroupKey(path, "codex", "/home/julius/.codex_personal"), ); const workSnapshot = yield* work!.snapshot.getSnapshot; expect(workSnapshot.instanceId).toBe(workId); expect(workSnapshot.driver).toBe(codexDriverKind); expect(workSnapshot.enabled).toBe(false); - expect(workSnapshot.continuation?.groupKey).toBe("codex:home:/home/julius/.codex"); + expect(workSnapshot.continuation?.groupKey).toBe( + normalizedHomeGroupKey(path, "codex", "/home/julius/.codex"), + ); // Nothing goes to the unavailable bucket — both drivers are registered. const unavailable = yield* registry.listUnavailable; @@ -256,14 +273,17 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const codexId = ProviderInstanceId.make("codex_default"); const claudeId = ProviderInstanceId.make("claude_default"); const cursorId = ProviderInstanceId.make("cursor_default"); + const devinId = ProviderInstanceId.make("devin_default"); const grokId = ProviderInstanceId.make("grok_default"); const openCodeId = ProviderInstanceId.make("opencode_default"); const codexDriverKind = ProviderDriverKind.make("codex"); const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); const cursorDriverKind = ProviderDriverKind.make("cursor"); + const devinDriverKind = ProviderDriverKind.make("devin"); const grokDriverKind = ProviderDriverKind.make("grok"); const openCodeDriverKind = ProviderDriverKind.make("opencode"); + const path = yield* Path.Path; const configMap: ProviderInstanceConfigMap = { [codexId]: { @@ -287,6 +307,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { enabled: false, config: makeCursorConfig({}), }, + [devinId]: { + driver: devinDriverKind, + displayName: "Devin", + enabled: false, + config: makeDevinConfig({}), + }, [grokId]: { driver: grokDriverKind, displayName: "Grok", @@ -302,7 +328,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], + drivers: [CodexDriver, ClaudeDriver, CursorDriver, DevinDriver, GrokDriver, OpenCodeDriver], configMap, }); @@ -312,9 +338,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(unavailable).toEqual([]); const instances = yield* registry.listInstances; - expect(instances).toHaveLength(5); + expect(instances).toHaveLength(6); expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), + [codexId, claudeId, cursorId, devinId, grokId, openCodeId].toSorted(), ); // Instance lookup by id resolves each instance to its own bundle — @@ -323,16 +349,19 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const codex = yield* registry.getInstance(codexId); const claude = yield* registry.getInstance(claudeId); const cursor = yield* registry.getInstance(cursorId); + const devin = yield* registry.getInstance(devinId); const grok = yield* registry.getInstance(grokId); const openCode = yield* registry.getInstance(openCodeId); expect(codex?.driverKind).toBe(codexDriverKind); expect(claude?.driverKind).toBe(claudeDriverKind); expect(cursor?.driverKind).toBe(cursorDriverKind); + expect(devin?.driverKind).toBe(devinDriverKind); expect(grok?.driverKind).toBe(grokDriverKind); expect(openCode?.driverKind).toBe(openCodeDriverKind); expect(codex?.displayName).toBe("Codex"); expect(claude?.displayName).toBe("Claude"); expect(cursor?.displayName).toBe("Cursor"); + expect(devin?.displayName).toBe("Devin"); expect(grok?.displayName).toBe("Grok"); expect(openCode?.displayName).toBe("OpenCode"); @@ -345,6 +374,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { codex!.adapter, claude!.adapter, cursor!.adapter, + devin!.adapter, grok!.adapter, openCode!.adapter, ]; @@ -353,6 +383,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { codex!.textGeneration, claude!.textGeneration, cursor!.textGeneration, + devin!.textGeneration, grok!.textGeneration, openCode!.textGeneration, ]; @@ -361,6 +392,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { codex!.snapshot, claude!.snapshot, cursor!.snapshot, + devin!.snapshot, grok!.snapshot, openCode!.snapshot, ]; @@ -376,13 +408,17 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(codexSnapshot.instanceId).toBe(codexId); expect(codexSnapshot.driver).toBe(codexDriverKind); expect(codexSnapshot.enabled).toBe(false); - expect(codexSnapshot.continuation?.groupKey).toBe("codex:home:/home/julius/.codex"); + expect(codexSnapshot.continuation?.groupKey).toBe( + normalizedHomeGroupKey(path, "codex", "/home/julius/.codex"), + ); const claudeSnapshot = yield* claude!.snapshot.getSnapshot; expect(claudeSnapshot.instanceId).toBe(claudeId); expect(claudeSnapshot.driver).toBe(claudeDriverKind); expect(claudeSnapshot.enabled).toBe(false); - expect(claudeSnapshot.continuation?.groupKey).toBe("claude:home:/home/julius/.claude-work"); + expect(claudeSnapshot.continuation?.groupKey).toBe( + normalizedHomeGroupKey(path, "claude", "/home/julius/.claude-work"), + ); const cursorSnapshot = yield* cursor!.snapshot.getSnapshot; expect(cursorSnapshot.instanceId).toBe(cursorId); @@ -392,6 +428,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { `${cursorDriverKind}:instance:${cursorId}`, ); + const devinSnapshot = yield* devin!.snapshot.getSnapshot; + expect(devinSnapshot.instanceId).toBe(devinId); + expect(devinSnapshot.driver).toBe(devinDriverKind); + expect(devinSnapshot.enabled).toBe(false); + expect(devinSnapshot.continuation?.groupKey).toBe(`${devinDriverKind}:instance:${devinId}`); + const grokSnapshot = yield* grok!.snapshot.getSnapshot; expect(grokSnapshot.instanceId).toBe(grokId); expect(grokSnapshot.driver).toBe(grokDriverKind); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index b3ab1145495..88441ebfe47 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -46,6 +46,7 @@ import * as ServerConfig from "../../config.ts"; import * as ServerSettingsModule from "../../serverSettings.ts"; import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; +import { DEVIN_MODEL_MERGE_POLICY } from "../Drivers/DevinDriver.ts"; import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; @@ -594,6 +595,64 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); }); + it("drops cached Devin raw model variants covered by refreshed grouped models", () => { + const devinDriver = ProviderDriverKind.make("devin"); + const previousProvider = { + instanceId: ProviderInstanceId.make("devin"), + driver: devinDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "unknown" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "1.2.3", + models: [ + { + slug: "gpt-5-5-low", + name: "GPT-5.5 Low Thinking", + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + { + slug: "local-devin-model", + name: "local-devin-model", + isCustom: true, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-04-14T00:01:00.000Z", + models: [ + { + slug: "gpt-5-5", + name: "GPT-5.5", + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual( + mergeProviderSnapshot( + previousProvider, + refreshedProvider, + DEVIN_MODEL_MERGE_POLICY, + ).models.map((model) => ({ + slug: model.slug, + name: model.name, + isCustom: model.isCustom, + })), + [ + { slug: "gpt-5-5", name: "GPT-5.5", isCustom: false }, + { slug: "local-devin-model", name: "local-devin-model", isCustom: true }, + ], + ); + }); + it.effect("does not run provider probes during layer construction", () => Effect.gen(function* () { const codexDriver = ProviderDriverKind.make("codex"); @@ -1436,6 +1495,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "claudeAgent", "codex", "cursor", + "devin", "grok", "opencode", ]); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 2df63e53830..8c4a0435c8e 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -51,7 +51,7 @@ import { resolveProviderStatusCachePath, writeProviderStatusCache, } from "../providerStatusCache.ts"; -import type { ProviderInstance } from "../ProviderDriver.ts"; +import type { ProviderInstance, ProviderModelMergePolicy } from "../ProviderDriver.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; @@ -81,6 +81,7 @@ const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean const mergeProviderModels = ( previousModels: ReadonlyArray, nextModels: ReadonlyArray, + modelMergePolicy?: ProviderModelMergePolicy | undefined, ): ReadonlyArray => { if (nextModels.length === 0 && previousModels.length > 0) { return previousModels; @@ -98,32 +99,49 @@ const mergeProviderModels = ( }; }); const nextSlugs = new Set(nextModels.map((model) => model.slug)); - return [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))]; + const shouldCarryPreviousModel: NonNullable< + ProviderModelMergePolicy["shouldCarryPreviousModel"] + > = + modelMergePolicy?.shouldCarryPreviousModel ?? + (({ previousModel, nextModelSlugs }) => !nextModelSlugs.has(previousModel.slug)); + return [ + ...mergedModels, + ...previousModels.filter((model) => + shouldCarryPreviousModel({ + previousModel: model, + nextModels, + nextModelSlugs: nextSlugs, + }), + ), + ]; }; export const mergeProviderSnapshot = ( previousProvider: ServerProvider | undefined, nextProvider: ServerProvider, + modelMergePolicy?: ProviderModelMergePolicy | undefined, ): ServerProvider => !previousProvider ? nextProvider : { ...nextProvider, - models: mergeProviderModels(previousProvider.models, nextProvider.models), + models: mergeProviderModels(previousProvider.models, nextProvider.models, modelMergePolicy), }; export const mergeProviderSnapshots = ( previousProviders: ReadonlyArray, nextProviders: ReadonlyArray, + modelMergePolicies?: ReadonlyMap, ): ReadonlyArray => { const mergedProviders = new Map( previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), ); for (const provider of nextProviders) { + const key = snapshotInstanceKey(provider); mergedProviders.set( - snapshotInstanceKey(provider), - mergeProviderSnapshot(mergedProviders.get(snapshotInstanceKey(provider)), provider), + key, + mergeProviderSnapshot(mergedProviders.get(key), provider, modelMergePolicies?.get(key)), ); } @@ -179,11 +197,31 @@ const snapshotInstanceKey = (provider: ServerProvider): ProviderInstanceId => { const buildSnapshotSource = (instance: ProviderInstance): ProviderSnapshotSource => ({ instanceId: instance.instanceId, driverKind: instance.driverKind, + modelMergePolicy: instance.modelMergePolicy, getSnapshot: instance.snapshot.getSnapshot, refresh: instance.snapshot.refresh, streamChanges: instance.snapshot.streamChanges, }); +const modelMergePoliciesByInstance = ( + sources: ReadonlyArray, +): ReadonlyMap => { + const policies = new Map(); + for (const source of sources) { + if (source.modelMergePolicy !== undefined) { + policies.set(source.instanceId, source.modelMergePolicy); + } + } + return policies; +}; + +const modelMergePolicyForSource = ( + source: ProviderSnapshotSource, +): ReadonlyMap => + source.modelMergePolicy === undefined + ? new Map() + : new Map([[source.instanceId, source.modelMergePolicy]]); + export const ProviderRegistryLive = Layer.effect( ProviderRegistry, Effect.gen(function* () { @@ -206,6 +244,7 @@ export const ProviderRegistryLive = Layer.effect( // below. const bootInstances = yield* instanceRegistry.listInstances; const bootSources = bootInstances.map(buildSnapshotSource); + const bootModelMergePolicies = modelMergePoliciesByInstance(bootSources); const fallbackProviders = yield* loadProviders(bootSources); const fallbackByInstance = new Map(); for (let index = 0; index < fallbackProviders.length; index++) { @@ -328,6 +367,7 @@ export const ProviderRegistryLive = Layer.effect( readonly publish?: boolean; readonly persist?: boolean; readonly replace?: boolean; + readonly modelMergePolicies?: ReadonlyMap; }, ) { const nextProvidersWithUpdateState = yield* Effect.forEach( @@ -352,7 +392,11 @@ export const ProviderRegistryLive = Layer.effect( key, options?.replace === true ? provider - : mergeProviderSnapshot(mergedProviders.get(key), provider), + : mergeProviderSnapshot( + mergedProviders.get(key), + provider, + options?.modelMergePolicies?.get(key), + ), ); } @@ -383,6 +427,7 @@ export const ProviderRegistryLive = Layer.effect( provider: ServerProvider, options?: { readonly publish?: boolean; + readonly modelMergePolicies?: ReadonlyMap; }, ) { return yield* upsertProviders([provider], options); @@ -433,7 +478,11 @@ export const ProviderRegistryLive = Layer.effect( return yield* providerSource.refresh.pipe( Effect.flatMap((nextProvider) => correlateSnapshotWithSource(providerSource, nextProvider).pipe( - Effect.flatMap(syncProvider), + Effect.flatMap((provider) => + syncProvider(provider, { + modelMergePolicies: modelMergePolicyForSource(providerSource), + }), + ), ), ), ); @@ -548,7 +597,13 @@ export const ProviderRegistryLive = Layer.effect( for (const [, instance] of newlyAdded) { const source = buildSnapshotSource(instance); yield* Stream.runForEach(source.streamChanges, (provider) => - correlateSnapshotWithSource(source, provider).pipe(Effect.flatMap(syncProvider)), + correlateSnapshotWithSource(source, provider).pipe( + Effect.flatMap((provider) => + syncProvider(provider, { + modelMergePolicies: modelMergePolicyForSource(source), + }), + ), + ), ).pipe(Effect.forkScoped); } yield* Effect.yieldNow; @@ -564,7 +619,11 @@ export const ProviderRegistryLive = Layer.effect( const source = buildSnapshotSource(instance); const provider = yield* source.getSnapshot; yield* correlateSnapshotWithSource(source, provider).pipe( - Effect.flatMap(syncProvider), + Effect.flatMap((correlated) => + syncProvider(correlated, { + modelMergePolicies: modelMergePolicyForSource(source), + }), + ), ); }).pipe(Effect.ignoreCause({ log: true })), { concurrency: "unbounded", discard: true }, @@ -627,20 +686,10 @@ export const ProviderRegistryLive = Layer.effect( // resolves. Cached snapshots (already in `providersRef`) merge with // these via `upsertProviders` so on-disk state wins where present // and pending fallbacks fill the gaps. - yield* upsertProviders(fallbackProviders, { publish: false }); - // Subscribe to registry mutations BEFORE running the initial sync. - // `subscribeChanges` acquires the dequeue synchronously in this - // fibre; the subscription is active the instant this `yield*` - // returns. Forking the consumer loop later cannot lose a publish - // because no publish can reach a not-yet-subscribed dequeue. - // - // (Contrast with the pre-fix code that did - // `Stream.runForEach(instanceRegistry.streamChanges, …).pipe(Effect.forkScoped)`. - // `Stream.fromPubSub` defers `PubSub.subscribe` to stream start, - // and `forkScoped` only schedules the fibre — so a reconcile that - // published between "fibre scheduled" and "fibre starts running" - // was dropped, which made any settings change that replaced an - // instance never propagate to the aggregator's `providersRef`.) + yield* upsertProviders(fallbackProviders, { + publish: false, + modelMergePolicies: bootModelMergePolicies, + }); // Subscribe to registry mutations BEFORE running the initial sync. // `subscribeChanges` acquires the `PubSub.Subscription` synchronously // in this fibre; the subscription is registered with the PubSub the diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index c738882c23a..8c18bd72081 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -25,6 +25,7 @@ import type { ProviderDriverKind, ProviderInstanceEnvironment, ProviderInstanceId, + ServerProviderModel, } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Schema from "effect/Schema"; @@ -68,11 +69,20 @@ export interface ProviderInstance { readonly displayName: string | undefined; readonly accentColor?: string | undefined; readonly enabled: boolean; + readonly modelMergePolicy?: ProviderModelMergePolicy | undefined; readonly snapshot: ServerProviderShape; readonly adapter: ProviderAdapterShape; readonly textGeneration: TextGeneration.TextGeneration["Service"]; } +export interface ProviderModelMergePolicy { + readonly shouldCarryPreviousModel?: (input: { + readonly previousModel: ServerProviderModel; + readonly nextModels: ReadonlyArray; + readonly nextModelSlugs: ReadonlySet; + }) => boolean; +} + export interface ProviderContinuationIdentity { readonly driverKind: ProviderDriverKind; readonly continuationKey: string; diff --git a/apps/server/src/provider/ProviderModelDiscoveryCache.test.ts b/apps/server/src/provider/ProviderModelDiscoveryCache.test.ts new file mode 100644 index 00000000000..ff71a4321e6 --- /dev/null +++ b/apps/server/src/provider/ProviderModelDiscoveryCache.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "@effect/vitest"; +import type { ServerProviderModel } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { makeProviderModelDiscoveryCache } from "./ProviderModelDiscoveryCache.ts"; + +describe("ProviderModelDiscoveryCache", () => { + it.effect("records empty model lists so stale discovered models can be cleared", () => + Effect.scoped( + Effect.gen(function* () { + const cache = yield* makeProviderModelDiscoveryCache(); + const model = { + slug: "devin-model", + name: "Devin Model", + isCustom: false, + capabilities: null, + } satisfies ServerProviderModel; + + yield* cache.recordModels([model]); + expect(yield* cache.getModels).toEqual([model]); + + yield* cache.recordModels([]); + + expect(yield* cache.getModels).toEqual([]); + }), + ), + ); +}); diff --git a/apps/server/src/provider/ProviderModelDiscoveryCache.ts b/apps/server/src/provider/ProviderModelDiscoveryCache.ts new file mode 100644 index 00000000000..276bd7f689c --- /dev/null +++ b/apps/server/src/provider/ProviderModelDiscoveryCache.ts @@ -0,0 +1,38 @@ +import type { ServerProviderModel } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; +import type * as Scope from "effect/Scope"; + +export interface ProviderModelDiscoveryCache { + readonly getModels: Effect.Effect, never, never>; + readonly setRefresh: ( + refresh: Effect.Effect, + ) => Effect.Effect; + readonly recordModels: ( + models: ReadonlyArray, + ) => Effect.Effect; +} + +export function makeProviderModelDiscoveryCache(): Effect.Effect< + ProviderModelDiscoveryCache, + never, + Scope.Scope +> { + return Effect.gen(function* () { + const scope = yield* Effect.scope; + const modelsRef = yield* Ref.make>([]); + const refreshRef = yield* Ref.make>(Effect.void); + + const scheduleRefresh: Effect.Effect = Effect.gen(function* () { + const refresh = yield* Ref.get(refreshRef); + yield* refresh.pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(scope), Effect.asVoid); + }); + + return { + getModels: Ref.get(modelsRef), + setRefresh: (refresh) => Ref.set(refreshRef, refresh), + recordModels: (models) => + Ref.set(modelsRef, models).pipe(Effect.andThen(scheduleRefresh), Effect.asVoid), + } satisfies ProviderModelDiscoveryCache; + }); +} diff --git a/apps/server/src/provider/Services/GrokAdapter.ts b/apps/server/src/provider/Services/GrokAdapter.ts deleted file mode 100644 index 73254cefe39..00000000000 --- a/apps/server/src/provider/Services/GrokAdapter.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * GrokAdapter — shape type for the Grok provider adapter. - * - * The driver model ({@link ../Drivers/GrokDriver}) 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. - * - * @module GrokAdapter - */ -import type { ProviderAdapterError } from "../Errors.ts"; -import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; - -/** - * GrokAdapterShape — per-instance Grok adapter contract. - */ -export interface GrokAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AcpAdapterLive.ts b/apps/server/src/provider/acp/AcpAdapterLive.ts new file mode 100644 index 00000000000..063f0b6613d --- /dev/null +++ b/apps/server/src/provider/acp/AcpAdapterLive.ts @@ -0,0 +1,980 @@ +import { + ApprovalRequestId, + EventId, + type ModelSelection, + type ProviderDriverKind, + type ProviderRuntimeEvent, + type ProviderSendTurnInput, + type ProviderSession, + type ProviderSessionStartInput, + ProviderInstanceId, + type ProviderTurnStartResult, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +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 Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + type ProviderAdapterError, + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { mapAcpToAdapterError } from "./AcpAdapterSupport.ts"; +import { + appendPromptResultToTurn, + emitAcpSessionReadyEvents, + forkAcpAdapterNotificationStream, + handleAcpPermissionRequest, + makeAcpPromptSettler, + makeAcpThreadLock, + parseAcpResume, + prepareAcpPromptContent, + respondToAcpPermissionRequest, + respondToAcpUserInput, + settlePendingAcpApprovalsAsCancelled, + settlePendingAcpUserInputsAsCancelled, + type AcpAdapterPendingApproval, + type AcpAdapterPendingUserInput, + type AcpAdapterSessionContext, +} from "./AcpAdapterRuntime.ts"; +import { makeAcpNativeLoggerFactory } from "./AcpNativeLogging.ts"; +import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts"; +import type { + ProviderAdapterCapabilities, + ProviderAdapterShape, +} from "../Services/ProviderAdapter.ts"; + +export interface AcpAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly instanceId?: ProviderInstanceId; + readonly afterSessionStarted?: (input: { + readonly threadId: ThreadId; + readonly sessionSetupResult: AcpSessionRuntime.AcpSessionRuntimeStartResult["sessionSetupResult"]; + }) => Effect.Effect; +} + +export interface AcpAdapterLiveSessionContext< + UserInputResponse = unknown, +> extends AcpAdapterSessionContext { + readonly threadId: ThreadId; + readonly acpSessionId: string; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map>; + currentModelId: string | undefined; + stopped: boolean; +} + +export interface AcpAdapterLiveCallbackContext { + readonly threadId: ThreadId; + readonly runtimeMode: ProviderSession["runtimeMode"]; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map>; + readonly resolveActiveTurnId: () => TurnId | undefined; + readonly mapAcpCallbackFailure: ( + effect: Effect.Effect, + ) => Effect.Effect; + readonly nextApprovalRequestId: Effect.Effect; + readonly makeEventStamp: () => Effect.Effect< + { readonly eventId: EventId; readonly createdAt: string }, + ProviderAdapterRequestError + >; + readonly offerRuntimeEvent: (event: ProviderRuntimeEvent) => Effect.Effect; + readonly logNative: (threadId: ThreadId, method: string, payload: unknown) => Effect.Effect; +} + +export interface AcpAdapterLiveModelBinding { + readonly currentModelId: string | undefined; + readonly displayModel: string | undefined; +} + +export interface AcpAdapterLiveConfig { + readonly provider: ProviderDriverKind; + readonly providerLabel: string; + readonly resumeSchemaVersion: number; + readonly readyReason: string; + readonly respondToUserInputMethod: string; + readonly capabilities: ProviderAdapterCapabilities; + readonly completedStopReasonFromPromptResponse: ( + response: EffectAcpSchema.PromptResponse, + ) => EffectAcpSchema.StopReason | null; + readonly makeAcpRuntime: (input: { + readonly threadId: ThreadId; + readonly cwd: string; + readonly resumeSessionId: string | undefined; + readonly sessionScope: Scope.Closeable; + readonly acpNativeLoggers: Pick< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "requestLogger" | "protocolLogging" + >; + readonly mcpServers: AcpSessionRuntime.AcpSessionRuntimeOptions["mcpServers"]; + }) => Effect.Effect; + readonly registerAcpCallbacks: ( + input: AcpAdapterLiveCallbackContext, + ) => Effect.Effect; + readonly bindSessionModel: (input: { + readonly threadId: ThreadId; + readonly runtimeMode: ProviderSession["runtimeMode"]; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly modelSelection: ModelSelection | undefined; + readonly sessionSetupResult: AcpSessionRuntime.AcpSessionRuntimeStartResult["sessionSetupResult"]; + }) => Effect.Effect; + readonly prepareTurnModel: (input: { + readonly threadId: ThreadId; + readonly ctx: AcpAdapterLiveSessionContext; + readonly modelSelection: ModelSelection | undefined; + readonly interactionMode: ProviderSendTurnInput["interactionMode"]; + }) => Effect.Effect; +} + +export function makeAcpAdapterLive( + config: AcpAdapterLiveConfig, + options?: AcpAdapterLiveOptions, +) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make(config.provider); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* Effect.service(ServerConfig); + const crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + + const sessions = new Map>(); + const threadLock = yield* makeAcpThreadLock(); + const withThreadLock = threadLock.withThreadLock; + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: config.provider, + method: "crypto/randomUUIDv4", + detail: `Failed to generate ${config.providerLabel} runtime identifier.`, + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const nextApprovalRequestId = Effect.map(randomUUIDv4, (id) => ApprovalRequestId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const mapAcpCallbackFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: `Failed to process ${config.providerLabel} ACP callback.`, + cause, + }), + ), + ); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const settlePromptInFlight = makeAcpPromptSettler({ + provider: config.provider, + sessions, + nowIso, + makeEventStamp, + offerRuntimeEvent, + }); + + const logNative = (threadId: ThreadId, method: string, payload: unknown) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: config.provider, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning(`Failed to write native ${config.providerLabel} notification log.`, { + cause, + threadId, + method, + }), + ), + ); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect< + AcpAdapterLiveSessionContext, + ProviderAdapterSessionNotFoundError + > => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: config.provider, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: AcpAdapterLiveSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingAcpApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingAcpUserInputsAsCancelled(ctx.pendingUserInputs); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: config.provider, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession = (input: ProviderSessionStartInput) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== config.provider) { + return yield* new ProviderAdapterValidationError({ + provider: config.provider, + operation: "startSession", + issue: `Expected provider '${config.provider}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: config.provider, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const boundModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + + const pendingApprovals = new Map(); + const pendingUserInputs = new Map< + ApprovalRequestId, + AcpAdapterPendingUserInput + >(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + + const resumeSessionId = parseAcpResume( + input.resumeCursor, + config.resumeSchemaVersion, + )?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: config.provider, + threadId: input.threadId, + }); + + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const acp = yield* config.makeAcpRuntime({ + threadId: input.threadId, + cwd, + resumeSessionId, + sessionScope, + acpNativeLoggers, + mcpServers: mcpSession + ? [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ] + : undefined, + }); + + const resolveActiveTurnId = () => sessions.get(input.threadId)?.activeTurnId; + const started = yield* Effect.gen(function* () { + yield* acp.handleRequestPermission((params) => + mapAcpCallbackFailure( + handleAcpPermissionRequest({ + provider: config.provider, + threadId: input.threadId, + runtimeMode: input.runtimeMode, + request: params, + pendingApprovals, + resolveTurnId: resolveActiveTurnId, + makeRequestId: nextApprovalRequestId, + makeEventStamp, + offerRuntimeEvent, + logNative, + }), + ), + ); + yield* config.registerAcpCallbacks({ + threadId: input.threadId, + runtimeMode: input.runtimeMode, + acp, + pendingApprovals, + pendingUserInputs, + resolveActiveTurnId, + mapAcpCallbackFailure, + nextApprovalRequestId, + makeEventStamp, + offerRuntimeEvent, + logNative, + }); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(config.provider, input.threadId, "session/start", error), + ), + ); + + yield* ( + options?.afterSessionStarted?.({ + threadId: input.threadId, + sessionSetupResult: started.sessionSetupResult, + }) ?? Effect.void + ); + + const { currentModelId, displayModel } = yield* config.bindSessionModel({ + threadId: input.threadId, + runtimeMode: input.runtimeMode, + acp, + modelSelection: boundModelSelection, + sessionSetupResult: started.sessionSetupResult, + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: config.provider, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + ...(displayModel ? { model: displayModel } : {}), + threadId: input.threadId, + resumeCursor: { + schemaVersion: config.resumeSchemaVersion, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + const ctx: AcpAdapterLiveSessionContext = { + threadId: input.threadId, + acpSessionId: started.sessionId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + interruptedTurnIds: new Set(), + promptsInFlight: 0, + currentModelId, + stopped: false, + }; + + const nf = yield* forkAcpAdapterNotificationStream({ + provider: config.provider, + ctx, + events: acp.getEvents(), + makeEventStamp, + offerRuntimeEvent, + logNative, + logErrorMessage: `Failed to process ${config.providerLabel} runtime notification.`, + }); + + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* emitAcpSessionReadyEvents({ + provider: config.provider, + threadId: input.threadId, + providerThreadId: started.sessionId, + initializeResult: started.initializeResult, + readyReason: config.readyReason, + makeEventStamp, + offerRuntimeEvent, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn = (input: ProviderSendTurnInput) => + Effect.gen(function* () { + // Created before the prompt slot is acquired so the settlement + // `ensuring` below is armed without an interruption window that could + // leak `promptsInFlight`. + const promptSettled = yield* Ref.make(false); + const promptRpcSucceeded = yield* Ref.make(false); + const promptResultRef = yield* Ref.make( + undefined, + ); + const promptFailureMessageRef = yield* Ref.make(undefined); + const prepared = yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + ctx.promptsInFlight += 1; + ctx.activeTurnId = turnId; + ctx.session = { + ...ctx.session, + status: steeringTurnId === undefined ? "connecting" : "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + return yield* Effect.gen(function* () { + const boundModelSelection = + input.modelSelection?.instanceId === boundInstanceId + ? input.modelSelection + : undefined; + const promptParts = yield* prepareAcpPromptContent({ + provider: config.provider, + text: input.input, + attachments: input.attachments, + attachmentsDir: serverConfig.attachmentsDir, + fileSystem, + }); + + const { currentModelId, displayModel } = yield* config.prepareTurnModel({ + threadId: input.threadId, + ctx, + modelSelection: boundModelSelection, + interactionMode: input.interactionMode, + }); + + ctx.currentModelId = currentModelId; + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + if (ctx.interruptedTurnIds.has(turnId)) { + yield* settlePromptInFlight(input.threadId, turnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + emitTurnCompletion: false, + settleAllPrompts: true, + }); + return yield* new ProviderAdapterRequestError({ + provider: config.provider, + method: "session/prompt", + detail: `${config.providerLabel} prompt was interrupted during preparation.`, + }); + } + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + ...(displayModel ? { model: displayModel } : {}), + }; + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: config.provider, + threadId: input.threadId, + turnId, + payload: displayModel ? { model: displayModel } : {}, + }); + } + + return { + acp: ctx.acp, + acpSessionId: ctx.acpSessionId, + displayModel, + promptParts, + turnId, + }; + }).pipe( + Effect.tapCause(() => + Effect.gen(function* () { + const liveCtx = sessions.get(input.threadId); + if (!liveCtx) { + return; + } + yield* settlePromptInFlight(input.threadId, turnId, liveCtx.acpSessionId, { + errorMessage: `${config.providerLabel} prompt preparation failed.`, + emitTurnCompletion: false, + }); + }), + ), + ); + }), + ); + + return yield* Effect.gen(function* () { + const result = yield* prepared.acp + .prompt({ + prompt: prepared.promptParts, + }) + .pipe( + Effect.tap((promptResult) => + Effect.all([ + Ref.set(promptRpcSucceeded, true), + Ref.set(promptResultRef, promptResult), + ]), + ), + Effect.tapError((error) => + Ref.set( + promptFailureMessageRef, + mapAcpToAdapterError(config.provider, input.threadId, "session/prompt", error) + .message, + ).pipe(Effect.andThen(prepared.acp.drainEvents)), + ), + Effect.mapError((error) => + mapAcpToAdapterError(config.provider, input.threadId, "session/prompt", error), + ), + ); + + return yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: `${config.providerLabel} session changed before the turn completed.`, + settleAllPrompts: true, + }, + ); + yield* Ref.set(promptSettled, true); + return yield* new ProviderAdapterRequestError({ + provider: config.provider, + method: "session/prompt", + detail: `${config.providerLabel} session changed before the turn completed.`, + }); + } + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + yield* prepared.acp.drainEvents; + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + } satisfies ProviderTurnStartResult; + } + + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + } satisfies ProviderTurnStartResult; + } + + appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: prepared.turnId, + updatedAt: yield* nowIso, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const remainingPrompts = Math.max(0, ctx.promptsInFlight - 1); + ctx.promptsInFlight = remainingPrompts; + + if ( + remainingPrompts === 0 && + ctx.activeTurnId === prepared.turnId && + ctx.session.activeTurnId === prepared.turnId + ) { + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + yield* Ref.set(promptSettled, true); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + } satisfies ProviderTurnStartResult; + } + const completedAt = yield* nowIso; + const { activeTurnId: _completedTurnId, ...readySession } = ctx.session; + ctx.activeTurnId = undefined; + ctx.session = { + ...readySession, + status: "ready", + updatedAt: completedAt, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const completedStopReason = config.completedStopReasonFromPromptResponse(result); + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: config.provider, + threadId: input.threadId, + turnId: prepared.turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: completedStopReason, + }, + }); + ctx.interruptedTurnIds.delete(prepared.turnId); + yield* Ref.set(promptSettled, true); + } else if (remainingPrompts > 0) { + yield* Ref.set(promptSettled, true); + } + + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + } satisfies ProviderTurnStartResult; + }), + ); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + if (yield* Ref.get(promptSettled)) { + return; + } + + if (yield* Ref.get(promptRpcSucceeded)) { + const promptResult = yield* Ref.get(promptResultRef); + if (promptResult === undefined) { + return; + } + yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if (ctx.acpSessionId !== prepared.acpSessionId) { + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + errorMessage: `${config.providerLabel} session changed before the turn completed.`, + settleAllPrompts: true, + }, + ); + return; + } + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + return; + } + if ( + ctx.promptsInFlight <= 0 || + ctx.activeTurnId !== prepared.turnId || + ctx.session.activeTurnId !== prepared.turnId + ) { + return; + } + appendPromptResultToTurn( + ctx, + prepared.turnId, + prepared.promptParts, + promptResult, + ); + yield* settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + { + completedStopReason: + config.completedStopReasonFromPromptResponse(promptResult), + }, + ); + }), + ); + return; + } + + const errorMessage = yield* Ref.get(promptFailureMessageRef); + yield* withThreadLock( + input.threadId, + settlePromptInFlight(input.threadId, prepared.turnId, prepared.acpSessionId, { + errorMessage: errorMessage ?? `${config.providerLabel} prompt request failed.`, + }), + ); + }).pipe(Effect.catch(() => Effect.void)), + ), + ); + }); + + const interruptTurn = (threadId: ThreadId, turnId?: TurnId) => + Effect.gen(function* () { + const observed = yield* Effect.sync(() => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return { + _tag: "Proceed" as const, + acpSessionId: undefined, + interruptedTurnId: turnId, + }; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return { _tag: "Ignore" as const }; + } + const interruptedTurnId = turnId ?? activeTurnId; + if (interruptedTurnId !== undefined) { + ctx.interruptedTurnIds.add(interruptedTurnId); + } + return { + _tag: "Proceed" as const, + acpSessionId: ctx.acpSessionId, + interruptedTurnId, + }; + }); + if (observed._tag === "Ignore") { + return; + } + if (observed.acpSessionId === undefined) { + return; + } + + yield* withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (observed.acpSessionId !== undefined && ctx.acpSessionId !== observed.acpSessionId) { + return; + } + const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; + if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + return; + } + if ( + observed.interruptedTurnId !== undefined && + activeTurnId !== undefined && + activeTurnId !== observed.interruptedTurnId + ) { + return; + } + const interruptedTurnId = + observed.interruptedTurnId ?? turnId ?? activeTurnId ?? ctx.session.activeTurnId; + yield* settlePendingAcpApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingAcpUserInputsAsCancelled(ctx.pendingUserInputs); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(config.provider, threadId, "session/cancel", error), + ), + ), + ); + if (interruptedTurnId) { + ctx.interruptedTurnIds.add(interruptedTurnId); + yield* settlePromptInFlight(threadId, interruptedTurnId, ctx.acpSessionId, { + completedStopReason: "cancelled", + settleAllPrompts: true, + }); + } else if ( + ctx.promptsInFlight > 0 || + ctx.session.status === "running" || + ctx.session.status === "connecting" + ) { + const updatedAt = yield* nowIso; + ctx.promptsInFlight = 0; + ctx.activeTurnId = undefined; + const { activeTurnId: _activeTurnId, ...readySession } = ctx.session; + ctx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + }), + ); + }); + + const respondToRequest = ( + threadId: ThreadId, + requestId: ApprovalRequestId, + decision: Parameters["respondToRequest"]>[2], + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* respondToAcpPermissionRequest({ + provider: config.provider, + requestId, + decision, + pendingApprovals: ctx.pendingApprovals, + }); + }); + + const respondToUserInput = ( + threadId: ThreadId, + requestId: ApprovalRequestId, + answers: Parameters["respondToUserInput"]>[2], + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* respondToAcpUserInput({ + provider: config.provider, + method: config.respondToUserInputMethod, + requestId, + answers, + pendingUserInputs: ctx.pendingUserInputs, + }); + }); + + const readThread = (threadId: ThreadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread = (threadId: ThreadId, numTurns: number) => + Effect.gen(function* () { + yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: config.provider, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + return yield* new ProviderAdapterRequestError({ + provider: config.provider, + method: "thread/rollback", + detail: `${config.providerLabel} ACP sessions do not support provider-side rollback yet.`, + }); + }); + + const stopSession = (threadId: ThreadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + yield* threadLock.deleteThreadLock(threadId); + }), + ); + + const listSessions = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession = (threadId: ThreadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + // Take each thread lock so a stop never interrupts the notification fiber + // while a locked `sendTurn` section is waiting on an event-stream barrier. + const stopAll = () => + Effect.forEach( + Array.from(sessions.keys()), + (threadId) => + withThreadLock( + threadId, + Effect.suspend(() => { + const ctx = sessions.get(threadId); + return ctx + ? Effect.gen(function* () { + yield* stopSessionInternal(ctx); + yield* threadLock.deleteThreadLock(threadId); + }) + : Effect.void; + }), + ), + { discard: true }, + ); + + yield* Effect.addFinalizer(() => + Effect.ignore(stopAll()).pipe( + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: config.provider, + capabilities: config.capabilities, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies ProviderAdapterShape; + }); +} diff --git a/apps/server/src/provider/acp/AcpAdapterRuntime.test.ts b/apps/server/src/provider/acp/AcpAdapterRuntime.test.ts new file mode 100644 index 00000000000..ee31d2873a4 --- /dev/null +++ b/apps/server/src/provider/acp/AcpAdapterRuntime.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { makeAcpThreadLock, selectPermissionOptionId } from "./AcpAdapterRuntime.ts"; + +describe("AcpAdapterRuntime", () => { + it("falls back to allow_once for acceptForSession when allow_always is unavailable", () => { + const request = { + sessionId: "session-1", + toolCall: { toolCallId: "tool-1" }, + options: [{ optionId: "allow-once", kind: "allow_once", name: "Allow once" }], + } satisfies EffectAcpSchema.RequestPermissionRequest; + + expect(selectPermissionOptionId(request, "acceptForSession")).toBe("allow-once"); + }); + + it.effect("allows stopped thread locks to be marked for deletion", () => + Effect.gen(function* () { + const threadLock = yield* makeAcpThreadLock(); + const events: Array = []; + + yield* threadLock.withThreadLock( + "thread-1", + Effect.gen(function* () { + events.push("first"); + yield* threadLock.deleteThreadLock("thread-1"); + }), + ); + yield* threadLock.withThreadLock( + "thread-1", + Effect.sync(() => { + events.push("second"); + }), + ); + + expect(events).toEqual(["first", "second"]); + }), + ); +}); diff --git a/apps/server/src/provider/acp/AcpAdapterRuntime.ts b/apps/server/src/provider/acp/AcpAdapterRuntime.ts new file mode 100644 index 00000000000..22346c463ca --- /dev/null +++ b/apps/server/src/provider/acp/AcpAdapterRuntime.ts @@ -0,0 +1,906 @@ +import { + ApprovalRequestId, + EventId, + type ProviderApprovalDecision, + type ProviderDriverKind, + type ProviderRuntimeEvent, + type ProviderSendTurnInput, + type ProviderSession, + type ProviderUserInputAnswers, + RuntimeRequestId, + type ThreadId, + type TurnId, + type UserInputQuestion, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ProviderAdapterRequestError, ProviderAdapterValidationError } from "../Errors.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "./AcpCoreRuntimeEvents.ts"; +import type { AcpSessionRuntimeEvent } from "./AcpSessionRuntime.ts"; +import { parsePermissionRequest } from "./AcpRuntimeModel.ts"; + +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); + +export interface AcpAdapterPromptContext { + readonly acpSessionId: string; + session: ProviderSession; + activeTurnId: TurnId | undefined; + interruptedTurnIds: Set; + promptsInFlight: number; +} + +export interface AcpAdapterPromptTurnStore { + turns: Array<{ id: TurnId; items: Array }>; +} + +export interface AcpAdapterSessionContext + extends AcpAdapterPromptContext, AcpAdapterPromptTurnStore { + readonly threadId: ThreadId; + lastPlanFingerprint: string | undefined; +} + +export interface AcpAdapterEventStamp { + readonly eventId: EventId; + readonly createdAt: string; +} + +export interface AcpPromptSettlementOptions { + readonly errorMessage?: string; + readonly completedStopReason?: EffectAcpSchema.StopReason | null; + readonly emitTurnCompletion?: boolean; + /** Interrupt/cancel: drop every outstanding prompt slot and settle once. */ + readonly settleAllPrompts?: boolean; +} + +export interface AcpAdapterPendingApproval { + readonly decision: Deferred.Deferred; +} + +export type AcpAdapterPendingUserInputResolution = + | { + readonly _tag: "answered"; + readonly answers: ProviderUserInputAnswers; + readonly response: Response; + } + | { readonly _tag: "cancelled" }; + +export interface AcpAdapterPendingUserInput { + readonly resolution: Deferred.Deferred>; + readonly makeResponse: (answers: ProviderUserInputAnswers) => Response; + readonly validateResponse?: (response: Response) => string | undefined; +} + +export interface AcpAdapterUserInputPrompt { + readonly questions: ReadonlyArray; + readonly makeResponse: (answers: ProviderUserInputAnswers) => Response; + readonly makeCancelledResponse: () => Response; + readonly validateResponse?: (response: Response) => string | undefined; +} + +interface AcpThreadLockEntry { + readonly semaphore: Semaphore.Semaphore; + readonly users: number; + readonly deleteWhenIdle: boolean; +} + +type AcpAdapterRuntimeEventSource = "acp.jsonrpc" | `acp.${string}.extension`; + +export function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function parseAcpResume( + raw: unknown, + schemaVersion: number, +): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== schemaVersion) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +export function selectPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + const kinds = + decision === "acceptForSession" + ? (["allow_always", "allow_once"] as const) + : decision === "accept" + ? (["allow_once"] as const) + : (["reject_once", "reject_always"] as const); + for (const kind of kinds) { + const option = request.options.find((entry) => entry.kind === kind); + const optionId = option?.optionId.trim(); + if (optionId) { + return optionId; + } + } + return undefined; +} + +export function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + return ( + selectPermissionOptionId(request, "acceptForSession") ?? + selectPermissionOptionId(request, "accept") + ); +} + +export function settlePendingAcpApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingApprovals.values()), + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); +} + +export function settlePendingAcpUserInputsAsCancelled( + pendingUserInputs: ReadonlyMap>, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingUserInputs.values()), + (pending) => Deferred.succeed(pending.resolution, { _tag: "cancelled" }).pipe(Effect.ignore), + { discard: true }, + ); +} + +export function makeAcpPlanUpdateEmitter(input: { + readonly provider: ProviderDriverKind; + readonly offerRuntimeEvent: (event: ProviderRuntimeEvent) => Effect.Effect; +}) { + return ( + ctx: AcpAdapterSessionContext, + turnId: TurnId | undefined, + stamp: AcpAdapterEventStamp, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${turnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* input.offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp, + provider: input.provider, + threadId: ctx.threadId, + turnId, + payload, + source: "acp.jsonrpc", + method, + rawPayload, + }), + ); + }); +} + +export function forkAcpAdapterNotificationStream< + Ctx extends AcpAdapterSessionContext, + EStamp = never, + RStamp = never, + EOffer = never, + ROffer = never, + ELog = never, + RLog = never, +>(input: { + readonly provider: ProviderDriverKind; + readonly ctx: Ctx; + readonly events: Stream.Stream; + readonly makeEventStamp: () => Effect.Effect; + readonly offerRuntimeEvent: (event: ProviderRuntimeEvent) => Effect.Effect; + readonly logNative: ( + threadId: ThreadId, + method: string, + payload: unknown, + ) => Effect.Effect; + readonly logErrorMessage: string; +}) { + const emitPlanUpdate = makeAcpPlanUpdateEmitter({ + provider: input.provider, + offerRuntimeEvent: input.offerRuntimeEvent, + }); + + return Stream.runDrain( + Stream.mapEffect(input.events, (event) => + Effect.gen(function* () { + if (event._tag === "EventStreamBarrier") { + yield* Deferred.succeed(event.acknowledge, undefined); + return; + } + if ( + event._tag === "PlanUpdated" || + event._tag === "ToolCallUpdated" || + event._tag === "ContentDelta" + ) { + yield* input.logNative(input.ctx.threadId, "session/update", event.rawPayload); + } + + if (event._tag === "ModeChanged") { + return; + } + + const notificationTurnId = input.ctx.activeTurnId; + if ( + notificationTurnId === undefined || + input.ctx.interruptedTurnIds.has(notificationTurnId) + ) { + return; + } + const stamp = yield* input.makeEventStamp(); + + switch (event._tag) { + case "AssistantItemStarted": + yield* input.offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: input.provider, + threadId: input.ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* input.offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp, + provider: input.provider, + threadId: input.ctx.threadId, + turnId: notificationTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* emitPlanUpdate( + input.ctx, + notificationTurnId, + stamp, + event.payload, + event.rawPayload, + "session/update", + ); + return; + case "ToolCallUpdated": + yield* input.offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp, + provider: input.provider, + threadId: input.ctx.threadId, + turnId: notificationTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* input.offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp, + provider: input.provider, + threadId: input.ctx.threadId, + turnId: notificationTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => Effect.logError(input.logErrorMessage, { cause })), + Effect.forkChild, + ); +} + +export function emitAcpSessionReadyEvents< + EStamp = never, + RStamp = never, + EOffer = never, + ROffer = never, +>(input: { + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly providerThreadId: string; + readonly initializeResult: unknown; + readonly readyReason: string; + readonly makeEventStamp: () => Effect.Effect; + readonly offerRuntimeEvent: (event: ProviderRuntimeEvent) => Effect.Effect; +}) { + return Effect.gen(function* () { + yield* input.offerRuntimeEvent({ + type: "session.started", + ...(yield* input.makeEventStamp()), + provider: input.provider, + threadId: input.threadId, + payload: { resume: input.initializeResult }, + }); + yield* input.offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* input.makeEventStamp()), + provider: input.provider, + threadId: input.threadId, + payload: { state: "ready", reason: input.readyReason }, + }); + yield* input.offerRuntimeEvent({ + type: "thread.started", + ...(yield* input.makeEventStamp()), + provider: input.provider, + threadId: input.threadId, + payload: { providerThreadId: input.providerThreadId }, + }); + }); +} + +export function handleAcpPermissionRequest< + ERequestId = never, + RRequestId = never, + EStamp = never, + RStamp = never, + EOffer = never, + ROffer = never, + ELog = never, + RLog = never, +>(input: { + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly runtimeMode: ProviderSession["runtimeMode"]; + readonly request: EffectAcpSchema.RequestPermissionRequest; + readonly pendingApprovals: Map; + readonly resolveTurnId: () => TurnId | undefined; + readonly makeRequestId: Effect.Effect; + readonly makeEventStamp: () => Effect.Effect; + readonly offerRuntimeEvent: (event: ProviderRuntimeEvent) => Effect.Effect; + readonly logNative: ( + threadId: ThreadId, + method: string, + payload: unknown, + ) => Effect.Effect; +}) { + return Effect.gen(function* () { + yield* input.logNative(input.threadId, "session/request_permission", input.request); + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(input.request); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const permissionRequest = parsePermissionRequest(input.request); + const requestId = yield* input.makeRequestId; + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + const turnId = input.resolveTurnId(); + input.pendingApprovals.set(requestId, { decision }); + const resolved = yield* Effect.gen(function* () { + yield* input.offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* input.makeEventStamp(), + provider: input.provider, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(input.request)?.slice(0, 2000) ?? + "[unserializable params]", + args: input.request, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: input.request, + }), + ); + return yield* Deferred.await(decision); + }).pipe( + // Interrupted callbacks (connection teardown, agent-side cancellation) + // must not leave a dangling entry that a later respond call could hit. + Effect.ensuring(Effect.sync(() => input.pendingApprovals.delete(requestId))), + ); + yield* input.offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* input.makeEventStamp(), + provider: input.provider, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + const selectedOptionId = + resolved === "cancel" ? undefined : selectPermissionOptionId(input.request, resolved); + return { + outcome: selectedOptionId + ? { + outcome: "selected" as const, + optionId: selectedOptionId, + } + : ({ outcome: "cancelled" } as const), + }; + }); +} + +export function respondToAcpPermissionRequest(input: { + readonly provider: ProviderDriverKind; + readonly requestId: ApprovalRequestId; + readonly decision: ProviderApprovalDecision; + readonly pendingApprovals: ReadonlyMap; +}) { + return Effect.gen(function* () { + const pending = input.pendingApprovals.get(input.requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: input.provider, + method: "session/request_permission", + detail: `Unknown pending approval request: ${input.requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, input.decision); + }); +} + +export function handleAcpUserInputRequest< + Response, + ERequestId = never, + RRequestId = never, + EStamp = never, + RStamp = never, + EOffer = never, + ROffer = never, + ELog = never, + RLog = never, +>(input: { + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly method: string; + readonly source: AcpAdapterRuntimeEventSource; + readonly request: unknown; + readonly prompt: AcpAdapterUserInputPrompt; + readonly pendingUserInputs: Map>; + /** Invoked with the request id after the pending entry is registered. */ + readonly onOpened?: (requestId: ApprovalRequestId) => void; + /** Invoked once the request is settled and removed from the pending map. */ + readonly onSettled?: (requestId: ApprovalRequestId) => void; + readonly resolveTurnId: () => TurnId | undefined; + readonly makeRequestId: Effect.Effect; + readonly makeEventStamp: () => Effect.Effect; + readonly offerRuntimeEvent: (event: ProviderRuntimeEvent) => Effect.Effect; + readonly logNative: ( + threadId: ThreadId, + method: string, + payload: unknown, + ) => Effect.Effect; +}) { + return Effect.gen(function* () { + yield* input.logNative(input.threadId, input.method, input.request); + const requestId = yield* input.makeRequestId; + const runtimeRequestId = RuntimeRequestId.make(requestId); + const resolution = yield* Deferred.make>(); + const turnId = input.resolveTurnId(); + input.pendingUserInputs.set(requestId, { + resolution, + makeResponse: input.prompt.makeResponse, + ...(input.prompt.validateResponse ? { validateResponse: input.prompt.validateResponse } : {}), + }); + input.onOpened?.(requestId); + const resolved = yield* Effect.gen(function* () { + yield* input.offerRuntimeEvent({ + type: "user-input.requested", + ...(yield* input.makeEventStamp()), + provider: input.provider, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + payload: { questions: input.prompt.questions }, + raw: { + source: input.source, + method: input.method, + payload: input.request, + }, + }); + return yield* Deferred.await(resolution); + }).pipe( + // Interrupted callbacks (connection teardown, agent-side cancellation) + // must not leave a dangling entry that a later respond call could hit. + Effect.ensuring( + Effect.sync(() => { + input.pendingUserInputs.delete(requestId); + input.onSettled?.(requestId); + }), + ), + ); + // Answered resolutions are validated in `respondToAcpUserInput` before the + // deferred is settled, so no re-validation is needed here. + const resolvedAnswers = resolved._tag === "answered" ? resolved.answers : {}; + yield* input.offerRuntimeEvent({ + type: "user-input.resolved", + ...(yield* input.makeEventStamp()), + provider: input.provider, + threadId: input.threadId, + turnId, + requestId: runtimeRequestId, + payload: { answers: resolvedAnswers }, + raw: { + source: input.source, + method: input.method, + payload: input.request, + }, + }); + return resolved._tag === "answered" ? resolved.response : input.prompt.makeCancelledResponse(); + }); +} + +export function respondToAcpUserInput(input: { + readonly provider: ProviderDriverKind; + readonly method: string; + readonly requestId: ApprovalRequestId; + readonly answers: ProviderUserInputAnswers; + readonly pendingUserInputs: ReadonlyMap>; +}) { + return Effect.gen(function* () { + const pending = input.pendingUserInputs.get(input.requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: input.provider, + method: input.method, + detail: `Unknown pending user-input request: ${input.requestId}`, + }); + } + const response = pending.makeResponse(input.answers); + const validationError = pending.validateResponse?.(response); + if (validationError !== undefined) { + return yield* new ProviderAdapterRequestError({ + provider: input.provider, + method: input.method, + detail: validationError, + }); + } + yield* Deferred.succeed(pending.resolution, { + _tag: "answered", + answers: input.answers, + response, + }); + }); +} + +export function prepareAcpPromptContent(input: { + readonly provider: ProviderDriverKind; + readonly text: string | undefined; + readonly attachments: ProviderSendTurnInput["attachments"]; + readonly attachmentsDir: string; + readonly fileSystem: FileSystem.FileSystem; +}) { + return Effect.gen(function* () { + const text = input.text?.trim(); + const imagePromptParts = yield* Effect.forEach(input.attachments ?? [], (attachment) => + Effect.gen(function* () { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: input.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: input.provider, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* input.fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: input.provider, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + return { + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + } satisfies EffectAcpSchema.ContentBlock; + }), + ); + const promptParts: Array = [ + ...(text ? [{ type: "text" as const, text }] : []), + ...imagePromptParts, + ]; + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: input.provider, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + return promptParts; + }); +} + +export function appendPromptResultToTurn( + ctx: AcpAdapterPromptTurnStore, + turnId: TurnId, + promptParts: ReadonlyArray, + result: EffectAcpSchema.PromptResponse, +): void { + const existingTurnRecord = ctx.turns.find((turn) => turn.id === turnId); + ctx.turns = existingTurnRecord + ? ctx.turns.map((turn) => + turn.id === turnId + ? { ...turn, items: [...turn.items, { prompt: promptParts, result }] } + : turn, + ) + : [...ctx.turns, { id: turnId, items: [{ prompt: promptParts, result }] }]; +} + +export function acpPromptSettlementBelongsToContext(input: { + readonly liveAcpSessionId: string; + readonly expectedAcpSessionId: string; + readonly liveActiveTurnId: TurnId | undefined; + readonly liveSessionActiveTurnId: TurnId | undefined; + readonly turnId: TurnId; +}): boolean { + return ( + input.liveAcpSessionId === input.expectedAcpSessionId && + (input.liveActiveTurnId === input.turnId || input.liveSessionActiveTurnId === input.turnId) + ); +} + +export const makeAcpThreadLock = Effect.fn("makeAcpThreadLock")(function* () { + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing = current.get(threadId); + if (existing) { + const next = new Map(current); + next.set(threadId, { ...existing, users: existing.users + 1 }); + return Effect.succeed([existing.semaphore, next] as const); + } + return Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, { semaphore, users: 1, deleteWhenIdle: false }); + return [semaphore, next] as const; + }), + ); + }); + + const releaseThreadSemaphore = (threadId: string) => + SynchronizedRef.update(threadLocksRef, (current) => { + const existing = current.get(threadId); + if (!existing) { + return current; + } + const next = new Map(current); + const users = Math.max(0, existing.users - 1); + if (users === 0 && existing.deleteWhenIdle) { + next.delete(threadId); + } else { + next.set(threadId, { ...existing, users }); + } + return next; + }); + + const deleteThreadLock = (threadId: string) => + SynchronizedRef.update(threadLocksRef, (current) => { + const existing = current.get(threadId); + if (!existing) { + return current; + } + const next = new Map(current); + if (existing.users === 0) { + next.delete(threadId); + } else { + next.set(threadId, { ...existing, deleteWhenIdle: true }); + } + return next; + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)).pipe( + Effect.ensuring(releaseThreadSemaphore(threadId)), + ); + + return { + withThreadLock, + deleteThreadLock, + }; +}); + +export function makeAcpPromptSettler< + Ctx extends AcpAdapterPromptContext, + ENow = never, + RNow = never, + EStamp = never, + RStamp = never, + EOffer = never, + ROffer = never, +>(input: { + readonly provider: ProviderDriverKind; + readonly sessions: ReadonlyMap; + readonly nowIso: Effect.Effect; + readonly makeEventStamp: () => Effect.Effect< + { + readonly eventId: EventId; + readonly createdAt: string; + }, + EStamp, + RStamp + >; + readonly offerRuntimeEvent: (event: ProviderRuntimeEvent) => Effect.Effect; +}) { + return ( + threadId: ThreadId, + turnId: TurnId, + expectedAcpSessionId: string, + options?: AcpPromptSettlementOptions, + ) => + Effect.gen(function* () { + const liveCtx = input.sessions.get(threadId); + if (!liveCtx) { + return; + } + const settlementBelongsToLiveContext = acpPromptSettlementBelongsToContext({ + liveAcpSessionId: liveCtx.acpSessionId, + expectedAcpSessionId, + liveActiveTurnId: liveCtx.activeTurnId, + liveSessionActiveTurnId: liveCtx.session.activeTurnId, + turnId, + }); + if (!settlementBelongsToLiveContext) { + if ( + liveCtx.acpSessionId !== expectedAcpSessionId || + liveCtx.interruptedTurnIds.has(turnId) + ) { + return; + } + if (options?.emitTurnCompletion !== false) { + if (options?.errorMessage !== undefined) { + yield* input.offerRuntimeEvent({ + type: "turn.completed", + ...(yield* input.makeEventStamp()), + provider: input.provider, + threadId, + turnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (options?.completedStopReason !== undefined) { + yield* input.offerRuntimeEvent({ + type: "turn.completed", + ...(yield* input.makeEventStamp()), + provider: input.provider, + threadId, + turnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + } + return; + } + let settleTurnId = turnId; + if (options?.settleAllPrompts) { + liveCtx.promptsInFlight = 0; + if (liveCtx.activeTurnId !== turnId && liveCtx.session.activeTurnId !== turnId) { + const fallbackTurnId = liveCtx.activeTurnId ?? liveCtx.session.activeTurnId; + if (!fallbackTurnId) { + if (liveCtx.session.status === "running" || liveCtx.session.status === "connecting") { + const updatedAt = yield* input.nowIso; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + } + return; + } + settleTurnId = fallbackTurnId; + } + } else { + const remainingPrompts = Math.max(0, liveCtx.promptsInFlight - 1); + if ( + remainingPrompts > 0 || + liveCtx.activeTurnId !== settleTurnId || + liveCtx.session.activeTurnId !== settleTurnId + ) { + liveCtx.promptsInFlight = remainingPrompts; + return; + } + liveCtx.promptsInFlight = remainingPrompts; + } + const updatedAt = yield* input.nowIso; + const canEmitTurnCompletion = + liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; + const shouldEmitFailedTurn = options?.errorMessage !== undefined && canEmitTurnCompletion; + const shouldEmitCompletedTurn = + options?.completedStopReason !== undefined && canEmitTurnCompletion; + const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; + liveCtx.activeTurnId = undefined; + liveCtx.session = { + ...readySession, + status: "ready", + updatedAt, + }; + if (options?.emitTurnCompletion === false) { + return; + } + if (shouldEmitFailedTurn) { + yield* input.offerRuntimeEvent({ + type: "turn.completed", + ...(yield* input.makeEventStamp()), + provider: input.provider, + threadId, + turnId: settleTurnId, + payload: { + state: "failed", + errorMessage: options.errorMessage, + }, + }); + } else if (shouldEmitCompletedTurn) { + yield* input.offerRuntimeEvent({ + type: "turn.completed", + ...(yield* input.makeEventStamp()), + provider: input.provider, + threadId, + turnId: settleTurnId, + payload: { + state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", + stopReason: options.completedStopReason ?? null, + }, + }); + } + }); +} diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index 7682c5f5f9c..315f3e2e47a 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -4,6 +4,8 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { extractModelConfigId, + findSessionModelConfigOption, + flattenSessionConfigSelectOptions, mergeToolCallState, parsePermissionRequest, parseSessionModeState, @@ -61,6 +63,69 @@ describe("AcpRuntimeModel", () => { expect(modelConfigId).toBe("model"); }); + it("finds model config options by category first and then id or name", () => { + const categoryMatch = findSessionModelConfigOption([ + { + id: "display-model", + name: "Display Model", + category: "model", + type: "select", + currentValue: "adaptive", + options: [{ value: "adaptive", name: "Adaptive" }], + }, + { + id: "model", + name: "Model", + type: "select", + currentValue: "fallback", + options: [{ value: "fallback", name: "Fallback" }], + }, + ]); + expect(categoryMatch?.id).toBe("display-model"); + + const nameFallback = findSessionModelConfigOption([ + { + id: "agent-model", + name: "Model", + type: "select", + currentValue: "adaptive", + options: [{ value: "adaptive", name: "Adaptive" }], + }, + ]); + expect(nameFallback?.id).toBe("agent-model"); + }); + + it("flattens grouped ACP select config options", () => { + const options = flattenSessionConfigSelectOptions({ + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "adaptive", + options: [ + { + group: "recommended", + name: "Recommended", + options: [ + { value: " adaptive ", name: " Adaptive " }, + { value: "swe-1-6", name: "SWE-1.6" }, + ], + }, + { + group: "private", + name: "Private", + options: [{ value: "MODEL_PRIVATE_11", name: "Private Model" }], + }, + ], + }); + + expect(options).toEqual([ + { value: "adaptive", name: "Adaptive" }, + { value: "swe-1-6", name: "SWE-1.6" }, + { value: "MODEL_PRIVATE_11", name: "Private Model" }, + ]); + }); + it("detects Grok session replay updates from _meta.isReplay", () => { expect( sessionUpdateIsReplay({ diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index e6bfc127e6e..3bd7930926c 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -56,6 +56,16 @@ export interface AcpSessionModeState { readonly availableModes: ReadonlyArray; } +export interface AcpSessionConfigSelectOptionValue { + readonly value: string; + readonly name: string; +} + +export type AcpSessionSelectConfigOption = Extract< + EffectAcpSchema.SessionConfigOption, + { readonly type: "select" } +>; + export interface AcpToolCallState { readonly toolCallId: string; readonly kind?: string; @@ -120,15 +130,26 @@ type AcpToolCallUpdate = Extract< { readonly sessionUpdate: "tool_call" | "tool_call_update" } >; +function configOptionToken(value: string | null | undefined): string { + return value?.trim().toLowerCase() ?? ""; +} + +export function findSessionModelConfigOption( + configOptions: ReadonlyArray | null | undefined, +): AcpSessionSelectConfigOption | undefined { + const selectOptions = configOptions?.filter((option) => option.type === "select") ?? []; + return ( + selectOptions.find((option) => configOptionToken(option.category) === "model") ?? + selectOptions.find((option) => { + const id = configOptionToken(option.id); + const name = configOptionToken(option.name); + return id === "model" || name === "model"; + }) + ); +} + export function extractModelConfigId(sessionResponse: AcpSessionSetupResponse): string | undefined { - const configOptions = sessionResponse.configOptions; - if (!configOptions) return undefined; - for (const opt of configOptions) { - if (opt.category === "model" && opt.id.trim().length > 0) { - return opt.id.trim(); - } - } - return undefined; + return findSessionModelConfigOption(sessionResponse.configOptions)?.id.trim() || undefined; } export function findSessionConfigOption( @@ -148,11 +169,30 @@ export function findSessionConfigOption( export function collectSessionConfigOptionValues( configOption: EffectAcpSchema.SessionConfigOption, ): ReadonlyArray { - if (configOption.type !== "select") { + return flattenSessionConfigSelectOptions(configOption).map((option) => option.value); +} + +export function flattenSessionConfigSelectOptions( + configOption: EffectAcpSchema.SessionConfigOption | undefined, +): ReadonlyArray { + if (!configOption || configOption.type !== "select") { return []; } return configOption.options.flatMap((entry) => - "value" in entry ? [entry.value] : entry.options.map((option) => option.value), + "value" in entry + ? [ + { + value: entry.value.trim(), + name: entry.name.trim(), + } satisfies AcpSessionConfigSelectOptionValue, + ] + : entry.options.map( + (option) => + ({ + value: option.value.trim(), + name: option.name.trim(), + }) satisfies AcpSessionConfigSelectOptionValue, + ), ); } diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpSessionRuntime.test.ts similarity index 90% rename from apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts rename to apps/server/src/provider/acp/AcpSessionRuntime.test.ts index 4e9700dab7d..9dadbc88663 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.test.ts @@ -65,6 +65,37 @@ describe("AcpSessionRuntime", () => { ); }); + it.effect("can skip authenticate for ACP agents that read stored credentials", () => { + const requestEvents: Array = []; + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + const started = yield* runtime.start(); + + expect(started.sessionId).toBe("mock-session-1"); + expect(requestEvents.some((event) => event.method === "authenticate")).toBe(false); + expect(requestEvents.some((event) => event.method === "session/new")).toBe(true); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "unused", + skipAuthentication: true, + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); + it.effect("starts a session, prompts, and emits normalized events against the mock agent", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; @@ -568,6 +599,40 @@ describe("AcpSessionRuntime", () => { ), ); + it.effect("acknowledges a dequeued drainEvents barrier when the runtime scope closes", () => + Effect.gen(function* () { + const drainFiber = yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + const fiber = yield* runtime.drainEvents.pipe(Effect.forkDetach); + const event = yield* Stream.runHead(runtime.getEvents()).pipe(Effect.timeout("1 second")); + + expect(Option.isSome(event)).toBe(true); + if (Option.isSome(event)) { + expect(event.value._tag).toBe("EventStreamBarrier"); + } + + return fiber; + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "test", + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + }), + ), + ), + ); + + yield* Fiber.join(drainFiber).pipe(Effect.timeout("1 second")); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("rejects invalid config option values before sending session/set_config_option", () => { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-")); const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index bc2df3aa8d4..560be1387ae 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -68,6 +68,10 @@ export interface AcpSessionRuntimeOptions { readonly version: string; }; readonly authMethodId: string; + readonly skipAuthentication?: boolean; + readonly resolveAuthMethodId?: ( + initializeResult: EffectAcpSchema.InitializeResponse, + ) => string | undefined; readonly mcpServers?: ReadonlyArray; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { @@ -276,6 +280,32 @@ export const make = ( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runtimeScope = yield* Scope.Scope; const eventQueue = yield* Queue.unbounded(); + const outstandingEventStreamBarriersRef = yield* Ref.make(new Set>()); + // Once the runtime scope closes there may be no event consumer left, so + // reject new offers and acknowledge queued or dequeued barriers to keep + // `drainEvents` callers from waiting on a barrier nobody will process. + yield* Scope.addFinalizer( + runtimeScope, + Effect.gen(function* () { + yield* Queue.interrupt(eventQueue); + const remaining = yield* Queue.clear(eventQueue).pipe( + Effect.catchCause(() => Effect.succeed([] as Array)), + ); + const outstanding = yield* Ref.get(outstandingEventStreamBarriersRef); + yield* Ref.set(outstandingEventStreamBarriersRef, new Set()); + const acknowledgements = new Set(outstanding); + for (const event of remaining) { + if (event._tag === "EventStreamBarrier") { + acknowledgements.add(event.acknowledge); + } + } + yield* Effect.forEach( + acknowledgements, + (acknowledge) => Deferred.succeed(acknowledge, undefined).pipe(Effect.asVoid), + { discard: true }, + ); + }), + ); const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); @@ -529,15 +559,17 @@ export const make = ( acp.agent.initialize(initializePayload), ); - const authenticatePayload = { - methodId: options.authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; + if (!options.skipAuthentication) { + const authenticatePayload = { + methodId: options.resolveAuthMethodId?.(initializeResult) ?? options.authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + } let sessionId: string; let sessionSetupResult: @@ -696,11 +728,29 @@ export const make = ( getEvents: () => Stream.fromQueue(eventQueue), drainEvents: Effect.gen(function* () { const acknowledge = yield* Deferred.make(); - yield* Queue.offer(eventQueue, { - _tag: "EventStreamBarrier", - acknowledge, + yield* Ref.update(outstandingEventStreamBarriersRef, (barriers) => { + const next = new Set(barriers); + next.add(acknowledge); + return next; }); - yield* Deferred.await(acknowledge); + yield* Effect.gen(function* () { + const offered = yield* Queue.offer(eventQueue, { + _tag: "EventStreamBarrier", + acknowledge, + }); + if (!offered) { + return; + } + yield* Deferred.await(acknowledge); + }).pipe( + Effect.ensuring( + Ref.update(outstandingEventStreamBarriersRef, (barriers) => { + const next = new Set(barriers); + next.delete(acknowledge); + return next; + }), + ), + ); }), getModeState: Ref.get(modeStateRef), getConfigOptions: Ref.get(configOptionsRef), diff --git a/apps/server/src/provider/acp/DevinAcpSupport.test.ts b/apps/server/src/provider/acp/DevinAcpSupport.test.ts new file mode 100644 index 00000000000..419eb2e2d47 --- /dev/null +++ b/apps/server/src/provider/acp/DevinAcpSupport.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { + applyDevinAcpModelSelection, + applyDevinRequestedMode, + buildDevinAcpSpawnInput, + currentDevinModelIdFromSessionSetup, + devinAcpModelVariantGroupsFromConfigOptions, + isDevinAcpModelCoveredByBaseModelIds, + resolveDevinAcpDisplayModelId, + resolveDevinAcpModelSelection, +} from "./DevinAcpSupport.ts"; + +describe("DevinAcpSupport", () => { + it("passes the config path as a Devin global flag before the acp subcommand", () => { + expect( + buildDevinAcpSpawnInput( + { + binaryPath: "devin", + configPath: " C:\\devin\\test-config.json ", + }, + "C:\\workspace\\t3code", + ), + ).toEqual({ + command: "devin", + args: ["--config", "C:\\devin\\test-config.json", "acp"], + cwd: "C:\\workspace\\t3code", + }); + }); + + it("reads the current model from ACP configOptions before unstable model state", () => { + const response = { + sessionId: "session-1", + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "adaptive", + options: [{ value: "adaptive", name: "Adaptive" }], + }, + ], + models: { + currentModelId: "legacy-model-state", + availableModels: [{ modelId: "legacy-model-state", name: "Legacy" }], + }, + } satisfies EffectAcpSchema.NewSessionResponse; + + expect(currentDevinModelIdFromSessionSetup(response)).toBe("adaptive"); + }); + + it("groups Devin thinking and speed variants by base model", () => { + const groups = devinAcpModelVariantGroupsFromConfigOptions([ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "gpt-5-5-high-priority", + options: [ + { value: "gpt-5-5-low", name: "GPT-5.5 Low Thinking" }, + { value: "gpt-5-5-high-priority", name: "GPT-5.5 High Thinking Fast" }, + { value: "MODEL_PRIVATE_2", name: "Claude Sonnet 4.5" }, + { value: "MODEL_PRIVATE_3", name: "Claude Sonnet 4.5 Thinking" }, + ], + }, + ]); + + expect( + groups.map((group) => ({ + id: group.baseModelId, + name: group.baseModelName, + current: group.currentVariant?.exactModelId, + variants: group.variants.map((variant) => ({ + exact: variant.exactModelId, + reasoning: variant.reasoning, + fast: variant.fastMode, + })), + })), + ).toEqual([ + { + id: "gpt-5-5", + name: "GPT-5.5", + current: "gpt-5-5-high-priority", + variants: [ + { exact: "gpt-5-5-low", reasoning: "low", fast: false }, + { exact: "gpt-5-5-high-priority", reasoning: "high", fast: true }, + ], + }, + { + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + current: undefined, + variants: [ + { exact: "MODEL_PRIVATE_2", reasoning: undefined, fast: false }, + { exact: "MODEL_PRIVATE_3", reasoning: "thinking", fast: false }, + ], + }, + ]); + }); + + it("keeps model options selectable when exact ids collide on the same display variant", () => { + const groups = devinAcpModelVariantGroupsFromConfigOptions([ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "MODEL_PRIVATE_2", + options: [ + { value: "MODEL_PRIVATE_2", name: "Claude Sonnet 4.5" }, + { value: "MODEL_PRIVATE_3", name: "Claude Sonnet 4.5" }, + ], + }, + ]); + + expect( + groups.map((group) => ({ + id: group.baseModelId, + name: group.baseModelName, + current: group.currentVariant?.exactModelId, + variants: group.variants.map((variant) => variant.exactModelId), + })), + ).toEqual([ + { + id: "MODEL_PRIVATE_2", + name: "Claude Sonnet 4.5 (MODEL_PRIVATE_2)", + current: "MODEL_PRIVATE_2", + variants: ["MODEL_PRIVATE_2"], + }, + { + id: "MODEL_PRIVATE_3", + name: "Claude Sonnet 4.5 (MODEL_PRIVATE_3)", + current: undefined, + variants: ["MODEL_PRIVATE_3"], + }, + ]); + }); + + it("resolves Devin base model options back to exact ACP model ids", () => { + const configOptions: ReadonlyArray = [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "adaptive", + options: [ + { value: "gpt-5-5-low", name: "GPT-5.5 Low Thinking" }, + { value: "gpt-5-5-high", name: "GPT-5.5 High Thinking" }, + { value: "gpt-5-5-high-priority", name: "GPT-5.5 High Thinking Fast" }, + { value: "MODEL_PRIVATE_2", name: "Claude Sonnet 4.5" }, + { value: "MODEL_PRIVATE_3", name: "Claude Sonnet 4.5 Thinking" }, + ], + }, + ]; + + expect( + resolveDevinAcpModelSelection({ + configOptions, + model: "gpt-5-5", + selections: [ + { id: "reasoning", value: "high" }, + { id: "fastMode", value: true }, + ], + }), + ).toBe("gpt-5-5-high-priority"); + expect( + resolveDevinAcpModelSelection({ + configOptions, + model: "claude-sonnet-4-5", + selections: [{ id: "reasoning", value: "thinking" }], + }), + ).toBe("MODEL_PRIVATE_3"); + }); + + it("resolves base model selections to the ACP current variant when no options are supplied", () => { + const configOptions: ReadonlyArray = [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "gpt-5-5-high-priority", + options: [ + { value: "gpt-5-5-low", name: "GPT-5.5 Low Thinking" }, + { value: "gpt-5-5-high-priority", name: "GPT-5.5 High Thinking Fast" }, + ], + }, + ]; + + expect( + resolveDevinAcpModelSelection({ + configOptions, + model: "gpt-5-5", + selections: [], + }), + ).toBe("gpt-5-5-high-priority"); + }); + + it("does not arbitrarily resolve ambiguous built-in model aliases", () => { + const configOptions: ReadonlyArray = [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "claude-sonnet-4", + options: [ + { value: "claude-sonnet-4", name: "Claude Sonnet 4" }, + { value: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + ], + }, + ]; + + expect( + resolveDevinAcpModelSelection({ + configOptions, + model: "sonnet", + selections: [], + }), + ).toBe("sonnet"); + }); + + it("maps exact private ACP model ids to grouped display ids", () => { + const configOptions: ReadonlyArray = [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "MODEL_PRIVATE_2", + options: [ + { value: "MODEL_PRIVATE_2", name: "Claude Sonnet 4.5" }, + { value: "MODEL_PRIVATE_3", name: "Claude Sonnet 4.5 Thinking" }, + ], + }, + ]; + + expect(resolveDevinAcpDisplayModelId(configOptions, "MODEL_PRIVATE_2")).toBe( + "claude-sonnet-4-5", + ); + }); + + it("treats built-in Devin aliases as covering previous discovered concrete slugs", () => { + expect( + isDevinAcpModelCoveredByBaseModelIds({ + modelId: "claude-sonnet-4-5", + modelName: "Claude Sonnet 4.5", + baseModelIds: new Set(["sonnet"]), + }), + ).toBe(true); + }); + + it.effect("switches Devin models through ACP set_config_option", () => + Effect.gen(function* () { + const modelCalls: Array = []; + const runtime = { + setModel: (modelId: string) => + Effect.sync(() => { + modelCalls.push(modelId); + }), + }; + + const result = yield* applyDevinAcpModelSelection({ + runtime, + currentModelId: "adaptive", + requestedModelId: "swe-1-6", + mapError: (cause) => cause.message, + }); + + expect(modelCalls).toEqual(["swe-1-6"]); + expect(result).toBe("swe-1-6"); + }), + ); + + it.effect("maps Devin model switch failures", () => + Effect.gen(function* () { + const failure = EffectAcpErrors.AcpRequestError.invalidParams("unsupported model"); + const runtime = { + setModel: (_modelId: string) => Effect.fail(failure), + }; + + const error = yield* Effect.flip( + applyDevinAcpModelSelection({ + runtime, + currentModelId: "adaptive", + requestedModelId: "swe-1-6", + mapError: (cause) => cause.message, + }), + ); + + expect(error).toBe(failure.message); + }), + ); + + it.effect("switches normal requested mode to Devin ask mode", () => + Effect.gen(function* () { + const modeCalls: Array = []; + const runtime = { + getModeState: Effect.succeed({ + currentModeId: "plan", + availableModes: [ + { id: "ask", name: "Ask" }, + { id: "plan", name: "Plan" }, + { id: "bypass", name: "Bypass" }, + ], + }), + setMode: (modeId: string) => + Effect.sync(() => { + modeCalls.push(modeId); + return {}; + }), + }; + + yield* applyDevinRequestedMode({ + runtime, + runtimeMode: "approval-required", + interactionMode: undefined, + mapError: (cause) => cause.message, + }); + + expect(modeCalls).toEqual(["ask"]); + }), + ); +}); diff --git a/apps/server/src/provider/acp/DevinAcpSupport.ts b/apps/server/src/provider/acp/DevinAcpSupport.ts new file mode 100644 index 00000000000..970a42377ba --- /dev/null +++ b/apps/server/src/provider/acp/DevinAcpSupport.ts @@ -0,0 +1,763 @@ +import { + type DevinSettings, + type ModelCapabilities, + type ProviderOptionSelection, + ProviderDriverKind, + type ProviderInteractionMode, + type RuntimeMode, + type ServerProviderModel, +} from "@t3tools/contracts"; +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 { + createModelCapabilities, + getProviderOptionBooleanSelectionValue, + getProviderOptionStringSelectionValue, + normalizeModelSlug, +} from "@t3tools/shared/model"; + +import { buildBooleanOptionDescriptor, buildSelectOptionDescriptor } from "../providerSnapshot.ts"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import { + findSessionModelConfigOption, + flattenSessionConfigSelectOptions, + type AcpSessionConfigSelectOptionValue, + type AcpSessionModeState, +} from "./AcpRuntimeModel.ts"; + +const DEVIN_STORED_CREDENTIALS_AUTH_METHOD = "devin-stored-credentials"; +const DEVIN_DRIVER_KIND = ProviderDriverKind.make("devin"); +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +type DevinAcpRuntimeDevinSettings = Pick; + +export type DevinAcpReasoningLevel = + | "standard" + | "none" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh" + | "max" + | "thinking"; + +export interface DevinAcpModelVariant { + readonly exactModelId: string; + readonly displayName: string; + readonly baseModelId: string; + readonly baseModelName: string; + readonly reasoning?: DevinAcpReasoningLevel; + readonly fastMode: boolean; + readonly contextWindow?: string; +} + +export interface DevinAcpModelVariantGroup { + readonly baseModelId: string; + readonly baseModelName: string; + readonly variants: ReadonlyArray; + readonly currentVariant?: DevinAcpModelVariant; +} + +export const DEVIN_REASONING_LEVEL_ORDER: ReadonlyArray = [ + "standard", + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "thinking", +]; + +export const DEVIN_REASONING_LEVEL_LABELS: Readonly> = { + standard: "Standard", + none: "No Thinking", + minimal: "Minimal", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "XHigh", + max: "Max", + thinking: "Thinking", +}; + +interface DevinAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly devinSettings: DevinAcpRuntimeDevinSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; +} + +export function buildDevinAcpSpawnInput( + devinSettings: DevinAcpRuntimeDevinSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + const configPath = devinSettings?.configPath?.trim(); + return { + command: devinSettings?.binaryPath || "devin", + args: [...(configPath ? ["--config", configPath] : []), "acp"], + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +export const makeDevinAcpRuntime = ( + input: DevinAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildDevinAcpSpawnInput(input.devinSettings, input.cwd, input.environment), + // Required by the options type but never sent: `skipAuthentication` + // is set because the Devin CLI reads stored credentials itself. + authMethodId: DEVIN_STORED_CREDENTIALS_AUTH_METHOD, + clientCapabilities: { + elicitation: { + form: {}, + url: {}, + }, + }, + skipAuthentication: true, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +export const discoverDevinModelsViaAcp = ( + devinSettings: DevinAcpRuntimeDevinSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtime = yield* makeDevinAcpRuntime({ + devinSettings, + environment, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + const started = yield* runtime.start(); + return buildDevinDiscoveredModelsFromSessionSetup(started.sessionSetupResult); + }).pipe(Effect.scoped); + +export function resolveDevinAcpBaseModelId(model: string | null | undefined): string { + const trimmed = model?.trim(); + const base = trimmed && trimmed.length > 0 ? trimmed : "adaptive"; + return normalizeModelSlug(base, DEVIN_DRIVER_KIND) ?? "adaptive"; +} + +export function devinModelConfigOptionsFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): ReadonlyArray { + return flattenSessionConfigSelectOptions( + findSessionModelConfigOption(sessionSetupResult.configOptions), + ); +} + +function slugifyDevinBaseModelName(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/&/gu, "and") + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-+|-+$/gu, ""); +} + +function stripNameSuffix( + name: string, + pattern: RegExp, +): { readonly name: string; readonly matched: boolean } { + const next = name.replace(pattern, "").trim(); + return next === name ? { name, matched: false } : { name: next, matched: true }; +} + +function parseDevinReasoningSuffix(name: string): { + readonly baseName: string; + readonly reasoning: DevinAcpReasoningLevel | undefined; +} { + const patterns: ReadonlyArray<{ + readonly reasoning: DevinAcpReasoningLevel; + readonly pattern: RegExp; + }> = [ + { reasoning: "none", pattern: /\s+No\s+Thinking$/iu }, + { reasoning: "xhigh", pattern: /\s+X[-\s]?High(?:\s+Thinking)?$/iu }, + { reasoning: "minimal", pattern: /\s+Minimal(?:\s+Thinking)?$/iu }, + { reasoning: "low", pattern: /\s+Low(?:\s+Thinking)?$/iu }, + { reasoning: "medium", pattern: /\s+Medium(?:\s+Thinking)?$/iu }, + { reasoning: "high", pattern: /\s+High(?:\s+Thinking)?$/iu }, + { reasoning: "max", pattern: /\s+Max(?:\s+Thinking)?$/iu }, + { reasoning: "thinking", pattern: /\s+Thinking$/iu }, + ]; + + for (const entry of patterns) { + const stripped = stripNameSuffix(name, entry.pattern); + if (stripped.matched) { + return { baseName: stripped.name, reasoning: entry.reasoning }; + } + } + return { baseName: name.trim(), reasoning: undefined }; +} + +export function devinReasoningKeyForVariant(variant: DevinAcpModelVariant): DevinAcpReasoningLevel { + return variant.reasoning ?? "standard"; +} + +export function devinContextWindowKeyForVariant(variant: DevinAcpModelVariant): string { + return variant.contextWindow ?? "default"; +} + +export function parseDevinAcpModelVariant( + option: AcpSessionConfigSelectOptionValue, +): DevinAcpModelVariant | undefined { + const exactModelId = option.value.trim(); + let displayName = option.name.trim(); + if (!exactModelId || !displayName) { + return undefined; + } + + const fast = stripNameSuffix(displayName, /\s+Fast$/iu); + displayName = fast.name; + const context = stripNameSuffix(displayName, /\s+1M$/iu); + displayName = context.name; + const { baseName, reasoning } = parseDevinReasoningSuffix(displayName); + const baseModelName = baseName || option.name.trim() || exactModelId; + const baseModelId = slugifyDevinBaseModelName(baseModelName) || exactModelId; + + return { + exactModelId, + displayName: option.name.trim(), + baseModelId, + baseModelName, + ...(reasoning ? { reasoning } : {}), + fastMode: fast.matched, + ...(context.matched ? { contextWindow: "1m" } : {}), + }; +} + +export function devinAcpModelVariantsFromConfigOptions( + configOptions: ReadonlyArray | null | undefined, +): ReadonlyArray { + return flattenSessionConfigSelectOptions(findSessionModelConfigOption(configOptions)) + .map(parseDevinAcpModelVariant) + .filter((variant): variant is DevinAcpModelVariant => variant !== undefined); +} + +function devinVariantOptionKey(variant: DevinAcpModelVariant): string { + return [ + devinReasoningKeyForVariant(variant), + devinContextWindowKeyForVariant(variant), + variant.fastMode ? "fast" : "normal", + ].join(":"); +} + +function ambiguousDevinBaseModelIds( + variants: ReadonlyArray, +): ReadonlySet { + const seenVariantKeys = new Map>(); + const ambiguous = new Set(); + for (const variant of variants) { + const variantKeys = seenVariantKeys.get(variant.baseModelId) ?? new Set(); + const variantKey = devinVariantOptionKey(variant); + if (variantKeys.has(variantKey)) { + ambiguous.add(variant.baseModelId); + } + variantKeys.add(variantKey); + seenVariantKeys.set(variant.baseModelId, variantKeys); + } + return ambiguous; +} + +export function devinAcpModelVariantGroupsFromConfigOptions( + configOptions: ReadonlyArray | null | undefined, +): ReadonlyArray { + const modelConfigOption = findSessionModelConfigOption(configOptions); + const currentModelId = modelConfigOption?.currentValue?.trim(); + const groups = new Map< + string, + { baseModelName: string; variants: Array } + >(); + const variants = flattenSessionConfigSelectOptions(modelConfigOption) + .map(parseDevinAcpModelVariant) + .filter((variant): variant is DevinAcpModelVariant => variant !== undefined); + const ambiguousBaseModelIds = ambiguousDevinBaseModelIds(variants); + for (const variant of variants) { + const hasAmbiguousBaseModel = ambiguousBaseModelIds.has(variant.baseModelId); + const groupId = hasAmbiguousBaseModel + ? resolveDevinAcpBaseModelId(variant.exactModelId) + : variant.baseModelId; + const existing = groups.get(groupId); + if (existing) { + existing.variants.push(variant); + continue; + } + groups.set(groupId, { + baseModelName: hasAmbiguousBaseModel + ? `${variant.displayName} (${variant.exactModelId})` + : variant.baseModelName, + variants: [variant], + }); + } + return Array.from(groups, ([baseModelId, group]) => { + const currentVariant = currentModelId + ? group.variants.find((variant) => variant.exactModelId === currentModelId) + : undefined; + return { + baseModelId, + baseModelName: group.baseModelName, + variants: group.variants, + ...(currentVariant ? { currentVariant } : {}), + }; + }); +} + +function normalizeDevinReasoningSelection( + value: string | null | undefined, +): DevinAcpReasoningLevel | undefined { + const normalized = value + ?.trim() + .toLowerCase() + .replace(/[\s_-]+/gu, "-"); + switch (normalized) { + case "standard": + case "default": + return "standard"; + case "none": + case "no-thinking": + return "none"; + case "minimal": + return "minimal"; + case "low": + return "low"; + case "medium": + return "medium"; + case "high": + return "high"; + case "xhigh": + case "x-high": + case "extra-high": + return "xhigh"; + case "max": + return "max"; + case "thinking": + return "thinking"; + default: + return undefined; + } +} + +function modelAliasMatchesDevinBaseModelId(requestedModel: string, baseModelId: string): boolean { + switch (requestedModel) { + case "opus": + return baseModelId.startsWith("claude-opus-"); + case "sonnet": + return baseModelId.startsWith("claude-sonnet-"); + case "swe": + return baseModelId.startsWith("swe-"); + case "codex": + return baseModelId.includes("codex"); + case "gemini": + return baseModelId.startsWith("gemini-"); + default: + return false; + } +} + +function modelAliasMatchesDevinGroup( + requestedModel: string, + group: DevinAcpModelVariantGroup, +): boolean { + return modelAliasMatchesDevinBaseModelId(requestedModel, group.baseModelId); +} + +export function isDevinAcpModelCoveredByBaseModelIds(input: { + readonly modelId: string | null | undefined; + readonly modelName: string | null | undefined; + readonly baseModelIds: ReadonlySet; +}): boolean { + const requestedModel = resolveDevinAcpBaseModelId(input.modelId); + if ( + [...input.baseModelIds].some( + (baseModelId) => + modelAliasMatchesDevinBaseModelId(requestedModel, baseModelId) || + modelAliasMatchesDevinBaseModelId(baseModelId, requestedModel), + ) + ) { + return true; + } + + const variant = parseDevinAcpModelVariant({ + value: requestedModel, + name: input.modelName?.trim() || requestedModel, + }); + return ( + variant !== undefined && + variant.baseModelId !== requestedModel && + input.baseModelIds.has(variant.baseModelId) + ); +} + +function findDevinVariantGroup( + groups: ReadonlyArray, + model: string, +): DevinAcpModelVariantGroup | undefined { + const requestedModel = model.trim(); + if (!requestedModel) { + return undefined; + } + return ( + groups.find((group) => group.baseModelId === requestedModel) ?? + groups.find((group) => + group.variants.some((variant) => variant.exactModelId === requestedModel), + ) ?? + findUniqueDevinAliasGroup(groups, requestedModel) + ); +} + +function findUniqueDevinAliasGroup( + groups: ReadonlyArray, + requestedModel: string, +): DevinAcpModelVariantGroup | undefined { + const matches = groups.filter((group) => modelAliasMatchesDevinGroup(requestedModel, group)); + return matches.length === 1 ? matches[0] : undefined; +} + +/** + * Every filter below is guarded by a `.some(...)` check against a non-empty + * variant list, so `candidates` always stays non-empty. + */ +function selectPreferredDevinVariant(input: { + readonly group: DevinAcpModelVariantGroup; + readonly requestedExactModelId: string | undefined; + readonly requestedReasoning: DevinAcpReasoningLevel | undefined; + readonly requestedFastMode: boolean | undefined; + readonly requestedContextWindow: string | undefined; +}): DevinAcpModelVariant { + const exactRequested = + input.group.variants.find((variant) => variant.exactModelId === input.requestedExactModelId) ?? + input.group.currentVariant; + let candidates = input.group.variants; + + const desiredReasoning = + input.requestedReasoning ?? + (exactRequested ? devinReasoningKeyForVariant(exactRequested) : undefined); + if ( + desiredReasoning && + candidates.some((variant) => devinReasoningKeyForVariant(variant) === desiredReasoning) + ) { + candidates = candidates.filter( + (variant) => devinReasoningKeyForVariant(variant) === desiredReasoning, + ); + } + + const desiredContext = + input.requestedContextWindow ?? + (exactRequested ? devinContextWindowKeyForVariant(exactRequested) : undefined); + if ( + desiredContext && + candidates.some((variant) => devinContextWindowKeyForVariant(variant) === desiredContext) + ) { + candidates = candidates.filter( + (variant) => devinContextWindowKeyForVariant(variant) === desiredContext, + ); + } + + const desiredFastMode = + input.requestedFastMode ?? (exactRequested ? exactRequested.fastMode : undefined); + if ( + typeof desiredFastMode === "boolean" && + candidates.some((variant) => variant.fastMode === desiredFastMode) + ) { + candidates = candidates.filter((variant) => variant.fastMode === desiredFastMode); + } else if (candidates.some((variant) => !variant.fastMode)) { + candidates = candidates.filter((variant) => !variant.fastMode); + } + + return candidates[0]!; +} + +export function resolveDevinAcpModelSelection(input: { + readonly configOptions: ReadonlyArray | null | undefined; + readonly model: string | null | undefined; + readonly selections: ReadonlyArray | null | undefined; +}): string { + const rawRequestedModel = input.model?.trim(); + const requestedModel = resolveDevinAcpBaseModelId(input.model); + const groups = devinAcpModelVariantGroupsFromConfigOptions(input.configOptions); + const group = + (rawRequestedModel ? findDevinVariantGroup(groups, rawRequestedModel) : undefined) ?? + findDevinVariantGroup(groups, requestedModel); + if (!group) { + return requestedModel; + } + + return selectPreferredDevinVariant({ + group, + requestedExactModelId: rawRequestedModel ?? requestedModel, + requestedReasoning: normalizeDevinReasoningSelection( + getProviderOptionStringSelectionValue(input.selections, "reasoning"), + ), + requestedFastMode: getProviderOptionBooleanSelectionValue(input.selections, "fastMode"), + requestedContextWindow: getProviderOptionStringSelectionValue( + input.selections, + "contextWindow", + ), + }).exactModelId; +} + +export function resolveDevinAcpDisplayModelId( + configOptions: ReadonlyArray | null | undefined, + model: string | null | undefined, +): string { + const groups = devinAcpModelVariantGroupsFromConfigOptions(configOptions); + const rawModel = model?.trim(); + if (rawModel) { + const exactGroup = groups.find((group) => + group.variants.some((variant) => variant.exactModelId === rawModel), + ); + if (exactGroup) { + return exactGroup.baseModelId; + } + } + const resolvedModel = resolveDevinAcpBaseModelId(model); + const group = findDevinVariantGroup(groups, resolvedModel); + return group?.baseModelId ?? resolvedModel; +} + +export function currentDevinModelIdFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): string | undefined { + const configModelId = findSessionModelConfigOption( + sessionSetupResult.configOptions, + )?.currentValue; + return configModelId?.trim() || sessionSetupResult.models?.currentModelId?.trim() || undefined; +} + +export function applyDevinAcpModelSelection(input: { + readonly runtime: Pick; + readonly currentModelId: string | undefined; + readonly requestedModelId: string | undefined; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect { + const shouldSwitchModel = + input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; + if (!shouldSwitchModel) { + return Effect.succeed(input.currentModelId); + } + return input.runtime + .setModel(input.requestedModelId) + .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); +} + +function normalizeModeToken(value: string): string { + return value.toLowerCase().replace(/[\s_]+/g, "-"); +} + +function findModeId( + modeState: AcpSessionModeState | undefined, + aliases: ReadonlyArray, +): string | undefined { + if (!modeState) { + return undefined; + } + const normalizedAliases = new Set(aliases.map(normalizeModeToken)); + for (const mode of modeState.availableModes) { + const id = normalizeModeToken(mode.id); + const name = normalizeModeToken(mode.name); + if (normalizedAliases.has(id) || normalizedAliases.has(name)) { + return mode.id; + } + } + return undefined; +} + +export function applyDevinRequestedMode(input: { + readonly runtime: Pick< + AcpSessionRuntime.AcpSessionRuntime["Service"], + "getModeState" | "setMode" + >; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode | undefined; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect { + const aliases = + input.interactionMode === "plan" + ? ["plan"] + : input.runtimeMode === "full-access" + ? ["bypass", "bypass-permissions", "bypasspermissions", "danger-full-access"] + : input.runtimeMode === "auto-accept-edits" + ? ["accept-edits", "acceptedits", "accept-edits-mode"] + : ["ask", "normal", "default"]; + + return input.runtime.getModeState.pipe( + Effect.flatMap((modeState) => { + const modeId = findModeId(modeState, aliases); + return modeId ? input.runtime.setMode(modeId).pipe(Effect.asVoid) : Effect.void; + }), + Effect.mapError(input.mapError), + ); +} + +function buildDevinDiscoveredModelsFromSessionModelState( + modelState: EffectAcpSchema.SessionModelState | null | undefined, +): ReadonlyArray { + if (!modelState || modelState.availableModels.length === 0) { + return []; + } + const seen = new Set(); + return modelState.availableModels + .map((model): ServerProviderModel | undefined => { + const slug = resolveDevinAcpBaseModelId(model.modelId); + if (!slug || seen.has(slug)) { + return undefined; + } + seen.add(slug); + return { + slug, + name: model.name.trim() || slug, + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }; + }) + .filter((model): model is ServerProviderModel => model !== undefined); +} + +function uniqueSortedDevinReasoningLevels(group: DevinAcpModelVariantGroup) { + const levels = new Set(group.variants.map(devinReasoningKeyForVariant)); + return DEVIN_REASONING_LEVEL_ORDER.filter((level) => levels.has(level)); +} + +function buildDevinCapabilitiesForVariantGroup(group: DevinAcpModelVariantGroup) { + const defaultVariant = group.currentVariant ?? group.variants[0]; + const reasoningLevels = uniqueSortedDevinReasoningLevels(group); + const contextWindows = Array.from(new Set(group.variants.map(devinContextWindowKeyForVariant))); + const hasFastVariants = group.variants.some((variant) => variant.fastMode); + const hasNormalSpeedVariants = group.variants.some((variant) => !variant.fastMode); + + const optionDescriptors = [ + ...(reasoningLevels.length > 1 && defaultVariant + ? [ + buildSelectOptionDescriptor({ + id: "reasoning", + label: "Thinking", + options: reasoningLevels.map((level) => ({ + value: level, + label: DEVIN_REASONING_LEVEL_LABELS[level], + ...(devinReasoningKeyForVariant(defaultVariant) === level ? { isDefault: true } : {}), + })), + }), + ] + : []), + ...(contextWindows.length > 1 && defaultVariant + ? [ + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + options: contextWindows + .sort((a, b) => (a === "default" ? -1 : b === "default" ? 1 : a.localeCompare(b))) + .map((contextWindow) => ({ + value: contextWindow, + label: contextWindow === "default" ? "Default" : contextWindow.toUpperCase(), + ...(devinContextWindowKeyForVariant(defaultVariant) === contextWindow + ? { isDefault: true } + : {}), + })), + }), + ] + : []), + ...(hasFastVariants && hasNormalSpeedVariants + ? [ + buildBooleanOptionDescriptor({ + id: "fastMode", + label: "Fast Mode", + currentValue: defaultVariant?.fastMode === true, + }), + ] + : []), + ]; + + return createModelCapabilities({ optionDescriptors }); +} + +function buildDevinDiscoveredModelsFromConfigOptions( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): ReadonlyArray { + const seen = new Set(); + const groups = devinAcpModelVariantGroupsFromConfigOptions(sessionSetupResult.configOptions); + if (groups.length > 0) { + return groups + .map((group): ServerProviderModel | undefined => { + const slug = group.baseModelId.trim(); + if (!slug || seen.has(slug)) { + return undefined; + } + seen.add(slug); + return { + slug, + name: group.baseModelName.trim() || slug, + isCustom: false, + capabilities: buildDevinCapabilitiesForVariantGroup(group), + }; + }) + .filter((model): model is ServerProviderModel => model !== undefined); + } + + return devinModelConfigOptionsFromSessionSetup(sessionSetupResult) + .map((option): ServerProviderModel | undefined => { + const slug = option.value.trim(); + if (!slug || seen.has(slug)) { + return undefined; + } + seen.add(slug); + return { + slug, + name: option.name.trim() || slug, + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }; + }) + .filter((model): model is ServerProviderModel => model !== undefined); +} + +export function buildDevinDiscoveredModelsFromSessionSetup( + sessionSetupResult: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): ReadonlyArray { + const configModels = buildDevinDiscoveredModelsFromConfigOptions(sessionSetupResult); + return configModels.length > 0 + ? configModels + : buildDevinDiscoveredModelsFromSessionModelState(sessionSetupResult.models); +} diff --git a/apps/server/src/provider/acp/DevinElicitation.test.ts b/apps/server/src/provider/acp/DevinElicitation.test.ts new file mode 100644 index 00000000000..d252bf681fc --- /dev/null +++ b/apps/server/src/provider/acp/DevinElicitation.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { makeDevinElicitationPrompt } from "./DevinElicitation.ts"; + +describe("makeDevinElicitationPrompt", () => { + it("disambiguates duplicate enum labels and rejects invalid custom answers", () => { + const prompt = makeDevinElicitationPrompt({ + mode: "form", + sessionId: "session-1", + message: "Choose a scope", + requestedSchema: { + type: "object", + title: "Scope", + properties: { + scope: { + type: "string", + title: "Scope", + oneOf: [ + { const: "repo", title: "Repository" }, + { const: "workspace", title: "Repository" }, + { const: "Repository (workspace)", title: "Repository (workspace)" }, + ], + }, + }, + required: ["scope"], + }, + }); + + expect(prompt.questions[0]?.options.map((option) => option.label)).toEqual([ + "Repository", + "Repository (workspace)", + "Repository (workspace) (Repository (workspace))", + ]); + + const invalid = prompt.makeResponse({ scope: "foobar" }); + expect(invalid).toEqual({ action: { action: "decline" } }); + + const valid = prompt.makeResponse({ scope: "Repository (workspace)" }); + expect(valid).toEqual({ + action: { + action: "accept", + content: { scope: "workspace" }, + }, + }); + + const labelCollision = prompt.makeResponse({ + scope: "Repository (workspace) (Repository (workspace))", + }); + expect(labelCollision).toEqual({ + action: { + action: "accept", + content: { scope: "Repository (workspace)" }, + }, + }); + }); + + it("treats empty numeric answers as missing instead of zero", () => { + const prompt = makeDevinElicitationPrompt({ + mode: "form", + sessionId: "session-1", + message: "How many retries?", + requestedSchema: { + type: "object", + title: "Retries", + properties: { + retries: { + type: "integer", + title: "Retries", + }, + }, + required: ["retries"], + }, + }); + + expect(prompt.makeResponse({ retries: "" })).toEqual({ action: { action: "decline" } }); + expect(prompt.makeResponse({ retries: " " })).toEqual({ action: { action: "decline" } }); + expect(prompt.makeResponse({ retries: "2" })).toEqual({ + action: { action: "accept", content: { retries: 2 } }, + }); + }); + + it("marks optional form questions and omits unanswered optional values", () => { + const prompt = makeDevinElicitationPrompt({ + mode: "form", + sessionId: "session-1", + message: "Choose options", + requestedSchema: { + type: "object", + title: "Options", + properties: { + scope: { + type: "string", + title: "Scope", + }, + notes: { + type: "string", + title: "Notes", + }, + }, + required: ["scope"], + }, + }); + + expect( + prompt.questions.map((question) => ({ id: question.id, required: question.required })), + ).toEqual([ + { id: "scope", required: true }, + { id: "notes", required: false }, + ]); + expect(prompt.makeResponse({ scope: "workspace" })).toEqual({ + action: { action: "accept", content: { scope: "workspace" } }, + }); + }); + + it("only accepts explicit Done answers for URL elicitation prompts", () => { + const prompt = makeDevinElicitationPrompt({ + mode: "url", + sessionId: "session-1", + elicitationId: "elicitation-1", + message: "Complete setup", + url: "https://example.com/setup", + }); + + expect(prompt.makeResponse({ __devin_elicitation_url: "Done" })).toEqual({ + action: { action: "accept" }, + }); + expect(prompt.makeResponse({ __devin_elicitation_url: "Cancel" })).toEqual({ + action: { action: "cancel" }, + }); + expect(prompt.makeResponse({ __devin_elicitation_url: "not done" })).toEqual({ + action: { action: "decline" }, + }); + }); +}); diff --git a/apps/server/src/provider/acp/DevinElicitation.ts b/apps/server/src/provider/acp/DevinElicitation.ts new file mode 100644 index 00000000000..7320013070c --- /dev/null +++ b/apps/server/src/provider/acp/DevinElicitation.ts @@ -0,0 +1,387 @@ +import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +function trimmedString(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +function optionDescription(label: string, fallback: string | undefined): string { + return trimmedString(fallback) ?? label; +} + +function uniqueOptionLabel(label: string, value: string, seenLabels: Set): string { + if (!seenLabels.has(label)) { + seenLabels.add(label); + return label; + } + let suffix = 2; + let candidate = `${label} (${value})`; + while (seenLabels.has(candidate)) { + candidate = `${label} (${value}) #${suffix}`; + suffix += 1; + } + seenLabels.add(candidate); + return candidate; +} + +function enumOptionMaps(entries: ReadonlyArray): { + readonly options: UserInputQuestion["options"]; + readonly valuesByLabel: ReadonlyMap; + readonly allowedValues: ReadonlySet; +} { + const valuesByLabel = new Map(); + const allowedValues = new Set(); + const seenLabels = new Set(); + const options = entries.flatMap((entry) => { + const baseLabel = trimmedString(entry.title) ?? trimmedString(entry.const); + const value = trimmedString(entry.const); + if (!baseLabel || !value) { + return []; + } + allowedValues.add(value); + const label = uniqueOptionLabel(baseLabel, value, seenLabels); + // Only labels are mapped here; raw values resolve through `allowedValues` + // so a value that collides with another option's label cannot shadow it. + valuesByLabel.set(label, value); + return [ + { + label, + description: optionDescription(label, value), + }, + ]; + }); + return { options, valuesByLabel, allowedValues }; +} + +function stringEnumOptionMaps(values: ReadonlyArray): { + readonly options: UserInputQuestion["options"]; + readonly valuesByLabel: ReadonlyMap; + readonly allowedValues: ReadonlySet; +} { + const valuesByLabel = new Map(); + const allowedValues = new Set(); + const seenLabels = new Set(); + const options = values.flatMap((entry) => { + const value = trimmedString(entry); + if (!value) { + return []; + } + allowedValues.add(value); + const label = uniqueOptionLabel(value, value, seenLabels); + valuesByLabel.set(label, value); + return [{ label, description: label }]; + }); + return { options, valuesByLabel, allowedValues }; +} + +function answerStrings(answer: unknown): ReadonlyArray { + if (Array.isArray(answer)) { + return answer.flatMap((entry) => { + const value = typeof entry === "string" ? trimmedString(entry) : undefined; + return value ? [value] : []; + }); + } + if (typeof answer !== "string") { + return []; + } + const value = trimmedString(answer); + return value ? [value] : []; +} + +function resolveChoiceValue( + value: string, + valuesByLabel: ReadonlyMap, + allowedValues: ReadonlySet, +): string | undefined { + const mapped = valuesByLabel.get(value); + if (mapped !== undefined) { + return mapped; + } + return allowedValues.has(value) ? value : undefined; +} + +function normalizeStringAnswer( + answer: unknown, + valuesByLabel: ReadonlyMap, + allowedValues: ReadonlySet, + fallback: string | null | undefined, +): string | undefined { + const value = answerStrings(answer)[0] ?? trimmedString(fallback); + if (!value) { + return undefined; + } + return allowedValues.size > 0 ? resolveChoiceValue(value, valuesByLabel, allowedValues) : value; +} + +function normalizeStringArrayAnswer( + answer: unknown, + valuesByLabel: ReadonlyMap, + allowedValues: ReadonlySet, + fallback: ReadonlyArray | null | undefined, +): ReadonlyArray | undefined { + const values = Array.isArray(answer) + ? answerStrings(answer) + : typeof answer === "string" + ? answer + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + : (fallback ?? []).flatMap((entry) => { + const value = trimmedString(entry); + return value ? [value] : []; + }); + if (values.length === 0) { + return undefined; + } + if (allowedValues.size === 0) { + return values; + } + const normalized = values.flatMap((value) => { + const resolved = resolveChoiceValue(value, valuesByLabel, allowedValues); + return resolved ? [resolved] : []; + }); + return normalized.length > 0 ? normalized : undefined; +} + +function normalizeNumberAnswer( + answer: unknown, + fallback: number | null | undefined, + integer: boolean, +): number | undefined { + // An empty string is "no answer" and must fall back to the default instead + // of Number("") === 0. + const trimmedAnswer = typeof answer === "string" ? trimmedString(answer) : undefined; + const value = + typeof answer === "number" + ? answer + : trimmedAnswer !== undefined + ? Number(trimmedAnswer) + : fallback; + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + if (integer && !Number.isInteger(value)) { + return undefined; + } + return value; +} + +function normalizeBooleanAnswer( + answer: unknown, + fallback: boolean | null | undefined, +): boolean | undefined { + if (typeof answer === "boolean") { + return answer; + } + if (typeof answer === "string") { + const normalized = answer.trim().toLowerCase(); + if (normalized === "yes" || normalized === "true") { + return true; + } + if (normalized === "no" || normalized === "false") { + return false; + } + } + return typeof fallback === "boolean" ? fallback : undefined; +} + +interface DevinElicitationQuestionMapping { + readonly id: string; + readonly question: UserInputQuestion; + readonly toContentValue: (answer: unknown) => EffectAcpSchema.ElicitationContentValue | undefined; +} + +export interface DevinElicitationPrompt { + readonly questions: ReadonlyArray; + readonly makeResponse: (answers: ProviderUserInputAnswers) => EffectAcpSchema.ElicitationResponse; +} + +function makeDevinElicitationQuestion( + request: Extract, + id: string, + property: EffectAcpSchema.ElicitationPropertySchema, + required: boolean, +): DevinElicitationQuestionMapping | undefined { + const schema = request.requestedSchema; + const header = trimmedString(schema.title) ?? "Question"; + const title = trimmedString(property.title) ?? id; + const question = trimmedString(property.description) ?? title; + + switch (property.type) { + case "string": { + const mappedOptions = + property.oneOf && property.oneOf.length > 0 + ? enumOptionMaps(property.oneOf) + : property.enum && property.enum.length > 0 + ? stringEnumOptionMaps(property.enum) + : { + options: [], + valuesByLabel: new Map(), + allowedValues: new Set(), + }; + return { + id, + question: { + id, + header, + question, + options: mappedOptions.options, + required, + multiSelect: false, + }, + toContentValue: (answer) => + normalizeStringAnswer( + answer, + mappedOptions.valuesByLabel, + mappedOptions.allowedValues, + property.default, + ), + }; + } + case "number": + case "integer": + return { + id, + question: { + id, + header, + question, + options: [], + required, + multiSelect: false, + }, + toContentValue: (answer) => + normalizeNumberAnswer(answer, property.default, property.type === "integer"), + }; + case "boolean": + return { + id, + question: { + id, + header, + question, + options: [ + { label: "Yes", description: "True" }, + { label: "No", description: "False" }, + ], + required, + multiSelect: false, + }, + toContentValue: (answer) => normalizeBooleanAnswer(answer, property.default), + }; + case "array": { + const mappedOptions = + "anyOf" in property.items + ? enumOptionMaps(property.items.anyOf) + : stringEnumOptionMaps(property.items.enum); + return { + id, + question: { + id, + header, + question, + options: mappedOptions.options, + required, + multiSelect: true, + }, + toContentValue: (answer) => + normalizeStringArrayAnswer( + answer, + mappedOptions.valuesByLabel, + mappedOptions.allowedValues, + property.default, + ), + }; + } + } +} + +function makeDevinFormElicitationPrompt( + request: Extract, +): DevinElicitationPrompt { + const properties = request.requestedSchema.properties ?? {}; + const required = new Set(request.requestedSchema.required ?? []); + const mappings = Object.entries(properties).flatMap(([id, property]) => { + const mapping = makeDevinElicitationQuestion(request, id, property, required.has(id)); + return mapping ? [mapping] : []; + }); + + if (mappings.length === 0) { + const id = "__devin_elicitation_continue"; + const question = { + id, + header: trimmedString(request.requestedSchema.title) ?? "Question", + question: trimmedString(request.message) ?? "Continue?", + options: [{ label: "Continue", description: "Continue" }], + required: true, + multiSelect: false, + } satisfies UserInputQuestion; + return { + questions: [question], + makeResponse: () => ({ action: { action: "accept" } }), + }; + } + + return { + questions: mappings.map((mapping) => mapping.question), + makeResponse: (answers) => { + const content: Record = {}; + for (const mapping of mappings) { + const value = mapping.toContentValue(answers[mapping.id]); + if (value === undefined) { + if (required.has(mapping.id)) { + return { action: { action: "decline" } }; + } + continue; + } + content[mapping.id] = value; + } + return { + action: { + action: "accept", + ...(Object.keys(content).length > 0 ? { content } : {}), + }, + }; + }, + }; +} + +function makeDevinUrlElicitationPrompt( + request: Extract, +): DevinElicitationPrompt { + const id = "__devin_elicitation_url"; + return { + questions: [ + { + id, + header: "Devin", + question: `${request.message}\n${request.url}`, + options: [ + { label: "Done", description: "Continue after completing the request" }, + { label: "Cancel", description: "Cancel this request" }, + ], + required: true, + multiSelect: false, + }, + ], + makeResponse: (answers) => { + const answer = normalizeStringAnswer(answers[id], new Map(), new Set(), undefined); + if (answer === "Done") { + return { action: { action: "accept" } }; + } + if (answer === "Cancel") { + return { action: { action: "cancel" } }; + } + return { action: { action: "decline" } }; + }, + }; +} + +export function makeDevinElicitationPrompt( + request: EffectAcpSchema.ElicitationRequest, +): DevinElicitationPrompt { + return request.mode === "form" + ? makeDevinFormElicitationPrompt(request) + : makeDevinUrlElicitationPrompt(request); +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3..318d450edfd 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/AcpJsonTextGeneration.ts b/apps/server/src/textGeneration/AcpJsonTextGeneration.ts new file mode 100644 index 00000000000..5d7fd09af5a --- /dev/null +++ b/apps/server/src/textGeneration/AcpJsonTextGeneration.ts @@ -0,0 +1,275 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; +import type * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { type ModelSelection, TextGenerationError } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import type * as AcpSessionRuntime from "../provider/acp/AcpSessionRuntime.ts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; + +const ACP_TEXT_GENERATION_TIMEOUT_MS = 180_000; + +const isTextGenerationError = Schema.is(TextGenerationError); + +export type AcpJsonTextGenerationOperation = + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + +export interface AcpJsonTextGenerationOptions { + /** Prefix for `Effect.fn` trace names, e.g. `DevinTextGeneration`. */ + readonly traceName: string; + /** Label for request-level errors, e.g. `Devin ACP`. */ + readonly requestLabel: string; + /** Label for output-level errors, e.g. `Devin` or `Grok Agent`. */ + readonly outputLabel: string; + readonly makeRuntime: ( + cwd: string, + ) => Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Scope.Scope + >; + /** Provider-specific session setup (mode, model selection) after start. */ + readonly configureSession: (input: { + readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly started: AcpSessionRuntime.AcpSessionRuntimeStartResult; + readonly modelSelection: ModelSelection; + readonly operation: AcpJsonTextGenerationOperation; + }) => Effect.Effect; +} + +/** + * Builds a headless ACP text-generation service: spawn the agent, send one + * prompt, collect assistant text, and decode it as JSON. Shared by every + * provider whose CLI speaks ACP (Cursor, Grok, Devin). + */ +export function makeAcpJsonTextGeneration( + options: AcpJsonTextGenerationOptions, +): TextGeneration.TextGeneration["Service"] { + const runAcpJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: AcpJsonTextGenerationOperation; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const outputRef = yield* Ref.make(""); + const runtime = yield* options.makeRuntime(cwd); + + 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); + }); + // Headless runs cannot answer interactive requests; cancel them so the + // agent terminates deterministically instead of waiting forever. + yield* runtime.handleElicitation(() => + Effect.succeed({ + action: { action: "cancel" }, + } satisfies EffectAcpSchema.ElicitationResponse), + ); + yield* runtime.handleRequestPermission(() => + Effect.succeed({ + outcome: { outcome: "cancelled" }, + } satisfies EffectAcpSchema.RequestPermissionResponse), + ); + + const promptResult = yield* Effect.gen(function* () { + const started = yield* runtime.start(); + yield* options.configureSession({ runtime, started, modelSelection, operation }); + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(ACP_TEXT_GENERATION_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: `${options.requestLabel} request timed out.`, + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: `${options.requestLabel} request failed.`, + cause, + }), + ), + ); + + if (promptResult.stopReason === "cancelled") { + return yield* new TextGenerationError({ + operation, + detail: `${options.requestLabel} request was cancelled.`, + }); + } + + const trimmed = (yield* Ref.get(outputRef)).trim(); + if (!trimmed) { + return yield* new TextGenerationError({ + operation, + detail: `${options.outputLabel} returned empty output.`, + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(trimmed)).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: `${options.outputLabel} returned invalid structured output.`, + cause, + }), + ), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: `${options.requestLabel} text generation failed.`, + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn(`${options.traceName}.generateCommitMessage`)(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + const generated = yield* runAcpJson({ + 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(`${options.traceName}.generatePrContent`)(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + + const generated = yield* runAcpJson({ + 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(`${options.traceName}.generateBranchName`)(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runAcpJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn(`${options.traceName}.generateThreadTitle`)(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runAcpJson({ + 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/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index 3e1f4eb8bbc..0bec3617926 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -1,34 +1,14 @@ import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { type CursorSettings, type ModelSelection } from "@t3tools/contracts"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { extractJsonObject } from "@t3tools/shared/schemaJson"; +import { type CursorSettings, TextGenerationError } from "@t3tools/contracts"; -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 { applyCursorAcpModelSelection, makeCursorAcpRuntime, } from "../provider/acp/CursorAcpSupport.ts"; - -const CURSOR_TIMEOUT_MS = 180_000; - -const isTextGenerationError = Schema.is(TextGenerationError); +import { makeAcpJsonTextGeneration } from "./AcpJsonTextGeneration.ts"; +import type * as TextGeneration from "./TextGeneration.ts"; /** * Build a Cursor text-generation closure bound to a specific `CursorSettings` @@ -41,47 +21,20 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const resolvedEnvironment = environment ?? process.env; - const runCursorJson = ({ - 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* makeCursorAcpRuntime({ + return makeAcpJsonTextGeneration({ + traceName: "CursorTextGeneration", + requestLabel: "Cursor ACP", + outputLabel: "Cursor Agent", + makeRuntime: (cwd) => + makeCursorAcpRuntime({ cursorSettings, environment: resolvedEnvironment, childProcessSpawner: commandSpawner, cwd, clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, - }); - - 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(); + }), + configureSession: ({ runtime, modelSelection, operation }) => + Effect.gen(function* () { yield* Effect.ignore(runtime.setMode("ask")); yield* applyCursorAcpModelSelection({ runtime, @@ -97,166 +50,6 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu cause, }), }); - - return yield* runtime.prompt({ - prompt: [{ type: "text", text: prompt }], - }); - }).pipe( - Effect.timeoutOption(CURSOR_TIMEOUT_MS), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Cursor Agent request timed out.", - }), - ), - onSome: (value) => Effect.succeed(value), - }), - ), - Effect.mapError((cause) => - isTextGenerationError(cause) - ? cause - : new TextGenerationError({ - operation, - detail: "Cursor ACP request failed.", - cause, - }), - ), - ); - - const rawResult = (yield* Ref.get(outputRef)).trim(); - if (!rawResult) { - return yield* new TextGenerationError({ - operation, - detail: - promptResult.stopReason === "cancelled" - ? "Cursor ACP request was cancelled." - : "Cursor 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: "Cursor Agent returned invalid structured output.", - cause, - }), - ), - }), - ); - }).pipe( - Effect.mapError((cause) => - isTextGenerationError(cause) - ? cause - : new TextGenerationError({ - operation, - detail: "Cursor ACP text generation failed.", - cause, - }), - ), - Effect.scoped, - ); - - const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = - Effect.fn("CursorTextGeneration.generateCommitMessage")(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); - - const generated = yield* runCursorJson({ - 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("CursorTextGeneration.generatePrContent")(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); - - const generated = yield* runCursorJson({ - 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("CursorTextGeneration.generateBranchName")(function* (input) { - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); - - const generated = yield* runCursorJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - branch: sanitizeBranchFragment(generated.branch), - }; - }); - - const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = - Effect.fn("CursorTextGeneration.generateThreadTitle")(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, - }); - - const generated = yield* runCursorJson({ - 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"]; + }), + }) satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/DevinTextGeneration.ts b/apps/server/src/textGeneration/DevinTextGeneration.ts new file mode 100644 index 00000000000..31d4c15e0e4 --- /dev/null +++ b/apps/server/src/textGeneration/DevinTextGeneration.ts @@ -0,0 +1,54 @@ +import * as Effect from "effect/Effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { type DevinSettings, TextGenerationError } from "@t3tools/contracts"; + +import { + applyDevinAcpModelSelection, + currentDevinModelIdFromSessionSetup, + makeDevinAcpRuntime, + resolveDevinAcpModelSelection, +} from "../provider/acp/DevinAcpSupport.ts"; +import { makeAcpJsonTextGeneration } from "./AcpJsonTextGeneration.ts"; +import type * as TextGeneration from "./TextGeneration.ts"; + +export const makeDevinTextGeneration = Effect.fn("makeDevinTextGeneration")(function* ( + devinSettings: DevinSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + return makeAcpJsonTextGeneration({ + traceName: "DevinTextGeneration", + requestLabel: "Devin ACP", + outputLabel: "Devin", + makeRuntime: (cwd) => + makeDevinAcpRuntime({ + devinSettings, + environment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }), + configureSession: ({ runtime, started, modelSelection, operation }) => + Effect.gen(function* () { + yield* Effect.ignore(runtime.setMode("ask")); + const resolvedModel = resolveDevinAcpModelSelection({ + configOptions: started.sessionSetupResult.configOptions, + model: modelSelection.model, + selections: modelSelection.options, + }); + yield* applyDevinAcpModelSelection({ + runtime, + currentModelId: currentDevinModelIdFromSessionSetup(started.sessionSetupResult), + requestedModelId: resolvedModel, + mapError: (cause) => + new TextGenerationError({ + operation, + detail: "Failed to set Devin ACP base model for text generation.", + cause, + }), + }); + }), + }) satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index 1bb58216305..5082c0b0a01 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -1,37 +1,16 @@ import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/unstable/process"; -import type * as EffectAcpErrors from "effect-acp/errors"; -import { type GrokSettings, type ModelSelection } from "@t3tools/contracts"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { extractJsonObject } from "@t3tools/shared/schemaJson"; +import { type GrokSettings, TextGenerationError } from "@t3tools/contracts"; -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 { applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, } from "../provider/acp/GrokAcpSupport.ts"; - -const GROK_TIMEOUT_MS = 180_000; - -const isTextGenerationError = Schema.is(TextGenerationError); +import { makeAcpJsonTextGeneration } from "./AcpJsonTextGeneration.ts"; +import type * as TextGeneration from "./TextGeneration.ts"; export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(function* ( grokSettings: GrokSettings, @@ -39,216 +18,29 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const runGrokJson = ({ - operation, - cwd, - prompt, - outputSchemaJson, - modelSelection, - }: { - operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; - cwd: string; - prompt: string; - outputSchemaJson: S; - modelSelection: ModelSelection; - }): Effect.Effect => - Effect.gen(function* () { - const resolvedModel = resolveGrokAcpBaseModelId(modelSelection.model); - const outputRef = yield* Ref.make(""); - const runtime = yield* makeGrokAcpRuntime({ + return makeAcpJsonTextGeneration({ + traceName: "GrokTextGeneration", + requestLabel: "Grok ACP", + outputLabel: "Grok Agent", + makeRuntime: (cwd) => + makeGrokAcpRuntime({ grokSettings, environment, childProcessSpawner: commandSpawner, cwd, clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, - }); - - yield* runtime.handleSessionUpdate((notification) => { - const update = notification.update; - if (update.sessionUpdate !== "agent_message_chunk") { - return Effect.void; - } - const content = update.content; - if (content.type !== "text") { - return Effect.void; - } - return Ref.update(outputRef, (current) => current + content.text); - }); - - const promptResult = yield* Effect.gen(function* () { - const started = yield* runtime.start(); - yield* applyGrokAcpModelSelection({ - runtime, - currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), - requestedModelId: resolvedModel, - mapError: (cause) => - new TextGenerationError({ - operation, - detail: "Failed to set Grok ACP base model for text generation.", - cause, - }), - }); - - return yield* runtime.prompt({ - prompt: [{ type: "text", text: prompt }], - }); - }).pipe( - Effect.timeoutOption(GROK_TIMEOUT_MS), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - new TextGenerationError({ operation, detail: "Grok ACP request timed out." }), - ), - onSome: (value) => Effect.succeed(value), + }), + configureSession: ({ runtime, started, modelSelection, operation }) => + applyGrokAcpModelSelection({ + runtime, + currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult), + requestedModelId: resolveGrokAcpBaseModelId(modelSelection.model), + mapError: (cause) => + new TextGenerationError({ + operation, + detail: "Failed to set Grok ACP base model for text generation.", + cause, }), - ), - Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => - isTextGenerationError(cause) - ? cause - : new TextGenerationError({ - operation, - detail: "Grok ACP request failed.", - cause, - }), - ), - ); - - const trimmed = (yield* Ref.get(outputRef)).trim(); - if (!trimmed) { - return yield* new TextGenerationError({ - operation, - detail: - promptResult.stopReason === "cancelled" - ? "Grok ACP request was cancelled." - : "Grok Agent returned empty output.", - }); - } - - const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); - return yield* decodeOutput(extractJsonObject(trimmed)).pipe( - Effect.catchTags({ - SchemaError: (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Grok Agent returned invalid structured output.", - cause, - }), - ), - }), - ); - }).pipe( - Effect.mapError((cause) => - isTextGenerationError(cause) - ? cause - : new TextGenerationError({ - operation, - detail: "Grok ACP text generation failed.", - cause, - }), - ), - Effect.scoped, - ); - - const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = - Effect.fn("GrokTextGeneration.generateCommitMessage")(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); - - const generated = yield* runGrokJson({ - 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("GrokTextGeneration.generatePrContent")(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); - - const generated = yield* runGrokJson({ - 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("GrokTextGeneration.generateBranchName")(function* (input) { - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); - - const generated = yield* runGrokJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - branch: sanitizeBranchFragment(generated.branch), - }; - }); - - const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = - Effect.fn("GrokTextGeneration.generateThreadTitle")(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, - }); - - const generated = yield* runGrokJson({ - 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"]; + }).pipe(Effect.asVoid), + }) satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index e62a79afe78..b736baeb313 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -7,8 +7,6 @@ import { TextGenerationError } from "@t3tools/contracts"; import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; - export interface CommitMessageGenerationInput { cwd: string; branch: string | null; diff --git a/apps/web/public/brand/devin-light.png b/apps/web/public/brand/devin-light.png new file mode 100644 index 0000000000000000000000000000000000000000..48aa1ce4c810ac3894b831c3e39a68134a465af4 GIT binary patch literal 2970 zcmeH}`8(8$7so#{V=N7YMnkTeWGmSjTgG6rg={JNa=FGbl66`zvP2?7mMjrUL@HZj zYh-1b?J86&fWZ@1*Y3*qF{YNn&Bf+dG{5M&;GQY^9 zVRI_9DZ{!nOQr)l-xi1N6WzUhw=T(cH-=!T25}Z5v^?5m(bwgi*u2T1--77|`?9u0 zWmXeq6Jg36lbmkAVI@ygcnm@!d^&(`Q&ytg`*RQ#9Ra|P{(|#=1-KkgABLGjVWrto zjA(WkRh+aFH%=OfGVlj&7PL#IUuDj(Tbk-q&R8&e8ukNG?j8tJU0|lX2Yps-Y9_bF zbg?>siI5EsT#Aej-sV(C3Bq}ZIzul^tY;$kQhh*RwFK96m4(+c5FNAhxf4i$O<=qi z1szKD4s*`4jRY=^cdS*p*20dDX|qha+BD1=x|GdpGbBSzsbWBHRiTYal* zFv{^)yWQ7>THnciZuy9};ybU8xl>yDt5}(rev}QUiJmWVm)t#9& z8=+yg;`FL{y&~RUhEPz%&x0kry$(60884p|wSxH8eX+PDQn*g`CN*u&x8b%~38+m{ z$3406JmA4amxGSH($`ItgBX9Rm>I6CbES z>egw64{jF=1B}{V6}_sZx&k0TA=G;_eOZd!)0z9F@jE+^UgOpL%HKF4*2;j$w-$g% zOw1<(Dlck@3NxycL$u+Q-eRdQ@OMOh)Xj0C&{H-iX z(|2}$CiCZ({kgwbMLA6`VbVHVp2vsYC_gq5@5#QhU_I~85lhAkvQ#Z;wUHHk;Iv`McxQU<~+S=7Y{jnouoRUNK_q=ywQtCF#a|HKHVM)*Z zl*aS+eJuEfN;8ZnneK7+ClkW8bsUFjhsh(nsio`Wch_Ql+k^>d!6O!JwFCWN=U%d1qMSa8ko84ZgS5S4s+u|NM2#>mV~mGt3rBP{~FK z$5`Y|<@iH|y5Mi=S2TVsl=iAj+LP$jMf82CWCigfEYZuVzDKj;H#Tm8r*a%09TgAJ znnOjY5iui4Te4rH&F`Ne4LuDM=-`#4(T!{*fD@@2jh6P8h8r+epXB?DQ5XIZnLo!h+;1FFeeMa#XgRqoruT&Y8DpztVY&#v!%^T((;TF`r`Cm|v}XwMB8KL%4|ekjH9(9x=e^ z5Oqo_+KTfNR7)!eW66C;gou^p;`dV@fxht2>;sqzg|O?K8L&;5BH|63A|mlv25b(R z0b_@deM!H0C-IwidB1rV_Zx`P{{ZnHL9Yg+X7z+q)uPkO`BqyZEqe?+)T+8~p7>C^ zp%k5mB6`D_7k-xg!L>&6v^&>rxF?Pjb#A-^eZyN(NNh47FLL8x(?j)5B$@VTK>}AB zsy}cz7G70AFu_^p;Nfhe6v5tiX!Z8;-u)u^mZwwBi%WtnhE+rF&?DAD_~Mw*-5}Eh za1K`;>%+K;)kNPNTo-}G-Mh53l{#Xe^ob`cZZP7bYYKm)BZ?C)t~q35wxOb_6kg0T zL3nQ{@o8Jz(9_7f?J#!6e8`N_#4yqvB&YZKM0OioMyypxl@0Mq<;oG!mi^-;0rpF? zF~%Aan}czLb0TgbCTEIVo;hrw|Lve+!26J|q~%Q)F+K6azXdS3WP*LI?R4*NL090$ literal 0 HcmV?d00001 diff --git a/apps/web/public/brand/devin.png b/apps/web/public/brand/devin.png new file mode 100644 index 0000000000000000000000000000000000000000..afb6f651999c1e67e9b10c8faf414104d5fc3cf2 GIT binary patch literal 2970 zcmeH}=Tp<$7KVRG2*rQ`1_N@`fK-tVp-Bi3Y0^QZw*!Yzf=CS(5@~`I2}qG52nd2; zp-MGKs8S+WP-+5Flpc_dmpkM6bk84fKb$ptX6?1+{jldVpu;N&V22zgpK=>(005Vo8(Ux0_cuQ5{2Tu|@PF>Wa$4TO;S(7_mZnyK_}dR3 zK?hFP_6x|nBo?+UJ_r=c%ip&L4i(|xVpPR$Zmh(ya~?7IOBZ$gsKU^CS@+0n>JJ%} zV+%hUcUGsVJxmvYbC)$GVBx2vyiU(84NtZ`{Qj-=<+lGHik@&+btMh4lkykNwlq)N zS^HGepD<-9D9UPtRFyUe;SsX1M;Mx-ZO-`N?99Ssuc)~{>Kd&bENZWufAwr8H@TP| zJ)Zw1>)tzB!_&?{2fVkA??r+e@v63Pz@;E-jPY%A$rIc@`nEUp&dc4eSnPfM^>KAN zrF=OfvveT-&6B3=n7XlgDJ#B60AOn|#A;g;au#zph;V&4#W{dir9cFOWMP*#&_No| zkQ`1r5sg;&>RzWYq%EseALxu7M)Pf=p%&<*%=A)7h+ zc135`k>rwLDjbuX9>8IxKvH}FLL&TnfPQmUlEZ6Bh^npt5I`^H{9ges4>W{f=22K_ z4isaW14a!eT^Xt~;1{5JH=0M9qFv`;lVXgFq5<_=5HBf?Z9+K{Gv8nA$)PA}j2&|suo~gRApnQ0{bZ7!g$YGt$QQv_?QmMq$fV2ciWnV??xfS8rs<@J8vyA}^L#P+{xA4dUcE{vx z^)!|kpDa|;Woz-yJH*e*+EF7&ARqrcPQUGrMoKp4X}fXl8z8`^Qa~0*Ywlwk*!?8c z43KUi+PxZI8m6U#BTBRP&ouX8_|m<#~zOOyzC4iUBa!C&TSBoH)=* zVdNuVU~MnddRJ&@rM+n7B`-LC=N6M#^Yq>MGk)g^9&iJ43Olv&5*03jHqT=L5`5wf zHAKTUqvYO=GGTzx__KOYtwK)#1Sp34Ze=b@*AMjOe`x*A4rDg?v_A_pNs7B-Na9-y zLL?^_)&r`=jU>ee7=J}0>2nZbrC|v?PIIIfk3pMC8%e1Ru%|-~6eVi|%!|>Kym5uF z97@}Fc77&vW7|>kFIG`b^NW~_-i{}U;n%8;jU;-ruPoTk2Xe&K;{`f{?FwQ@42~0K zC+I{r_GnUdWO(}Ki(WVWdKq01sQZqopgh{v-AUttGi98TS0Csdacxri2Fnu!_e@Fo zz}>XgbB;qS_?8+AjCVcV>+A*-!nNcPWRx}qr7Gs4c=?+9mM4u}NBr7!SC!@XPi-n| zT+q7Sw#k8VAL*naqzepTT>I+MYf7tp;d6CJVKactR%8u0{+CvGd zxhUaStAeS#KW*B%X&-wmQ z@!Q&SsAzQ(W+Y`>?rV(Y-4o>DN5KL;yizo}v7Lm%E&inb;_T8VIG6)gd}vmaK87`l zW+M;bC&^laChJK!k=oH1nLru1A!GGnq2D+qb==uC;PJFK^R!3`$J2P<2B%5ej*Io@Bb??B$x4>mybvwp;aY0@%9S~QnS2pqA}&DTsK+I%qeoI zPWl$N2&GYKILE6b@?&cCF6GmBii{8#t&~}#Ix1gM--xCDDxp_D$_>|g6!sz!l_tBQApJh*0}stHh^bftv*FBwZNZcfFWHn3$;Yx_ zbI>dpJEY#9{F`?Yzj;^in|JZQfhhAg5dRhoYC;-TPq@@Bx;&llv?tMW$H2p#Y6q6d z2|6tm=mHeU7tTDt@!}7zHL|yZWWUkAI8xNL^(OQsZ*>W&&9Huv8wZ;nZf+yXcE<=3 zxVlgugTKVVYl{e`INLltoNbgMI9Q3+=&l+(C{^fqH08RuB-mk8J6wq#u|0(^iw)lk zF;4>LaW%0f#8uB~iIbB{IhP<9KK Ct3FHs literal 0 HcmV?d00001 diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c51958..9b9d8716813 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -211,6 +211,25 @@ 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 f9e7a700716..aaed59fe269 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,5 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, DevinIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -7,6 +7,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, + [ProviderDriverKind.make("devin")]: DevinIcon, [ProviderDriverKind.make("grok")]: GrokIcon, }; diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index 33331c14901..29d6d85f200 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -36,6 +36,33 @@ describe("ProviderSettingsForm helpers", () => { }); }); + it("derives Devin provider metadata and configurable fields", () => { + const devin = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("devin")]; + + expect(devin).toMatchObject({ + label: "Devin", + badgeLabel: "Early Access", + }); + expect( + deriveProviderSettingsFields(devin!).map((field) => ({ + key: field.key, + label: field.label, + placeholder: field.placeholder, + })), + ).toEqual([ + { + key: "binaryPath", + label: "Binary path", + placeholder: "devin", + }, + { + key: "configPath", + label: "Config path", + placeholder: ".devin/config.json", + }, + ]); + }); + it("preserves unknown config keys while omitting empty configurable fields", () => { const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; expect(opencode).toBeDefined(); diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d680..0da3e2f20c8 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>; @@ -61,6 +70,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = badgeLabel: "Early Access", settingsSchema: GrokSettings, }, + { + value: ProviderDriverKind.make("devin"), + label: "Devin", + icon: DevinIcon, + badgeLabel: "Early Access", + settingsSchema: DevinSettings, + }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 3d1cb336e1f..6dbf07dac2b 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -153,6 +153,31 @@ describe("buildPendingUserInputAnswers", () => { it("returns null when any question is unanswered", () => { expect(buildPendingUserInputAnswers([singleSelectQuestion], {})).toBeNull(); }); + + it("omits unanswered optional questions", () => { + expect( + buildPendingUserInputAnswers( + [ + singleSelectQuestion, + { + id: "notes", + header: "Notes", + question: "Any notes?", + options: [], + required: false, + multiSelect: false, + }, + ], + { + scope: { + selectedOptionLabels: ["Orchestration-first"], + }, + }, + ), + ).toEqual({ + scope: "Orchestration-first", + }); + }); }); describe("pending user input question progress", () => { @@ -247,4 +272,27 @@ describe("pending user input question progress", () => { isComplete: true, }); }); + + it("allows advancement through unanswered optional questions", () => { + expect( + derivePendingUserInputProgress( + [ + { + id: "notes", + header: "Notes", + question: "Any notes?", + options: [], + required: false, + multiSelect: false, + }, + ], + {}, + 0, + ), + ).toMatchObject({ + resolvedAnswer: null, + canAdvance: true, + isComplete: true, + }); + }); }); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index d3a7a129378..2dca883a379 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -45,6 +45,10 @@ function normalizeSelectedOptionLabels(value: string[] | undefined): string[] { return Array.from(new Set(normalized)); } +function isRequiredQuestion(question: UserInputQuestion): boolean { + return question.required !== false; +} + export function resolvePendingUserInputAnswer( question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, @@ -111,7 +115,10 @@ export function buildPendingUserInputAnswers( for (const question of questions) { const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); if (!answer) { - return null; + if (isRequiredQuestion(question)) { + return null; + } + continue; } answers[question.id] = answer; } @@ -133,7 +140,9 @@ export function findFirstUnansweredPendingUserInputQuestionIndex( draftAnswers: Record, ): number { const unansweredIndex = questions.findIndex( - (question) => !resolvePendingUserInputAnswer(question, draftAnswers[question.id]), + (question) => + isRequiredQuestion(question) && + !resolvePendingUserInputAnswer(question, draftAnswers[question.id]), ); return unansweredIndex === -1 ? Math.max(questions.length - 1, 0) : unansweredIndex; @@ -167,6 +176,7 @@ export function derivePendingUserInputProgress( answeredQuestionCount, isLastQuestion, isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null, - canAdvance: Boolean(resolvedAnswer), + canAdvance: + Boolean(resolvedAnswer) || (activeQuestion ? !isRequiredQuestion(activeQuestion) : false), }; } diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 0f12e672f66..1c50ec0f54a 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -258,6 +258,7 @@ describe("derivePendingUserInputs", () => { description: "Allow workspace writes only", }, ], + required: true, multiSelect: true, }, ], @@ -265,6 +266,47 @@ describe("derivePendingUserInputs", () => { ]); }); + it("tracks free-text user-input prompts with no options", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "user-input-free-text", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-free-text", + questions: [ + { + id: "notes", + header: "Devin", + question: "Any notes?", + options: [], + multiSelect: false, + }, + ], + }, + }), + ]; + + expect(derivePendingUserInputs(activities)).toEqual([ + { + requestId: "req-user-input-free-text", + createdAt: "2026-02-23T00:00:01.000Z", + questions: [ + { + id: "notes", + header: "Devin", + question: "Any notes?", + options: [], + required: true, + multiSelect: false, + }, + ], + }, + ]); + }); + it("clears stale pending user-input prompts when the provider reports an orphaned request", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5d5051f748e..819daa4a58b 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -51,6 +51,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("devin"), + label: "Devin", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = @@ -443,14 +449,12 @@ function parseUserInputQuestions( }; }) .filter((option): option is UserInputQuestion["options"][number] => option !== null); - if (options.length === 0) { - return null; - } return { id: question.id, header: question.header, question: question.question, options, + required: question.required !== false, multiSelect: question.multiSelect === true, }; }) diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index dddf3f37459..55cd9f1aada 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -130,6 +130,7 @@ export type ModelCapabilities = typeof ModelCapabilities.Type; const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); +const DEVIN_DRIVER_KIND = ProviderDriverKind.make("devin"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); @@ -140,6 +141,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CODEX_DRIVER_KIND]: "Codex", [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", + [DEVIN_DRIVER_KIND]: "Devin", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", }; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index eb2563eff00..fd49ec77744 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -443,6 +443,9 @@ export const UserInputQuestion = Schema.Struct({ header: TrimmedNonEmptyStringSchema, question: TrimmedNonEmptyStringSchema, options: Schema.Array(UserInputQuestionOption), + required: Schema.optional(Schema.Boolean).pipe( + Schema.withConstructorDefault(Effect.succeed(true)), + ), multiSelect: Schema.optional(Schema.Boolean).pipe( Schema.withConstructorDefault(Effect.succeed(false)), ), diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index ac2d47ca336..378cf49aecd 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -99,6 +99,27 @@ describe("ServerSettings worktree defaults", () => { }); }); +describe("ServerSettings Devin provider settings", () => { + it("decodes Devin defaults and trims patch paths", () => { + const decoded = decodeServerSettings({}); + expect(decoded.providers.devin.enabled).toBe(false); + expect(decoded.providers.devin.binaryPath).toBe("devin"); + expect(decoded.providers.devin.configPath).toBe(""); + + const patch = decodeServerSettingsPatch({ + providers: { + devin: { + binaryPath: " /usr/local/bin/devin ", + configPath: " .devin/config.json ", + }, + }, + }); + + expect(patch.providers?.devin?.binaryPath).toBe("/usr/local/bin/devin"); + expect(patch.providers?.devin?.configPath).toBe(".devin/config.json"); + }); +}); + describe("ServerSettingsPatch.providerInstances", () => { it("treats providerInstances as an optional whole-map replacement", () => { const patch = decodeServerSettingsPatch({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6ccd65533dd..ac437f067d2 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -305,6 +305,41 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const DevinSettings = makeProviderSettingsSchema( + { + 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" }, + }), + ), + configPath: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Config path", + description: "Optional path passed to `devin --config acp`.", + providerSettingsForm: { + placeholder: ".devin/config.json", + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "configPath"], + }, +); +export type DevinSettings = typeof DevinSettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( @@ -397,6 +432,7 @@ export const ServerSettings = Schema.Struct({ codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + devin: DevinSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), @@ -493,6 +529,13 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const DevinSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + configPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -521,6 +564,7 @@ export const ServerSettingsPatch = Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), + devin: Schema.optionalKey(DevinSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), From d7b9c3f29239114708315d22930ad070e1f8c612 Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Fri, 3 Jul 2026 20:17:05 -0400 Subject: [PATCH 02/10] Address Devin PR review comments --- .../features/threads/PendingUserInputCard.tsx | 14 ++- .../features/threads/ThreadDetailScreen.tsx | 3 +- apps/mobile/src/lib/threadActivity.ts | 76 +++++++++++-- .../src/state/use-selected-thread-requests.ts | 32 ++++-- .../src/provider/Layers/DevinAdapter.ts | 16 ++- .../server/src/provider/acp/AcpAdapterLive.ts | 105 +++++++++--------- .../provider/acp/AcpAdapterRuntime.test.ts | 39 ++++++- .../src/provider/acp/AcpAdapterRuntime.ts | 21 +++- .../src/provider/acp/DevinAcpSupport.test.ts | 19 ++++ .../src/provider/acp/DevinAcpSupport.ts | 8 +- apps/web/src/session-logic.test.ts | 26 +++++ apps/web/src/session-logic.ts | 6 +- 12 files changed, 278 insertions(+), 87 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 4d2c2c84159..09048619110 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -3,12 +3,16 @@ import { Pressable, View } from "react-native"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { cn } from "../../lib/cn"; -import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/threadActivity"; +import type { + PendingUserInput, + PendingUserInputAnswers, + PendingUserInputDraftAnswer, +} from "../../lib/threadActivity"; export interface PendingUserInputCardProps { readonly pendingUserInput: PendingUserInput; readonly drafts: Record; - readonly answers: Record | null; + readonly answers: PendingUserInputAnswers | null; readonly respondingUserInputId: ApprovalRequestId | null; readonly onSelectOption: ( requestId: ApprovalRequestId, @@ -34,6 +38,9 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { {props.pendingUserInput.questions.map((question) => { const draft = props.drafts[question.id]; + const selectedOptionLabels = Array.isArray(draft?.selectedOptionLabels) + ? draft.selectedOptionLabels + : []; return ( @@ -45,7 +52,8 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { {question.options.map((option) => { const selected = - draft?.selectedOptionLabel === option.label && !draft.customAnswer?.trim().length; + selectedOptionLabels.includes(option.label) && + !draft?.customAnswer?.trim().length; return ( ; - readonly activePendingUserInputAnswers: Record | null; + readonly activePendingUserInputAnswers: PendingUserInputAnswers | null; readonly respondingUserInputId: ApprovalRequestId | null; readonly draftMessage: string; readonly draftAttachments: ReadonlyArray; diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index b523a7278c9..2aa9f49aa1f 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -26,10 +26,12 @@ export interface PendingUserInput { } export interface PendingUserInputDraftAnswer { - readonly selectedOptionLabel?: string; + readonly selectedOptionLabels?: ReadonlyArray; readonly customAnswer?: string; } +export type PendingUserInputAnswers = Record>; + export interface ThreadFeedActivity { readonly id: string; readonly createdAt: string; @@ -182,7 +184,8 @@ function parseUserInputQuestions( ) { return null; } - const options = question.options + const rawOptions = question.options; + const options = rawOptions .map((option) => { if (!option || typeof option !== "object") return null; const record = option as Record; @@ -195,6 +198,9 @@ function parseUserInputQuestions( }; }) .filter((option): option is UserInputQuestion["options"][number] => option !== null); + if (rawOptions.length > 0 && options.length === 0) { + return null; + } return { id: question.id, header: question.header, @@ -217,14 +223,37 @@ function normalizeDraftAnswer(value: string | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } +function normalizeSelectedOptionLabels(value: ReadonlyArray | undefined): string[] { + if (!Array.isArray(value)) { + return []; + } + + const normalized: string[] = []; + for (const entry of value) { + const trimmed = entry.trim(); + if (trimmed.length > 0) { + normalized.push(trimmed); + } + } + + return Array.from(new Set(normalized)); +} + function resolvePendingUserInputAnswer( + question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, -): string | null { +): string | ReadonlyArray | null { const customAnswer = normalizeDraftAnswer(draft?.customAnswer); if (customAnswer) { return customAnswer; } - return normalizeDraftAnswer(draft?.selectedOptionLabel); + + const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + if (question.multiSelect) { + return selectedOptionLabels.length > 0 ? selectedOptionLabels : null; + } + + return selectedOptionLabels[0] ?? null; } function isRequiredQuestion(question: UserInputQuestion): boolean { @@ -1286,22 +1315,49 @@ export function setPendingUserInputCustomAnswer( draft: PendingUserInputDraftAnswer | undefined, customAnswer: string, ): PendingUserInputDraftAnswer { - const selectedOptionLabel = - customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel; + const selectedOptionLabels = + customAnswer.trim().length > 0 + ? undefined + : normalizeSelectedOptionLabels(draft?.selectedOptionLabels); return { customAnswer, - ...(selectedOptionLabel ? { selectedOptionLabel } : {}), + ...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}), + }; +} + +export function togglePendingUserInputOptionSelection( + question: UserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + optionLabel: string, +): PendingUserInputDraftAnswer { + if (question.multiSelect) { + const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + const nextSelectedOptionLabels = selectedOptionLabels.includes(optionLabel) + ? selectedOptionLabels.filter((label) => label !== optionLabel) + : [...selectedOptionLabels, optionLabel]; + + return { + customAnswer: "", + ...(nextSelectedOptionLabels.length > 0 + ? { selectedOptionLabels: nextSelectedOptionLabels } + : {}), + }; + } + + return { + customAnswer: "", + selectedOptionLabels: [optionLabel], }; } export function buildPendingUserInputAnswers( questions: ReadonlyArray, draftAnswers: Record, -): Record | null { - const answers: Record = {}; +): PendingUserInputAnswers | null { + const answers: PendingUserInputAnswers = {}; for (const question of questions) { - const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]); + const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); if (!answer) { if (isRequiredQuestion(question)) { return null; diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index c9e9db12530..c8c245ade57 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -1,7 +1,11 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useMemo, useState } from "react"; -import { ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts"; +import { + ApprovalRequestId, + type ProviderApprovalDecision, + type UserInputQuestion, +} from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { threadEnvironment } from "../state/threads"; @@ -11,6 +15,8 @@ import { derivePendingApprovals, derivePendingUserInputs, setPendingUserInputCustomAnswer, + togglePendingUserInputOptionSelection, + type PendingUserInputAnswers, type PendingUserInputDraftAnswer, } from "../lib/threadActivity"; import { appAtomRegistry } from "./atom-registry"; @@ -22,15 +28,21 @@ const userInputDraftsByRequestKeyAtom = Atom.make< Record> >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:user-input-drafts")); -function setUserInputDraftOption(requestKey: string, questionId: string, label: string): void { +function setUserInputDraftOption( + requestKey: string, + question: UserInputQuestion, + label: string, +): void { const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { ...current, [requestKey]: { ...current[requestKey], - [questionId]: { - selectedOptionLabel: label, - }, + [question.id]: togglePendingUserInputOptionSelection( + question, + current[requestKey]?.[question.id], + label, + ), }, }); } @@ -86,7 +98,7 @@ export function useSelectedThreadRequests() { scopedRequestKey(selectedThreadShell.environmentId, activePendingUserInput.requestId) ] ?? {}) : {}; - const activePendingUserInputAnswers = activePendingUserInput + const activePendingUserInputAnswers: PendingUserInputAnswers | null = activePendingUserInput ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingUserInputDrafts) : null; @@ -97,9 +109,13 @@ export function useSelectedThreadRequests() { } const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId); - setUserInputDraftOption(requestKey, questionId, label); + const question = activePendingUserInput?.questions.find((entry) => entry.id === questionId); + if (!question) { + return; + } + setUserInputDraftOption(requestKey, question, label); }, - [selectedThreadShell], + [activePendingUserInput, selectedThreadShell], ); const onChangeUserInputCustomAnswer = useCallback( diff --git a/apps/server/src/provider/Layers/DevinAdapter.ts b/apps/server/src/provider/Layers/DevinAdapter.ts index 58ce7d28d01..aa460f372eb 100644 --- a/apps/server/src/provider/Layers/DevinAdapter.ts +++ b/apps/server/src/provider/Layers/DevinAdapter.ts @@ -212,15 +212,13 @@ export function makeDevinAdapter(devinSettings: DevinSettings, options?: DevinAd const discoveredModels = buildDevinDiscoveredModelsFromSessionSetup( input.sessionSetupResult, ); - if (discoveredModels.length > 0) { - yield* (options?.onSessionModelsDiscovered?.(discoveredModels) ?? Effect.void).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Failed to record Devin ACP session model discovery.", { - cause, - }), - ), - ); - } + yield* (options?.onSessionModelsDiscovered?.(discoveredModels) ?? Effect.void).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to record Devin ACP session model discovery.", { + cause, + }), + ), + ); yield* options?.afterSessionStarted?.(input) ?? Effect.void; }), }, diff --git a/apps/server/src/provider/acp/AcpAdapterLive.ts b/apps/server/src/provider/acp/AcpAdapterLive.ts index 063f0b6613d..945db15c19d 100644 --- a/apps/server/src/provider/acp/AcpAdapterLive.ts +++ b/apps/server/src/provider/acp/AcpAdapterLive.ts @@ -83,6 +83,7 @@ export interface AcpAdapterLiveSessionContext< notificationFiber: Fiber.Fiber | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map>; + readonly promptCapabilities: EffectAcpSchema.PromptCapabilities | undefined; currentModelId: string | undefined; stopped: boolean; } @@ -423,6 +424,7 @@ export function makeAcpAdapterLive( notificationFiber: undefined, pendingApprovals, pendingUserInputs, + promptCapabilities: started.initializeResult.agentCapabilities?.promptCapabilities, turns: [], lastPlanFingerprint: undefined, activeTurnId: undefined, @@ -499,6 +501,7 @@ export function makeAcpAdapterLive( provider: config.provider, text: input.input, attachments: input.attachments, + promptCapabilities: ctx.promptCapabilities, attachmentsDir: serverConfig.attachmentsDir, fileSystem, }); @@ -643,62 +646,64 @@ export function makeAcpAdapterLive( } satisfies ProviderTurnStartResult; } - appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); - ctx.session = { - ...ctx.session, - status: "running", - activeTurnId: prepared.turnId, - updatedAt: yield* nowIso, - ...(prepared.displayModel ? { model: prepared.displayModel } : {}), - }; - const remainingPrompts = Math.max(0, ctx.promptsInFlight - 1); - ctx.promptsInFlight = remainingPrompts; - - if ( - remainingPrompts === 0 && - ctx.activeTurnId === prepared.turnId && - ctx.session.activeTurnId === prepared.turnId - ) { - if (ctx.interruptedTurnIds.has(prepared.turnId)) { + return yield* Effect.uninterruptible( + Effect.gen(function* () { + appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); yield* Ref.set(promptSettled, true); + ctx.session = { + ...ctx.session, + status: "running", + activeTurnId: prepared.turnId, + updatedAt: yield* nowIso, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const remainingPrompts = Math.max(0, ctx.promptsInFlight - 1); + ctx.promptsInFlight = remainingPrompts; + + if ( + remainingPrompts === 0 && + ctx.activeTurnId === prepared.turnId && + ctx.session.activeTurnId === prepared.turnId + ) { + if (ctx.interruptedTurnIds.has(prepared.turnId)) { + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: ctx.session.resumeCursor, + } satisfies ProviderTurnStartResult; + } + const completedAt = yield* nowIso; + const { activeTurnId: _completedTurnId, ...readySession } = ctx.session; + ctx.activeTurnId = undefined; + ctx.session = { + ...readySession, + status: "ready", + updatedAt: completedAt, + ...(prepared.displayModel ? { model: prepared.displayModel } : {}), + }; + const completedStopReason = + config.completedStopReasonFromPromptResponse(result); + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: config.provider, + threadId: input.threadId, + turnId: prepared.turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: completedStopReason, + }, + }); + ctx.interruptedTurnIds.delete(prepared.turnId); + } + return { threadId: input.threadId, turnId: prepared.turnId, resumeCursor: ctx.session.resumeCursor, } satisfies ProviderTurnStartResult; - } - const completedAt = yield* nowIso; - const { activeTurnId: _completedTurnId, ...readySession } = ctx.session; - ctx.activeTurnId = undefined; - ctx.session = { - ...readySession, - status: "ready", - updatedAt: completedAt, - ...(prepared.displayModel ? { model: prepared.displayModel } : {}), - }; - const completedStopReason = config.completedStopReasonFromPromptResponse(result); - yield* offerRuntimeEvent({ - type: "turn.completed", - ...(yield* makeEventStamp()), - provider: config.provider, - threadId: input.threadId, - turnId: prepared.turnId, - payload: { - state: result.stopReason === "cancelled" ? "cancelled" : "completed", - stopReason: completedStopReason, - }, - }); - ctx.interruptedTurnIds.delete(prepared.turnId); - yield* Ref.set(promptSettled, true); - } else if (remainingPrompts > 0) { - yield* Ref.set(promptSettled, true); - } - - return { - threadId: input.threadId, - turnId: prepared.turnId, - resumeCursor: ctx.session.resumeCursor, - } satisfies ProviderTurnStartResult; + }), + ); }), ); }).pipe( diff --git a/apps/server/src/provider/acp/AcpAdapterRuntime.test.ts b/apps/server/src/provider/acp/AcpAdapterRuntime.test.ts index ee31d2873a4..1868becdb25 100644 --- a/apps/server/src/provider/acp/AcpAdapterRuntime.test.ts +++ b/apps/server/src/provider/acp/AcpAdapterRuntime.test.ts @@ -1,8 +1,19 @@ import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import type * as EffectAcpSchema from "effect-acp/schema"; -import { makeAcpThreadLock, selectPermissionOptionId } from "./AcpAdapterRuntime.ts"; +import { + ApprovalRequestId, + ProviderDriverKind, + type ProviderApprovalDecision, +} from "@t3tools/contracts"; + +import { + makeAcpThreadLock, + respondToAcpPermissionRequest, + selectPermissionOptionId, +} from "./AcpAdapterRuntime.ts"; describe("AcpAdapterRuntime", () => { it("falls back to allow_once for acceptForSession when allow_always is unavailable", () => { @@ -37,4 +48,30 @@ describe("AcpAdapterRuntime", () => { expect(events).toEqual(["first", "second"]); }), ); + + it.effect("rejects duplicate ACP permission responses", () => + Effect.gen(function* () { + const requestId = ApprovalRequestId.make("permission-1"); + const decision = yield* Deferred.make(); + const pendingApprovals = new Map([[requestId, { decision }]]); + + yield* respondToAcpPermissionRequest({ + provider: ProviderDriverKind.make("devin"), + requestId, + decision: "accept", + pendingApprovals, + }); + const error = yield* Effect.flip( + respondToAcpPermissionRequest({ + provider: ProviderDriverKind.make("devin"), + requestId, + decision: "decline", + pendingApprovals, + }), + ); + + expect(error._tag).toBe("ProviderAdapterRequestError"); + expect(error.detail).toContain("Unknown pending approval request"); + }), + ); }); diff --git a/apps/server/src/provider/acp/AcpAdapterRuntime.ts b/apps/server/src/provider/acp/AcpAdapterRuntime.ts index 22346c463ca..3562c8647d5 100644 --- a/apps/server/src/provider/acp/AcpAdapterRuntime.ts +++ b/apps/server/src/provider/acp/AcpAdapterRuntime.ts @@ -478,7 +478,14 @@ export function respondToAcpPermissionRequest(input: { detail: `Unknown pending approval request: ${input.requestId}`, }); } - yield* Deferred.succeed(pending.decision, input.decision); + const accepted = yield* Deferred.succeed(pending.decision, input.decision); + if (!accepted) { + return yield* new ProviderAdapterRequestError({ + provider: input.provider, + method: "session/request_permission", + detail: `Unknown pending approval request: ${input.requestId}`, + }); + } }); } @@ -610,12 +617,22 @@ export function prepareAcpPromptContent(input: { readonly provider: ProviderDriverKind; readonly text: string | undefined; readonly attachments: ProviderSendTurnInput["attachments"]; + readonly promptCapabilities?: EffectAcpSchema.PromptCapabilities | undefined; readonly attachmentsDir: string; readonly fileSystem: FileSystem.FileSystem; }) { return Effect.gen(function* () { const text = input.text?.trim(); - const imagePromptParts = yield* Effect.forEach(input.attachments ?? [], (attachment) => + const attachments = input.attachments ?? []; + if (attachments.length > 0 && input.promptCapabilities?.image !== true) { + return yield* new ProviderAdapterRequestError({ + provider: input.provider, + method: "session/prompt", + detail: "ACP agent does not support image prompt attachments.", + }); + } + + const imagePromptParts = yield* Effect.forEach(attachments, (attachment) => Effect.gen(function* () { const attachmentPath = resolveAttachmentPath({ attachmentsDir: input.attachmentsDir, diff --git a/apps/server/src/provider/acp/DevinAcpSupport.test.ts b/apps/server/src/provider/acp/DevinAcpSupport.test.ts index 419eb2e2d47..f6424034640 100644 --- a/apps/server/src/provider/acp/DevinAcpSupport.test.ts +++ b/apps/server/src/provider/acp/DevinAcpSupport.test.ts @@ -6,6 +6,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { applyDevinAcpModelSelection, applyDevinRequestedMode, + buildDevinDiscoveredModelsFromSessionSetup, buildDevinAcpSpawnInput, currentDevinModelIdFromSessionSetup, devinAcpModelVariantGroupsFromConfigOptions, @@ -246,6 +247,24 @@ describe("DevinAcpSupport", () => { ); }); + it("derives discovered model slugs from display names when ACP model ids are opaque", () => { + const models = buildDevinDiscoveredModelsFromSessionSetup({ + sessionId: "session-1", + configOptions: [], + models: { + currentModelId: "MODEL_PRIVATE_3", + availableModels: [{ modelId: "MODEL_PRIVATE_3", name: "Claude Sonnet 4.5 Thinking" }], + }, + } satisfies EffectAcpSchema.NewSessionResponse); + + expect(models).toMatchObject([ + { + slug: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + }, + ]); + }); + it("treats built-in Devin aliases as covering previous discovered concrete slugs", () => { expect( isDevinAcpModelCoveredByBaseModelIds({ diff --git a/apps/server/src/provider/acp/DevinAcpSupport.ts b/apps/server/src/provider/acp/DevinAcpSupport.ts index 970a42377ba..b8ac4bff02d 100644 --- a/apps/server/src/provider/acp/DevinAcpSupport.ts +++ b/apps/server/src/provider/acp/DevinAcpSupport.ts @@ -635,14 +635,18 @@ function buildDevinDiscoveredModelsFromSessionModelState( const seen = new Set(); return modelState.availableModels .map((model): ServerProviderModel | undefined => { - const slug = resolveDevinAcpBaseModelId(model.modelId); + const variant = parseDevinAcpModelVariant({ + value: model.modelId, + name: model.name, + }); + const slug = variant?.baseModelId ?? resolveDevinAcpBaseModelId(model.modelId); if (!slug || seen.has(slug)) { return undefined; } seen.add(slug); return { slug, - name: model.name.trim() || slug, + name: (variant?.baseModelName ?? model.name.trim()) || slug, isCustom: false, capabilities: EMPTY_CAPABILITIES, }; diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 1c50ec0f54a..c32bea18e22 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -307,6 +307,32 @@ describe("derivePendingUserInputs", () => { ]); }); + it("ignores user-input prompts whose options are all malformed", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "user-input-malformed-options", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-malformed-options", + questions: [ + { + id: "approval", + header: "Approval", + question: "Continue?", + options: [{ label: "Continue" }], + multiSelect: false, + }, + ], + }, + }), + ]; + + expect(derivePendingUserInputs(activities)).toEqual([]); + }); + it("clears stale pending user-input prompts when the provider reports an orphaned request", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 819daa4a58b..b5a75fa11d4 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -433,7 +433,8 @@ function parseUserInputQuestions( ) { return null; } - const options = question.options + const rawOptions = question.options; + const options = rawOptions .map((option) => { if (!option || typeof option !== "object") return null; const optionRecord = option as Record; @@ -449,6 +450,9 @@ function parseUserInputQuestions( }; }) .filter((option): option is UserInputQuestion["options"][number] => option !== null); + if (rawOptions.length > 0 && options.length === 0) { + return null; + } return { id: question.id, header: question.header, From 458ecc9cf8fc68ea93e15d085d9345dae9a96de0 Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Fri, 3 Jul 2026 23:16:49 -0400 Subject: [PATCH 03/10] Prepare Devin ACP integration for readiness --- .gitignore | 7 ++ .../src/shell/DesktopShellEnvironment.test.ts | 12 ++++ .../src/shell/DesktopShellEnvironment.ts | 14 +++- .../src/provider/Layers/DevinAdapter.test.ts | 34 ++++++++++ .../src/provider/Layers/DevinProvider.test.ts | 45 ++---------- .../src/provider/Layers/DevinProvider.ts | 52 +------------- .../server/src/provider/acp/AcpAdapterLive.ts | 5 +- .../src/provider/acp/DevinElicitation.test.ts | 27 ++++++++ .../src/provider/acp/DevinElicitation.ts | 10 ++- .../AcpJsonTextGeneration.test.ts | 68 +++++++++++++++++++ .../textGeneration/AcpJsonTextGeneration.ts | 19 +++--- packages/shared/src/shell.test.ts | 6 ++ packages/shared/src/shell.ts | 14 +++- 13 files changed, 210 insertions(+), 103 deletions(-) create mode 100644 apps/server/src/textGeneration/AcpJsonTextGeneration.test.ts diff --git a/.gitignore b/.gitignore index ef6067824f2..5276bf748e2 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,10 @@ node_modules/ *.log .env* !.env.example + +# Local agent/MCP harness artifacts (not part of the app) +.firecrawl/ +/agent-tools/ +/mcps/ +/terminals/ +/apps/server/mcps/ diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 7ec0ab80ae7..69e491b166c 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -204,12 +204,14 @@ describe("DesktopShellEnvironment", () => { LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", USERPROFILE: "C:\\Users\\testuser", }; + const commands: ChildProcess.Command[] = []; yield* runShellEnvironment({ env, platform: "win32", handler: (command) => { if (command._tag !== "StandardCommand") return ""; + commands.push(command); const loadProfile = !command.args.includes("-NoProfile"); return loadProfile ? envOutput({ @@ -240,6 +242,16 @@ describe("DesktopShellEnvironment", () => { env.FNM_MULTISHELL_PATH, "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", ); + const noProfileCommand = commands.find( + (command) => command._tag === "StandardCommand" && command.args.includes("-NoProfile"), + ); + const captureCommand = + noProfileCommand?._tag === "StandardCommand" ? (noProfileCommand.args.at(-1) ?? "") : ""; + assert.include(captureCommand, "[Environment]::GetEnvironmentVariable('Path', 'Process')"); + assert.include(captureCommand, "[Environment]::GetEnvironmentVariable('Path', 'Machine')"); + assert.include(captureCommand, "[Environment]::GetEnvironmentVariable('Path', 'User')"); + assert.include(captureCommand, ") | Where-Object { $null -ne $_ -and $_.Length -gt 0 }"); + assert.notInclude(captureCommand, "Where-Object { $null -ne $_ -and $_.Length -gt 0 })"); }), ); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 8219f18b7a5..8106bde669a 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -193,9 +193,21 @@ const captureWindowsEnvironmentCommand = (names: ReadonlyArray) => [ "$ErrorActionPreference = 'Stop'", ...names.flatMap((name) => { + const valueCapture = + name.toUpperCase() === "PATH" + ? [ + "$parts = @(", + " [Environment]::GetEnvironmentVariable('Path', 'Process'),", + " [Environment]::GetEnvironmentVariable('Path', 'Machine'),", + " [Environment]::GetEnvironmentVariable('Path', 'User')", + ") | Where-Object { $null -ne $_ -and $_.Length -gt 0 }", + "$value = [string]::Join(';', $parts)", + ] + : [`$value = [Environment]::GetEnvironmentVariable('${name}')`]; + return [ `Write-Output '${startMarker(name)}'`, - `$value = [Environment]::GetEnvironmentVariable('${name}')`, + ...valueCapture, "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }", `Write-Output '${endMarker(name)}'`, ]; diff --git a/apps/server/src/provider/Layers/DevinAdapter.test.ts b/apps/server/src/provider/Layers/DevinAdapter.test.ts index e0baa4c7b0c..1525321770c 100644 --- a/apps/server/src/provider/Layers/DevinAdapter.test.ts +++ b/apps/server/src/provider/Layers/DevinAdapter.test.ts @@ -514,6 +514,40 @@ it.layer(devinAdapterTestLayer)("DevinAdapterLive", (it) => { }), ); + it.effect("ignores stale explicit turn interrupts after completion", () => + Effect.gen(function* () { + const threadId = ThreadId.make("devin-ignore-stale-turn-interrupt"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-stale-interrupt-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockDevinWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const completed = yield* adapter + .sendTurn({ threadId, input: "complete before interrupt", attachments: [] }) + .pipe(Effect.timeout("2 seconds")); + + yield* adapter.interruptTurn(threadId, completed.turnId).pipe(Effect.timeout("2 seconds")); + yield* Effect.sleep("100 millis"); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isFalse(requests.some((entry) => entry.method === "session/cancel")); + + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + it.effect("restores ready without completing an unstarted turn when preparation fails", () => Effect.gen(function* () { const threadId = ThreadId.make("devin-preparation-failure-while-connecting"); diff --git a/apps/server/src/provider/Layers/DevinProvider.test.ts b/apps/server/src/provider/Layers/DevinProvider.test.ts index 957ab41dadf..f55f5c7810a 100644 --- a/apps/server/src/provider/Layers/DevinProvider.test.ts +++ b/apps/server/src/provider/Layers/DevinProvider.test.ts @@ -12,26 +12,14 @@ import { buildDevinDiscoveredModelsFromSessionSetup } from "../acp/DevinAcpSuppo import { checkDevinProviderStatus } from "./DevinProvider.ts"; const decodeDevinSettings = Schema.decodeSync(DevinSettings); -const mockAgentUrl = new URL("../../../scripts/acp-mock-agent.ts", import.meta.url); -function quotePosixShell(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'`; -} - -const makeMockDevinCli = Effect.fn("makeMockDevinCli")(function* ( - prefix: string, - options?: { - readonly acp?: boolean; - }, -) { +const makeMockDevinCli = Effect.fn("makeMockDevinCli")(function* (prefix: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const hostPlatform = yield* HostProcessPlatform; const isWin32 = hostPlatform === "win32"; const dir = yield* fs.makeTempDirectoryScoped({ prefix }); const devinPath = path.join(dir, isWin32 ? "devin.cmd" : "devin"); - const mockAgentPath = yield* path.fromFileUrl(mockAgentUrl); - const supportsAcp = options?.acp ?? true; yield* fs.writeFileString( devinPath, @@ -42,14 +30,6 @@ const makeMockDevinCli = Effect.fn("makeMockDevinCli")(function* ( " echo devin 1.2.3", " exit /b 0", ")", - ...(supportsAcp - ? [ - 'if "%~1"=="acp" (', - ` "${process.execPath}" "${mockAgentPath}"`, - " exit /b %ERRORLEVEL%", - ")", - ] - : []), "echo unexpected Devin invocation: %* 1>&2", "exit /b 7", "", @@ -60,13 +40,6 @@ const makeMockDevinCli = Effect.fn("makeMockDevinCli")(function* ( ' printf "devin 1.2.3\\n"', " exit 0", "fi", - ...(supportsAcp - ? [ - 'if [ "$1" = "acp" ]; then', - ` exec ${quotePosixShell(process.execPath)} ${quotePosixShell(mockAgentPath)}`, - "fi", - ] - : []), 'printf "unexpected Devin invocation: %s\\n" "$*" >&2', "exit 7", "", @@ -203,7 +176,7 @@ describe("buildDevinDiscoveredModelsFromSessionSetup", () => { }); it.layer(NodeServices.layer)("checkDevinProviderStatus", (it) => { - it.effect("reports models discovered through Devin ACP after `devin version` succeeds", () => + it.effect("reports ready without starting an ACP probe after `devin version` succeeds", () => Effect.gen(function* () { const snapshot = yield* Effect.scoped( Effect.gen(function* () { @@ -219,19 +192,15 @@ it.layer(NodeServices.layer)("checkDevinProviderStatus", (it) => { expect(snapshot.installed).toBe(true); expect(snapshot.version).toBe("1.2.3"); expect(snapshot.message).toBeUndefined(); - expect(snapshot.models.map((model) => model.slug)).toEqual([ - "auto", - "composer-2", - "codex-5-3", - ]); + expect(snapshot.models.map((model) => model.slug)).toEqual([]); }), ); - it.effect("uses cached real-session model discovery when ACP probing fails", () => + it.effect("uses cached real-session model discovery without ACP probing", () => Effect.gen(function* () { const snapshot = yield* Effect.scoped( Effect.gen(function* () { - const devinPath = yield* makeMockDevinCli("t3code-devin-cached-", { acp: false }); + const devinPath = yield* makeMockDevinCli("t3code-devin-cached-"); return yield* checkDevinProviderStatus( decodeDevinSettings({ enabled: true, binaryPath: devinPath }), @@ -256,9 +225,9 @@ it.layer(NodeServices.layer)("checkDevinProviderStatus", (it) => { }), ); - expect(snapshot.status).toBe("warning"); + expect(snapshot.status).toBe("ready"); expect(snapshot.models.map((model) => model.slug)).toEqual(["adaptive", "gpt-5-5"]); - expect(snapshot.message).toContain("last models discovered"); + expect(snapshot.message).toBeUndefined(); }), ); }); diff --git a/apps/server/src/provider/Layers/DevinProvider.ts b/apps/server/src/provider/Layers/DevinProvider.ts index b3b75b67522..c886d986fae 100644 --- a/apps/server/src/provider/Layers/DevinProvider.ts +++ b/apps/server/src/provider/Layers/DevinProvider.ts @@ -8,7 +8,6 @@ import { import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import { HttpClient } from "effect/unstable/http"; @@ -28,7 +27,6 @@ import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; -import { discoverDevinModelsViaAcp } from "../acp/DevinAcpSupport.ts"; const DEVIN_PRESENTATION = { displayName: "Devin", @@ -42,7 +40,6 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ }); const VERSION_PROBE_TIMEOUT_MS = 4_000; -const DEVIN_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; export function buildInitialDevinProviderSnapshot( devinSettings: DevinSettings, @@ -208,56 +205,13 @@ export const checkDevinProviderStatus = Effect.fn("checkDevinProviderStatus")(fu }); } - const buildDiscoveryFailureSnapshot = (message: string) => - buildServerProvider({ - presentation: DEVIN_PRESENTATION, - enabled: devinSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version, - status: cachedModels.length > 0 ? "warning" : "error", - auth: { status: "unknown" }, - message: - cachedModels.length > 0 - ? `${message} Showing the last models discovered from a Devin ACP session.` - : message, - }, - }); - - const discoveryExit = yield* discoverDevinModelsViaAcp(devinSettings, environment).pipe( - Effect.timeoutOption(DEVIN_ACP_MODEL_DISCOVERY_TIMEOUT_MS), - Effect.exit, - ); - if (Exit.isFailure(discoveryExit)) { - yield* Effect.logWarning("Devin ACP model discovery failed", { - errorTag: causeErrorTag(discoveryExit.cause), - }); - return buildDiscoveryFailureSnapshot( - "Devin CLI is installed but ACP model discovery failed. Run `devin auth login`, then try again.", - ); - } - if (Option.isNone(discoveryExit.value)) { - yield* Effect.logWarning( - `Devin ACP model discovery timed out after ${DEVIN_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, - ); - return buildDiscoveryFailureSnapshot( - `Devin CLI is installed but ACP model discovery timed out after ${DEVIN_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, - ); - } - - const discoveredModels = discoveryExit.value.value; - if (discoveredModels.length === 0) { - return buildDiscoveryFailureSnapshot("Devin ACP model discovery returned no built-in models."); - } - const models = devinModelsFromSettings(devinSettings.customModels, discoveredModels); - + // Provider refreshes run periodically. Devin ACP startup creates a real + // session, so model details are captured only from user-started sessions. return buildServerProvider({ presentation: DEVIN_PRESENTATION, enabled: devinSettings.enabled, checkedAt, - models, + models: fallbackModels, probe: { installed: true, version, diff --git a/apps/server/src/provider/acp/AcpAdapterLive.ts b/apps/server/src/provider/acp/AcpAdapterLive.ts index 945db15c19d..80360470896 100644 --- a/apps/server/src/provider/acp/AcpAdapterLive.ts +++ b/apps/server/src/provider/acp/AcpAdapterLive.ts @@ -788,7 +788,7 @@ export function makeAcpAdapterLive( }; } const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; - if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + if (turnId !== undefined && activeTurnId !== turnId) { return { _tag: "Ignore" as const }; } const interruptedTurnId = turnId ?? activeTurnId; @@ -816,12 +816,11 @@ export function makeAcpAdapterLive( return; } const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; - if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { + if (turnId !== undefined && activeTurnId !== turnId) { return; } if ( observed.interruptedTurnId !== undefined && - activeTurnId !== undefined && activeTurnId !== observed.interruptedTurnId ) { return; diff --git a/apps/server/src/provider/acp/DevinElicitation.test.ts b/apps/server/src/provider/acp/DevinElicitation.test.ts index d252bf681fc..66978a9d353 100644 --- a/apps/server/src/provider/acp/DevinElicitation.test.ts +++ b/apps/server/src/provider/acp/DevinElicitation.test.ts @@ -79,6 +79,33 @@ describe("makeDevinElicitationPrompt", () => { }); }); + it("declines required multi-select answers when any selected value is invalid", () => { + const prompt = makeDevinElicitationPrompt({ + mode: "form", + sessionId: "session-1", + message: "Choose permissions", + requestedSchema: { + type: "object", + title: "Permissions", + properties: { + permissions: { + type: "array", + title: "Permissions", + items: { type: "string", enum: ["read", "write"] }, + }, + }, + required: ["permissions"], + }, + }); + + expect(prompt.makeResponse({ permissions: ["read", "write"] })).toEqual({ + action: { action: "accept", content: { permissions: ["read", "write"] } }, + }); + expect(prompt.makeResponse({ permissions: ["read", "admin"] })).toEqual({ + action: { action: "decline" }, + }); + }); + it("marks optional form questions and omits unanswered optional values", () => { const prompt = makeDevinElicitationPrompt({ mode: "form", diff --git a/apps/server/src/provider/acp/DevinElicitation.ts b/apps/server/src/provider/acp/DevinElicitation.ts index 7320013070c..a59e89a477d 100644 --- a/apps/server/src/provider/acp/DevinElicitation.ts +++ b/apps/server/src/provider/acp/DevinElicitation.ts @@ -137,10 +137,14 @@ function normalizeStringArrayAnswer( if (allowedValues.size === 0) { return values; } - const normalized = values.flatMap((value) => { + const normalized: Array = []; + for (const value of values) { const resolved = resolveChoiceValue(value, valuesByLabel, allowedValues); - return resolved ? [resolved] : []; - }); + if (!resolved) { + return undefined; + } + normalized.push(resolved); + } return normalized.length > 0 ? normalized : undefined; } diff --git a/apps/server/src/textGeneration/AcpJsonTextGeneration.test.ts b/apps/server/src/textGeneration/AcpJsonTextGeneration.test.ts new file mode 100644 index 00000000000..ba6a6b85826 --- /dev/null +++ b/apps/server/src/textGeneration/AcpJsonTextGeneration.test.ts @@ -0,0 +1,68 @@ +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +import { ProviderInstanceId } from "@t3tools/contracts"; + +import type * as AcpSessionRuntime from "../provider/acp/AcpSessionRuntime.ts"; +import { makeAcpJsonTextGeneration } from "./AcpJsonTextGeneration.ts"; + +function makeQueuedRuntime(): Effect.Effect { + return Effect.gen(function* () { + const promptStarted = yield* Deferred.make(); + const contentEvent = { + _tag: "ContentDelta", + text: '{"title":"Drain queued ACP text"}', + rawPayload: { sessionId: "queued-session" }, + } satisfies AcpSessionRuntime.AcpSessionRuntimeEvent; + + return { + handleElicitation: () => Effect.void, + handleRequestPermission: () => Effect.void, + start: () => + Effect.succeed({ + sessionId: "queued-session", + initializeResult: { protocolVersion: 1, agentCapabilities: {} }, + sessionSetupResult: { sessionId: "queued-session" }, + modelConfigId: undefined, + } as AcpSessionRuntime.AcpSessionRuntimeStartResult), + getEvents: () => + Stream.fromEffect(Deferred.await(promptStarted)).pipe( + Stream.flatMap(() => Stream.fromIterable([contentEvent])), + ), + drainEvents: Effect.gen(function* () { + for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + }), + prompt: () => + Deferred.succeed(promptStarted, undefined).pipe( + Effect.as({ stopReason: "end_turn" as const }), + ), + } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; + }); +} + +it.effect("drains queued ACP events before decoding generated JSON", () => + Effect.gen(function* () { + const textGeneration = makeAcpJsonTextGeneration({ + traceName: "QueuedAcpTextGeneration", + requestLabel: "Queued ACP", + outputLabel: "Queued ACP", + makeRuntime: () => makeQueuedRuntime(), + configureSession: () => Effect.void, + }); + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread", + modelSelection: { + instanceId: ProviderInstanceId.make("devin"), + model: "mock-model", + }, + }); + + assert.equal(generated.title, "Drain queued ACP text"); + }), +); diff --git a/apps/server/src/textGeneration/AcpJsonTextGeneration.ts b/apps/server/src/textGeneration/AcpJsonTextGeneration.ts index 5d7fd09af5a..e530610d158 100644 --- a/apps/server/src/textGeneration/AcpJsonTextGeneration.ts +++ b/apps/server/src/textGeneration/AcpJsonTextGeneration.ts @@ -1,7 +1,9 @@ +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import type * as Scope from "effect/Scope"; import type * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; @@ -82,17 +84,16 @@ export function makeAcpJsonTextGeneration( const outputRef = yield* Ref.make(""); const runtime = yield* options.makeRuntime(cwd); - yield* runtime.handleSessionUpdate((notification) => { - const update = notification.update; - if (update.sessionUpdate !== "agent_message_chunk") { - return Effect.void; + yield* Stream.runForEach(runtime.getEvents(), (event) => { + if (event._tag === "EventStreamBarrier") { + return Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid); } - const content = update.content; - if (content.type !== "text") { + if (event._tag !== "ContentDelta") { return Effect.void; } - return Ref.update(outputRef, (current) => current + content.text); - }); + return Ref.update(outputRef, (current) => current + event.text); + }).pipe(Effect.forkScoped); + // Headless runs cannot answer interactive requests; cancel them so the // agent terminates deterministically instead of waiting forever. yield* runtime.handleElicitation(() => @@ -137,6 +138,8 @@ export function makeAcpJsonTextGeneration( ), ); + yield* runtime.drainEvents; + if (promptResult.stopReason === "cancelled") { return yield* new TextGenerationError({ operation, diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index e8b2c41cb77..6f131166d93 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -236,6 +236,12 @@ describe("readEnvironmentFromWindowsShell", () => { expect.arrayContaining(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]), { encoding: "utf8", timeout: 5000 }, ); + const command = execFile.mock.calls[0]?.[1]?.at(-1); + expect(command).toContain("[Environment]::GetEnvironmentVariable('Path', 'Process')"); + expect(command).toContain("[Environment]::GetEnvironmentVariable('Path', 'Machine')"); + expect(command).toContain("[Environment]::GetEnvironmentVariable('Path', 'User')"); + expect(command).toContain(") | Where-Object { $null -ne $_ -and $_.Length -gt 0 }"); + expect(command).not.toContain("Where-Object { $null -ne $_ -and $_.Length -gt 0 })"); }); it("strips CRLF delimiters from captured PowerShell values", () => { diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index cf2f2417ff4..3aeaca08e70 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -268,9 +268,21 @@ function buildWindowsEnvironmentCaptureCommand(names: ReadonlyArray): st throw new Error(`Unsupported environment variable name: ${name}`); } + const valueCapture = + name.toUpperCase() === "PATH" + ? [ + "$parts = @(", + " [Environment]::GetEnvironmentVariable('Path', 'Process'),", + " [Environment]::GetEnvironmentVariable('Path', 'Machine'),", + " [Environment]::GetEnvironmentVariable('Path', 'User')", + ") | Where-Object { $null -ne $_ -and $_.Length -gt 0 }", + "$value = [string]::Join(';', $parts)", + ] + : [`$value = [Environment]::GetEnvironmentVariable('${name}')`]; + return [ `Write-Output '${envCaptureStart(name)}'`, - `$value = [Environment]::GetEnvironmentVariable('${name}')`, + ...valueCapture, "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }", `Write-Output '${envCaptureEnd(name)}'`, ]; From 3c59d5493ce3565e9685deaf8a2f6d667f14980d Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Fri, 3 Jul 2026 23:38:12 -0400 Subject: [PATCH 04/10] Fix Windows PATH capture command --- apps/desktop/src/shell/DesktopShellEnvironment.test.ts | 2 ++ apps/desktop/src/shell/DesktopShellEnvironment.ts | 6 +----- packages/shared/src/shell.test.ts | 2 ++ packages/shared/src/shell.ts | 6 +----- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 69e491b166c..d47fcba74f0 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -251,6 +251,8 @@ describe("DesktopShellEnvironment", () => { assert.include(captureCommand, "[Environment]::GetEnvironmentVariable('Path', 'Machine')"); assert.include(captureCommand, "[Environment]::GetEnvironmentVariable('Path', 'User')"); assert.include(captureCommand, ") | Where-Object { $null -ne $_ -and $_.Length -gt 0 }"); + assert.notInclude(captureCommand, "@(; "); + assert.notInclude(captureCommand, ",; "); assert.notInclude(captureCommand, "Where-Object { $null -ne $_ -and $_.Length -gt 0 })"); }), ); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 8106bde669a..45a4a4a0f22 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -196,11 +196,7 @@ const captureWindowsEnvironmentCommand = (names: ReadonlyArray) => const valueCapture = name.toUpperCase() === "PATH" ? [ - "$parts = @(", - " [Environment]::GetEnvironmentVariable('Path', 'Process'),", - " [Environment]::GetEnvironmentVariable('Path', 'Machine'),", - " [Environment]::GetEnvironmentVariable('Path', 'User')", - ") | Where-Object { $null -ne $_ -and $_.Length -gt 0 }", + "$parts = @([Environment]::GetEnvironmentVariable('Path', 'Process'), [Environment]::GetEnvironmentVariable('Path', 'Machine'), [Environment]::GetEnvironmentVariable('Path', 'User')) | Where-Object { $null -ne $_ -and $_.Length -gt 0 }", "$value = [string]::Join(';', $parts)", ] : [`$value = [Environment]::GetEnvironmentVariable('${name}')`]; diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 6f131166d93..cd07273c7e6 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -241,6 +241,8 @@ describe("readEnvironmentFromWindowsShell", () => { expect(command).toContain("[Environment]::GetEnvironmentVariable('Path', 'Machine')"); expect(command).toContain("[Environment]::GetEnvironmentVariable('Path', 'User')"); expect(command).toContain(") | Where-Object { $null -ne $_ -and $_.Length -gt 0 }"); + expect(command).not.toContain("@(; "); + expect(command).not.toContain(",; "); expect(command).not.toContain("Where-Object { $null -ne $_ -and $_.Length -gt 0 })"); }); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 3aeaca08e70..b3961982e72 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -271,11 +271,7 @@ function buildWindowsEnvironmentCaptureCommand(names: ReadonlyArray): st const valueCapture = name.toUpperCase() === "PATH" ? [ - "$parts = @(", - " [Environment]::GetEnvironmentVariable('Path', 'Process'),", - " [Environment]::GetEnvironmentVariable('Path', 'Machine'),", - " [Environment]::GetEnvironmentVariable('Path', 'User')", - ") | Where-Object { $null -ne $_ -and $_.Length -gt 0 }", + "$parts = @([Environment]::GetEnvironmentVariable('Path', 'Process'), [Environment]::GetEnvironmentVariable('Path', 'Machine'), [Environment]::GetEnvironmentVariable('Path', 'User')) | Where-Object { $null -ne $_ -and $_.Length -gt 0 }", "$value = [string]::Join(';', $parts)", ] : [`$value = [Environment]::GetEnvironmentVariable('${name}')`]; From 4302755812661478502f5c7308418399a7c9df31 Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Fri, 3 Jul 2026 23:48:21 -0400 Subject: [PATCH 05/10] Seed Devin models with one-shot discovery --- .../src/provider/Drivers/DevinDriver.ts | 22 ++++++++- .../src/provider/Layers/DevinProvider.test.ts | 35 ++++++++++++++ .../src/provider/Layers/DevinProvider.ts | 48 +++++++++++++++++-- .../ProviderModelDiscoveryCache.test.ts | 25 ++++++++++ .../provider/ProviderModelDiscoveryCache.ts | 4 ++ 5 files changed, 129 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Drivers/DevinDriver.ts b/apps/server/src/provider/Drivers/DevinDriver.ts index e76240d95df..2f8a7454259 100644 --- a/apps/server/src/provider/Drivers/DevinDriver.ts +++ b/apps/server/src/provider/Drivers/DevinDriver.ts @@ -4,6 +4,7 @@ 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 Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -39,7 +40,10 @@ import { makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; -import { isDevinAcpModelCoveredByBaseModelIds } from "../acp/DevinAcpSupport.ts"; +import { + discoverDevinModelsViaAcp, + isDevinAcpModelCoveredByBaseModelIds, +} from "../acp/DevinAcpSupport.ts"; const decodeDevinSettings = Schema.decodeSync(DevinSettings); @@ -122,6 +126,7 @@ export const DevinDriver: ProviderDriver = { env: processEnv, }); const modelDiscoveryCache = yield* makeProviderModelDiscoveryCache(); + const initialModelDiscoveryAttempted = yield* Ref.make(false); const adapter = yield* makeDevinAdapter(effectiveConfig, { environment: processEnv, @@ -130,10 +135,23 @@ export const DevinDriver: ProviderDriver = { onSessionModelsDiscovered: modelDiscoveryCache.recordModels, }); const textGeneration = yield* makeDevinTextGeneration(effectiveConfig, processEnv); + const discoverInitialModels = Effect.fn("DevinDriver.discoverInitialModels")(function* () { + const alreadyAttempted = yield* Ref.getAndSet(initialModelDiscoveryAttempted, true); + if (alreadyAttempted) { + return []; + } + + const discoveredModels = yield* discoverDevinModelsViaAcp(effectiveConfig, processEnv); + yield* modelDiscoveryCache.primeModels(discoveredModels); + return discoveredModels; + }); const checkProvider = modelDiscoveryCache.getModels.pipe( Effect.flatMap((cachedDiscoveredModels) => - checkDevinProviderStatus(effectiveConfig, processEnv, { cachedDiscoveredModels }), + checkDevinProviderStatus(effectiveConfig, processEnv, { + cachedDiscoveredModels, + discoverModels: discoverInitialModels(), + }), ), Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Layers/DevinProvider.test.ts b/apps/server/src/provider/Layers/DevinProvider.test.ts index f55f5c7810a..59fa691d3e7 100644 --- a/apps/server/src/provider/Layers/DevinProvider.test.ts +++ b/apps/server/src/provider/Layers/DevinProvider.test.ts @@ -196,6 +196,41 @@ it.layer(NodeServices.layer)("checkDevinProviderStatus", (it) => { }), ); + it.effect("reports models discovered by a supplied cold-start ACP probe", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const devinPath = yield* makeMockDevinCli("t3code-devin-cold-discovery-"); + + return yield* checkDevinProviderStatus( + decodeDevinSettings({ enabled: true, binaryPath: devinPath }), + process.env, + { + discoverModels: Effect.succeed([ + { + slug: "adaptive", + name: "Adaptive", + isCustom: false, + capabilities: null, + }, + { + slug: "gpt-5-5", + name: "GPT-5.5", + isCustom: false, + capabilities: null, + }, + ]), + }, + ); + }), + ); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["adaptive", "gpt-5-5"]); + expect(snapshot.message).toBeUndefined(); + }), + ); + it.effect("uses cached real-session model discovery without ACP probing", () => Effect.gen(function* () { const snapshot = yield* Effect.scoped( diff --git a/apps/server/src/provider/Layers/DevinProvider.ts b/apps/server/src/provider/Layers/DevinProvider.ts index c886d986fae..fe2232b6d62 100644 --- a/apps/server/src/provider/Layers/DevinProvider.ts +++ b/apps/server/src/provider/Layers/DevinProvider.ts @@ -8,10 +8,12 @@ import { import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -40,6 +42,7 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ }); const VERSION_PROBE_TIMEOUT_MS = 4_000; +const DEVIN_ACP_INITIAL_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; export function buildInitialDevinProviderSnapshot( devinSettings: DevinSettings, @@ -112,6 +115,11 @@ const runDevinVersionCommand = ( export interface DevinProviderStatusOptions { readonly cachedDiscoveredModels?: ReadonlyArray; + readonly discoverModels?: Effect.Effect< + ReadonlyArray, + EffectAcpErrors.AcpError, + ChildProcessSpawner.ChildProcessSpawner + >; } export const checkDevinProviderStatus = Effect.fn("checkDevinProviderStatus")(function* ( @@ -123,6 +131,36 @@ export const checkDevinProviderStatus = Effect.fn("checkDevinProviderStatus")(fu const cachedModels = options?.cachedDiscoveredModels ?? []; const fallbackModels = devinModelsFromSettings(devinSettings.customModels, cachedModels); + const discoverModelsForSnapshot = Effect.fn("discoverDevinModelsForSnapshot")(function* () { + if (cachedModels.length > 0 || options?.discoverModels === undefined) { + return cachedModels; + } + + const discoveryExit = yield* options.discoverModels.pipe( + Effect.timeoutOption(DEVIN_ACP_INITIAL_MODEL_DISCOVERY_TIMEOUT_MS), + Effect.exit, + ); + if (Exit.isFailure(discoveryExit)) { + yield* Effect.logWarning("Devin cold-start ACP model discovery failed", { + errorTag: causeErrorTag(discoveryExit.cause), + }); + return cachedModels; + } + if (Option.isNone(discoveryExit.value)) { + yield* Effect.logWarning( + `Devin cold-start ACP model discovery timed out after ${DEVIN_ACP_INITIAL_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + ); + return cachedModels; + } + + const discoveredModels = discoveryExit.value.value; + if (discoveredModels.length === 0) { + yield* Effect.logWarning("Devin cold-start ACP model discovery returned no models."); + return cachedModels; + } + return discoveredModels; + }); + if (!devinSettings.enabled) { return buildServerProvider({ presentation: DEVIN_PRESENTATION, @@ -205,13 +243,17 @@ export const checkDevinProviderStatus = Effect.fn("checkDevinProviderStatus")(fu }); } - // Provider refreshes run periodically. Devin ACP startup creates a real - // session, so model details are captured only from user-started sessions. + // Provider refreshes run periodically. The driver supplies cold-start + // discovery as a one-shot effect so we seed the model picker without creating + // a real Devin session on every refresh. + const discoveredModels = yield* discoverModelsForSnapshot(); + const models = devinModelsFromSettings(devinSettings.customModels, discoveredModels); + return buildServerProvider({ presentation: DEVIN_PRESENTATION, enabled: devinSettings.enabled, checkedAt, - models: fallbackModels, + models, probe: { installed: true, version, diff --git a/apps/server/src/provider/ProviderModelDiscoveryCache.test.ts b/apps/server/src/provider/ProviderModelDiscoveryCache.test.ts index ff71a4321e6..2f1a8dcb9ee 100644 --- a/apps/server/src/provider/ProviderModelDiscoveryCache.test.ts +++ b/apps/server/src/provider/ProviderModelDiscoveryCache.test.ts @@ -25,4 +25,29 @@ describe("ProviderModelDiscoveryCache", () => { }), ), ); + + it.effect("primes models without triggering a provider refresh", () => + Effect.scoped( + Effect.gen(function* () { + const cache = yield* makeProviderModelDiscoveryCache(); + let refreshCount = 0; + const model = { + slug: "devin-model", + name: "Devin Model", + isCustom: false, + capabilities: null, + } satisfies ServerProviderModel; + + yield* cache.setRefresh( + Effect.sync(() => { + refreshCount += 1; + }), + ); + yield* cache.primeModels([model]); + + expect(yield* cache.getModels).toEqual([model]); + expect(refreshCount).toBe(0); + }), + ), + ); }); diff --git a/apps/server/src/provider/ProviderModelDiscoveryCache.ts b/apps/server/src/provider/ProviderModelDiscoveryCache.ts index 276bd7f689c..8cc2e5c5335 100644 --- a/apps/server/src/provider/ProviderModelDiscoveryCache.ts +++ b/apps/server/src/provider/ProviderModelDiscoveryCache.ts @@ -8,6 +8,9 @@ export interface ProviderModelDiscoveryCache { readonly setRefresh: ( refresh: Effect.Effect, ) => Effect.Effect; + readonly primeModels: ( + models: ReadonlyArray, + ) => Effect.Effect; readonly recordModels: ( models: ReadonlyArray, ) => Effect.Effect; @@ -31,6 +34,7 @@ export function makeProviderModelDiscoveryCache(): Effect.Effect< return { getModels: Ref.get(modelsRef), setRefresh: (refresh) => Ref.set(refreshRef, refresh), + primeModels: (models) => Ref.set(modelsRef, models), recordModels: (models) => Ref.set(modelsRef, models).pipe(Effect.andThen(scheduleRefresh), Effect.asVoid), } satisfies ProviderModelDiscoveryCache; From 9f19f9398c583ff9e4c461026b57a94f8f0d2929 Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Fri, 3 Jul 2026 23:59:38 -0400 Subject: [PATCH 06/10] Reject incomplete ACP JSON generations --- .../AcpJsonTextGeneration.test.ts | 53 +++++++++++++++++-- .../textGeneration/AcpJsonTextGeneration.ts | 7 ++- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/apps/server/src/textGeneration/AcpJsonTextGeneration.test.ts b/apps/server/src/textGeneration/AcpJsonTextGeneration.test.ts index ba6a6b85826..367f88fe832 100644 --- a/apps/server/src/textGeneration/AcpJsonTextGeneration.test.ts +++ b/apps/server/src/textGeneration/AcpJsonTextGeneration.test.ts @@ -1,19 +1,26 @@ import { assert, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; +import type * as EffectAcpSchema from "effect-acp/schema"; -import { ProviderInstanceId } from "@t3tools/contracts"; +import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; import type * as AcpSessionRuntime from "../provider/acp/AcpSessionRuntime.ts"; import { makeAcpJsonTextGeneration } from "./AcpJsonTextGeneration.ts"; -function makeQueuedRuntime(): Effect.Effect { +function makeQueuedRuntime( + input: { + readonly text?: string; + readonly stopReason?: EffectAcpSchema.PromptResponse["stopReason"]; + } = {}, +): Effect.Effect { return Effect.gen(function* () { const promptStarted = yield* Deferred.make(); const contentEvent = { _tag: "ContentDelta", - text: '{"title":"Drain queued ACP text"}', + text: input.text ?? '{"title":"Drain queued ACP text"}', rawPayload: { sessionId: "queued-session" }, } satisfies AcpSessionRuntime.AcpSessionRuntimeEvent; @@ -38,7 +45,9 @@ function makeQueuedRuntime(): Effect.Effect Deferred.succeed(promptStarted, undefined).pipe( - Effect.as({ stopReason: "end_turn" as const }), + Effect.as({ + stopReason: input.stopReason ?? "end_turn", + } satisfies EffectAcpSchema.PromptResponse), ), } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; }); @@ -66,3 +75,39 @@ it.effect("drains queued ACP events before decoding generated JSON", () => assert.equal(generated.title, "Drain queued ACP text"); }), ); + +it.effect("fails before decoding ACP output when stop reason is non-success", () => + Effect.gen(function* () { + const textGeneration = makeAcpJsonTextGeneration({ + traceName: "StoppedAcpTextGeneration", + requestLabel: "Stopped ACP", + outputLabel: "Stopped ACP", + makeRuntime: () => + makeQueuedRuntime({ + text: '{"title":"Should not be accepted"}', + stopReason: "refusal", + }), + configureSession: () => Effect.void, + }); + + const result = yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread", + modelSelection: { + instanceId: ProviderInstanceId.make("devin"), + model: "mock-model", + }, + }) + .pipe(Effect.result); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, TextGenerationError); + assert.include( + result.failure.message, + "Stopped ACP request stopped before completing: refusal", + ); + } + }), +); diff --git a/apps/server/src/textGeneration/AcpJsonTextGeneration.ts b/apps/server/src/textGeneration/AcpJsonTextGeneration.ts index e530610d158..564efbec316 100644 --- a/apps/server/src/textGeneration/AcpJsonTextGeneration.ts +++ b/apps/server/src/textGeneration/AcpJsonTextGeneration.ts @@ -140,10 +140,13 @@ export function makeAcpJsonTextGeneration( yield* runtime.drainEvents; - if (promptResult.stopReason === "cancelled") { + if (promptResult.stopReason !== "end_turn") { return yield* new TextGenerationError({ operation, - detail: `${options.requestLabel} request was cancelled.`, + detail: + promptResult.stopReason === "cancelled" + ? `${options.requestLabel} request was cancelled.` + : `${options.requestLabel} request stopped before completing: ${promptResult.stopReason}.`, }); } From 5fae219036373083af2e736c722aea3e3c6daf00 Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Sat, 4 Jul 2026 00:01:54 -0400 Subject: [PATCH 07/10] Harden Devin ACP provider recovery --- .../src/provider/Drivers/DevinDriver.ts | 12 +- .../src/provider/acp/AcpAdapterLive.test.ts | 130 ++++++++++++++++++ .../server/src/provider/acp/AcpAdapterLive.ts | 3 - 3 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/provider/acp/AcpAdapterLive.test.ts diff --git a/apps/server/src/provider/Drivers/DevinDriver.ts b/apps/server/src/provider/Drivers/DevinDriver.ts index 2f8a7454259..9838728ff3e 100644 --- a/apps/server/src/provider/Drivers/DevinDriver.ts +++ b/apps/server/src/provider/Drivers/DevinDriver.ts @@ -2,6 +2,7 @@ import { DevinSettings, ProviderDriverKind, type ServerProvider } from "@t3tools import * as Duration from "effect/Duration"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; @@ -141,8 +142,15 @@ export const DevinDriver: ProviderDriver = { return []; } - const discoveredModels = yield* discoverDevinModelsViaAcp(effectiveConfig, processEnv); - yield* modelDiscoveryCache.primeModels(discoveredModels); + const discoveredModels = yield* Effect.gen(function* () { + const models = yield* discoverDevinModelsViaAcp(effectiveConfig, processEnv); + yield* modelDiscoveryCache.primeModels(models); + return models; + }).pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) ? Effect.void : Ref.set(initialModelDiscoveryAttempted, false), + ), + ); return discoveredModels; }); diff --git a/apps/server/src/provider/acp/AcpAdapterLive.test.ts b/apps/server/src/provider/acp/AcpAdapterLive.test.ts new file mode 100644 index 00000000000..67e8f4fb9d6 --- /dev/null +++ b/apps/server/src/provider/acp/AcpAdapterLive.test.ts @@ -0,0 +1,130 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +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 Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import { makeAcpAdapterLive } from "./AcpAdapterLive.ts"; +import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +type TestAcpRuntime = AcpSessionRuntime.AcpSessionRuntime["Service"]; + +const acpAdapterLiveTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-acp-adapter-live-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +const noopHandler = (() => Effect.void) as unknown; +const unsupported = () => + Effect.die(new Error("unsupported test ACP method")) as Effect.Effect; + +it.effect( + "does not mark a turn interrupted when interruptTurn times out before taking the lock", + () => + Effect.gen(function* () { + const provider = ProviderDriverKind.make("test-acp"); + const threadId = ThreadId.make("acp-interrupt-timeout-before-lock"); + const drainEntered = yield* Deferred.make(); + const releaseDrain = yield* Deferred.make(); + + const acp = { + handleRequestPermission: noopHandler as TestAcpRuntime["handleRequestPermission"], + handleElicitation: noopHandler as TestAcpRuntime["handleElicitation"], + handleReadTextFile: noopHandler as TestAcpRuntime["handleReadTextFile"], + handleWriteTextFile: noopHandler as TestAcpRuntime["handleWriteTextFile"], + handleCreateTerminal: noopHandler as TestAcpRuntime["handleCreateTerminal"], + handleTerminalOutput: noopHandler as TestAcpRuntime["handleTerminalOutput"], + handleTerminalWaitForExit: noopHandler as TestAcpRuntime["handleTerminalWaitForExit"], + handleTerminalKill: noopHandler as TestAcpRuntime["handleTerminalKill"], + handleTerminalRelease: noopHandler as TestAcpRuntime["handleTerminalRelease"], + handleSessionUpdate: noopHandler as TestAcpRuntime["handleSessionUpdate"], + handleElicitationComplete: noopHandler as TestAcpRuntime["handleElicitationComplete"], + handleUnknownExtRequest: noopHandler as TestAcpRuntime["handleUnknownExtRequest"], + handleUnknownExtNotification: noopHandler as TestAcpRuntime["handleUnknownExtNotification"], + handleExtRequest: noopHandler as TestAcpRuntime["handleExtRequest"], + handleExtNotification: noopHandler as TestAcpRuntime["handleExtNotification"], + start: () => + Effect.succeed({ + sessionId: "test-acp-session", + initializeResult: { + protocolVersion: 1, + agentCapabilities: { promptCapabilities: {} }, + } satisfies EffectAcpSchema.InitializeResponse, + sessionSetupResult: { + sessionId: "test-acp-session", + } satisfies EffectAcpSchema.NewSessionResponse, + modelConfigId: undefined, + }), + getEvents: () => Stream.empty, + drainEvents: Effect.gen(function* () { + yield* Deferred.succeed(drainEntered, undefined).pipe(Effect.ignore); + yield* Deferred.await(releaseDrain); + }), + getModeState: Effect.sync(() => undefined), + getConfigOptions: Effect.succeed([]), + prompt: () => Effect.succeed({ stopReason: "end_turn" as const }), + cancel: Effect.void, + setMode: () => unsupported(), + setConfigOption: () => unsupported(), + setModel: () => unsupported(), + setSessionModel: () => unsupported(), + request: () => unsupported(), + notify: () => unsupported(), + } satisfies TestAcpRuntime; + + const adapter = yield* makeAcpAdapterLive({ + provider, + providerLabel: "Test ACP", + resumeSchemaVersion: 1, + readyReason: "test-ready", + respondToUserInputMethod: "session/elicitation", + capabilities: { sessionModelSwitch: "unsupported" }, + completedStopReasonFromPromptResponse: (response) => response.stopReason, + makeAcpRuntime: () => Effect.succeed(acp), + registerAcpCallbacks: () => Effect.void, + bindSessionModel: () => + Effect.succeed({ currentModelId: undefined, displayModel: undefined }), + prepareTurnModel: () => + Effect.succeed({ currentModelId: undefined, displayModel: undefined }), + }); + + yield* adapter.startSession({ + threadId, + provider, + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "complete while interrupted cancellation is waiting", + attachments: [], + }) + .pipe(Effect.forkChild); + + yield* Deferred.await(drainEntered).pipe(Effect.timeout("2 seconds")); + const interruptedExit = yield* Effect.exit( + adapter.interruptTurn(threadId).pipe(Effect.timeout("25 millis")), + ); + assert.isTrue(Exit.isFailure(interruptedExit)); + + yield* Deferred.succeed(releaseDrain, undefined); + yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("2 seconds")); + + const sessions = yield* adapter.listSessions(); + const session = sessions.find((entry) => entry.threadId === threadId); + + assert.equal(session?.status, "ready"); + assert.isUndefined(session?.activeTurnId); + + yield* adapter.stopSession(threadId); + }).pipe(Effect.provide(acpAdapterLiveTestLayer), TestClock.withLive), +); diff --git a/apps/server/src/provider/acp/AcpAdapterLive.ts b/apps/server/src/provider/acp/AcpAdapterLive.ts index 80360470896..ee5b415e163 100644 --- a/apps/server/src/provider/acp/AcpAdapterLive.ts +++ b/apps/server/src/provider/acp/AcpAdapterLive.ts @@ -792,9 +792,6 @@ export function makeAcpAdapterLive( return { _tag: "Ignore" as const }; } const interruptedTurnId = turnId ?? activeTurnId; - if (interruptedTurnId !== undefined) { - ctx.interruptedTurnIds.add(interruptedTurnId); - } return { _tag: "Proceed" as const, acpSessionId: ctx.acpSessionId, From 15bac9598c9b61815d94dc411f464807dbac973b Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Sat, 4 Jul 2026 00:11:22 -0400 Subject: [PATCH 08/10] Reject stale ACP user input responses --- .../provider/acp/AcpAdapterRuntime.test.ts | 34 +++++++++++++++++++ .../src/provider/acp/AcpAdapterRuntime.ts | 9 ++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/acp/AcpAdapterRuntime.test.ts b/apps/server/src/provider/acp/AcpAdapterRuntime.test.ts index 1868becdb25..09ebc1196cb 100644 --- a/apps/server/src/provider/acp/AcpAdapterRuntime.test.ts +++ b/apps/server/src/provider/acp/AcpAdapterRuntime.test.ts @@ -7,12 +7,16 @@ import { ApprovalRequestId, ProviderDriverKind, type ProviderApprovalDecision, + type ProviderUserInputAnswers, } from "@t3tools/contracts"; import { + type AcpAdapterPendingUserInputResolution, makeAcpThreadLock, respondToAcpPermissionRequest, + respondToAcpUserInput, selectPermissionOptionId, + settlePendingAcpUserInputsAsCancelled, } from "./AcpAdapterRuntime.ts"; describe("AcpAdapterRuntime", () => { @@ -74,4 +78,34 @@ describe("AcpAdapterRuntime", () => { expect(error.detail).toContain("Unknown pending approval request"); }), ); + + it.effect("rejects stale ACP user-input responses after cancellation", () => + Effect.gen(function* () { + const requestId = ApprovalRequestId.make("user-input-1"); + const resolution = yield* Deferred.make>(); + const pendingUserInputs = new Map([ + [ + requestId, + { + resolution, + makeResponse: (answers: ProviderUserInputAnswers) => String(answers["scope"]), + }, + ], + ]); + + yield* settlePendingAcpUserInputsAsCancelled(pendingUserInputs); + const error = yield* Effect.flip( + respondToAcpUserInput({ + provider: ProviderDriverKind.make("devin"), + method: "session/elicitation", + requestId, + answers: { scope: "Workspace" }, + pendingUserInputs, + }), + ); + + expect(error._tag).toBe("ProviderAdapterRequestError"); + expect(error.detail).toContain("no longer awaiting a response"); + }), + ); }); diff --git a/apps/server/src/provider/acp/AcpAdapterRuntime.ts b/apps/server/src/provider/acp/AcpAdapterRuntime.ts index 3562c8647d5..96d24d4a9f2 100644 --- a/apps/server/src/provider/acp/AcpAdapterRuntime.ts +++ b/apps/server/src/provider/acp/AcpAdapterRuntime.ts @@ -605,11 +605,18 @@ export function respondToAcpUserInput(input: { detail: validationError, }); } - yield* Deferred.succeed(pending.resolution, { + const accepted = yield* Deferred.succeed(pending.resolution, { _tag: "answered", answers: input.answers, response, }); + if (!accepted) { + return yield* new ProviderAdapterRequestError({ + provider: input.provider, + method: input.method, + detail: `Pending user-input request is no longer awaiting a response: ${input.requestId}`, + }); + } }); } From 3f72ccc75158f3011184857c1501791afb129810 Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Sat, 4 Jul 2026 00:30:41 -0400 Subject: [PATCH 09/10] Remove local artifact ignores from PR --- .gitignore | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.gitignore b/.gitignore index 5276bf748e2..ef6067824f2 100644 --- a/.gitignore +++ b/.gitignore @@ -32,10 +32,3 @@ node_modules/ *.log .env* !.env.example - -# Local agent/MCP harness artifacts (not part of the app) -.firecrawl/ -/agent-tools/ -/mcps/ -/terminals/ -/apps/server/mcps/ From 45248826225e729e9cfad34cd3c9fd84788a3e60 Mon Sep 17 00:00:00 2001 From: Derpedyea Date: Sat, 4 Jul 2026 00:48:28 -0400 Subject: [PATCH 10/10] Prune redundant Devin adapter tests --- .../src/shell/DesktopShellEnvironment.test.ts | 14 - .../src/provider/Layers/DevinAdapter.test.ts | 854 +----------------- .../ProviderModelDiscoveryCache.test.ts | 22 + 3 files changed, 23 insertions(+), 867 deletions(-) diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index d47fcba74f0..7ec0ab80ae7 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -204,14 +204,12 @@ describe("DesktopShellEnvironment", () => { LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", USERPROFILE: "C:\\Users\\testuser", }; - const commands: ChildProcess.Command[] = []; yield* runShellEnvironment({ env, platform: "win32", handler: (command) => { if (command._tag !== "StandardCommand") return ""; - commands.push(command); const loadProfile = !command.args.includes("-NoProfile"); return loadProfile ? envOutput({ @@ -242,18 +240,6 @@ describe("DesktopShellEnvironment", () => { env.FNM_MULTISHELL_PATH, "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", ); - const noProfileCommand = commands.find( - (command) => command._tag === "StandardCommand" && command.args.includes("-NoProfile"), - ); - const captureCommand = - noProfileCommand?._tag === "StandardCommand" ? (noProfileCommand.args.at(-1) ?? "") : ""; - assert.include(captureCommand, "[Environment]::GetEnvironmentVariable('Path', 'Process')"); - assert.include(captureCommand, "[Environment]::GetEnvironmentVariable('Path', 'Machine')"); - assert.include(captureCommand, "[Environment]::GetEnvironmentVariable('Path', 'User')"); - assert.include(captureCommand, ") | Where-Object { $null -ne $_ -and $_.Length -gt 0 }"); - assert.notInclude(captureCommand, "@(; "); - assert.notInclude(captureCommand, ",; "); - assert.notInclude(captureCommand, "Where-Object { $null -ne $_ -and $_.Length -gt 0 })"); }), ); diff --git a/apps/server/src/provider/Layers/DevinAdapter.test.ts b/apps/server/src/provider/Layers/DevinAdapter.test.ts index 1525321770c..ecdc50f241b 100644 --- a/apps/server/src/provider/Layers/DevinAdapter.test.ts +++ b/apps/server/src/provider/Layers/DevinAdapter.test.ts @@ -10,10 +10,8 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -23,14 +21,12 @@ import { ProviderDriverKind, ProviderInstanceId, ThreadId, - TurnId, type ProviderRuntimeEvent, type ServerProviderModel, } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { ServerConfig } from "../../config.ts"; -import { devinPromptSettlementBelongsToContext, makeDevinAdapter } from "./DevinAdapter.ts"; +import { makeDevinAdapter } from "./DevinAdapter.ts"; const decodeDevinSettings = Schema.decodeSync(DevinSettings); @@ -46,40 +42,6 @@ async function makeMockDevinWrapper(extraEnv?: Record) { return wrapperPath; } -function waitForFileContent( - filePath: string, - attempts = 40, - expectedContent?: string, -): Effect.Effect { - const readAttempt = (remainingAttempts: number): Effect.Effect => - Effect.gen(function* () { - if (remainingAttempts <= 0) { - return yield* Effect.die(new Error(`Timed out waiting for file content at ${filePath}`)); - } - const raw = yield* Effect.tryPromise(() => NodeFSP.readFile(filePath, "utf8")).pipe( - Effect.orElseSucceed(() => ""), - ); - if ( - raw.trim().length > 0 && - (expectedContent === undefined || raw.includes(expectedContent)) - ) { - return raw; - } - yield* Effect.sleep("25 millis"); - return yield* readAttempt(remainingAttempts - 1); - }); - return readAttempt(attempts); -} - -async function readJsonLines(filePath: string) { - const raw = await NodeFSP.readFile(filePath, "utf8"); - return raw - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as Record); -} - const devinAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3code-devin-adapter-test-", }).pipe(Layer.provideMerge(NodeServices.layer)); @@ -113,39 +75,6 @@ const makeTestAdapter = (binaryPath: string, options?: Parameters { - const staleTurnId = TurnId.make("stale-turn"); - const replacementTurnId = TurnId.make("replacement-turn"); - - assert.isFalse( - devinPromptSettlementBelongsToContext({ - liveAcpSessionId: "session-1", - expectedAcpSessionId: "session-1", - liveActiveTurnId: replacementTurnId, - liveSessionActiveTurnId: replacementTurnId, - turnId: staleTurnId, - }), - ); - assert.isFalse( - devinPromptSettlementBelongsToContext({ - liveAcpSessionId: "replacement-session", - expectedAcpSessionId: "stale-session", - liveActiveTurnId: staleTurnId, - liveSessionActiveTurnId: staleTurnId, - turnId: staleTurnId, - }), - ); - assert.isTrue( - devinPromptSettlementBelongsToContext({ - liveAcpSessionId: "session-1", - expectedAcpSessionId: "session-1", - liveActiveTurnId: staleTurnId, - liveSessionActiveTurnId: staleTurnId, - turnId: staleTurnId, - }), - ); -}); - it.layer(devinAdapterTestLayer)("DevinAdapterLive", (it) => { it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => Effect.gen(function* () { @@ -240,90 +169,6 @@ it.layer(devinAdapterTestLayer)("DevinAdapterLive", (it) => { }), ); - it.effect("closes the ACP child process when a session stops", () => - Effect.gen(function* () { - const threadId = ThreadId.make("devin-stop-session-close"); - const tempDir = yield* Effect.promise(() => - NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-adapter-exit-log-")), - ); - const exitLogPath = NodePath.join(tempDir, "exit.log"); - - const wrapperPath = yield* Effect.promise(() => - makeMockDevinWrapper({ - T3_ACP_EXIT_LOG_PATH: exitLogPath, - }), - ); - const adapter = yield* makeTestAdapter(wrapperPath); - - yield* adapter.startSession({ - threadId, - provider: ProviderDriverKind.make("devin"), - cwd: process.cwd(), - runtimeMode: "full-access", - }); - - yield* adapter.stopSession(threadId); - - assert.isFalse(yield* adapter.hasSession(threadId)); - const hostPlatform = yield* HostProcessPlatform; - if (hostPlatform !== "win32") { - const exitLog = yield* waitForFileContent(exitLogPath); - assert.include(exitLog, "SIGTERM"); - } - }), - ); - - it.effect("reports a Devin session running only while the prompt is in flight", () => - Effect.gen(function* () { - const threadId = ThreadId.make("devin-session-ready-after-prompt"); - const wrapperPath = yield* Effect.promise(() => - makeMockDevinWrapper({ - T3_ACP_EMIT_TOOL_CALLS: "1", - }), - ); - const adapter = yield* makeTestAdapter(wrapperPath); - const requestOpened = - yield* Deferred.make>(); - const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - event.type === "request.opened" - ? Deferred.succeed(requestOpened, event).pipe(Effect.ignore) - : Effect.void, - ).pipe(Effect.forkChild); - - yield* adapter.startSession({ - threadId, - provider: ProviderDriverKind.make("devin"), - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - const sendTurnFiber = yield* adapter - .sendTurn({ threadId, input: "check lifecycle", attachments: [] }) - .pipe(Effect.forkChild); - const requestOpenedEvent = yield* Deferred.await(requestOpened); - - const runningSessions = yield* adapter.listSessions(); - const runningSession = runningSessions.find((session) => session.threadId === threadId); - assert.equal(runningSession?.status, "running"); - assert.isDefined(runningSession?.activeTurnId); - - yield* adapter.respondToRequest( - threadId, - ApprovalRequestId.make(String(requestOpenedEvent.requestId)), - "accept", - ); - yield* Fiber.join(sendTurnFiber); - - const readySessions = yield* adapter.listSessions(); - const readySession = readySessions.find((session) => session.threadId === threadId); - assert.equal(readySession?.status, "ready"); - assert.isUndefined(readySession?.activeTurnId); - - yield* Fiber.interrupt(eventsFiber); - yield* adapter.stopSession(threadId); - }), - ); - it.effect("handles ACP session elicitation requests", () => Effect.gen(function* () { const threadId = ThreadId.make("devin-session-elicitation"); @@ -503,701 +348,4 @@ it.layer(devinAdapterTestLayer)("DevinAdapterLive", (it) => { yield* adapter.stopSession(threadId); }), ); - - it.effect("treats interrupting a missing session as a no-op", () => - Effect.gen(function* () { - const threadId = ThreadId.make("devin-interrupt-missing-session"); - const wrapperPath = yield* Effect.promise(() => makeMockDevinWrapper()); - const adapter = yield* makeTestAdapter(wrapperPath); - - yield* adapter.interruptTurn(threadId).pipe(Effect.timeout("2 seconds")); - }), - ); - - it.effect("ignores stale explicit turn interrupts after completion", () => - Effect.gen(function* () { - const threadId = ThreadId.make("devin-ignore-stale-turn-interrupt"); - const tempDir = yield* Effect.promise(() => - NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-stale-interrupt-")), - ); - const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); - const wrapperPath = yield* Effect.promise(() => - makeMockDevinWrapper({ - T3_ACP_REQUEST_LOG_PATH: requestLogPath, - }), - ); - const adapter = yield* makeTestAdapter(wrapperPath); - - yield* adapter.startSession({ - threadId, - provider: ProviderDriverKind.make("devin"), - cwd: process.cwd(), - runtimeMode: "full-access", - }); - const completed = yield* adapter - .sendTurn({ threadId, input: "complete before interrupt", attachments: [] }) - .pipe(Effect.timeout("2 seconds")); - - yield* adapter.interruptTurn(threadId, completed.turnId).pipe(Effect.timeout("2 seconds")); - yield* Effect.sleep("100 millis"); - - const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); - assert.isFalse(requests.some((entry) => entry.method === "session/cancel")); - - yield* adapter.stopSession(threadId); - }).pipe(TestClock.withLive), - ); - - it.effect("restores ready without completing an unstarted turn when preparation fails", () => - Effect.gen(function* () { - const threadId = ThreadId.make("devin-preparation-failure-while-connecting"); - const wrapperPath = yield* Effect.promise(() => makeMockDevinWrapper()); - const adapter = yield* makeTestAdapter(wrapperPath); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => { - runtimeEvents.push(event); - }), - ).pipe(Effect.forkChild); - - yield* adapter.startSession({ - threadId, - provider: ProviderDriverKind.make("devin"), - cwd: process.cwd(), - runtimeMode: "full-access", - }); - - const error = yield* Effect.flip( - adapter.sendTurn({ - threadId, - input: "prepare invalid attachment", - attachments: [ - { - type: "image", - id: "missing-image", - name: "missing.png", - mimeType: "image/png", - sizeBytes: 1, - }, - ], - }), - ); - for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { - yield* Effect.yieldNow; - } - - const turnCompletedEvent = runtimeEvents.find( - (event): event is Extract => - event.type === "turn.completed", - ); - const readySessions = yield* adapter.listSessions(); - const readySession = readySessions.find((session) => session.threadId === threadId); - - assert.equal(error._tag, "ProviderAdapterRequestError"); - assert.isUndefined(turnCompletedEvent); - assert.equal(readySession?.status, "ready"); - assert.isUndefined(readySession?.activeTurnId); - - yield* Fiber.interrupt(runtimeEventsFiber); - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("lets Stop unblock a fully silent Devin prompt and accept a follow-up turn", () => - Effect.gen(function* () { - const threadId = ThreadId.make("devin-stop-after-full-silence"); - const wrapperPath = yield* Effect.promise(() => - makeMockDevinWrapper({ - T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", - }), - ); - const adapter = yield* makeTestAdapter(wrapperPath); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => { - runtimeEvents.push(event); - }), - ).pipe(Effect.forkChild); - - yield* adapter.startSession({ - threadId, - provider: ProviderDriverKind.make("devin"), - cwd: process.cwd(), - runtimeMode: "full-access", - }); - - yield* Effect.gen(function* () { - yield* Effect.sleep("500 millis"); - yield* adapter.interruptTurn(threadId); - }).pipe(Effect.forkChild({ startImmediately: true })); - - yield* adapter.sendTurn({ - threadId, - input: "hang forever", - attachments: [], - }); - for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { - yield* Effect.yieldNow; - } - - const cancelledEvents = runtimeEvents.filter( - (event): event is Extract => - event.type === "turn.completed" && String(event.threadId) === String(threadId), - ); - const readySessions = yield* adapter.listSessions(); - const readySession = readySessions.find((session) => session.threadId === threadId); - - assert.lengthOf(cancelledEvents, 1); - assert.equal(cancelledEvents[0]?.payload.state, "cancelled"); - assert.equal(readySession?.status, "ready"); - assert.isUndefined(readySession?.activeTurnId); - - const followUpEventsBefore = runtimeEvents.length; - yield* adapter.sendTurn({ - threadId, - input: "continue after stop", - attachments: [], - }); - for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { - yield* Effect.yieldNow; - } - - const followUpCompletedEvents = runtimeEvents - .slice(followUpEventsBefore) - .filter( - (event): event is Extract => - event.type === "turn.completed" && String(event.threadId) === String(threadId), - ); - assert.lengthOf(followUpCompletedEvents, 1); - assert.equal(followUpCompletedEvents[0]?.payload.state, "completed"); - - yield* Fiber.interrupt(runtimeEventsFiber); - yield* adapter.stopSession(threadId); - }).pipe(TestClock.withLive), - ); - - it.effect("does not let a cancelled prompt settlement consume the follow-up prompt slot", () => - Effect.gen(function* () { - const threadId = ThreadId.make("devin-cancelled-settlement-before-follow-up"); - const tempDir = yield* Effect.promise(() => - NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-cancel-race-")), - ); - const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); - const wrapperPath = yield* Effect.promise(() => - makeMockDevinWrapper({ - T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", - T3_ACP_REQUEST_LOG_PATH: requestLogPath, - }), - ); - const adapter = yield* makeTestAdapter(wrapperPath); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const firstTurnStarted = yield* Deferred.make(); - const twoTurnsCompleted = yield* Deferred.make(); - const completedCountRef = yield* Ref.make(0); - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.gen(function* () { - runtimeEvents.push(event); - if (String(event.threadId) !== String(threadId)) { - return; - } - if (event.type === "turn.started" && event.turnId !== undefined) { - yield* Deferred.succeed(firstTurnStarted, event.turnId).pipe(Effect.ignore); - return; - } - if (event.type !== "turn.completed") { - return; - } - const completedCount = yield* Ref.updateAndGet(completedCountRef, (count) => count + 1); - if (completedCount === 2) { - yield* Deferred.succeed(twoTurnsCompleted, undefined); - } - }), - ).pipe(Effect.forkChild); - - yield* adapter.startSession({ - threadId, - provider: ProviderDriverKind.make("devin"), - cwd: process.cwd(), - runtimeMode: "full-access", - }); - - const firstSendTurnFiber = yield* adapter - .sendTurn({ threadId, input: "cancel this prompt", attachments: [] }) - .pipe(Effect.forkChild); - const firstTurnId = yield* Deferred.await(firstTurnStarted).pipe(Effect.timeout("2 seconds")); - yield* waitForFileContent(requestLogPath, 80, '"method":"session/prompt"'); - - yield* adapter.interruptTurn(threadId, firstTurnId).pipe(Effect.timeout("2 seconds")); - const followUp = yield* adapter - .sendTurn({ threadId, input: "complete the follow-up", attachments: [] }) - .pipe(Effect.timeout("2 seconds")); - yield* Fiber.join(firstSendTurnFiber).pipe(Effect.timeout("2 seconds")); - yield* Deferred.await(twoTurnsCompleted).pipe(Effect.timeout("2 seconds")); - - const turnCompletedEvents = runtimeEvents.filter( - (event): event is Extract => - event.type === "turn.completed" && String(event.threadId) === String(threadId), - ); - const readySessions = yield* adapter.listSessions(); - const readySession = readySessions.find((session) => session.threadId === threadId); - - assert.notEqual(String(followUp.turnId), String(firstTurnId)); - assert.deepEqual( - turnCompletedEvents.map((event) => [String(event.turnId), event.payload.state]), - [ - [String(firstTurnId), "cancelled"], - [String(followUp.turnId), "completed"], - ], - ); - assert.equal(readySession?.status, "ready"); - assert.isUndefined(readySession?.activeTurnId); - - yield* Fiber.interrupt(runtimeEventsFiber); - yield* adapter.stopSession(threadId); - }).pipe(TestClock.withLive), - ); - - it.effect("drops late ACP notifications after a turn is cancelled", () => - Effect.gen(function* () { - const threadId = ThreadId.make("devin-drop-late-cancelled-notifications"); - const wrapperPath = yield* Effect.promise(() => - makeMockDevinWrapper({ - T3_ACP_HANG_PROMPT_FOREVER: "1", - T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL: "1", - }), - ); - const lateNativeUpdate = yield* Deferred.make(); - const adapter = yield* makeTestAdapter(wrapperPath, { - nativeEventLogger: { - filePath: "memory://devin-cancelled-native-events", - write: (record: unknown) => - JSON.stringify(record).includes("late after cancel") - ? Deferred.succeed(lateNativeUpdate, undefined).pipe(Effect.asVoid) - : Effect.void, - close: () => Effect.void, - }, - }); - - const runtimeEvents: ProviderRuntimeEvent[] = []; - const turnStarted = yield* Deferred.make(); - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => { - runtimeEvents.push(event); - }).pipe( - Effect.andThen( - event.type === "turn.started" && - event.turnId !== undefined && - String(event.threadId) === String(threadId) - ? Deferred.succeed(turnStarted, event.turnId).pipe(Effect.asVoid) - : Effect.void, - ), - ), - ).pipe(Effect.forkChild); - - yield* adapter.startSession({ - threadId, - provider: ProviderDriverKind.make("devin"), - cwd: process.cwd(), - runtimeMode: "full-access", - }); - - const sendTurnFiber = yield* adapter - .sendTurn({ threadId, input: "cancel before the late update", attachments: [] }) - .pipe(Effect.forkChild); - const turnId = yield* Deferred.await(turnStarted).pipe(Effect.timeout("2 seconds")); - yield* adapter.interruptTurn(threadId, turnId).pipe(Effect.timeout("2 seconds")); - yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("2 seconds")); - yield* Deferred.await(lateNativeUpdate).pipe(Effect.timeout("2 seconds")); - for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { - yield* Effect.yieldNow; - } - - const cancelledIndex = runtimeEvents.findIndex( - (event) => - event.type === "turn.completed" && - String(event.threadId) === String(threadId) && - String(event.turnId) === String(turnId) && - event.payload.state === "cancelled", - ); - const turnOutputTypes = new Set([ - "content.delta", - "item.started", - "item.updated", - "item.completed", - "turn.plan.updated", - ]); - const outputAfterCancellation = runtimeEvents - .slice(cancelledIndex + 1) - .filter( - (event) => String(event.threadId) === String(threadId) && turnOutputTypes.has(event.type), - ); - - assert.isAtLeast(cancelledIndex, 0); - assert.deepEqual(outputAfterCancellation, []); - - yield* Fiber.interrupt(runtimeEventsFiber); - yield* adapter.stopSession(threadId); - }).pipe(TestClock.withLive), - ); - - it.effect("settles the in-flight prompt before emitting completion", () => - Effect.gen(function* () { - const threadId = ThreadId.make("devin-completion-before-next-turn"); - const wrapperPath = yield* Effect.promise(() => makeMockDevinWrapper()); - const adapter = yield* makeTestAdapter(wrapperPath); - const completedCountRef = yield* Ref.make(0); - const secondTurnCompleted = yield* Deferred.make(); - - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { - if (event.type !== "turn.completed" || String(event.threadId) !== String(threadId)) { - return Effect.void; - } - - return Ref.modify(completedCountRef, (count) => { - const nextCount = count + 1; - return [nextCount, nextCount] as const; - }).pipe( - Effect.flatMap((count) => { - if (count === 1) { - return adapter - .sendTurn({ - threadId, - input: "second turn after completion", - attachments: [], - }) - .pipe(Effect.forkChild, Effect.asVoid); - } - if (count === 2) { - return Deferred.succeed(secondTurnCompleted, undefined).pipe(Effect.asVoid); - } - return Effect.void; - }), - ); - }).pipe(Effect.forkChild); - - yield* adapter.startSession({ - threadId, - provider: ProviderDriverKind.make("devin"), - cwd: process.cwd(), - runtimeMode: "full-access", - }); - - yield* adapter.sendTurn({ - threadId, - input: "first turn", - attachments: [], - }); - yield* Deferred.await(secondTurnCompleted); - - const completedCount = yield* Ref.get(completedCountRef); - const readySessions = yield* adapter.listSessions(); - const readySession = readySessions.find((session) => session.threadId === threadId); - - assert.equal(completedCount, 2); - assert.equal(readySession?.status, "ready"); - assert.isUndefined(readySession?.activeTurnId); - - yield* Fiber.interrupt(runtimeEventsFiber); - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => - Effect.gen(function* () { - const threadId = ThreadId.make("devin-steer-thread"); - const wrapperPath = yield* Effect.promise(() => - makeMockDevinWrapper({ T3_ACP_PROMPT_DELAY_MS: "750" }), - ); - const adapter = yield* makeTestAdapter(wrapperPath); - - const runtimeEventsFiber = yield* adapter.streamEvents.pipe( - Stream.filter((event) => event.threadId === threadId), - Stream.takeUntil((event) => event.type === "turn.completed"), - Stream.runCollect, - Effect.forkChild, - ); - - yield* adapter.startSession({ - threadId, - provider: ProviderDriverKind.make("devin"), - cwd: process.cwd(), - runtimeMode: "full-access", - }); - - const firstTurnFiber = yield* adapter - .sendTurn({ - threadId, - input: "run 5 commands", - attachments: [], - }) - .pipe(Effect.forkChild); - - yield* Effect.gen(function* () { - for (let attempt = 0; attempt < 100; attempt += 1) { - const sessions = yield* adapter.listSessions(); - const session = sessions.find((entry) => entry.threadId === threadId); - if (session?.activeTurnId !== undefined) { - return; - } - yield* Effect.sleep("20 millis"); - } - throw new Error("Timed out waiting for the first prompt to be in flight."); - }); - - const steeredTurn = yield* adapter.sendTurn({ - threadId, - input: "actually run 15", - attachments: [], - }); - const firstTurn = yield* Fiber.join(firstTurnFiber); - assert.equal(String(steeredTurn.turnId), String(firstTurn.turnId)); - - const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); - const turnStartedEvents = runtimeEvents.filter((event) => event.type === "turn.started"); - const turnCompletedEvents = runtimeEvents.filter((event) => event.type === "turn.completed"); - - assert.equal(turnStartedEvents.length, 1); - assert.equal(String(turnStartedEvents[0]?.turnId), String(firstTurn.turnId)); - assert.equal(turnCompletedEvents.length, 1); - assert.equal(String(turnCompletedEvents[0]?.turnId), String(firstTurn.turnId)); - - yield* adapter.stopSession(threadId); - }).pipe(TestClock.withLive), - ); - - it.effect("restores a Devin session to ready when the prompt RPC fails", () => - Effect.gen(function* () { - const threadId = ThreadId.make("devin-prompt-failure-ready"); - const wrapperPath = yield* Effect.promise(() => - makeMockDevinWrapper({ - T3_ACP_FAIL_PROMPT: "1", - }), - ); - const adapter = yield* makeTestAdapter(wrapperPath); - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => { - runtimeEvents.push(event); - }), - ).pipe(Effect.forkChild); - - yield* adapter.startSession({ - threadId, - provider: ProviderDriverKind.make("devin"), - cwd: process.cwd(), - runtimeMode: "full-access", - }); - - const error = yield* Effect.flip( - adapter.sendTurn({ - threadId, - input: "fail prompt", - attachments: [], - }), - ); - const readySessions = yield* adapter.listSessions(); - const readySession = readySessions.find((session) => session.threadId === threadId); - const failedTurnCompleted = runtimeEvents.find( - (event) => event.type === "turn.completed" && event.threadId === threadId, - ); - - assert.equal(error._tag, "ProviderAdapterRequestError"); - assert.equal(readySession?.status, "ready"); - assert.isUndefined(readySession?.activeTurnId); - assert.equal(failedTurnCompleted?.type, "turn.completed"); - if (failedTurnCompleted?.type === "turn.completed") { - assert.equal(failedTurnCompleted.payload.state, "failed"); - assert.isString(failedTurnCompleted.payload.errorMessage); - } - - yield* Fiber.interrupt(runtimeEventsFiber); - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("ignores replayed session/load updates when resuming a Devin session", () => - Effect.gen(function* () { - const threadId = ThreadId.make("devin-load-replay-filter"); - const wrapperPath = yield* Effect.promise(() => - makeMockDevinWrapper({ - T3_ACP_EMIT_LOAD_REPLAY: "1", - }), - ); - const adapter = yield* makeTestAdapter(wrapperPath); - const runtimeEvents: ProviderRuntimeEvent[] = []; - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => { - runtimeEvents.push(event); - }), - ).pipe(Effect.forkChild); - - const session = yield* adapter.startSession({ - threadId, - provider: ProviderDriverKind.make("devin"), - cwd: process.cwd(), - runtimeMode: "full-access", - resumeCursor: { schemaVersion: 1, sessionId: "mock-session-1" }, - }); - - yield* adapter.sendTurn({ - threadId, - input: "after resume", - attachments: [], - }); - - assert.deepStrictEqual(session.resumeCursor, { - schemaVersion: 1, - sessionId: "mock-session-1", - }); - assert.isFalse( - runtimeEvents.some( - (event) => event.type === "item.completed" && event.payload.title === "Replay tool", - ), - ); - assert.isFalse( - runtimeEvents.some( - (event) => - event.type === "content.delta" && event.payload.delta === "replayed assistant text", - ), - ); - - yield* Fiber.interrupt(runtimeEventsFiber); - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("responds to ACP approvals using provider-supplied option ids", () => - Effect.gen(function* () { - const threadId = ThreadId.make("devin-custom-approval-option-id"); - const tempDir = yield* Effect.promise(() => - NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-")), - ); - const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); - const wrapperPath = yield* Effect.promise(() => - makeMockDevinWrapper({ - T3_ACP_REQUEST_LOG_PATH: requestLogPath, - T3_ACP_EMIT_TOOL_CALLS: "1", - T3_ACP_ALLOW_ONCE_OPTION_ID: "agent-defined-approval-id", - }), - ); - const adapter = yield* makeTestAdapter(wrapperPath); - const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - event.type === "request.opened" - ? adapter.respondToRequest( - threadId, - ApprovalRequestId.make(String(event.requestId)), - "accept", - ) - : Effect.void, - ).pipe(Effect.forkChild); - - yield* adapter.startSession({ - threadId, - provider: ProviderDriverKind.make("devin"), - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - yield* adapter.sendTurn({ threadId, input: "approve this", attachments: [] }); - - const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); - assert.isTrue( - requests.some( - (entry) => - !("method" in entry) && - typeof entry.result === "object" && - entry.result !== null && - "outcome" in entry.result && - typeof entry.result.outcome === "object" && - entry.result.outcome !== null && - "optionId" in entry.result.outcome && - entry.result.outcome.optionId === "agent-defined-approval-id", - ), - ); - - yield* Fiber.interrupt(eventsFiber); - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("stopping a session settles pending approval waits", () => - Effect.gen(function* () { - const threadId = ThreadId.make("devin-stop-pending-approval"); - const requestOpened = yield* Deferred.make(); - const wrapperPath = yield* Effect.promise(() => - makeMockDevinWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), - ); - const adapter = yield* makeTestAdapter(wrapperPath); - const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { - if (String(event.threadId) !== String(threadId) || event.type !== "request.opened") { - return Effect.void; - } - return Deferred.succeed(requestOpened, undefined).pipe(Effect.ignore); - }).pipe(Effect.forkChild); - - yield* adapter.startSession({ - threadId, - provider: ProviderDriverKind.make("devin"), - cwd: process.cwd(), - runtimeMode: "approval-required", - }); - - const sendTurnFiber = yield* adapter - .sendTurn({ - threadId, - input: "run a tool call and then stop", - attachments: [], - }) - .pipe(Effect.forkChild); - - yield* Deferred.await(requestOpened); - yield* adapter.stopSession(threadId); - yield* Fiber.await(sendTurnFiber).pipe(Effect.timeout("2 seconds")); - - assert.equal(yield* adapter.hasSession(threadId), false); - - yield* Fiber.interrupt(eventsFiber); - }), - ); - - it.effect("continues streaming events when native notification logging fails", () => - Effect.gen(function* () { - const threadId = ThreadId.make("devin-native-log-failure"); - const wrapperPath = yield* Effect.promise(() => makeMockDevinWrapper()); - const adapter = yield* makeTestAdapter(wrapperPath, { - nativeEventLogger: { - filePath: "memory://devin-native-events", - write: (record: unknown) => - typeof record === "object" && - record !== null && - "event" in record && - typeof record.event === "object" && - record.event !== null && - "kind" in record.event && - record.event.kind === "notification" - ? Effect.die(new Error("native log write failed")) - : Effect.void, - close: () => Effect.void, - }, - }); - const contentDelta = yield* Deferred.make(); - const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - event.type === "content.delta" ? Deferred.succeed(contentDelta, undefined) : Effect.void, - ).pipe(Effect.forkChild); - - yield* adapter.startSession({ - threadId, - provider: ProviderDriverKind.make("devin"), - cwd: process.cwd(), - runtimeMode: "full-access", - }); - yield* adapter.sendTurn({ threadId, input: "keep streaming", attachments: [] }); - yield* Deferred.await(contentDelta); - - yield* Fiber.interrupt(eventsFiber); - yield* adapter.stopSession(threadId); - }), - ); }); diff --git a/apps/server/src/provider/ProviderModelDiscoveryCache.test.ts b/apps/server/src/provider/ProviderModelDiscoveryCache.test.ts index 2f1a8dcb9ee..7989b2f0920 100644 --- a/apps/server/src/provider/ProviderModelDiscoveryCache.test.ts +++ b/apps/server/src/provider/ProviderModelDiscoveryCache.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import type { ServerProviderModel } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import { makeProviderModelDiscoveryCache } from "./ProviderModelDiscoveryCache.ts"; @@ -50,4 +51,25 @@ describe("ProviderModelDiscoveryCache", () => { }), ), ); + + it.effect("schedules a provider refresh when real session discovery records models", () => + Effect.scoped( + Effect.gen(function* () { + const cache = yield* makeProviderModelDiscoveryCache(); + const refreshed = yield* Deferred.make(); + const model = { + slug: "devin-model", + name: "Devin Model", + isCustom: false, + capabilities: null, + } satisfies ServerProviderModel; + + yield* cache.setRefresh(Deferred.succeed(refreshed, undefined).pipe(Effect.asVoid)); + yield* cache.recordModels([model]); + yield* Deferred.await(refreshed); + + expect(yield* cache.getModels).toEqual([model]); + }), + ), + ); });