diff --git a/README.md b/README.md index 27b5dc491693..cde3a14757e5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). -Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, and Google Antigravity. If they're set up on your computer, T3 Code can control them. +Works with your subscriptions on Claude Code, Codex, Cursor, Devin, Grok Build, OpenCode, and Google Antigravity. If they're set up on your computer, T3 Code can control them. ## "Wait, what are you selling me?" @@ -13,11 +13,12 @@ We wanted something performant, remote-ready, and truly open. If we ever go the ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, and Antigravity. Install and authenticate at least one provider before use: +> T3 Code currently supports Codex, Claude, Cursor, Devin, Grok Build, OpenCode, and Antigravity. Install and authenticate at least one provider before use: > > - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` > - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` > - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login` +> - Devin: install [Devin CLI](https://docs.devin.ai/work-with-devin/devin-cli) and run `devin auth login` > - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login` > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` > - Antigravity: enable it in Settings, then use **Install Antigravity** and **Sign in with Google**. No CLI is required. diff --git a/apps/mobile/assets/devin-logo.webp b/apps/mobile/assets/devin-logo.webp new file mode 100644 index 000000000000..8097ad8657a6 Binary files /dev/null and b/apps/mobile/assets/devin-logo.webp differ diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 8ad497c9a79b..86f299b72f4c 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -1,9 +1,13 @@ import { Image } from "expo-image"; -import { Path, Svg } from "react-native-svg"; +import { Image as NativeImage } from "react-native"; +import { Circle, Path, Rect, Svg } from "react-native-svg"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +const DEVIN_LOGO = require("../../assets/devin-logo.webp") as number; + type ProviderIconProps = { readonly provider: string | null | undefined; + readonly model?: string | null | undefined; readonly size?: number; }; @@ -12,6 +16,65 @@ export function ProviderIcon(props: ProviderIconProps) { const isDarkMode = themeAppearance === "dark"; const size = props.size ?? 16; const mono = isDarkMode ? "#e5e5e5" : "#171717"; + const modelIdentity = props.model?.toLowerCase() ?? ""; + + if (props.provider === "devin" && modelIdentity) { + if (modelIdentity.includes("claude")) { + return ; + } + if (modelIdentity.includes("gpt") || modelIdentity.includes("codex")) { + return ; + } + if (modelIdentity.includes("grok")) { + return ; + } + if (modelIdentity.includes("gemini") || modelIdentity.includes("google")) { + return ( + + + + ); + } + if ( + modelIdentity.includes("glm") || + modelIdentity.includes("zhipu") || + modelIdentity.includes("z.ai") + ) { + return ( + + + + + ); + } + const brand = modelIdentity.includes("kimi") + ? { color: "#6D5DFB", path: "M15.8 5.9a7 7 0 1 0 2.3 11.7 6.2 6.2 0 1 1-2.3-11.7Z" } + : modelIdentity.includes("deepseek") + ? { + color: "#4D6BFE", + path: "M5.5 13.8c2.2.2 3.5-.4 4.5-1.8 1 1.8 2.8 2.7 5.2 2.4 1.3-.2 2.4-.8 3.3-1.8-.3 3.3-2.9 5.5-6.4 5.5-3.4 0-5.9-1.6-6.6-4.3Z", + } + : modelIdentity.includes("nemotron") + ? { + color: "#76B900", + path: "M5.2 11.9c2.8-3.5 7.6-4.6 11.8-2.5-2.9-.4-5.6.3-7.3 2 1.4-1.1 3.8-1.4 5.5-.2 1.3.9 1.6 2.5.7 3.6-.8 1-2.4 1.2-3.5.5-1.7-1-1.6-3.5.2-4.3 2.2-1 4.8.7 4.8 3.2 0 1.6-.8 3-2 3.9-3.8 1.4-8.2-.5-9.5-4.3Z", + } + : null; + if (brand) { + return ( + + + + + ); + } + } if (props.provider?.trim().toLowerCase() === "antigravity") { return ( @@ -61,6 +124,21 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "devin") { + return ( + + ); + } + if (props.provider === "opencode") { return ( diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 5c05d7482cc4..33909d5a6987 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1353,6 +1353,11 @@ export function NewTaskDraftScreen(props: { iconNode={ } diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 684b69c843a5..3085463cdc92 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -765,7 +765,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer accessibilityLabel="Model and reasoning settings" emphasized iconNode={ - + } label={currentModelOption?.label ?? currentModelSelection.model} maxWidth={152} diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 24cdb0cc4546..47eeda87da74 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -120,6 +120,11 @@ function ModelRow(props: { props.isLast ? "rounded-b-2xl" : "border-b border-border-subtle", )} > + | null>(null); - const { merged, environments, selectedEnvironments, isPending, refresh } = useUsage( + const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage( window, selectedEnvironmentIds, ); @@ -259,8 +260,6 @@ export function UsageRouteScreen() { /> ) : ( <> - {/* Period and metric together: neither applies to Limits, and - both change every number below, so they share one bar. */} - {merged.duplicateSources.length > 0 ? ( - - Counted once across environments sharing a transcript directory:{" "} - {merged.duplicateSources.join(", ")} - + + + {merged.accountUsage !== null ? ( + ) : null} {isPending ? ( @@ -318,6 +320,41 @@ export function UsageRouteScreen() { ); } +function formatAcus(value: number): string { + return new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(value); +} + +function DevinAccountUsageSection({ usage }: { readonly usage: UsageAccountConsumption }) { + const message = + usage.status === "notConfigured" + ? "Optional account ACU data is not configured on the server." + : usage.status === "forbidden" + ? "The configured Devin key cannot read organization consumption." + : usage.status === "failed" + ? (usage.message ?? "Devin account consumption could not be loaded.") + : null; + + return ( + + + {usage.status === "available" ? ( + + {formatAcus(usage.totalAcus)} ACUs + + ) : null} + + {message ?? "Official organization consumption for this window."} + + {usage.status === "available" ? ( + + {usage.days.length} billing days returned · Source: {usage.source} + + ) : null} + + + ); +} + function SegmentedControl(props: { readonly options: readonly { readonly value: Value; @@ -641,3 +678,54 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string { return environment.summary ? "Updating usage…" : "Loading usage…"; return "Usage up to date"; } + +/** + * Says plainly when the totals are incomplete: an environment still answering, + * one that failed, or one whose transcripts another environment already + * reported. + */ +function UsageCoverageNotice(props: { + readonly environments: readonly EnvironmentUsageStatus[]; + readonly merged: MergedUsage; + readonly isPartial: boolean; +}) { + const failed = props.environments.filter((environment) => environment.error !== null); + const stale = props.environments.filter((environment) => + props.merged.staleEnvironments.includes(environment.environmentId), + ); + const duplicateSources = props.merged.duplicateSources; + if ( + failed.length === 0 && + stale.length === 0 && + duplicateSources.length === 0 && + !props.isPartial + ) { + return null; + } + + return ( + + {props.isPartial ? ( + + Some environments are still reporting. Totals are partial. + + ) : null} + {failed.map((environment) => ( + + {environment.label} could not report usage. + + ))} + {stale.map((environment) => ( + + {environment.label} runs an older server version and is excluded from totals. + + ))} + {duplicateSources.length > 0 ? ( + + Counted once across environments sharing a transcript directory:{" "} + {duplicateSources.join(", ")} + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index 2576ac21fb07..84794b61aa0e 100644 --- a/apps/mobile/src/features/usage/usageProviders.ts +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -5,17 +5,20 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe * Series and table order. The chart stacks providers from the bottom in this * order, so it also fixes which band sits on top of the bars. */ -export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok"]; +export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok", "devin"]; export const PROVIDER_LABEL: Record = { claude: "Claude Code", codex: "Codex", grok: "Grok Build", + devin: "Devin ACP", }; /** * Claude's brand orange holds in both themes; Codex and Grok are neutrals and * must flip with the theme or their bars vanish against the matching background. + * Devin uses its indigo accent in both themes so its provider band remains + * distinct from the neutral providers. */ export function useProviderColors(): Record { const { themeAppearance: scheme } = useAppearancePreferences(); @@ -23,5 +26,6 @@ export function useProviderColors(): Record { claude: "#d97757", codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", grok: scheme === "dark" ? "#a1a1aa" : "#52525b", + devin: "#6b7cff", }; } diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index dde67773d948..772a80db616e 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -60,6 +60,37 @@ const nativeQuestion = { allowCustomAnswer: false, } as const; +describe("pending approvals", () => { + it("preserves Devin's advertised options without adding unavailable session actions", () => { + const options = [ + { decision: "accept", label: "Allow once" }, + { decision: "decline", label: "Reject" }, + { decision: "cancel", label: "Cancel" }, + ]; + const requested = makeActivity({ + id: EventId.make("devin-permission"), + kind: "approval.requested", + summary: "Command approval requested", + createdAt: "2026-09-08T00:00:00.000Z", + tone: "approval", + payload: { + requestId: "devin-permission", + requestType: "command_execution_approval", + options, + }, + }); + + expect(derivePendingRequests([requested]).approvals).toEqual([ + { + requestId: "devin-permission", + requestKind: "command", + createdAt: requested.createdAt, + options, + }, + ]); + }); +}); + describe("pending user input answers", () => { it("accepts free-text answers to async questions without options", () => { const question = { @@ -274,6 +305,39 @@ function makeThread( } describe("buildThreadFeed", () => { + it("projects canonical user-input activity into the existing message row", () => { + const thread = makeThread({ + id: ThreadId.make("thread-user-input-activity"), + projectId: ProjectId.make("project-1"), + title: "User input activity", + activities: [ + makeActivity({ + id: EventId.make("user-input-requested"), + kind: "user-input.requested", + summary: "User input requested", + createdAt: "2026-09-01T00:00:00.000Z", + payload: { + requestId: "req-user-input", + questions: [singleSelectQuestion], + }, + }), + ], + }); + + expect(buildThreadFeed(thread)).toMatchObject([ + { + type: "activity-group", + activities: [ + { + id: "user-input-requested", + icon: "message", + workEntry: { sourceActivityKind: "user-input.requested" }, + }, + ], + }, + ]); + }); + it("reuses unchanged feed and presentation rows during an assistant text update", () => { const completedTurnId = TurnId.make("completed-turn"); const activeTurnId = TurnId.make("active-turn"); @@ -1467,6 +1531,57 @@ describe("buildThreadFeed", () => { ]); }); + it("shows canonical ACP resource URI and text in expanded generic tool activities", () => { + const data = { + toolCallId: "devin-resource-tool", + kind: "other", + resource: { + uri: "urn:acp:fixture:resource-link", + name: "schema fixture", + description: "typed protocol fixture", + mimeType: "text/markdown", + text: "Embedded resource notes", + }, + content: [ + { + type: "content", + content: { type: "text", text: "ordinary tool output" }, + }, + ], + }; + const thread = makeThread({ + id: ThreadId.make("thread-devin-resource"), + projectId: ProjectId.make("project-1"), + title: "Devin resource", + activities: [ + makeActivity({ + id: EventId.make("devin-resource-tool"), + kind: "tool.completed", + tone: "tool", + summary: "Resource fixture", + createdAt: "2026-09-07T00:00:00.000Z", + payload: { + itemType: "dynamic_tool_call", + status: "completed", + title: "Resource fixture", + data, + }, + }), + ], + }); + + const [group] = buildThreadFeed(thread); + expect(group).toMatchObject({ + type: "activity-group", + activities: [{ workEntry: { toolData: data } }], + }); + expect(group?.type).toBe("activity-group"); + if (group?.type !== "activity-group") return; + expect(group.activities[0]?.canExpand).toBe(true); + expect(group.activities[0]?.getFullDetail()).toContain("urn:acp:fixture:resource-link"); + expect(group.activities[0]?.getFullDetail()).toContain("Embedded resource notes"); + }); + it.each([ { status: "completed", diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index f77f54cf625c..a50ccfb2dff3 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -30,7 +30,12 @@ import { type ToolGroupSummaryKind, type WorkLogToolLifecycleStatus, } from "@t3tools/client-runtime/work-log/presentation"; -import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; +import { + extractToolActivityData, + extractToolActivityPresentation, + hasToolActivityData, + toolActivityDataBody, +} from "@t3tools/client-runtime/work-log/tool-presentation"; import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; import * as Arr from "effect/Array"; @@ -553,12 +558,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (toolPresentation.toolSource) { entry.toolSource = toolPresentation.toolSource; } - if (itemType === "mcp_tool_call") { - const data = asRecord(payload?.data); - const toolData = typeof data?.toolName === "string" ? (data.item ?? data) : data?.item; - if (toolData !== undefined) { - entry.toolData = toolData; - } + const toolData = extractToolActivityData(payload); + if (toolData !== undefined) { + entry.toolData = toolData; } if (itemType) { entry.itemType = itemType; @@ -951,9 +953,7 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { } }; - if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) { - appendBlock(`MCP call\n${JSON.stringify(entry.toolData, null, 2)}`); - } + appendBlock(toolActivityDataBody(entry)); appendBlock(entry.rawCommand ?? entry.command); appendBlock(entry.detail); if ((entry.changedFiles?.length ?? 0) > 0) { @@ -970,7 +970,7 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { */ function workEntryCanExpand(entry: WorkLogEntry): boolean { if (entry.agentSpawn) return agentSpawnMembers(entry.agentSpawn).length > 0; - if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true; + if (hasToolActivityData(entry)) return true; if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true; return Boolean((entry.rawCommand ?? entry.command)?.trim() || entry.detail?.trim()); } diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index c923fa401021..82e3b23f1225 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -16,6 +16,7 @@ const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH; const antigravityProfile = process.env.T3_ACP_ANTIGRAVITY === "1"; const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; +const emitDevinResourceToolCall = process.env.T3_ACP_EMIT_DEVIN_RESOURCE_TOOL_CALL === "1"; const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; @@ -30,6 +31,7 @@ const emitXAiAskUserQuestionThenHang = const emitContentThenHang = process.env.T3_ACP_EMIT_CONTENT_THEN_HANG === "1"; const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1"; const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1"; +const emitUsageUpdate = process.env.T3_ACP_EMIT_USAGE_UPDATE === "1"; const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; const waitForResumeRelease = process.env.T3_ACP_WAIT_FOR_RESUME_RELEASE === "1"; const completeFirstPromptOnCancel = process.env.T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL === "1"; @@ -40,6 +42,7 @@ const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANC const omitXAiPromptCompleteStopReason = process.env.T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON === "1"; const failLoadSession = process.env.T3_ACP_FAIL_LOAD_SESSION === "1"; +const omitLoadSessionCapability = process.env.T3_ACP_OMIT_LOAD_SESSION_CAPABILITY === "1"; const emitLoadReplay = process.env.T3_ACP_EMIT_LOAD_REPLAY === "1"; const hangLoadSessionAfterReplay = process.env.T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY === "1"; const delayLoadSessionAfterReplay = process.env.T3_ACP_DELAY_LOAD_SESSION_AFTER_REPLAY === "1"; @@ -406,7 +409,10 @@ const program = Effect.gen(function* () { } return { protocolVersion: 1, - agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } }, + agentCapabilities: { + ...(omitLoadSessionCapability ? {} : { loadSession: true }), + sessionCapabilities: { resume: {} }, + }, // Grok advertises model state before any session exists; the provider // health check reads it from here without authenticating. _meta: { modelState: modelState() }, @@ -835,6 +841,69 @@ const program = Effect.gen(function* () { return yield* Effect.never; } + if (emitDevinResourceToolCall) { + const content = [ + { + type: "content", + content: { + type: "resource_link", + uri: "urn:acp:fixture:resource-link", + name: "schema fixture", + description: "typed protocol fixture", + mimeType: "text/markdown", + }, + }, + { + type: "content", + content: { + type: "resource_link", + uri: "", + name: "malformed-resource-link", + }, + }, + { + type: "content", + content: { + type: "resource", + resource: { + uri: "urn:acp:fixture:oversized-resource", + text: "x".repeat(8_001), + }, + }, + }, + { + type: "content", + content: { + type: "resource", + resource: { + uri: "urn:acp:fixture:binary-resource", + mimeType: "application/octet-stream", + blob: "opaque-binary-fixture", + }, + }, + }, + { + type: "content", + content: { + type: "text", + text: "ordinary tool output", + }, + }, + ] satisfies ReadonlyArray; + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "devin-resource-tool", + title: "Resource fixture", + kind: "other", + status: "completed", + content, + }, + }); + return { stopReason: "end_turn" }; + } + if (emitInterleavedAssistantToolCalls) { const toolCallId = "tool-call-1"; @@ -944,6 +1013,32 @@ const program = Effect.gen(function* () { description: index === 0 ? "Read package metadata" : "Read it again", }, content: [ + ...(process.env.T3_ACP_PERMISSION_RESOURCE === "1" + ? ([ + { + type: "content", + content: { + type: "resource", + resource: { uri: "urn:permission:notes", text: "Review these notes" }, + _meta: { secret: "permission-resource-metadata" }, + }, + }, + { + type: "content", + content: { + type: "resource", + resource: { uri: "urn:permission:binary", blob: "permission-binary" }, + }, + }, + { + type: "content", + content: { + type: "resource", + resource: { uri: "urn:permission:oversized", text: "x".repeat(8_001) }, + }, + }, + ] satisfies AcpSchema.ToolCallContent[]) + : []), { type: "content", content: { @@ -1227,7 +1322,34 @@ const program = Effect.gen(function* () { }, }); - return { stopReason: "end_turn" }; + if (emitUsageUpdate) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "usage_update", + used: + process.env.T3_ACP_COMPACT_USAGE === "1" ? (promptCount === 1 ? 600 : 100) : 12_000, + size: 200_000, + cost: { amount: 0.02, currency: "USD" }, + }, + }); + } + + return { + stopReason: "end_turn", + ...(emitUsageUpdate + ? { + usage: { + inputTokens: 1_000, + cachedReadTokens: 250, + cachedWriteTokens: 50, + outputTokens: 120, + thoughtTokens: 20, + totalTokens: 1_140, + }, + } + : {}), + }; }), ); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 1094ab48b7ac..d077486f7d73 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -3342,6 +3342,80 @@ describe("ProviderRuntimeIngestion", () => { expect(resolvedPayload?.requestType).toBe("command_execution_approval"); }); + it("persists MCP elicitation approvals identically for provider-neutral events", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const options = [ + { decision: "decline", label: "Decline" }, + { decision: "acceptAlways", label: "Always allow Safari" }, + { decision: "accept", label: "Approve" }, + ] as const; + const payload = { + requestType: "mcp_elicitation_approval", + detail: "Allow ChatGPT to use Safari?", + appName: "Safari", + options, + }; + + await harness.emitAndDrain([ + { + type: "request.opened", + eventId: asEventId("evt-codex-mcp-elicitation"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + requestId: ApprovalRequestId.make("req-codex-mcp-elicitation"), + payload, + }, + { + type: "request.opened", + eventId: asEventId("evt-devin-mcp-elicitation"), + provider: ProviderDriverKind.make("devin"), + createdAt: now, + threadId: asThreadId("thread-1"), + requestId: ApprovalRequestId.make("req-devin-mcp-elicitation"), + payload, + }, + ]); + + const thread = await waitForThread( + harness.readModel, + (entry) => + entry.activities.filter( + (activity: ProviderRuntimeTestActivity) => activity.kind === "approval.requested", + ).length === 2, + ); + const approvals = thread.activities + .filter((activity) => activity.kind === "approval.requested") + .sort((left, right) => String(left.id).localeCompare(String(right.id))); + + expect(approvals).toHaveLength(2); + expect(approvals[0]).toMatchObject({ + kind: "approval.requested", + summary: "App access approval requested", + payload: { + requestId: "req-codex-mcp-elicitation", + requestKind: "mcp-elicitation", + requestType: "mcp_elicitation_approval", + detail: "Allow ChatGPT to use Safari?", + appName: "Safari", + options, + }, + }); + expect(approvals[1]).toMatchObject({ + kind: "approval.requested", + summary: "App access approval requested", + payload: { + requestId: "req-devin-mcp-elicitation", + requestKind: "mcp-elicitation", + requestType: "mcp_elicitation_approval", + detail: "Allow ChatGPT to use Safari?", + appName: "Safari", + options, + }, + }); + }); + it("maps runtime.error into errored session state", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/provider/Drivers/DevinDriver.test.ts b/apps/server/src/provider/Drivers/DevinDriver.test.ts new file mode 100644 index 000000000000..2115b523bf5f --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinDriver.test.ts @@ -0,0 +1,128 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeFSP from "node:fs/promises"; +import { expect, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { DevinDriver } from "./DevinDriver.ts"; + +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +const testLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-devin-driver-skills-", +}).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + ), +); + +// The `#!/bin/sh` stub below cannot be resolved as an executable on Windows. +const windowsHost = HostProcessPlatform.defaultValue() === "win32"; + +const makeSkillsBinary = Effect.fn("makeDevinSkillsBinary")(function* (options: { + readonly skillsJson: string; + readonly skillsExitCode?: number; +}) { + const dir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-driver-skills-")), + ); + const binaryPath = NodePath.join(dir, "fake-devin.sh"); + const skillsJsonPath = NodePath.join(dir, "skills.json"); + yield* Effect.promise(() => NodeFSP.writeFile(skillsJsonPath, options.skillsJson, "utf8")); + const script = `#!/bin/sh +if [ "$1" = "--version" ]; then + echo "devin 1.0.0" + exit 0 +fi +if [ "$1" = "skills" ]; then + cat '${skillsJsonPath}' + exit ${options.skillsExitCode ?? 0} +fi +echo "unexpected command: $*" >&2 +exit 1 +`; + yield* Effect.promise(() => NodeFSP.writeFile(binaryPath, script, "utf8")); + yield* Effect.promise(() => NodeFSP.chmod(binaryPath, 0o755)); + return binaryPath; +}); + +const createInstance = (binaryPath: string, enabled: boolean) => + DevinDriver.create({ + instanceId: ProviderInstanceId.make("devin-skills-test"), + displayName: "Devin skills test", + enabled, + environment: [], + config: { ...DevinDriver.defaultConfig(), binaryPath, enabled }, + }); + +it.layer(testLayer)("DevinDriver snapshotForCwd", (it) => { + it.effect.skipIf(windowsHost)( + "returns the normal snapshot without discovery when Devin is disabled", + () => + Effect.gen(function* () { + const noSpawn = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.die("Disabled Devin must not spawn a skills process"), + ), + ); + const instance = yield* createInstance("devin-unused", false).pipe(Effect.provide(noSpawn)); + const snapshot = yield* instance.snapshotForCwd!("/workspace"); + + expect(snapshot.skills).toEqual([]); + expect(snapshot.enabled).toBe(false); + }), + ); + + it.effect.skipIf(windowsHost)("includes discovered skills in the workspace snapshot", () => + Effect.gen(function* () { + const binaryPath = yield* makeSkillsBinary({ + skillsJson: encodeUnknownJson([ + { + name: "deploy", + base_dir: "/tmp/skills/deploy", + description: "Deploy the app.", + triggers: ["user", "model"], + }, + ]), + }); + const instance = yield* createInstance(binaryPath, true); + const snapshot = yield* instance.snapshotForCwd!("/workspace"); + + expect(snapshot.skills).toHaveLength(1); + expect(snapshot.skills[0]).toMatchObject({ name: "deploy", enabled: true }); + }), + ); + + it.effect.skipIf(windowsHost)( + "fails with a typed error so the registry keeps the last valid snapshot", + () => + Effect.gen(function* () { + const binaryPath = yield* makeSkillsBinary({ + skillsJson: "[]", + skillsExitCode: 3, + }); + const instance = yield* createInstance(binaryPath, true); + const exit = yield* Effect.exit(instance.snapshotForCwd!("/workspace")); + + expect(Exit.isFailure(exit)).toBe(true); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/DevinDriver.ts b/apps/server/src/provider/Drivers/DevinDriver.ts new file mode 100644 index 000000000000..43b30f13b0c8 --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinDriver.ts @@ -0,0 +1,161 @@ +import { DevinSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeDevinTextGeneration } from "../../textGeneration/DevinTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeDevinAdapter } from "../Layers/DevinAdapter.ts"; +import { + buildInitialDevinProviderSnapshot, + checkDevinProviderStatus, +} from "../Layers/DevinProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { discoverDevinSkills } from "./DevinSkills.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeDevinSettings = Schema.decodeSync(DevinSettings); +const DRIVER_KIND = ProviderDriverKind.make("devin"); +const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, +}); + +export type DevinDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const DevinDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Devin", + supportsMultipleInstances: true, + }, + configSchema: DevinSettings, + defaultConfig: (): DevinSettings => decodeDevinSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const path = yield* Path.Path; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies DevinSettings; + + const adapter = yield* makeDevinAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeDevinTextGeneration(effectiveConfig, processEnv); + const checkProvider = checkDevinProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES), + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialDevinProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Devin snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + const snapshotForCwd = (workspaceCwd: string) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : Effect.all([ + snapshot.getSnapshot, + discoverDevinSkills(effectiveConfig, processEnv, workspaceCwd).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to discover Devin skills for '${workspaceCwd}'`, + cause, + }), + ), + ), + ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + snapshotForCwd, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/DevinSkillDispatch.test.ts b/apps/server/src/provider/Drivers/DevinSkillDispatch.test.ts new file mode 100644 index 000000000000..e5135e032826 --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinSkillDispatch.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { planDevinSkillDispatch } from "./DevinSkillDispatch.ts"; + +const SKILLS = new Set(["2spec", "deploy", "implement", "review", "re-release-version"]); + +describe("planDevinSkillDispatch", () => { + it("leaves a prompt without a known skill untouched", () => { + expect(planDevinSkillDispatch("fix the build", SKILLS)).toBeUndefined(); + // Not a discovered skill, so it stays prose rather than becoming an invocation. + expect(planDevinSkillDispatch("echo $HOME then $unknown", SKILLS)).toBeUndefined(); + }); + + it("rewrites a known token into Devin's native mention", () => { + expect(planDevinSkillDispatch("$deploy", SKILLS)).toEqual({ + prompt: "@skills:deploy", + skillName: "deploy", + }); + }); + + it("preserves trailing arguments after the token", () => { + expect(planDevinSkillDispatch("$deploy staging", SKILLS)).toEqual({ + prompt: "@skills:deploy staging", + skillName: "deploy", + }); + }); + + it("preserves surrounding text around a mid-prompt mention", () => { + expect(planDevinSkillDispatch("ok, now $deploy all the tickets", SKILLS)).toEqual({ + prompt: "ok, now @skills:deploy all the tickets", + skillName: "deploy", + }); + }); + + it("preserves a mention that opens the prompt", () => { + expect(planDevinSkillDispatch("$review\nfocus on auth", SKILLS)).toEqual({ + prompt: "@skills:review\nfocus on auth", + skillName: "review", + }); + }); + + it("rewrites multiple mentions and keeps the last name for diagnostics", () => { + expect(planDevinSkillDispatch("$review the diff, then $deploy the fixes", SKILLS)).toEqual({ + prompt: "@skills:review the diff, then @skills:deploy the fixes", + skillName: "deploy", + }); + }); + + it("preserves unknown tokens and $HOME", () => { + expect(planDevinSkillDispatch("echo $HOME then $deploy", SKILLS)).toEqual({ + prompt: "echo $HOME then @skills:deploy", + skillName: "deploy", + }); + expect(planDevinSkillDispatch("echo $HOME", SKILLS)).toBeUndefined(); + expect(planDevinSkillDispatch("echo $unknown then $deploy", SKILLS)).toEqual({ + prompt: "echo $unknown then @skills:deploy", + skillName: "deploy", + }); + }); + + it("does not infer a skill from a path-like token", () => { + // A filesystem path is never a skill mention, even when a segment matches. + expect(planDevinSkillDispatch("check src/$deploy/config.ts", SKILLS)).toBeUndefined(); + }); + + it("ignores a dollar token glued to other text", () => { + expect(planDevinSkillDispatch("cost is 5$deploy", SKILLS)).toBeUndefined(); + }); + + it("ignores currency amounts and compact monetary expressions", () => { + const skillsWithCurrency = new Set([...SKILLS, "20", "20k", "100M"]); + expect(planDevinSkillDispatch("pay $20 tomorrow", skillsWithCurrency)).toBeUndefined(); + expect(planDevinSkillDispatch("budget is $20k tomorrow", skillsWithCurrency)).toBeUndefined(); + }); + + it("dispatches a known skill whose name begins with a digit", () => { + expect(planDevinSkillDispatch("use $2spec for this", SKILLS)).toEqual({ + prompt: "use @skills:2spec for this", + skillName: "2spec", + }); + }); + + it("dispatches a hyphenated skill name", () => { + expect(planDevinSkillDispatch("run $re-release-version now", SKILLS)).toEqual({ + prompt: "run @skills:re-release-version now", + skillName: "re-release-version", + }); + }); +}); diff --git a/apps/server/src/provider/Drivers/DevinSkillDispatch.ts b/apps/server/src/provider/Drivers/DevinSkillDispatch.ts new file mode 100644 index 000000000000..7fac9d846c4e --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinSkillDispatch.ts @@ -0,0 +1,78 @@ +/** + * DevinSkillDispatch — turns `$skill` mentions in a composer prompt into the + * `@skills:name` invocation Devin actually runs. + * + * The composer inserts `$name` for every provider. Devin does not parse that + * token; Devin's documented user-side invocation is `@skills:` (see + * docs.devin.ai/product-guides/skills). Unlike Claude Code — which requires a + * slash command as the first character of the last text block — Devin accepts + * the mention inline, so dispatch is a plain token substitution and the text + * around it survives untouched. + * + * The same token shape the composer and timeline chips recognise + * (`packages/shared/src/composerInlineTokens.ts`) is reused here, so a + * rendered chip and a dispatched skill are always the same set. Unknown dollar + * tokens stay literal: a `$HOME` in prose must not become a command, and a + * token glued to other text (`5$implement`) is not a mention. + * + * @module provider/Drivers/DevinSkillDispatch + */ + +const SKILL_MENTION_PATTERN = + /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g; + +export interface DevinSkillDispatch { + /** The prompt with every known `$name` mention rewritten to `@skills:name`. */ + readonly prompt: string; + /** The last skill name dispatched, for diagnostics. */ + readonly skillName: string; +} + +/** + * Rewrite every known `$skill` mention in `prompt` to `@skills:`, + * preserving surrounding text and trailing arguments. Returns `undefined` + * when no known skill is mentioned, in which case the prompt goes out + * unchanged. Mentions that do not match a discovered skill stay literal: a + * `$HOME` in prose must not become a command, and a path-like token is never + * inferred to be a skill. + */ +export function planDevinSkillDispatch( + prompt: string, + skillNames: ReadonlySet, +): DevinSkillDispatch | undefined { + let dispatched = ""; + let lastIndex = 0; + let dispatchedAny = false; + let lastSkillName = ""; + + for (const match of prompt.matchAll(SKILL_MENTION_PATTERN)) { + const name = match[2] ?? ""; + if (!skillNames.has(name)) { + continue; + } + const leading = match[1] ?? ""; + const tokenStart = (match.index ?? 0) + leading.length; + + dispatched += prompt.slice(lastIndex, tokenStart) + `@skills:${name}`; + lastIndex = tokenStart + name.length + 1; + dispatchedAny = true; + lastSkillName = name; + } + + if (!dispatchedAny) { + return undefined; + } + return { prompt: dispatched + prompt.slice(lastIndex), skillName: lastSkillName }; +} + +/** + * Whether `prompt` carries any `$skill`-shaped token at all. The adapter uses + * this to skip discovery entirely on ordinary turns — the CLI probe only runs + * when a candidate token exists, so prompts without a mention never pay for it. + */ +export function hasCandidateSkillMention(prompt: string): boolean { + for (const _ of prompt.matchAll(SKILL_MENTION_PATTERN)) { + return true; + } + return false; +} diff --git a/apps/server/src/provider/Drivers/DevinSkills.test.ts b/apps/server/src/provider/Drivers/DevinSkills.test.ts new file mode 100644 index 000000000000..e50a7de6631a --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinSkills.test.ts @@ -0,0 +1,279 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { layerPosix as nodePathLayerPosix } from "@effect/platform-node/NodePath"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Cause from "effect/Cause"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { + DevinSkillsProbeError, + decodeDevinSkillRecords, + discoverDevinSkills, +} from "./DevinSkills.ts"; + +/** Posix path semantics keep parser expectations identical on every host. */ +const decodeWithPosixPaths = (stdout: string, cwd?: string) => + Effect.gen(function* () { + const path = yield* Path.Path.pipe(Effect.provide(nodePathLayerPosix)); + return decodeDevinSkillRecords(stdout, path, cwd); + }); + +const decodeSync = (stdout: string, cwd?: string) => + Effect.runSync(decodeWithPosixPaths(stdout, cwd)); + +const makeListSpawner = ( + stdout: string, + exitCode = 0, + observed?: { cwds: Array; commands: Array }, +) => + ChildProcessSpawner.make((command) => { + if (observed) { + observed.cwds.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); + observed.commands.push(command._tag === "StandardCommand" ? command.command : ""); + } + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + +describe("decodeDevinSkillRecords", () => { + it("maps valid records onto provider skills with SKILL.md paths", () => { + const skills = decodeSync( + JSON.stringify([ + { + name: "deploy", + base_dir: "/tmp/skills/deploy", + description: "Deploy the app.", + display_name: "Deploy", + triggers: ["user", "model"], + }, + ]), + ); + + expect(skills).toEqual([ + { + name: "deploy", + description: "Deploy the app.", + path: "/tmp/skills/deploy/SKILL.md", + scope: "other", + enabled: true, + displayName: "Deploy", + userInvocable: true, + }, + ]); + }); + + it("derives userInvocationOnly when triggers carry user but not model", () => { + const skills = decodeSync( + JSON.stringify([ + { name: "user-only", base_dir: "/tmp/a", triggers: ["user"] }, + { name: "both", base_dir: "/tmp/b", triggers: ["user", "model"] }, + { name: "model-only", base_dir: "/tmp/c", triggers: ["model"] }, + ]), + ); + + const byName = new Map(skills?.map((skill) => [skill.name, skill])); + expect(byName.get("user-only")?.userInvocationOnly).toBe(true); + expect(byName.get("user-only")?.userInvocable).toBe(true); + expect(byName.get("both")?.userInvocationOnly).toBeUndefined(); + expect(byName.get("both")?.userInvocable).toBe(true); + expect(byName.get("model-only")?.userInvocable).toBeUndefined(); + }); + + it("disables records with errors and keeps warning-only records enabled", () => { + const skills = decodeSync( + JSON.stringify([ + { name: "broken", base_dir: "/tmp/broken", errors: ["bad frontmatter"] }, + { name: "warned", base_dir: "/tmp/warned", warnings: ["deprecated trigger"] }, + ]), + ); + + const byName = new Map(skills?.map((skill) => [skill.name, skill])); + expect(byName.get("broken")?.enabled).toBe(false); + expect(byName.get("warned")?.enabled).toBe(true); + }); + + it("skips malformed records without failing the batch", () => { + const skills = decodeSync( + JSON.stringify([ + { name: "", base_dir: "/tmp/empty-name" }, + { name: "no-dir", base_dir: "" }, + { name: "no-dir", base_dir: " " }, + "a string, not an object", + 42, + null, + { name: "valid", base_dir: "/tmp/valid" }, + ]), + ); + + expect(skills).toEqual([ + { + name: "valid", + path: "/tmp/valid/SKILL.md", + scope: "other", + enabled: true, + }, + ]); + }); + + it("classifies project scope from the workspace cwd", () => { + const skills = decodeSync( + JSON.stringify([ + { name: "project-skill", base_dir: "/workspace/.devin/skills/deploy" }, + { name: "elsewhere", base_dir: "/opt/other/skills/deploy" }, + ]), + "/workspace", + ); + + const byName = new Map(skills?.map((skill) => [skill.name, skill])); + expect(byName.get("project-skill")?.scope).toBe("project"); + expect(byName.get("elsewhere")?.scope).toBe("other"); + }); + + it("classifies personal scope for Devin user-global roots", () => { + // Compute the personal root exactly as the implementation does so the + // expectation holds on every host path semantics. + const homeRoot = Effect.runSync( + Effect.gen(function* () { + const path = yield* Path.Path.pipe(Effect.provide(nodePathLayerPosix)); + return path.resolve(NodeOS.homedir(), ".devin/skills"); + }), + ); + const skills = decodeSync( + JSON.stringify([{ name: "personal", base_dir: `${homeRoot}/notes` }]), + ); + + expect(skills?.[0]?.scope).toBe("personal"); + }); + + it("deduplicates names case-insensitively keeping the first record", () => { + const skills = decodeSync( + JSON.stringify([ + { name: "Deploy", base_dir: "/tmp/one" }, + { name: "deploy", base_dir: "/tmp/two" }, + { name: "DEPLOY", base_dir: "/tmp/three" }, + { name: "zeta", base_dir: "/tmp/zeta" }, + ]), + ); + + expect(skills?.map((skill) => skill.name)).toEqual(["Deploy", "zeta"]); + expect(skills?.[0]?.path).toBe("/tmp/one/SKILL.md"); + }); + + it("sorts deterministically by name", () => { + const skills = decodeSync( + JSON.stringify([ + { name: "zulu", base_dir: "/tmp/z" }, + { name: "alpha", base_dir: "/tmp/a" }, + { name: "mike", base_dir: "/tmp/m" }, + ]), + ); + + expect(skills?.map((skill) => skill.name)).toEqual(["alpha", "mike", "zulu"]); + }); + + it("treats an empty array as a valid empty result", () => { + expect(decodeSync("[]")).toEqual([]); + }); + + it("returns undefined for a non-array payload or undecodable JSON", () => { + expect(decodeSync("not json")).toBeUndefined(); + expect(decodeSync('{"skills": []}')).toBeUndefined(); + expect(decodeSync('{"name": "x", "base_dir": "/tmp"}')).toBeUndefined(); + // A record with a non-string name fails the record schema and is skipped. + expect(decodeSync('[{"name": 42, "base_dir": "/tmp"}]')).toEqual([]); + }); +}); + +const findProbeError = (exit: Exit.Exit): DevinSkillsProbeError | undefined => { + if (Exit.isSuccess(exit)) return undefined; + const failReason = exit.cause.reasons.find(Cause.isFailReason); + return isDevinSkillsProbeError(failReason?.error) ? failReason.error : undefined; +}; + +const isDevinSkillsProbeError = Schema.is(DevinSkillsProbeError); + +describe("discoverDevinSkills", () => { + it("passes the workspace cwd and configured binary to the command", async () => { + const observed: { cwds: Array; commands: Array } = { + cwds: [], + commands: [], + }; + const cwd = NodePath.resolve(process.cwd()); + await Effect.runPromise( + discoverDevinSkills({ binaryPath: "devin" }, {}, cwd).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeListSpawner("[]", 0, observed), + ), + Effect.provide(nodePathLayerPosix), + ), + ); + + expect(observed.cwds).toContain(cwd); + }); + + it("returns an empty catalog for an authoritative empty array", async () => { + const skills = await Effect.runPromise( + discoverDevinSkills({ binaryPath: "devin" }, {}).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, makeListSpawner("[]")), + Effect.provide(nodePathLayerPosix), + ), + ); + expect(skills).toEqual([]); + }); + + it("fails with a typed exit error on nonzero exit", async () => { + const exit = await Effect.runPromiseExit( + discoverDevinSkills({ binaryPath: "devin" }, {}).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, makeListSpawner("", 2)), + Effect.provide(nodePathLayerPosix), + ), + ); + const error = findProbeError(exit); + expect(error?.stage).toBe("exit"); + expect(error?.exitCode).toBe(2); + }); + + it("fails with a typed decode error on invalid JSON", async () => { + const exit = await Effect.runPromiseExit( + discoverDevinSkills({ binaryPath: "devin" }, {}).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, makeListSpawner("not json")), + Effect.provide(nodePathLayerPosix), + ), + ); + expect(findProbeError(exit)?.stage).toBe("decode"); + }); + + it("fails with a typed decode error on a non-array payload", async () => { + const exit = await Effect.runPromiseExit( + discoverDevinSkills({ binaryPath: "devin" }, {}).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeListSpawner('{"skills": []}'), + ), + Effect.provide(nodePathLayerPosix), + ), + ); + expect(findProbeError(exit)?.stage).toBe("decode"); + }); +}); diff --git a/apps/server/src/provider/Drivers/DevinSkills.ts b/apps/server/src/provider/Drivers/DevinSkills.ts new file mode 100644 index 000000000000..49a664729c58 --- /dev/null +++ b/apps/server/src/provider/Drivers/DevinSkills.ts @@ -0,0 +1,287 @@ +/** + * DevinSkills — skill discovery for the `$` picker via `devin skills list --json`. + * + * The Devin CLI reports its own skill catalog, so asking it beats scanning the + * filesystem: the catalog honors Devin's own skill state (errors, warnings, + * triggers) and includes skills from locations T3 cannot enumerate cheaply. + * The command takes the selected workspace as its cwd, so project-scoped + * skills resolve against the right directory. + * + * The module has two halves: a pure parser that validates and normalizes CLI + * records, and a runner that spawns the command with the configured binary and + * environment, bounds its output and runtime, and maps process failures to a + * typed discovery error. Keeping the parser pure makes the normalization rules + * testable without spawning anything. + * + * @module provider/Drivers/DevinSkills + */ +import * as NodeOS from "node:os"; + +import type { DevinSettings, ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { collectUint8StreamText } from "../../stream/collectUint8StreamText.ts"; +import { isWindowsCommandNotFound } from "../../processRunner.ts"; + +/** Discovery budget matches the other provider skill probes (Codex uses 20s). */ +export const DEVIN_SKILLS_PROBE_TIMEOUT_MS = 20_000; + +/** Skill catalogs are small; anything past this is treated as a runaway. */ +export const DEVIN_SKILLS_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; + +export class DevinSkillsProbeError extends Schema.TaggedErrorClass()( + "DevinSkillsProbeError", + { + stage: Schema.Literals(["spawn", "timeout", "exit", "output-limit", "decode"]), + cwd: Schema.optional(Schema.String), + exitCode: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const location = this.cwd === undefined ? "" : ` for '${this.cwd}'`; + const exitCode = this.exitCode === undefined ? "" : ` with exit code ${this.exitCode}`; + return `\`devin skills list --json\` failed during ${this.stage}${location}${exitCode}.`; + } +} + +/** One CLI record, narrowed to the fields the snapshot reads. */ +const DevinSkillRecord = Schema.Struct({ + name: Schema.String, + base_dir: Schema.String, + description: Schema.optional(Schema.String), + display_name: Schema.optional(Schema.String), + triggers: Schema.optional(Schema.Array(Schema.String)), + errors: Schema.optional(Schema.Array(Schema.Unknown)), + warnings: Schema.optional(Schema.Array(Schema.Unknown)), +}); + +const DevinSkillsPayload = Schema.Array(Schema.Unknown); + +const decodePayload = Schema.decodeUnknownOption(DevinSkillsPayload); +const decodeSkillRecord = Schema.decodeUnknownOption(DevinSkillRecord); + +/** + * Devin's user-global skill roots. Skills under one of these are personal; + * skills under the workspace cwd are project; anything else keeps `other` so + * the picker can still show it instead of discarding a valid skill. + */ +const DEVIN_PERSONAL_SKILL_ROOTS = [".devin/skills", ".config/devin/skills"]; + +type DevinSkillScope = "project" | "personal" | "other"; + +const deriveSkillScope = ( + path: Path.Path, + baseDir: string, + workspaceCwd: string | undefined, +): DevinSkillScope => { + const normalizedBase = path.resolve(baseDir); + if (workspaceCwd !== undefined) { + const normalizedCwd = path.resolve(workspaceCwd); + if (normalizedBase === normalizedCwd || normalizedBase.startsWith(normalizedCwd + path.sep)) { + return "project"; + } + } + const personalRoot = DEVIN_PERSONAL_SKILL_ROOTS.find((root) => { + const homeRoot = path.resolve(NodeOS.homedir(), root); + return normalizedBase === homeRoot || normalizedBase.startsWith(homeRoot + path.sep); + }); + return personalRoot !== undefined ? "personal" : "other"; +}; + +/** + * Map `devin skills list --json` output onto provider skills. Records without + * a usable name or base_dir are skipped; records carrying errors are disabled + * while warning-only records stay enabled. Names deduplicate + * case-insensitively — Devin's `@skills:` syntax is case-insensitive, so two + * records differing only in case would be ambiguous at dispatch time. An + * otherwise valid empty array is an authoritative empty result; a non-array + * payload is a decode failure. + */ +export function decodeDevinSkillRecords( + stdout: string, + path: Path.Path, + workspaceCwd?: string, +): ReadonlyArray | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return undefined; + } + const decodedPayload = decodePayload(parsed); + if (Option.isNone(decodedPayload)) { + return undefined; + } + + const skillsByName = new Map(); + for (const entry of decodedPayload.value) { + const decoded = decodeSkillRecord(entry); + if (Option.isNone(decoded)) { + continue; + } + const record = decoded.value; + const name = record.name.trim(); + const baseDir = record.base_dir.trim(); + if (!name || !baseDir) { + continue; + } + + const skillPath = path.join(baseDir, "SKILL.md"); + const description = record.description?.trim(); + const displayName = record.display_name?.trim(); + const triggers = record.triggers ?? []; + const hasErrors = (record.errors ?? []).length > 0; + const skill: ServerProviderSkill = { + name, + path: skillPath, + enabled: !hasErrors, + scope: deriveSkillScope(path, baseDir, workspaceCwd), + ...(description ? { description } : {}), + ...(displayName ? { displayName } : {}), + ...(triggers.includes("user") ? { userInvocable: true } : {}), + ...(triggers.includes("user") && !triggers.includes("model") + ? { userInvocationOnly: true } + : {}), + }; + + const key = name.toLowerCase(); + if (!skillsByName.has(key)) { + skillsByName.set(key, skill); + } + } + + return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); +} + +interface BoundedCommandResult { + readonly stdout: string; + readonly stderr: string; + readonly code: number; + readonly truncated: boolean; +} + +/** + * Spawn the skills command and collect bounded output. Truncation keeps + * draining so the child can exit normally (see collectUint8StreamText); the + * truncated flag lets the caller reject the result instead of decoding a + * silently clipped catalog. + */ +const spawnBoundedSkillsCommand = ( + binaryPath: string, + command: ChildProcess.Command, +): Effect.Effect< + BoundedCommandResult, + DevinSkillsProbeError, + ChildProcessSpawner.ChildProcessSpawner +> => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner + .spawn(command) + .pipe(Effect.mapError((cause) => new DevinSkillsProbeError({ stage: "spawn", cause }))); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectUint8StreamText({ + stream: child.stdout, + maxBytes: DEVIN_SKILLS_MAX_OUTPUT_BYTES, + }), + collectUint8StreamText({ + stream: child.stderr, + maxBytes: DEVIN_SKILLS_MAX_OUTPUT_BYTES, + }), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.mapError((cause) => new DevinSkillsProbeError({ stage: "spawn", cause }))); + + if (yield* isWindowsCommandNotFound(exitCode, stderr.text)) { + return yield* new DevinSkillsProbeError({ + stage: "spawn", + cause: new Error(`Devin command '${binaryPath}' was not found (exit code ${exitCode}).`), + }); + } + return { + stdout: stdout.text, + stderr: stderr.text, + code: exitCode, + truncated: stdout.truncated || stderr.truncated, + }; + }).pipe(Effect.scoped); + +const isDevinSkillsProbeError = Schema.is(DevinSkillsProbeError); + +/** + * Run `devin skills list --json` with the workspace as cwd and map the + * reported catalog onto provider skills. Spawn, timeout, nonzero exit, + * output-limit, and decode failures surface as typed `DevinSkillsProbeError`s + * so callers can distinguish a best-effort miss from an authoritative empty + * catalog. + */ +export const discoverDevinSkills = Effect.fn("discoverDevinSkills")(function* ( + devinSettings: Pick, + environment: NodeJS.ProcessEnv = process.env, + cwd?: string, +) { + const command = devinSettings.binaryPath || "devin"; + const listResult = yield* Effect.gen(function* () { + const spawnCommand = yield* resolveSpawnCommand(command, ["skills", "list", "--json"], { + env: environment, + }); + return yield* spawnBoundedSkillsCommand( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(cwd ? { cwd } : {}), + env: environment, + shell: spawnCommand.shell, + }), + ); + }).pipe( + Effect.mapError((cause) => + isDevinSkillsProbeError(cause) + ? cause + : new DevinSkillsProbeError({ + stage: "spawn", + ...(cwd ? { cwd } : {}), + cause, + }), + ), + Effect.timeoutOption(DEVIN_SKILLS_PROBE_TIMEOUT_MS), + ); + + if (Option.isNone(listResult)) { + return yield* new DevinSkillsProbeError({ + stage: "timeout", + ...(cwd ? { cwd } : {}), + }); + } + const output = listResult.value; + if (output.truncated) { + return yield* new DevinSkillsProbeError({ + stage: "output-limit", + ...(cwd ? { cwd } : {}), + }); + } + if (output.code !== 0) { + return yield* new DevinSkillsProbeError({ + stage: "exit", + ...(cwd ? { cwd } : {}), + exitCode: output.code, + }); + } + const path = yield* Path.Path; + const skills = decodeDevinSkillRecords(output.stdout, path, cwd); + if (!skills) { + return yield* new DevinSkillsProbeError({ + stage: "decode", + ...(cwd ? { cwd } : {}), + }); + } + return skills; +}); diff --git a/apps/server/src/provider/Layers/DevinAdapter.test.ts b/apps/server/src/provider/Layers/DevinAdapter.test.ts new file mode 100644 index 000000000000..6bcd80cb4522 --- /dev/null +++ b/apps/server/src/provider/Layers/DevinAdapter.test.ts @@ -0,0 +1,1524 @@ +// @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 Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { isHostWindows } from "@t3tools/shared/hostProcess"; +import { + ApprovalRequestId, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ProviderApprovalOption, + type ProviderRuntimeEvent, + ThreadId, + TurnId, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { makeDevinAdapter, makeDevinPromptLease, settleDevinPromptLease } from "./DevinAdapter.ts"; +import { ProviderAdapterRequestError, ProviderAdapterValidationError } from "../Errors.ts"; + +const decodeDevinSettings = Schema.decodeSync( + Schema.Struct({ + enabled: Schema.Boolean, + binaryPath: Schema.String, + customModels: Schema.Array(Schema.String), + }), +); + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const mockAgentCommand = process.execPath; +const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`; +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const decodeUnknownJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); + +const makeMockDevinWrapper = Effect.fn("makeMockDevinWrapper")(function* ( + extraEnv?: Record, + skills?: { + readonly jsonPath: string; + readonly callLogPath: string; + readonly exitCode?: number; + }, +) { + const isWindows = yield* isHostWindows; + const dir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-mock-")), + ); + if (isWindows) { + // resolveSpawnCommand detects .cmd and uses shell:true on Windows. + const wrapperPath = NodePath.join(dir, "fake-devin.cmd"); + const envLines = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `set ${key}=${value}`) + .join("\r\n"); + const skillsBranch = skills + ? `if "%~1"=="skills" (\r\n echo called>>"${skills.callLogPath}"\r\n type "${skills.jsonPath}"\r\n exit /b ${skills.exitCode ?? 0}\r\n)\r\n` + : ""; + const script = `@echo off\r\n${envLines}\r\n${skillsBranch}"${mockAgentCommand}" "${mockAgentPath}" %*\r\n`; + yield* Effect.promise(() => NodeFSP.writeFile(wrapperPath, script, "utf8")); + return wrapperPath; + } + const wrapperPath = NodePath.join(dir, "fake-devin.sh"); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `export ${key}=${shellQuote(value)}`) + .join("\n"); + const skillsBranch = skills + ? `if [ "$1" = "skills" ]; then\n echo called >> '${skills.callLogPath}'\n cat '${skills.jsonPath}'\n exit ${skills.exitCode ?? 0}\nfi\n` + : ""; + const script = `#!/bin/sh +${envExports} +${skillsBranch} +exec ${shellQuote(mockAgentCommand)} ${shellQuote(mockAgentPath)} "$@" +`; + yield* Effect.promise(() => NodeFSP.writeFile(wrapperPath, script, "utf8")); + yield* Effect.promise(() => NodeFSP.chmod(wrapperPath, 0o755)); + return wrapperPath; +}); + +const devinAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-devin-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +const makeTestAdapter = (binaryPath: string, options?: Parameters[1]) => + makeDevinAdapter( + decodeDevinSettings({ enabled: true, binaryPath, customModels: [] }), + options, + ).pipe(Effect.orDie); + +const devinModelSelection = (model: string) => ({ + instanceId: ProviderInstanceId.make("devin"), + model, +}); + +const devinResourceLinkFixture = { + type: "content", + content: { + type: "resource_link", + uri: "urn:acp:fixture:resource-link", + name: "schema fixture", + description: "typed protocol fixture", + mimeType: "text/markdown", + }, +} satisfies EffectAcpSchema.ToolCallContent; + +function containsString(value: unknown, needle: string): boolean { + if (typeof value === "string") return value.includes(needle); + if (Array.isArray(value)) return value.some((entry) => containsString(entry, needle)); + if (typeof value !== "object" || value === null) return false; + return Object.values(value).some((entry) => containsString(entry, needle)); +} + +it("does not let a stale prompt lease consume a newer turn's accounting", () => { + const firstTurnId = TurnId.make("devin-first-turn"); + const nextTurnId = TurnId.make("devin-next-turn"); + const staleLease = makeDevinPromptLease(firstTurnId); + const nextLease = makeDevinPromptLease(nextTurnId); + const accounting = { + activeTurnId: firstTurnId, + activePromptLeases: new Set([staleLease]), + }; + + accounting.activeTurnId = nextTurnId; + accounting.activePromptLeases.clear(); + accounting.activePromptLeases.add(nextLease); + + assert.isFalse(settleDevinPromptLease(accounting, staleLease)); + assert.equal(accounting.activePromptLeases.size, 1); + assert.isTrue(accounting.activePromptLeases.has(nextLease)); + assert.equal(accounting.activeTurnId, nextTurnId); +}); + +it("settles each steered prompt lease exactly once", () => { + const turnId = TurnId.make("devin-steered-turn"); + const firstLease = makeDevinPromptLease(turnId); + const secondLease = makeDevinPromptLease(turnId); + const accounting = { + activeTurnId: turnId, + activePromptLeases: new Set([firstLease, secondLease]), + }; + + assert.isTrue(settleDevinPromptLease(accounting, firstLease)); + assert.equal(accounting.activePromptLeases.size, 1); + assert.isTrue(accounting.activePromptLeases.has(secondLease)); + assert.isFalse(settleDevinPromptLease(accounting, firstLease)); + assert.equal(accounting.activePromptLeases.size, 1); +}); + +// excludeTestServices avoids the TestClock/TestConsole layers so that +// Effect.timeout and Effect.sleep use the real wall clock — which is what +// these integration tests need since they spawn real child processes. +it.layer(devinAdapterTestLayer, { excludeTestServices: true })("DevinAdapterLive", (it) => { + it.effect("rejects unsupported conversation rollback without changing local history", () => + Effect.gen(function* () { + const adapter = yield* makeTestAdapter(yield* makeMockDevinWrapper()); + const threadId = ThreadId.make("devin-rollback"); + yield* adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" }); + yield* adapter.sendTurn({ threadId, input: "keep this turn" }); + const before = structuredClone(yield* adapter.readThread(threadId)); + assert.isNotEmpty(before.turns); + const result = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(result)); + assert.equal(adapter.capabilities.supportsConversationRollback, false); + assert.deepEqual(yield* adapter.readThread(threadId), before); + }), + ); + + for (const stage of [ + "spawn", + "start", + "configure", + "restart-start", + "restart-configure", + ] as const) { + it.effect(`closes the independent runtime scope on ${stage} failure`, () => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const scopes: Scope.Scope[] = []; + let closed = 0; + const trackedSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + scopes.push(yield* Effect.scope); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + closed += 1; + }), + ); + if (stage === "spawn") + return yield* PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "spawn", + description: "test spawn failure", + }); + return yield* spawner.spawn(command); + }), + ); + const goodWrapper = yield* makeMockDevinWrapper(); + const failingWrapper = yield* makeMockDevinWrapper( + stage.endsWith("start") + ? { T3_ACP_FAIL_LOAD_SESSION: "1" } + : { T3_ACP_FAIL_SET_CONFIG_OPTION: "1" }, + ); + const restart = stage.startsWith("restart"); + let binaryPath = restart ? goodWrapper : failingWrapper; + const adapter = yield* makeTestAdapter(binaryPath, { + resolveSettings: Effect.sync(() => + decodeDevinSettings({ enabled: true, binaryPath, customModels: [] }), + ), + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, trackedSpawner)); + // Red runs must also release the scopes whose missing finalizers this test detects. + yield* Effect.addFinalizer(() => + Effect.forEach(scopes, (scope) => Scope.close(scope, Exit.void), { discard: true }), + ); + const threadId = ThreadId.make(`devin-failure-${stage}`); + const start = adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: devinModelSelection(restart ? "default" : "composer-2"), + ...(stage === "start" + ? { resumeCursor: { schemaVersion: 1, sessionId: "prior-session" } } + : {}), + }); + if (restart) yield* start; + binaryPath = failingWrapper; + const operation = restart + ? adapter + .sendTurn({ + threadId, + input: "change model", + modelSelection: devinModelSelection("composer-2"), + }) + .pipe(Effect.asVoid) + : start.pipe(Effect.asVoid); + const result = yield* Effect.exit(operation); + assert.isTrue(Exit.isFailure(result)); + assert.equal(closed, restart ? 2 : 1); + assert.isFalse(yield* adapter.hasSession(threadId)); + assert.isEmpty(yield* adapter.listSessions()); + }), + ); + } + + for (const [decision, optionId] of [ + ["accept", "native-allow-once"], + ["acceptForSession", "native-allow-always"], + ["acceptAlways", "native-allow-always"], + ["decline", "native-reject"], + ["cancel", undefined], + ] as const) { + it.effect( + `resolves ${decision} with advertised ACP permission options and bounded resources`, + () => + Effect.gen(function* () { + const wrapperPath = yield* makeMockDevinWrapper(); + const logPath = NodePath.join(NodePath.dirname(wrapperPath), "requests.log"); + const binaryPath = yield* makeMockDevinWrapper({ + T3_ACP_REQUEST_LOG_PATH: logPath, + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_PERMISSION_RESOURCE: "1", + T3_ACP_ALLOW_ONCE_OPTION_ID: "native-allow-once", + T3_ACP_ALLOW_ALWAYS_OPTION_ID: "native-allow-always", + T3_ACP_REJECT_ONCE_OPTION_ID: "native-reject", + T3_ACP_OMIT_ALLOW_ALWAYS: decision === "cancel" ? "1" : "0", + }); + const nativeLogs: unknown[] = []; + const adapter = yield* makeTestAdapter(binaryPath, { + nativeEventLogger: { + filePath: logPath, + write: (event) => Effect.sync(() => nativeLogs.push(event)), + close: () => Effect.void, + }, + }); + const threadId = ThreadId.make(`devin-approval-${decision}`); + const opened = + yield* Deferred.make>(); + yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "request.opened" + ? Deferred.succeed(opened, event).pipe( + Effect.andThen( + decision === "cancel" + ? adapter + .respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + "acceptForSession", + ) + .pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => assert.include(error.message, "did not advertise")), + ), + ) + : Effect.void, + ), + Effect.andThen( + adapter.respondToRequest( + threadId, + ApprovalRequestId.make(String(event.requestId)), + decision, + ), + ), + ) + : Effect.void, + ).pipe(Effect.forkChild); + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ threadId, input: "review resource" }); + const request = yield* Deferred.await(opened); + const allowOnce: ProviderApprovalOption = { decision: "accept", label: "Allow once" }; + const allowAlways: ProviderApprovalOption = { + decision: "acceptForSession", + label: "Allow always", + }; + const reject: ProviderApprovalOption = { decision: "decline", label: "Reject" }; + const cancel: ProviderApprovalOption = { decision: "cancel", label: "Cancel" }; + const expectedOptions: ReadonlyArray = + decision === "cancel" + ? [allowOnce, reject, cancel] + : [allowOnce, allowAlways, reject, cancel]; + assert.deepEqual(request.payload.options, expectedOptions); + assert.isTrue(containsString(request.payload.args, "urn:permission:notes")); + assert.isTrue(containsString(request.payload.args, "Review these notes")); + assert.deepEqual(request.raw?.payload, request.payload.args); + for (const excluded of [ + "permission-binary", + "permission-resource-metadata", + "urn:permission:oversized", + "x".repeat(8_001), + ]) { + assert.isFalse(containsString(request, excluded), excluded.slice(0, 40)); + assert.isFalse(containsString(nativeLogs, excluded), excluded.slice(0, 40)); + } + const requestLog = yield* Effect.promise(() => NodeFSP.readFile(logPath, "utf8")); + const requests = yield* Effect.forEach(requestLog.trim().split("\n"), (line) => + decodeUnknownJson(line), + ); + assert.isTrue( + requests.some( + Schema.is( + Schema.Struct({ + result: Schema.Struct({ + outcome: + optionId === undefined + ? Schema.Struct({ outcome: Schema.Literal("cancelled") }) + : Schema.Struct({ + outcome: Schema.Literal("selected"), + optionId: Schema.Literal(optionId), + }), + }), + }), + ), + ), + ); + }), + ); + } + + it.effect("keeps compacted ACP occupancy separate from lifetime processed tokens", () => + Effect.gen(function* () { + const adapter = yield* makeTestAdapter( + yield* makeMockDevinWrapper({ T3_ACP_EMIT_USAGE_UPDATE: "1", T3_ACP_COMPACT_USAGE: "1" }), + ); + const threadId = ThreadId.make("devin-compacted-context"); + const usageEvents: Extract[] = + []; + const completed = yield* Deferred.make(); + let turns = 0; + yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (event.type === "thread.token-usage.updated") usageEvents.push(event); + if (event.type === "turn.completed" && ++turns === 2) + return Deferred.succeed(completed, undefined); + return Effect.void; + }).pipe(Effect.forkChild); + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: devinModelSelection("default"), + }); + yield* adapter.sendTurn({ threadId, input: "first" }); + yield* adapter.sendTurn({ threadId, input: "compact" }); + yield* Deferred.await(completed); + assert.deepEqual( + usageEvents.map((event) => event.payload.usage.usedTokens), + [600, 600, 100, 100], + ); + assert.equal(usageEvents.at(-1)?.payload.usage.totalProcessedTokens, 1_140); + assert.equal(usageEvents.at(-1)?.payload.usage.lastUsedTokens, 0); + }), + ); + + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => + Effect.gen(function* () { + const wrapperPath = yield* makeMockDevinWrapper(); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("devin-mock-thread"); + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: devinModelSelection("default"), + }); + + yield* adapter.sendTurn({ + threadId, + input: "hello mock", + attachments: [], + }); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const types = runtimeEvents.map((e) => e.type); + + for (const t of [ + "session.started", + "session.state.changed", + "thread.started", + "turn.started", + "item.started", + "content.delta", + "item.completed", + ] as const) { + assert.include(types, t); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("sanitizes resource content at the canonical and native log boundaries", () => + Effect.gen(function* () { + const wrapperPath = yield* makeMockDevinWrapper({ + T3_ACP_EMIT_DEVIN_RESOURCE_TOOL_CALL: "1", + }); + const nativeLogs: unknown[] = []; + const adapter = yield* makeTestAdapter(wrapperPath, { + nativeEventLogger: { + filePath: "devin-resource-test.log", + write: (event) => Effect.sync(() => nativeLogs.push(event)), + close: () => Effect.void, + }, + }); + const threadId = ThreadId.make("devin-resource-tool-event"); + const completedTool = + yield* Deferred.make>(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "item.completed" && + String(event.threadId) === String(threadId) && + String(event.itemId) === "devin-resource-tool" + ? Deferred.succeed(completedTool, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkChild); + + yield* Effect.gen(function* () { + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: devinModelSelection("default"), + }); + + yield* adapter.sendTurn({ + threadId, + input: "emit typed resource fixture", + attachments: [], + }); + + const event = yield* Deferred.await(completedTool).pipe(Effect.timeout("5 seconds")); + assert.isDefined(event); + const data = event.payload.data as Record | undefined; + assert.deepEqual(data?.resource, { + uri: devinResourceLinkFixture.content.uri, + name: devinResourceLinkFixture.content.name, + description: devinResourceLinkFixture.content.description, + mimeType: devinResourceLinkFixture.content.mimeType, + }); + assert.deepEqual(data?.content, [ + { + type: "content", + content: { + type: "text", + text: "ordinary tool output", + }, + }, + ]); + assert.isFalse(containsString(data, "malformed-resource-link")); + assert.isFalse(containsString(data, "oversized-resource")); + assert.isFalse(containsString(data, "binary-resource")); + assert.isFalse(containsString(data, "opaque-binary-fixture")); + + const nativeToolLogs = nativeLogs.filter((log) => { + const payload = (log as { event?: { payload?: unknown } } | undefined)?.event?.payload; + return ( + typeof payload === "object" && + payload !== null && + "update" in payload && + typeof payload.update === "object" && + payload.update !== null && + "sessionUpdate" in payload.update && + payload.update.sessionUpdate === "tool_call" + ); + }); + assert.lengthOf(nativeToolLogs, 1); + const nativePayload = (nativeToolLogs[0] as { event?: { payload?: unknown } } | undefined) + ?.event?.payload; + assert.deepEqual(nativePayload, { + sessionId: "mock-session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "devin-resource-tool", + title: "Resource fixture", + kind: "other", + status: "completed", + resource: { + uri: devinResourceLinkFixture.content.uri, + name: devinResourceLinkFixture.content.name, + description: devinResourceLinkFixture.content.description, + mimeType: devinResourceLinkFixture.content.mimeType, + }, + }, + }); + assert.deepEqual(event.raw?.payload, nativePayload); + assert.isFalse(containsString(nativeLogs, "malformed-resource-link")); + assert.isFalse(containsString(nativeLogs, "oversized-resource")); + assert.isFalse(containsString(nativeLogs, "binary-resource")); + assert.isFalse(containsString(nativeLogs, "opaque-binary-fixture")); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + yield* adapter.stopSession(threadId).pipe(Effect.ignore); + yield* Fiber.interrupt(runtimeEventsFiber); + }), + ), + ); + }), + ); + + it.effect("passes the current T3 MCP server to a new Devin ACP session", () => + Effect.gen(function* () { + const requestLogDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-mcp-")), + ); + const requestLogPath = NodePath.join(requestLogDir, "requests.log"); + const wrapperPath = yield* makeMockDevinWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("devin-t3-mcp"); + const endpoint = "http://127.0.0.1:43123/mcp"; + const authorizationHeader = "Bearer devin-mcp-test-token"; + + yield* Effect.sync(() => + McpProviderSession.setMcpProviderSession({ + environmentId: EnvironmentId.make("devin-mcp-test-environment"), + threadId, + providerSessionId: "devin-mcp-test-session", + providerInstanceId: ProviderInstanceId.make("devin"), + endpoint, + authorizationHeader, + }), + ); + + yield* Effect.gen(function* () { + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: devinModelSelection("default"), + }); + + const logContents = yield* Effect.promise(() => NodeFSP.readFile(requestLogPath, "utf8")); + const requests = logContents + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line): { method?: string; params?: unknown } | undefined => { + try { + return JSON.parse(line) as { method?: string; params?: unknown }; + } catch { + return undefined; + } + }) + .filter( + (value): value is { method: string; params: unknown } => + value !== undefined && typeof value.method === "string", + ); + const newSessionRequest = requests.find((request) => request.method === "session/new"); + assert.isDefined(newSessionRequest); + const params = newSessionRequest!.params as { mcpServers?: unknown }; + assert.deepEqual(params.mcpServers, [ + { + type: "http", + name: "t3-code", + url: endpoint, + headers: [{ name: "Authorization", value: authorizationHeader }], + }, + ]); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + yield* adapter.stopSession(threadId).pipe(Effect.ignore); + yield* Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)); + }), + ), + ); + }), + ); + + it.effect("keeps the session ready after rejecting an empty prompt", () => + Effect.gen(function* () { + const wrapperPath = yield* makeMockDevinWrapper(); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("devin-empty-prompt-recovery"); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: devinModelSelection("default"), + }); + + const error = yield* adapter + .sendTurn({ + threadId, + input: "", + attachments: [], + }) + .pipe(Effect.flip); + assert.instanceOf(error, ProviderAdapterValidationError); + + const sessionsAfterRejection = yield* adapter.listSessions(); + const sessionAfterRejection = sessionsAfterRejection.find( + (session) => session.threadId === threadId, + ); + assert.isUndefined(sessionAfterRejection?.activeTurnId); + + yield* adapter.sendTurn({ + threadId, + input: "continue after rejected prompt", + attachments: [], + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("recovers when turn event id generation fails", () => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const uuidError = PlatformError.systemError({ + _tag: "Unknown", + module: "Crypto", + method: "randomUUIDv4", + description: "UUID generation unavailable", + }); + let successfulUuidsBeforeFailure: number | undefined; + const wrapperPath = yield* makeMockDevinWrapper(); + const adapter = yield* makeTestAdapter(wrapperPath).pipe( + Effect.provideService(Crypto.Crypto, { + ...crypto, + randomUUIDv4: Effect.suspend(() => { + if (successfulUuidsBeforeFailure === undefined) { + return crypto.randomUUIDv4; + } + if (successfulUuidsBeforeFailure > 0) { + successfulUuidsBeforeFailure -= 1; + return crypto.randomUUIDv4; + } + successfulUuidsBeforeFailure = undefined; + return Effect.fail(uuidError); + }), + }), + ); + const threadId = ThreadId.make("devin-turn-event-id-recovery"); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: devinModelSelection("default"), + }); + + const turnEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.started" || event.type === "turn.completed"), + Stream.take(2), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + + // The first UUID opens the turn; the second stamps turn.started. + successfulUuidsBeforeFailure = 1; + const error = yield* adapter + .sendTurn({ + threadId, + input: "fail while opening the turn", + attachments: [], + }) + .pipe(Effect.flip); + assert.instanceOf(error, ProviderAdapterRequestError); + + const sessionsAfterFailure = yield* adapter.listSessions(); + const sessionAfterFailure = sessionsAfterFailure.find( + (session) => session.threadId === threadId, + ); + assert.isUndefined(sessionAfterFailure?.activeTurnId); + + const recovered = yield* adapter.sendTurn({ + threadId, + input: "continue after event id failure", + attachments: [], + }); + const turnEvents = Array.from( + yield* Fiber.join(turnEventsFiber).pipe(Effect.timeout("5 seconds")), + ); + assert.deepStrictEqual( + turnEvents.map((event) => event.type), + ["turn.started", "turn.completed"], + ); + assert.equal(turnEvents[0]?.turnId, recovered.turnId); + assert.equal(turnEvents[1]?.turnId, recovered.turnId); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("prompt timeout surfaces a clear error and resets the session", () => + Effect.gen(function* () { + const wrapperPath = yield* makeMockDevinWrapper({ + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + }); + const adapter = yield* makeTestAdapter(wrapperPath, { + promptTimeout: "2 seconds", + }); + const threadId = ThreadId.make("devin-prompt-timeout"); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: devinModelSelection("default"), + }); + + // Run the hanging prompt directly and capture its exit. The 2s + // prompt timeout should fire and produce a ProviderAdapterRequestError. + const exit = yield* adapter + .sendTurn({ + threadId, + input: "hang forever", + attachments: [], + }) + .pipe( + Effect.map(Exit.succeed), + Effect.catch((error) => Effect.succeed(Exit.fail(error))), + Effect.timeoutOption("10 seconds"), + ); + assert.isTrue(Option.isSome(exit)); + if (Option.isSome(exit)) { + const result = exit.value; + assert.isTrue(Exit.isFailure(result)); + if (Exit.isFailure(result)) { + const error = Cause.findErrorOption(result.cause); + if (Option.isSome(error)) { + assert.instanceOf(error.value, ProviderAdapterRequestError); + assert.match(error.value.detail, /timed out/i); + } + } + } + + // After the timeout, the session should still be usable. + yield* adapter.sendTurn({ + threadId, + input: "continue after timeout", + attachments: [], + }); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("cancelling a stuck request recovers so the next prompt works", () => + Effect.gen(function* () { + const wrapperPath = yield* makeMockDevinWrapper({ + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + }); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("devin-cancel-recover"); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnStarted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (String(event.threadId) !== String(threadId)) return; + runtimeEvents.push(event); + if (event.type === "turn.started") { + yield* Deferred.succeed(turnStarted, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: devinModelSelection("default"), + }); + + const sendTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "hang first prompt", + attachments: [], + }) + .pipe(Effect.forkChild); + + // Wait for the turn to start, then let the prompt RPC actually reach + // the mock agent before cancelling. `turn.started` fires inside the + // thread lock before the RPC is sent; cooperative yields let the + // sendTurn fiber release the lock and dispatch the prompt. + yield* Deferred.await(turnStarted).pipe(Effect.timeout("5 seconds")); + for (let i = 0; i < 16; i += 1) { + yield* Effect.yieldNow; + } + yield* adapter.interruptTurn(threadId); + yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("5 seconds")); + + for (let i = 0; i < 8; i += 1) { + yield* Effect.yieldNow; + } + + // The session should be ready for a new turn. + const sessions = yield* adapter.listSessions(); + const session = sessions.find((s) => s.threadId === threadId); + assert.equal(session?.status, "ready"); + + // The next prompt should complete normally. + yield* adapter.sendTurn({ + threadId, + input: "continue after cancel", + attachments: [], + }); + + const completedEvents = runtimeEvents.filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + const followUpCompleted = completedEvents.filter((e) => e.payload.state === "completed"); + assert.isAtLeast(followUpCompleted.length, 1); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("concurrent sendTurn calls do not create duplicate turns", () => + Effect.gen(function* () { + const wrapperPath = yield* makeMockDevinWrapper({ T3_ACP_PROMPT_DELAY_MS: "500" }); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("devin-concurrent-send"); + + 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", + modelSelection: devinModelSelection("default"), + }); + + // Fire two sendTurn calls concurrently. The thread lock should + // serialize preparation so only one opens a new turn; the second + // becomes a steer on the same turn. + const [first, second] = yield* Effect.all( + [ + adapter.sendTurn({ threadId, input: "first", attachments: [] }), + adapter.sendTurn({ threadId, input: "second", attachments: [] }), + ], + { concurrency: 2 }, + ); + + // Both should return the same turn id (steer). + assert.equal(String(first.turnId), String(second.turnId)); + + const turnStartedEvents = runtimeEvents.filter((e) => e.type === "turn.started"); + assert.equal(turnStartedEvents.length, 1); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("emits context-window and per-turn usage for ACP telemetry", () => + Effect.gen(function* () { + const wrapperPath = yield* makeMockDevinWrapper({ T3_ACP_EMIT_USAGE_UPDATE: "1" }); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("devin-usage-telemetry"); + 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", + modelSelection: devinModelSelection("default"), + }); + yield* adapter.sendTurn({ threadId, input: "measure usage", attachments: [] }); + yield* Effect.yieldNow; + + const usageEvents = runtimeEvents.filter( + (event): event is Extract => + event.type === "thread.token-usage.updated", + ); + assert.isAtLeast(usageEvents.length, 2); + const contextUsage = usageEvents.find((event) => event.payload.usage.maxTokens === 200_000); + assert.exists(contextUsage); + assert.equal(contextUsage?.payload.usage.usedTokens, 12_000); + const turnUsage = usageEvents.find((event) => event.payload.usage.lastOutputTokens === 120); + assert.exists(turnUsage); + assert.equal(turnUsage?.payload.usage.lastInputTokens, 1_000); + assert.equal(turnUsage?.payload.usage.lastCachedInputTokens, 250); + assert.equal(turnUsage?.payload.usage.lastCacheCreationTokens, 50); + assert.equal(turnUsage?.payload.usage.lastCostUsd, 0.02); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("reinitializes the ACP session when the model changes mid-thread", () => + Effect.gen(function* () { + const wrapperPath = yield* makeMockDevinWrapper(); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("devin-model-change-restart"); + + 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", + modelSelection: devinModelSelection("default"), + }); + + // First turn with the initial model. + yield* adapter.sendTurn({ + threadId, + input: "hello with default model", + attachments: [], + }); + + // Now send a turn with a different model. The adapter should + // internally restart the ACP session and restore context via + // loadSession, keeping the same T3 thread. + yield* adapter.sendTurn({ + threadId, + input: "now with composer-2", + attachments: [], + modelSelection: devinModelSelection("composer-2"), + }); + + // The session should still be active with the new model. + const sessions = yield* adapter.listSessions(); + const session = sessions.find((s) => s.threadId === threadId); + assert.equal(session?.status, "ready"); + assert.equal(session?.model, "composer-2"); + + // We should see a "starting" state change for the reinit, then "ready". + const stateChanges = runtimeEvents.filter( + (event): event is Extract => + event.type === "session.state.changed" && String(event.threadId) === String(threadId), + ); + const reinitStates = stateChanges.filter((e) => e.payload.reason?.includes("model change")); + assert.isAtLeast(reinitStates.length, 2); + assert.equal(reinitStates[0]?.payload.state, "starting"); + assert.equal(reinitStates[1]?.payload.state, "ready"); + + // No session.exited should be emitted — the thread stays continuous. + const exitedEvents = runtimeEvents.filter( + (event) => event.type === "session.exited" && String(event.threadId) === String(threadId), + ); + assert.lengthOf(exitedEvents, 0); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + // Verifies that a model-change restart initializes the new ACP session with + // loadSession using the previous session id. Per the ACP spec, the backend + // replays the full conversation (including images) via session/update + // notifications, so the adapter does not client-side replay prior images. + it.effect("model-change restart loads the prior session without client-side image replay", () => + Effect.gen(function* () { + // A request log captures every JSON-RPC request the adapter sends to + // the mock Devin CLI, so we can assert on loadSession and prompt + // payloads across the restart. The wrapper re-sets this env var on + // every invocation, so the restarted child process appends to the + // same file. + const requestLogDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-reqlog-")), + ); + const requestLogPath = NodePath.join(requestLogDir, "requests.log"); + const wrapperPath = yield* makeMockDevinWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("devin-image-replay"); + + // Write a small PNG into the test attachments dir so the adapter can + // resolve and base64-encode it as an image attachment. + const serverConfig = yield* Effect.service(ServerConfig); + const attachmentsDir = serverConfig.attachmentsDir; + yield* Effect.promise(() => NodeFSP.mkdir(attachmentsDir, { recursive: true })); + const attachmentId = "devin-image-replay-test-image"; + const imageBytes = Buffer.from( + // 1x1 transparent PNG. + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", + "base64", + ); + const imageFilePath = NodePath.join(attachmentsDir, `${attachmentId}.png`); + yield* Effect.promise(() => NodeFSP.writeFile(imageFilePath, imageBytes)); + + 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", + modelSelection: devinModelSelection("default"), + }); + + // First turn includes an image attachment. The adapter base64-encodes + // it into the prompt and tracks it in priorImages for replay on restart. + yield* adapter.sendTurn({ + threadId, + input: "look at this image", + attachments: [ + { + type: "image", + id: attachmentId, + name: "test.png", + mimeType: "image/png", + sizeBytes: imageBytes.length, + }, + ], + }); + + // Trigger the model-change restart by sending a turn with a new model. + // The adapter tears down the old ACP runtime and spawns a new one with + // the previous session id for loadSession context restoration. + yield* adapter.sendTurn({ + threadId, + input: "now with composer-2", + attachments: [], + modelSelection: devinModelSelection("composer-2"), + }); + + // The visible T3 thread stays continuous: same threadId, no + // session.exited, and the session is ready with the new model. + const sessions = yield* adapter.listSessions(); + const session = sessions.find((s) => s.threadId === threadId); + assert.equal(session?.status, "ready"); + assert.equal(session?.model, "composer-2"); + + const exitedEvents = runtimeEvents.filter( + (event) => event.type === "session.exited" && String(event.threadId) === String(threadId), + ); + assert.lengthOf(exitedEvents, 0); + + // The restart must emit the starting -> ready state changes. + const stateChanges = runtimeEvents.filter( + (event): event is Extract => + event.type === "session.state.changed" && String(event.threadId) === String(threadId), + ); + const reinitStates = stateChanges.filter((e) => e.payload.reason?.includes("model change")); + assert.isAtLeast(reinitStates.length, 2); + assert.equal(reinitStates[0]?.payload.state, "starting"); + assert.equal(reinitStates[1]?.payload.state, "ready"); + + // Parse the raw JSON-RPC request log captured from the mock CLI. + const logContents = yield* Effect.promise(() => + NodeFSP.readFile(requestLogPath, "utf8").catch(() => ""), + ); + const requests = logContents + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line): { method?: string; params?: unknown } | undefined => { + try { + return JSON.parse(line) as { method?: string; params?: unknown }; + } catch { + return undefined; + } + }) + .filter( + (value): value is { method: string; params: unknown } => + value !== undefined && typeof value.method === "string", + ); + + // The restart must initialize the new ACP session with loadSession, + // passing the previous session id so the Devin backend restores + // conversation context. + const loadSessionRequests = requests.filter((r) => r.method === "session/load"); + assert.isAtLeast(loadSessionRequests.length, 1); + const loadParams = loadSessionRequests[0]!.params as { sessionId?: string }; + assert.equal(loadParams.sessionId, "mock-session-1"); + + // Per the ACP spec, session/load MUST replay the entire conversation + // history (including images) via session/update notifications. The + // loadSession request itself carries only { sessionId, cwd, mcpServers } + // because the backend already has the conversation content. The adapter + // does NOT client-side replay prior images — that would duplicate them + // in the agent's context (once from the backend's session restore, once + // from our replay). T3 Code's own read model persists across the model + // change for the UI side. These assertions confirm the correct behavior. + const loadParamsBlocks = ( + loadSessionRequests[0]!.params as { + prompt?: ReadonlyArray<{ type?: string }>; + } + )?.prompt; + assert.isUndefined( + loadParamsBlocks, + "loadSession payload should not carry a prompt with image blocks", + ); + + // Only the original first-turn prompt should carry an image content + // block. The model-change turn sends text only, and the adapter does + // not re-send prior images as a replay prompt. + const promptRequests = requests.filter((r) => r.method === "session/prompt"); + const promptsWithImages = promptRequests.filter((r) => { + const params = r.params as { prompt?: ReadonlyArray<{ type?: string }> } | undefined; + return ( + Array.isArray(params?.prompt) && params!.prompt.some((block) => block.type === "image") + ); + }); + assert.lengthOf(promptsWithImages, 1); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + // After a model-change restart, a subsequent turn carrying an image + // attachment must still be accepted and base64-encoded into the + // session/prompt request on the fresh ACP session. This pins that the + // restart path does not leave the session unable to ingest images, and + // that the image block lands in the prompt RPC (not just tracked in + // priorImages). + it.effect("accepts an image attachment on a turn after a model-change restart", () => + Effect.gen(function* () { + const requestLogDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-reqlog-img-")), + ); + const requestLogPath = NodePath.join(requestLogDir, "requests.log"); + const wrapperPath = yield* makeMockDevinWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("devin-image-after-restart"); + + const serverConfig = yield* Effect.service(ServerConfig); + const attachmentsDir = serverConfig.attachmentsDir; + yield* Effect.promise(() => NodeFSP.mkdir(attachmentsDir, { recursive: true })); + const attachmentId = "devin-image-after-restart-img"; + const imageBytes = Buffer.from( + // 1x1 transparent PNG. + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", + "base64", + ); + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(attachmentsDir, `${attachmentId}.png`), imageBytes), + ); + + 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", + modelSelection: devinModelSelection("default"), + }); + + // First turn with the initial model so the session has a response. + yield* adapter.sendTurn({ + threadId, + input: "first turn with default model", + attachments: [], + }); + + // Trigger the model-change restart with a text-only turn. + yield* adapter.sendTurn({ + threadId, + input: "switch to composer-2", + attachments: [], + modelSelection: devinModelSelection("composer-2"), + }); + + // Now send a turn with an image attachment on the restarted session. + yield* adapter.sendTurn({ + threadId, + input: "look at this image on composer-2", + attachments: [ + { + type: "image", + id: attachmentId, + name: "test.png", + mimeType: "image/png", + sizeBytes: imageBytes.length, + }, + ], + }); + + // The session stays continuous and reports the new model. + const sessions = yield* adapter.listSessions(); + const session = sessions.find((s) => s.threadId === threadId); + assert.equal(session?.status, "ready"); + assert.equal(session?.model, "composer-2"); + + const exitedEvents = runtimeEvents.filter( + (event) => event.type === "session.exited" && String(event.threadId) === String(threadId), + ); + assert.lengthOf(exitedEvents, 0); + + const logContents = yield* Effect.promise(() => + NodeFSP.readFile(requestLogPath, "utf8").catch(() => ""), + ); + const requests = logContents + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line): { method?: string; params?: unknown } | undefined => { + try { + return JSON.parse(line) as { method?: string; params?: unknown }; + } catch { + return undefined; + } + }) + .filter( + (value): value is { method: string; params: unknown } => + value !== undefined && typeof value.method === "string", + ); + + // The restart must have called loadSession with the prior session id. + const loadSessionRequests = requests.filter((r) => r.method === "session/load"); + assert.isAtLeast(loadSessionRequests.length, 1); + const loadParams = loadSessionRequests[0]!.params as { sessionId?: string }; + assert.equal(loadParams.sessionId, "mock-session-1"); + + // The last session/prompt must carry a base64 image content block — + // the image attachment on the post-restart turn is accepted and sent. + const promptRequests = requests.filter((r) => r.method === "session/prompt"); + assert.isAtLeast(promptRequests.length, 1); + const lastPrompt = promptRequests.at(-1)!; + const lastPromptParams = lastPrompt.params as { + prompt?: ReadonlyArray<{ type?: string; data?: string; mimeType?: string }>; + }; + const imageBlocks = (lastPromptParams.prompt ?? []).filter((block) => block.type === "image"); + assert.lengthOf(imageBlocks, 1); + assert.equal(imageBlocks[0]!.mimeType, "image/png"); + // The base64 data must round-trip back to the original PNG bytes. + assert.equal( + Buffer.from(imageBlocks[0]!.data ?? "", "base64").toString("base64"), + imageBytes.toString("base64"), + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + + const readPromptRequests = (logContents: string) => + logContents + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line): { method?: string; params?: unknown } | undefined => { + try { + return JSON.parse(line) as { method?: string; params?: unknown }; + } catch { + return undefined; + } + }) + .filter( + (value): value is { method: string; params: unknown } => + value !== undefined && typeof value.method === "string", + ) + .filter((request) => request.method === "session/prompt"); + + it.effect("dispatches a known $skill mention as @skills:name to Devin", () => + Effect.gen(function* () { + const requestLogDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-skills-req-")), + ); + const requestLogPath = NodePath.join(requestLogDir, "requests.log"); + const skillsJsonPath = NodePath.join(requestLogDir, "skills.json"); + const skillsCallLogPath = NodePath.join(requestLogDir, "skills-calls.log"); + yield* Effect.promise(() => + NodeFSP.writeFile( + skillsJsonPath, + encodeUnknownJson([ + { + name: "deploy", + base_dir: NodePath.join(requestLogDir, "skills", "deploy"), + description: "Deploy the app.", + triggers: ["user", "model"], + }, + ]), + "utf8", + ), + ); + const wrapperPath = yield* makeMockDevinWrapper( + { T3_ACP_REQUEST_LOG_PATH: requestLogPath }, + { jsonPath: skillsJsonPath, callLogPath: skillsCallLogPath }, + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("devin-skill-dispatch"); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: devinModelSelection("default"), + }); + yield* adapter.sendTurn({ + threadId, + input: "$deploy staging now", + attachments: [], + }); + + const logContents = yield* Effect.promise(() => + NodeFSP.readFile(requestLogPath, "utf8").catch(() => ""), + ); + const promptRequests = readPromptRequests(logContents); + assert.isAtLeast(promptRequests.length, 1); + const lastPrompt = promptRequests.at(-1)!; + const params = lastPrompt.params as { + prompt?: ReadonlyArray<{ type?: string; text?: string }>; + }; + const textBlocks = (params.prompt ?? []).filter((block) => block.type === "text"); + assert.lengthOf(textBlocks, 1); + assert.equal(textBlocks[0]!.text, "@skills:deploy staging now"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("sends the original prompt unchanged when skill discovery fails", () => + Effect.gen(function* () { + const requestLogDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-skill-fail-")), + ); + const requestLogPath = NodePath.join(requestLogDir, "requests.log"); + const skillsJsonPath = NodePath.join(requestLogDir, "skills.json"); + const skillsCallLogPath = NodePath.join(requestLogDir, "skills-calls.log"); + yield* Effect.promise(() => NodeFSP.writeFile(skillsJsonPath, "[]", "utf8")); + const wrapperPath = yield* makeMockDevinWrapper( + { T3_ACP_REQUEST_LOG_PATH: requestLogPath }, + { jsonPath: skillsJsonPath, callLogPath: skillsCallLogPath, exitCode: 3 }, + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("devin-skill-fail"); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: devinModelSelection("default"), + }); + yield* adapter.sendTurn({ + threadId, + input: "$deploy staging now", + attachments: [], + }); + + const logContents = yield* Effect.promise(() => + NodeFSP.readFile(requestLogPath, "utf8").catch(() => ""), + ); + const promptRequests = readPromptRequests(logContents); + assert.isAtLeast(promptRequests.length, 1); + const lastPrompt = promptRequests.at(-1)!; + const params = lastPrompt.params as { + prompt?: ReadonlyArray<{ type?: string; text?: string }>; + }; + const textBlocks = (params.prompt ?? []).filter((block) => block.type === "text"); + // Discovery failed, so the original prompt goes out unchanged. + assert.lengthOf(textBlocks, 1); + assert.equal(textBlocks[0]!.text, "$deploy staging now"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("caches a successful discovery per workspace across turns", () => + Effect.gen(function* () { + const requestLogDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-skill-cache-")), + ); + const requestLogPath = NodePath.join(requestLogDir, "requests.log"); + const skillsJsonPath = NodePath.join(requestLogDir, "skills.json"); + const skillsCallLogPath = NodePath.join(requestLogDir, "skills-calls.log"); + yield* Effect.promise(() => + NodeFSP.writeFile( + skillsJsonPath, + encodeUnknownJson([{ name: "deploy", base_dir: NodePath.join(requestLogDir, "deploy") }]), + "utf8", + ), + ); + const wrapperPath = yield* makeMockDevinWrapper( + { T3_ACP_REQUEST_LOG_PATH: requestLogPath }, + { jsonPath: skillsJsonPath, callLogPath: skillsCallLogPath }, + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("devin-skill-cache"); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: devinModelSelection("default"), + }); + yield* adapter.sendTurn({ threadId, input: "$deploy staging", attachments: [] }); + yield* adapter.sendTurn({ + threadId, + input: "follow up with $deploy again", + attachments: [], + }); + + const calls = yield* Effect.promise(() => + NodeFSP.readFile(skillsCallLogPath, "utf8").catch(() => ""), + ); + // Two turns, one discovery: the successful catalog is cached per cwd. + assert.lengthOf( + calls.split("called").filter((part) => part.length === 0), + 1, + ); + + const logContents = yield* Effect.promise(() => + NodeFSP.readFile(requestLogPath, "utf8").catch(() => ""), + ); + const promptRequests = readPromptRequests(logContents); + assert.lengthOf(promptRequests, 2); + const firstText = ( + ( + promptRequests[0]!.params as { + prompt?: ReadonlyArray<{ type?: string; text?: string }>; + } + ).prompt ?? [] + ).find((block) => block.type === "text")?.text; + const secondText = ( + ( + promptRequests.at(-1)!.params as { + prompt?: ReadonlyArray<{ type?: string; text?: string }>; + } + ).prompt ?? [] + ).find((block) => block.type === "text")?.text; + assert.equal(firstText, "@skills:deploy staging"); + assert.equal(secondText, "follow up with @skills:deploy again"); + + yield* adapter.stopSession(threadId); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/DevinAdapter.ts b/apps/server/src/provider/Layers/DevinAdapter.ts new file mode 100644 index 000000000000..44306aef0a0e --- /dev/null +++ b/apps/server/src/provider/Layers/DevinAdapter.ts @@ -0,0 +1,1821 @@ +/** + * DevinAdapterLive — Devin CLI (`devin acp`) via ACP. + * + * @module DevinAdapterLive + */ + +import { + ApprovalRequestId, + type DevinSettings, + type ProviderOptionSelection, + EventId, + type ProviderApprovalDecision, + type ProviderApprovalOption, + type ProviderInteractionMode, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type RuntimeMode, + type ThreadId, + type ThreadTokenUsageSnapshot, + TurnId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + type ProviderAdapterError, + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpTokenUsageEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { + type AcpToolCallState, + type AcpSessionMode, + type AcpSessionModeState, + parsePermissionRequest, +} from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + DEVIN_RESOURCE_TEXT_MAX_CHARS, + normalizeDevinResourceContent, +} from "../acp/DevinResourceSupport.ts"; +import { + applyDevinAcpModelSelection, + inferDevinContextWindowTokens, + makeDevinAcpRuntime, + resolveDevinModelUid, + resolveDevinAcpBaseModelId, +} from "../acp/DevinAcpSupport.ts"; +import { type DevinAdapterShape } from "../Services/DevinAdapter.ts"; +import { hasCandidateSkillMention, planDevinSkillDispatch } from "../Drivers/DevinSkillDispatch.ts"; +import { discoverDevinSkills } from "../Drivers/DevinSkills.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +const PROVIDER = ProviderDriverKind.make("devin"); +const DEVIN_RESUME_VERSION = 1 as const; +const ACP_PLAN_MODE_ALIASES = ["plan"]; +const ACP_IMPLEMENT_MODE_ALIASES = ["accept-edits", "smart", "bypass"]; +const ACP_APPROVAL_MODE_ALIASES = ["ask"]; +const DEFAULT_PROMPT_TIMEOUT = Duration.seconds(300); + +export interface DevinPromptAccountingState { + activeTurnId: TurnId | undefined; + readonly activePromptLeases: Set; +} + +export interface DevinPromptLease { + readonly turnId: TurnId; +} + +export const makeDevinPromptLease = (turnId: TurnId): DevinPromptLease => ({ + turnId, +}); + +export const isDevinPromptLeaseCurrent = ( + state: DevinPromptAccountingState, + lease: DevinPromptLease, +): boolean => state.activeTurnId === lease.turnId && state.activePromptLeases.has(lease); + +/** + * Releases one prompt slot only while its turn still owns the accounting + * state. Interrupts and timeouts clear the active turn before a replacement + * turn starts, so a delayed finalizer from the old turn becomes a no-op. + */ +export const settleDevinPromptLease = ( + state: DevinPromptAccountingState, + lease: DevinPromptLease, +): boolean => { + const wasCurrent = isDevinPromptLeaseCurrent(state, lease); + state.activePromptLeases.delete(lease); + return wasCurrent; +}; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface DevinAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + /** + * Selections are honored when `modelSelection.instanceId` matches this value. + * Defaults to the legacy built-in instance id (`devin`). + */ + readonly instanceId?: ProviderInstanceId; + /** + * Optional per-session settings resolver. When provided the adapter yields + * this effect at the start of every session and uses the result instead of + * the `devinSettings` captured at construction. + * + * Production instances bind settings to the instance scope (the hydration + * layer rebuilds the adapter on config change) and leave this undefined. + * Test suites that mutate `ServerSettingsService` mid-flight — e.g. to + * swap `binaryPath` to a mock ACP wrapper — pass a resolver that reads + * the latest snapshot so the closure isn't stale. + */ + readonly resolveSettings?: Effect.Effect; + /** Override the default prompt timeout (5 minutes) in focused tests. */ + readonly promptTimeout?: Duration.Input; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; + readonly kind: string | "unknown"; + readonly options: EffectAcpSchema.RequestPermissionRequest["options"]; +} + +interface PendingUserInput { + readonly answers: Deferred.Deferred; +} + +interface DevinSessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + scope: Scope.Closeable; + acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + /** Prompt leases currently in flight or being prepared. A non-empty set + * means a turn is actively running, so a new sendTurn steers that turn. + * Lease identity prevents stale finalizers from consuming newer prompts. */ + readonly activePromptLeases: Set; + /** Latest ACP-reported context window values. */ + lastContextWindowUsed: number | undefined; + lastContextWindowSize: number | undefined; + /** ACP Usage is cumulative on the wire; retain it to derive turn deltas. */ + lastAcpUsage: DevinAcpUsageTotals | undefined; + lastAcpCostUsd: number | undefined; + pendingCostDeltaUsd: number | undefined; + totalProcessedTokens: number; + /** Full ACP model UID, including reasoning/context/speed variants. */ + activeModelUid: string | undefined; + stopped: boolean; +} + +interface DevinAcpUsageTotals { + readonly inputTokens: number; + readonly cachedReadTokens: number; + readonly cachedWriteTokens: number; + readonly outputTokens: number; + readonly thoughtTokens: number; + readonly totalTokens: number; +} + +function nonNegativeInteger(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.trunc(value) : 0; +} + +function normalizeDevinAcpUsage(usage: EffectAcpSchema.Usage): DevinAcpUsageTotals { + const inputTokens = nonNegativeInteger(usage.inputTokens); + const outputTokens = nonNegativeInteger(usage.outputTokens); + const cachedReadTokens = nonNegativeInteger(usage.cachedReadTokens); + const cachedWriteTokens = nonNegativeInteger(usage.cachedWriteTokens); + const thoughtTokens = nonNegativeInteger(usage.thoughtTokens); + const reportedTotal = nonNegativeInteger(usage.totalTokens); + const calculatedTotal = inputTokens + outputTokens + thoughtTokens; + return { + inputTokens, + cachedReadTokens, + cachedWriteTokens, + outputTokens, + thoughtTokens, + totalTokens: Math.max(reportedTotal, calculatedTotal), + }; +} + +function subtractDevinAcpUsage( + current: DevinAcpUsageTotals, + previous: DevinAcpUsageTotals | undefined, +): DevinAcpUsageTotals { + const delta = (value: number, before: number | undefined) => + before === undefined || value < before ? value : value - before; + return { + inputTokens: delta(current.inputTokens, previous?.inputTokens), + cachedReadTokens: delta(current.cachedReadTokens, previous?.cachedReadTokens), + cachedWriteTokens: delta(current.cachedWriteTokens, previous?.cachedWriteTokens), + outputTokens: delta(current.outputTokens, previous?.outputTokens), + thoughtTokens: delta(current.thoughtTokens, previous?.thoughtTokens), + totalTokens: delta(current.totalTokens, previous?.totalTokens), + }; +} + +function acpCostAmountUsd(cost: EffectAcpSchema.Cost | null | undefined): number | undefined { + if (!cost || !Number.isFinite(cost.amount) || cost.amount < 0) return undefined; + return cost.currency.trim().toUpperCase() === "USD" ? cost.amount : undefined; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingApprovals.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function settlePendingUserInputsAsEmptyAnswers( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingUserInputs.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isDevinResourceContent(value: unknown): boolean { + if (!isRecord(value) || value.type !== "content" || !isRecord(value.content)) { + return false; + } + return value.content.type === "resource_link" || value.content.type === "resource"; +} + +function sanitizeDevinToolCall(toolCall: AcpToolCallState): AcpToolCallState { + const content = toolCall.data.content; + if (!Array.isArray(content)) { + return toolCall; + } + let changed = false; + let resource = toolCall.data.resource; + const retainedContent: Array = []; + for (const entry of content) { + if (!isDevinResourceContent(entry)) { + retainedContent.push(entry); + continue; + } + changed = true; + if (resource !== undefined) continue; + const normalized = normalizeDevinResourceContent(entry); + if (normalized.kind === "resource") { + resource = normalized.resource; + } + } + if (!changed) { + return toolCall; + } + const data: Record = { ...toolCall.data }; + if (retainedContent.length > 0) { + data.content = retainedContent; + } else { + delete data.content; + } + if (resource !== undefined) { + data.resource = resource; + } else { + delete data.resource; + } + return { ...toolCall, data }; +} + +const DEVIN_TOOL_CALL_RAW_METADATA_FIELDS = [ + "sessionUpdate", + "toolCallId", + "title", + "kind", + "status", +] as const; + +function boundedDevinMetadata(value: unknown): string | undefined { + return typeof value === "string" && value.length <= DEVIN_RESOURCE_TEXT_MAX_CHARS + ? value + : undefined; +} + +function sanitizeDevinToolCallRawPayload(rawPayload: unknown, toolCall: AcpToolCallState): unknown { + if (!isRecord(rawPayload) || !isRecord(rawPayload.update)) { + return rawPayload; + } + const rawUpdate = rawPayload.update; + const hasRawResource = + Array.isArray(rawUpdate.content) && rawUpdate.content.some(isDevinResourceContent); + if (!hasRawResource && toolCall.data.resource === undefined) { + return rawPayload; + } + const update: Record = {}; + for (const field of DEVIN_TOOL_CALL_RAW_METADATA_FIELDS) { + const value = boundedDevinMetadata(rawUpdate[field]); + if (value !== undefined) { + update[field] = value; + } + } + if (toolCall.data.resource !== undefined) { + update.resource = toolCall.data.resource; + } + const sessionId = boundedDevinMetadata(rawPayload.sessionId); + return { + ...(sessionId !== undefined ? { sessionId } : {}), + update, + }; +} + +function sanitizeDevinPermissionRequest(params: EffectAcpSchema.RequestPermissionRequest) { + const permissionRequest = parsePermissionRequest(params); + if (!params.toolCall.content?.some(isDevinResourceContent)) { + return { permissionRequest, payload: params }; + } + const toolCall = permissionRequest.toolCall + ? sanitizeDevinToolCall(permissionRequest.toolCall) + : undefined; + const metadata: Record = {}; + for (const field of DEVIN_TOOL_CALL_RAW_METADATA_FIELDS) { + const value = boundedDevinMetadata(Reflect.get(params.toolCall, field)); + if (value !== undefined) metadata[field] = value; + } + if (toolCall?.data.resource !== undefined) metadata.resource = toolCall.data.resource; + const detail = boundedDevinMetadata(permissionRequest.detail); + return { + permissionRequest: { + kind: permissionRequest.kind, + ...(detail !== undefined ? { detail } : {}), + }, + payload: { + sessionId: boundedDevinMetadata(params.sessionId), + toolCall: metadata, + options: params.options.map(({ optionId, name, kind }) => ({ + optionId: boundedDevinMetadata(optionId), + name: boundedDevinMetadata(name), + kind, + })), + }, + }; +} + +function selectDevinPermissionOptionId( + options: EffectAcpSchema.RequestPermissionRequest["options"], + decision: ProviderApprovalDecision, +): string | undefined { + const kind = + decision === "acceptForSession" || decision === "acceptAlways" + ? "allow_always" + : decision === "accept" + ? "allow_once" + : decision === "decline" + ? "reject_once" + : undefined; + return options.find((option) => option.kind === kind)?.optionId; +} + +function devinApprovalOptions( + options: EffectAcpSchema.RequestPermissionRequest["options"], +): ReadonlyArray { + const approvals: ProviderApprovalOption[] = []; + for (const decision of ["accept", "acceptForSession", "decline"] as const) { + const optionId = selectDevinPermissionOptionId(options, decision); + const option = options.find((entry) => entry.optionId === optionId); + const label = boundedDevinMetadata(option?.name)?.trim(); + if (option?.optionId.trim() && label) { + approvals.push({ decision, label }); + } + } + // ACP cancellation is always supported, even without a selectable native option. + approvals.push({ decision: "cancel", label: "Cancel" }); + return approvals; +} + +function parseDevinResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== DEVIN_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +function normalizeModeSearchText(mode: AcpSessionMode): string { + return [mode.id, mode.name, mode.description] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" ") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function findModeByAliases( + modes: ReadonlyArray, + aliases: ReadonlyArray, +): AcpSessionMode | undefined { + const normalizedAliases = aliases.map((alias) => alias.toLowerCase()); + for (const alias of normalizedAliases) { + const exact = modes.find((mode) => { + const id = mode.id.toLowerCase(); + const name = mode.name.toLowerCase(); + return id === alias || name === alias; + }); + if (exact) { + return exact; + } + } + for (const alias of normalizedAliases) { + const partial = modes.find((mode) => normalizeModeSearchText(mode).includes(alias)); + if (partial) { + return partial; + } + } + return undefined; +} + +function isPlanMode(mode: AcpSessionMode): boolean { + return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined; +} + +function resolveRequestedModeId(input: { + readonly interactionMode: ProviderInteractionMode | undefined; + readonly runtimeMode: RuntimeMode; + readonly modeState: AcpSessionModeState | undefined; +}): string | undefined { + const modeState = input.modeState; + if (!modeState) { + return undefined; + } + + if (input.interactionMode === "plan") { + return findModeByAliases(modeState.availableModes, ACP_PLAN_MODE_ALIASES)?.id; + } + + const aliases = + input.runtimeMode === "full-access" + ? ["bypass"] + : input.runtimeMode === "auto" + ? ["smart"] + : input.runtimeMode === "auto-accept-edits" + ? ["accept-edits"] + : ACP_APPROVAL_MODE_ALIASES; + return ( + findModeByAliases(modeState.availableModes, aliases)?.id ?? + findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + modeState.currentModeId + ); +} + +function applyRequestedSessionConfiguration(input: { + readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode | undefined; + readonly modelSelection: + | { + readonly model: string; + readonly options?: ReadonlyArray | null | undefined; + } + | undefined; + readonly mapError: (context: { + readonly cause: import("effect-acp/errors").AcpError; + readonly method: "session/set_config_option" | "session/set_mode"; + }) => E; +}): Effect.Effect { + return Effect.gen(function* () { + if (input.modelSelection) { + yield* applyDevinAcpModelSelection({ + runtime: input.runtime, + model: input.modelSelection.model, + selections: input.modelSelection.options, + mapError: ({ cause }) => + input.mapError({ + cause, + method: "session/set_config_option", + }), + }); + } + + const requestedModeId = resolveRequestedModeId({ + interactionMode: input.interactionMode, + runtimeMode: input.runtimeMode, + modeState: yield* input.runtime.getModeState, + }); + if (!requestedModeId) { + return; + } + + yield* input.runtime.setMode(requestedModeId).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + method: "session/set_mode", + }), + ), + ); + }); +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + return ( + selectDevinPermissionOptionId(request.options, "acceptForSession") ?? + selectDevinPermissionOptionId(request.options, "accept") + ); +} + +function mapPromptTimeout( + provider: ProviderDriverKind, + threadId: ThreadId, + timeout: Duration.Duration, +): ProviderAdapterRequestError { + const millis = Duration.toMillis(timeout); + const seconds = Math.round(millis / 1000); + return new ProviderAdapterRequestError({ + provider, + method: "session/prompt", + detail: `Devin ACP prompt timed out after ${seconds}s. The session has been reset — try sending your message again.`, + }); +} + +export function makeDevinAdapter(devinSettings: DevinSettings, options?: DevinAdapterLiveOptions) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("devin"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + /** + * Successful lazy skill discoveries, keyed by the session workspace cwd. + * Failures are never cached: a failed probe retries on the next turn that + * carries a candidate `$skill` token. + */ + const skillNamesByCwd = new Map>(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate Devin runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const promptTimeout = Duration.fromInputUnsafe( + options?.promptTimeout ?? DEFAULT_PROMPT_TIMEOUT, + ); + const mapExtensionFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Devin ACP extension event.", + cause, + }), + ), + ); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + /** + * Best-effort lazy skill discovery for the session workspace. Successful + * catalogs are cached per cwd for the adapter's lifetime; any failure is + * logged at debug level without environment or prompt contents and left + * uncached so a later turn can retry. + */ + const resolveSkillNamesForCwd = (cwd: string, settings: DevinSettings) => { + const cached = skillNamesByCwd.get(cwd); + if (cached) { + return Effect.succeed(cached); + } + return discoverDevinSkills(settings, options?.environment ?? process.env, cwd).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.provideService(Path.Path, path), + Effect.map((skills) => { + const names = new Set( + skills + .filter((skill) => skill.enabled && skill.userInvocable !== false) + .map((skill) => skill.name), + ); + skillNamesByCwd.set(cwd, names); + return names; + }), + Effect.tapError((cause) => + Effect.logDebug("devin skill discovery failed; sending prompt unchanged", { + stage: cause.stage, + }), + ), + Effect.catch(() => Effect.succeed(new Set() as ReadonlySet)), + ); + }; + + /** + * Translate known `$skill` mentions into Devin's native `@skills:name` + * syntax. Discovery runs lazily — only when the prompt carries a candidate + * token — and a failure leaves the prompt unchanged so the turn still goes + * out. + */ + const dispatchDevinSkills = (prompt: string, cwd: string, settings: DevinSettings) => + hasCandidateSkillMention(prompt) + ? resolveSkillNamesForCwd(cwd, settings).pipe( + Effect.map( + (skillNames) => planDevinSkillDispatch(prompt, skillNames)?.prompt ?? prompt, + ), + ) + : Effect.succeed(prompt); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const logNative = ( + threadId: ThreadId, + method: string, + payload: unknown, + _source: "acp.jsonrpc" | "acp.devin.extension", + ) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }); + + const emitPlanUpdate = ( + ctx: DevinSessionContext, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + source: "acp.jsonrpc" | "acp.devin.extension", + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload, + source, + method, + rawPayload, + }), + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: DevinSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const providerSessionIdFor = (ctx: DevinSessionContext): string | undefined => + parseDevinResume(ctx.session.resumeCursor)?.sessionId; + + /** Emits the ACP context-window update used by the composer meter. */ + const emitDevinContextUsage = ( + ctx: DevinSessionContext, + update: EffectAcpSchema.UsageUpdate, + rawPayload: unknown, + ) => + Effect.gen(function* () { + const usedTokens = nonNegativeInteger(update.used); + const reportedMaxTokens = nonNegativeInteger(update.size); + const maxTokens = + reportedMaxTokens > 0 + ? reportedMaxTokens + : (inferDevinContextWindowTokens(ctx.activeModelUid ?? ctx.session.model) ?? 0); + ctx.lastContextWindowUsed = usedTokens; + ctx.lastContextWindowSize = maxTokens > 0 ? maxTokens : undefined; + + const sessionCostUsd = acpCostAmountUsd(update.cost); + if (sessionCostUsd !== undefined) { + const previousCost = ctx.lastAcpCostUsd; + const costDelta = + previousCost === undefined + ? sessionCostUsd + : Math.max(0, sessionCostUsd - previousCost); + ctx.pendingCostDeltaUsd = (ctx.pendingCostDeltaUsd ?? 0) + costDelta; + ctx.lastAcpCostUsd = sessionCostUsd; + } + + const usage: ThreadTokenUsageSnapshot = { + usedTokens, + ...(ctx.totalProcessedTokens > 0 + ? { totalProcessedTokens: ctx.totalProcessedTokens } + : {}), + ...(maxTokens > 0 ? { maxTokens } : {}), + ...((ctx.activeModelUid ?? ctx.session.model) + ? { model: ctx.activeModelUid ?? ctx.session.model } + : {}), + ...(providerSessionIdFor(ctx) ? { providerSessionId: providerSessionIdFor(ctx) } : {}), + ...(sessionCostUsd !== undefined ? { sessionCostUsd } : {}), + ...(update.cost?.currency?.trim() ? { costCurrency: update.cost.currency.trim() } : {}), + }; + + yield* offerRuntimeEvent( + makeAcpTokenUsageEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + usage, + rawPayload, + }), + ); + }); + + /** Emits turn token deltas and preserves cumulative ACP context data. */ + const emitDevinPromptUsage = ( + ctx: DevinSessionContext, + usageInput: EffectAcpSchema.Usage | null | undefined, + rawPayload: unknown, + ) => + Effect.gen(function* () { + if (!usageInput) return; + + const current = normalizeDevinAcpUsage(usageInput); + const delta = subtractDevinAcpUsage(current, ctx.lastAcpUsage); + const previousTotal = ctx.totalProcessedTokens; + const reportedCumulative = + ctx.lastAcpUsage !== undefined && current.totalTokens >= ctx.lastAcpUsage.totalTokens; + const totalProcessedTokens = reportedCumulative + ? Math.max(previousTotal, current.totalTokens) + : previousTotal + delta.totalTokens; + ctx.totalProcessedTokens = totalProcessedTokens; + ctx.lastAcpUsage = current; + + const usedTokens = ctx.lastContextWindowUsed ?? 0; + const maxTokens = ctx.lastContextWindowSize; + const usage: ThreadTokenUsageSnapshot = { + usedTokens, + ...(totalProcessedTokens > 0 ? { totalProcessedTokens } : {}), + ...(maxTokens !== undefined ? { maxTokens } : {}), + ...((ctx.activeModelUid ?? ctx.session.model) + ? { model: ctx.activeModelUid ?? ctx.session.model } + : {}), + ...(providerSessionIdFor(ctx) ? { providerSessionId: providerSessionIdFor(ctx) } : {}), + inputTokens: current.inputTokens, + cachedInputTokens: current.cachedReadTokens, + cacheCreationTokens: current.cachedWriteTokens, + outputTokens: current.outputTokens, + reasoningOutputTokens: current.thoughtTokens, + lastUsedTokens: delta.totalTokens, + lastInputTokens: delta.inputTokens, + lastCachedInputTokens: delta.cachedReadTokens, + lastCacheCreationTokens: delta.cachedWriteTokens, + lastOutputTokens: delta.outputTokens, + lastReasoningOutputTokens: delta.thoughtTokens, + ...(ctx.pendingCostDeltaUsd !== undefined + ? { lastCostUsd: ctx.pendingCostDeltaUsd } + : {}), + ...(ctx.lastAcpCostUsd !== undefined ? { sessionCostUsd: ctx.lastAcpCostUsd } : {}), + }; + ctx.pendingCostDeltaUsd = undefined; + + yield* offerRuntimeEvent( + makeAcpTokenUsageEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + method: "session/prompt", + usage, + rawPayload, + }), + ); + }); + + // Tears down the ACP runtime (child process, notification fiber, scope) + // without emitting session.exited or removing the context from the + // sessions map. Used by the model-change restart path so the visible T3 + // thread stays continuous. + const teardownAcpRuntime = (ctx: DevinSessionContext) => + Effect.gen(function* () { + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + ctx.notificationFiber = undefined; + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + }); + + // Spawns and starts a runtime in the caller's scope. The caller retains + // cleanup ownership until configuration and notification setup succeed. + // Shared by startSession and the model-change restart path. + const createAcpRuntime = (input: { + readonly scope: Scope.Closeable; + readonly threadId: ThreadId; + readonly cwd: string; + readonly runtimeMode: RuntimeMode; + readonly resumeSessionId: string | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + readonly ctxRef: { current: DevinSessionContext | undefined }; + }): Effect.Effect< + { + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly started: AcpSessionRuntime.AcpSessionRuntimeStartResult; + }, + ProviderAdapterError + > => + Effect.gen(function* () { + const sessionScope = input.scope; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + const effectiveDevinSettings = options?.resolveSettings + ? yield* options.resolveSettings + : devinSettings; + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + + const acp = yield* makeDevinAcpRuntime({ + devinSettings: effectiveDevinSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + 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, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const started = yield* Effect.gen(function* () { + yield* acp.handleUnknownExtRequest((method, params) => + mapExtensionFailure( + logNative(input.threadId, method, params, "acp.devin.extension").pipe(Effect.as({})), + ), + ); + yield* acp.handleUnknownExtNotification((method, params) => + mapExtensionFailure(logNative(input.threadId, method, params, "acp.devin.extension")), + ); + yield* acp.handleRequestPermission((params) => + mapExtensionFailure( + Effect.gen(function* () { + const { permissionRequest, payload } = sanitizeDevinPermissionRequest(params); + yield* logNative( + input.threadId, + "session/request_permission", + payload, + "acp.jsonrpc", + ); + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + const pending = { + decision, + kind: permissionRequest.kind, + options: params.options, + }; + input.pendingApprovals.set(requestId, pending); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: input.ctxRef.current?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + approvalOptions: devinApprovalOptions(params.options), + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(payload)?.slice(0, 2000) ?? + "[unserializable params]", + args: payload, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: payload, + }), + ); + const resolved = yield* Deferred.await(decision); + input.pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: input.ctxRef.current?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + const optionId = selectDevinPermissionOptionId(pending.options, resolved); + return { + outcome: + optionId === undefined + ? ({ outcome: "cancelled" } as const) + : { + outcome: "selected" as const, + optionId, + }, + }; + }), + ), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + return { acp, started }; + }); + + // Forks the notification consumer into the session scope. The ctx must + // already have its acp and scope fields set before calling this. + const startNotificationFiber = (ctx: DevinSessionContext) => + Stream.runDrain( + Stream.mapEffect(ctx.acp.getEvents(), (event) => + Effect.gen(function* () { + switch (event._tag) { + case "EventStreamBarrier": + yield* Deferred.succeed(event.acknowledge, undefined); + return; + case "ModeChanged": + return; + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* logNative(ctx.threadId, "session/update", event.rawPayload, "acp.jsonrpc"); + yield* emitPlanUpdate( + ctx, + event.payload, + event.rawPayload, + "acp.jsonrpc", + "session/update", + ); + return; + case "ToolCallUpdated": + { + const toolCall = sanitizeDevinToolCall(event.toolCall); + const rawPayload = sanitizeDevinToolCallRawPayload(event.rawPayload, toolCall); + yield* logNative(ctx.threadId, "session/update", rawPayload, "acp.jsonrpc"); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall, + rawPayload, + }), + ); + } + return; + case "ContentDelta": + yield* logNative(ctx.threadId, "session/update", event.rawPayload, "acp.jsonrpc"); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + case "UsageUpdated": + yield* logNative(ctx.threadId, "session/update", event.rawPayload, "acp.jsonrpc"); + yield* emitDevinContextUsage(ctx, event.usage, event.rawPayload); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Devin runtime notification.", { cause }), + ), + Effect.forkIn(ctx.scope), + ); + + const startSession: DevinAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const devinModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const pendingApprovals = new Map(); + const pendingUserInputs = new Map(); + const ctxRef: { current: DevinSessionContext | undefined } = { current: undefined }; + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + + const resumeSessionId = parseDevinResume(input.resumeCursor)?.sessionId; + const { acp, started } = yield* createAcpRuntime({ + scope: sessionScope, + threadId: input.threadId, + cwd, + runtimeMode: input.runtimeMode, + resumeSessionId, + pendingApprovals, + pendingUserInputs, + ctxRef, + }); + + yield* applyRequestedSessionConfiguration({ + runtime: acp, + runtimeMode: input.runtimeMode, + interactionMode: undefined, + modelSelection: devinModelSelection, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: devinModelSelection?.model, + threadId: input.threadId, + resumeCursor: { + schemaVersion: DEVIN_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + const ctx: DevinSessionContext = { + threadId: input.threadId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + activePromptLeases: new Set(), + lastContextWindowUsed: undefined, + lastContextWindowSize: undefined, + lastAcpUsage: undefined, + lastAcpCostUsd: undefined, + pendingCostDeltaUsd: undefined, + totalProcessedTokens: 0, + activeModelUid: devinModelSelection + ? resolveDevinModelUid(devinModelSelection.model, devinModelSelection.options) + : undefined, + stopped: false, + }; + ctxRef.current = ctx; + + const nf = yield* startNotificationFiber(ctx); + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Devin ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + // Restarts the ACP session for a model change. Tears down the old + // runtime, spawns a new one with the new model, and restores context + // via loadSession. The visible T3 thread stays the same — no new thread, + // no session.exited event. Must be called under the thread lock. + const restartForModelChange = ( + ctx: DevinSessionContext, + newModel: string, + options?: ReadonlyArray | null, + ) => + Effect.gen(function* () { + const previousSessionId = parseDevinResume(ctx.session.resumeCursor)?.sessionId; + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { state: "starting", reason: "Reinitializing Devin session for model change" }, + }); + + yield* teardownAcpRuntime(ctx); + + const newScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => { + if (sessionScopeTransferred) return Effect.void; + ctx.stopped = true; + sessions.delete(ctx.threadId); + return Scope.close(newScope, Exit.void); + }); + const ctxRef: { current: DevinSessionContext | undefined } = { current: ctx }; + const { acp, started } = yield* createAcpRuntime({ + scope: newScope, + threadId: ctx.threadId, + cwd: ctx.session.cwd ?? process.cwd(), + runtimeMode: ctx.session.runtimeMode, + // Resume from the previous Devin session so the new ACP child + // restores conversation context via loadSession. + resumeSessionId: previousSessionId, + pendingApprovals: ctx.pendingApprovals, + pendingUserInputs: ctx.pendingUserInputs, + ctxRef, + }); + + // Apply the new model to the fresh session. `newModel` is the base + // (group) slug; the reasoning option is folded in to form the full + // UID Devin's backend expects. + yield* applyDevinAcpModelSelection({ + runtime: acp, + model: newModel, + selections: options, + mapError: ({ cause }) => + mapAcpToAdapterError(PROVIDER, ctx.threadId, "session/set_config_option", cause), + }); + + ctx.acp = acp; + ctx.scope = newScope; + ctx.session = { + ...ctx.session, + model: newModel, + resumeCursor: { + schemaVersion: DEVIN_RESUME_VERSION, + sessionId: started.sessionId, + }, + updatedAt: yield* nowIso, + }; + ctx.activeModelUid = resolveDevinModelUid(newModel, options); + + const nf = yield* startNotificationFiber(ctx); + ctx.notificationFiber = nf; + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { state: "ready", reason: "Devin session ready after model change" }, + }); + }).pipe(Effect.scoped); + + const sendTurn: DevinAdapterShape["sendTurn"] = (input) => + Effect.acquireUseRelease( + // Preparation (prompt validation, turn accounting, model-change + // restart, config) runs under the thread lock so two concurrent + // sendTurn calls cannot both observe no active leases and open + // duplicate turns. Keep fallible preparation interruptible even + // though acquireUseRelease protects the accounting handoff. + withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + + const effectiveDevinSettings = options?.resolveSettings + ? yield* options.resolveSettings + : devinSettings; + // Known `$skill` mentions become Devin's native `@skills:name` + // before the prompt is built. Discovery is lazy (a candidate token + // is required) and best-effort: a failure leaves the prompt as-is. + const trimmedInput = input.input?.trim(); + const dispatchedInput = + trimmedInput && ctx.session.cwd + ? yield* dispatchDevinSkills(trimmedInput, ctx.session.cwd, effectiveDevinSettings) + : trimmedInput; + + const promptParts: Array = []; + if (dispatchedInput) { + promptParts.push({ type: "text", text: dispatchedInput }); + } + if (input.attachments && input.attachments.length > 0) { + for (const attachment of input.attachments) { + // Devin ingests images only. Generic files reach the agent + // through the path line ProviderService puts in the prompt. + if (attachment.type !== "image") { + continue; + } + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + const imageBase64 = Buffer.from(bytes).toString("base64"); + promptParts.push({ + type: "image", + data: imageBase64, + mimeType: attachment.mimeType, + }); + } + } + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + // 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.activePromptLeases.size > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId + ? input.modelSelection + : undefined; + const model = turnModelSelection?.model ?? ctx.session.model; + const resolvedModel = resolveDevinAcpBaseModelId(model); + const resolvedModelUid = resolveDevinModelUid(model, turnModelSelection?.options); + + // If the base model changed on an existing session, restart the + // ACP session internally. The visible T3 thread stays the same. + // A reasoning-only change (same base) is applied below as a + // config-option tweak without a restart. + const previousModel = resolveDevinAcpBaseModelId(ctx.session.model); + if ( + steeringTurnId === undefined && + resolvedModel !== previousModel && + ctx.session.model !== undefined + ) { + yield* restartForModelChange(ctx, resolvedModel, turnModelSelection?.options); + } + + yield* applyRequestedSessionConfiguration({ + runtime: ctx.acp, + runtimeMode: ctx.session.runtimeMode, + interactionMode: input.interactionMode, + modelSelection: + model === undefined + ? undefined + : { + model, + options: turnModelSelection?.options, + }, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + + // Resolve every fallible value before acquiring the accounting + // slot. Once counted, the uninterruptible handoff publishes the + // start event and returns the resource as one acquisition step. + const updatedAt = yield* nowIso; + const turnStartedStamp = + steeringTurnId === undefined ? yield* makeEventStamp() : undefined; + const lease = makeDevinPromptLease(turnId); + + return yield* Effect.uninterruptible( + Effect.gen(function* () { + ctx.activePromptLeases.add(lease); + ctx.activeTurnId = turnId; + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + ...(model !== undefined ? { model: resolvedModel } : {}), + updatedAt, + }; + if (model !== undefined) ctx.activeModelUid = resolvedModelUid; + + if (turnStartedStamp !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...turnStartedStamp, + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model: resolvedModel }, + }); + } + + return { ctx, turnId, promptParts, resolvedModel, lease }; + }), + ); + }), + ).pipe(Effect.interruptible), + // Run the prompt outside the thread lock so interrupts and steers + // can still acquire it while the RPC is in flight. + ({ ctx, turnId, promptParts, resolvedModel, lease }) => + Effect.gen(function* () { + const result = yield* ctx.acp + .prompt({ + prompt: promptParts, + }) + .pipe( + // Map ACP protocol errors to adapter errors first, before the + // timeout wrapper adds a non-ACP error type to the channel. + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + Effect.timeoutOption(promptTimeout), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + withThreadLock( + input.threadId, + Effect.gen(function* () { + // A timeout from an invalidated turn must not reset + // or cancel a replacement prompt on the same ACP + // session. + if (!isDevinPromptLeaseCurrent(ctx, lease)) { + settleDevinPromptLease(ctx, lease); + return; + } + + const updatedAt = yield* nowIso; + ctx.activePromptLeases.clear(); + ctx.activeTurnId = undefined; + ctx.session = { + ...ctx.session, + activeTurnId: undefined, + updatedAt, + }; + // Keep cancellation under the thread lock so a new + // prompt cannot become active between the reset and + // the session-wide cancel notification. + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError( + PROVIDER, + input.threadId, + "session/cancel", + error, + ), + ), + ), + ); + }), + ).pipe( + Effect.andThen(mapPromptTimeout(PROVIDER, input.threadId, promptTimeout)), + ), + onSome: Effect.succeed, + }), + ), + ); + + // Keep result projection and prompt accounting atomic with + // interrupt, timeout, and replacement-turn acquisition. + yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + if (!isDevinPromptLeaseCurrent(ctx, lease)) { + settleDevinPromptLease(ctx, lease); + return; + } + + // ACP prompt responses may carry cumulative token usage. + if (result?.usage) { + yield* emitDevinPromptUsage(ctx, result.usage, { + method: "session/prompt", + result, + }); + } + + const updatedAt = yield* nowIso; + const completesTurn = ctx.activePromptLeases.size === 1; + const stopReason = result?.stopReason ?? null; + const completionStamp = completesTurn ? yield* makeEventStamp() : undefined; + + yield* Effect.uninterruptible( + Effect.gen(function* () { + const turnRecord = ctx.turns.find((turn) => turn.id === turnId); + if (turnRecord) { + turnRecord.items.push({ prompt: promptParts, result: result ?? null }); + } else { + ctx.turns.push({ + id: turnId, + items: [{ prompt: promptParts, result: result ?? null }], + }); + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt, + model: resolvedModel, + }; + + settleDevinPromptLease(ctx, lease); + if (completionStamp !== undefined) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...completionStamp, + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: stopReason === "cancelled" ? "cancelled" : "completed", + stopReason, + }, + }); + } + }), + ); + }), + ); + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }), + ({ ctx, lease }) => + withThreadLock( + input.threadId, + Effect.sync(() => { + settleDevinPromptLease(ctx, lease); + }), + ), + ); + + const interruptTurn: DevinAdapterShape["interruptTurn"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + // Reset turn accounting so the next sendTurn opens a fresh turn + // instead of steering into a stuck one. Without this, a cancelled + // stuck prompt leaves an active lease and the session never + // recovers. + ctx.activePromptLeases.clear(); + ctx.activeTurnId = undefined; + ctx.session = { + ...ctx.session, + activeTurnId: undefined, + updatedAt: yield* nowIso, + }; + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + }), + ); + + const respondToRequest: DevinAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + if ( + decision !== "cancel" && + selectDevinPermissionOptionId(pending.options, decision) === undefined + ) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Devin did not advertise an option for approval decision '${decision}'.`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: DevinAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/elicitation", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.answers, answers); + }); + + const readThread: DevinAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: DevinAdapterShape["rollbackThread"] = () => + Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "Devin does not support conversation rewind. Start a new thread instead.", + }), + ); + + const stopSession: DevinAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: DevinAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: DevinAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: DevinAdapterShape["stopAll"] = () => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( + Effect.catch((cause) => + Effect.logError("Failed to emit Devin session shutdown event.", { cause }), + ), + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies DevinAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/DevinProvider.test.ts b/apps/server/src/provider/Layers/DevinProvider.test.ts new file mode 100644 index 000000000000..cb50690f9314 --- /dev/null +++ b/apps/server/src/provider/Layers/DevinProvider.test.ts @@ -0,0 +1,364 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildDevinModelsFromPayload, + inferDevinContextWindowTokens, + parseDevinHumanModelList, + parseDevinModelUid, +} from "./DevinProvider.ts"; + +describe("buildDevinModelsFromPayload", () => { + it("maps families, marks adaptive as default, and removes duplicate ids", () => { + const models = buildDevinModelsFromPayload({ + families: [ + { + family_label: "Devin", + family_uid: "devin", + variants: [ + { model_uid: "adaptive", label: "Adaptive" }, + { model_uid: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, + ], + }, + { + family_label: "Duplicate", + family_uid: "duplicate", + variants: [{ model_uid: "adaptive", label: "Adaptive again" }], + }, + ], + }); + + expect( + models.map(({ slug, name, subProvider, isDefault }) => ({ + slug, + name, + subProvider, + isDefault, + })), + ).toEqual([ + { slug: "adaptive", name: "Adaptive", subProvider: "Devin", isDefault: true }, + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + subProvider: "Devin", + isDefault: undefined, + }, + ]); + }); + + it("populates per-model input capabilities from the known mapping", () => { + const models = buildDevinModelsFromPayload({ + families: [ + { + family_label: "Devin", + family_uid: "devin", + variants: [ + { model_uid: "adaptive", label: "Adaptive" }, + { model_uid: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, + ], + }, + ], + }); + + const adaptive = models.find((model) => model.slug === "adaptive"); + // Devin models accept images and files but not audio. + expect(adaptive?.capabilities?.inputImages).not.toBe(false); + expect(adaptive?.capabilities?.inputFiles).not.toBe(false); + expect(adaptive?.capabilities?.inputAudio).toBe(false); + + // An unmapped slug keeps the permissive default (no capability fields set). + const unmapped = models.find((model) => model.slug === "claude-sonnet-4-6"); + expect(unmapped?.capabilities?.inputImages).toBeUndefined(); + expect(unmapped?.capabilities?.inputAudio).toBeUndefined(); + expect(unmapped?.capabilities?.inputFiles).toBeUndefined(); + }); + + it("groups reasoning variants into one model with a reasoning option descriptor", () => { + const models = buildDevinModelsFromPayload({ + families: [ + { + family_label: "Claude Opus 5", + family_uid: "claude-opus-5", + variants: [ + { model_uid: "claude-opus-5-low", label: "Claude Opus 5 Low" }, + { model_uid: "claude-opus-5-medium", label: "Claude Opus 5 Medium" }, + { model_uid: "claude-opus-5-high", label: "Claude Opus 5 High" }, + ], + }, + ], + }); + + expect(models.map((m) => m.slug)).toEqual(["claude-opus-5"]); + const grouped = models[0]!; + expect(grouped.name).toBe("Claude Opus 5"); + const descriptor = grouped.capabilities?.optionDescriptors?.[0]; + expect(descriptor?.type).toBe("select"); + expect(descriptor?.id).toBe("reasoning"); + const options = descriptor?.type === "select" ? descriptor.options : undefined; + expect(options?.map((o) => ({ id: o.id, label: o.label, isDefault: o.isDefault }))).toEqual([ + { id: "low", label: "Low", isDefault: undefined }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High", isDefault: undefined }, + ]); + }); + + it("groups speed-tier variants into one model with a speed option", () => { + const models = buildDevinModelsFromPayload({ + families: [ + { + family_label: "Claude Opus 5", + family_uid: "claude-opus-5", + variants: [ + { model_uid: "claude-opus-5-medium", label: "Claude Opus 5 Medium" }, + { model_uid: "claude-opus-5-low", label: "Claude Opus 5 Low" }, + { model_uid: "claude-opus-5-medium-fast", label: "Claude Opus 5 Medium Fast" }, + { model_uid: "claude-opus-5-low-fast", label: "Claude Opus 5 Low Fast" }, + ], + }, + ], + }); + + const slugs = models.map((m) => m.slug); + expect(slugs).toEqual(["claude-opus-5"]); + const descriptors = models[0]!.capabilities?.optionDescriptors ?? []; + const speedDescriptor = descriptors.find((d) => d.id === "speed"); + expect(speedDescriptor?.type).toBe("select"); + expect( + speedDescriptor?.type === "select" ? speedDescriptor.options.map((o) => o.id) : [], + ).toEqual(["standard", "fast"]); + }); + + it("groups uppercase enum-style variants and defaults to medium", () => { + const models = buildDevinModelsFromPayload({ + families: [ + { + family_label: "GPT 5.2", + family_uid: "gpt-5-2", + variants: [ + { model_uid: "MODEL_GPT_5_2_LOW", label: "GPT 5.2 Low" }, + { model_uid: "MODEL_GPT_5_2_MEDIUM", label: "GPT 5.2 Medium" }, + { model_uid: "MODEL_GPT_5_2_HIGH", label: "GPT 5.2 High" }, + ], + }, + ], + }); + + expect(models.map((m) => m.slug)).toEqual(["MODEL_GPT_5_2"]); + const upperDescriptor = models[0]!.capabilities?.optionDescriptors?.[0]; + const upperOptions = upperDescriptor?.type === "select" ? upperDescriptor.options : undefined; + expect(upperOptions?.find((o) => o.id === "medium")?.isDefault).toBe(true); + }); + + it("keeps GLM reasoning limited to none, high, and max across context windows", () => { + const models = buildDevinModelsFromPayload({ + families: [ + { + family_label: "GLM-5.2", + family_uid: "glm-5.2", + variants: [ + { model_uid: "glm-5-2", label: "GLM-5.2 High" }, + { model_uid: "glm-5-2-max", label: "GLM-5.2 Max" }, + { model_uid: "glm-5-2-1m", label: "GLM-5.2 High 1M" }, + { model_uid: "glm-5-2-max-1m", label: "GLM-5.2 Max 1M" }, + { model_uid: "glm-5-2-none", label: "GLM-5.2 No Thinking" }, + { model_uid: "glm-5-2-none-1m", label: "GLM-5.2 No Thinking 1M" }, + ], + }, + ], + }); + expect(models).toHaveLength(1); + expect(models[0]?.name).toBe("GLM-5.2"); + const descriptors = models[0]?.capabilities?.optionDescriptors ?? []; + const reasoning = descriptors.find((descriptor) => descriptor.id === "reasoning"); + expect( + reasoning?.type === "select" ? reasoning.options.map((option) => option.id) : [], + ).toEqual(["none", "high", "max"]); + expect( + reasoning?.type === "select" + ? reasoning.options.find((option) => option.isDefault)?.id + : undefined, + ).toBe("high"); + const context = descriptors.find((descriptor) => descriptor.id === "contextWindow"); + expect(context?.type === "select" ? context.options.map((option) => option.id) : []).toEqual([ + "200k", + "1m", + ]); + expect( + context?.type === "select" + ? context.options.find((option) => option.isDefault)?.id + : undefined, + ).toBe("200k"); + }); + + it("groups opaque private UIDs under their family and preserves concrete reasoning UIDs", () => { + const models = buildDevinModelsFromPayload({ + families: [ + { + family_label: "Claude Sonnet 4.5", + family_uid: "claude-sonnet-4.5", + variants: [ + { model_uid: "MODEL_PRIVATE_2", label: "Claude Sonnet 4.5" }, + { model_uid: "MODEL_PRIVATE_3", label: "Claude Sonnet 4.5 Thinking" }, + ], + }, + ], + }); + + expect(models).toHaveLength(1); + expect(models[0]?.slug).toBe("MODEL_PRIVATE_2"); + const reasoning = models[0]?.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "reasoning", + ); + expect(reasoning?.type === "select" ? reasoning.options : []).toEqual([ + { id: "none", label: "None", isDefault: true }, + { id: "__uid:MODEL_PRIVATE_3", label: "Thinking" }, + ]); + }); + + it("leaves unrelated models without a reasoning suffix as standalone variants", () => { + const models = buildDevinModelsFromPayload({ + families: [ + { + family_label: "Misc", + family_uid: "misc", + variants: [ + { model_uid: "kimi-k2-6", label: "Kimi K2.6" }, + { model_uid: "claude-opus-4-6-1m", label: "Claude Opus 4.6 1M" }, + ], + }, + ], + }); + + expect(models.map((m) => m.slug)).toEqual(["kimi-k2-6", "claude-opus-4-6-1m"]); + for (const model of models) { + expect(model.capabilities?.optionDescriptors).toEqual([]); + } + }); + + it("keeps explicit context metadata when the JSON catalog omits UID suffixes", () => { + const models = buildDevinModelsFromPayload({ + families: [ + { + family_label: "GLM-5.2", + family_uid: "glm-5.2", + variants: [ + { + model_uid: "glm-5-2", + label: "GLM-5.2 High", + context_window: 200_000, + pricing: { inputPerMillion: 0, outputPerMillion: 0 }, + }, + { + model_uid: "glm-5-2-1m", + label: "GLM-5.2 High 1M", + context_window: 1_000_000, + pricing: { inputPerMillion: 0.7, outputPerMillion: 2.2 }, + }, + { + model_uid: "glm-5-2-none", + label: "GLM-5.2 No Thinking", + context_window: 200_000, + pricing: { inputPerMillion: 0.7, outputPerMillion: 2.2 }, + }, + ], + }, + ], + }); + + const glm = models[0]!; + const context = glm.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "contextWindow", + ); + expect(context?.type === "select" ? context.options.map((option) => option.id) : []).toEqual([ + "200k", + "1m", + ]); + expect(glm.contextWindowTokens).toBe(1_000_000); + expect(glm.pricingByVariant?.["glm-5-2-1m"]?.outputPerMillion).toBe(2.2); + }); +}); + +describe("Devin model catalog fallbacks", () => { + it("parses the human-readable CLI catalog with context and prices", () => { + const payload = parseDevinHumanModelList( + `Available models (1 families)\n\nGLM-5.2 (glm-5.2)\n aliases: glm\n glm-5-2 GLM-5.2 High [200K context, Free]\n glm-5-2-max GLM-5.2 Max [200K context, $0.7 / 1M Input · $0.13 / 1M Cached input · $2.2 / 1M Output]\n glm-5-2-none-1m GLM-5.2 No Thinking 1M [1M context, $0.7 / 1M Input · $0.13 / 1M Cached input · $2.2 / 1M Output]`, + ); + + expect(payload?.families).toHaveLength(1); + expect(payload?.families[0]?.variants).toHaveLength(3); + expect(payload?.families[0]?.variants[1]?.contextWindow).toBe(200_000); + const variantPricing = payload?.families[0]?.variants[1]?.pricing as + | { outputPerMillion?: number } + | undefined; + expect(variantPricing?.outputPerMillion).toBe(2.2); + + const glm = buildDevinModelsFromPayload(payload!)[0]!; + expect(glm.slug).toBe("glm-5-2"); + expect(glm.pricingByVariant?.["glm-5-2-max"]?.cachedInputPerMillion).toBe(0.13); + expect(glm.contextWindowTokens).toBe(1_000_000); + }); + + it("infers known Devin context limits from family and variant UIDs", () => { + expect(inferDevinContextWindowTokens("glm-5-2-max")).toBe(200_000); + expect(inferDevinContextWindowTokens("glm-5-2-max-1m")).toBe(1_000_000); + expect(inferDevinContextWindowTokens("MODEL_GPT_5_2_HIGH")).toBe(384_000); + expect(inferDevinContextWindowTokens("unknown-model")).toBeUndefined(); + }); +}); + +describe("parseDevinModelUid", () => { + it("extracts reasoning and speed from lowercase hyphenated UIDs", () => { + expect(parseDevinModelUid("claude-opus-5-medium")).toEqual({ + base: "claude-opus-5", + reasoning: "medium", + speed: undefined, + contextWindow: undefined, + }); + expect(parseDevinModelUid("claude-opus-5-medium-fast")).toEqual({ + base: "claude-opus-5", + reasoning: "medium", + speed: "fast", + contextWindow: undefined, + }); + }); + + it("extracts reasoning from uppercase underscore UIDs", () => { + expect(parseDevinModelUid("MODEL_GPT_5_2_LOW")).toEqual({ + base: "MODEL_GPT_5_2", + reasoning: "low", + speed: undefined, + contextWindow: undefined, + }); + }); + + it("extracts context window suffixes", () => { + expect(parseDevinModelUid("glm-5-2-max-1m")).toEqual({ + base: "glm-5-2", + reasoning: "max", + speed: undefined, + contextWindow: "1m", + }); + }); + + it("returns no reasoning for UIDs without a known suffix", () => { + expect(parseDevinModelUid("kimi-k2-6")).toEqual({ + base: "kimi-k2-6", + reasoning: undefined, + speed: undefined, + contextWindow: undefined, + }); + expect(parseDevinModelUid("claude-opus-4-6-thinking")).toEqual({ + base: "claude-opus-4-6", + reasoning: "thinking", + speed: undefined, + contextWindow: undefined, + }); + }); + + it("does not treat a bare speed suffix as a speed tier without reasoning", () => { + expect(parseDevinModelUid("claude-opus-5-fast")).toEqual({ + base: "claude-opus-5-fast", + reasoning: undefined, + speed: undefined, + }); + }); +}); diff --git a/apps/server/src/provider/Layers/DevinProvider.ts b/apps/server/src/provider/Layers/DevinProvider.ts new file mode 100644 index 000000000000..05e08ac3640d --- /dev/null +++ b/apps/server/src/provider/Layers/DevinProvider.ts @@ -0,0 +1,1101 @@ +import { + type DevinSettings, + type ModelCapabilities, + type ModelPricing, + type ProviderOptionChoice, + type SelectProviderOptionDescriptor, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { ChildProcess } from "effect/unstable/process"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; + +/** + * Bumped whenever Devin's model inventory representation changes. The + * provider registry uses this marker to prevent an older flattened snapshot + * from being merged into the parent/family catalog. v3 invalidates snapshots + * written by the first grouped implementation so the picker cannot retain + * rows such as `Max 1M`, `Fast`, or `Priority` as standalone models. + */ +export const DEVIN_MODEL_CATALOG_VERSION = "devin-model-catalog-v3"; + +const DEVIN_PRESENTATION = { + displayName: "Devin", + badgeLabel: "Early Access", + showInteractionModeToggle: true, + requiresNewThreadForModelChange: false, + modelCatalogVersion: DEVIN_MODEL_CATALOG_VERSION, +} as const; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [] }); +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const MODEL_PROBE_TIMEOUT_MS = 15_000; + +/** + * Reasoning levels Devin bakes into model UIDs as a trailing suffix, ordered + * from least to most effort. Used both to group variants into a single + * selectable model and to order the option choices in the picker. + */ +const DEVIN_REASONING_LEVELS = [ + "none", + "low", + "medium", + "high", + "xhigh", + "max", + "thinking", +] as const; + +const DEVIN_REASONING_LABELS: Readonly> = { + none: "None", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "X-High", + max: "Max", + thinking: "Thinking", +}; +const DEVIN_SPEED_LABELS: Readonly> = { + standard: "Standard", + fast: "Fast", + priority: "Priority", +}; +const DEVIN_DEFAULT_CONTEXT_WINDOWS: Readonly> = { + "glm-5-2": "200k", +}; + +/** + * Fallback context sizes used when an ACP/CLI catalog omits the field. Devin + * currently advertises these limits in its model catalog. Keeping this map in + * the provider layer means the context meter still has a useful denominator + * while a model is streaming its first usage update. + */ +const DEVIN_CONTEXT_WINDOWS: Readonly> = { + "claude-opus-5": 1_000_000, + "claude-5-fable": 1_000_000, + "claude-sonnet-5": 1_000_000, + "gemini-3-7-flash": 1_048_576, + "gpt-5-6-sol": 1_000_000, + "gpt-5-6-luna": 1_000_000, + "glm-5-2": 200_000, + "kimi-k3": 1_048_576, + "swe-1-7": 262_000, + "swe-1-7-lightning": 202_752, + adaptive: 1_000_000, + "claude-opus-4-7": 1_000_000, + "claude-opus-4-8": 1_000_000, + "gemini-3-5-flash": 1_048_576, + "gemini-3-6-flash": 1_048_576, + "gpt-5-6-terra": 1_000_000, + "grok-4-5": 500_000, + "grok-4-6": 500_000, + inkling: 1_048_576, + "deepseek-v4-flash": 1_048_576, + "claude-opus-4-6": 200_000, + "gpt-5-4": 272_000, + "gpt-5-5": 272_000, + "gpt-5-4-mini": 400_000, + "claude-sonnet-4-6": 200_000, + "gpt-5-2": 384_000, + model_gpt_5_2: 384_000, + "claude-opus-4-5": 200_000, + "claude-haiku-4-5": 200_000, + "claude-sonnet-4-5": 200_000, + "gpt-4-1": 1_047_576, + "gpt-5-1": 272_000, + model_private_12: 272_000, + model_private_13: 272_000, + model_private_14: 272_000, + model_private_15: 272_000, + "gpt-5-3-codex": 400_000, + "kimi-k2-6": 262_144, + "kimi-k2-7": 262_144, + "nemotron-3-ultra": 1_000_000, + "swe-1-6": 200_000, + "swe-1-6-fast": 200_000, + "gemini-3-1-pro": 1_048_576, + "gemini-3-flash": 1_048_576, + "deepseek-v4-pro": 1_048_576, +}; + +export function inferDevinContextWindowTokens( + modelUid: string | null | undefined, +): number | undefined { + const trimmed = modelUid?.trim(); + if (!trimmed) return undefined; + const parsed = parseDevinModelUid(trimmed); + if (parsed.contextWindow) return parseContext(parsed.contextWindow) * 1_000; + const normalizedBase = parsed.base.toLowerCase(); + return ( + DEVIN_CONTEXT_WINDOWS[normalizedBase] ?? + DEVIN_CONTEXT_WINDOWS[normalizedBase.replaceAll("_", "-")] + ); +} + +/** + * Speed suffixes that follow the reasoning level in a model UID + * (e.g. `claude-opus-5-medium-fast`). Kept separate from reasoning so the + * group key can carry the speed while reasoning becomes an option choice. + */ +const LOWER_REASONING_SUFFIX = /-(none|low|medium|high|xhigh|max|thinking)$/; +const LOWER_SPEED_SUFFIX = /-(fast|priority)$/; +const UPPER_REASONING_SUFFIX = /_(NONE|LOW|MEDIUM|HIGH|XHIGH|MAX|THINKING)$/; +const UPPER_SPEED_SUFFIX = /_(FAST|PRIORITY)$/; +const LOWER_CONTEXT_SUFFIX = /-(\d+(?:k|m))$/i; +const UPPER_CONTEXT_SUFFIX = /_(\d+(?:K|M))$/; + +function capitalize(word: string): string { + return word.charAt(0).toUpperCase() + word.slice(1); +} + +function parseContext(value: string): number { + const match = value.match(/^(\d+)(k|m)$/i); + if (!match) return Number.MAX_SAFE_INTEGER; + const amount = Number(match[1]); + return match[2]!.toLowerCase() === "m" ? amount * 1000 : amount; +} + +function inferReasoningFromLabel(label: string): string { + const normalized = label.toLowerCase(); + if (normalized.includes("no thinking") || normalized.includes("none")) return "none"; + for (const level of ["xhigh", "high", "medium", "low", "max", "thinking"]) { + if (normalized.includes(level)) return level; + } + return "none"; +} + +function labelHasReasoningSignal(label: string): boolean { + return /\b(?:no\s+thinking|none|low|medium|high|x[- ]?high|max|thinking)\b/iu.test(label); +} + +/** + * Extracts the `{ base, reasoning, speed }` triple from a Devin model UID. + * + * Devin bakes the reasoning level (and an optional speed tier) into the UID + * as a trailing suffix: `claude-opus-5-medium`, `claude-opus-5-medium-fast`. + * Uppercase enum-style UIDs use underscore separators: `MODEL_GPT_5_2_LOW`. + * + * Speed only counts when a reasoning level precedes it — a bare `-fast` + * without a reasoning suffix is treated as part of the base so it cannot + * collide with a grouped family that happens to share the same speed tier. + * Models with no recognizable reasoning suffix are single variants. + */ +export function parseDevinModelUid(uid: string): { + base: string; + reasoning: string | undefined; + speed: string | undefined; + contextWindow: string | undefined; +} { + const trimmed = uid.trim(); + const strip = (value: string, re: RegExp): [string, string] | undefined => { + const match = value.match(re); + if (!match) return undefined; + return [value.slice(0, value.length - match[0].length), match[1]!.toLowerCase()]; + }; + + const contextMatch = strip(trimmed, LOWER_CONTEXT_SUFFIX) ?? strip(trimmed, UPPER_CONTEXT_SUFFIX); + const withoutContext = contextMatch?.[0] ?? trimmed; + const contextWindow = contextMatch?.[1]; + const speedMatch = + strip(withoutContext, LOWER_SPEED_SUFFIX) ?? strip(withoutContext, UPPER_SPEED_SUFFIX); + if (speedMatch) { + const [afterSpeed, speed] = speedMatch; + const reasoningMatch = + strip(afterSpeed, LOWER_REASONING_SUFFIX) ?? strip(afterSpeed, UPPER_REASONING_SUFFIX); + if (reasoningMatch) { + return { base: reasoningMatch[0], reasoning: reasoningMatch[1], speed, contextWindow }; + } + } + + const reasoningMatch = + strip(withoutContext, LOWER_REASONING_SUFFIX) ?? strip(withoutContext, UPPER_REASONING_SUFFIX); + if (reasoningMatch) { + return { + base: reasoningMatch[0], + reasoning: reasoningMatch[1], + speed: undefined, + contextWindow, + }; + } + return { base: withoutContext, reasoning: undefined, speed: undefined, contextWindow }; +} + +/** + * Group key for a parsed UID: the family base, with reasoning, speed, and + * context-window qualifiers removed. All variants in a Devin family collapse + * into one selectable parent model under this key; the qualifiers become + * option descriptors on that row. + */ +export function devinModelGroupKey(parsed: { base: string; speed?: string | undefined }): string { + return parsed.base; +} + +/** + * Known per-model input capabilities for Devin models. + * + * The `devin models list --format json` payload (see `DevinModelsPayload`) + * reports model families and variants but does not advertise input + * modalities, so we map them here from the model_uid. Devin is an agentic + * coding harness: every catalogued model accepts text, pasted/attached + * images, and uploaded file context. None of the current models accept + * audio input, so `inputAudio` is false across the board. A model_uid that + * is absent from this map falls back to the default (all modalities + * supported), which keeps newly added Devin models usable until this map + * is refreshed. + */ +const DEVIN_MODEL_INPUT_CAPABILITIES: Readonly< + Record +> = { + adaptive: { inputAudio: false }, +}; + +function devinModelCapabilities(slug: string): ModelCapabilities { + const overrides = DEVIN_MODEL_INPUT_CAPABILITIES[slug]; + if (!overrides) { + return EMPTY_CAPABILITIES; + } + return createModelCapabilities({ + optionDescriptors: [], + ...overrides, + }); +} + +const DEVIN_PRICING_SOURCE = "devin-cli models list"; + +function finiteNonNegative(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +function firstFiniteNonNegative(record: Record, keys: ReadonlyArray) { + for (const key of keys) { + const value = finiteNonNegative(record[key]); + if (value !== undefined) return value; + } + return undefined; +} + +/** Read either a per-million or per-token rate and normalize it to USD per + * million tokens. Devin has used both spellings across CLI releases, and a + * nested `pricing` object is used by newer releases. */ +function readNormalizedRate( + record: Record, + perMillionKeys: ReadonlyArray, + perTokenKeys: ReadonlyArray, +): number | undefined { + const perMillion = firstFiniteNonNegative(record, perMillionKeys); + if (perMillion !== undefined) return perMillion; + const perToken = firstFiniteNonNegative(record, perTokenKeys); + return perToken === undefined ? undefined : perToken * 1_000_000; +} + +function perMillionToPricing(input: { + readonly input: number; + readonly cachedInput?: number; + readonly cacheCreation?: number; + readonly output: number; + readonly contextWindowTokens?: number; +}): ModelPricing { + return { + inputPerMillion: input.input, + ...(input.cachedInput !== undefined ? { cachedInputPerMillion: input.cachedInput } : {}), + ...(input.cacheCreation !== undefined ? { cacheCreationPerMillion: input.cacheCreation } : {}), + outputPerMillion: input.output, + ...(input.contextWindowTokens !== undefined + ? { contextWindowTokens: input.contextWindowTokens } + : {}), + currency: "USD", + source: DEVIN_PRICING_SOURCE, + }; +} + +function parseContextWindowTokens(value: unknown): number | undefined { + if (typeof value === "number") return finiteNonNegative(value); + if (typeof value !== "string") return undefined; + const normalized = value.trim().toLowerCase(); + const match = /^(\d+(?:\.\d+)?)\s*(k|m)?$/.exec(normalized); + if (!match) return undefined; + const amount = Number(match[1]); + if (!Number.isFinite(amount) || amount <= 0) return undefined; + const multiplier = match[2] === "m" ? 1_000_000 : match[2] === "k" ? 1_000 : 1; + return Math.round(amount * multiplier); +} + +function formatContextWindowOption(tokens: number): string | undefined { + if (!Number.isFinite(tokens) || tokens <= 0) return undefined; + if (tokens % 1_000_000 === 0) return `${tokens / 1_000_000}m`; + if (tokens % 1_000 === 0) return `${tokens / 1_000}k`; + return `${tokens}`; +} + +/** + * Accepts the JSON shapes used by different Devin CLI releases. Older builds + * expose flat `*_per_million` fields, while newer builds nest rates under a + * `pricing` object and use per-token values. The normalized result is always + * USD per million tokens so it can be persisted in the provider snapshot. + */ +function parseDevinVariantPricing(variant: Record): ModelPricing | undefined { + const nested = + variant.pricing && typeof variant.pricing === "object" && !Array.isArray(variant.pricing) + ? (variant.pricing as Record) + : {}; + const input = + readNormalizedRate( + variant, + ["input_per_million", "inputPerMillion", "input_cost_per_million", "inputCostPerMillion"], + ["input_cost_per_token", "inputCostPerToken"], + ) ?? + readNormalizedRate( + nested, + ["input_per_million", "inputPerMillion", "input_cost_per_million", "inputCostPerMillion"], + ["input_cost_per_token", "inputCostPerToken"], + ); + const output = + readNormalizedRate( + variant, + ["output_per_million", "outputPerMillion", "output_cost_per_million", "outputCostPerMillion"], + ["output_cost_per_token", "outputCostPerToken"], + ) ?? + readNormalizedRate( + nested, + ["output_per_million", "outputPerMillion", "output_cost_per_million", "outputCostPerMillion"], + ["output_cost_per_token", "outputCostPerToken"], + ); + if (input === undefined || output === undefined) return undefined; + + const cached = + readNormalizedRate( + variant, + [ + "cached_input_per_million", + "cachedInputPerMillion", + "cache_read_cost_per_million", + "cacheReadCostPerMillion", + ], + [ + "cached_input_cost_per_token", + "cachedInputCostPerToken", + "cache_read_cost_per_token", + "cacheReadCostPerToken", + ], + ) ?? + readNormalizedRate( + nested, + [ + "cached_input_per_million", + "cachedInputPerMillion", + "cache_read_cost_per_million", + "cacheReadCostPerMillion", + ], + [ + "cached_input_cost_per_token", + "cachedInputCostPerToken", + "cache_read_cost_per_token", + "cacheReadCostPerToken", + ], + ); + const cacheCreation = + readNormalizedRate( + variant, + [ + "cache_creation_per_million", + "cacheCreationPerMillion", + "cache_write_cost_per_million", + "cacheWriteCostPerMillion", + ], + [ + "cache_creation_cost_per_token", + "cacheCreationCostPerToken", + "cache_write_cost_per_token", + "cacheWriteCostPerToken", + ], + ) ?? + readNormalizedRate( + nested, + [ + "cache_creation_per_million", + "cacheCreationPerMillion", + "cache_write_cost_per_million", + "cacheWriteCostPerMillion", + ], + [ + "cache_creation_cost_per_token", + "cacheCreationCostPerToken", + "cache_write_cost_per_token", + "cacheWriteCostPerToken", + ], + ); + const contextWindowTokens = + parseContextWindowTokens(variant.context_window ?? variant.contextWindow) ?? + parseContextWindowTokens(nested.context_window ?? nested.contextWindow); + return perMillionToPricing({ + input, + ...(cached !== undefined ? { cachedInput: cached } : {}), + ...(cacheCreation !== undefined ? { cacheCreation } : {}), + output, + ...(contextWindowTokens !== undefined ? { contextWindowTokens } : {}), + }); +} + +function pricingWithContext( + pricing: ModelPricing | undefined, + contextWindowTokens: number | undefined, +): ModelPricing | undefined { + if (!pricing) return undefined; + if (pricing.contextWindowTokens !== undefined || contextWindowTokens === undefined) { + return pricing; + } + return { ...pricing, contextWindowTokens }; +} + +function modelPricingRecord( + variants: ReadonlyMap, +): Readonly> | undefined { + if (variants.size === 0) return undefined; + return Object.fromEntries(variants.entries()); +} + +const DevinModelVariant = Schema.Struct({ + model_uid: Schema.String, + label: Schema.String, + context_window: Schema.optional(Schema.Union([Schema.Number, Schema.String])), + contextWindow: Schema.optional(Schema.Union([Schema.Number, Schema.String])), + input_per_million: Schema.optional(Schema.Number), + output_per_million: Schema.optional(Schema.Number), + cached_input_per_million: Schema.optional(Schema.Number), + cache_creation_per_million: Schema.optional(Schema.Number), + input_cost_per_million: Schema.optional(Schema.Number), + output_cost_per_million: Schema.optional(Schema.Number), + cache_read_cost_per_million: Schema.optional(Schema.Number), + cache_write_cost_per_million: Schema.optional(Schema.Number), + input_cost_per_token: Schema.optional(Schema.Number), + output_cost_per_token: Schema.optional(Schema.Number), + cached_input_cost_per_token: Schema.optional(Schema.Number), + cache_creation_cost_per_token: Schema.optional(Schema.Number), + cache_read_cost_per_token: Schema.optional(Schema.Number), + cache_write_cost_per_token: Schema.optional(Schema.Number), + inputPerMillion: Schema.optional(Schema.Number), + outputPerMillion: Schema.optional(Schema.Number), + cachedInputPerMillion: Schema.optional(Schema.Number), + cacheCreationPerMillion: Schema.optional(Schema.Number), + inputCostPerToken: Schema.optional(Schema.Number), + outputCostPerToken: Schema.optional(Schema.Number), + cachedInputCostPerToken: Schema.optional(Schema.Number), + cacheCreationCostPerToken: Schema.optional(Schema.Number), + cacheReadCostPerToken: Schema.optional(Schema.Number), + cacheWriteCostPerToken: Schema.optional(Schema.Number), + cacheReadCostPerMillion: Schema.optional(Schema.Number), + cacheWriteCostPerMillion: Schema.optional(Schema.Number), + pricing: Schema.optional(Schema.Unknown), +}); + +const DevinModelFamily = Schema.Struct({ + family_label: Schema.String, + family_uid: Schema.String, + variants: Schema.Array(DevinModelVariant), +}); + +const DevinModelsPayload = Schema.Struct({ + families: Schema.Array(DevinModelFamily), +}); + +const decodeDevinModels = Schema.decodeUnknownEffect(Schema.fromJsonString(DevinModelsPayload)); + +/** Parse the human-readable fallback emitted by older Devin CLIs. */ +export function parseDevinHumanModelList(output: string): typeof DevinModelsPayload.Type | null { + const families: Array<{ + family_label: string; + family_uid: string; + variants: Array>; + }> = []; + let current: + | { + family_label: string; + family_uid: string; + variants: Array>; + } + | undefined; + + for (const line of output.split(/\r?\n/u)) { + const familyMatch = /^\s*([^()\r\n]+?)\s+\(([^()\r\n]+)\)\s*$/u.exec(line); + if (familyMatch) { + if (familyMatch[1]!.trim().toLowerCase() === "available models") continue; + current = { + family_label: familyMatch[1]!.trim(), + family_uid: familyMatch[2]!.trim(), + variants: [], + }; + families.push(current); + continue; + } + if (!current || /^\s*aliases?:/iu.test(line)) continue; + + const variantMatch = /^\s{2,}(\S+)\s+(.+?)(?:\s+\[(.*)\])?\s*$/u.exec(line); + if (!variantMatch) continue; + const modelUid = variantMatch[1]!.trim(); + const label = variantMatch[2]!.trim(); + if (!modelUid || !label || modelUid === "Available") continue; + + const details = variantMatch[3] ?? ""; + const contextMatch = /(\d+(?:\.\d+)?)\s*(k|m)?\s+context\b/iu.exec(details); + const contextWindowTokens = contextMatch + ? parseContextWindowTokens(`${contextMatch[1]}${contextMatch[2] ?? ""}`) + : undefined; + const pricingValues = new Map(); + const pricePattern = /\$\s*(\d+(?:\.\d+)?)\s*\/\s*1M\s*(Input|Cached\s+input|Output)\b/giu; + for (const match of details.matchAll(pricePattern)) { + const amount = Number(match[1]); + const kind = match[2]!.toLowerCase().replaceAll(/\s+/gu, " "); + if (Number.isFinite(amount)) pricingValues.set(kind, amount); + } + if (/\bfree\b/iu.test(details) && pricingValues.size === 0) { + pricingValues.set("input", 0); + pricingValues.set("output", 0); + } + current.variants.push({ + model_uid: modelUid, + label, + ...(contextWindowTokens !== undefined ? { contextWindow: contextWindowTokens } : {}), + ...(pricingValues.size > 0 + ? { + pricing: { + inputPerMillion: pricingValues.get("input") ?? 0, + cachedInputPerMillion: pricingValues.get("cached input"), + outputPerMillion: pricingValues.get("output") ?? 0, + }, + } + : {}), + }); + } + + return families.length > 0 ? ({ families } as unknown as typeof DevinModelsPayload.Type) : null; +} + +export function buildInitialDevinProviderSnapshot( + settings: DevinSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const models = providerModelsFromSettings([], settings.customModels, EMPTY_CAPABILITIES); + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe: settings.enabled + ? { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Devin CLI availability...", + } + : { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Devin is disabled in T3 Code settings.", + }, + }); + }); +} + +function runDevinCommand( + settings: Pick, + args: ReadonlyArray, + environment?: NodeJS.ProcessEnv, +) { + return Effect.gen(function* () { + const command = settings.binaryPath || "devin"; + const spawn = yield* resolveSpawnCommand( + command, + args, + environment ? { env: environment } : {}, + ); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawn.command, spawn.args, { + ...(environment ? { env: environment } : { extendEnv: true }), + shell: spawn.shell, + }), + ); + }); +} + +export function buildDevinModelsFromPayload( + payload: typeof DevinModelsPayload.Type, +): ReadonlyArray { + const seen = new Set(); + const models: ServerProviderModel[] = []; + + for (const family of payload.families) { + const subProvider = family.family_label.trim() || family.family_uid.trim(); + const parsedFamilyVariants = family.variants + .map((candidate) => ({ + uid: candidate.model_uid.trim(), + label: candidate.label.trim(), + parsed: parseDevinModelUid(candidate.model_uid), + })) + .filter((candidate) => candidate.uid.length > 0 && candidate.label.length > 0); + const familyHasReasoningVariants = + parsedFamilyVariants.length > 1 && + parsedFamilyVariants.some( + (candidate) => + candidate.parsed.reasoning !== undefined || labelHasReasoningSignal(candidate.label), + ); + // A few Devin families use opaque/private UIDs that do not share a common + // suffix (for example MODEL_PRIVATE_2 + MODEL_PRIVATE_3 for a + // no-thinking/thinking pair). The unsuffixed variant is the best parent + // slug when present; otherwise use the first parsed base. + const familyGroupBase = + parsedFamilyVariants.find((candidate) => candidate.parsed.reasoning === undefined)?.parsed + .base ?? parsedFamilyVariants[0]?.parsed.base; + + // Variants that share a base (+ speed tier) but differ only in reasoning + // level collapse into one selectable model. Track them here keyed by the + // group key, then emit a single ServerProviderModel with a `reasoning` + // option descriptor per group. + type DevinModelGroup = { + base: string; + reasoningLevels: Map; + /** The first reasoning variant reported by Devin, or a reasoning + * level inferred from the family's unsuffixed canonical entry. */ + firstReasoning?: string; + defaultReasoning?: string; + /** Some families use the unsuffixed UID for their no-thinking variant. */ + hasCanonicalNoReasoning?: boolean; + /** Concrete UIDs for families whose reasoning variants are opaque. */ + reasoningUids: Map; + usesExplicitReasoningUids?: boolean; + speeds: Set; + contexts: Set; + defaultContextWindow?: string; + pricingByVariant: Map; + }; + const groups = new Map(); + + for (const variant of family.variants) { + const uid = variant.model_uid.trim(); + const label = variant.label.trim(); + if (!uid || !label) continue; + + const parsed = parseDevinModelUid(uid); + const rawVariant = variant as unknown as Record; + const explicitContextTokens = + parseContextWindowTokens(rawVariant.context_window ?? rawVariant.contextWindow) ?? + (parsed.contextWindow ? parseContext(parsed.contextWindow) * 1_000 : undefined); + const variantPricing = pricingWithContext( + parseDevinVariantPricing(rawVariant), + explicitContextTokens, + ); + if (parsed.reasoning === undefined) { + if (familyHasReasoningVariants) { + const key = familyGroupBase ?? devinModelGroupKey(parsed); + const group = + groups.get(key) ?? + (() => { + const defaultContext = DEVIN_DEFAULT_CONTEXT_WINDOWS[parsed.base]; + const created: DevinModelGroup = { + base: key, + reasoningLevels: new Map(), + reasoningUids: new Map(), + speeds: new Set(["standard"]), + contexts: new Set(), + ...(defaultContext ? { defaultContextWindow: defaultContext } : {}), + pricingByVariant: new Map(), + }; + if (defaultContext) created.contexts.add(defaultContext); + if (variantPricing) created.pricingByVariant.set(uid, variantPricing); + groups.set(key, created); + return created; + })(); + if (variantPricing) group.pricingByVariant.set(uid, variantPricing); + const contextOption = formatContextWindowOption(explicitContextTokens ?? 0); + if (contextOption) group.contexts.add(contextOption); + const inferredReasoning = inferReasoningFromLabel(label); + group.reasoningUids.set(inferredReasoning, uid); + if (parsed.base !== group.base) group.usesExplicitReasoningUids = true; + if (inferredReasoning === "none") { + group.hasCanonicalNoReasoning = true; + } + group.reasoningLevels.set( + inferredReasoning, + DEVIN_REASONING_LABELS[inferredReasoning] ?? capitalize(inferredReasoning), + ); + // Some families (notably GLM-5.2) use an unsuffixed UID for their + // canonical/default reasoning level. Preserve that signal instead + // of letting the alphabetically/effort-sorted options choose None. + if ( + inferredReasoning !== "none" && + parsed.base === group.base && + group.defaultReasoning === undefined + ) { + group.defaultReasoning = inferredReasoning; + } + if (parsed.contextWindow) group.contexts.add(parsed.contextWindow); + if (parsed.contextWindow && group.defaultContextWindow === undefined) { + group.defaultContextWindow = parsed.contextWindow; + } + continue; + } + // No reasoning suffix — a standalone model, emitted as-is. + if (seen.has(uid)) continue; + seen.add(uid); + const contextWindowTokens = + variantPricing?.contextWindowTokens ?? + explicitContextTokens ?? + inferDevinContextWindowTokens(uid); + models.push({ + slug: uid, + name: label, + subProvider, + isCustom: false, + ...(uid === "adaptive" ? { isDefault: true } : {}), + capabilities: devinModelCapabilities(uid), + ...(variantPricing ? { pricing: variantPricing } : {}), + ...(contextWindowTokens !== undefined ? { contextWindowTokens } : {}), + } satisfies ServerProviderModel); + continue; + } + + const key = familyGroupBase ?? devinModelGroupKey(parsed); + const group = + groups.get(key) ?? + (() => { + const defaultContext = DEVIN_DEFAULT_CONTEXT_WINDOWS[parsed.base] ?? parsed.contextWindow; + const created: DevinModelGroup = { + base: key, + reasoningLevels: new Map(), + reasoningUids: new Map(), + speeds: new Set([parsed.speed ?? "standard"]), + contexts: new Set(parsed.contextWindow ? [parsed.contextWindow] : []), + ...(defaultContext ? { defaultContextWindow: defaultContext } : {}), + pricingByVariant: new Map(), + }; + if (defaultContext) created.contexts.add(defaultContext); + groups.set(key, created); + return created; + })(); + if (variantPricing) group.pricingByVariant.set(uid, variantPricing); + const contextOption = formatContextWindowOption(explicitContextTokens ?? 0); + if (contextOption) group.contexts.add(contextOption); + if (parsed.reasoning !== undefined && !group.reasoningLevels.has(parsed.reasoning)) { + const reasoning = parsed.reasoning; + if (!reasoning) continue; + if (group.firstReasoning === undefined) group.firstReasoning = reasoning; + group.reasoningLevels.set( + reasoning, + DEVIN_REASONING_LABELS[reasoning] ?? capitalize(reasoning), + ); + group.reasoningUids.set(reasoning, uid); + if (parsed.base !== group.base) group.usesExplicitReasoningUids = true; + } + group.speeds.add(parsed.speed ?? "standard"); + if (parsed.contextWindow) group.contexts.add(parsed.contextWindow); + if (parsed.contextWindow && group.defaultContextWindow === undefined) { + group.defaultContextWindow = parsed.contextWindow; + } + } + + for (const [key, group] of groups) { + if (seen.has(key)) continue; + seen.add(key); + + const orderedLevels = DEVIN_REASONING_LEVELS.filter((level) => + group.reasoningLevels.has(level), + ); + const defaultReasoning = + group.defaultReasoning ?? + (group.hasCanonicalNoReasoning + ? "none" + : orderedLevels.includes("medium") + ? "medium" + : group.firstReasoning) ?? + orderedLevels[0]; + const choices: ProviderOptionChoice[] = orderedLevels.map((level) => { + const concreteUid = group.reasoningUids.get(level); + const optionId = + group.usesExplicitReasoningUids && + concreteUid && + parseDevinModelUid(concreteUid).base !== group.base + ? `__uid:${concreteUid}` + : level; + return { + id: optionId, + label: group.reasoningLevels.get(level)!, + ...(level === defaultReasoning ? { isDefault: true } : {}), + }; + }); + + const reasoningDescriptor: SelectProviderOptionDescriptor = { + id: "reasoning", + label: "Reasoning", + type: "select", + options: choices, + }; + + const speedValues = ["standard", "fast", "priority"].filter((value) => + group.speeds.has(value), + ); + const contextValues = [...group.contexts].sort((a, b) => parseContext(a) - parseContext(b)); + const optionDescriptors: SelectProviderOptionDescriptor[] = [reasoningDescriptor]; + if (speedValues.length > 1) { + optionDescriptors.push({ + id: "speed", + label: "Speed", + type: "select", + options: speedValues.map((value) => ({ + id: value, + label: DEVIN_SPEED_LABELS[value] ?? capitalize(value), + ...(value === "standard" ? { isDefault: true } : {}), + })), + }); + } + if (contextValues.length > 1) { + optionDescriptors.push({ + id: "contextWindow", + label: "Context window", + type: "select", + options: contextValues.map((value, index) => ({ + id: value, + label: value.toUpperCase(), + ...(value === (group.defaultContextWindow ?? contextValues[0] ?? "") || + (group.defaultContextWindow === undefined && index === 0) + ? { isDefault: true } + : {}), + })), + }); + } + + const overrides = DEVIN_MODEL_INPUT_CAPABILITIES[group.base]; + const pricingEntries = group.pricingByVariant; + const defaultPricing = + [...pricingEntries.entries()].find(([uid]) => { + const parsed = parseDevinModelUid(uid); + return ( + (parsed.reasoning ?? "") === "medium" && (parsed.speed ?? "standard") === "standard" + ); + })?.[1] ?? [...pricingEntries.values()][0]; + const pricingContextWindowTokens = [...pricingEntries.values()] + .map((pricing) => pricing.contextWindowTokens) + .filter((value): value is number => value !== undefined) + .sort((left, right) => right - left)[0]; + const contextWindowTokens = + (contextValues.length > 0 + ? parseContext(contextValues[contextValues.length - 1]!) * 1_000 + : undefined) ?? + pricingContextWindowTokens ?? + defaultPricing?.contextWindowTokens ?? + inferDevinContextWindowTokens(group.base); + models.push({ + slug: key, + name: subProvider, + subProvider, + isCustom: false, + ...(key === "adaptive" ? { isDefault: true } : {}), + capabilities: createModelCapabilities({ + optionDescriptors, + ...overrides, + }), + ...(defaultPricing ? { pricing: defaultPricing } : {}), + ...(modelPricingRecord(pricingEntries) + ? { pricingByVariant: modelPricingRecord(pricingEntries) } + : {}), + ...(contextWindowTokens !== undefined ? { contextWindowTokens } : {}), + } satisfies ServerProviderModel); + } + } + + return models; +} + +function looksUnauthenticated(output: string): boolean { + const normalized = output.toLowerCase(); + return ( + normalized.includes("not authenticated") || + normalized.includes("not logged in") || + normalized.includes("log in") || + normalized.includes("login required") + ); +} + +export const checkDevinProviderStatus = Effect.fn("checkDevinProviderStatus")(function* ( + settings: DevinSettings, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = providerModelsFromSettings([], settings.customModels, EMPTY_CAPABILITIES); + + if (!settings.enabled) { + return yield* buildInitialDevinProviderSnapshot(settings); + } + + const versionResult = yield* runDevinCommand(settings, ["--version"], environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(versionResult)) { + const missing = isCommandMissingCause(versionResult.failure); + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: !missing, + version: null, + status: "error", + auth: { status: "unknown" }, + message: missing + ? "Devin CLI (`devin`) is not installed or not on PATH." + : "Failed to execute the Devin CLI health check.", + }, + }); + } + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Devin CLI timed out while checking its version.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Devin CLI is installed but failed to run.", + }, + }); + } + + const modelResult = yield* runDevinCommand( + settings, + ["models", "list", "--format", "json"], + environment, + ).pipe(Effect.timeoutOption(MODEL_PROBE_TIMEOUT_MS), Effect.result); + + if (Result.isFailure(modelResult) || Option.isNone(modelResult.success)) { + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "warning", + auth: { status: "unknown" }, + message: "Devin is installed, but model discovery failed or timed out.", + }, + }); + } + + const output = modelResult.success.value; + if (output.code !== 0) { + const unauthenticated = looksUnauthenticated(`${output.stdout}\n${output.stderr}`); + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: unauthenticated ? "unauthenticated" : "unknown" }, + message: unauthenticated + ? "Devin CLI is not authenticated. Run `devin auth login`." + : "Devin model discovery failed.", + }, + }); + } + + let decoded = yield* decodeDevinModels(output.stdout).pipe(Effect.option); + // `--format json` was added after the first Devin CLI releases. If an older + // binary ignores the flag (or returns a different JSON envelope), retry the + // same probe without it and retain the pricing/context details from the + // human-readable catalog. + if (Option.isNone(decoded)) { + const humanResult = yield* runDevinCommand(settings, ["models", "list"], environment).pipe( + Effect.timeoutOption(MODEL_PROBE_TIMEOUT_MS), + Effect.result, + ); + if (Result.isSuccess(humanResult) && Option.isSome(humanResult.success)) { + const humanOutput = humanResult.success.value; + if (humanOutput.code === 0) { + const parsedHuman = parseDevinHumanModelList(humanOutput.stdout); + if (parsedHuman) decoded = Option.some(parsedHuman); + } + } + } + if (Option.isNone(decoded)) { + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "warning", + auth: { status: "authenticated" }, + message: "Devin returned an unrecognized model catalog.", + }, + }); + } + + return buildServerProvider({ + presentation: DEVIN_PRESENTATION, + enabled: true, + checkedAt, + models: providerModelsFromSettings( + buildDevinModelsFromPayload(decoded.value), + settings.customModels, + EMPTY_CAPABILITIES, + ), + probe: { + installed: true, + version, + status: "ready", + auth: { status: "authenticated" }, + }, + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 988c89e1e679..9b9313ad186e 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -7,6 +7,7 @@ import * as Path from "effect/Path"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -43,6 +44,7 @@ import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; import { + haveProvidersChanged, mergeProviderSnapshot, upsertProviderWorkspaceSnapshot, ProviderRegistryLive, @@ -554,6 +556,124 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }); describe("ProviderRegistryLive", () => { + it("treats equal provider snapshots as unchanged", () => { + const providers = [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + }, + { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + status: "warning", + enabled: true, + installed: true, + auth: { status: "unknown" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + }, + ] as const satisfies ReadonlyArray; + + assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); + }); + + it("replaces the previous model inventory when the catalog version changes", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("devin"), + driver: ProviderDriverKind.make("devin"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-08-30T00:00:00.000Z", + version: "1.0.0", + modelCatalogVersion: "devin-model-catalog-v1", + models: [ + { + slug: "claude-opus-5-medium", + name: "Medium", + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + slashCommands: [], + skills: [], + } satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-08-30T00:01:00.000Z", + modelCatalogVersion: "devin-model-catalog-v2", + models: [ + { + slug: "claude-opus-5", + name: "Claude Opus 5", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [selectDescriptor("reasoning", "Reasoning", [])], + }), + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, refreshedProvider), + refreshedProvider, + ); + }); + + it("treats a ready Devin catalog as authoritative within one version", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("devin"), + driver: ProviderDriverKind.make("devin"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-08-30T00:00:00.000Z", + version: "1.0.0", + modelCatalogVersion: "devin-model-catalog-v2", + models: [ + { + slug: "legacy-flattened-model", + name: "Legacy Flattened Model", + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + slashCommands: [], + skills: [], + } satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-08-30T00:01:00.000Z", + models: [ + { + slug: "claude-opus-5", + name: "Claude Opus 5", + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + } satisfies ServerProvider; + + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, refreshedProvider).models, + refreshedProvider.models, + ); + }); + it("stores workspace skills and commands without changing machine metadata", () => { const provider = { instanceId: ProviderInstanceId.make("codex"), @@ -1862,6 +1982,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const persisted = yield* awaitPersistedProvider(registry, refreshedProvider.checkedAt); yield* PubSub.publish(changes, refreshedProvider); yield* Fiber.join(persisted); + const cachedProvider = yield* readProviderStatusCache(filePath); assert.deepStrictEqual(cachedProvider, { @@ -2345,9 +2466,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const firstMissing = `t3code_codex_first_`; const secondMissing = `t3code_codex_second_`; const spawnedCommands: Array = []; - const secondProbeStarted = yield* Deferred.make(); - const releaseSecondProbe = yield* Deferred.make(); const allowLazySettingsStream = yield* Deferred.make(); + const secondSpawnObserved = yield* Deferred.make(); const mutableServerSettings = yield* makeMutableServerSettingsService( decodeServerSettings( deepMerge(encodedDefaultServerSettings, { @@ -2395,14 +2515,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { if (command._tag !== "StandardCommand") return spawner.spawn(command); - spawnedCommands.push(command.command); - const beforeSpawn = - command.command === secondMissing - ? Deferred.succeed(secondProbeStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseSecondProbe)), - ) - : Effect.void; - return beforeSpawn.pipe(Effect.andThen(spawner.spawn(command))); + const commandName = command.command; + spawnedCommands.push(commandName); + return ( + commandName === secondMissing + ? Deferred.succeed(secondSpawnObserved, undefined) + : Effect.void + ).pipe(Effect.andThen(spawner.spawn(command))); }), ), Layer.provideMerge(NodeServices.layer), @@ -2432,11 +2551,24 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.strictEqual(initialCodex?.installed, false); assert.deepStrictEqual(spawnedCommands, [firstMissing]); - const pendingRebuild = yield* Stream.toPull( - codexSnapshots.pipe( - Stream.filter((provider) => provider.status === "warning" && !provider.installed), + const refreshedProvidersFiber = yield* registry.streamChanges.pipe( + Stream.filter( + (providers) => + spawnedCommands.includes(secondMissing) && + providers.find((provider) => provider.instanceId === "codex")?.status === "error", ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), ); + + // Drive a settings change. The Hydration layer's + // `SettingsWatcherLive` consumes this via `subscribeChanges`, + // calls `reconcile`, which rebuilds the codex instance (the + // envelope changed because `binaryPath` differs → `entryEqual` + // is false). The registry's `Stream.runForEach( + // instanceRegistry.streamChanges, () => syncLiveSources)` + // fires `syncLiveSources`, which subscribes and launches a fresh + // background refresh on the rebuilt instance. yield* serverSettings.updateSettings({ providers: { codex: { enabled: true, binaryPath: secondMissing }, @@ -2446,15 +2578,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te // not subscribe before forking has already lost this update. yield* Deferred.succeed(allowLazySettingsStream, undefined); - // Hold the second probe until the aggregator sees the rebuilt - // instance. Its next error must come from the new executable. - yield* Deferred.await(secondProbeStarted); - yield* pendingRebuild; - const rebuiltError = yield* Stream.toPull( - codexSnapshots.pipe(Stream.filter((provider) => provider.status === "error")), - ); - yield* Deferred.succeed(releaseSecondProbe, undefined); - const [reprobedCodex] = yield* rebuiltError; + // Wait on the process-boundary and registry-stream receipts. This + // gives Node's ENOENT callback a real scheduling turn instead of + // polling TestClock, which can starve host I/O on Windows. + yield* Deferred.await(secondSpawnObserved); + const refreshed = Option.getOrThrow(yield* Fiber.join(refreshedProvidersFiber)); + + const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]); assert.strictEqual(reprobedCodex?.status, "error"); assert.strictEqual(reprobedCodex?.installed, false); @@ -2615,6 +2745,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "claudeAgent", "codex", "cursor", + "devin", "grok", "opencode", ]); @@ -2793,7 +2924,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); it.effect("runs Claude status probes with the configured CLAUDE_CONFIG_DIR", () => { - const claudeConfigDir = "/tmp/t3code-claude-home"; + const claudeConfigDir = process.cwd(); const recorded = recordingMockSpawnerLayer((args) => { const joined = args.join(" "); if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index a8e6caf95aa7..7049f1f0346c 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -45,6 +45,7 @@ import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.t import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; import { hydrateCachedProvider, + isCachedProviderCatalogCompatible, isCachedProviderCorrelated, orderProviderSnapshots, readProviderStatusCache, @@ -102,6 +103,14 @@ export function upsertProviderWorkspaceSnapshot( const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); + if (provider.driver === ProviderDriverKind.make("devin")) { + // A ready Devin model probe is authoritative: its family catalog should + // replace the previous inventory so removed/flattened variants cannot + // remain visible. Keep the last known catalog only while discovery is + // partial (warning or an installed CLI error). + return provider.status === "warning" || (provider.status === "error" && provider.installed); + } + const isCodex = provider.driver === ProviderDriverKind.make("codex"); if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) { return true; @@ -194,11 +203,27 @@ const carrySavedAntigravityAccount = ( return { auth: previousProvider.auth, status }; }; +/** + * A catalog version is a replacement boundary, not another piece of model + * metadata. Once a provider publishes a new version, never merge models from + * a previous-version snapshot into it (the old snapshot may use a completely + * different family/variant shape). + */ +export const isProviderModelCatalogVersionCompatible = ( + previousProvider: ServerProvider, + nextProvider: ServerProvider, +): boolean => + nextProvider.modelCatalogVersion === undefined || + previousProvider.modelCatalogVersion === nextProvider.modelCatalogVersion; + export const mergeProviderSnapshot = ( previousProvider: ServerProvider | undefined, nextProvider: ServerProvider, ): ServerProvider => { - if (!previousProvider) { + if ( + !previousProvider || + !isProviderModelCatalogVersionCompatible(previousProvider, nextProvider) + ) { return nextProvider; } const savedAccount = carrySavedAntigravityAccount(previousProvider, nextProvider); @@ -226,7 +251,7 @@ export const mergeProviderSnapshot = ( }; }; -const haveProvidersChanged = ( +export const haveProvidersChanged = ( previousProviders: ReadonlyArray, nextProviders: ReadonlyArray, ): boolean => !Equal.equals(previousProviders, nextProviders); @@ -344,6 +369,37 @@ export const ProviderRegistryLive = Layer.effect( cachedDriver: cachedProvider.driver ?? null, }).pipe(Effect.as(undefined as ServerProvider | undefined)); } + + if (!isCachedProviderCatalogCompatible(correlation)) { + // Replace the on-disk entry immediately so a failed or + // interrupted startup probe cannot resurrect the old model + // shape on the next launch. The live provider will overwrite + // this fallback with its authoritative catalog once probing + // completes. + return Effect.logWarning( + "provider status cache catalog version mismatch, replacing stale snapshot", + { + path: filePath, + instanceId: source.instanceId, + driver: source.driverKind, + cachedCatalogVersion: cachedProvider.modelCatalogVersion ?? null, + expectedCatalogVersion: fallbackProvider.modelCatalogVersion ?? null, + }, + ).pipe( + Effect.andThen( + writeProviderStatusCache({ + filePath, + provider: fallbackProvider, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.ignore, + ), + ), + Effect.as(fallbackProvider), + ); + } + return Effect.succeed(hydrateCachedProvider(correlation)); }), ); @@ -670,7 +726,7 @@ export const ProviderRegistryLive = Layer.effect( const source = buildSnapshotSource(instance); yield* Stream.runForEach(source.streamChanges, (provider) => correlateSnapshotWithSource(source, provider).pipe(Effect.flatMap(syncProvider)), - ).pipe(Effect.forkScoped); + ).pipe(Effect.forkScoped({ startImmediately: true })); } yield* Effect.yieldNow; diff --git a/apps/server/src/provider/Services/DevinAdapter.ts b/apps/server/src/provider/Services/DevinAdapter.ts new file mode 100644 index 000000000000..57a5a0aa508c --- /dev/null +++ b/apps/server/src/provider/Services/DevinAdapter.ts @@ -0,0 +1,5 @@ +/** Devin provider adapter shape. */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface DevinAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.ts b/apps/server/src/provider/acp/AcpAdapterSupport.ts index cde110e6dd99..9c54639d5c5c 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.ts @@ -13,6 +13,8 @@ import { } from "../Errors.ts"; const isAcpProcessExitedError = Schema.is(EffectAcpErrors.AcpProcessExitedError); const isAcpRequestError = Schema.is(EffectAcpErrors.AcpRequestError); +const isAcpTransportError = Schema.is(EffectAcpErrors.AcpTransportError); +const isAcpSpawnError = Schema.is(EffectAcpErrors.AcpSpawnError); export function mapAcpToAdapterError( provider: ProviderDriverKind, @@ -27,6 +29,25 @@ export function mapAcpToAdapterError( cause: error, }); } + if (isAcpSpawnError(error)) { + return new ProviderAdapterRequestError({ + provider, + method, + detail: `Failed to start the ${provider} process. Check that the binary is installed and on your PATH.`, + cause: error, + }); + } + if (isAcpTransportError(error)) { + const detail = error.detail + ? `${provider} communication failed${error.method ? ` during ${error.method}` : ""}: ${error.detail}` + : `${provider} communication failed${error.method ? ` during ${error.method}` : ""}. The process may have stalled.`; + return new ProviderAdapterRequestError({ + provider, + method, + detail, + cause: error, + }); + } if (isAcpRequestError(error)) { return new ProviderAdapterRequestError({ provider, diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts index cb9433b5bf4a..a2329a80696e 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts @@ -7,6 +7,7 @@ import { makeAcpPlanUpdatedEvent, makeAcpRequestOpenedEvent, makeAcpRequestResolvedEvent, + makeAcpTokenUsageEvent, makeAcpToolCallEvent, } from "./AcpCoreRuntimeEvents.ts"; @@ -242,4 +243,40 @@ describe("AcpCoreRuntimeEvents", () => { }, }); }); + + it("maps ACP usage onto the canonical token event without losing attribution", () => { + const event = makeAcpTokenUsageEvent({ + stamp: { eventId: "event-usage" as never, createdAt: "2026-03-27T00:00:00.000Z" }, + provider: ProviderDriverKind.make("devin"), + threadId: "thread-1" as never, + turnId: TurnId.make("turn-1"), + usage: { + usedTokens: 12_000, + maxTokens: 200_000, + model: "glm-5-2-max", + providerSessionId: "acp-session-1", + lastInputTokens: 1_000, + lastOutputTokens: 120, + lastCostUsd: 0.01, + }, + rawPayload: { update: { sessionUpdate: "usage_update" } }, + }); + + expect(event).toMatchObject({ + type: "thread.token-usage.updated", + provider: "devin", + payload: { + usage: { + usedTokens: 12_000, + model: "glm-5-2-max", + providerSessionId: "acp-session-1", + lastCostUsd: 0.01, + }, + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + }, + }); + }); }); diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts index 79def1125305..43169c7b4a25 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts @@ -8,6 +8,7 @@ import { type ProviderDriverKind, type ProviderRuntimeEvent, type RuntimeRequestId, + type ThreadTokenUsageSnapshot, type ThreadId, type TurnId, } from "@t3tools/contracts"; @@ -232,3 +233,33 @@ export function makeAcpContentDeltaEvent(input: { }, }; } + +/** Normalizes ACP context/token telemetry onto the shared runtime event. */ +export function makeAcpTokenUsageEvent(input: { + readonly stamp: AcpEventStamp; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly turnId: TurnId | undefined; + readonly usage: ThreadTokenUsageSnapshot; + /** JSON-RPC method that produced the usage payload. */ + readonly method?: string; + readonly rawPayload?: unknown; +}): ProviderRuntimeEvent { + return { + type: "thread.token-usage.updated", + ...input.stamp, + provider: input.provider, + threadId: input.threadId, + ...(input.turnId ? { turnId: input.turnId } : {}), + payload: { usage: input.usage }, + ...(input.rawPayload !== undefined + ? { + raw: { + source: "acp.jsonrpc" as const, + method: input.method ?? "session/update", + payload: input.rawPayload, + }, + } + : {}), + }; +} diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 57323e2675e0..05e9cda7b441 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -32,6 +32,34 @@ const mockRuntimeOptions = { } satisfies AcpSessionRuntime.AcpSessionRuntimeOptions; describe("AcpSessionRuntime", () => { + it.effect("can start an agent that uses existing CLI authentication state", () => { + 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); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); + for (const setupMethod of ["session/new", "session/resume"] as const) { it.effect(`buffers root metadata while ${setupMethod} startup is still pending`, () => Effect.gen(function* () { @@ -1012,6 +1040,40 @@ describe("AcpSessionRuntime", () => { ), ); + it.effect("starts a new session when the agent cannot load sessions", () => { + 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 === "session/load")).toBe(false); + expect(requestEvents.some((event) => event.method === "session/new")).toBe(true); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "test", + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_OMIT_LOAD_SESSION_CAPABILITY: "1", + }, + }, + cwd: process.cwd(), + resumeSessionId: "unsupported-session-id", + clientInfo: { name: "t3-test", version: "0.0.0" }, + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ); + }); + it.effect("ignores session/update replay notifications during session/load", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index b5ee1708a902..9ee33395308a 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -878,4 +878,24 @@ describe("AcpRuntimeModel", () => { ).toEqual({ emit: true, skippedSinceEmit: 0 }); }); }); + + it("parses ACP usage_update notifications", () => { + const rawPayload = { + sessionId: "session-1", + update: { + sessionUpdate: "usage_update", + used: 12_000, + size: 200_000, + cost: { amount: 0.12, currency: "USD" }, + }, + } satisfies EffectAcpSchema.SessionNotification; + + expect(parseSessionUpdateEvent(rawPayload).events).toEqual([ + { + _tag: "UsageUpdated", + usage: rawPayload.update, + rawPayload, + }, + ]); + }); }); diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index 89c02dc7f949..4dfa8ced53e0 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -123,6 +123,11 @@ export type AcpParsedSessionEvent = readonly _tag: "ThoughtDelta"; readonly text: string; readonly rawPayload: unknown; + } + | { + readonly _tag: "UsageUpdated"; + readonly usage: EffectAcpSchema.UsageUpdate; + readonly rawPayload: unknown; }; type AcpSessionSetupResponse = @@ -879,6 +884,14 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat } break; } + case "usage_update": { + events.push({ + _tag: "UsageUpdated", + usage: upd, + rawPayload: params, + }); + break; + } default: break; } diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index b5894192eed9..22aa4398e7dc 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -92,7 +92,9 @@ export interface AcpSessionRuntimeOptions { readonly name: string; readonly version: string; }; - readonly authMethodId: string; + /** Authentication method to invoke after initialization. Omit when the + * agent consumes credentials from its own CLI state (for example Devin). */ + readonly authMethodId?: string; readonly mcpServers?: ReadonlyArray; /** Extra workspace roots the agent may read and write besides `cwd`. */ readonly additionalDirectories?: ReadonlyArray; @@ -699,15 +701,17 @@ export const make = ( const startOnce = Effect.gen(function* () { const initializeResult = yield* sendInitialize; - const authenticatePayload = { - methodId: options.authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; + if (options.authMethodId) { + const authenticatePayload = { + methodId: 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: @@ -750,7 +754,10 @@ export const make = ( ), ), ); - } else if (options.resumeSessionId) { + } else if ( + options.resumeSessionId && + initializeResult.agentCapabilities?.loadSession === true + ) { const loadPayload = { sessionId: options.resumeSessionId, cwd: options.cwd, diff --git a/apps/server/src/provider/acp/DevinAcpCliProbe.test.ts b/apps/server/src/provider/acp/DevinAcpCliProbe.test.ts new file mode 100644 index 000000000000..96d0b855f66f --- /dev/null +++ b/apps/server/src/provider/acp/DevinAcpCliProbe.test.ts @@ -0,0 +1,441 @@ +/** + * Optional integration checks against a real `devin` CLI installation. + * Enable with: T3_DEVIN_ACP_PROBE=1 vp test run DevinAcpCliProbe + * Set T3_DEVIN_BINARY_PATH when `devin` is not on PATH. + * + * Set T3_DEVIN_LIVE_TURN=1 to send a real prompt. This consumes Devin usage. + * Set T3_DEVIN_MCP_SMOKE=1 to drive a real turn through the local T3 MCP server. + * The regular Devin adapter tests use the local ACP fixture for permissions, + * cancellation, image input, and failure recovery; these checks validate the + * installed CLI's command and ACP compatibility at the opt-in boundary. + */ +// @effect-diagnostics nodeBuiltinImport:off - the opt-in smoke test creates and removes an isolated real workspace. +import * as NodeFSP from "node:fs/promises"; +import * as NodeHttp from "node:http"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { NodeHttpServer } from "@effect/platform-node"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as Schema from "effect/Schema"; +import { + DevinSettings, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ProviderRuntimeEvent, + ThreadId, +} from "@t3tools/contracts"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { + FetchHttpClient, + HttpBody, + HttpClient, + HttpRouter, + HttpServer, +} from "effect/unstable/http"; +import { describe, expect } from "vite-plus/test"; + +import * as ServerConfig from "../../config.ts"; +import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; +import * as McpHttpServer from "../../mcp/McpHttpServer.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; +import * as PreviewAutomationBroker from "../../mcp/PreviewAutomationBroker.ts"; +import { makeDevinAdapter } from "../Layers/DevinAdapter.ts"; +import { checkDevinProviderStatus } from "../Layers/DevinProvider.ts"; +import { spawnAndCollect } from "../providerSnapshot.ts"; +import { makeDevinAcpRuntime } from "./DevinAcpSupport.ts"; +import { + DEVIN_OPTIONAL_CONTENT_UNSUPPORTED_FIXTURE, + createDevinAcpCapture, + selectDevinOptionalContent, + type DevinAcpCapture, +} from "./DevinOptionalContentFixtures.test.ts"; + +const configuredBinary = process.env.T3_DEVIN_BINARY_PATH?.trim() || "devin"; +const decodeDevinSettings = Schema.decodeSync(DevinSettings); +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +const makeProbeSettings = () => + decodeDevinSettings({ + enabled: true, + binaryPath: configuredBinary, + customModels: [], + }); + +const runDevinCommand = (args: ReadonlyArray) => + Effect.gen(function* () { + const spawn = yield* resolveSpawnCommand(configuredBinary, args, { env: process.env }); + return yield* spawnAndCollect( + configuredBinary, + ChildProcess.make(spawn.command, spawn.args, { + env: process.env, + shell: spawn.shell, + }), + ); + }); + +const makeProbeRuntime = (capture?: DevinAcpCapture) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* makeDevinAcpRuntime({ + devinSettings: { binaryPath: configuredBinary }, + environment: process.env, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-devin-probe", version: "0.0.0" }, + ...(capture + ? { + protocolLogging: { + logIncoming: true, + logger: (event: unknown) => Effect.sync(() => capture.write(event)), + }, + } + : {}), + }); + }); + +const captureEnabled = process.env.T3_DEVIN_ACP_CAPTURE === "1"; + +const emitCapture = (capture: DevinAcpCapture | undefined, force = false) => + Effect.sync(() => { + if (!capture || (!captureEnabled && !force)) return; + const records = selectDevinOptionalContent(capture.records()); + process.stderr.write( + `${encodeUnknownJson({ + optionalContent: records.length > 0 ? records : DEVIN_OPTIONAL_CONTENT_UNSUPPORTED_FIXTURE, + })}\n`, + ); + }); + +describe.runIf(process.env.T3_DEVIN_ACP_PROBE === "1")("Devin ACP CLI probe", () => { + it.effect("reports the real CLI auth state without invoking login", () => + runDevinCommand(["auth", "status"]).pipe( + Effect.tap((result) => + Effect.sync(() => { + const output = `${result.stdout}\n${result.stderr}`; + expect(result.code).toBe(0); + expect(output).toMatch(/logged in|authenticated/iu); + }), + ), + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("discovers the current model catalog through the provider health check", () => + checkDevinProviderStatus(makeProbeSettings(), process.env).pipe( + Effect.tap((snapshot) => + Effect.sync(() => { + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("ready"); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.models.length).toBeGreaterThan(0); + expect(snapshot.models.some((model) => model.slug === "adaptive")).toBe(true); + }), + ), + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("starts a real ACP session and accepts an advertised model selection", () => + Effect.gen(function* () { + const runtime = yield* makeProbeRuntime(); + const started = yield* runtime.start(); + expect(typeof started.sessionId).toBe("string"); + expect(started.initializeResult).toBeDefined(); + expect(started.sessionSetupResult).toBeDefined(); + + const configOptions = yield* runtime.getConfigOptions; + const modelConfig = configOptions.find( + (option) => option.category === "model" || option.id === started.modelConfigId, + ); + expect(modelConfig).toBeDefined(); + expect(modelConfig?.type).toBe("select"); + if (modelConfig?.type !== "select") return; + + const values = modelConfig.options.flatMap((option) => + "value" in option ? [option.value] : option.options.map((nested) => nested.value), + ); + expect(values.length).toBeGreaterThan(0); + const target = values.find((value) => value !== modelConfig.currentValue); + yield* runtime.setModel(target ?? modelConfig.currentValue); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect.skipIf(process.env.T3_DEVIN_LIVE_TURN !== "1")( + "finishes a real Devin turn and streams its answer", + () => { + const capture = createDevinAcpCapture(captureEnabled); + return Effect.gen(function* () { + const runtime = yield* makeProbeRuntime(capture); + yield* runtime.start(); + const chunks: string[] = []; + const events = yield* Stream.runForEach(runtime.getEvents(), (event) => { + if (event._tag === "EventStreamBarrier") { + return Deferred.succeed(event.acknowledge, undefined); + } + if (event._tag === "ContentDelta") { + chunks.push(event.text); + } + return Effect.void; + }).pipe(Effect.forkChild); + const result = yield* runtime.prompt({ + prompt: [{ type: "text", text: "Reply exactly T3_DEVIN_OK. Do not use any tools." }], + }); + yield* runtime.drainEvents; + expect(result.stopReason).toBe("end_turn"); + expect(chunks.join("")).toContain("T3_DEVIN_OK"); + yield* Fiber.interrupt(events); + yield* emitCapture(capture); + }).pipe( + Effect.scoped, + Effect.provide(NodeServices.layer), + Effect.tapError(() => emitCapture(capture, true)), + ); + }, + ); +}); + +const DevinMcpSmokeLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-devin-mcp-smoke-", +}).pipe( + Layer.provideMerge( + HttpServer.layerTestClient.pipe( + Layer.provide( + Layer.fresh(FetchHttpClient.layer).pipe( + Layer.provide(Layer.succeed(FetchHttpClient.RequestInit)({ keepalive: false })), + ), + ), + Layer.provideMerge( + NodeHttpServer.layer(NodeHttp.createServer, { + host: "127.0.0.1", + port: 0, + }), + ), + ), + ), +); + +describe.runIf(process.env.T3_DEVIN_MCP_SMOKE === "1")("Devin MCP smoke", () => { + it.effect( + "runs a real Devin ACP turn through the T3 MCP server", + () => + Effect.scoped( + Effect.gen(function* () { + const environmentId = EnvironmentId.make("devin-mcp-smoke-environment"); + const threadId = ThreadId.make("devin-mcp-smoke-thread"); + const providerInstanceId = ProviderInstanceId.make("devin"); + const workspace = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-devin-mcp-workspace-")), + ); + yield* Effect.addFinalizer(() => + Effect.promise(() => NodeFSP.rm(workspace, { recursive: true, force: true })), + ); + + const serverEnvironmentLayer = Layer.succeed( + ServerEnvironment.ServerEnvironment, + ServerEnvironment.ServerEnvironment.of({ + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.die("Devin MCP smoke does not read the environment descriptor"), + }), + ); + const mcpContext = yield* Layer.build( + Layer.mergeAll( + McpSessionRegistry.layer.pipe(Layer.provideMerge(serverEnvironmentLayer)), + PreviewAutomationBroker.layer, + ), + ); + yield* HttpRouter.serve( + McpHttpServer.layer.pipe(Layer.provide(Layer.succeedContext(mcpContext))), + { disableListenLog: true, disableLogger: true }, + ).pipe(Layer.build); + + const registry = Context.get(mcpContext, McpSessionRegistry.McpSessionRegistry); + const broker = Context.get(mcpContext, PreviewAutomationBroker.PreviewAutomationBroker); + const issued = yield* registry.issue({ threadId, providerInstanceId }); + const issuedToken = issued.config.authorizationHeader.slice("Bearer ".length); + yield* Effect.addFinalizer(() => + registry.revokeProviderSession(issued.config.providerSessionId).pipe( + Effect.andThen(registry.resolve(issuedToken)), + Effect.tap((scope) => + Effect.sync(() => { + expect(scope).toBeUndefined(); + }), + ), + Effect.asVoid, + ), + ); + McpProviderSession.setMcpProviderSession(issued.config); + yield* Effect.addFinalizer(() => + Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)), + ); + + const httpClient = yield* HttpClient.HttpClient; + const postMcp = (body: Readonly>, mcpSessionId?: string) => + httpClient.post("/mcp", { + headers: { + accept: "application/json, text/event-stream", + authorization: issued.config.authorizationHeader, + "content-type": "application/json", + ...(mcpSessionId === undefined + ? {} + : { + "mcp-session-id": mcpSessionId, + "mcp-protocol-version": "2025-06-18", + }), + }, + body: HttpBody.text(encodeUnknownJson(body), "application/json"), + }); + + const initializeResponse = yield* postMcp({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "t3-devin-mcp-smoke", version: "0.0.0" }, + }, + }); + const mcpSessionId = initializeResponse.headers["mcp-session-id"]; + expect(initializeResponse.status).toBe(200); + expect(mcpSessionId).toBeTruthy(); + if (!mcpSessionId) return; + yield* Effect.addFinalizer(() => + httpClient + .del("/mcp", { + headers: { + authorization: issued.config.authorizationHeader, + "mcp-session-id": mcpSessionId, + "mcp-protocol-version": "2025-06-18", + }, + }) + .pipe(Effect.ignore), + ); + + yield* postMcp( + { jsonrpc: "2.0", method: "notifications/initialized", params: {} }, + mcpSessionId, + ); + const toolsResponse = yield* postMcp( + { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, + mcpSessionId, + ); + expect(toolsResponse.status).toBe(200); + const toolsBody = (yield* toolsResponse.json) as { + readonly result?: { readonly tools?: ReadonlyArray<{ readonly name?: string }> }; + }; + expect(toolsBody.result?.tools?.some((tool) => tool.name === "preview_status")).toBe( + true, + ); + + const requests: Array<{ readonly threadId: ThreadId; readonly operation: string }> = []; + const hostEvents = yield* broker.connect({ + clientId: "devin-mcp-smoke-preview-host", + environmentId, + supportedOperations: ["status"], + }); + yield* Stream.runForEach(hostEvents, (event) => { + if (event.type === "connected") return Effect.void; + requests.push({ threadId: event.request.threadId, operation: event.request.operation }); + return broker.respond({ + clientId: "devin-mcp-smoke-preview-host", + connectionId: event.connectionId, + requestId: event.request.requestId, + ok: true, + result: { + available: true, + visible: false, + tabId: null, + url: null, + title: null, + loading: false, + }, + }); + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const nativeAcpEvents: unknown[] = []; + const adapter = yield* makeDevinAdapter(makeProbeSettings(), { + environment: process.env, + promptTimeout: Duration.seconds(180), + nativeEventLogger: { + filePath: "devin-mcp-smoke-native-events", + write: (event) => Effect.sync(() => nativeAcpEvents.push(event)), + close: () => Effect.void, + }, + }); + yield* Effect.addFinalizer(() => adapter.stopSession(threadId).pipe(Effect.ignore)); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + yield* Stream.runForEach(adapter.streamEvents, (event) => { + if (event.threadId !== threadId) return Effect.void; + runtimeEvents.push(event); + return event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined).pipe(Effect.asVoid) + : Effect.void; + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: workspace, + runtimeMode: "full-access", + modelSelection: { instanceId: providerInstanceId, model: "adaptive" }, + }); + const sessionNewRequest = nativeAcpEvents + .map( + (record) => + record as { + readonly event?: { + readonly kind?: unknown; + readonly payload?: { + readonly method?: unknown; + readonly request?: { readonly fieldCount?: unknown }; + }; + }; + }, + ) + .find( + (record) => + record.event?.kind === "request" && record.event.payload?.method === "session/new", + ); + expect(sessionNewRequest?.event?.payload?.request?.fieldCount).toBe(2); + yield* adapter.sendTurn({ + threadId, + input: + "Use the T3 Code MCP tool preview_status exactly once. After the tool succeeds, reply exactly T3_DEVIN_MCP_OK and do not use any other tool.", + }); + yield* Deferred.await(turnCompleted); + + const assistantText = runtimeEvents + .filter((event) => event.type === "content.delta") + .map((event) => event.payload.delta) + .join(""); + expect(requests).toHaveLength(1); + expect(assistantText).toContain("T3_DEVIN_MCP_OK"); + expect( + requests.some( + (request) => request.threadId === threadId && request.operation === "status", + ), + ).toBe(true); + expect( + requests.every( + (request) => request.threadId === threadId && request.operation === "status", + ), + ).toBe(true); + }), + ).pipe(Effect.provide(DevinMcpSmokeLayer)), + { timeout: 190_000 }, + ); +}); diff --git a/apps/server/src/provider/acp/DevinAcpSupport.test.ts b/apps/server/src/provider/acp/DevinAcpSupport.test.ts new file mode 100644 index 000000000000..e5026477683d --- /dev/null +++ b/apps/server/src/provider/acp/DevinAcpSupport.test.ts @@ -0,0 +1,162 @@ +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { describe, expect } from "vite-plus/test"; + +import { + applyDevinAcpModelSelection, + buildDevinAcpSpawnInput, + resolveDevinAcpBaseModelId, + resolveDevinModelUid, +} from "./DevinAcpSupport.ts"; + +describe("buildDevinAcpSpawnInput", () => { + it("builds the default Devin ACP command", () => { + expect(buildDevinAcpSpawnInput(undefined, "/tmp/project")).toEqual({ + command: "devin", + args: ["acp"], + cwd: "/tmp/project", + }); + }); + + it("uses a configured binary and environment", () => { + expect( + buildDevinAcpSpawnInput({ binaryPath: "/usr/local/bin/devin" }, "/tmp/project", { + DEVIN_ORG: "example", + }), + ).toEqual({ + command: "/usr/local/bin/devin", + args: ["acp"], + cwd: "/tmp/project", + env: { DEVIN_ORG: "example" }, + }); + }); +}); + +describe("applyDevinAcpModelSelection", () => { + it.effect("selects the requested model through ACP config", () => { + const calls: string[] = []; + return applyDevinAcpModelSelection({ + runtime: { + setModel: (model) => Effect.sync(() => calls.push(model)).pipe(Effect.asVoid), + }, + model: "claude-sonnet-4-6", + mapError: ({ cause }) => cause, + }).pipe(Effect.tap(() => Effect.sync(() => expect(calls).toEqual(["claude-sonnet-4-6"])))); + }); + + it.effect("folds a reasoning option into the full model UID", () => { + const calls: string[] = []; + return applyDevinAcpModelSelection({ + runtime: { + setModel: (model) => Effect.sync(() => calls.push(model)).pipe(Effect.asVoid), + }, + model: "claude-opus-5", + selections: [{ id: "reasoning", value: "high" }], + mapError: ({ cause }) => cause, + }).pipe(Effect.tap(() => Effect.sync(() => expect(calls).toEqual(["claude-opus-5-high"])))); + }); + + it.effect("falls back to an unsuffixed no-thinking UID when a CLI uses that form", () => { + const calls: string[] = []; + return applyDevinAcpModelSelection({ + runtime: { + setModel: (model) => + Effect.gen(function* () { + calls.push(model); + if (model.endsWith("-none")) { + return yield* Effect.fail(new Error("unknown model") as never); + } + }), + }, + model: "claude-opus-4-6", + selections: [{ id: "reasoning", value: "none" }], + mapError: ({ cause }) => cause, + }).pipe( + Effect.tap(() => + Effect.sync(() => expect(calls).toEqual(["claude-opus-4-6-none", "claude-opus-4-6"])), + ), + ); + }); + + it("falls back to adaptive", () => { + expect(resolveDevinAcpBaseModelId(" ")).toBe("adaptive"); + }); +}); + +describe("resolveDevinModelUid", () => { + it("combines a base slug with a reasoning option using a hyphen", () => { + expect(resolveDevinModelUid("claude-opus-5", [{ id: "reasoning", value: "medium" }])).toBe( + "claude-opus-5-medium", + ); + }); + + it("inserts the reasoning level before a speed tier", () => { + expect(resolveDevinModelUid("claude-opus-5-fast", [{ id: "reasoning", value: "medium" }])).toBe( + "claude-opus-5-medium-fast", + ); + }); + + it("combines reasoning, speed, and context options", () => { + expect( + resolveDevinModelUid("glm-5-2", [ + { id: "reasoning", value: "max" }, + { id: "speed", value: "priority" }, + { id: "contextWindow", value: "1m" }, + ]), + ).toBe("glm-5-2-max-priority-1m"); + }); + + it("uses GLM's unsuffixed UID for High", () => { + expect(resolveDevinModelUid("glm-5-2", [{ id: "reasoning", value: "high" }])).toBe("glm-5-2"); + expect( + resolveDevinModelUid("glm-5-2", [ + { id: "reasoning", value: "high" }, + { id: "contextWindow", value: "1m" }, + ]), + ).toBe("glm-5-2-1m"); + }); + + it("does not encode GLM's implicit 200K context in the UID", () => { + expect( + resolveDevinModelUid("glm-5-2", [ + { id: "reasoning", value: "max" }, + { id: "contextWindow", value: "200k" }, + ]), + ).toBe("glm-5-2-max"); + expect( + resolveDevinModelUid("glm-5-2", [ + { id: "reasoning", value: "none" }, + { id: "contextWindow", value: "200k" }, + ]), + ).toBe("glm-5-2-none"); + }); + + it("combines uppercase enum-style bases with an underscore and uppercased suffix", () => { + expect(resolveDevinModelUid("MODEL_GPT_5_2", [{ id: "reasoning", value: "low" }])).toBe( + "MODEL_GPT_5_2_LOW", + ); + }); + + it("accepts an explicit UID for opaque Devin reasoning variants", () => { + expect( + resolveDevinModelUid("MODEL_PRIVATE_2", [ + { id: "reasoning", value: "__uid:MODEL_PRIVATE_3" }, + ]), + ).toBe("MODEL_PRIVATE_3"); + }); + + it("returns the base slug when no reasoning option is provided", () => { + expect(resolveDevinModelUid("claude-opus-5")).toBe("claude-opus-5"); + expect(resolveDevinModelUid("claude-opus-5-fast", [])).toBe("claude-opus-5-fast"); + }); + + it("ignores a non-string reasoning value", () => { + expect(resolveDevinModelUid("claude-opus-5", [{ id: "reasoning", value: true }])).toBe( + "claude-opus-5", + ); + }); + + it("falls back to adaptive for empty input", () => { + expect(resolveDevinModelUid(" ")).toBe("adaptive"); + }); +}); diff --git a/apps/server/src/provider/acp/DevinAcpSupport.ts b/apps/server/src/provider/acp/DevinAcpSupport.ts new file mode 100644 index 000000000000..7773dba054f6 --- /dev/null +++ b/apps/server/src/provider/acp/DevinAcpSupport.ts @@ -0,0 +1,204 @@ +import { type DevinSettings, type ProviderOptionSelection } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +import { devinModelGroupKey, parseDevinModelUid } from "../Layers/DevinProvider.ts"; + +export { inferDevinContextWindowTokens } from "../Layers/DevinProvider.ts"; + +const LOWER_SPEED_SUFFIX = /-(fast|priority)$/; +const UPPER_SPEED_SUFFIX = /_(FAST|PRIORITY)$/; + +function stripTrailingSpeed(slug: string): { base: string; speed: string } | undefined { + const lower = slug.match(LOWER_SPEED_SUFFIX); + if (lower) { + return { base: slug.slice(0, slug.length - lower[0].length), speed: lower[1]! }; + } + const upper = slug.match(UPPER_SPEED_SUFFIX); + if (upper) { + return { base: slug.slice(0, slug.length - upper[0].length), speed: upper[1]!.toLowerCase() }; + } + return undefined; +} + +type DevinAcpRuntimeSettings = Pick; + +export const DEVIN_ACP_CLIENT_CAPABILITIES = { + _meta: { + "cognition.ai/requestDiagnostics": true, + }, +} satisfies NonNullable; + +export interface DevinAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly devinSettings: DevinAcpRuntimeSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; +} + +export function buildDevinAcpSpawnInput( + devinSettings: DevinAcpRuntimeSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + return { + command: devinSettings?.binaryPath || "devin", + args: ["acp"], + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +export const makeDevinAcpRuntime = ( + input: DevinAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildDevinAcpSpawnInput(input.devinSettings, input.cwd, input.environment), + clientCapabilities: DEVIN_ACP_CLIENT_CAPABILITIES, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +export interface DevinAcpModelSelectionErrorContext { + readonly cause: EffectAcpErrors.AcpError; + readonly step: "set-config-option"; + readonly configId: "model"; +} + +export function applyDevinAcpModelSelection(input: { + readonly runtime: Pick; + readonly model: string | null | undefined; + readonly selections?: ReadonlyArray | null | undefined; + readonly mapError: (context: DevinAcpModelSelectionErrorContext) => E; +}): Effect.Effect { + const model = resolveDevinModelUid(input.model, input.selections); + const reasoning = input.selections?.find((option) => option.id === "reasoning")?.value; + const context = input.selections?.find((option) => option.id === "contextWindow")?.value; + const speed = input.selections?.find((option) => option.id === "speed")?.value; + const base = resolveDevinAcpBaseModelId(input.model); + const isUpper = base.includes("_") && /[A-Z]/.test(base); + const separator = isUpper ? "_" : "-"; + const contextSuffix = + typeof context === "string" && context.trim() + ? `${separator}${isUpper ? context.toUpperCase() : context.toLowerCase()}` + : ""; + const fallbackCandidates = + reasoning === "none" && (speed === undefined || speed === "standard") + ? [contextSuffix.length > 0 ? `${base}${contextSuffix}` : null, base].filter( + (candidate): candidate is string => candidate !== null && candidate !== model, + ) + : []; + const setModel = input.runtime.setModel(model).pipe( + Effect.catch((cause) => { + const fallback = fallbackCandidates[0]; + return fallback === undefined + ? Effect.fail(cause) + : input.runtime.setModel(fallback).pipe( + Effect.catch((fallbackCause) => { + const secondFallback = fallbackCandidates[1]; + return secondFallback === undefined + ? Effect.fail(fallbackCause) + : input.runtime.setModel(secondFallback); + }), + ); + }), + ); + return setModel.pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + step: "set-config-option", + configId: "model", + }), + ), + ); +} + +/** + * Returns the base model slug (the group key, with no reasoning level) used + * to decide whether a model change requires an ACP session restart. Changing + * only the reasoning level is a config-option tweak, not a model swap. + */ +export function resolveDevinAcpBaseModelId(model: string | null | undefined): string { + const trimmed = model?.trim(); + if (!trimmed) return "adaptive"; + return devinModelGroupKey(parseDevinModelUid(trimmed)); +} + +/** + * Recombines a base model slug with its `reasoning` option selection to form + * the full model UID Devin's ACP backend expects. + * + * The base slug is the group key (e.g. `claude-opus-5` or + * `claude-opus-5-fast`); the reasoning choice is inserted before any speed + * tier so `claude-opus-5-fast` + `medium` → `claude-opus-5-medium-fast`. + * Uppercase enum-style UIDs recombine with `_` separators and uppercased + * suffixes: `MODEL_GPT_5_2` + `low` → `MODEL_GPT_5_2_LOW`. + */ +export function resolveDevinModelUid( + model: string | null | undefined, + options?: ReadonlyArray | null, +): string { + const groupSlug = resolveDevinAcpBaseModelId(model); + const reasoning = options?.find((option) => option.id === "reasoning")?.value; + if (typeof reasoning !== "string" || !reasoning.trim()) { + return groupSlug; + } + if (reasoning.startsWith("__uid:")) { + const concreteUid = reasoning.slice("__uid:".length).trim(); + if (concreteUid.length > 0) return concreteUid; + } + // The group slug is `base + sep + speed` (or just base). Split the speed + // tier off so the reasoning level can be inserted before it. + const speedParts = stripTrailingSpeed(groupSlug); + const base = speedParts?.base ?? groupSlug; + const selectedSpeed = options?.find((option) => option.id === "speed")?.value; + const speed = + typeof selectedSpeed === "string" && selectedSpeed !== "standard" + ? selectedSpeed + : speedParts?.speed; + const isUpper = base.includes("_") && /[A-Z]/.test(base); + const sep = isUpper ? "_" : "-"; + const reasoningSuffix = isUpper ? reasoning.toUpperCase() : reasoning; + const speedSuffix = speed ? (isUpper ? `_${speed.toUpperCase()}` : `-${speed}`) : ""; + const context = options?.find((option) => option.id === "contextWindow")?.value; + // Devin's GLM family uses the unsuffixed UID for the 200K variants and + // appends only the context suffix for 1M variants. The reasoning level is + // encoded for None/Max, while High is the family default (`glm-5-2`). + // It is never `glm-5-2-high-200k` (or another `-200k` UID). + const isGlm52 = base.toLowerCase() === "glm-5-2"; + if (isGlm52 && (reasoning ?? "").toLowerCase() === "high") { + return context === "1m" ? "glm-5-2-1m" : "glm-5-2"; + } + if (isGlm52 && !speed && ["none", "max"].includes((reasoning ?? "").toLowerCase())) { + const glmContextSuffix = context === "1m" ? "-1m" : ""; + return `glm-5-2-${reasoning!.toLowerCase()}${glmContextSuffix}`; + } + const contextSuffix = + typeof context === "string" && context.trim() + ? `${sep}${isUpper ? context.toUpperCase() : context.toLowerCase()}` + : ""; + return `${base}${sep}${reasoningSuffix}${speedSuffix}${contextSuffix}`; +} diff --git a/apps/server/src/provider/acp/DevinOptionalContentFixtures.test.ts b/apps/server/src/provider/acp/DevinOptionalContentFixtures.test.ts new file mode 100644 index 000000000000..00304d475a56 --- /dev/null +++ b/apps/server/src/provider/acp/DevinOptionalContentFixtures.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "vite-plus/test"; + +const MAX_CAPTURE_RECORD_BYTES = 512; +const MAX_CAPTURE_RECORDS = 32; +const NON_OPTIONAL_UPDATE_TYPES = new Set([ + "config_option_update", + "current_mode_update", + "available_commands_update", + "session_info_update", + "usage_update", +]); + +export type DevinOptionalContentClassification = "ordinary text" | "unsupported"; + +export interface DevinSanitizedAcpCapture { + readonly method: "session/update" | "session/elicitation" | "unknown"; + readonly updateType?: string; + readonly contentType?: string; + readonly classification: DevinOptionalContentClassification; + readonly reason?: "redacted-size"; +} + +export const DEVIN_OPTIONAL_CONTENT_UNSUPPORTED_FIXTURE = { + status: "unsupported", + reason: "not-emitted-by-installed-devin-cli", +} as const; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function safeTag(value: unknown): string | undefined { + return typeof value === "string" && /^[A-Za-z][A-Za-z0-9._:-]{0,63}$/.test(value) + ? value + : undefined; +} + +function boundedRecord(record: DevinSanitizedAcpCapture): DevinSanitizedAcpCapture { + const encoded = new TextEncoder().encode(JSON.stringify(record)); + return encoded.byteLength <= MAX_CAPTURE_RECORD_BYTES + ? record + : { method: "unknown", classification: "unsupported", reason: "redacted-size" }; +} + +export function captureDevinAcpUpdate(event: unknown): DevinSanitizedAcpCapture | undefined { + if (!isRecord(event) || event.direction !== "incoming" || event.stage !== "decoded") { + return undefined; + } + + const messages = Array.isArray(event.payload) ? event.payload : [event.payload]; + const message = messages.find(isRecord); + if (!message) return undefined; + + const method = + message.tag === "session/update" + ? "session/update" + : message.tag === "session/elicitation" + ? "session/elicitation" + : "unknown"; + if (method === "unknown") return undefined; + + const payload = isRecord(message.payload) ? message.payload : undefined; + const update = payload && isRecord(payload.update) ? payload.update : undefined; + const oversizedField = + (typeof update?.sessionUpdate === "string" && update.sessionUpdate.length > 64) || + (isRecord(update?.content) && + typeof update.content.type === "string" && + update.content.type.length > 64); + if (oversizedField) { + return { method: "unknown", classification: "unsupported", reason: "redacted-size" }; + } + const updateType = safeTag(update?.sessionUpdate); + const content = update && isRecord(update.content) ? update.content : undefined; + const contentType = safeTag(content?.type); + return boundedRecord({ + method, + ...(updateType ? { updateType } : {}), + ...(contentType ? { contentType } : {}), + classification: contentType === "text" ? "ordinary text" : "unsupported", + }); +} + +export interface DevinAcpCapture { + readonly write: (event: unknown) => void; + readonly records: () => ReadonlyArray; +} + +export function selectDevinOptionalContent( + records: ReadonlyArray, +): ReadonlyArray { + return records.filter( + (record) => + record.classification !== "ordinary text" && + !NON_OPTIONAL_UPDATE_TYPES.has(record.updateType ?? ""), + ); +} + +export function createDevinAcpCapture(enabled: boolean): DevinAcpCapture | undefined { + if (!enabled) return undefined; + const records: DevinSanitizedAcpCapture[] = []; + return { + write(event: unknown): void { + const record = captureDevinAcpUpdate(event); + if (record && records.length < MAX_CAPTURE_RECORDS) records.push(record); + }, + records(): ReadonlyArray { + return records.slice(); + }, + }; +} + +describe("Devin sanitized optional ACP fixtures", () => { + it("redacts prompts, credentials, paths, environment values, and blobs", () => { + const capture = createDevinAcpCapture(true); + expect(capture).toBeDefined(); + if (!capture) return; + capture.write({ + direction: "incoming", + stage: "decoded", + payload: [ + { + tag: "session/update", + payload: { + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "secret prompt" }, + authorization: "Bearer secret-token", + workspace: "C:\\Users\\person\\project", + environment: { SECRET: "secret-value" }, + blob: "a".repeat(10_000), + }, + }, + }, + ], + }); + + const serialized = JSON.stringify(capture.records()); + expect(serialized).not.toContain("secret prompt"); + expect(serialized).not.toContain("secret-token"); + expect(serialized).not.toContain("C:\\Users\\person\\project"); + expect(serialized).not.toContain("secret-value"); + expect(serialized).not.toContain("a".repeat(1_000)); + expect(capture.records()).toEqual([ + { + method: "session/update", + updateType: "agent_message_chunk", + contentType: "text", + classification: "ordinary text", + }, + ]); + }); + + it("bounds each record and the in-memory capture", () => { + const capture = createDevinAcpCapture(true); + expect(capture).toBeDefined(); + if (!capture) return; + for (let index = 0; index < 40; index += 1) { + capture.write({ + direction: "incoming", + stage: "decoded", + payload: [ + { + tag: "session/update", + payload: { + update: { + sessionUpdate: `not-safe-${"x".repeat(1_000)}-${index}`, + content: { type: "unsupported" }, + }, + }, + }, + ], + }); + } + + expect(capture.records()).toHaveLength(32); + for (const record of capture.records()) { + expect(new TextEncoder().encode(JSON.stringify(record)).byteLength).toBeLessThanOrEqual(512); + expect(record.classification).toBe("unsupported"); + expect(record.reason).toBe("redacted-size"); + } + }); + + it("does not create capture state when capture is disabled", () => { + expect(createDevinAcpCapture(false)).toBeUndefined(); + }); + + it("keeps startup metadata out of optional fixture output", () => { + expect( + selectDevinOptionalContent([ + { + method: "session/update", + updateType: "agent_message_chunk", + contentType: "text", + classification: "ordinary text", + }, + { + method: "session/update", + updateType: "available_commands_update", + classification: "unsupported", + }, + { + method: "session/update", + updateType: "future_optional_update", + classification: "unsupported", + }, + ]), + ).toEqual([ + { + method: "session/update", + updateType: "future_optional_update", + classification: "unsupported", + }, + ]); + }); +}); diff --git a/apps/server/src/provider/acp/DevinResourceSupport.test.ts b/apps/server/src/provider/acp/DevinResourceSupport.test.ts new file mode 100644 index 000000000000..e14be9be4357 --- /dev/null +++ b/apps/server/src/provider/acp/DevinResourceSupport.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vite-plus/test"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { + DEVIN_RESOURCE_TEXT_MAX_CHARS, + normalizeDevinResourceContent, +} from "./DevinResourceSupport.ts"; + +describe("Devin ACP resource normalization", () => { + // Task 2 did not observe resource content from the installed Devin CLI. These are + // intentionally synthetic, typed ACP schema fixtures; they are not live Devin evidence. + it("normalizes a typed ACP resource-link fixture", () => { + const fixture = { + type: "content", + content: { + type: "resource_link", + uri: "urn:acp:fixture:resource-link", + name: "schema fixture", + description: "typed protocol fixture", + mimeType: "text/markdown", + }, + } satisfies EffectAcpSchema.ToolCallContent; + + expect(normalizeDevinResourceContent(fixture)).toEqual({ + kind: "resource", + resource: { + uri: "urn:acp:fixture:resource-link", + name: "schema fixture", + description: "typed protocol fixture", + mimeType: "text/markdown", + }, + }); + }); + + it("normalizes a typed ACP embedded-text resource fixture", () => { + const fixture = { + type: "content", + content: { + type: "resource", + resource: { + uri: "urn:acp:fixture:embedded-text", + mimeType: "text/plain", + text: "protocol fixture", + }, + }, + } satisfies EffectAcpSchema.ToolCallContent; + + expect(normalizeDevinResourceContent(fixture)).toEqual({ + kind: "resource", + resource: { + uri: "urn:acp:fixture:embedded-text", + mimeType: "text/plain", + text: "protocol fixture", + }, + }); + }); + + it("omits null optional resource-link metadata", () => { + const fixture = { + type: "content", + content: { + type: "resource_link", + uri: "https://resources.example/acp/null-metadata", + name: "ACP reference", + description: null, + mimeType: null, + }, + } satisfies EffectAcpSchema.ToolCallContent; + + expect(normalizeDevinResourceContent(fixture)).toEqual({ + kind: "resource", + resource: { + uri: "https://resources.example/acp/null-metadata", + name: "ACP reference", + }, + }); + }); + + it("rejects malformed optional resource content without throwing", () => { + expect( + normalizeDevinResourceContent({ + type: "content", + content: { type: "resource", resource: { uri: 42, text: "fixture" } }, + }), + ).toEqual({ kind: "unsupported", reason: "invalid" }); + }); + + it("rejects resource content missing its required URI", () => { + expect( + normalizeDevinResourceContent({ + type: "content", + content: { type: "resource_link", name: "fixture" }, + }), + ).toEqual({ kind: "unsupported", reason: "invalid" }); + }); + + it("rejects oversized embedded text", () => { + const fixture = { + type: "content", + content: { + type: "resource", + resource: { + uri: "https://resources.example/acp/oversized", + text: "x".repeat(DEVIN_RESOURCE_TEXT_MAX_CHARS + 1), + }, + }, + } satisfies EffectAcpSchema.ToolCallContent; + + expect(normalizeDevinResourceContent(fixture)).toEqual({ + kind: "unsupported", + reason: "oversized", + }); + }); + + it("rejects a protocol-schema embedded blob without decoding it", () => { + const fixture = { + type: "content", + content: { + type: "resource", + resource: { + uri: "https://resources.example/acp/binary", + mimeType: "application/octet-stream", + blob: "opaque-binary-fixture", + }, + }, + } satisfies EffectAcpSchema.ToolCallContent; + + expect(normalizeDevinResourceContent(fixture)).toEqual({ + kind: "unsupported", + reason: "binary", + }); + }); + + it("rejects a malformed blob field rather than treating it as text", () => { + expect( + normalizeDevinResourceContent({ + type: "content", + content: { + type: "resource", + resource: { + uri: "urn:acp:fixture:malformed-blob", + text: "typed protocol fixture", + blob: 42, + }, + }, + }), + ).toEqual({ kind: "unsupported", reason: "invalid" }); + }); +}); diff --git a/apps/server/src/provider/acp/DevinResourceSupport.ts b/apps/server/src/provider/acp/DevinResourceSupport.ts new file mode 100644 index 000000000000..28beeb958984 --- /dev/null +++ b/apps/server/src/provider/acp/DevinResourceSupport.ts @@ -0,0 +1,120 @@ +export type DevinResourceContent = { + readonly uri: string; + readonly name?: string; + readonly description?: string; + readonly mimeType?: string; + readonly text?: string; +}; + +export type DevinResourceNormalization = + | { readonly kind: "resource"; readonly resource: DevinResourceContent } + | { readonly kind: "unsupported"; readonly reason: "invalid" | "oversized" | "binary" }; + +/** Matches the ACP tool-output retention limit before resource data enters tool event state. */ +export const DEVIN_RESOURCE_TEXT_MAX_CHARS = 8_000; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function boundedString(value: unknown): string | undefined { + return typeof value === "string" && value.length <= DEVIN_RESOURCE_TEXT_MAX_CHARS + ? value + : undefined; +} + +function resourceLink(content: Record): DevinResourceNormalization { + const uri = boundedString(content.uri); + const name = boundedString(content.name); + const description = content.description === null ? undefined : boundedString(content.description); + const mimeType = content.mimeType === null ? undefined : boundedString(content.mimeType); + if ( + !uri || + !name || + (content.description !== undefined && + content.description !== null && + description === undefined) || + (content.mimeType !== undefined && content.mimeType !== null && mimeType === undefined) + ) { + return { + kind: "unsupported", + reason: [content.uri, content.name, content.description, content.mimeType].some( + (value) => typeof value === "string" && value.length > DEVIN_RESOURCE_TEXT_MAX_CHARS, + ) + ? "oversized" + : "invalid", + }; + } + return { + kind: "resource", + resource: { + uri, + name, + ...(description !== undefined ? { description } : {}), + ...(mimeType !== undefined ? { mimeType } : {}), + }, + }; +} + +function embeddedResource(content: Record): DevinResourceNormalization { + const resource = content.resource; + if (!isRecord(resource)) { + return { kind: "unsupported", reason: "invalid" }; + } + const uri = boundedString(resource.uri); + const mimeType = resource.mimeType === null ? undefined : boundedString(resource.mimeType); + if ( + !uri || + (resource.mimeType !== undefined && resource.mimeType !== null && mimeType === undefined) + ) { + return { + kind: "unsupported", + reason: [resource.uri, resource.mimeType].some( + (value) => typeof value === "string" && value.length > DEVIN_RESOURCE_TEXT_MAX_CHARS, + ) + ? "oversized" + : "invalid", + }; + } + if ("blob" in resource) { + return typeof resource.blob === "string" + ? { kind: "unsupported", reason: "binary" } + : { kind: "unsupported", reason: "invalid" }; + } + const text = boundedString(resource.text); + if (text === undefined) { + return { + kind: "unsupported", + reason: + typeof resource.text === "string" && resource.text.length > DEVIN_RESOURCE_TEXT_MAX_CHARS + ? "oversized" + : "invalid", + }; + } + return { + kind: "resource", + resource: { + uri, + ...(mimeType !== undefined ? { mimeType } : {}), + text, + }, + }; +} + +/** + * Selects the ACP resource forms that the typed runtime can carry. It intentionally + * ignores ACP metadata and never reads or decodes binary resource blobs. + */ +export function normalizeDevinResourceContent(content: unknown): DevinResourceNormalization { + if (!isRecord(content) || content.type !== "content" || !isRecord(content.content)) { + return { kind: "unsupported", reason: "invalid" }; + } + switch (content.content.type) { + case "resource_link": + return resourceLink(content.content); + case "resource": + return embeddedResource(content.content); + default: + return { kind: "unsupported", reason: "invalid" }; + } +} diff --git a/apps/server/src/provider/acp/DevinSubagentSupport.test.ts b/apps/server/src/provider/acp/DevinSubagentSupport.test.ts new file mode 100644 index 000000000000..930760b50e09 --- /dev/null +++ b/apps/server/src/provider/acp/DevinSubagentSupport.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from "vite-plus/test"; + +const MAX_SUMMARY_CHARS = 160; +const SAFE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,96}$/; +const ORDINARY_TOOL_UPDATE_TYPES = new Set(["tool_call", "tool_call_update"]); + +export type DevinSubagentEventClassification = + | "stable child-agent stream" + | "event stream without stable identity" + | "no child-agent event support"; + +export interface DevinSanitizedSubagentObservation { + readonly method: "session/update"; + readonly updateType: string; + readonly toolCallId?: string; + readonly summary?: string; +} + +export const DEVIN_SUBAGENT_UNSUPPORTED_FIXTURE = { + status: "unsupported", + classification: "no child-agent event support", + reason: "not-emitted-by-installed-devin-cli", +} as const; + +interface DevinSubagentClassification { + readonly classification: DevinSubagentEventClassification; + readonly observations: ReadonlyArray; + readonly taskEvents: readonly []; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function boundedSummary(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const summary = value.trim(); + return summary.length > 0 ? summary.slice(0, MAX_SUMMARY_CHARS) : undefined; +} + +function safeId(value: unknown): string | undefined { + return typeof value === "string" && SAFE_ID_PATTERN.test(value) ? value : undefined; +} + +/** + * Test-local capture for the negative-evidence path. It records only bounded, + * non-sensitive fields and never turns an ordinary ACP tool update into a task. + */ +export function captureDevinSubagentObservation( + event: unknown, +): DevinSanitizedSubagentObservation | undefined { + if (!isRecord(event) || event.direction !== "incoming" || event.stage !== "decoded") { + return undefined; + } + + const messages = Array.isArray(event.payload) ? event.payload : [event.payload]; + const message = messages.find(isRecord); + if (message?.tag !== "session/update") return undefined; + + const payload = isRecord(message.payload) ? message.payload : undefined; + const update = payload && isRecord(payload.update) ? payload.update : undefined; + if (update === undefined) return undefined; + const updateType = typeof update?.sessionUpdate === "string" ? update.sessionUpdate : undefined; + if (updateType === undefined || updateType.length > 64) return undefined; + + const toolCallId = safeId(update.toolCallId); + const summary = boundedSummary(update.title); + return { + method: "session/update", + updateType, + ...(toolCallId ? { toolCallId } : {}), + ...(summary ? { summary } : {}), + }; +} + +/** + * Classify what the installed Devin ACP stream demonstrated. A future unknown + * update is kept as an unclassified stream; it is never promoted to a stable + * child-agent stream without observed identity, lifecycle, and parent linkage. + */ +export function classifyDevinSubagentEvents( + events: ReadonlyArray, +): DevinSubagentClassification { + const observations = events.flatMap((event) => { + const observation = captureDevinSubagentObservation(event); + return observation ? [observation] : []; + }); + + const onlyOrdinaryToolUpdates = + observations.length > 0 && + observations.every((observation) => ORDINARY_TOOL_UPDATE_TYPES.has(observation.updateType)); + + return { + classification: + observations.length === 0 || onlyOrdinaryToolUpdates + ? DEVIN_SUBAGENT_UNSUPPORTED_FIXTURE.classification + : "event stream without stable identity", + observations, + taskEvents: [], + }; +} + +const decodedUpdate = (update: Record) => ({ + direction: "incoming", + stage: "decoded", + payload: [{ tag: "session/update", payload: { update } }], +}); + +describe("Devin ACP child-agent evidence", () => { + it("records the explicit unsupported/no-stream result", () => { + expect(DEVIN_SUBAGENT_UNSUPPORTED_FIXTURE).toEqual({ + status: "unsupported", + classification: "no child-agent event support", + reason: "not-emitted-by-installed-devin-cli", + }); + expect(classifyDevinSubagentEvents([])).toMatchObject({ + classification: "no child-agent event support", + taskEvents: [], + }); + }); + + it("does not synthesize a child agent from ordinary tool calls", () => { + const result = classifyDevinSubagentEvents([ + decodedUpdate({ + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Delegate-like tool title", + }), + decodedUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + title: "Delegate-like tool title", + }), + ]); + + expect(result.classification).toBe("no child-agent event support"); + expect(result.taskEvents).toEqual([]); + }); + + it("keeps duplicate ordinary updates out of task events", () => { + const update = decodedUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: "tool-duplicate", + title: "bounded tool update", + }); + const result = classifyDevinSubagentEvents([update, update]); + + expect(result.observations).toHaveLength(2); + expect(result.classification).toBe("no child-agent event support"); + expect(result.taskEvents).toEqual([]); + }); + + it("keeps out-of-order ordinary updates out of task events", () => { + const result = classifyDevinSubagentEvents([ + decodedUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: "tool-out-of-order", + status: "completed", + }), + decodedUpdate({ + sessionUpdate: "tool_call", + toolCallId: "tool-out-of-order", + status: "pending", + }), + ]); + + expect(result.classification).toBe("no child-agent event support"); + expect(result.taskEvents).toEqual([]); + }); + + it("does not treat an ordinary update without parent linkage as a child agent", () => { + const result = classifyDevinSubagentEvents([ + decodedUpdate({ + sessionUpdate: "tool_call", + toolCallId: "tool-no-parent", + title: "ordinary tool", + }), + ]); + + expect(result.observations[0]).not.toHaveProperty("parentAgentId"); + expect(result.classification).toBe("no child-agent event support"); + expect(result.taskEvents).toEqual([]); + }); + + it("keeps a future unknown update as an event stream without stable identity", () => { + const result = classifyDevinSubagentEvents([ + decodedUpdate({ + sessionUpdate: "future_update", + title: "unrecognized update", + }), + ]); + + expect(result.classification).toBe("event stream without stable identity"); + expect(result.taskEvents).toEqual([]); + }); + + it("ignores malformed notifications without creating task events", () => { + const result = classifyDevinSubagentEvents([ + undefined, + { direction: "incoming", stage: "decoded", payload: [{ tag: "session/update" }] }, + decodedUpdate({ sessionUpdate: 42 }), + decodedUpdate({ sessionUpdate: "tool_call", toolCallId: "bad id" }), + ]); + + expect(result.observations).toEqual([{ method: "session/update", updateType: "tool_call" }]); + expect(result.classification).toBe("no child-agent event support"); + expect(result.taskEvents).toEqual([]); + }); + + it("bounds sanitized summaries and omits raw payload fields", () => { + const secretLikeText = "redacted-summary-" + "x".repeat(500); + const result = classifyDevinSubagentEvents([ + decodedUpdate({ + sessionUpdate: "tool_call", + toolCallId: "tool-bounded", + title: secretLikeText, + prompt: "must not be retained", + credentials: "must not be retained", + }), + ]); + + expect(result.observations[0]).toEqual({ + method: "session/update", + updateType: "tool_call", + toolCallId: "tool-bounded", + summary: secretLikeText.slice(0, MAX_SUMMARY_CHARS), + }); + expect(JSON.stringify(result)).not.toContain("must not be retained"); + expect(JSON.stringify(result)).not.toContain("credentials"); + }); +}); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 60e3402eed42..8630252ec2d6 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -23,6 +23,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; +import { DevinDriver, type DevinDriverEnv } from "./Drivers/DevinDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; @@ -37,6 +38,7 @@ export type BuiltInDriversEnv = | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv + | DevinDriverEnv | GrokDriverEnv | OpenCodeDriverEnv | AntigravityDriverEnv; @@ -50,6 +52,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray; @@ -251,6 +257,9 @@ export function buildServerProvider(input: { ...(typeof input.presentation.requiresNewThreadForModelChange === "boolean" ? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange } : {}), + ...(input.presentation.modelCatalogVersion + ? { modelCatalogVersion: input.presentation.modelCatalogVersion } + : {}), enabled: input.enabled, installed: input.probe.installed, version: input.probe.version, diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index cd7bfff273f8..28fb186bf276 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -13,6 +13,7 @@ import * as Logger from "effect/Logger"; import { hydrateCachedProvider, + isCachedProviderCatalogCompatible, isCachedProviderCorrelated, readProviderStatusCache, resolveProviderStatusCachePath, @@ -234,6 +235,78 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { ); }); + it("rejects cached model catalogs from an older provider catalog version", () => { + const cachedDevin = makeProvider(ProviderDriverKind.make("devin"), { + modelCatalogVersion: "devin-model-catalog-v1", + models: [ + { + slug: "claude-opus-5-medium", + name: "Medium", + isCustom: false, + capabilities: emptyCapabilities, + }, + ], + }); + const fallbackDevin = makeProvider(ProviderDriverKind.make("devin"), { + modelCatalogVersion: "devin-model-catalog-v2", + models: [ + { + slug: "claude-opus-5", + name: "Claude Opus 5", + isCustom: false, + capabilities: emptyCapabilities, + }, + ], + }); + + assert.strictEqual( + isCachedProviderCatalogCompatible({ + cachedProvider: cachedDevin, + fallbackProvider: fallbackDevin, + }), + false, + ); + assert.deepStrictEqual( + hydrateCachedProvider({ + cachedProvider: cachedDevin, + fallbackProvider: fallbackDevin, + }), + fallbackDevin, + ); + }); + + it("rejects pre-versioned cached model catalogs when the fallback is versioned", () => { + const cachedDevin = makeProvider(ProviderDriverKind.make("devin"), { + models: [ + { + slug: "legacy-flattened-model", + name: "Legacy Flattened Model", + isCustom: false, + capabilities: emptyCapabilities, + }, + ], + }); + const fallbackDevin = makeProvider(ProviderDriverKind.make("devin"), { + modelCatalogVersion: "devin-model-catalog-v2", + models: [], + }); + + assert.strictEqual( + isCachedProviderCatalogCompatible({ + cachedProvider: cachedDevin, + fallbackProvider: fallbackDevin, + }), + false, + ); + assert.deepStrictEqual( + hydrateCachedProvider({ + cachedProvider: cachedDevin, + fallbackProvider: fallbackDevin, + }), + fallbackDevin, + ); + }); + it("rejects cached snapshots that are not correlated to the fallback instance", () => { const fallbackCodex = makeProvider(CODEX_DRIVER, { models: [ diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index a4b260f43bf6..7e5d7c148e6b 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -65,6 +65,20 @@ export const isCachedProviderCorrelated = (input: { input.cachedProvider.instanceId === input.fallbackProvider.instanceId && input.cachedProvider.driver === input.fallbackProvider.driver; +/** + * A provider may change the shape of its model inventory without changing its + * instance identity. When the live fallback advertises a catalog version, + * cached snapshots must carry the same version before their models can be + * hydrated. Missing versions are intentionally rejected so snapshots written + * before versioning was introduced cannot reintroduce stale model entries. + */ +export const isCachedProviderCatalogCompatible = (input: { + readonly cachedProvider: ServerProvider; + readonly fallbackProvider: ServerProvider; +}): boolean => + input.fallbackProvider.modelCatalogVersion === undefined || + input.cachedProvider.modelCatalogVersion === input.fallbackProvider.modelCatalogVersion; + export const hydrateCachedProvider = (input: { readonly cachedProvider: ServerProvider; readonly fallbackProvider: ServerProvider; @@ -73,6 +87,10 @@ export const hydrateCachedProvider = (input: { return input.fallbackProvider; } + if (!isCachedProviderCatalogCompatible(input)) { + return input.fallbackProvider; + } + if ( !input.fallbackProvider.enabled || input.cachedProvider.enabled !== input.fallbackProvider.enabled diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 5d2571e72dc3..7feed9e84365 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -797,6 +797,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { yield* serverSettings.updateSettings({ providers: { cursor: { enabled: true }, + devin: { enabled: true }, grok: { enabled: true }, opencode: { enabled: true }, }, @@ -807,6 +808,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { // @effect-diagnostics-next-line preferSchemaOverJson:off const persisted = JSON.parse(raw); assert.isTrue(persisted.providers.cursor.enabled); + assert.isTrue(persisted.providers.devin.enabled); assert.isTrue(persisted.providers.grok.enabled); assert.isTrue(persisted.providers.opencode.enabled); }).pipe(Effect.provide(makeServerSettingsLayer())), @@ -822,6 +824,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.isFalse(initial.providers.grok.enabled); assert.isFalse(initial.providers.opencode.enabled); assert.isFalse(initial.providers.cursor.enabled); + assert.isFalse(initial.providers.devin.enabled); const next = yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/Development", @@ -1037,6 +1040,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { cursor: { enabled: false, }, + devin: { + enabled: false, + }, grok: { enabled: false, }, diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index fe72147dfa0c..f19a3b91d95f 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -258,6 +258,7 @@ const PersistedOptionalProviderSettings = Schema.Struct({ providers: Schema.optionalKey( Schema.Struct({ cursor: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), + devin: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), grok: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), opencode: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), }), @@ -286,6 +287,7 @@ function restoreUsedProviders( instanceId, instance.enabled === undefined && (instance.driver === "cursor" || + instance.driver === "devin" || instance.driver === "grok" || instance.driver === "opencode") && usedProviderInstances.has(instanceId) @@ -302,6 +304,10 @@ function restoreUsedProviders( ...settings.providers.cursor, enabled: persisted.providers?.cursor?.enabled ?? usedProviders.has("cursor"), }, + devin: { + ...settings.providers.devin, + enabled: persisted.providers?.devin?.enabled ?? usedProviders.has("devin"), + }, grok: { ...settings.providers.grok, enabled: persisted.providers?.grok?.enabled ?? usedProviders.has("grok"), @@ -361,6 +367,7 @@ const PERSISTED_SERVER_SETTINGS_DEFAULTS = { providers: { ...DEFAULT_SERVER_SETTINGS.providers, cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: undefined }, + devin: { ...DEFAULT_SERVER_SETTINGS.providers.devin, enabled: undefined }, grok: { ...DEFAULT_SERVER_SETTINGS.providers.grok, enabled: undefined }, opencode: { ...DEFAULT_SERVER_SETTINGS.providers.opencode, enabled: undefined }, }, @@ -472,13 +479,13 @@ const make = Effect.gen(function* () { provider_name AS "providerName", provider_instance_id AS "providerInstanceId" FROM projection_thread_sessions - WHERE provider_name IN ('cursor', 'grok', 'opencode') + WHERE provider_name IN ('cursor', 'devin', 'grok', 'opencode') UNION SELECT DISTINCT provider_name AS "providerName", provider_instance_id AS "providerInstanceId" FROM provider_session_runtime - WHERE provider_name IN ('cursor', 'grok', 'opencode') + WHERE provider_name IN ('cursor', 'devin', 'grok', 'opencode') `.pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/textGeneration/DevinTextGeneration.test.ts b/apps/server/src/textGeneration/DevinTextGeneration.test.ts new file mode 100644 index 000000000000..1319cfe7b359 --- /dev/null +++ b/apps/server/src/textGeneration/DevinTextGeneration.test.ts @@ -0,0 +1,204 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { DevinSettings, ProviderInstanceId } from "@t3tools/contracts"; +import { isHostWindows } from "@t3tools/shared/hostProcess"; +import { createModelSelection } from "@t3tools/shared/model"; +import { expect } from "vite-plus/test"; + +import * as ServerConfig from "../config.ts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { makeDevinTextGeneration } from "./DevinTextGeneration.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 shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`; + +const DevinTextGenerationTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-devin-text-generation-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +// The shared ACP fixture advertises `default`, while the real Devin catalog +// supplies `adaptive`. Text-generation behavior does not depend on that name. +const modelSelection = createModelSelection(ProviderInstanceId.make("devin"), "default"); + +function makeThreadTitleInput() { + return { + cwd: process.cwd(), + message: "Add Devin text generation coverage.", + modelSelection, + } satisfies TextGeneration.ThreadTitleGenerationInput; +} + +function makeCommitMessageInput() { + return { + cwd: process.cwd(), + branch: "feature/devin-text-generation", + stagedSummary: "M apps/server/src/textGeneration/DevinTextGeneration.ts", + stagedPatch: "diff --git a/apps/server/src/textGeneration/DevinTextGeneration.ts b/...", + modelSelection, + } satisfies TextGeneration.CommitMessageGenerationInput; +} + +const makeMockDevinWrapper = Effect.fn("makeMockDevinWrapper")(function* ( + extraEnv?: Record, +) { + const isWindows = yield* isHostWindows; + const dir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-text-generation-mock-")), + ); + + if (isWindows) { + const wrapperPath = NodePath.join(dir, "fake-devin.cmd"); + const envLines = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `set ${key}=${value}`) + .join("\r\n"); + const script = `@echo off\r\n${envLines}\r\n"${mockAgentCommand}" "${mockAgentPath}" %*\r\n`; + yield* Effect.promise(() => NodeFSP.writeFile(wrapperPath, script, "utf8")); + return wrapperPath; + } + + const wrapperPath = NodePath.join(dir, "fake-devin.sh"); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `export ${key}=${shellQuote(value)}`) + .join("\n"); + const script = `#!/bin/sh +${envExports} +exec ${shellQuote(mockAgentCommand)} ${shellQuote(mockAgentPath)} "$@" +`; + yield* Effect.promise(() => NodeFSP.writeFile(wrapperPath, script, "utf8")); + yield* Effect.promise(() => NodeFSP.chmod(wrapperPath, 0o755)); + return wrapperPath; +}); + +function withFakeDevin( + env: Record, + effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, + options?: Parameters[2], +) { + return Effect.gen(function* () { + const wrapperPath = yield* makeMockDevinWrapper(env); + const textGeneration = yield* makeDevinTextGeneration( + decodeDevinSettings({ enabled: true, binaryPath: wrapperPath, customModels: [] }), + undefined, + options, + ); + return yield* effectFn(textGeneration); + }).pipe(Effect.scoped); +} + +it.layer(DevinTextGenerationTestLayer, { excludeTestServices: true })( + "DevinTextGeneration", + (it) => { + it.effect("decodes successful structured output", () => + withFakeDevin( + { + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + subject: "Add Devin text generation coverage", + body: "Cover the ACP text-generation boundary.", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage(makeCommitMessageInput()); + expect(generated.subject).toBe("Add Devin text generation coverage"); + expect(generated.body).toBe("Cover the ACP text-generation boundary."); + }), + ), + ); + + it.effect("accepts a large structured response without truncating it", () => { + const body = "large response ".repeat(1_024).trimEnd(); + return withFakeDevin( + { + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + title: "Handle a large Devin response", + body, + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generatePrContent({ + cwd: process.cwd(), + baseBranch: "main", + headBranch: "feature/devin-text-generation", + commitSummary: "Add Devin text generation coverage", + diffSummary: "M apps/server/src/textGeneration/DevinTextGeneration.ts", + diffPatch: "diff --git a/apps/server/src/textGeneration/DevinTextGeneration.ts b/...", + modelSelection, + }); + expect(generated.body).toBe(body); + }), + ); + }); + + it.effect("returns a typed error for malformed JSON", () => + withFakeDevin({ T3_ACP_PROMPT_RESPONSE_TEXT: "not structured JSON" }, (textGeneration) => + Effect.gen(function* () { + const error = yield* Effect.flip( + textGeneration.generateThreadTitle(makeThreadTitleInput()), + ); + expect(error._tag).toBe("TextGenerationError"); + expect(error.detail).toMatch(/invalid structured output/i); + }), + ), + ); + + it.effect("returns a typed error for empty output", () => + withFakeDevin({ T3_ACP_PROMPT_RESPONSE_TEXT: " \n " }, (textGeneration) => + Effect.gen(function* () { + const error = yield* Effect.flip( + textGeneration.generateThreadTitle(makeThreadTitleInput()), + ); + expect(error._tag).toBe("TextGenerationError"); + expect(error.detail).toMatch(/empty/i); + }), + ), + ); + + it.effect("maps process startup failures to TextGenerationError", () => + Effect.gen(function* () { + const isWindows = yield* isHostWindows; + const missingDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-text-generation-missing-")), + ); + const missingBinary = NodePath.join(missingDir, `devin${isWindows ? ".exe" : ""}`); + const textGeneration = yield* makeDevinTextGeneration( + decodeDevinSettings({ enabled: true, binaryPath: missingBinary, customModels: [] }), + ); + const error = yield* Effect.flip( + textGeneration.generateThreadTitle(makeThreadTitleInput()), + ); + expect(error._tag).toBe("TextGenerationError"); + expect(error.operation).toBe("generateThreadTitle"); + }).pipe(Effect.scoped), + ); + + it.effect("times out and terminates a stalled ACP process", () => { + // The scoped text-generation effect must close the ACP child after its + // timeout. If cleanup regresses, this test hangs on the stalled mock. + return withFakeDevin( + { T3_ACP_HANG_PROMPT_FOREVER: "1" }, + (textGeneration) => + Effect.gen(function* () { + const error = yield* Effect.flip( + textGeneration.generateThreadTitle(makeThreadTitleInput()), + ); + expect(error._tag).toBe("TextGenerationError"); + expect(error.detail).toMatch(/timed out/i); + }), + { timeout: 50 }, + ); + }); + }, +); diff --git a/apps/server/src/textGeneration/DevinTextGeneration.ts b/apps/server/src/textGeneration/DevinTextGeneration.ts new file mode 100644 index 000000000000..d07f6d0eb440 --- /dev/null +++ b/apps/server/src/textGeneration/DevinTextGeneration.ts @@ -0,0 +1,273 @@ +import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { type DevinSettings, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { TextGenerationError } from "@t3tools/contracts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import { + applyDevinAcpModelSelection, + makeDevinAcpRuntime, +} from "../provider/acp/DevinAcpSupport.ts"; + +const DEVIN_TIMEOUT_MS = 180_000; + +const isTextGenerationError = Schema.is(TextGenerationError); + +export interface DevinTextGenerationOptions { + /** Override the ACP request timeout for focused tests and local diagnostics. */ + readonly timeout?: Duration.Input; +} + +/** + * Build a Devin text-generation closure bound to a specific `DevinSettings` + * payload. See `makeCodexAdapter` for the overall per-instance rationale. + */ +export const makeDevinTextGeneration = Effect.fn("makeDevinTextGeneration")(function* ( + devinSettings: DevinSettings, + environment?: NodeJS.ProcessEnv, + options?: DevinTextGenerationOptions, +) { + const crypto = yield* Crypto.Crypto; + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const resolvedEnvironment = environment ?? process.env; + const requestTimeout = options?.timeout ?? DEVIN_TIMEOUT_MS; + + const runDevinJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const outputRef = yield* Ref.make(""); + const runtime = yield* makeDevinAcpRuntime({ + devinSettings, + environment: resolvedEnvironment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + yield* runtime.start(); + yield* Effect.ignore(runtime.setMode("ask")); + yield* applyDevinAcpModelSelection({ + runtime, + model: modelSelection.model, + selections: modelSelection.options, + mapError: ({ cause, configId }) => + new TextGenerationError({ + operation, + detail: `Failed to set Devin ACP config option "${configId}" for text generation.`, + cause, + }), + }); + + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(requestTimeout), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Devin Agent request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Devin ACP request failed.", + cause, + }), + ), + ); + + const rawResult = (yield* Ref.get(outputRef)).trim(); + if (!rawResult) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Devin ACP request was cancelled." + : "Devin Agent returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(rawResult)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Devin Agent returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Devin ACP text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("DevinTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + + const generated = yield* runDevinJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("DevinTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + + const generated = yield* runDevinJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("DevinTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runDevinJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("DevinTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + + const generated = yield* runDevinJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index cc9b2e3926f3..7b12df6868ac 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "devin" + | "grok" + | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 9d728c88cf42..da6caa6fa23f 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -16,6 +16,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Scheduler from "effect/Scheduler"; +import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; @@ -23,6 +24,8 @@ import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as UsageService from "./UsageService.ts"; +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { return `${JSON.stringify({ type: "assistant", @@ -98,6 +101,78 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { + it.live("scopes catalog rates to Devin when provider model names collide", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5, "shared-model"))); + yield* Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + for (const [driver, inputPerMillion] of [ + ["devin", 2], + ["codex", 999], + ] as const) { + yield* Effect.promise(() => + NodeFSP.writeFile( + NodePath.join(config.providerStatusCacheDir, `${driver}.json`), + encodeUnknownJson({ + driver, + models: [ + { + slug: "shared-model", + pricing: { inputPerMillion, outputPerMillion: inputPerMillion * 4 }, + }, + ], + }), + ), + ); + } + yield* Effect.promise(() => + NodeFSP.writeFile( + NodePath.join(config.providerLogsDir, "events.devin-collision.log"), + `[2026-08-01T10:00:00Z] CANON: ${encodeUnknownJson({ + type: "thread.token-usage.updated", + eventId: "usage-collision", + createdAt: "2026-08-01T10:00:00Z", + provider: "devin", + threadId: "devin-collision", + payload: { + usage: { + model: "shared-model", + providerSessionId: "devin-session", + lastInputTokens: 10, + lastOutputTokens: 5, + }, + }, + })}\n`, + ), + ); + const service = yield* UsageService.make; + const summary = yield* service.readSummary(WINDOW); + const claude = summary.buckets.find((bucket) => bucket.provider === "claude"); + const devin = summary.buckets.find((bucket) => bucket.provider === "devin"); + assert.closeTo(claude?.costUsd ?? -1, 0.00035, 1e-12); + assert.closeTo(devin?.costUsd ?? -1, 0.00006, 1e-12); + yield* Effect.promise(() => + NodeFSP.rm(NodePath.join(config.providerStatusCacheDir, "devin.json")), + ); + const withoutDevin = yield* service.readSummary(WINDOW); + assert.isFalse(withoutDevin.pricing.source.includes("Devin CLI model catalog")); + for (const bucket of withoutDevin.buckets) assert.closeTo(bucket.costUsd, 0.00035, 1e-12); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-provider-collision", + home, + settings, + ratesDocument: { + "shared-model": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, + }, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -292,6 +367,65 @@ describe("UsageService", () => { }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); + it.live("keeps Devin catalog rates when LiteLLM loads on the first scan", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + yield* Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const devinCachePath = NodePath.join(config.providerStatusCacheDir, "devin.json"); + yield* Effect.promise(() => + NodeFSP.writeFile( + devinCachePath, + encodeUnknownJson({ + driver: "devin", + models: [ + { + slug: "devin-exclusive-model", + pricing: { + inputPerMillion: 0.5, + cachedInputPerMillion: 0.1, + outputPerMillion: 2, + }, + }, + ], + }), + ), + ); + + const service = yield* UsageService.make; + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(first.pricing.knownModels, 2); + assert.include(first.pricing.source, "Devin CLI model catalog"); + + yield* TestClock.adjust(Duration.minutes(2)); + const refreshed = yield* service.refreshRates; + assert.strictEqual(refreshed.knownModels, 2); + assert.include(refreshed.source, "Devin CLI model catalog"); + + yield* Effect.promise(() => NodeFSP.rm(devinCachePath)); + const withoutDevinCatalog = yield* service.readSummary(WINDOW); + assert.strictEqual(withoutDevinCatalog.pricing.knownModels, 1); + assert.isFalse(withoutDevinCatalog.pricing.source.includes("Devin CLI model catalog")); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-devin-rates-test", + home, + settings, + ratesDocument: { + "claude-fable-5": { + input_cost_per_token: 1e-5, + output_cost_per_token: 5e-5, + }, + }, + }), + ), + ); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + it.live("does not orphan an in-flight scan when its first caller is interrupted", () => Effect.gen(function* () { const { settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0e6b0c1eecd6..bd085a71dab7 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -2,8 +2,10 @@ * UsageService - scans provider transcripts and returns priced usage buckets. * * The scan reads the provider CLIs' own session files (Claude Code, Codex, and - * Grok Build) rather than T3 Code's orchestration projections, so usage covers - * turns driven outside T3 Code too. This is the approach `ccusage` takes. + * Grok Build) plus T3's canonical Devin ACP event logs. CLI usage covers turns + * driven outside T3 Code too; Devin ACP usage is limited to sessions driven by + * this server. Optional official account ACUs are fetched separately when a + * billing-capable Devin service credential is configured. * * Transcripts are append-only, so parsed records are memoised per file by * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm @@ -16,7 +18,9 @@ import * as NodeOS from "node:os"; import { USAGE_CONTRACT_VERSION, + resolveProviderInstanceEnabled, type ServerSettings as ServerSettingsValue, + type UsageAccountConsumption, type UsageProviderKind, type UsageSource, type UsagePricing, @@ -35,9 +39,10 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; @@ -45,8 +50,15 @@ import * as ServerSettings from "../serverSettings.ts"; import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { UsageAggregator } from "./usageAggregation.ts"; -import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { + createOverrideRateTable, + parseProviderModelRateTable, + parseRateTable, + type ModelRate, + type RateTable, +} from "./usagePricing.ts"; +import { + listProviderEventLogFiles, listTranscriptFiles, readDirectoryVolumeId, readTranscriptRecords, @@ -59,9 +71,100 @@ import { type ScanCache, } from "./usageScanCache.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; +import { parseDevinAccountConsumptionPayload } from "./devinAccountUsage.ts"; const LITELLM_RATES_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; +const DEVIN_RATES_SOURCE = "Devin CLI model catalog (local provider snapshot)"; +const DEVIN_ACCOUNT_API_BASE = "https://api.devin.ai/v3"; +const DEVIN_ACCOUNT_SOURCE = "Devin organization consumption API (ACUs)"; +const DEVIN_ACCOUNT_API_KEY_ENV_NAMES = ["DEVIN_API_KEY", "DEVIN_PERSONAL_ACCESS_TOKEN"] as const; +const DEVIN_ACCOUNT_ORG_ENV_NAMES = ["DEVIN_ORG_ID", "DEVIN_ORGANIZATION_ID"] as const; +const DEVIN_ACCOUNT_REQUEST_TIMEOUT_MS = 10_000; +const DEVIN_ACCOUNT_CACHE_TTL_MS = 5 * 60 * 1_000; + +function nonEmptyString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +function findEnvironmentValue( + environment: NodeJS.ProcessEnv, + names: readonly string[], +): string | undefined { + const wanted = new Set(names.map((name) => name.toUpperCase())); + for (const [name, value] of Object.entries(environment)) { + if (wanted.has(name.toUpperCase())) { + const resolved = nonEmptyString(value); + if (resolved !== undefined) return resolved; + } + } + return undefined; +} + +function findDevinProviderEnvironmentValue( + settings: ServerSettingsValue, + names: readonly string[], +): string | undefined { + const wanted = new Set(names.map((name) => name.toUpperCase())); + for (const instance of Object.values(settings.providerInstances)) { + if (String(instance.driver).toLowerCase() !== "devin") continue; + for (const variable of instance.environment ?? []) { + if (!wanted.has(variable.name.toUpperCase())) continue; + const resolved = nonEmptyString(variable.value); + if (resolved !== undefined) return resolved; + } + } + return undefined; +} + +function isDevinEnabled(settings: ServerSettingsValue): boolean { + if (settings.providers.devin.enabled === true) return true; + return Object.values(settings.providerInstances).some( + (instance) => + String(instance.driver).toLowerCase() === "devin" && resolveProviderInstanceEnabled(instance), + ); +} + +function accountConsumptionStatus(input: { + readonly status: UsageAccountConsumption["status"]; + readonly message: string | null; + readonly fetchedAt?: string | null; + readonly totalAcus?: number; + readonly days?: UsageAccountConsumption["days"]; +}): UsageAccountConsumption { + return { + provider: "devin", + status: input.status, + source: DEVIN_ACCOUNT_SOURCE, + fetchedAt: input.fetchedAt ?? null, + totalAcus: input.totalAcus ?? 0, + days: input.days ?? [], + message: input.message, + }; +} + +function epochSecondsForDay(day: string, offsetDays: number): number | null { + const milliseconds = Date.parse(`${day}T00:00:00.000Z`); + if (!Number.isFinite(milliseconds)) return null; + return Math.floor((milliseconds + offsetDays * 24 * 60 * 60 * 1_000) / 1_000); +} + +function selectAccountWindow( + parsed: ReturnType, + input: UsageSummaryInput, +): { readonly totalAcus: number; readonly days: UsageAccountConsumption["days"] } | null { + if (parsed === null) return null; + const days = parsed.days.filter((day) => day.day >= input.sinceDay && day.day <= input.untilDay); + return { + // If the endpoint returned no date rows, retain its required total. When + // rows are present, summing the selected rows avoids leaking an adjacent + // day from the one-day query padding used for Devin's PST billing boundary. + totalAcus: + parsed.days.length === 0 ? parsed.totalAcus : days.reduce((sum, day) => sum + day.acus, 0), + days, + }; +} /** Rates move rarely; a day-old table keeps the page working offline. */ const RATES_TTL_MS = 24 * 60 * 60 * 1000; @@ -95,6 +198,11 @@ const encodeRatesCache = Schema.encodeEffect( const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec); const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); const encodeScanCacheFile = Schema.encodeEffect(ScanCacheJson); +const decodeProviderSnapshotJson = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ driver: Schema.Literal("devin"), models: Schema.Array(Schema.Unknown) }), + ), +); export class UsageService extends Context.Service< UsageService, @@ -145,7 +253,8 @@ export const make = Effect.gen(function* () { const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json"); - let rates: RateTable = new Map(); + let liteLlmRates: RateTable = new Map(); + let devinRates: RateTable = new Map(); let ratesFetchedAtMs: number | null = null; let ratesStatus: UsagePricing["status"] = "unavailable"; // One fetch at a time. A burst of refreshes from several clients waits on @@ -154,11 +263,16 @@ export const make = Effect.gen(function* () { const pricing = (): UsagePricing => ({ status: ratesStatus, - source: LITELLM_RATES_URL, + source: + devinRates.size > 0 ? `${LITELLM_RATES_URL} + ${DEVIN_RATES_SOURCE}` : LITELLM_RATES_URL, fetchedAt: ratesFetchedAtMs === null ? null : DateTime.formatIso(DateTime.makeUnsafe(ratesFetchedAtMs)), - knownModels: rates.size, + knownModels: liteLlmRates.size + devinRates.size, }); + const devinAccountCache = new Map< + string, + { readonly fetchedAtMs: number; readonly value: UsageAccountConsumption } + >(); /** * Loads the LiteLLM rate table, preferring a fresh copy and falling back to @@ -179,7 +293,7 @@ export const make = Effect.gen(function* () { if (fromDisk !== null) { const parsed = parseRateTable(fromDisk.document); if (parsed.size > 0) { - rates = parsed; + liteLlmRates = parsed; ratesFetchedAtMs = fromDisk.fetchedAtMs; ratesStatus = "cached"; if (now - fromDisk.fetchedAtMs < maxAgeMs) return; @@ -196,14 +310,14 @@ export const make = Effect.gen(function* () { if (fetched === null) { // The refresh failed; whatever we are serving is now past its TTL and // must not keep claiming to be fresh. - if (rates.size > 0) ratesStatus = "cached"; + if (liteLlmRates.size > 0) ratesStatus = "cached"; return; } const parsed = parseRateTable(fetched); if (parsed.size === 0) return; - rates = parsed; + liteLlmRates = parsed; ratesFetchedAtMs = now; ratesStatus = "fresh"; @@ -220,6 +334,153 @@ export const make = Effect.gen(function* () { Effect.withSpan("UsageService.refreshRates"), ); + /** + * Provider probes persist their model catalog in the status-cache directory. + * Retain Devin's advertised per-million prices for Devin records so + * canonical ACP records can be priced even when the public LiteLLM table + * does not yet contain a newly launched Devin model. + */ + const ensureDevinRates = Effect.fn("UsageService.ensureDevinRates")(function* () { + const entries = yield* fileSystem + .readDirectory(config.providerStatusCacheDir) + .pipe(Effect.catchCause(() => Effect.succeed([] as ReadonlyArray))); + const merged = new Map(); + for (const entry of entries) { + if (!entry.toLowerCase().endsWith(".json")) continue; + const raw = yield* fileSystem + .readFileString(path.join(config.providerStatusCacheDir, entry)) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + if (raw === null) continue; + const document = yield* decodeProviderSnapshotJson(raw).pipe(Effect.option); + if (Option.isNone(document)) continue; + for (const [model, rate] of parseProviderModelRateTable(document.value)) { + merged.set(model, rate); + } + } + devinRates = merged; + }); + + /** + * Optionally reads official account-level Devin ACUs. The normal CLI login + * token is intentionally not inspected or reused: Devin's organization + * consumption endpoint requires a separately provisioned service credential + * with billing permission. Missing credentials simply produce an explicit + * not-configured state and never make the usage scan fail. + */ + const readDevinAccountUsage = Effect.fn("UsageService.readDevinAccountUsage")(function* ( + input: UsageSummaryInput, + settings: ServerSettingsValue, + ) { + if (!isDevinEnabled(settings)) return undefined; + + const apiKey = + findDevinProviderEnvironmentValue(settings, DEVIN_ACCOUNT_API_KEY_ENV_NAMES) ?? + findEnvironmentValue(hostEnvironment, DEVIN_ACCOUNT_API_KEY_ENV_NAMES); + const organizationId = + findDevinProviderEnvironmentValue(settings, DEVIN_ACCOUNT_ORG_ENV_NAMES) ?? + findEnvironmentValue(hostEnvironment, DEVIN_ACCOUNT_ORG_ENV_NAMES); + if (apiKey === undefined || organizationId === undefined) { + const missing = [ + apiKey === undefined ? "DEVIN_API_KEY" : null, + organizationId === undefined ? "DEVIN_ORG_ID" : null, + ].filter((name): name is string => name !== null); + return accountConsumptionStatus({ + status: "notConfigured", + message: `Optional account ACU data needs ${missing.join(" and ")} as Devin provider environment variables.`, + }); + } + + const timeAfter = epochSecondsForDay(input.sinceDay, -1); + const timeBefore = epochSecondsForDay(input.untilDay, 2); + if (timeAfter === null || timeBefore === null) { + return accountConsumptionStatus({ + status: "failed", + message: "The requested usage window is not a valid date range.", + }); + } + + const cacheKey = `${organizationId}\u0000${input.sinceDay}\u0000${input.untilDay}`; + const now = yield* Clock.currentTimeMillis; + const cached = devinAccountCache.get(cacheKey); + if (cached !== undefined && now - cached.fetchedAtMs < DEVIN_ACCOUNT_CACHE_TTL_MS) { + return cached.value; + } + + const endpoint = new URL( + `${DEVIN_ACCOUNT_API_BASE}/organizations/${encodeURIComponent(organizationId)}/consumption/daily`, + ); + endpoint.searchParams.set("time_after", String(timeAfter)); + endpoint.searchParams.set("time_before", String(timeBefore)); + const request = HttpClientRequest.get(endpoint.toString()).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(apiKey), + ); + const responseResult = yield* httpClient + .execute(request) + .pipe(Effect.timeout(DEVIN_ACCOUNT_REQUEST_TIMEOUT_MS), Effect.result); + if (Result.isFailure(responseResult)) { + const value = accountConsumptionStatus({ + status: "failed", + message: "Devin account consumption could not be reached.", + }); + devinAccountCache.set(cacheKey, { fetchedAtMs: now, value }); + return value; + } + + const response = responseResult.success; + if (response.status === 401 || response.status === 403) { + const value = accountConsumptionStatus({ + status: "forbidden", + message: "The Devin API key lacks organization consumption permission.", + }); + devinAccountCache.set(cacheKey, { fetchedAtMs: now, value }); + return value; + } + if (response.status < 200 || response.status >= 300) { + const value = accountConsumptionStatus({ + status: "failed", + message: + response.status === 404 + ? "Devin organization consumption is unavailable for this account." + : `Devin account consumption returned HTTP ${response.status}.`, + }); + devinAccountCache.set(cacheKey, { fetchedAtMs: now, value }); + return value; + } + + const payloadResult = yield* response.json.pipe(Effect.result); + if (Result.isFailure(payloadResult)) { + const value = accountConsumptionStatus({ + status: "failed", + message: "Devin returned an unreadable account consumption response.", + }); + devinAccountCache.set(cacheKey, { fetchedAtMs: now, value }); + return value; + } + const parsed = selectAccountWindow( + parseDevinAccountConsumptionPayload(payloadResult.success), + input, + ); + if (parsed === null) { + const value = accountConsumptionStatus({ + status: "failed", + message: "Devin returned an invalid account consumption response.", + }); + devinAccountCache.set(cacheKey, { fetchedAtMs: now, value }); + return value; + } + + const value = accountConsumptionStatus({ + status: "available", + message: null, + fetchedAt: DateTime.formatIso(DateTime.makeUnsafe(now)), + totalAcus: parsed.totalAcus, + days: parsed.days, + }); + devinAccountCache.set(cacheKey, { fetchedAtMs: now, value }); + return value; + }); + /** * Claude's config dir is the home itself when overridden, but a default * install nests transcripts under `~/.claude/projects`. Probe both. @@ -268,6 +529,11 @@ export const make = Effect.gen(function* () { dir: path.join(grokHome, "sessions"), fileName: "updates.jsonl", }, + { + provider: "devin" as const, + dir: config.providerLogsDir, + eventLog: true as const, + }, ]; }); @@ -388,7 +654,7 @@ export const make = Effect.gen(function* () { Effect.provideService(Path.Path, path), ); const scanned: ScannedDir[] = []; - for (const { provider, dir, fileName } of dirs) { + for (const { provider, dir, fileName, eventLog } of dirs) { const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); const exists = yield* fileSystem .exists(dir) @@ -398,7 +664,13 @@ export const make = Effect.gen(function* () { continue; } const files = yield* Effect.promise(() => - listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), + eventLog + ? listProviderEventLogFiles(dir, "events", windowStartMs) + : listTranscriptFiles( + dir, + windowStartMs, + fileName === undefined ? undefined : { fileName }, + ), ); const parsedFiles: { path: string; records: readonly UsageRecord[] }[] = []; for (const file of files) { @@ -449,6 +721,14 @@ export const make = Effect.gen(function* () { yield* ensureScanCacheLoaded; const hostId = NodeOS.hostname(); + yield* ensureDevinRates(); + const settingsForAccount = yield* settingsService.getSettings.pipe( + Effect.catchCause(() => Effect.succeed(null)), + ); + const accountUsage = + settingsForAccount === null + ? undefined + : yield* readDevinAccountUsage(input, settingsForAccount); const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); if (Option.isNone(windowStart)) { return yield* new UsageReadError({ @@ -473,7 +753,8 @@ export const make = Effect.gen(function* () { untilDay: input.untilDay, resolution: input.resolution ?? "day", ...hourlyWindow, - rates, + rates: liteLlmRates, + providerRates: { devin: new Map([...liteLlmRates, ...devinRates]) }, priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), }); @@ -525,7 +806,10 @@ export const make = Effect.gen(function* () { skippedFiles, malformedRecords: 0, distinctSessions: sessionIds.size, - message: null, + message: + provider === "devin" + ? "Devin ACP usage is captured from this T3 server's local event logs." + : null, }); } @@ -551,6 +835,7 @@ export const make = Effect.gen(function* () { buckets: aggregated.buckets, sources, pricing: pricing(), + ...(accountUsage === undefined ? {} : { accountUsage }), scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), } satisfies UsageSummary; }); diff --git a/apps/server/src/usage/devinAccountUsage.test.ts b/apps/server/src/usage/devinAccountUsage.test.ts new file mode 100644 index 000000000000..ab42320fd91d --- /dev/null +++ b/apps/server/src/usage/devinAccountUsage.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { parseDevinAccountConsumptionPayload } from "./devinAccountUsage.ts"; + +describe("parseDevinAccountConsumptionPayload", () => { + it("parses Devin's documented ACU response and normalizes Unix dates", () => { + const parsed = parseDevinAccountConsumptionPayload({ + total_acus: 7.5, + consumption_by_date: [ + { + date: 1_756_598_400, + acus: 5, + acus_by_product: { devin: 4, terminal: 1 }, + }, + { + date: "2026-08-31", + acus: 2.5, + acus_by_product: { cascade: 2.5, malformed: "ignored" }, + }, + ], + }); + + expect(parsed?.totalAcus).toBe(7.5); + expect(parsed?.days).toHaveLength(2); + expect(parsed?.days[0]).toMatchObject({ + day: "2025-08-31", + acus: 5, + byProduct: { devin: 4, terminal: 1 }, + }); + expect(parsed?.days[1]).toMatchObject({ + day: "2026-08-31", + acus: 2.5, + byProduct: { cascade: 2.5 }, + }); + }); + + it("ignores malformed rows and derives a total when the response omits it", () => { + const parsed = parseDevinAccountConsumptionPayload({ + consumption_by_date: [ + { date: "2026-08-30", acus: 1.25 }, + { date: "not-a-date", acus: 4 }, + { date: "2026-08-31", acus: -1 }, + ], + }); + + expect(parsed).toEqual({ + totalAcus: 1.25, + days: [ + { + day: "2026-08-30", + acus: 1.25, + byProduct: {}, + }, + ], + }); + }); + + it("rejects an error body or an empty unusable response", () => { + expect(parseDevinAccountConsumptionPayload({ status: 403, detail: "forbidden" })).toBeNull(); + expect(parseDevinAccountConsumptionPayload({ consumption_by_date: [] })).toBeNull(); + }); +}); diff --git a/apps/server/src/usage/devinAccountUsage.ts b/apps/server/src/usage/devinAccountUsage.ts new file mode 100644 index 000000000000..9d59cae47227 --- /dev/null +++ b/apps/server/src/usage/devinAccountUsage.ts @@ -0,0 +1,110 @@ +// @effect-diagnostics globalDate:off -- API date values are normalized to calendar-day labels. +/** + * Pure parsing helpers for Devin's organization consumption endpoint. + * + * The API reports account billing units (ACUs), not model tokens. The payload + * shape is decoded with an Effect Schema so raw API responses stay out of the + * usage contract; malformed rows and fields are dropped rather than failing + * the whole response. + * + * @module devinAccountUsage + */ +import { UsageDay, type UsageAccountConsumptionDay } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +export interface ParsedDevinAccountConsumption { + readonly totalAcus: number; + readonly days: readonly UsageAccountConsumptionDay[]; +} + +const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/u; + +/** ACU amounts are counts: finite and never negative. */ +const AcuAmount = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)); + +/** Devin currently returns Unix seconds, but tolerate milliseconds and ISO dates. */ +const DayKey = Schema.Union([Schema.String, Schema.Number]); + +const ConsumptionDayRecord = Schema.Struct({ + date: Schema.optional(DayKey), + day: Schema.optional(DayKey), + acus: AcuAmount, + acus_by_product: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + byProduct: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}); + +const ConsumptionPayload = Schema.Struct({ + consumption_by_date: Schema.Array(Schema.Unknown), + total_acus: Schema.optional(Schema.Unknown), + totalAcus: Schema.optional(Schema.Unknown), +}); + +const decodePayload = Schema.decodeUnknownOption(ConsumptionPayload); +const decodeDayRecord = Schema.decodeUnknownOption(ConsumptionDayRecord); +const decodeAcu = Schema.decodeUnknownOption(AcuAmount); + +const decodeOptionalAcu = (value: unknown): number | undefined => { + const decoded = decodeAcu(value); + return Option.isSome(decoded) ? decoded.value : undefined; +}; + +/** Devin currently returns Unix seconds, but tolerate milliseconds and ISO dates. */ +const toUsageDay = (key: string | number): UsageDay | null => { + if (typeof key === "string") { + const trimmed = key.trim(); + if (DATE_ONLY_PATTERN.test(trimmed)) return UsageDay.make(trimmed); + const parsed = Date.parse(trimmed); + return Number.isFinite(parsed) + ? UsageDay.make(new Date(parsed).toISOString().slice(0, 10)) + : null; + } + const milliseconds = key < 100_000_000_000 ? key * 1_000 : key; + const date = new Date(milliseconds); + return Number.isFinite(date.getTime()) ? UsageDay.make(date.toISOString().slice(0, 10)) : null; +}; + +/** Drops product entries that are not usable non-negative amounts. */ +const parseProducts = ( + value: Readonly>, +): Readonly> => { + const products: Record = {}; + for (const [name, raw] of Object.entries(value)) { + const amount = decodeOptionalAcu(raw); + if (amount !== undefined && name.trim().length > 0) products[name] = amount; + } + return products; +}; + +/** + * Parse the documented `{ total_acus, consumption_by_date }` response. + * Invalid rows are ignored; a response with no usable rows is rejected so a + * transient proxy/error body cannot look like a legitimate zero-usage result. + */ +export function parseDevinAccountConsumptionPayload( + document: unknown, +): ParsedDevinAccountConsumption | null { + const payload = decodePayload(document); + if (Option.isNone(payload)) return null; + + const days: UsageAccountConsumptionDay[] = []; + for (const rawDay of payload.value.consumption_by_date) { + const entry = decodeDayRecord(rawDay); + if (Option.isNone(entry)) continue; + const day = toUsageDay(entry.value.date ?? entry.value.day ?? ""); + if (day === null) continue; + days.push({ + day, + acus: entry.value.acus, + byProduct: parseProducts(entry.value.acus_by_product ?? entry.value.byProduct ?? {}), + }); + } + + const totalFromResponse = + decodeOptionalAcu(payload.value.total_acus) ?? decodeOptionalAcu(payload.value.totalAcus); + if (days.length === 0 && totalFromResponse === undefined) return null; + return { + totalAcus: totalFromResponse ?? days.reduce((total, day) => total + day.acus, 0), + days, + }; +} diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 8da4e920ac06..9534b19f21e4 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -61,6 +61,44 @@ function aggregate( } describe("UsageAggregator", () => { + it("uses provider catalog rates for both cost and cache savings without affecting other providers", () => { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + providerRates: { + devin: new Map([ + [ + "claude-fable-5", + { + inputCostPerToken: 2e-5, + outputCostPerToken: 8e-5, + cacheReadCostPerToken: 2e-6, + cacheCreationCostPerToken: 3e-5, + }, + ], + ]), + }, + }); + aggregator.add(record()); + aggregator.add(record({ provider: "devin" })); + const buckets = aggregator.finish().buckets; + expect(buckets.find((bucket) => bucket.provider === "claude")?.costUsd).toBeCloseTo( + 0.004625, + 9, + ); + expect(buckets.find((bucket) => bucket.provider === "claude")?.cacheSavingsUsd).toBeCloseTo( + 0.009, + 9, + ); + expect(buckets.find((bucket) => bucket.provider === "devin")?.costUsd).toBeCloseTo(0.0083, 9); + expect(buckets.find((bucket) => bucket.provider === "devin")?.cacheSavingsUsd).toBeCloseTo( + 0.018, + 9, + ); + }); + it("requires exact bounds for hourly aggregation", () => { expect( () => diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 01a1195efb60..75e5bcd8fc58 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -61,6 +61,7 @@ export interface AggregateOptions { readonly sinceDay: string; readonly untilDay: string; readonly rates: RateTable; + readonly providerRates?: Partial>>; readonly priceOverrides?: RateTable; readonly resolution?: UsageResolution; readonly sinceTimeMs?: number; @@ -161,8 +162,9 @@ export class UsageAggregator { this.#buckets.set(key, bucket); } + const rates = this.#options.providerRates?.[record.provider] ?? this.#options.rates; const priced = priceUsage( - this.#options.rates, + rates, record.model, record.totals, record.reportedCostUsd, @@ -172,7 +174,7 @@ export class UsageAggregator { bucket.totals = addTotals(bucket.totals, record.totals); bucket.costUsd += priced.costUsd; bucket.cacheSavingsUsd += cacheSavingsUsd( - this.#options.rates, + rates, record.model, record.totals, this.#options.priceOverrides, diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index 713d860999cb..de693ee37b3e 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -4,6 +4,8 @@ import { cacheSavingsUsd, createOverrideRateTable, lookupRate, + normalizeModelName, + parseProviderModelRateTable, parseRateTable, priceUsage, } from "./usagePricing.ts"; @@ -131,3 +133,56 @@ describe("usage pricing", () => { expect(lookupRate(table, "example-model")).toBeNull(); }); }); + +describe("parseProviderModelRateTable", () => { + it("reads standalone and grouped Devin pricing metadata", () => { + const rates = parseProviderModelRateTable({ + models: [ + { + slug: "adaptive", + pricing: { + inputPerMillion: 0.5, + cachedInputPerMillion: 0.1, + outputPerMillion: 2, + }, + }, + { + slug: "glm-5-2", + pricingByVariant: { + "glm-5-2": { + inputPerMillion: 0.7, + cachedInputPerMillion: 0.13, + outputPerMillion: 2.2, + }, + }, + }, + ], + }); + + expect(lookupRate(rates, "adaptive")).toMatchObject({ + inputCostPerToken: 0.5e-6, + outputCostPerToken: 2e-6, + cacheCreationCostPerToken: 0.5e-6, + }); + expect(lookupRate(rates, "adaptive")?.cacheReadCostPerToken).toBeCloseTo(0.1e-6); + expect(lookupRate(rates, "glm-5-2")).toMatchObject({ + inputCostPerToken: 0.7e-6, + outputCostPerToken: 2.2e-6, + cacheCreationCostPerToken: 0.7e-6, + }); + expect(lookupRate(rates, "glm-5-2")?.cacheReadCostPerToken).toBeCloseTo(0.13e-6); + }); + + it("ignores malformed or incomplete pricing without crashing", () => { + expect( + parseProviderModelRateTable({ + models: [ + { slug: "missing-output", pricing: { inputPerMillion: 1 } }, + { slug: "negative", pricing: { inputPerMillion: -1, outputPerMillion: 2 } }, + { slug: "not-an-object", pricing: "free" }, + ], + }).size, + ).toBe(0); + expect(parseProviderModelRateTable(null).size).toBe(0); + }); +}); diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 6c94be424827..582c9d33d9c0 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -12,6 +12,8 @@ import type { UsageModelPriceOverride, UsageTokenTotals, } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; /** * The subset of a LiteLLM entry we price against. All values are USD per token. @@ -49,6 +51,55 @@ export function createOverrideRateTable( ); } +/** Provider-advertised pricing is USD per one million tokens, never negative. */ +const NonNegativePerMillion = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)); + +/** One provider-snapshot model entry, narrowed to the pricing fields we read. */ +const ProviderModelPricing = Schema.Struct({ + inputPerMillion: NonNegativePerMillion, + outputPerMillion: NonNegativePerMillion, + cachedInputPerMillion: Schema.optional(NonNegativePerMillion), + cacheCreationPerMillion: Schema.optional(NonNegativePerMillion), +}); + +const ProviderModelPricingEntry = Schema.Struct({ + slug: Schema.String, + pricing: Schema.optional(ProviderModelPricing), + pricingByVariant: Schema.optional(Schema.Record(Schema.String, ProviderModelPricing)), +}); + +const ProviderSnapshotPricingDocument = Schema.Struct({ + models: Schema.Array(ProviderModelPricingEntry), +}); + +const decodeProviderSnapshotPricing = Schema.decodeUnknownOption(ProviderSnapshotPricingDocument); + +/** Convert a provider snapshot's persisted pricing metadata into rates. */ +export function parseProviderModelRateTable(document: unknown): RateTable { + const table = new Map(); + const decoded = decodeProviderSnapshotPricing(document); + if (Option.isNone(decoded)) return table; + + const toRate = (pricing: typeof ProviderModelPricing.Type): ModelRate => ({ + inputCostPerToken: pricing.inputPerMillion / 1_000_000, + outputCostPerToken: pricing.outputPerMillion / 1_000_000, + cacheReadCostPerToken: (pricing.cachedInputPerMillion ?? pricing.inputPerMillion) / 1_000_000, + cacheCreationCostPerToken: + (pricing.cacheCreationPerMillion ?? pricing.inputPerMillion) / 1_000_000, + }); + + for (const entry of decoded.value.models) { + if (entry.slug && entry.pricing !== undefined) { + table.set(normalizeModelName(entry.slug), toRate(entry.pricing)); + } + if (entry.pricingByVariant === undefined) continue; + for (const [variant, pricing] of Object.entries(entry.pricingByVariant)) { + table.set(normalizeModelName(variant), toRate(pricing)); + } + } + return table; +} + /** Raw shape of one LiteLLM entry, narrowed to the fields we read. */ interface LiteLlmEntry { readonly input_cost_per_token?: unknown; @@ -132,6 +183,16 @@ function bareModelName(key: string): string { return slash === -1 ? key : key.slice(slash + 1); } +/** + * Canonicalises a model name for lookup. + * + * Strips a `provider/` prefix and lowercases, since transcripts are + * inconsistent about casing. + */ +export function normalizeModelName(model: string): string { + return bareModelName(normalizeRateKey(model)); +} + /** * Drops a bracketed variant suffix such as `claude-fable-5-1[1m]`, which * Claude Code writes for the 1M context tier. The rate table only knows the diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index fdb0aabafa40..52a609c60c2c 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -130,6 +130,33 @@ describe("scan cache round trip", () => { expect(decodeScanCache(JSON.parse(JSON.stringify(previous))).size).toBe(0); }); + it("round-trips Devin ACP event-log records", () => { + const original: ScanCache = new Map([ + [ + "events.thread-1.log", + { + size: 128, + mtimeMs: 300, + provider: "devin", + records: [ + record({ + provider: "devin", + model: "gpt-5-6-luna-medium", + sessionId: "acp-session-1", + reportedCostUsd: 0.0125, + dedupeKey: "event-1", + }), + ], + tailRecords: [], + position: position(), + }, + ], + ]); + + const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); + expect(restored.get("events.thread-1.log")).toEqual(original.get("events.thread-1.log")); + }); + it("interns repeated model and session strings", () => { const encoded = encodeScanCache( cacheWith([["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:" }), record()]]]), diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 102058a07d35..713a40fda5c5 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -26,6 +26,9 @@ import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts"; // entries would keep serving double-counted records forever. // v3: entries carry the parse position and reducer state so a grown file // re-parses only its appended bytes instead of starting over. +// v3: Devin ACP event-log records were added and the provider set is part of +// the cache schema. Invalidating older entries also ensures a previously +// flattened provider snapshot cannot mask the new model/session attribution. export const USAGE_SCAN_CACHE_VERSION = 3 as const; export interface CachedFile { @@ -219,7 +222,8 @@ export function decodeScanCache(document: unknown): ScanCache { if (typeof raw !== "object" || raw === null) continue; const entry = raw as Partial; if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; - if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok") continue; + if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok" && entry.p !== "devin") + continue; if (!isRecordArray(entry.r) || !isRecordArray(entry.t)) continue; // Position fields feed byte offsets and a Buffer allocation in the reader, // so anything outside their real ranges must reject the entry: a bogus diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 9e5ab6e0c9e0..58612a8c6cb0 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -17,6 +17,7 @@ */ import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; +import type * as NodeFS from "node:fs"; import type { UsageProviderKind } from "@t3tools/contracts"; @@ -25,6 +26,7 @@ import { mightCarryUsage, parseClaudeLine, parseCodexLine, + parseDevinCanonicalLogLine, parseGrokLine, type CodexScanState, type UsageRecord, @@ -138,6 +140,47 @@ export async function listTranscriptFiles( return found; } +/** + * Lists T3 provider event logs (`events..log`) and their rotated + * siblings. Event logs are intentionally kept flat in one provider directory, + * unlike CLI transcripts which use nested session folders. + */ +export async function listProviderEventLogFiles( + root: string, + baseName: string, + sinceMs = Number.NEGATIVE_INFINITY, +): Promise { + const found: TranscriptFile[] = []; + let entries: ReadonlyArray; + try { + entries = await NodeFSP.readdir(root, { withFileTypes: true }); + } catch { + return found; + } + + const prefix = `${baseName}.`; + for (const entry of entries) { + if ( + !entry.isFile() || + !entry.name.startsWith(prefix) || + !/\.log(?:\.\d+)?$/u.test(entry.name) + ) { + continue; + } + const child = NodePath.join(root, entry.name); + try { + const stats = await NodeFSP.stat(child); + if (stats.mtimeMs >= sinceMs) { + found.push({ path: child, size: stats.size, mtimeMs: stats.mtimeMs }); + } + } catch { + // Rotated logs may disappear between readdir and stat. + } + } + + return found; +} + /** * Filesystem identity of a directory, as `device:inode`. * @@ -218,6 +261,18 @@ export async function readTranscriptRecords( } const parseLine = (line: string, state: CodexScanState, out: UsageRecord[]): void => { + if (provider === "devin") { + // Canonical event logs also contain native ACP and orchestration + // records. Avoid parsing those as JSON unless this line could be one + // of Devin's normalized usage events. + if (!line.includes('"thread.token-usage.updated"') || !line.includes('"devin"')) { + return; + } + const record = parseDevinCanonicalLogLine(line); + if (record !== null) out.push(record); + return; + } + if (provider === "codex") { if ( !mightCarryUsage(line, provider) && diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index b09db613ed85..162e1faa8108 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -5,6 +5,7 @@ import { initialCodexScanState, parseClaudeLine, parseCodexLine, + parseDevinCanonicalLogLine, parseGrokLine, totalTokens, } from "./usageTranscripts.ts"; @@ -564,3 +565,76 @@ describe("parseGrokLine", () => { expect(records[0]?.timestampMs).toBe(1_786_372_566_000); }); }); + +describe("parseDevinCanonicalLogLine", () => { + const contextOnly = JSON.stringify({ + type: "thread.token-usage.updated", + eventId: "event-context", + createdAt: "2026-08-10T12:00:00.000Z", + provider: "devin", + threadId: "thread-1", + payload: { usage: { usedTokens: 4_000, maxTokens: 200_000 } }, + }); + + const promptUsage = JSON.stringify({ + type: "thread.token-usage.updated", + eventId: "event-prompt", + createdAt: "2026-08-10T12:00:01.000Z", + provider: "devin", + threadId: "thread-1", + payload: { + usage: { + model: "gpt-5-6-luna-medium", + providerSessionId: "acp-session-1", + lastInputTokens: 1_200, + lastCachedInputTokens: 300, + lastCacheCreationTokens: 100, + lastOutputTokens: 80, + lastReasoningOutputTokens: 20, + lastCostUsd: 0.0125, + }, + }, + }); + + it("ignores context-only updates so they cannot double count prompts", () => { + expect( + parseDevinCanonicalLogLine(`[2026-08-10T12:00:00.000Z] CANON: ${contextOnly}`), + ).toBeNull(); + }); + + it("maps ACP turn deltas, model/session identity, and cost", () => { + const record = parseDevinCanonicalLogLine(`[2026-08-10T12:00:01.000Z] CANON: ${promptUsage}`); + + expect(record).toEqual({ + provider: "devin", + timestampMs: Date.parse("2026-08-10T12:00:01.000Z"), + model: "gpt-5-6-luna-medium", + sessionId: "acp-session-1", + totals: { + uncachedInputTokens: 800, + cachedInputTokens: 300, + cacheCreationTokens: 100, + outputTokens: 80, + reasoningTokens: 20, + }, + reportedCostUsd: 0.0125, + dedupeKey: "event-prompt", + }); + }); + + it("accepts rotated-log lines and falls back to thread identity", () => { + const event = JSON.parse(promptUsage) as Record; + const payload = event.payload as Record; + const usage = payload.usage as Record; + delete usage.providerSessionId; + delete usage.model; + delete event.createdAt; + const record = parseDevinCanonicalLogLine( + `[2026-08-10T12:00:01.000Z] CANON: ${JSON.stringify(event)}`, + ); + + expect(record?.model).toBe("devin"); + expect(record?.sessionId).toBe("thread-1"); + expect(record?.timestampMs).toBe(Date.parse("2026-08-10T12:00:01.000Z")); + }); +}); diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 2aea60709666..a3f30bdc780d 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -40,6 +40,111 @@ function parseTimestampMs(value: unknown): number | null { return Number.isNaN(parsed) ? null : parsed; } +function parseProviderEventLogLine(line: string): { + readonly loggedAtMs: number | null; + readonly event: Record; +} | null { + // EventNdjsonLogger prefixes every record with a human-readable timestamp + // and stream label. Only canonical records are useful here; native ACP + // payloads do not have stable token accounting semantics. + const match = /^\[([^\]\r\n]+)\]\s+([A-Z]+):\s+(.+)$/u.exec(line); + if (!match || match[2] !== "CANON") return null; + + let parsed: unknown; + try { + parsed = JSON.parse(match[3] ?? ""); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + + return { + loggedAtMs: parseTimestampMs(match[1]), + event: parsed as Record, + }; +} + +function nonNegative(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.trunc(value) : 0; +} + +/** + * Parses a canonical Devin ACP token-usage event from a provider event log. + * + * Context-only `usage_update` notifications are intentionally ignored. The + * corresponding canonical event carries `usedTokens`/`maxTokens`, but no + * per-turn token delta, so counting it would double count the prompt record. + */ +export function parseDevinCanonicalLogLine(line: string): UsageRecord | null { + const parsed = parseProviderEventLogLine(line); + if (parsed === null) return null; + + const event = parsed.event; + if (event.provider !== "devin" || event.type !== "thread.token-usage.updated") return null; + const payload = event.payload; + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return null; + const usage = (payload as Record)["usage"]; + if (typeof usage !== "object" || usage === null || Array.isArray(usage)) return null; + const usageRecord = usage as Record; + + const hasTurnBreakdown = [ + "lastInputTokens", + "lastCachedInputTokens", + "lastCacheCreationTokens", + "lastOutputTokens", + "lastReasoningOutputTokens", + ].some((key) => typeof usageRecord[key] === "number" && Number.isFinite(usageRecord[key])); + if (!hasTurnBreakdown) return null; + + const lastInputTokens = nonNegative(usageRecord["lastInputTokens"]); + const cachedInputTokens = nonNegative(usageRecord["lastCachedInputTokens"]); + const cacheCreationTokens = nonNegative(usageRecord["lastCacheCreationTokens"]); + const outputTokens = nonNegative(usageRecord["lastOutputTokens"]); + const reasoningTokens = Math.min( + outputTokens, + nonNegative(usageRecord["lastReasoningOutputTokens"]), + ); + const totals: UsageTokenTotals = { + uncachedInputTokens: Math.max(0, lastInputTokens - cachedInputTokens - cacheCreationTokens), + cachedInputTokens, + cacheCreationTokens, + outputTokens, + reasoningTokens, + }; + if (totalTokens(totals) === 0) return null; + + const createdAtMs = parseTimestampMs(event.createdAt) ?? parsed.loggedAtMs; + if (createdAtMs === null) return null; + + const model = + typeof usageRecord.model === "string" && usageRecord.model.trim().length > 0 + ? usageRecord.model.trim() + : "devin"; + const sessionId = + typeof usageRecord.providerSessionId === "string" && + usageRecord.providerSessionId.trim().length > 0 + ? usageRecord.providerSessionId.trim() + : typeof event.threadId === "string" + ? event.threadId + : ""; + const reportedCostUsd = + typeof usageRecord.lastCostUsd === "number" && + Number.isFinite(usageRecord.lastCostUsd) && + usageRecord.lastCostUsd >= 0 + ? usageRecord.lastCostUsd + : null; + + return { + provider: "devin", + timestampMs: createdAtMs, + model, + sessionId, + totals, + reportedCostUsd, + dedupeKey: typeof event.eventId === "string" ? event.eventId : null, + }; +} + export function addTotals(a: UsageTokenTotals, b: UsageTokenTotals): UsageTokenTotals { return { uncachedInputTokens: a.uncachedInputTokens + b.uncachedInputTokens, diff --git a/apps/web/src/assets/devin-logo.webp b/apps/web/src/assets/devin-logo.webp new file mode 100644 index 000000000000..8097ad8657a6 Binary files /dev/null and b/apps/web/src/assets/devin-logo.webp differ diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 459506efc78c..6bea7be712de 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -137,6 +137,30 @@ function agentActivityText(agent: RuntimeSubagent): string | null { ); } +function AgentActivityTranscript({ agent }: { agent: RuntimeSubagent }) { + if (agent.recentActivity.length <= 1) return null; + return ( +
+ + Activity · {agent.recentActivity.length} observations + +
    + {agent.recentActivity.map((entry, index) => ( +
  1. + + {entry.summary} +
  2. + ))} +
+

+ Provider summaries, tools, and status only. Private reasoning is never shown. +

+
+ ); +} + /** Flat, non-interactive agent status line. No unfold. */ function AgentRow({ agent }: { agent: RuntimeSubagent }) { const visuals = STATUS_VISUALS[agent.status]; @@ -156,39 +180,42 @@ function AgentRow({ agent }: { agent: RuntimeSubagent }) { ].filter((value): value is string => value !== null); return ( -
- - - - - {agent.title} - {role ? ( - - {role} - - ) : null} - - - - - {agent.status === "completed" ? ( - + <> +
+ + + + + {agent.title} + {role ? ( + + {role} + ) : null} - - - {activity ?? statusLabel} - - - {metadata.join(" · ")} - - {statusLabel} -
+ + + + {agent.status === "completed" ? ( + + ) : null} + + + + {activity ?? statusLabel} + + + {metadata.join(" · ")} + + {statusLabel} +
+ + ); } @@ -540,6 +567,10 @@ export function AgentsPanel({ When this thread spawns subagents or runs a workflow, they show up here with live status, activity, and token usage.

+

+ This panel only consumes structured provider events. Assistant prose and private reasoning + never create an agent row. +

); } diff --git a/apps/web/src/components/ChatView.modelOptions.test.ts b/apps/web/src/components/ChatView.modelOptions.test.ts new file mode 100644 index 000000000000..7f0067cc0412 --- /dev/null +++ b/apps/web/src/components/ChatView.modelOptions.test.ts @@ -0,0 +1,116 @@ +import type { + ModelCapabilities, + ProviderOptionDescriptor, + ProviderOptionSelection, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { preserveCompatibleOptions } from "./ChatView.modelOptions"; + +function selectDescriptor( + id: string, + options: Array<{ id: string; label: string; isDefault?: boolean }>, +): ProviderOptionDescriptor { + return { + type: "select", + id, + label: id, + options: options.map((option) => ({ + id: option.id, + label: option.label, + ...(option.isDefault ? { isDefault: true } : {}), + })), + }; +} + +function caps(descriptors: ProviderOptionDescriptor[]): ModelCapabilities { + return { optionDescriptors: descriptors }; +} + +const effortDescriptor = selectDescriptor("effort", [ + { id: "low", label: "Low", isDefault: true }, + { id: "high", label: "High" }, +]); + +const variantDescriptor = selectDescriptor("variant", [ + { id: "fast", label: "Fast", isDefault: true }, + { id: "thorough", label: "Thorough" }, +]); + +describe("preserveCompatibleOptions", () => { + it("keeps options whose descriptor id exists on the next model", () => { + const current: ProviderOptionSelection[] = [ + { id: "effort", value: "high" }, + { id: "agent", value: "plan" }, + ]; + const nextCaps = caps([effortDescriptor]); + + const result = preserveCompatibleOptions(current, nextCaps); + + expect(result).toEqual([{ id: "effort", value: "high" }]); + }); + + it("preserves the user's explicit value, not the new model's default", () => { + const current: ProviderOptionSelection[] = [{ id: "effort", value: "high" }]; + const nextCaps = caps([effortDescriptor]); + + const result = preserveCompatibleOptions(current, nextCaps); + + expect(result).toEqual([{ id: "effort", value: "high" }]); + }); + + it("drops options the new model does not expose", () => { + const current: ProviderOptionSelection[] = [ + { id: "effort", value: "high" }, + { id: "agent", value: "plan" }, + ]; + const nextCaps = caps([effortDescriptor, variantDescriptor]); + + const result = preserveCompatibleOptions(current, nextCaps); + + expect(result).toEqual([{ id: "effort", value: "high" }]); + }); + + it("returns undefined when no options survive", () => { + const current: ProviderOptionSelection[] = [{ id: "agent", value: "plan" }]; + const nextCaps = caps([effortDescriptor]); + + expect(preserveCompatibleOptions(current, nextCaps)).toBeUndefined(); + }); + + it("returns undefined when the next model has no option descriptors", () => { + const current: ProviderOptionSelection[] = [{ id: "effort", value: "high" }]; + const nextCaps = caps([]); + + expect(preserveCompatibleOptions(current, nextCaps)).toBeUndefined(); + }); + + it("returns undefined when there are no current options", () => { + expect(preserveCompatibleOptions([], caps([effortDescriptor]))).toBeUndefined(); + expect(preserveCompatibleOptions(null, caps([effortDescriptor]))).toBeUndefined(); + expect(preserveCompatibleOptions(undefined, caps([effortDescriptor]))).toBeUndefined(); + }); + + it("returns undefined when capabilities are null", () => { + const current: ProviderOptionSelection[] = [{ id: "effort", value: "high" }]; + + expect(preserveCompatibleOptions(current, null)).toBeUndefined(); + expect(preserveCompatibleOptions(current, undefined)).toBeUndefined(); + }); + + it("carries over multiple compatible options", () => { + const current: ProviderOptionSelection[] = [ + { id: "effort", value: "high" }, + { id: "variant", value: "thorough" }, + { id: "agent", value: "plan" }, + ]; + const nextCaps = caps([effortDescriptor, variantDescriptor]); + + const result = preserveCompatibleOptions(current, nextCaps); + + expect(result).toEqual([ + { id: "effort", value: "high" }, + { id: "variant", value: "thorough" }, + ]); + }); +}); diff --git a/apps/web/src/components/ChatView.modelOptions.ts b/apps/web/src/components/ChatView.modelOptions.ts new file mode 100644 index 000000000000..3b9f7671ac58 --- /dev/null +++ b/apps/web/src/components/ChatView.modelOptions.ts @@ -0,0 +1,21 @@ +import type { ModelCapabilities, ProviderOptionSelection } from "@t3tools/contracts"; + +/** + * Filter stored option selections down to those the next model also exposes. + * Used when switching models so a reasoning-effort choice carries over to a + * model that supports the same option descriptor, while options the new model + * does not have are dropped instead of sent blindly. Returns `undefined` when + * nothing survives so callers can omit the `options` field entirely. + */ +export function preserveCompatibleOptions( + currentOptions: ReadonlyArray | null | undefined, + nextCaps: ModelCapabilities | null | undefined, +): ReadonlyArray | undefined { + if (!currentOptions || currentOptions.length === 0) return undefined; + const descriptorIds = new Set( + (nextCaps?.optionDescriptors ?? []).map((descriptor) => descriptor.id), + ); + if (descriptorIds.size === 0) return undefined; + const preserved = currentOptions.filter((option) => descriptorIds.has(option.id)); + return preserved.length > 0 ? preserved : undefined; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6d1906ff975c..72b7bf5d5fb6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -54,6 +54,7 @@ import { import { applyClaudePromptEffortPrefix, createModelSelection, + getModelInputCapabilities, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; import { @@ -345,7 +346,11 @@ import { hasDismissedResumeCompaction, shouldOfferResumeCompaction, } from "./chat/ContextWindowMeter.logic"; -import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow"; +import { + deriveKnownContextWindowSnapshot, + deriveLatestContextWindowSnapshot, + formatContextWindowTokens, +} from "../lib/contextWindow"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, DRAFT_HERO_TRANSITION_DURATION_MS, @@ -421,6 +426,8 @@ import { previewEnvironment } from "../state/preview"; import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles"; +import { findModelCapabilities } from "./chat/modelFamilyGrouping"; +import { preserveCompatibleOptions } from "./ChatView.modelOptions"; import { assetEnvironment } from "../state/assets"; import { readPreparedConnection } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; @@ -1602,6 +1609,15 @@ export default function ChatView(props: ChatViewProps) { >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); + // Tracks a thread that is expecting an in-place session restart because the + // user changed the model on an existing session (Devin restarts the ACP + // session on the next sendTurn). Set when the model picker writes a new + // model on a thread that already has a session; cleared once the session + // leaves the transient "starting" status so the reinitializing banner + // disappears when the new session is ready. + const [pendingModelReinitThreadKey, setPendingModelReinitThreadKey] = useState( + null, + ); const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState( null, ); @@ -2560,6 +2576,20 @@ export default function ChatView(props: ChatViewProps) { conversationProviderStatus !== null && conversationProviderStatus.supportsConversationRollback !== false; const phase = derivePhase(activeThread?.session ?? null); + // The session goes through a transient "starting" status when the Devin + // adapter restarts the ACP session for a model change. The banner below + // distinguishes that from an initial connect using the flag set in + // onProviderModelSelect. Clear the flag as soon as the session leaves + // "starting" so the indicator disappears the moment the new session is ready. + const isReinitializingForModelChange = + phase === "connecting" && + activeThreadKey !== null && + pendingModelReinitThreadKey === activeThreadKey; + useEffect(() => { + if (phase !== "connecting" && pendingModelReinitThreadKey !== null) { + setPendingModelReinitThreadKey(null); + } + }, [phase, pendingModelReinitThreadKey]); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const latestCheckpointCompletedAt = activeThread?.checkpoints.at(-1)?.completedAt ?? null; const workspaceMutationId = useMemo(() => { @@ -2569,8 +2599,16 @@ export default function ChatView(props: ChatViewProps) { : JSON.stringify([activityId, latestCheckpointCompletedAt]); }, [latestCheckpointCompletedAt, threadActivities]); const activeContextWindow = useMemo( - () => deriveLatestContextWindowSnapshot(threadActivities), - [threadActivities], + () => + deriveLatestContextWindowSnapshot(threadActivities) ?? + (activeThread + ? deriveKnownContextWindowSnapshot({ + selection: activeThread?.modelSelection, + providers: providerStatuses, + updatedAt: activeThread.updatedAt, + }) + : null), + [activeThread, providerStatuses, threadActivities], ); const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); // Native subagent fold: memoized by activity-list identity, shared by the @@ -5695,6 +5733,22 @@ export default function ChatView(props: ChatViewProps) { }), [feedbackSubmissions, routeThreadKey], ); + const reinitBannerItem = useMemo(() => { + if (!isReinitializingForModelChange || !activeThreadKey) return null; + return { + id: `model-reinit:${activeThreadKey}`, + variant: "default", + icon: ( + + } + /> + + The selected model does not accept image attachments. Remove it or + switch models to send. + + + )} {upload?.status === "uploading" && ( {formatAttachmentUploadProgress(upload.progress)} @@ -5368,6 +5442,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : null} + {!inputCapabilities.files ? ( + + + + + } + /> + + The selected model does not accept file attachments. Remove it or + switch models to send. + + + ) : null} + } + /> + + {props.onReasoningLevelChange + ? `Reasoning: ${props.reasoningLabel}. Click to change.` + : `Reasoning: ${props.reasoningLabel}`} + + + ) : null} {props.showProvider && (
- {ProviderIcon ? : null} + {providerLabel} diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 63dea7844c46..c1b32a78a8f9 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -2,6 +2,7 @@ import { ANTIGRAVITY_DEFAULT_MODEL, type ProviderInstanceId, type ProviderDriverKind, + type ProviderOptionSelection, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; @@ -15,10 +16,20 @@ import { getProviderStatusMessage, hasProviderSetup } from "./ProviderStatusBann import { modelPickerLegacySectionKey, modelPickerModelKey, + modelPickerProviderGroupKey, parseModelPickerLegacySectionKey, parseModelPickerModelKey, + parseModelPickerProviderGroupKey, } from "./modelPickerKeys"; import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; +import { + groupByProvider, + getProviderGroupLabel, + getReasoningLevelDescriptor, + resolveReasoningLevelLabel, + withReasoningLevelChange, + findModelCapabilities, +} from "./modelFamilyGrouping"; import { Combobox, ComboboxEmpty, @@ -26,7 +37,7 @@ import { ComboboxItem, ComboboxListVirtualized, } from "../ui/combobox"; -import { ModelEsque } from "./providerIconUtils"; +import { ModelEsque, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; import { isCommandPaletteOpen } from "../../commandPaletteBus"; import { primaryServerKeybindingsAtom } from "../../state/server"; import { @@ -151,6 +162,20 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void; getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; + /** + * Stored provider option selections for the active model (the `options` + * array on `ModelSelection`). Used to read the current reasoning level so + * the picker can display it on the selected row. When absent, the + * reasoning-level indicator is hidden. + */ + activeModelOptions?: ReadonlyArray | null | undefined; + /** + * Callback fired when the user changes the reasoning level from within the + * picker. Receives the next options array (with the reasoning descriptor + * value replaced, all other options preserved). When absent, the + * reasoning-level control renders read-only. + */ + onModelOptionsChange?: (nextOptions: ReadonlyArray | undefined) => void; }) { const { keybindings: providedKeybindings, @@ -603,6 +628,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { } return mapping; }, [getModelDisabledReason, visibleModels]); + + // ── Provider group headers ──────────────────────────────────────────── + // When the visible list spans more than one provider (favorites view or + // search results), insert a provider logo + name header before each + // provider's models. Single-instance views already identify the provider + // via the sidebar, so headers are omitted there to avoid redundancy. + const providerGroups = useMemo(() => { + if (legacySection) { + return null; + } + const groups = groupByProvider(visibleModels); + if (groups.length <= 1) { + return null; + } + return groups; + }, [legacySection, visibleModels]); + const modelJumpModelKeys = useMemo( () => [...modelJumpCommandByKey.keys()], [modelJumpCommandByKey], @@ -615,6 +657,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { .filter((model) => model.isLegacy) .map((model) => modelPickerLegacySectionKey(model.instanceId)), ), + ...new Set(flatModels.map((model) => modelPickerProviderGroupKey(model.driverKind))), ], [flatModels], ); @@ -622,12 +665,22 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const modelKeys = visibleModels.map((model) => modelPickerModelKey(model.instanceId, model.slug), ); - if (!legacySection) { + if (legacySection) { + modelKeys.splice(legacySection.currentModels.length, 0, legacySection.key); return modelKeys; } - modelKeys.splice(legacySection.currentModels.length, 0, legacySection.key); + if (providerGroups) { + const interleaved: string[] = []; + for (const group of providerGroups) { + interleaved.push(modelPickerProviderGroupKey(group.driverKind)); + for (const model of group.items) { + interleaved.push(modelPickerModelKey(model.instanceId, model.slug)); + } + } + return interleaved; + } return modelKeys; - }, [legacySection, visibleModels]); + }, [legacySection, providerGroups, visibleModels]); const filteredModelByKey = useMemo( (): ReadonlyMap => new Map( @@ -637,6 +690,59 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ), [visibleModels], ); + + // ── Reasoning level for the active model ────────────────────────────── + // Read the selected model's capabilities from its instance snapshot so the + // picker can show the current reasoning effort and let the user change it + // without leaving the picker. The descriptor comes from the model's + // `capabilities.optionDescriptors`; the current value is resolved against + // the stored `options` array (`activeModelOptions`). + const activeModelCapabilities = useMemo(() => { + const activeEntry = entryByInstanceId.get(props.activeInstanceId); + if (!activeEntry) { + return { optionDescriptors: [] }; + } + return findModelCapabilities(activeEntry.models, props.model); + }, [entryByInstanceId, props.activeInstanceId, props.model]); + + const activeReasoningDescriptor = useMemo( + () => + getReasoningLevelDescriptor({ + caps: activeModelCapabilities, + selections: props.activeModelOptions, + }), + [activeModelCapabilities, props.activeModelOptions], + ); + + const activeReasoningLabel = useMemo( + () => + resolveReasoningLevelLabel({ + caps: activeModelCapabilities, + selections: props.activeModelOptions, + }), + [activeModelCapabilities, props.activeModelOptions], + ); + + const handleReasoningLevelChange = useCallback( + (nextValue: string) => { + if (!props.onModelOptionsChange || !activeReasoningDescriptor) { + return; + } + const nextOptions = withReasoningLevelChange({ + caps: activeModelCapabilities, + selections: props.activeModelOptions, + nextValue, + }); + props.onModelOptionsChange(nextOptions); + }, + [ + activeModelCapabilities, + activeReasoningDescriptor, + props.activeModelOptions, + props.onModelOptionsChange, + ], + ); + const updateModelListScrollFades = useCallback(() => { const scrollElement = modelListRef.current?.getScrollableNode(); if (!(scrollElement instanceof HTMLElement)) { @@ -773,6 +879,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (typeof modelKey !== "string") { return; } + const providerGroupKind = parseModelPickerProviderGroupKey(modelKey); + if (providerGroupKind) { + return; + } const legacyInstanceId = parseModelPickerLegacySectionKey(modelKey); if (legacyInstanceId) { toggleLegacySection(legacyInstanceId); @@ -817,6 +927,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ).preventBaseUIHandler?.(); e.preventDefault(); e.stopPropagation(); + if (parseModelPickerProviderGroupKey(highlightedModelKeyRef.current)) { + return; + } const legacyInstanceId = parseModelPickerLegacySectionKey( highlightedModelKeyRef.current, ); @@ -849,6 +962,22 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { extraData={modelListExtraData} keyExtractor={(modelKey) => modelKey} renderItem={({ item: modelKey, index }) => { + const providerGroupKind = parseModelPickerProviderGroupKey(modelKey); + if (providerGroupKind) { + const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[providerGroupKind] ?? null; + return ( + + ); + } if (legacySection?.key === modelKey) { return ( toggleFavorite(model.instanceId, model.slug)} + {...(isRowSelected && activeReasoningDescriptor + ? { + reasoningDescriptor: activeReasoningDescriptor, + reasoningLabel: activeReasoningLabel, + ...(props.onModelOptionsChange + ? { onReasoningLevelChange: handleReasoningLevelChange } + : {}), + } + : {})} /> ); }} diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index 53c66667f939..607ce8b05cfd 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -1,6 +1,7 @@ import { type CSSProperties, memo } from "react"; import { type ProviderDriverKind } from "@t3tools/contracts"; +import { type Icon } from "../Icons"; import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; import { cn } from "~/lib/utils"; @@ -17,6 +18,7 @@ export function providerInstanceInitials(label: string): string { export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { driverKind: ProviderDriverKind; displayName: string; + icon?: Icon; accentColor?: string | undefined; showBadge?: boolean; badgeContent?: "initials" | "none"; @@ -26,7 +28,7 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { statusDotClassName?: string; indicatorBackground?: string; }) { - const Icon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; + const Icon = props.icon ?? PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; const indicatorBackground = props.indicatorBackground ?? "var(--card)"; const accentStyle = props.accentColor ? ({ "--provider-accent": props.accentColor } as CSSProperties) diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 932754db1eca..5f292af74a9b 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -2,6 +2,7 @@ import { ANTIGRAVITY_DEFAULT_MODEL, type ProviderInstanceId, type ProviderDriverKind, + type ProviderOptionSelection, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { memo, useEffect, useMemo, useState } from "react"; @@ -15,6 +16,7 @@ import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPick import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; import { ModelEsque, + getModelProviderBrand, getTriggerDisplayModelLabel, getTriggerDisplayModelName, } from "./providerIconUtils"; @@ -54,6 +56,13 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void; getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; + /** + * Stored provider option selections for the active model. Forwarded to + * {@link ModelPickerContent} so the picker can display and control the + * reasoning level without changing the model identity. + */ + activeModelOptions?: ReadonlyArray | null | undefined; + onModelOptionsChange?: (nextOptions: ReadonlyArray | undefined) => void; }) { const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); const isMenuOpen = props.open ?? uncontrolledIsMenuOpen; @@ -90,6 +99,10 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { : triggerTitle; const showInstanceBadge = activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries); + const ActiveModelProviderIcon = + activeEntry?.driverKind === "devin" && selectedModel + ? getModelProviderBrand(activeEntry.driverKind, selectedModel).icon + : null; const setIsMenuOpen = (open: boolean) => { props.onOpenChange?.(open); @@ -186,6 +199,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 87d7ac6fdcb8..769a6d4484d5 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -61,6 +61,8 @@ const ULTRATHINK_FRAME_CLASSES = { modelPickerIconClassName: "ultrathink-chroma", } as const; +const DEFAULT_INPUT_CAPS = { images: true, audio: true, files: true } as const; + describe("getComposerProviderState", () => { it("derives a stable prompt injection state for ordinary prompt edits", () => { expect(getComposerPromptInjectionState("Investigate this failure")).toBe("none"); @@ -87,6 +89,7 @@ describe("getComposerProviderState", () => { provider: PROVIDER, promptEffort: "high", modelOptionsForDispatch: undefined, + inputCapabilities: DEFAULT_INPUT_CAPS, }); }); @@ -109,6 +112,7 @@ describe("getComposerProviderState", () => { provider: PROVIDER, promptEffort: "low", modelOptionsForDispatch: selections(["effort", "low"], ["fastMode", true]), + inputCapabilities: DEFAULT_INPUT_CAPS, }); }); @@ -142,6 +146,7 @@ describe("getComposerProviderState", () => { provider: PROVIDER, promptEffort: null, modelOptionsForDispatch: selections(["thinking", false]), + inputCapabilities: DEFAULT_INPUT_CAPS, }); }); @@ -200,6 +205,7 @@ describe("getComposerProviderState", () => { provider: PROVIDER, promptEffort: null, modelOptionsForDispatch: undefined, + inputCapabilities: DEFAULT_INPUT_CAPS, }); }); @@ -233,6 +239,7 @@ describe("getComposerProviderState", () => { provider: PROVIDER, promptEffort: null, modelOptionsForDispatch: undefined, + inputCapabilities: DEFAULT_INPUT_CAPS, }); }); @@ -351,6 +358,7 @@ describe("getComposerProviderState", () => { provider: PROVIDER, promptEffort: "medium", modelOptionsForDispatch: selections(["effort", "medium"]), + inputCapabilities: DEFAULT_INPUT_CAPS, ...ULTRATHINK_FRAME_CLASSES, }); }); @@ -373,6 +381,41 @@ describe("getComposerProviderState", () => { expect(state).not.toHaveProperty("composerSurfaceClassName"); expect(state).not.toHaveProperty("modelPickerIconClassName"); }); + + it("reflects the active model's declared input capabilities", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: [ + { + slug: MODEL, + name: MODEL, + isCustom: false, + capabilities: { optionDescriptors: [], inputImages: false, inputAudio: false }, + }, + ], + modelOptions: undefined, + planModeEnabled: true, + }); + + expect(state.inputCapabilities).toEqual({ + images: false, + audio: false, + files: true, + }); + }); + + it("defaults unsupported-modalities to true when the model declares none", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([]), + modelOptions: undefined, + planModeEnabled: true, + }); + + expect(state.inputCapabilities).toEqual(DEFAULT_INPUT_CAPS); + }); }); describe("provider traits render guards", () => { diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index e2fee32d6d06..f0cc990da5e0 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -7,6 +7,8 @@ import { } from "@t3tools/contracts"; import { buildExplicitProviderOptionSelectionsFromDescriptors, + getModelInputCapabilities, + type ModelInputCapabilities, getProviderOptionCurrentValue, getProviderOptionDescriptors, isClaudeUltrathinkPrompt, @@ -36,6 +38,13 @@ export type ComposerProviderState = { provider: ProviderDriverKind; promptEffort: string | null; modelOptionsForDispatch: ReadonlyArray | undefined; + /** + * Resolved input modalities for the active model. Absent capability fields + * default to supported, so this is only false when the model explicitly + * declares it cannot accept that modality. The composer uses this to + * disable or warn about unsupported attachments. + */ + inputCapabilities: ModelInputCapabilities; composerFrameClassName?: string; composerSurfaceClassName?: string; modelPickerIconClassName?: string; @@ -84,6 +93,10 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com promptEffort: null, modelOptionsForDispatch: preservedOptions && preservedOptions.length > 0 ? preservedOptions : undefined, + // The model is absent from the catalog, so we cannot know its + // capabilities. Default to permissive so attachments are not + // silently blocked on a stale catalog. + inputCapabilities: getModelInputCapabilities(null), }; } } @@ -106,6 +119,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com descriptors, modelOptions, ), + inputCapabilities: getModelInputCapabilities(caps), ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame", diff --git a/apps/web/src/components/chat/modelFamilyGrouping.test.ts b/apps/web/src/components/chat/modelFamilyGrouping.test.ts new file mode 100644 index 000000000000..f6ae97d456f2 --- /dev/null +++ b/apps/web/src/components/chat/modelFamilyGrouping.test.ts @@ -0,0 +1,243 @@ +import { + ProviderDriverKind, + type ModelCapabilities, + type ProviderOptionSelection, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { + getReasoningLevelDescriptor, + resolveReasoningLevel, + resolveReasoningLevelLabel, + withReasoningLevelChange, + getModelFamilyLabel, + getProviderGroupLabel, + groupByProvider, + findModelCapabilities, +} from "./modelFamilyGrouping"; + +function capsWithEffort( + effortId: string, + options: Array<{ id: string; label: string; isDefault?: boolean }>, + currentValue?: string, +): ModelCapabilities { + return { + optionDescriptors: [ + { + id: effortId, + label: "Reasoning effort", + type: "select", + options, + ...(currentValue ? { currentValue } : {}), + }, + ], + }; +} + +describe("getReasoningLevelDescriptor", () => { + it("extracts the effort select descriptor by id", () => { + const caps = capsWithEffort("effort", [ + { id: "low", label: "Low", isDefault: true }, + { id: "high", label: "High" }, + ]); + const descriptor = getReasoningLevelDescriptor({ + caps, + selections: [{ id: "effort", value: "high" }], + }); + expect(descriptor).not.toBeNull(); + expect(descriptor!.descriptorId).toBe("effort"); + expect(descriptor!.currentValue).toBe("high"); + expect(descriptor!.options).toHaveLength(2); + }); + + it("falls back to the first select descriptor when no id matches", () => { + const caps: ModelCapabilities = { + optionDescriptors: [ + { + id: "serviceTier", + label: "Service tier", + type: "select", + options: [{ id: "default", label: "Default", isDefault: true }], + }, + ], + }; + const descriptor = getReasoningLevelDescriptor({ caps }); + expect(descriptor).not.toBeNull(); + expect(descriptor!.descriptorId).toBe("serviceTier"); + }); + + it("returns null when there are no select descriptors", () => { + const caps: ModelCapabilities = { + optionDescriptors: [ + { id: "fastMode", label: "Fast mode", type: "boolean", currentValue: false }, + ], + }; + expect(getReasoningLevelDescriptor({ caps })).toBeNull(); + }); + + it("resolves the default value when no selection is stored", () => { + const caps = capsWithEffort("effort", [ + { id: "low", label: "Low", isDefault: true }, + { id: "high", label: "High" }, + ]); + const descriptor = getReasoningLevelDescriptor({ caps, selections: null }); + expect(descriptor!.currentValue).toBe("low"); + }); +}); + +describe("resolveReasoningLevel", () => { + it("returns the stored effort value", () => { + const caps = capsWithEffort("effort", [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ]); + expect(resolveReasoningLevel({ caps, selections: [{ id: "effort", value: "high" }] })).toBe( + "high", + ); + }); + + it("returns null when the model has no reasoning descriptor", () => { + const caps: ModelCapabilities = { optionDescriptors: [] }; + expect(resolveReasoningLevel({ caps })).toBeNull(); + }); +}); + +describe("resolveReasoningLevelLabel", () => { + it("returns the human-readable label for the current value", () => { + const caps = capsWithEffort("effort", [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ]); + expect( + resolveReasoningLevelLabel({ caps, selections: [{ id: "effort", value: "high" }] }), + ).toBe("High"); + }); + + it("returns null when no reasoning descriptor exists", () => { + const caps: ModelCapabilities = { optionDescriptors: [] }; + expect(resolveReasoningLevelLabel({ caps })).toBeNull(); + }); +}); + +describe("withReasoningLevelChange", () => { + it("replaces the reasoning value and preserves other options", () => { + const caps = capsWithEffort("effort", [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ]); + const selections: ProviderOptionSelection[] = [ + { id: "effort", value: "low" }, + { id: "fastMode", value: true }, + ]; + const next = withReasoningLevelChange({ caps, selections, nextValue: "high" }); + expect(next).toEqual([ + { id: "fastMode", value: true }, + { id: "effort", value: "high" }, + ]); + }); + + it("adds the reasoning option when no prior selection exists", () => { + const caps = capsWithEffort("effort", [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ]); + const next = withReasoningLevelChange({ caps, selections: [], nextValue: "high" }); + expect(next).toEqual([{ id: "effort", value: "high" }]); + }); + + it("returns undefined when the model has no reasoning descriptor", () => { + const caps: ModelCapabilities = { optionDescriptors: [] }; + expect(withReasoningLevelChange({ caps, selections: [], nextValue: "high" })).toBeUndefined(); + }); +}); + +describe("getModelFamilyLabel", () => { + it("strips trailing reasoning qualifiers from the slug", () => { + expect( + getModelFamilyLabel("claude-sonnet-5-high", ProviderDriverKind.make("claudeAgent")), + ).toBe("Claude Sonnet 5"); + }); + + it("uses the fallback name when the slug has no qualifier", () => { + expect( + getModelFamilyLabel( + "claude-sonnet-5", + ProviderDriverKind.make("claudeAgent"), + "Claude Sonnet 5", + ), + ).toBe("Claude Sonnet 5"); + }); + + it("humanizes a bare slug", () => { + expect(getModelFamilyLabel("gpt-5-codex", ProviderDriverKind.make("codex"))).toBe( + "Gpt 5 Codex", + ); + }); +}); + +describe("getProviderGroupLabel", () => { + it("uses the canonical PROVIDER_DISPLAY_NAMES entry", () => { + expect(getProviderGroupLabel(ProviderDriverKind.make("claudeAgent"))).toBe("Claude"); + expect(getProviderGroupLabel(ProviderDriverKind.make("devin"))).toBe("Devin"); + }); + + it("falls back to a humanized slug for unknown kinds", () => { + expect(getProviderGroupLabel(ProviderDriverKind.make("claudeAgent"))).not.toBe("claudeAgent"); + }); +}); + +describe("groupByProvider", () => { + const codex = ProviderDriverKind.make("codex"); + const claude = ProviderDriverKind.make("claudeAgent"); + const grok = ProviderDriverKind.make("grok"); + + it("groups items by driver kind preserving first-appearance order", () => { + const items = [ + { driverKind: codex, slug: "gpt-5" }, + { driverKind: claude, slug: "sonnet" }, + { driverKind: codex, slug: "gpt-5.4" }, + { driverKind: grok, slug: "grok-build" }, + ]; + const groups = groupByProvider(items); + expect(groups).toHaveLength(3); + expect(groups[0]!.driverKind).toBe("codex"); + expect(groups[0]!.items.map((i) => i.slug)).toEqual(["gpt-5", "gpt-5.4"]); + expect(groups[1]!.driverKind).toBe("claudeAgent"); + expect(groups[1]!.items).toHaveLength(1); + expect(groups[2]!.driverKind).toBe("grok"); + }); + + it("returns a single group when all items share a provider", () => { + const items = [ + { driverKind: codex, slug: "a" }, + { driverKind: codex, slug: "b" }, + ]; + const groups = groupByProvider(items); + expect(groups).toHaveLength(1); + expect(groups[0]!.items).toHaveLength(2); + }); + + it("returns an empty array for no items", () => { + expect(groupByProvider([])).toEqual([]); + }); +}); + +describe("findModelCapabilities", () => { + it("returns the matching model capabilities", () => { + const caps = capsWithEffort("effort", [{ id: "low", label: "Low" }]); + const models = [ + { slug: "model-a", name: "Model A", isCustom: false, capabilities: caps }, + { + slug: "model-b", + name: "Model B", + isCustom: false, + capabilities: { optionDescriptors: [] }, + }, + ]; + expect(findModelCapabilities(models, "model-a")).toBe(caps); + }); + + it("returns empty capabilities when the model is not found", () => { + const result = findModelCapabilities([], "missing"); + expect(result.optionDescriptors).toEqual([]); + }); +}); diff --git a/apps/web/src/components/chat/modelFamilyGrouping.ts b/apps/web/src/components/chat/modelFamilyGrouping.ts new file mode 100644 index 000000000000..4c303de0b8bd --- /dev/null +++ b/apps/web/src/components/chat/modelFamilyGrouping.ts @@ -0,0 +1,208 @@ +import { + PROVIDER_DISPLAY_NAMES, + type ProviderDriverKind, + type ProviderOptionDescriptor, + type ProviderOptionSelection, + type ModelCapabilities, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { getProviderOptionCurrentValue, getProviderOptionDescriptors } from "@t3tools/shared/model"; + +/** + * The descriptor id used for the primary reasoning-effort select option. + * Providers expose reasoning level through different descriptor ids (`effort`, + * `reasoningEffort`, `variant`); the first select descriptor on a model's + * capabilities is treated as the reasoning-level control, matching the + * convention in {@link getComposerProviderState} and {@link TraitsPicker}. + */ +const REASONING_DESCRIPTOR_IDS = new Set([ + "effort", + "reasoningEffort", + "variant", + "reasoning", + "thinking", +]); + +export interface ReasoningLevelOption { + readonly id: string; + readonly label: string; + readonly isDefault: boolean; +} + +export interface ReasoningLevelDescriptor { + readonly descriptorId: string; + readonly label: string; + readonly currentValue: string | null; + readonly options: ReadonlyArray; +} + +/** + * Extract the reasoning-level select descriptor from a model's capabilities. + * Returns the first select descriptor whose id is a known reasoning id, or + * the first select descriptor overall when none match by id (preserving the + * existing "primary select descriptor" convention). Returns `null` when the + * model exposes no select options. + */ +export function getReasoningLevelDescriptor(input: { + readonly caps: ModelCapabilities; + readonly selections?: ReadonlyArray | null | undefined; +}): ReasoningLevelDescriptor | null { + const descriptors = getProviderOptionDescriptors({ + caps: input.caps, + selections: input.selections, + }); + const selectDescriptors = descriptors.filter( + (descriptor): descriptor is Extract => + descriptor.type === "select", + ); + if (selectDescriptors.length === 0) { + return null; + } + const descriptor = + selectDescriptors.find((candidate) => REASONING_DESCRIPTOR_IDS.has(candidate.id)) ?? + selectDescriptors[0]!; + const currentValue = getProviderOptionCurrentValue(descriptor); + return { + descriptorId: descriptor.id, + label: descriptor.label, + currentValue: typeof currentValue === "string" ? currentValue : null, + options: descriptor.options.map((option) => ({ + id: option.id, + label: option.label, + isDefault: Boolean(option.isDefault), + })), + }; +} + +/** + * Read the current reasoning level value from a stored options array, + * resolving against the descriptor's default when no explicit selection + * exists. Returns `null` when the model has no reasoning descriptor. + */ +export function resolveReasoningLevel(input: { + readonly caps: ModelCapabilities; + readonly selections?: ReadonlyArray | null | undefined; +}): string | null { + const descriptor = getReasoningLevelDescriptor(input); + return descriptor?.currentValue ?? null; +} + +/** + * Produce the next options array after changing the reasoning level, leaving + * every other option untouched. Returns `undefined` when the model has no + * reasoning descriptor (so callers can short-circuit). + */ +export function withReasoningLevelChange(input: { + readonly caps: ModelCapabilities; + readonly selections: ReadonlyArray | null | undefined; + readonly nextValue: string; +}): ReadonlyArray | undefined { + const descriptor = getReasoningLevelDescriptor({ + caps: input.caps, + selections: input.selections, + }); + if (!descriptor) { + return undefined; + } + const existing = input.selections ?? []; + const filtered = existing.filter((selection) => selection.id !== descriptor.descriptorId); + return [...filtered, { id: descriptor.descriptorId, value: input.nextValue }]; +} + +/** + * Derive a clean "family" display name from a model slug + provider. Strips + * trailing reasoning-effort qualifiers (e.g. `-low`, `-high`, `:medium`) that + * some providers append to variant slugs, then falls back to the raw name. + */ +export function getModelFamilyLabel( + slug: string, + provider: ProviderDriverKind, + fallbackName?: string, +): string { + const stripped = stripReasoningQualifier(slug); + if (stripped.length > 0 && stripped !== slug) { + return humanizeSlug(stripped); + } + if (fallbackName && fallbackName.length > 0) { + return fallbackName; + } + return humanizeSlug(slug); +} + +function stripReasoningQualifier(slug: string): string { + return slug.replace(/[-_:](?:low|medium|minimal|high|max|extended|none)$/iu, ""); +} + +function humanizeSlug(slug: string): string { + return slug + .replace(/[_-]+/g, " ") + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/\b\w/g, (char) => char.toUpperCase()) + .trim(); +} + +/** + * Resolve the provider display name for a driver kind, using the canonical + * contracts constant and falling back to a title-cased kind slug. + */ +export function getProviderGroupLabel(driverKind: ProviderDriverKind): string { + return PROVIDER_DISPLAY_NAMES[driverKind] ?? humanizeSlug(driverKind); +} + +/** + * Group a flat list of items by their provider (driver kind), preserving the + * first-appearance order of providers. Each group keeps its items in their + * original relative order. Used by the model picker to render provider logo + * headers above each provider's models in favorites and search views. + */ +export function groupByProvider( + items: ReadonlyArray, +): ReadonlyArray<{ readonly driverKind: ProviderDriverKind; readonly items: readonly T[] }> { + const order: ProviderDriverKind[] = []; + const buckets = new Map(); + for (const item of items) { + const bucket = buckets.get(item.driverKind); + if (bucket) { + bucket.push(item); + } else { + buckets.set(item.driverKind, [item]); + order.push(item.driverKind); + } + } + return order.map((driverKind) => ({ + driverKind, + items: buckets.get(driverKind)!, + })); +} + +/** + * Look up a model's capabilities by slug within an instance's model snapshot. + * Returns an empty-capabilities object when the model is not found so callers + * can treat the result uniformly. + */ +export function findModelCapabilities( + models: ReadonlyArray, + slug: string, +): ModelCapabilities { + return ( + models.find((candidate) => candidate.slug === slug)?.capabilities ?? { + optionDescriptors: [], + } + ); +} + +/** + * Read the reasoning level for a model from its capabilities + stored + * selections, returning the option label (e.g. "High") rather than the id. + * Returns `null` when the model has no reasoning descriptor. + */ +export function resolveReasoningLevelLabel(input: { + readonly caps: ModelCapabilities; + readonly selections?: ReadonlyArray | null | undefined; +}): string | null { + const descriptor = getReasoningLevelDescriptor(input); + if (!descriptor || !descriptor.currentValue) { + return null; + } + return descriptor.options.find((option) => option.id === descriptor.currentValue)?.label ?? null; +} diff --git a/apps/web/src/components/chat/modelPickerKeys.test.ts b/apps/web/src/components/chat/modelPickerKeys.test.ts index e56fcb3d4146..2b7788f36a33 100644 --- a/apps/web/src/components/chat/modelPickerKeys.test.ts +++ b/apps/web/src/components/chat/modelPickerKeys.test.ts @@ -1,10 +1,12 @@ -import { ProviderInstanceId } from "@t3tools/contracts"; +import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { modelPickerLegacySectionKey, modelPickerModelKey, + modelPickerProviderGroupKey, parseModelPickerLegacySectionKey, parseModelPickerModelKey, + parseModelPickerProviderGroupKey, } from "./modelPickerKeys"; describe("model picker item keys", () => { @@ -29,3 +31,26 @@ describe("model picker item keys", () => { expect(parseModelPickerModelKey(key)).toEqual({ instanceId, slug }); }); }); + +describe("provider group keys", () => { + it("round-trips a driver kind", () => { + const key = modelPickerProviderGroupKey(ProviderDriverKind.make("claudeAgent")); + expect(parseModelPickerProviderGroupKey(key)).toBe("claudeAgent"); + }); + + it("is distinct from model and legacy section keys", () => { + const groupKey = modelPickerProviderGroupKey(ProviderDriverKind.make("codex")); + const modelKey = modelPickerModelKey(ProviderInstanceId.make("codex"), "gpt-5"); + const legacyKey = modelPickerLegacySectionKey(ProviderInstanceId.make("codex")); + + expect(groupKey).not.toBe(modelKey); + expect(groupKey).not.toBe(legacyKey); + expect(parseModelPickerModelKey(groupKey)).toBeNull(); + expect(parseModelPickerLegacySectionKey(groupKey)).toBeNull(); + }); + + it("returns null for non-group keys", () => { + expect(parseModelPickerProviderGroupKey("model:5:codexgpt-5")).toBeNull(); + expect(parseModelPickerProviderGroupKey("legacy-models:codex")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/modelPickerKeys.ts b/apps/web/src/components/chat/modelPickerKeys.ts index 0b4f0845e116..e9027ca7ad3e 100644 --- a/apps/web/src/components/chat/modelPickerKeys.ts +++ b/apps/web/src/components/chat/modelPickerKeys.ts @@ -1,7 +1,8 @@ -import type { ProviderInstanceId } from "@t3tools/contracts"; +import type { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; const MODEL_KEY_PREFIX = "model:"; const LEGACY_SECTION_KEY_PREFIX = "legacy-models:"; +const PROVIDER_GROUP_KEY_PREFIX = "provider-group:"; export function modelPickerModelKey(instanceId: ProviderInstanceId, slug: string): string { return `${MODEL_KEY_PREFIX}${instanceId.length}:${instanceId}${slug}`; @@ -45,3 +46,13 @@ export function parseModelPickerLegacySectionKey(key: string): ProviderInstanceI ? (key.slice(LEGACY_SECTION_KEY_PREFIX.length) as ProviderInstanceId) : null; } + +export function modelPickerProviderGroupKey(driverKind: ProviderDriverKind): string { + return `${PROVIDER_GROUP_KEY_PREFIX}${driverKind}`; +} + +export function parseModelPickerProviderGroupKey(key: string): ProviderDriverKind | null { + return key.startsWith(PROVIDER_GROUP_KEY_PREFIX) + ? (key.slice(PROVIDER_GROUP_KEY_PREFIX.length) as ProviderDriverKind) + : null; +} diff --git a/apps/web/src/components/chat/providerIconUtils.test.ts b/apps/web/src/components/chat/providerIconUtils.test.ts new file mode 100644 index 000000000000..0b86553a15da --- /dev/null +++ b/apps/web/src/components/chat/providerIconUtils.test.ts @@ -0,0 +1,35 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { getDevinModelProviderBrand, getModelProviderBrand } from "./providerIconUtils"; + +describe("Devin model provider brands", () => { + it.each([ + ["claude-opus-5", "Claude Opus 5", "Anthropic"], + ["gpt-5-6-luna", "GPT-5.6 Luna", "OpenAI"], + ["gemini-3-7-flash", "Gemini 3.7 Flash", "Google"], + ["glm-5-2", "GLM-5.2", "Z.ai"], + ["kimi-k3", "Kimi K3", "Moonshot AI"], + ["deepseek-v4-pro", "DeepSeek V4 Pro", "DeepSeek"], + ["grok-4-6", "Grok 4.6", "xAI"], + ["nemotron-3-ultra", "Nemotron 3 Ultra", "NVIDIA"], + ["swe-1-7", "SWE-1.7", "Cognition"], + ])("maps %s to %s", (slug, name, expected) => { + expect(getDevinModelProviderBrand({ slug, name }).label).toBe(expected); + }); + + it("falls back to Devin for an unknown Devin model", () => { + expect(getDevinModelProviderBrand({ slug: "future-model", name: "Future Model" }).label).toBe( + "Devin", + ); + }); + + it("keeps normal provider branding outside Devin", () => { + expect( + getModelProviderBrand(ProviderDriverKind.make("codex"), { + slug: "gpt-5.6", + name: "GPT-5.6", + }).label, + ).toBe("Codex"); + }); +}); diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index db0e5ca222f3..2fc7f340df01 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -2,18 +2,27 @@ import { ProviderDriverKind } from "@t3tools/contracts"; import { AntigravityIcon, ClaudeAI, + CognitionIcon, CursorIcon, + DeepSeekIcon, + DevinIcon, + Gemini, + GLMIcon, GrokIcon, Icon, + KimiIcon, + NvidiaIcon, OpenAI, OpenCodeIcon, } from "../Icons"; +import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { [ProviderDriverKind.make("codex")]: OpenAI, [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, + [ProviderDriverKind.make("devin")]: DevinIcon, [ProviderDriverKind.make("grok")]: GrokIcon, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, }; @@ -30,6 +39,61 @@ export type ModelEsque = { isUnavailable?: boolean | undefined; }; +export type ModelProviderBrand = { + readonly label: string; + readonly icon: Icon; +}; + +function modelIdentity(model: ModelEsque): string { + return `${model.slug} ${model.name} ${model.subProvider ?? ""}`.toLowerCase(); +} + +/** Resolve the company behind a model exposed through Devin. */ +export function getDevinModelProviderBrand(model: ModelEsque): ModelProviderBrand { + const identity = modelIdentity(model); + if (identity.includes("claude") || identity.includes("anthropic")) { + return { label: "Anthropic", icon: ClaudeAI }; + } + if (identity.includes("gpt") || identity.includes("openai") || identity.includes("codex")) { + return { label: "OpenAI", icon: OpenAI }; + } + if (identity.includes("gemini") || identity.includes("google")) { + return { label: "Google", icon: Gemini }; + } + if (identity.includes("glm") || identity.includes("zhipu") || identity.includes("z.ai")) { + return { label: "Z.ai", icon: GLMIcon }; + } + if (identity.includes("kimi") || identity.includes("moonshot")) { + return { label: "Moonshot AI", icon: KimiIcon }; + } + if (identity.includes("deepseek")) { + return { label: "DeepSeek", icon: DeepSeekIcon }; + } + if (identity.includes("grok") || identity.includes("xai")) { + return { label: "xAI", icon: GrokIcon }; + } + if (identity.includes("nemotron") || identity.includes("nvidia")) { + return { label: "NVIDIA", icon: NvidiaIcon }; + } + if (identity.includes("swe-") || identity.includes("adaptive") || identity.includes("inkling")) { + return { label: "Cognition", icon: CognitionIcon }; + } + return { label: "Devin", icon: DevinIcon }; +} + +export function getModelProviderBrand( + driverKind: ProviderDriverKind, + model: ModelEsque, +): ModelProviderBrand { + if (driverKind === ProviderDriverKind.make("devin")) { + return getDevinModelProviderBrand(model); + } + return { + label: PROVIDER_OPTIONS.find((option) => option.value === driverKind)?.label ?? driverKind, + icon: PROVIDER_ICON_BY_PROVIDER[driverKind] ?? DevinIcon, + }; +} + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 7b386e062c18..69785ddc4f0f 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -4,6 +4,8 @@ import { Spinner } from "~/components/ui/spinner"; import { ArrowUpCircleIcon, + CircleAlertIcon, + CircleCheckIcon, CopyIcon, DownloadIcon, LockIcon, @@ -16,12 +18,12 @@ import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { + ProviderDriverKind, isProviderDriverKind, resolveProviderInstanceEnabled, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, type ProviderInstanceId, - type ProviderDriverKind, type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; @@ -170,6 +172,148 @@ function ProviderAuthEmail(props: { readonly email: string | undefined }) { ); } +function redactDiagnosticConfig(config: unknown): Record { + if (config === null || typeof config !== "object" || Array.isArray(config)) return {}; + const redacted: Record = {}; + for (const [key, value] of Object.entries(config as Record)) { + if (/(token|secret|password|api[-_]?key|credential)/iu.test(key)) { + redacted[key] = "[redacted]"; + } else if (typeof value === "string") { + redacted[key] = value; + } else if (typeof value === "boolean" || typeof value === "number" || value === null) { + redacted[key] = value; + } + } + return redacted; +} + +function DevinDiagnosticsSection(props: { + readonly instance: ProviderInstanceConfig; + readonly provider: ServerProvider | undefined; + readonly models: ReadonlyArray; + readonly onRefresh?: (() => void) | undefined; + readonly refreshing: boolean; +}) { + const { instance, provider, models, onRefresh, refreshing } = props; + const [copied, setCopied] = useState(false); + const builtInModels = models.filter((model) => !model.isCustom); + const pricedModels = builtInModels.filter( + (model) => model.pricing !== undefined || Object.keys(model.pricingByVariant ?? {}).length > 0, + ).length; + const contextModels = builtInModels.filter( + (model) => model.contextWindowTokens !== undefined, + ).length; + const metadataComplete = builtInModels.length > 0 && pricedModels === builtInModels.length; + const config = instance.config as Record | undefined; + const accountUsageNames = new Set([ + "DEVIN_API_KEY", + "DEVIN_PERSONAL_ACCESS_TOKEN", + "DEVIN_ORG_ID", + "DEVIN_ORGANIZATION_ID", + ]); + const accountUsageConfigured = (instance.environment ?? []).some((variable) => + accountUsageNames.has(variable.name.toUpperCase()), + ); + const accountUsageLabel = accountUsageConfigured + ? "Optional Devin organization ACU API configured (value redacted)." + : "Optional Devin organization ACU API not configured; CLI login alone does not grant billing access."; + const binaryPath = + typeof config?.binaryPath === "string" && config.binaryPath.trim() + ? config.binaryPath.trim() + : "devin (PATH)"; + const diagnosticsPayload = JSON.stringify( + { + provider: "devin", + transport: "devin acp", + binaryPath, + status: provider?.status ?? "unknown", + installed: provider?.installed ?? false, + authenticated: provider?.auth.status === "authenticated", + version: provider?.version ?? null, + checkedAt: provider?.checkedAt ?? null, + modelCatalogVersion: provider?.modelCatalogVersion ?? null, + modelCount: builtInModels.length, + pricedModelCount: pricedModels, + contextModelCount: contextModels, + usageSource: "T3 local provider event logs", + accountUsage: accountUsageConfigured ? "configured (value redacted)" : "not configured", + subagentEvents: "not advertised by standard ACP", + config: redactDiagnosticConfig(instance.config), + }, + null, + 2, + ); + const copyDiagnostics = () => { + if (!globalThis.navigator?.clipboard) return; + void globalThis.navigator.clipboard.writeText(diagnosticsPayload).then( + () => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }, + () => undefined, + ); + }; + + return ( +
+ + + {metadataComplete ? ( + + ) : ( + + )} + Devin ACP diagnostics + + +
+
+ Transport + {binaryPath} acp + CLI / auth + + {provider?.version ?? "Not probed"} · {provider?.auth.status ?? "unknown"} + + Catalog + + {builtInModels.length} parent models ·{" "} + {provider?.modelCatalogVersion ?? "not versioned"} + + Metadata + + {pricedModels}/{builtInModels.length} priced · {contextModels}/{builtInModels.length}{" "} + context sizes + + Usage + Local T3 event-log estimate. {accountUsageLabel} + Subagents + + Standard ACP does not advertise child-agent events; only structured events are shown. + +
+
+ {onRefresh ? ( + + ) : null} + +
+
+
+ ); +} + function ProviderEnvironmentSection(props: { readonly environment: ReadonlyArray; readonly onChange: (environment: ReadonlyArray) => void; @@ -376,6 +520,9 @@ interface ProviderInstanceCardProps { readonly onModelOrderChange: (next: ReadonlyArray) => void; readonly onRunUpdate?: (() => void) | undefined; readonly isUpdating?: boolean | undefined; + /** Re-run the provider probe from the diagnostics panel. */ + readonly onRefresh?: (() => void) | undefined; + readonly isRefreshing?: boolean | undefined; } /** @@ -418,6 +565,8 @@ export function ProviderInstanceCard({ onModelOrderChange, onRunUpdate, isUpdating = false, + onRefresh, + isRefreshing = false, }: ProviderInstanceCardProps) { const enabled = resolveProviderInstanceEnabled(instance); // A locally disabled provider reads "Disabled" with a muted dot even if its @@ -869,6 +1018,17 @@ export function ProviderInstanceCard({ } /> )} + {driverKind === ProviderDriverKind.make("devin") ? ( +
+ +
+ ) : null} ) => void; } +function formatModelMetadataTokens(value: number | undefined): string | null { + if (value === undefined || !Number.isFinite(value) || value <= 0) return null; + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}M`; + } + return `${Math.round(value / 1_000)}K`; +} + +function formatModelRate(value: number | undefined): string | null { + if (value === undefined || !Number.isFinite(value) || value < 0) return null; + if (value === 0) return "Free"; + return `$${value < 1 ? value.toFixed(2) : value.toFixed(2).replace(/\.00$/u, "")}`; +} + /** * Shared "Models" section rendered on both the built-in default and custom * provider-instance cards. Owns its own input + error local state so two diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 4bf4da3919ba..66200f17e4d0 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -3,6 +3,7 @@ import { ClaudeSettings, CodexSettings, CursorSettings, + DevinSettings, GrokSettings, OpenCodeSettings, ProviderDriverKind, @@ -12,6 +13,7 @@ import { AntigravityIcon, ClaudeAI, CursorIcon, + DevinIcon, GrokIcon, type Icon, OpenAI, @@ -63,6 +65,13 @@ const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ badgeLabel: "Early Access", settingsSchema: CursorSettings, }, + { + value: ProviderDriverKind.make("devin"), + label: "Devin", + icon: DevinIcon, + badgeLabel: "Early Access", + settingsSchema: DevinSettings, + }, { value: ProviderDriverKind.make("grok"), label: "Grok", diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index deb05f266b98..1224ad3f6084 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -3,12 +3,15 @@ import { useAtomValue } from "@effect/atom-react"; import { USAGE_CONTRACT_VERSION, type EnvironmentId, + type UsageAccountConsumption, type UsageProviderKind, } from "@t3tools/contracts"; import { + CheckIcon, CircleAlertIcon, ChevronDownIcon, CircleDashedIcon, + DownloadIcon, SlidersHorizontalIcon, } from "lucide-react"; import { useMemo, useRef, useState } from "react"; @@ -67,6 +70,7 @@ import { saveUsagePagePreferences, type UsagePagePreferences, } from "./usagePagePreferences"; +import { usageToCsv, usageToJson } from "./usageExport"; type UsageMetric = UsageChartMetric | "limits"; const METRIC_OPTIONS = [ @@ -90,6 +94,24 @@ function isUsageWindowDays(value: number): value is UsagePagePreferences["window return WINDOW_OPTIONS.some((option) => option.days === value); } +function filterUsagePeriods( + periods: readonly T[], + providerFilter: UsageProviderKind | "all", +): readonly T[] { + if (providerFilter === "all") return periods; + return periods.map((period) => { + const selected = period.byProvider.get(providerFilter); + return { + ...period, + costUsd: selected?.costUsd ?? 0, + totalTokens: selected?.totalTokens ?? 0, + byProvider: selected + ? new Map([[providerFilter, selected]]) + : new Map(), + } as T; + }); +} + export function UsagePage() { const [preferences, setPreferences] = useState(readUsagePagePreferences); const [windowSelection, setWindowSelection] = useState(() => ({ @@ -107,6 +129,7 @@ export function UsagePage() { const [breakdown, setBreakdown] = useState<"model" | "time">("model"); const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState | null>(null); + const [providerFilter, setProviderFilter] = useState("all"); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage( @@ -131,21 +154,54 @@ export function UsagePage() { ); // Newest first: the window can run 90 periods, so the interesting end // belongs at the top of the table. + const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]); + const visibleProviders = useMemo( + () => + providerFilter === "all" + ? activeProviders + : activeProviders.filter((provider) => provider === providerFilter), + [activeProviders, providerFilter], + ); + const visibleDaily = useMemo( + () => filterUsagePeriods(merged.daily, providerFilter), + [merged.daily, providerFilter], + ); + const visibleHourly = useMemo( + () => filterUsagePeriods(merged.hourly, providerFilter), + [merged.hourly, providerFilter], + ); const breakdownPeriods = useMemo( - () => (isPast24Hours ? merged.hourly : merged.daily).toReversed(), - [isPast24Hours, merged.daily, merged.hourly], + () => (isPast24Hours ? visibleHourly : visibleDaily).toReversed(), + [isPast24Hours, visibleDaily, visibleHourly], ); const breakdownModels = useMemo( () => breakdown === "model" && metric === "tokens" - ? merged.models.toSorted( - (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd, - ) - : merged.models, - [breakdown, merged.models, metric], + ? merged.models + .filter((model) => providerFilter === "all" || model.provider === providerFilter) + .toSorted( + (left, right) => right.totalTokens - left.totalTokens || right.costUsd - left.costUsd, + ) + : merged.models.filter( + (model) => providerFilter === "all" || model.provider === providerFilter, + ), + [breakdown, merged.models, metric, providerFilter], ); - const activeProviders = useMemo(() => providersWithUsage(merged.providers), [merged.providers]); - const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; + const timeValueColumnWidth = `${60 / (visibleProviders.length + 2)}%`; + + const downloadExport = (format: "csv" | "json") => { + if (typeof document === "undefined") return; + const body = format === "csv" ? usageToCsv(merged, window) : usageToJson(merged, window); + const blob = new Blob([body], { + type: format === "csv" ? "text/csv;charset=utf-8" : "application/json;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `t3-usage-${window.sinceDay}-${window.untilDay}.${format}`; + anchor.click(); + URL.revokeObjectURL(url); + }; const selectWindow = (days: number) => { if (!isUsageWindowDays(days)) return; @@ -218,6 +274,7 @@ export function UsagePage() { isPartial={isPartial} duplicateSources={merged.duplicateSources} staleEnvironments={merged.staleEnvironments} + accountUsage={merged.accountUsage} /> @@ -270,6 +327,15 @@ export function UsagePage() { > +
{ + if ( + value === "all" || + PROVIDER_ORDER.includes(value as UsageProviderKind) + ) { + setProviderFilter(value as UsageProviderKind | "all"); + } + }} + > + + + {providerFilter === "all" + ? "All providers" + : PROVIDER_PRESENTATION[providerFilter].label} + + + + All providers + {PROVIDER_ORDER.filter( + (provider) => PROVIDER_PRESENTATION[provider] !== undefined, + ).map((provider) => ( + + {PROVIDER_PRESENTATION[provider].label} + + ))} + + + { + const value = next[0]; + if (value === "model" || value === "time") setBreakdown(value); + }} + > + {( + [ + { value: "model", label: "Model" }, + { value: "time", label: isPast24Hours ? "Hour" : "Day" }, + ] as const + ).map((option) => ( + + {option.label} + + ))} + +
{breakdown === "model" ? ( @@ -528,7 +646,7 @@ export function UsagePage() { - {activeProviders.map((provider) => ( + {visibleProviders.map((provider) => ( ))} @@ -537,7 +655,7 @@ export function UsagePage() { - {activeProviders.map((provider) => ( + {visibleProviders.map((provider) => ( @@ -550,7 +668,7 @@ export function UsagePage() { {breakdownPeriods.length === 0 ? ( - {activeProviders.map((provider) => ( + {visibleProviders.map((provider) => (
{isPast24Hours ? "Hour" : "Day"} {PROVIDER_PRESENTATION[provider].label}
No activity in this window. @@ -567,7 +685,7 @@ export function UsagePage() { ? formatHourShort(period.hourStart, window.timeZone) : formatDayShort(period.day)} environment.error !== null); const stale = environments.filter((environment) => staleEnvironments.includes(environment.environmentId), ); - if (failed.length === 0 && stale.length === 0 && duplicateSources.length === 0) { + const hasDevinSource = environments.some((environment) => + environment.summary?.sources.some( + (source) => source.fingerprint.provider === "devin" && source.status !== "missing", + ), + ); + if ( + failed.length === 0 && + stale.length === 0 && + duplicateSources.length === 0 && + !hasDevinSource + ) { return null; } @@ -655,10 +785,80 @@ function UsageCoverageNotice({ {duplicateSources.join(", ")} ) : null} + {hasDevinSource ? ( + + Devin ACP usage is read from this T3 server's local event logs. Devin account billing + is kept separate from the local token/cost estimate + {accountUsage?.status === "available" ? " and is shown below." : "."} + + ) : null} ); } +function formatAcus(value: number): string { + return new Intl.NumberFormat("en-US", { + maximumFractionDigits: 2, + }).format(value); +} + +/** Official Devin account units, deliberately kept separate from token costs. */ +function DevinAccountUsageCard({ usage }: { readonly usage: UsageAccountConsumption }) { + const statusMessage = + usage.status === "notConfigured" + ? "Optional account usage is not configured. Add DEVIN_API_KEY and DEVIN_ORG_ID as sensitive Devin provider environment variables." + : usage.status === "forbidden" + ? "The configured Devin API key cannot read organization consumption. Use a cog_ service key with ViewOrgConsumption permission." + : usage.status === "failed" + ? (usage.message ?? "Devin account consumption could not be loaded.") + : null; + + return ( +
+
+
+

Devin account usage

+ Official ACUs for this window +
+ {usage.status === "available" ? ( + + {formatAcus(usage.totalAcus)} ACUs + + ) : null} +
+ + {statusMessage !== null ? ( +

{statusMessage}

+ ) : ( + <> +
+ {usage.days.length} billing days returned + Source: {usage.source} + {usage.fetchedAt !== null ? ( + Updated {formatDateTimeShort(usage.fetchedAt)} + ) : null} +
+ {usage.days.length > 0 ? ( +
+ {usage.days + .toReversed() + .slice(0, 5) + .map((day) => ( +
+ {formatDayShort(day.day)} + + {formatAcus(day.acus)} ACUs + +
+ ))} +
+ ) : null} + + )} +
+ ); +} + /** Environment selection and scan progress share a permanent header control. */ function UsageEnvironmentFilter({ environments, @@ -669,6 +869,7 @@ function UsageEnvironmentFilter({ isPartial, duplicateSources, staleEnvironments, + accountUsage, }: { readonly environments: readonly EnvironmentUsageStatus[]; readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; @@ -678,6 +879,7 @@ function UsageEnvironmentFilter({ readonly isPartial: boolean; readonly duplicateSources: readonly string[]; readonly staleEnvironments: readonly string[]; + readonly accountUsage: UsageAccountConsumption | null; }) { const [modelPricesOpen, setModelPricesOpen] = useState(false); const allSelected = selectedEnvironmentIds === null; @@ -791,6 +993,7 @@ function UsageEnvironmentFilter({ environments={selectedEnvironments} duplicateSources={duplicateSources} staleEnvironments={staleEnvironments} + accountUsage={accountUsage} /> ) : null} diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index 622d73d13844..d894e773312c 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -89,6 +89,7 @@ describe("buildPeriodColumns", () => { { provider: "codex", value: 10 }, { provider: "claude", value: 20 }, { provider: "grok", value: 0 }, + { provider: "devin", value: 0 }, ]); }); diff --git a/apps/web/src/components/usage/usageExport.test.ts b/apps/web/src/components/usage/usageExport.test.ts new file mode 100644 index 000000000000..02e7916b040a --- /dev/null +++ b/apps/web/src/components/usage/usageExport.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { mergeUsage } from "@t3tools/shared/usageMerge"; +import { UsageDay } from "@t3tools/contracts"; +import { usageToCsv, usageToJson } from "./usageExport"; + +const input = { + sinceDay: UsageDay.make("2026-08-10"), + untilDay: UsageDay.make("2026-08-11"), + timeZone: "UTC", + resolution: "day" as const, +}; + +describe("usage exports", () => { + it("exports model rows as escaped CSV", () => { + const usage = mergeUsage( + [ + { + environmentId: "env-a" as never, + label: "Local", + summary: { + contractVersion: 6, + readAt: "2026-08-11T00:00:00.000Z", + timeZone: "UTC", + sinceDay: input.sinceDay, + untilDay: input.untilDay, + buckets: [ + { + day: input.sinceDay, + provider: "devin", + model: "gpt,5.6", + totals: { + uncachedInputTokens: 10, + cachedInputTokens: 2, + cacheCreationTokens: 0, + outputTokens: 5, + reasoningTokens: 1, + }, + costUsd: 0.25, + cacheSavingsUsd: 0, + costSource: "providerReported", + records: 1, + unpricedRecords: 0, + sessions: 1, + }, + ], + sources: [ + { + fingerprint: { + hostId: "host", + provider: "devin", + resolvedHomePath: "/logs", + volumeId: "1:2", + }, + status: "ok", + scannedFiles: 1, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: 1, + message: null, + }, + ], + pricing: { + status: "unavailable", + source: "test", + fetchedAt: null, + knownModels: 0, + }, + scanDurationMs: 1, + }, + }, + ], + 6, + ); + const csv = usageToCsv(usage, input); + + expect(csv).toContain("Provider,Model,Tokens,Cost (USD),Records,Cost share"); + expect(csv).toContain('devin,"gpt,5.6"'); + expect(csv).toContain("Total tokens,17"); + }); + + it("serializes provider maps in JSON instead of dropping them", () => { + const usage = mergeUsage([], 6); + const parsed = JSON.parse(usageToJson(usage, input)) as { + readonly daily: readonly unknown[]; + readonly window: { readonly timeZone: string }; + }; + + expect(parsed.window.timeZone).toBe("UTC"); + expect(parsed.daily).toEqual([]); + }); +}); diff --git a/apps/web/src/components/usage/usageExport.ts b/apps/web/src/components/usage/usageExport.ts new file mode 100644 index 000000000000..6e8d38ea3fe9 --- /dev/null +++ b/apps/web/src/components/usage/usageExport.ts @@ -0,0 +1,88 @@ +import { USAGE_CONTRACT_VERSION, type UsageSummaryInput } from "@t3tools/contracts"; +import type { MergedUsage } from "@t3tools/shared/usageMerge"; + +function csvCell(value: string | number): string { + const text = String(value); + return /[",\r\n]/u.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +} + +function csvRow(values: readonly (string | number)[]): string { + return values.map(csvCell).join(","); +} + +/** Creates a spreadsheet-friendly export without exposing raw transcripts. */ +export function usageToCsv(usage: MergedUsage, input: UsageSummaryInput): string { + const rows: string[] = [ + csvRow(["T3 Code usage export"]), + csvRow(["Since", input.sinceDay]), + csvRow(["Until", input.untilDay]), + csvRow(["Time zone", input.timeZone]), + csvRow(["Contract version", USAGE_CONTRACT_VERSION]), + "", + csvRow(["Provider", "Model", "Tokens", "Cost (USD)", "Records", "Cost share"]), + ]; + + for (const model of usage.models) { + rows.push( + csvRow([ + model.provider, + model.model, + model.totalTokens, + model.costUsd, + model.records, + model.costShare, + ]), + ); + } + + rows.push( + "", + csvRow(["Total tokens", usage.totalTokens]), + csvRow(["Total cost (USD)", usage.costUsd]), + csvRow(["Sessions", usage.sessions]), + ); + if (usage.accountUsage !== null) { + rows.push( + "", + csvRow(["Devin account usage status", usage.accountUsage.status]), + csvRow(["Devin account ACUs", usage.accountUsage.totalAcus]), + csvRow(["Devin account source", usage.accountUsage.source]), + ); + } + return `${rows.join("\r\n")}\r\n`; +} + +/** JSON export counterpart for scripts and issue reports. */ +export function usageToJson(usage: MergedUsage, input: UsageSummaryInput): string { + const serializePeriods = (periods: readonly (typeof usage.daily)[number][]) => + periods.map((period) => ({ + ...period, + byProvider: Object.fromEntries(period.byProvider), + })); + + return JSON.stringify( + { + exportedAt: new Date().toISOString(), + window: input, + totals: { + costUsd: usage.costUsd, + totalTokens: usage.totalTokens, + uncachedInputTokens: usage.uncachedInputTokens, + cachedInputTokens: usage.cachedInputTokens, + cacheCreationTokens: usage.cacheCreationTokens, + outputTokens: usage.outputTokens, + reasoningTokens: usage.reasoningTokens, + records: usage.records, + sessions: usage.sessions, + }, + providers: usage.providers, + models: usage.models, + daily: serializePeriods(usage.daily), + hourly: serializePeriods(usage.hourly), + costQuality: usage.costQuality, + accountUsage: usage.accountUsage, + }, + null, + 2, + ); +} diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index efad95e531ad..39e4587c9db1 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -1,6 +1,6 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { ClaudeAI, GrokIcon, type Icon, OpenAI } from "../Icons"; +import { ClaudeAI, DevinIcon, GrokIcon, type Icon, OpenAI } from "../Icons"; type UsageProviderPresentation = { readonly label: string; @@ -30,6 +30,11 @@ export const PROVIDER_PRESENTATION = { color: "color-mix(in oklab, var(--contrast-foreground) 72%, var(--background))", mark: GrokIcon, }, + devin: { + label: "Devin ACP", + color: "#6b7cff", + mark: DevinIcon, + }, } satisfies Record; /** Stable provider reading order across charts, summaries, tables, and hover rows. */ diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 4a682b97910d..0938fc64ce10 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -30,9 +30,11 @@ const CODEX_INSTANCE = ProviderInstanceId.make("codex"); const CODEX_SECONDARY_INSTANCE = ProviderInstanceId.make("codex_secondary"); const CLAUDE_AGENT_INSTANCE = ProviderInstanceId.make("claudeAgent"); const CURSOR_INSTANCE = ProviderInstanceId.make("cursor"); +const DEVIN_INSTANCE = ProviderInstanceId.make("devin"); const CODEX_DRIVER = ProviderDriverKind.make("codex"); const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); +const DEVIN_DRIVER = ProviderDriverKind.make("devin"); type ProviderOptionSelectionBag = ReadonlyArray; type ProviderOptionSelectionsByProvider = Partial>; @@ -961,7 +963,9 @@ describe("composerDraftStore element contexts", () => { expect(accepted).toBe(true); const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.elementContexts).toHaveLength(1); - const entry = draft?.elementContexts[0]!; + const entry = draft?.elementContexts[0]; + expect(entry).toBeDefined(); + if (!entry) return; expect(entry.id.startsWith("el_")).toBe(true); expect(entry.threadId).toBe(threadId); expect(entry.pickedAt.length).toBeGreaterThan(0); @@ -1996,6 +2000,19 @@ describe("composerDraftStore modelSelection", () => { ); }); + it("stores Devin options through the provider options compatibility setter", () => { + const store = useComposerDraftStore.getState(); + + store.setModelOptions( + threadRef, + providerModelOptions({ devin: { reasoning: "high", fast: true } }), + ); + + expect( + draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[DEVIN_INSTANCE], + ).toEqual(modelSelection(DEVIN_DRIVER, "adaptive", { reasoning: "high", fast: true })); + }); + it("preserves other provider options when switching the active model selection", () => { const store = useComposerDraftStore.getState(); @@ -2299,6 +2316,39 @@ describe("composerDraftStore model seed migration", () => { }, ); + it("keeps off-provider Devin options when upgrading legacy storage", async () => { + vi.useFakeTimers(); + try { + const storage = useComposerDraftStore.persist.getOptions().storage; + expect(storage).toBeDefined(); + storage?.setItem(COMPOSER_DRAFT_STORAGE_KEY, { + version: 2, + state: { + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + stickyProvider: "codex", + stickyModel: "gpt-5.6-terra", + stickyModelOptions: providerModelOptions({ + devin: { reasoning: "high", fast: true }, + }), + }, + } as never); + await vi.advanceTimersByTimeAsync(300); + + await useComposerDraftStore.persist.rehydrate(); + + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider).toMatchObject({ + [DEVIN_INSTANCE]: modelSelection(DEVIN_DRIVER, "adaptive", { + reasoning: "high", + fast: true, + }), + }); + } finally { + vi.useRealTimers(); + } + }); + it("strips seeded models only from empty draft sessions when upgrading storage", async () => { vi.useFakeTimers(); try { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 5bdd823b4d88..388a0c377fc7 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -265,6 +265,14 @@ type ProviderOptionSelectionsByProvider = Partial< Record> >; +const LEGACY_MODEL_OPTION_PROVIDER_KEYS = [ + "codex", + "claudeAgent", + "cursor", + "devin", + "opencode", +] as const; + type LegacyCodexFields = { effort?: unknown; codexFastMode?: unknown; @@ -970,7 +978,7 @@ function normalizeProviderModelOptions( ): ProviderOptionSelectionsByProvider | null { const candidate = value && typeof value === "object" ? (value as Record) : null; const result: ProviderOptionSelectionsByProvider = {}; - for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const providerKey of LEGACY_MODEL_OPTION_PROVIDER_KEYS) { const selections = coerceProviderOptionSelections(candidate?.[providerKey]); if (selections) { result[providerKey] = selections; @@ -1129,7 +1137,7 @@ function legacyToModelSelectionByProvider( ): Partial> { const result: Partial> = {}; if (modelOptions) { - for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const provider of LEGACY_MODEL_OPTION_PROVIDER_KEYS) { const options = modelOptions[provider]; if (options && options.length > 0) { const driverKind = ProviderDriverKind.make(provider); @@ -3076,7 +3084,7 @@ const composerDraftStore = create()( } const base = existing ?? createEmptyThreadDraft(); const nextMap = { ...base.modelSelectionByProvider }; - for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const provider of LEGACY_MODEL_OPTION_PROVIDER_KEYS) { if (!modelOptions || !(provider in modelOptions)) continue; const opts = modelOptions[provider]; const driverKind = ProviderDriverKind.make(provider); diff --git a/apps/web/src/lib/contextWindow.test.ts b/apps/web/src/lib/contextWindow.test.ts index b87c0664403e..6e8a52d6c871 100644 --- a/apps/web/src/lib/contextWindow.test.ts +++ b/apps/web/src/lib/contextWindow.test.ts @@ -1,7 +1,17 @@ import { describe, expect, it } from "vite-plus/test"; -import { EventId, type OrchestrationThreadActivity, TurnId } from "@t3tools/contracts"; +import { + EventId, + ProviderDriverKind, + ProviderInstanceId, + type OrchestrationThreadActivity, + TurnId, +} from "@t3tools/contracts"; -import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "./contextWindow"; +import { + deriveKnownContextWindowSnapshot, + deriveLatestContextWindowSnapshot, + formatContextWindowTokens, +} from "./contextWindow"; function makeActivity(id: string, kind: string, payload: unknown): OrchestrationThreadActivity { return { @@ -83,4 +93,104 @@ describe("contextWindow", () => { expect(snapshot?.usedTokens).toBe(81_659); expect(snapshot?.totalProcessedTokens).toBe(748_126); }); + + it("uses the selected Devin model catalog limit before ACP usage arrives", () => { + const instanceId = ProviderInstanceId.make("devin"); + const snapshot = deriveKnownContextWindowSnapshot({ + selection: { instanceId, model: "glm-5-2" }, + providers: [ + { + instanceId, + driver: ProviderDriverKind.make("devin"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-03-23T00:00:00.000Z", + models: [ + { + slug: "glm-5-2", + name: "GLM-5.2", + isCustom: false, + capabilities: { optionDescriptors: [] }, + contextWindowTokens: 200_000, + }, + ], + slashCommands: [], + skills: [], + }, + ], + updatedAt: "2026-03-23T00:00:00.000Z", + }); + + expect(snapshot).toMatchObject({ + usedTokens: 0, + maxTokens: 200_000, + remainingTokens: 200_000, + usedPercentage: 0, + model: "glm-5-2", + }); + }); + + it("uses the selected context-window option instead of the catalog maximum", () => { + const instanceId = ProviderInstanceId.make("devin"); + const snapshot = deriveKnownContextWindowSnapshot({ + selection: { + instanceId, + model: "glm-5-2", + options: [{ id: "contextWindow", value: "200k" }], + }, + providers: [ + { + instanceId, + driver: ProviderDriverKind.make("devin"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-03-23T00:00:00.000Z", + models: [ + { + slug: "glm-5-2", + name: "GLM-5.2", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "contextWindow", + label: "Context window", + type: "select", + options: [ + { id: "200k", label: "200K" }, + { id: "1m", label: "1M" }, + ], + }, + ], + }, + contextWindowTokens: 1_000_000, + }, + ], + slashCommands: [], + skills: [], + }, + ], + updatedAt: "2026-03-23T00:00:00.000Z", + }); + + expect(snapshot?.maxTokens).toBe(200_000); + expect(snapshot?.remainingTokens).toBe(200_000); + }); + + it("does not invent a context meter when the catalog has no limit", () => { + const instanceId = ProviderInstanceId.make("devin"); + expect( + deriveKnownContextWindowSnapshot({ + selection: { instanceId, model: "custom" }, + providers: [], + updatedAt: "2026-03-23T00:00:00.000Z", + }), + ).toBeNull(); + }); }); diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 4e2ee139cd85..13098622b67c 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -1,4 +1,9 @@ -import type { OrchestrationThreadActivity, ThreadTokenUsageSnapshot } from "@t3tools/contracts"; +import type { + ModelSelection, + OrchestrationThreadActivity, + ServerProvider, + ThreadTokenUsageSnapshot, +} from "@t3tools/contracts"; function asRecord(value: unknown): Record | null { return value && typeof value === "object" ? (value as Record) : null; @@ -12,6 +17,22 @@ function asBoolean(value: unknown): boolean | null { return typeof value === "boolean" ? value : null; } +/** + * Context-window options are provider-defined strings (Devin uses values such + * as `200k` and `1m`). Parse the common forms so the meter reflects the active + * option rather than always using the model's largest advertised limit. + */ +function parseContextWindowOption(value: unknown): number | null { + if (typeof value !== "string") return null; + const match = /^(\d+(?:\.\d+)?)\s*(k|m)?$/iu.exec(value.trim()); + if (!match) return null; + const amount = Number(match[1]); + if (!Number.isFinite(amount) || amount <= 0) return null; + const multiplier = match[2]?.toLowerCase() === "m" ? 1_000_000 : match[2] ? 1_000 : 1; + const tokens = Math.round(amount * multiplier); + return tokens > 0 ? tokens : null; +} + type NullableContextWindowUsage = { readonly [Key in keyof ThreadTokenUsageSnapshot]: undefined extends ThreadTokenUsageSnapshot[Key] ? Exclude | null @@ -25,6 +46,30 @@ export type ContextWindowSnapshot = NullableContextWindowUsage & { readonly updatedAt: string; }; +/** Map a provider driver kind to a user-facing display name. */ +export function formatProviderDisplayName(provider: string | null | undefined): string { + if (!provider) return "This agent"; + switch (provider) { + case "claudeAgent": + case "claude": + return "Claude"; + case "codex": + return "Codex"; + case "devin": + return "Devin"; + case "cursor": + return "Cursor"; + case "opencode": + return "OpenCode"; + default: { + // Title-case unknown driver kinds so they read reasonably. + const trimmed = provider.replace(/Agent$/i, "").trim(); + if (trimmed.length === 0) return provider; + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1); + } + } +} + export function deriveLatestContextWindowSnapshot( activities: ReadonlyArray, ): ContextWindowSnapshot | null { @@ -51,22 +96,30 @@ export function deriveLatestContextWindowSnapshot( usedTokens, totalProcessedTokens: asFiniteNumber(payload?.totalProcessedTokens), maxTokens, + model: typeof payload?.model === "string" ? payload.model : null, + providerSessionId: + typeof payload?.providerSessionId === "string" ? payload.providerSessionId : null, remainingTokens, usedPercentage, remainingPercentage, inputTokens: asFiniteNumber(payload?.inputTokens), cachedInputTokens: asFiniteNumber(payload?.cachedInputTokens), + cacheCreationTokens: asFiniteNumber(payload?.cacheCreationTokens), outputTokens: asFiniteNumber(payload?.outputTokens), reasoningOutputTokens: asFiniteNumber(payload?.reasoningOutputTokens), lastUsedTokens: asFiniteNumber(payload?.lastUsedTokens), lastInputTokens: asFiniteNumber(payload?.lastInputTokens), lastCachedInputTokens: asFiniteNumber(payload?.lastCachedInputTokens), + lastCacheCreationTokens: asFiniteNumber(payload?.lastCacheCreationTokens), lastOutputTokens: asFiniteNumber(payload?.lastOutputTokens), lastReasoningOutputTokens: asFiniteNumber(payload?.lastReasoningOutputTokens), toolUses: asFiniteNumber(payload?.toolUses), durationMs: asFiniteNumber(payload?.durationMs), compactsAutomatically: asBoolean(payload?.compactsAutomatically) ?? false, autoCompactThreshold: asFiniteNumber(payload?.autoCompactThreshold), + lastCostUsd: asFiniteNumber(payload?.lastCostUsd), + sessionCostUsd: asFiniteNumber(payload?.sessionCostUsd), + costCurrency: typeof payload?.costCurrency === "string" ? payload.costCurrency : null, updatedAt: activity.createdAt, }; } @@ -74,6 +127,66 @@ export function deriveLatestContextWindowSnapshot( return null; } +/** + * Build a zero-usage context snapshot from provider catalog metadata. + * + * ACP providers are allowed to omit usage notifications until the first turn + * (and some older Devin CLI builds never send a usage update at all). Keeping + * the known model limit visible avoids hiding the context control merely + * because no token event has arrived yet. A real `context-window.updated` + * activity always takes precedence in the caller. + */ +export function deriveKnownContextWindowSnapshot(input: { + readonly selection: ModelSelection | null | undefined; + readonly providers: ReadonlyArray; + readonly updatedAt: string; +}): ContextWindowSnapshot | null { + const selection = input.selection; + if (!selection) return null; + + const provider = input.providers.find( + (candidate) => candidate.instanceId === selection.instanceId, + ); + const model = provider?.models.find((candidate) => candidate.slug === selection.model); + const selectedContextWindow = selection.options?.find( + (option) => option.id === "contextWindow", + )?.value; + const maxTokens = parseContextWindowOption(selectedContextWindow) ?? model?.contextWindowTokens; + if (typeof maxTokens !== "number" || !Number.isFinite(maxTokens) || maxTokens <= 0) { + return null; + } + + return { + usedTokens: 0, + totalProcessedTokens: 0, + maxTokens, + model: selection.model, + providerSessionId: null, + remainingTokens: maxTokens, + usedPercentage: 0, + remainingPercentage: 100, + inputTokens: null, + cachedInputTokens: null, + cacheCreationTokens: null, + outputTokens: null, + reasoningOutputTokens: null, + lastUsedTokens: null, + lastInputTokens: null, + lastCachedInputTokens: null, + lastCacheCreationTokens: null, + lastOutputTokens: null, + lastReasoningOutputTokens: null, + toolUses: null, + durationMs: null, + compactsAutomatically: false, + autoCompactThreshold: null, + lastCostUsd: null, + sessionCostUsd: null, + costCurrency: null, + updatedAt: input.updatedAt, + }; +} + export function formatContextWindowTokens(value: number | null): string { if (value === null || !Number.isFinite(value)) { return "0"; diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 401934bce5ba..ce9ab3028012 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -7,7 +7,12 @@ import { type OrchestrationThreadActivity, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; +import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; import { resolveWorkEntryToolPresentation } from "@t3tools/client-runtime/work-log/presentation"; +import { + hasToolActivityData, + toolActivityDataBody, +} from "@t3tools/client-runtime/work-log/tool-presentation"; import { createMessageAttachmentPreviewProjector, @@ -61,6 +66,33 @@ function makeActivity(overrides: { }; } +describe("pending approvals", () => { + it("preserves Devin's advertised options without adding unavailable session actions", () => { + const options = [ + { decision: "accept", label: "Allow once" }, + { decision: "decline", label: "Reject" }, + { decision: "cancel", label: "Cancel" }, + ]; + const requested = makeActivity({ + kind: "approval.requested", + payload: { + requestId: "devin-permission", + requestType: "command_execution_approval", + options, + }, + }); + + expect(derivePendingRequests([requested]).approvals).toEqual([ + { + requestId: "devin-permission", + requestKind: "command", + createdAt: requested.createdAt, + options, + }, + ]); + }); +}); + describe("deriveActivePlanState", () => { it("returns the latest plan update for the active turn", () => { const activities: OrchestrationThreadActivity[] = [ @@ -825,6 +857,46 @@ describe("deriveWorkLogEntries", () => { expect(entry?.toolLifecycleStatus).toBe("completed"); }); + it("presents canonical ACP resource URI and text for generic tool activities", () => { + const data = { + toolCallId: "devin-resource-tool", + kind: "other", + resource: { + uri: "urn:acp:fixture:resource-link", + name: "schema fixture", + description: "typed protocol fixture", + mimeType: "text/markdown", + text: "Embedded resource notes", + }, + content: [ + { + type: "content", + content: { type: "text", text: "ordinary tool output" }, + }, + ], + }; + const [entry] = deriveWorkLogEntries([ + makeActivity({ + id: "devin-resource-tool", + kind: "tool.completed", + summary: "Resource fixture", + payload: { + itemType: "dynamic_tool_call", + status: "completed", + title: "Resource fixture", + data, + }, + }), + ]); + + expect(entry?.toolData).toBe(data); + expect(entry).toBeDefined(); + if (!entry) return; + expect(hasToolActivityData(entry)).toBe(true); + expect(toolActivityDataBody(entry)).toContain("urn:acp:fixture:resource-link"); + expect(toolActivityDataBody(entry)).toContain("Embedded resource notes"); + }); + it("preserves MCP server, tool, arguments, and results for expanded display", () => { const item = { type: "mcpToolCall", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index bbef3a4945a6..6f360c1440e6 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -16,7 +16,10 @@ import { workLogEntryIsToolLike, type WorkLogToolLifecycleStatus, } from "@t3tools/client-runtime/work-log/presentation"; -import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; +import { + extractToolActivityData, + extractToolActivityPresentation, +} from "@t3tools/client-runtime/work-log/tool-presentation"; import { isToolLifecycleItemType, type AssetResource, @@ -24,6 +27,7 @@ import { type OrchestrationThreadActivity, type OrchestrationProposedPlanId, type ToolLifecycleItemType, + ProviderDriverKind, type ThreadId, type TurnId, } from "@t3tools/contracts"; @@ -43,6 +47,49 @@ export type { PendingApproval, PendingUserInput } from "@t3tools/client-runtime/ export { formatDuration } from "@t3tools/shared/orchestrationTiming"; +export type ProviderPickerKind = ProviderDriverKind; + +export const PROVIDER_OPTIONS: Array<{ + value: ProviderPickerKind; + label: string; + available: boolean; + /** Shown on the model picker sidebar when relevant */ + pickerSidebarBadge?: "new" | "soon"; +}> = [ + { value: ProviderDriverKind.make("codex"), label: "Codex", available: true }, + { value: ProviderDriverKind.make("claudeAgent"), label: "Claude", available: true }, + { + value: ProviderDriverKind.make("opencode"), + label: "OpenCode", + available: true, + pickerSidebarBadge: "new", + }, + { + value: ProviderDriverKind.make("cursor"), + label: "Cursor", + available: true, + pickerSidebarBadge: "new", + }, + { + value: ProviderDriverKind.make("devin"), + label: "Devin", + available: true, + pickerSidebarBadge: "new", + }, + { + value: ProviderDriverKind.make("grok"), + label: "Grok", + available: true, + pickerSidebarBadge: "new", + }, + { + value: ProviderDriverKind.make("antigravity"), + label: "Antigravity", + available: true, + pickerSidebarBadge: "new", + }, +]; + export { workEntryDisplayIndicatesToolFailure, workEntryIndicatesToolSuccess, @@ -581,12 +628,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (toolPresentation.toolSource) { entry.toolSource = toolPresentation.toolSource; } - if (itemType === "mcp_tool_call") { - const data = asRecord(payload?.data); - const toolData = typeof data?.toolName === "string" ? (data.item ?? data) : data?.item; - if (toolData !== undefined) { - entry.toolData = toolData; - } + const toolData = extractToolActivityData(payload); + if (toolData !== undefined) { + entry.toolData = toolData; } if (itemType) { entry.itemType = itemType; diff --git a/docs/README.md b/docs/README.md index 4e6f82bfb826..83ca551de557 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,7 +17,7 @@ - [Remote access](./user/remote-access.md) - [Running in the background](./user/background-service.md) - [Updating T3 Code](./user/updating.md) -- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) +- Provider guides: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [Devin](./user/providers-devin.md) · [OpenCode](./user/providers-opencode.md) · [Antigravity](./user/providers-antigravity.md) --- diff --git a/docs/internals/providers.md b/docs/internals/providers.md index ec40c49810dc..c1311c70d91b 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -93,6 +93,40 @@ checkpoints but cannot roll back its conversation. The [checkpoint boundary](./o therefore rejects revert before touching files. Native permission and question option IDs must also survive normalization; a display label is not necessarily a valid reply. +## Devin ACP + +Devin runs its CLI as an ACP subprocess (see +[DevinDriver](../../apps/server/src/provider/Drivers/DevinDriver.ts)). Model discovery follows the +same startup and settings-change refresh path as the other managed providers. Its CLI family +variants are normalized into parent model rows with reasoning, speed, and context-window option +descriptors before the snapshot is published. Devin snapshots carry a provider-owned model catalog +version; when that version changes, the registry replaces (rather than merges) the previous +snapshot and rewrites the per-instance status cache. + +Devin ACP telemetry is normalized in `DevinAdapter` from both `usage_update` notifications (context +window and cumulative session cost) and prompt response usage (per-turn token deltas). The adapter +emits the shared `thread.token-usage.updated` event, which feeds the composer context meter and the +Usage service. Usage scans canonical `events..log` files only; native ACP protocol lines +are retained for diagnostics but are not treated as billing records. When explicitly configured, +the Usage service also reads Devin's organization consumption endpoint with a server-only service +key and organization ID. That ACU result is an optional `UsageSummary.accountUsage` field and +remains separate from token/cost buckets. + +The Devin capability boundary is: + +| Capability | Status | Boundary | +| ------------------ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| MCP baseline | Local transport verified; live Devin handoff failing | `DevinAdapter` sends the existing T3 MCP HTTP server declaration with provider-scoped bearer auth. Direct local MCP `initialize` and `tools/list` work. The opt-in real Devin turn is not passing: it makes zero T3 MCP broker requests and does not return the marker. | +| Resources | Transport and projection ready; live emission unverified | A pure typed ACP normalizer, adapter-boundary sanitization, canonical event retention, and generic web/mobile projection coverage are present. The installed Devin CLI emitted no resource blocks, so live Devin resource emission and rendering are unverified. | +| Elicitation | Unsupported | Public ACP form requests do not provide a per-elicitation correlation ID, and the installed Devin CLI emitted none; no handler is wired. | +| Child-agent events | Unsupported | No documented or typed stable child-agent stream, identity, lifecycle, or parent linkage was observed. Ordinary tools remain generic. Standard ACP does not guarantee a child-agent event stream, so the Agents panel cannot promise Devin child-agent telemetry. | + +These capabilities reuse the provider-neutral web, desktop, mobile, and remote paths. No Devin-specific +UI or contract was added. + +The focused Devin MCP probe is run with `vp run test:devin-smoke`. It is opt-in and skips during +normal tests. If `devin` is not on `PATH`, set `T3_DEVIN_BINARY_PATH` to the CLI binary. + ## Attachments and stored history Attachments live outside the project workspace. [ProviderService](../../apps/server/src/provider/Layers/ProviderService.ts) diff --git a/docs/superpowers/specs/2026-09-07-devin-acp-capability-parity-design.md b/docs/superpowers/specs/2026-09-07-devin-acp-capability-parity-design.md new file mode 100644 index 000000000000..59c7d36ec752 --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-devin-acp-capability-parity-design.md @@ -0,0 +1,185 @@ +# Devin ACP Capability Parity Design + +**Status:** Draft for written-spec review\ +**Date:** 2026-09-07 + +## Goal + +Bring Devin's MCP and richer ACP behavior to parity with the existing T3 provider experience across the server, shared contracts, client runtime, web, mobile, desktop, and remote connection modes. + +The implementation must reuse T3's existing provider-neutral activity, pending-request, and subagent models. Devin-specific code belongs at the ACP adapter boundary. + +## Current baseline + +The branch already contains the basic Devin MCP path: + +- `DevinAdapter` reads the thread-scoped `McpProviderSession` and passes the T3 MCP HTTP server to Devin ACP through `session/new` and `session/load`. +- `McpSessionRegistry` issues provider-scoped bearer credentials and revokes them with the provider session. +- `McpHttpServer` exposes the authenticated `/mcp` endpoint and the existing preview toolkit. +- `scripts/devin-mcp-smoke.ts` runs an opt-in live test against the real Devin CLI and verifies `tools/list`, `preview_status`, and one MCP-enabled turn. +- The shared runtime already represents MCP tool activity, pending requests, and subagent tasks for other providers. + +The Task 2 skill work did not alter this MCP path. The next work is a parity and capability expansion, not a second MCP transport. + +## Scope + +The work is divided into four independently testable phases. + +### Phase 1: MCP acceptance baseline + +Harden and run the existing real Devin MCP smoke path. The acceptance path must prove: + +1. Devin ACP starts with the T3 MCP server configuration. +2. The MCP endpoint accepts the provider-scoped bearer credential. +3. MCP initialization and `tools/list` succeed. +4. Devin calls `preview_status` exactly once through T3's MCP broker. +5. The assistant completes the turn with the expected response. +6. The MCP session and provider credential are revoked during cleanup. + +This phase is opt-in and must not become part of the normal test suite because it requires a real authenticated Devin CLI and consumes a real provider turn. + +### Phase 2: Resource parity + +Support Devin resource content using the same representation and display behavior already used by other providers. + +The adapter will first capture real Devin ACP/MCP payloads. It will then normalize supported resource content into the existing provider runtime item/tool data shape. Existing text, tool-call, and activity presentation remains intact when a resource block is absent or unsupported. + +The default behavior is not a new Devin-specific resource card or resource browser. If the shared runtime cannot retain a required resource field, add an optional backward-compatible contract field and teach the existing web/mobile presentation layer to consume it for all providers that supply it. + +Malformed or unsupported resource blocks are nonfatal. The surrounding turn and ordinary text/tool activity must remain usable, and bounded raw data may be retained for diagnostics. + +### Phase 3: Elicitation parity + +Wire Devin ACP elicitation requests into T3's existing pending-request flow. + +- MCP-originated elicitation maps to the existing `mcp-elicitation` request kind and its current approval presentation when the payload has approval-style semantics. +- General ACP questions map to the existing user-input request model when the payload contains structured questions and answers. +- Web and mobile reuse the current pending request components and shared response commands. +- Responses are translated back to the native Devin ACP response shape by the adapter. +- Cancellation, interruption, session stop, model restart, connection failure, stale responses, and malformed requests settle safely without leaving a blocked turn. + +No Devin-specific elicitation UI is introduced. + +### Phase 4: Subagent parity + +Capture and inspect Devin's native subagent or child-agent events. Only events that expose stable identity, lifecycle, and parent linkage are mapped into the existing `task.*` runtime events and `subagentRuntime` fold. + +- Stable start, progress, and terminal signals become existing task activity. +- Repeated lifecycle signals are idempotent. +- Parent/child linkage is preserved when Devin supplies it. +- Incomplete or unknown events remain diagnostic data and do not create misleading Agents-panel entries. +- If the Devin CLI does not expose a stable child-agent stream, the provider reports the capability as unavailable rather than synthesizing subagents from ordinary tool calls. + +No Devin-specific Agents panel is introduced. + +## Non-goals + +- Replacing the existing T3 MCP server or credential model. +- Creating a separate Devin-only contract family for events already represented by T3 contracts. +- Adding a new resource browser, resource card, elicitation dialog, or Agents panel. +- Changing MCP, ACP, or subagent behavior for other providers unless a narrowly scoped provider-neutral fix is required. +- Treating ordinary tool calls as subagent events without stable Devin identity data. +- Claiming support for a Devin capability that is not demonstrated by a real CLI payload or a protocol fixture derived from one. + +## Architecture and data flow + +```text +Devin ACP / MCP + | + v +DevinAdapter + - ACP request handlers + - Devin payload normalization + - MCP credential/session wiring + | + v +ProviderRuntimeEvent + | + v +Server ingestion and projections + | + +--> client-runtime activity/pending-request/subagent folds + | + +--> Web existing work-log and request UI + | + +--> Mobile existing activity and request UI +``` + +`AcpSessionRuntime` remains a transport and protocol seam. It may gain provider-neutral hooks only when the same behavior is useful to more than Devin and can be expressed without provider conditionals. + +The Devin adapter owns native method names, payload validation, lifecycle cleanup, and response conversion. The server's canonical event and request shapes remain the boundary shared by local, remote, relay, tunnel, desktop, web, and mobile clients. + +## Compatibility, failure, and security rules + +### Provider and client compatibility + +- Existing optional contract fields remain optional so older clients can read newer activity safely. +- Existing web and mobile components remain the presentation path. +- Desktop inherits web behavior. +- Remote connections receive the same typed events as local connections. +- Other provider adapters remain unchanged unless a shared ACP/runtime improvement is proven necessary. + +### Failure behavior + +- The MCP smoke test reports clear setup, authentication, protocol, tool-call, and cleanup failures. +- Resource decode failures do not fail the surrounding turn. +- Elicitation failures become visible provider request failures and never leave a permanently pending request. +- Subagent events with insufficient identity are ignored for user-facing projections and retained only within bounded diagnostics. +- Session stop, interruption, model restart, and connection loss settle pending approvals and user inputs using the existing cleanup behavior. + +### Security and performance + +- Provider-scoped MCP bearer credentials remain hashed in the registry and are revoked when their provider session or thread ends. +- Logs never include bearer tokens, complete environment values, or complete user prompts. +- Native payloads and tool output are bounded before persistence or runtime emission. +- No discovery or probe runs once per ordinary turn. +- Live tests are opt-in and never run against the user's shared T3 home. + +## Testing and acceptance + +### Server and provider tests + +- Devin ACP fixtures cover resource content, elicitation requests/responses, and every supported subagent event shape. +- Devin adapter tests prove normalization, response round trips, cleanup, stale-request handling, duplicate lifecycle handling, and unsupported-event behavior. +- MCP session registry and HTTP server tests continue to cover credential scope, authorization, tool listing, and revocation. +- Existing Devin skill, ACP, provider, registry, usage, and text-generation tests remain green. + +### Contract and client-runtime tests + +- Contract schemas decode new optional fields and reject unsafe malformed data. +- Provider runtime ingestion persists the canonical events correctly. +- Pending-request folds preserve the existing `mcp-elicitation` and user-input behavior. +- Subagent folds derive the same Agent-panel model used by other providers. + +### Web and mobile tests + +- Existing work-log components display Devin MCP/resource activity through the shared presentation path. +- Existing pending-request components render and resolve Devin elicitation without a Devin-specific branch. +- Existing Agents-panel components render mapped Devin subagents and ignore unsupported events. +- Web and mobile typechecks pass independently. + +### Live acceptance + +When the real Devin CLI supports the relevant capability, add an opt-in live probe for it. The existing smoke test remains the required baseline for Phase 1. Resource, elicitation, and subagent live tests must use real captured protocol behavior; fixtures alone cannot claim provider support. + +### Final verification gates + +Before a phase is considered complete: + +- Focused tests for the changed server, contracts, client-runtime, web, and mobile paths pass. +- Relevant server, web, and mobile typechecks pass. +- `git diff --check` is clean and no conflict markers exist in source. +- The live Devin MCP smoke test passes when the local CLI is available. +- No unrelated provider behavior changes. +- Any unsupported Devin capability is reported explicitly. + +## Rollout order + +Land and verify each phase independently: + +1. MCP smoke baseline. +2. Resource parity. +3. Elicitation parity. +4. Subagent parity or an explicit unsupported-capability result based on protocol evidence. + +Each phase should produce a reviewable commit and a focused verification report before the next phase begins. diff --git a/docs/user/install.md b/docs/user/install.md index 17e9291bf1a0..0c820bdf55ee 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -67,14 +67,15 @@ and enable the provider you want. Installation, login, and configuration belong to that environment's machine, even when you connect from a phone or another computer. -| Provider | Install and authenticate | -| ----------- | -------------------------------------------------------------------------------------------- | -| Codex | Install [Codex CLI](https://developers.openai.com/codex/cli), then run `codex login`. | -| Claude | Install [Claude Code](https://claude.com/product/claude-code), then run `claude auth login`. | -| Cursor | Install [Cursor CLI](https://cursor.com/cli), then run `agent login`. | -| Grok Build | Install [Grok Build CLI](https://x.ai/cli), then run `grok login`. | -| OpenCode | Install [OpenCode](https://opencode.ai), then run `opencode auth login`. | -| Antigravity | Install and sign in with Google from T3 Code's provider settings. | +| Provider | Install and authenticate | +| ----------- | ------------------------------------------------------------------------------------------------------ | +| Codex | Install [Codex CLI](https://developers.openai.com/codex/cli), then run `codex login`. | +| Claude | Install [Claude Code](https://claude.com/product/claude-code), then run `claude auth login`. | +| Cursor | Install [Cursor CLI](https://cursor.com/cli), then run `agent login`. | +| Devin | Install the [Devin CLI](https://docs.devin.ai/work-with-devin/devin-cli), then run `devin auth login`. | +| Grok Build | Install [Grok Build CLI](https://x.ai/cli), then run `grok login`. | +| OpenCode | Install [OpenCode](https://opencode.ai), then run `opencode auth login`. | +| Antigravity | Install and sign in with Google from T3 Code's provider settings. | Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set its **Binary path** in provider settings, especially when using a version manager. diff --git a/docs/user/providers-devin.md b/docs/user/providers-devin.md new file mode 100644 index 000000000000..fb0fcade9eac --- /dev/null +++ b/docs/user/providers-devin.md @@ -0,0 +1,77 @@ +# Devin + +T3 Code can run Devin through the Agent Client Protocol (ACP) exposed by the Devin CLI. + +## Setup + +1. Install the [Devin CLI](https://docs.devin.ai/work-with-devin/devin-cli). +2. Authenticate in a terminal: + + ```bash + devin auth login + ``` + +3. Restart T3 Code so it inherits the updated `PATH`, then enable **Devin** in Settings. + +T3 Code starts `devin acp` for each session. The model picker is populated from +`devin models list --format json`, including the `adaptive` model. + +Model families are refreshed when T3 Code starts and when Devin settings change. Reasoning level, +speed, and context-window variants appear as options on a single family row; restarting T3 Code +after upgrading this integration also refreshes any older cached model list. + +If T3 Code cannot find the executable, set **Binary path** in the Devin provider settings to the +absolute path of the `devin` executable. On Windows, an existing T3 Code process may need to be +restarted after the CLI installer updates your user `PATH`. + +## Usage and context window + +The **Usage** page includes token and cost records returned by Devin ACP prompt responses. These +records are read from the T3 server's canonical provider event logs, so they are available for +Devin sessions run through that server and remain local to the environment. The page labels this +source as **Devin ACP** and supports model/provider filtering and CSV export. + +Devin's ACP `usage_update` notification also powers the context-window meter beside the composer. +Open the meter to see the current context percentage, used/max tokens, total processed tokens, and +provider-reported turn/session estimates when the CLI supplies them. A model switch keeps the T3 +thread identity and asks Devin to restore the prior session through ACP `session/load`. + +The Usage page's local token/cost estimate is not Devin plan billing. If you have an Enterprise +organization, you can optionally add a separate `cog_...` Devin service-user key with +`ViewOrgConsumption` permission and the organization ID as sensitive provider environment +variables named `DEVIN_API_KEY` and `DEVIN_ORG_ID`. T3 calls Devin's organization consumption API +on the server and shows returned ACUs in a separate card; ACUs are never mixed into token costs. +The ordinary Devin CLI login token is not reused for this request. Without those optional +variables, the page continues to show the local estimate and explains that account ACUs are not +configured. Sessions outside T3 remain visible in Devin's Billing/Session Insights views. + +Provider event logs are retained for a limited period by the server's observability policy. Export +or review a Usage window before rotating logs if you need a longer local record. + +## Capability limits + +Devin sessions use T3's shared ACP integration paths. The current Devin CLI does not provide +a supported child-agent event stream or elicitation flow, and T3 preserves supported resource +metadata through the generic activity path; live Devin resource emission/rendering has not been +verified because the installed CLI emitted no resource blocks. These capabilities are therefore not +available as Devin-specific controls in T3 Code. + +## Remote servers + +When you pair a phone or hosted web app with a remote T3 server, Devin runs on the remote machine. +Install and authenticate the Devin CLI there, and configure its binary path in that server's +provider settings; installing Devin only on the client device is not sufficient. A production build +can serve the modified web UI, but it still needs a running compatible T3 server (`t3 serve`) for +ACP sessions, Usage scanning, and event-log telemetry. + +## Permissions + +T3 Code maps its permission modes to Devin ACP modes: + +- **Ask** uses Devin's `ask` mode. +- **Auto-accept edits** uses `accept-edits`. +- **Auto** uses `smart`. +- **Full access** uses `bypass`. +- Plan interactions use Devin's `plan` mode. + +Devin support is currently marked Early Access. diff --git a/docs/user/usage.md b/docs/user/usage.md index 4be084ea299e..cda799269332 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -2,10 +2,17 @@ ## Understand your usage -**Usage** combines Codex, Claude Code, and Grok Build session history from your connected +**Usage** combines Codex, Claude Code, Grok Build, and Devin ACP session history from your connected environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent cost. These estimates are not your subscription bill. +Devin ACP records come from canonical T3 provider event logs and cover sessions driven through the +selected T3 server. Use the provider filter above the breakdown to focus the chart and tables, and +use the download button to export the current window as CSV. The page can optionally show official +Devin organization ACUs in a separate section when a `cog_...` service key with +`ViewOrgConsumption` permission and `DEVIN_ORG_ID` are configured on the server; ACUs are never +combined with token-cost estimates. The normal CLI login is not an account-usage credential. + Totals depend on the history available on each server. Grok turns without a saved completed-turn record are missing from the totals. diff --git a/package.json b/package.json index 4e5aca36d135..d8a9de42c269 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "test": "vp run -r test", "test:resource-monitor": "cargo test --locked --manifest-path native/resource-monitor/Cargo.toml", "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test", + "test:devin-smoke": "node scripts/devin-mcp-smoke.ts", "fmt": "vp fmt", "fmt:check": "vp fmt --check", "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts", @@ -50,6 +51,7 @@ "@effect/tsgo": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", + "@vitest/coverage-v8": "catalog:", "knip": "6.34.0", "vite-plus": "catalog:" }, diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index e441de32db48..c2558559022f 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -499,6 +499,7 @@ export function foldSubagentActivities( } const detail = asString(payload.detail); if (detail && agent.title === agent.id) agent.title = detail; + agent.recentActivity = appendActivity(agent.recentActivity, at, "Started"); agent.updatedAt = at; break; } @@ -537,6 +538,10 @@ export function foldSubagentActivities( } const error = asString(payload.error); if (error) agent.error = bounded(error); + const status = asString(payload.status); + if (status) { + agent.recentActivity = appendActivity(agent.recentActivity, at, `Status: ${status}`); + } agent.usage = mergeUsageMax(agent.usage, asUsage(payload.typedUsage)); agent.updatedAt = at; break; @@ -568,6 +573,9 @@ export function foldSubagentActivities( if (endedAt && !wasTerminal && isTerminalSubagentStatus(agent.status)) { agent.completedAt = endedAt; } + if (status) { + agent.recentActivity = appendActivity(agent.recentActivity, at, `Status: ${status}`); + } agent.updatedAt = at; break; } @@ -597,6 +605,11 @@ export function foldSubagentActivities( agent.result = agent.result ?? bounded(summary); } } + agent.recentActivity = appendActivity( + agent.recentActivity, + at, + summary ?? `Status: ${agent.status}`, + ); agent.usage = mergeUsageMax(agent.usage, asUsage(payload.typedUsage)); break; } @@ -609,6 +622,11 @@ export function foldSubagentActivities( agent.result = bounded(summary); } } + agent.recentActivity = appendActivity( + agent.recentActivity, + at, + summary ?? `Status: ${status}`, + ); agent.usage = mergeUsageMax(agent.usage, asUsage(payload.typedUsage)); agent.updatedAt = at; break; diff --git a/packages/client-runtime/src/work-log/toolPresentation.test.ts b/packages/client-runtime/src/work-log/toolPresentation.test.ts index fe88f15769c5..d25befe8fd46 100644 --- a/packages/client-runtime/src/work-log/toolPresentation.test.ts +++ b/packages/client-runtime/src/work-log/toolPresentation.test.ts @@ -1,8 +1,72 @@ import { describe, expect, it } from "@effect/vitest"; -import { extractToolActivityPresentation } from "./toolPresentation.ts"; +import { + extractToolActivityData, + extractToolActivityPresentation, + hasToolActivityData, + toolActivityDataBody, +} from "./toolPresentation.ts"; + +const devinResourceToolEvent = { + type: "item.completed", + payload: { + itemType: "dynamic_tool_call", + status: "completed", + title: "Resource fixture", + data: { + toolCallId: "devin-resource-tool", + kind: "other", + resource: { + uri: "urn:acp:fixture:resource-link", + name: "schema fixture", + description: "typed protocol fixture", + mimeType: "text/markdown", + }, + content: [ + { + type: "content", + content: { type: "text", text: "ordinary tool output" }, + }, + ], + }, + }, +} as const; describe("extractToolActivityPresentation", () => { + it("expands generic resource URI and embedded text without serializing collapsed rows", () => { + const resource = { uri: "urn:notes", text: "Resource notes" }; + const entry = { itemType: "dynamic_tool_call", toolData: { resource } }; + expect(hasToolActivityData(entry)).toBe(true); + expect(toolActivityDataBody(entry)).toBe(`Resource\n${JSON.stringify(resource, null, 2)}`); + expect(hasToolActivityData({ itemType: "dynamic_tool_call", toolData: {} })).toBe(false); + expect(toolActivityDataBody({ itemType: "dynamic_tool_call" })).toBeUndefined(); + }); + + it("retains canonical ACP resource metadata for generic tool activity", () => { + expect(extractToolActivityData(devinResourceToolEvent.payload)).toBe( + devinResourceToolEvent.payload.data, + ); + }); + + it("keeps MCP item extraction precedence when MCP data also has a resource", () => { + const item = { + type: "mcpToolCall", + server: "t3-code", + tool: "preview_status", + result: { content: [{ type: "text", text: "attached" }] }, + }; + const data = { + toolName: "mcp__t3_code__preview_status", + item, + resource: devinResourceToolEvent.payload.data.resource, + }; + + expect(extractToolActivityData({ itemType: "mcp_tool_call", data })).toBe(item); + expect(toolActivityDataBody({ itemType: "mcp_tool_call", toolData: item })).toBe( + `MCP call\n${JSON.stringify(item, null, 2)}`, + ); + }); + it("reads provider-neutral presentation fields", () => { expect( extractToolActivityPresentation({ diff --git a/packages/client-runtime/src/work-log/toolPresentation.ts b/packages/client-runtime/src/work-log/toolPresentation.ts index 9b007bf81c33..d85534e59e41 100644 --- a/packages/client-runtime/src/work-log/toolPresentation.ts +++ b/packages/client-runtime/src/work-log/toolPresentation.ts @@ -11,6 +11,38 @@ export interface ExtractedToolActivityPresentation { readonly toolSource?: ToolActivitySource; } +/** + * Keeps the bounded resource metadata that providers attach to an otherwise + * generic tool row. MCP rows retain their existing focused payload shape. + */ +export function extractToolActivityData(payloadValue: unknown): unknown { + const payload = asRecord(payloadValue); + const data = asRecord(payload?.data); + if (!data) return undefined; + if (payload?.itemType === "mcp_tool_call") { + return typeof data.toolName === "string" ? (data.item ?? data) : data.item; + } + return "resource" in data ? data : undefined; +} + +type ToolActivityDataEntry = { readonly itemType?: string; readonly toolData?: unknown }; + +/** Checks expandability without serializing payloads on collapsed rows. */ +export function hasToolActivityData(entry: ToolActivityDataEntry): boolean { + return entry.itemType === "mcp_tool_call" + ? entry.toolData !== undefined + : asRecord(asRecord(entry.toolData)?.resource) !== undefined; +} + +/** Shared expanded detail for MCP calls and bounded generic resource metadata. */ +export function toolActivityDataBody(entry: ToolActivityDataEntry): string | undefined { + if (!hasToolActivityData(entry)) return undefined; + if (entry.itemType === "mcp_tool_call") { + return `MCP call\n${JSON.stringify(entry.toolData, null, 2)}`; + } + return `Resource\n${JSON.stringify(asRecord(entry.toolData)?.resource, null, 2)}`; +} + function asRecord(value: unknown): Record | undefined { return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index bce1a766bc9b..dc3acc4fdb39 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -124,6 +124,16 @@ function canonicalSelectionsToLegacyObject( export const ModelCapabilities = Schema.Struct({ optionDescriptors: Schema.optional(Schema.Array(ProviderOptionDescriptor)), + /** + * Per-model input modalities. All default to `true` when absent so providers + * that do not populate them keep their existing attachment behavior. Text is + * always supported and has no field. A provider sets a field to `false` only + * to signal that the model rejects that modality, so the composer can disable + * or warn about the corresponding attachment. + */ + inputImages: Schema.optional(Schema.Boolean), + inputAudio: Schema.optional(Schema.Boolean), + inputFiles: Schema.optional(Schema.Boolean), }); export type ModelCapabilities = typeof ModelCapabilities.Type; @@ -143,9 +153,29 @@ export type CustomModelEntry = typeof CustomModelEntry.Type; export const CustomModelSetting = Schema.Union([Schema.String, CustomModelEntry]); export type CustomModelSetting = typeof CustomModelSetting.Type; +/** + * Provider-reported model pricing, expressed in USD per one million tokens. + * + * This is deliberately metadata rather than a billing record: it describes + * the rate advertised by a provider at probe time and is used to calculate a + * transparent local estimate when a transcript does not include a cost. + */ +export const ModelPricing = Schema.Struct({ + inputPerMillion: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)), + cachedInputPerMillion: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))), + cacheCreationPerMillion: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))), + outputPerMillion: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)), + /** The context advertised alongside the rate, when the provider supplies it. */ + contextWindowTokens: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(1))), + currency: Schema.optional(TrimmedNonEmptyString), + source: Schema.optional(TrimmedNonEmptyString), +}); +export type ModelPricing = typeof ModelPricing.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"); @@ -170,6 +200,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 af1baac74f9d..1dde17f037cb 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -316,19 +316,29 @@ export const ThreadTokenUsageSnapshot = Schema.Struct({ usedTokens: NonNegativeInt, totalProcessedTokens: Schema.optional(NonNegativeInt), maxTokens: Schema.optional(PositiveInt), + /** Canonical model/session attribution for providers that report it. */ + model: Schema.optional(TrimmedNonEmptyStringSchema), + providerSessionId: Schema.optional(TrimmedNonEmptyStringSchema), inputTokens: Schema.optional(NonNegativeInt), cachedInputTokens: Schema.optional(NonNegativeInt), + cacheCreationTokens: Schema.optional(NonNegativeInt), outputTokens: Schema.optional(NonNegativeInt), reasoningOutputTokens: Schema.optional(NonNegativeInt), lastUsedTokens: Schema.optional(NonNegativeInt), lastInputTokens: Schema.optional(NonNegativeInt), lastCachedInputTokens: Schema.optional(NonNegativeInt), + lastCacheCreationTokens: Schema.optional(NonNegativeInt), lastOutputTokens: Schema.optional(NonNegativeInt), lastReasoningOutputTokens: Schema.optional(NonNegativeInt), toolUses: Schema.optional(NonNegativeInt), durationMs: Schema.optional(NonNegativeInt), compactsAutomatically: Schema.optional(Schema.Boolean), autoCompactThreshold: Schema.optional(PositiveInt), + /** Provider-reported cost for the latest usage interval, when available. */ + lastCostUsd: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))), + /** Provider-reported cumulative cost for the session, when available. */ + sessionCostUsd: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))), + costCurrency: Schema.optional(TrimmedNonEmptyStringSchema), }); export type ThreadTokenUsageSnapshot = typeof ThreadTokenUsageSnapshot.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 3ea7bed8f1c4..f7f5fbaea80d 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -22,7 +22,7 @@ import { ResolvedKeybindingsConfig, } from "./keybindings.ts"; import { EditorId, FileManagerRevealKind, RemoteOpenTarget } from "./editor.ts"; -import { ModelCapabilities } from "./model.ts"; +import { ModelCapabilities, ModelPricing } from "./model.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ServerProviderUsageLimits, UsageLimitSourceSnapshots } from "./providerUsageLimits.ts"; import { ServerSettings } from "./settings.ts"; @@ -77,6 +77,12 @@ export const ServerProviderModel = Schema.Struct({ isDefault: Schema.optional(Schema.Boolean), isLegacy: Schema.optional(Schema.Boolean), capabilities: Schema.NullOr(ModelCapabilities), + /** Provider-advertised rate for this model/variant, when known. */ + pricing: Schema.optional(ModelPricing), + /** Rates for the hidden concrete variants represented by a grouped model. */ + pricingByVariant: Schema.optional(Schema.Record(Schema.String, ModelPricing)), + /** Provider-advertised context size for a standalone model. */ + contextWindowTokens: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(1))), }); export type ServerProviderModel = typeof ServerProviderModel.Type; @@ -222,6 +228,11 @@ export const ServerProvider = Schema.Struct({ // Human-readable reason populated when `availability === "unavailable"`. // Surfaces in the UI alongside the missing-driver affordance. unavailableReason: Schema.optional(TrimmedNonEmptyString), + // Optional provider-owned model catalog version. Providers that change the + // shape or identity of their model inventory can bump this value so stale + // persisted snapshots are ignored instead of being merged back into the + // newly discovered catalog. + modelCatalogVersion: Schema.optional(TrimmedNonEmptyString), models: Schema.Array(ServerProviderModel), slashCommands: Schema.Array(ServerProviderSlashCommand).pipe( Schema.withDecodingDefault(Effect.succeed([])), diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index f01d24c3ac8c..2016171a31ad 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -7,6 +7,7 @@ import { ClientSettingsPatch, ClaudeSettings, DEFAULT_SERVER_SETTINGS, + defaultEnabledForDriver, resolveProviderInstanceEnabled, ServerSettings, ServerSettingsPatch, @@ -441,10 +442,20 @@ describe("provider enabled defaults", () => { expect(decoded.providers.codex.enabled).toBe(true); expect(decoded.providers.claudeAgent.enabled).toBe(true); expect(decoded.providers.cursor.enabled).toBe(false); + expect(decoded.providers.devin.enabled).toBe(false); expect(decoded.providers.grok.enabled).toBe(false); expect(decoded.providers.opencode.enabled).toBe(false); }); + it("derives per-driver defaults from the settings schemas", () => { + expect(defaultEnabledForDriver(ProviderDriverKind.make("codex"))).toBe(true); + expect(defaultEnabledForDriver(ProviderDriverKind.make("cursor"))).toBe(false); + expect(defaultEnabledForDriver(ProviderDriverKind.make("devin"))).toBe(false); + expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false); + // Unknown fork drivers stay enabled; their own build decides otherwise. + expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true); + }); + it("keeps Cursor enabled when an existing user explicitly opted in", () => { const cursor = ProviderDriverKind.make("cursor"); const cursorId = ProviderInstanceId.make("cursor"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7e25c8444dbb..5a0a827fa627 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -601,6 +601,31 @@ export const CursorSettings = makeProviderSettingsSchema( ); export type CursorSettings = typeof CursorSettings.Type; +export const DevinSettings = makeProviderSettingsSchema( + { + // Devin is opt-in because it requires a separately installed and authenticated CLI. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("devin").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Devin CLI binary.", + providerSettingsForm: { placeholder: "devin", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type DevinSettings = typeof DevinSettings.Type; + export const GrokSettings = makeProviderSettingsSchema( { // Off by default (like Cursor and OpenCode): the binding is not yet @@ -962,6 +987,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({}))), antigravity: AntigravitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), @@ -1009,7 +1035,7 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un * through `DEFAULT_SERVER_SETTINGS`, so the schema's decoding default stays * the single source of truth. Unknown (fork) drivers default to enabled. */ -const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { +export const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { const legacyDefaults = DEFAULT_SERVER_SETTINGS.providers as Record< string, { readonly enabled?: boolean } | undefined @@ -1113,6 +1139,12 @@ const CursorSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), }); +const DevinSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const GrokSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -1192,6 +1224,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), antigravity: Schema.optionalKey(AntigravitySettingsPatch), diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index c36a4557c294..882a27eda423 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -3,7 +3,8 @@ * * Each environment scans the provider CLIs' own on-disk session transcripts * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`, - * `~/.grok/sessions/**\/updates.jsonl`) rather than relying on T3 Code's own + * `~/.grok/sessions/**\/updates.jsonl`, and T3's Devin ACP event logs) rather + * than relying on T3 Code's own * orchestration projections, so usage stays complete even for turns that were * never driven through T3 Code. This mirrors the approach `ccusage` takes. * @@ -12,6 +13,7 @@ * * @module usage */ +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; @@ -21,18 +23,18 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; * client renders partial coverage when an environment reports an older version * rather than failing the whole page. */ -export const USAGE_CONTRACT_VERSION = 5 as const; +export const USAGE_CONTRACT_VERSION = 6 as const; /** * Oldest {@link UsageSummary} version a current client will still merge. * - * v5 only adds `grok` to {@link UsageProviderKind}; v4 Claude/Codex buckets - * remain valid, so mixed-version environments keep those totals instead of - * treating every older server as stale. + * v5 adds Grok buckets; v6 adds Devin buckets and optional account usage. + * v4 Claude/Codex buckets remain valid, so mixed-version environments keep + * those totals instead of treating every older server as stale. */ export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; -export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok"]); +export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok", "devin"]); export type UsageProviderKind = typeof UsageProviderKind.Type; /** @@ -169,6 +171,39 @@ export const UsagePricing = Schema.Struct({ }); export type UsagePricing = typeof UsagePricing.Type; +/** + * Optional account-level Devin consumption returned by the official API. + * This is intentionally separate from UsageBucket: ACUs are account billing + * units, not token counts, and must never be mixed into the local estimate. + */ +export const UsageAccountConsumptionStatus = Schema.Literals([ + "available", + "notConfigured", + "forbidden", + "failed", +]); +export type UsageAccountConsumptionStatus = typeof UsageAccountConsumptionStatus.Type; + +export const UsageAccountConsumptionDay = Schema.Struct({ + day: UsageDay, + acus: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)), + byProduct: Schema.Record(Schema.String, Schema.Number).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), +}); +export type UsageAccountConsumptionDay = typeof UsageAccountConsumptionDay.Type; + +export const UsageAccountConsumption = Schema.Struct({ + provider: Schema.Literal("devin"), + status: UsageAccountConsumptionStatus, + source: TrimmedNonEmptyString, + fetchedAt: Schema.NullOr(Schema.String), + totalAcus: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)), + days: Schema.Array(UsageAccountConsumptionDay), + message: Schema.NullOr(TrimmedNonEmptyString), +}); +export type UsageAccountConsumption = typeof UsageAccountConsumption.Type; + export const UsageSummaryInput = Schema.Struct({ /** Inclusive first day of the window, in `timeZone`. */ sinceDay: UsageDay, @@ -197,6 +232,8 @@ export const UsageSummary = Schema.Struct({ buckets: Schema.Array(UsageBucket), sources: Schema.Array(UsageSource), pricing: UsagePricing, + /** Optional official Devin account consumption; absent when not configured. */ + accountUsage: Schema.optional(UsageAccountConsumption), /** Wall-clock cost of the scan, surfaced in diagnostics. */ scanDurationMs: NonNegativeInt, }); diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index d2c64e3d9458..038e18f4dc47 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; +import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, @@ -7,6 +7,7 @@ import { buildProviderOptionSelectionsFromDescriptors, createModelCapabilities, createModelSelection, + getModelInputCapabilities, getModelSelectionBooleanOptionValue, getModelSelectionStringOptionValue, getProviderOptionDescriptors, @@ -14,6 +15,8 @@ import { toCustomModelSetting, getProviderOptionBooleanSelectionValue, getProviderOptionStringSelectionValue, + normalizeCustomModelSlug, + normalizeModelSlug, } from "./model.ts"; const codexCaps: ModelCapabilities = createModelCapabilities({ @@ -165,6 +168,68 @@ describe("descriptor helpers", () => { }); }); +describe("model slug normalization", () => { + it("preserves exact custom slugs instead of expanding provider aliases", () => { + const cursor = ProviderDriverKind.make("cursor"); + + expect(normalizeModelSlug("opus-4.6", cursor)).toBe("claude-opus-4-6"); + expect(normalizeCustomModelSlug(" opus-4.6 ")).toBe("opus-4.6"); + }); +}); + +describe("input capabilities", () => { + it("omits capability fields when no modality is disabled", () => { + const caps = createModelCapabilities({ optionDescriptors: [] }); + expect(caps.inputImages).toBeUndefined(); + expect(caps.inputAudio).toBeUndefined(); + expect(caps.inputFiles).toBeUndefined(); + }); + + it("only records modalities that are explicitly disabled", () => { + const caps = createModelCapabilities({ + optionDescriptors: [], + inputImages: false, + inputAudio: false, + }); + expect(caps.inputImages).toBe(false); + expect(caps.inputAudio).toBe(false); + expect(caps.inputFiles).toBeUndefined(); + }); + + it("ignores explicit true so the wire shape stays minimal", () => { + const caps = createModelCapabilities({ + optionDescriptors: [], + inputImages: true, + inputFiles: true, + }); + expect(caps.inputImages).toBeUndefined(); + expect(caps.inputFiles).toBeUndefined(); + }); + + it("resolves absent fields to supported", () => { + expect(getModelInputCapabilities(undefined)).toEqual({ + images: true, + audio: true, + files: true, + }); + expect(getModelInputCapabilities({ optionDescriptors: [] })).toEqual({ + images: true, + audio: true, + files: true, + }); + }); + + it("resolves explicitly disabled fields to false", () => { + expect( + getModelInputCapabilities({ + optionDescriptors: [], + inputImages: false, + inputAudio: false, + }), + ).toEqual({ images: false, audio: false, files: true }); + }); +}); + describe("applyClaudePromptEffortPrefix", () => { it("keeps slash commands intact when ultrathink is selected", () => { expect(applyClaudePromptEffortPrefix("/compact", "ultrathink")).toBe("/compact"); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index d6cb26f25be3..2c94682c6188 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -21,9 +21,36 @@ export interface SelectableModelOption { export function createModelCapabilities(input: { optionDescriptors: ReadonlyArray; + inputImages?: boolean; + inputAudio?: boolean; + inputFiles?: boolean; }): ModelCapabilities { return { optionDescriptors: input.optionDescriptors.map(cloneDescriptor), + ...(input.inputImages === false ? { inputImages: false } : {}), + ...(input.inputAudio === false ? { inputAudio: false } : {}), + ...(input.inputFiles === false ? { inputFiles: false } : {}), + }; +} + +/** + * Resolved input modalities for a model. Absent fields default to `true` + * (supported) so providers that never populate them keep working. Text is + * always supported and is not represented here. + */ +export interface ModelInputCapabilities { + images: boolean; + audio: boolean; + files: boolean; +} + +export function getModelInputCapabilities( + caps: ModelCapabilities | null | undefined, +): ModelInputCapabilities { + return { + images: caps?.inputImages !== false, + audio: caps?.inputAudio !== false, + files: caps?.inputFiles !== false, }; } diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 6c706395c6ff..26b8fa507b42 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -2,13 +2,16 @@ import { USAGE_CONTRACT_VERSION, type EnvironmentId, type UsageBucket, + type UsageAccountConsumption, type UsageDay, type UsageProviderKind, - type UsageSummary, + UsageSummary, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; import { mergeUsage, type EnvironmentUsage } from "./usageMerge.ts"; +const decodeUsageSummary = Schema.decodeUnknownSync(UsageSummary); function bucket(overrides: Partial = {}): UsageBucket { return { @@ -42,6 +45,7 @@ function summary( distinctSessions?: number; }[], contractVersion: number = USAGE_CONTRACT_VERSION, + accountUsage?: UsageAccountConsumption, ): UsageSummary { return { contractVersion, @@ -65,6 +69,7 @@ function summary( message: null, })), pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 10 }, + ...(accountUsage === undefined ? {} : { accountUsage }), scanDurationMs: 1, }; } @@ -74,6 +79,54 @@ function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage { } describe("mergeUsage", () => { + it("prefers available official Devin account usage over an unconfigured environment", () => { + const notConfigured: UsageAccountConsumption = { + provider: "devin", + status: "notConfigured", + source: "Devin organization consumption API (ACUs)", + fetchedAt: null, + totalAcus: 0, + days: [], + message: "missing credentials", + }; + const available: UsageAccountConsumption = { + provider: "devin", + status: "available", + source: "Devin organization consumption API (ACUs)", + fetchedAt: "2026-08-31T00:00:00.000Z", + totalAcus: 3.5, + days: [], + message: null, + }; + + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [], + [{ provider: "devin", hostId: "a", homePath: "/logs" }], + USAGE_CONTRACT_VERSION, + notConfigured, + ), + ), + environment( + "env-b", + summary( + [], + [{ provider: "devin", hostId: "b", homePath: "/logs" }], + USAGE_CONTRACT_VERSION, + available, + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.accountUsage?.status).toBe("available"); + expect(merged.accountUsage?.totalAcus).toBe(3.5); + }); + it("sums environments that read different transcript directories", () => { const merged = mergeUsage( [ @@ -155,11 +208,7 @@ describe("mergeUsage", () => { ), environment( "env-b", - summary( - [bucket()], - [{ provider: "claude", hostId: "linux", homePath: "/b" }], - USAGE_CONTRACT_VERSION - 2, - ), + summary([bucket()], [{ provider: "claude", hostId: "linux", homePath: "/b" }], 3), ), ], USAGE_CONTRACT_VERSION, @@ -169,7 +218,7 @@ describe("mergeUsage", () => { expect(merged.staleEnvironments).toEqual(["env-b"]); }); - it("keeps the previous compatible contract version so additive provider expansions still merge", () => { + it.each([4, 5])("keeps version %i after additive provider expansions", (contractVersion) => { const merged = mergeUsage( [ environment( @@ -181,10 +230,12 @@ describe("mergeUsage", () => { ), environment( "env-b", - summary( - [bucket({ costUsd: 4, provider: "codex", model: "gpt-5.6-sol" })], - [{ provider: "codex", hostId: "linux", homePath: "/b" }], - USAGE_CONTRACT_VERSION - 1, + decodeUsageSummary( + summary( + [bucket({ costUsd: 4, provider: "codex", model: "gpt-5.6-sol" })], + [{ provider: "codex", hostId: "linux", homePath: "/b" }], + contractVersion, + ), ), ), ], diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 95982bf507da..3caa9785bbb1 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -10,6 +10,7 @@ import { USAGE_MERGE_COMPATIBLE_SINCE, type EnvironmentId, type UsageBucket, + type UsageAccountConsumption, type UsageProviderKind, type UsageSourceFingerprint, type UsageSummary, @@ -81,6 +82,8 @@ export interface MergedUsage { readonly duplicateSources: readonly string[]; readonly contributingEnvironments: readonly EnvironmentId[]; readonly staleEnvironments: readonly EnvironmentId[]; + /** Optional official Devin ACU data; separate from token/cost totals. */ + readonly accountUsage: UsageAccountConsumption | null; } /** @@ -200,6 +203,7 @@ const EMPTY_MERGED: MergedUsage = { duplicateSources: [], contributingEnvironments: [], staleEnvironments: [], + accountUsage: null, }; /** @@ -270,8 +274,22 @@ export function mergeUsage( } >(); const contributingEnvironments: EnvironmentId[] = []; + let accountUsage: UsageAccountConsumption | null = null; for (const environment of current) { + const candidateAccountUsage = environment.summary.accountUsage; + if (candidateAccountUsage !== undefined) { + // A connected environment may be configured without account credentials + // while another one can report official ACUs. Prefer the usable result, + // but retain a single diagnostic state when every environment is missing + // credentials or has failed. + if ( + accountUsage === null || + (candidateAccountUsage.status === "available" && accountUsage.status !== "available") + ) { + accountUsage = candidateAccountUsage; + } + } const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint); if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); @@ -422,5 +440,6 @@ export function mergeUsage( duplicateSources: duplicates, contributingEnvironments, staleEnvironments, + accountUsage, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95403e871a9b..af19ed2e71b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ catalogs: '@typescript/native-preview': specifier: 7.0.0-dev.20260604.1 version: 7.0.0-dev.20260604.1 + '@vitest/coverage-v8': + specifier: 4.1.11 + version: 4.1.11 jose: specifier: 6.2.2 version: 6.2.2 @@ -118,12 +121,15 @@ importers: '@typescript/native-preview': specifier: 'catalog:' version: 7.0.0-dev.20260604.1 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) knip: specifier: 6.34.0 version: 6.34.0 vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/desktop: dependencies: @@ -193,7 +199,7 @@ importers: version: 4.3.3 vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/marketing: dependencies: @@ -547,7 +553,7 @@ importers: version: link:../../packages/effect-codex-app-server vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/web: dependencies: @@ -719,7 +725,7 @@ importers: version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) infra/relay: dependencies: @@ -774,7 +780,7 @@ importers: version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) oxlint-plugin-t3code: dependencies: @@ -793,7 +799,7 @@ importers: version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/client-runtime: dependencies: @@ -830,7 +836,7 @@ importers: version: 2.0.2 vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/contracts: dependencies: @@ -843,7 +849,7 @@ importers: version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/effect-acp: dependencies: @@ -865,7 +871,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/effect-codex-app-server: dependencies: @@ -887,7 +893,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/shared: dependencies: @@ -921,7 +927,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/ssh: dependencies: @@ -946,7 +952,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/tailscale: dependencies: @@ -965,7 +971,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) scripts: dependencies: @@ -1002,7 +1008,7 @@ importers: version: 6.0.3 vite-plus: specifier: 'catalog:' - version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages: @@ -1656,6 +1662,10 @@ packages: '@types/react': optional: true + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@blazediff/core@1.9.1': resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} @@ -5213,6 +5223,15 @@ packages: peerDependencies: vitest: 4.1.11 + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} + peerDependencies: + '@vitest/browser': 4.1.11 + vitest: 4.1.11 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.11': resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} @@ -5783,6 +5802,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + astro@7.0.3: resolution: {integrity: sha512-CK+G+Tl2DMV1EXCwVG45vyurxf2IfRTklMxDhRKn+tst9Yl8rWXpudL62Fa6zin5Bt968FBvuyASj1aJShROZg==} engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} @@ -7528,6 +7550,9 @@ packages: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} @@ -7757,6 +7782,18 @@ packages: isomorphic.js@0.2.5: resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jake@10.9.4: resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} engines: {node: '>=10'} @@ -7798,6 +7835,9 @@ packages: resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==} engines: {node: '>=20'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -8071,6 +8111,10 @@ packages: magicast@0.5.3: resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} @@ -11724,6 +11768,8 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 + '@bcoe/v8-coverage@1.0.2': {} + '@blazediff/core@1.9.1': {} '@bruits/satteri-darwin-arm64@0.9.3': @@ -15276,7 +15322,7 @@ snapshots: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.11) - vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) transitivePeerDependencies: - bufferutil - msw @@ -15292,7 +15338,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil @@ -15300,6 +15346,22 @@ snapshots: - utf-8-validate - vite + '@vitest/coverage-v8@4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.11 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.3 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + optionalDependencies: + '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.11) + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 @@ -15797,6 +15859,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.12.4)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0)(jiti@2.7.0)(rollup@4.61.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0): dependencies: '@astrojs/compiler-rs': 0.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) @@ -17932,6 +18000,8 @@ snapshots: dependencies: lru-cache: 10.4.3 + html-escaper@2.0.2: {} + html-escaper@3.0.3: {} html-url-attributes@3.0.1: {} @@ -18138,6 +18208,19 @@ snapshots: isomorphic.js@0.2.5: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jake@10.9.4: dependencies: async: 3.2.6 @@ -18183,6 +18266,8 @@ snapshots: js-cookie@3.0.7: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.2.0: @@ -18426,6 +18511,10 @@ snapshots: '@babel/types': 7.29.7 source-map-js: 1.2.1 + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + makeerror@1.0.12: dependencies: tmpl: 1.0.5 @@ -19593,7 +19682,7 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 - oxfmt@0.64.0(vite-plus@0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): + oxfmt@0.64.0(vite-plus@0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): dependencies: tinypool: 2.1.0 optionalDependencies: @@ -19616,7 +19705,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.64.0 '@oxfmt/binding-win32-ia32-msvc': 0.64.0 '@oxfmt/binding-win32-x64-msvc': 0.64.0 - vite-plus: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + vite-plus: 0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) oxlint-tsgolint@7.0.2001: optionalDependencies: @@ -19627,7 +19716,7 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 7.0.2001 '@oxlint-tsgolint/win32-x64': 7.0.2001 - oxlint@1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): + oxlint@1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.79.0 '@oxlint/binding-android-arm64': 1.79.0 @@ -19649,7 +19738,7 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.79.0 '@oxlint/binding-win32-x64-msvc': 1.79.0 oxlint-tsgolint: 7.0.2001 - vite-plus: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + vite-plus: 0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) p-cancelable@2.1.1: {} @@ -21475,7 +21564,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plus@0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0): + vite-plus@0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0): dependencies: '@oxc-project/types': 0.146.0 '@oxlint/plugins': 1.79.0 @@ -21489,11 +21578,11 @@ snapshots: '@vitest/spy': 4.1.11 '@vitest/utils': 4.1.11 '@voidzero-dev/vite-plus-core': 0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) - oxfmt: 0.64.0(vite-plus@0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) - oxlint: 1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) + oxfmt: 0.64.0(vite-plus@0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) + oxlint: 1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@24.12.4)(@vitest/coverage-v8@4.1.11)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) oxlint-tsgolint: 7.0.2001 vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + vitest: 4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.3.0 '@voidzero-dev/vite-plus-darwin-x64': 0.3.0 @@ -21537,7 +21626,7 @@ snapshots: optionalDependencies: vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitest@4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): + vitest@4.1.11(@types/node@24.12.4)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) @@ -21551,7 +21640,7 @@ snapshots: magic-string: 0.30.21 obug: 2.1.3 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.7 std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.2.4 @@ -21562,6 +21651,7 @@ snapshots: optionalDependencies: '@types/node': 24.12.4 '@vitest/browser-preview': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.11) + '@vitest/coverage-v8': 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ee1bd25547f6..c69e820513a9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -48,6 +48,7 @@ catalog: "@tailwindcss/vite": 4.3.3 "@types/node": 24.12.4 "@typescript/native-preview": 7.0.0-dev.20260604.1 + "@vitest/coverage-v8": 4.1.11 effect: 4.0.0-beta.103 jose: 6.2.2 lightningcss: 1.33.0 diff --git a/scripts/devin-mcp-smoke.ts b/scripts/devin-mcp-smoke.ts new file mode 100644 index 000000000000..3c7f0daf241f --- /dev/null +++ b/scripts/devin-mcp-smoke.ts @@ -0,0 +1,29 @@ +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalConsole:off +import * as NodeChildProcess from "node:child_process"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const repoRoot = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); +const vpExecutable = process.platform === "win32" ? "vp.cmd" : "vp"; +const args = ["test", "run", "apps/server/src/provider/acp/DevinAcpCliProbe.test.ts"]; +const command = [vpExecutable, ...args].join(" "); + +const result = NodeChildProcess.spawnSync(vpExecutable, args, { + cwd: repoRoot, + env: { + ...process.env, + T3_DEVIN_ACP_PROBE: "0", + T3_DEVIN_MCP_SMOKE: "1", + }, + shell: process.platform === "win32", + stdio: "inherit", + windowsHide: true, +}); + +if (result.error) { + console.error(`${command}: ${result.error.message}`); + process.exitCode = 1; +} else { + process.exitCode = result.status ?? 1; +}