From f5228501219ee38a2d8ace3033fae1611b7ea956 Mon Sep 17 00:00:00 2001 From: Aman Thanvi Date: Sat, 22 Aug 2026 02:31:27 -0400 Subject: [PATCH 1/9] feat(server): add Cline provider over ACP Cline CLI (`cline --acp`) speaks Agent Client Protocol v1 over stdio with session resume, plan/act modes, permission prompts, and a config-option model catalog. This wires it in as a built-in driver on the shared ACP runtime alongside Cursor and Grok: driver + adapter + status probe + text generation, contracts settings, web/mobile presentation, docs, and mock-agent test coverage. Unlike the other ACP drivers, Cline's `authenticate` blocks in an OAuth device flow until the user signs in, so the status probe tracks authenticate request phases and surfaces an explicit unauthenticated state pointing at `cline auth login` instead of a generic failure. --- AGENTS.md | 6 +- README.md | 5 +- apps/marketing/public/harnesses/cline.svg | 9 + apps/marketing/src/pages/index.astro | 19 +- apps/mobile/src/components/ProviderIcon.tsx | 37 +- .../features/threads/NewTaskDraftScreen.tsx | 68 +- .../src/features/threads/ThreadComposer.tsx | 55 +- .../features/threads/ThreadSettingsSheet.tsx | 13 +- .../threads/new-task-flow-provider.tsx | 57 +- .../thread-settings-sheet-state.test.ts | 3 + apps/mobile/src/lib/modelOptions.test.ts | 183 +++ apps/mobile/src/lib/modelOptions.ts | 142 ++- apps/mobile/src/state/thread-outbox-model.ts | 32 + apps/mobile/src/state/thread-outbox.test.ts | 57 + .../src/state/use-thread-composer-state.ts | 41 +- .../src/state/use-thread-outbox-drain.ts | 36 +- apps/server/scripts/acp-mock-agent.ts | 133 +- .../src/provider/Drivers/ClineDriver.ts | 160 +++ .../src/provider/Layers/ClineAdapter.test.ts | 879 +++++++++++++ .../src/provider/Layers/ClineAdapter.ts | 1087 +++++++++++++++++ .../src/provider/Layers/ClineProvider.test.ts | 251 ++++ .../src/provider/Layers/ClineProvider.ts | 366 ++++++ .../ProviderInstanceRegistryLive.test.ts | 47 +- .../provider/Layers/ProviderRegistry.test.ts | 11 +- .../provider/Layers/ProviderService.test.ts | 28 +- .../src/provider/Layers/ProviderService.ts | 27 +- apps/server/src/provider/ProviderDriver.ts | 7 +- .../src/provider/Services/ClineAdapter.ts | 16 + .../provider/acp/AcpJsonRpcConnection.test.ts | 33 + .../src/provider/acp/AcpSessionRuntime.ts | 141 ++- .../src/provider/acp/ClineAcpCliProbe.test.ts | 74 ++ .../src/provider/acp/ClineAcpSupport.test.ts | 275 +++++ .../src/provider/acp/ClineAcpSupport.ts | 178 +++ apps/server/src/provider/builtInDrivers.ts | 3 + apps/server/src/provider/providerSnapshot.ts | 9 + apps/server/src/serverSettings.test.ts | 114 ++ apps/server/src/serverSettings.ts | 54 +- .../src/textGeneration/TextGeneration.test.ts | 70 +- .../src/textGeneration/TextGeneration.ts | 10 +- apps/web/src/components/ChatView.tsx | 13 +- apps/web/src/components/Icons.tsx | 18 + apps/web/src/components/chat/ChatComposer.tsx | 76 +- .../chat/CompactComposerControlsMenu.tsx | 17 +- .../chat/ProviderModelPicker.logic.test.ts | 20 + .../chat/ProviderModelPicker.logic.ts | 7 + .../components/chat/ProviderModelPicker.tsx | 10 +- .../src/components/chat/providerIconUtils.ts | 3 +- .../settings/DiagnosticsSettings.tsx | 2 +- .../settings/ProviderInstanceCard.tsx | 4 +- .../settings/ProviderModelsSection.tsx | 51 +- .../components/settings/SettingsPanels.tsx | 7 +- .../SourceControlWritingSettings.test.ts | 23 + .../settings/SourceControlWritingSettings.tsx | 27 +- .../components/settings/providerDriverMeta.ts | 21 +- apps/web/src/lib/contextWindow.ts | 2 + apps/web/src/modelSelection.test.ts | 69 ++ apps/web/src/modelSelection.ts | 30 +- apps/web/src/providerInstances.test.ts | 51 + apps/web/src/providerInstances.ts | 19 +- apps/web/src/providerModels.test.ts | 92 ++ apps/web/src/providerModels.ts | 56 + apps/web/src/session-logic.ts | 6 + docs/internals/glossary.md | 2 +- docs/internals/overview.md | 10 +- docs/internals/providers.md | 22 +- docs/user/install.md | 38 +- packages/contracts/src/model.ts | 3 +- packages/contracts/src/server.ts | 7 + packages/contracts/src/settings.test.ts | 2 + packages/contracts/src/settings.ts | 38 +- packages/shared/src/serverSettings.test.ts | 30 + packages/shared/src/serverSettings.ts | 14 +- 72 files changed, 5268 insertions(+), 258 deletions(-) create mode 100644 apps/marketing/public/harnesses/cline.svg create mode 100644 apps/server/src/provider/Drivers/ClineDriver.ts create mode 100644 apps/server/src/provider/Layers/ClineAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/ClineAdapter.ts create mode 100644 apps/server/src/provider/Layers/ClineProvider.test.ts create mode 100644 apps/server/src/provider/Layers/ClineProvider.ts create mode 100644 apps/server/src/provider/Services/ClineAdapter.ts create mode 100644 apps/server/src/provider/acp/ClineAcpCliProbe.test.ts create mode 100644 apps/server/src/provider/acp/ClineAcpSupport.test.ts create mode 100644 apps/server/src/provider/acp/ClineAcpSupport.ts create mode 100644 apps/web/src/components/chat/ProviderModelPicker.logic.test.ts create mode 100644 apps/web/src/components/chat/ProviderModelPicker.logic.ts create mode 100644 apps/web/src/components/settings/SourceControlWritingSettings.test.ts create mode 100644 apps/web/src/providerModels.test.ts diff --git a/AGENTS.md b/AGENTS.md index 784b37cc47b2..d599c12c9641 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # T3 Code -T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs (Codex, Claude Code, Cursor, Grok, OpenCode) and serves web, desktop, and mobile clients. +T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs (Codex, Claude Code, Cline, Cursor, Grok, OpenCode) and serves web, desktop, and mobile clients. You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor. @@ -48,7 +48,7 @@ We need to be on the same page with terminology. When communicating, use this la - **we, us, and maintainers** mean Theo, Julius and the people building T3 Code. These are who you are talking to now. - **user** means the person using T3 Code to direct coding agents. - **agent** means the coding agent a user runs inside T3 Code. Depending on context, that may also include you. -- **provider** means the agent runtime or harness T3 Code talks to, such as Codex, Claude, Cursor, or OpenCode. +- **provider** means the agent runtime or harness T3 Code talks to, such as Codex, Claude, Cline, Cursor, or OpenCode. - **client** means the web, desktop, or mobile UI. - **environment** means one running T3 server and the machine, filesystem, provider credentials, and state it owns. - **project** means an environment-local workspace record rooted at a directory. @@ -68,7 +68,7 @@ The most common defect in this repo is a change that works on the path you teste - **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature. - **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime` -- **Providers.** Codex, Claude, Cursor, Grok, and OpenCode each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". +- **Providers.** Codex, Claude, Cline, Cursor, Grok, and OpenCode each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". - **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow. - **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug. - **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real. diff --git a/README.md b/README.md index 8ec101387f67..4a42df76eab6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). -Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them. +Works with your subscriptions on Claude Code, Codex, Cline, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them. ## "Wait, what are you selling me?" @@ -13,10 +13,11 @@ We wanted something performant, remote-ready, and truly open. If we ever go the ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use: +> T3 Code currently supports Codex, Claude, Cline, Cursor, Grok Build, and OpenCode. Install and authenticate at least one provider before use: > > - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` > - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` +> - Cline: install [Cline CLI](https://docs.cline.bot/usage/cli-overview) and run `cline auth` > - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent 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` diff --git a/apps/marketing/public/harnesses/cline.svg b/apps/marketing/public/harnesses/cline.svg new file mode 100644 index 000000000000..d46e692ed806 --- /dev/null +++ b/apps/marketing/public/harnesses/cline.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index e45cb7602873..f995c745de33 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -49,7 +49,7 @@ const mobileEndorsementRows = [

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

@@ -186,8 +186,8 @@ const mobileEndorsementRows = [

Bring your own sub

- T3 Code doesn't resell tokens. Plug in Claude Code, Codex, OpenCode, - Cursor, or Grok with the credentials you already have — we orchestrate + T3 Code doesn't resell tokens. Plug in Claude Code, Codex, Cline, + OpenCode, Cursor, or Grok with the credentials you already have — we orchestrate them, you keep your plan.

@@ -207,6 +207,13 @@ const mobileEndorsementRows = [
codex login
+
+
+
+
Cline
+
cline auth
+
+
@@ -811,7 +818,7 @@ const mobileEndorsementRows = [ .harness-grid { display: grid; - grid-template-columns: repeat(5, 1fr); + grid-template-columns: repeat(6, 1fr); gap: 0; border: 1px solid var(--border); border-radius: var(--radius); @@ -1266,10 +1273,6 @@ const mobileEndorsementRows = [ .harness-grid { grid-template-columns: 1fr 1fr; } .harness { border-right: 0; border-bottom: 1px solid var(--border); } .harness:nth-child(odd) { border-right: 1px solid var(--border); } - .harness:nth-child(5):last-child { - grid-column: 1 / -1; - border-right: 0; - } .git-inner { grid-template-columns: 1fr; gap: 40px; } .open-grid { grid-template-columns: 1fr; } } diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 5eb69627f58d..46bc8417c93f 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -1,4 +1,4 @@ -import { Path, Svg } from "react-native-svg"; +import { Circle, Path, Rect, Svg } from "react-native-svg"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; type ProviderIconProps = { @@ -39,6 +39,41 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "cline") { + const fill = isDarkMode ? "#F5F5F5" : "#0F0F0F"; + return ( + + + + + + + ); + } + if (props.provider === "cursor") { return ( diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 8f5beb69c938..120d608c48be 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -52,7 +52,13 @@ import { type ComposerDraft, } from "../../state/use-composer-drafts"; import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; -import { resolveSelectableModelSelection } from "../../lib/modelOptions"; +import { + getUnsupportedProviderAttachmentReason, + getUnsupportedProviderModeReason, + getUnavailableProviderModelReason, + providerSupportsImageAttachments, + resolveSelectableModelSelection, +} from "../../lib/modelOptions"; import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; @@ -613,6 +619,15 @@ export function NewTaskDraftScreen(props: { if (isIncomingShareTransferPending) { return; } + const unsupportedAttachmentReason = getUnsupportedProviderAttachmentReason({ + config: selectedEnvironmentServerConfig, + selection: flow.selectedModel, + attachmentCount: 1, + }); + if (unsupportedAttachmentReason !== null) { + Alert.alert("Attachments unavailable", unsupportedAttachmentReason); + return; + } const result = await pickComposerImages({ existingCount: flow.attachments.length }); if (result.images.length > 0) { flow.appendAttachments(result.images); @@ -661,6 +676,33 @@ export function NewTaskDraftScreen(props: { ? (draft.interactionMode ?? flow.interactionMode) : "default"; const initialMessageText = draft.text.trim(); + const unavailableModelReason = getUnavailableProviderModelReason({ + config: selectedEnvironmentServerConfig, + selection: modelSelection, + }); + if (unavailableModelReason !== null) { + Alert.alert("Provider still checking", unavailableModelReason); + return; + } + const unsupportedProviderModeReason = getUnsupportedProviderModeReason({ + config: selectedEnvironmentServerConfig, + selection: modelSelection, + runtimeMode, + interactionMode, + }); + if (unsupportedProviderModeReason !== null) { + Alert.alert("Change provider mode", unsupportedProviderModeReason); + return; + } + const unsupportedAttachmentReason = getUnsupportedProviderAttachmentReason({ + config: selectedEnvironmentServerConfig, + selection: modelSelection, + attachmentCount: draft.attachments.length, + }); + if (unsupportedAttachmentReason !== null) { + Alert.alert("Remove attachments", unsupportedAttachmentReason); + return; + } if ( !modelSelection || @@ -805,6 +847,7 @@ export function NewTaskDraftScreen(props: { isIncomingShareReady && !isImportingShare && !flow.submitting && + flow.unsupportedProviderModeReason === null && !(flow.workspaceMode === "worktree" && !flow.selectedBranchName); const promptEditor = ( - void handlePickImages()} - showChevron={false} - /> + {providerSupportsImageAttachments({ + config: selectedEnvironmentServerConfig, + selection: flow.selectedModel, + }) ? ( + void handlePickImages()} + showChevron={false} + /> + ) : null} - {flow.planModeEnabled ? ( + {flow.planModeEnabled && + (flow.interactionMode === "plan" || + flow.selectedModelOption?.showInteractionModeToggle !== false) ? ( item.command.includes(q)); + const builtIn = allBuiltIn.filter((item) => { + if (!item.command.includes(q)) return false; + if (item.command === "plan") { + return selectedProviderStatus?.showInteractionModeToggle !== false; + } + if (item.command === "default") { + return ( + selectedProviderStatus?.showInteractionModeToggle !== false || + props.selectedThread.interactionMode === "plan" + ); + } + return true; + }); const providerCommands: ComposerCommandItem[] = []; for (const cmd of selectedProviderStatus?.slashCommands ?? []) { @@ -542,7 +567,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } return []; - }, [composerTrigger, pathSearch.entries, selectedProviderStatus]); + }, [ + composerTrigger, + pathSearch.entries, + props.selectedThread.interactionMode, + selectedProviderStatus, + ]); // ── Handle command selection ────────────────────────────── const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; @@ -870,12 +900,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer fadeTransparent={toolbarFadeTransparent} contentPaddingRight={8} > - void props.onPickDraftImages()} - showChevron={false} - /> + {providerSupportsImageAttachments({ + config: props.serverConfig, + selection: currentModelSelection, + }) ? ( + void props.onPickDraftImages()} + showChevron={false} + /> + ) : null} ; readonly runtimeMode: RuntimeMode; + readonly supportedRuntimeModes: ReadonlyArray; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; readonly displayedDescriptors: ReadonlyArray; readonly providerExpansionOverrides: ReadonlySet; @@ -405,6 +406,12 @@ function ThreadSettingsSessionProvider( props.onSelectModel(pendingModel); } }, [pendingModel, props.onSelectModel]); + const activeProviderGroup = props.providerGroups.find( + (group) => + group.providerKey === (pendingModel?.selection.instanceId ?? props.selectedModel?.instanceId), + ); + const supportedRuntimeModes = + activeProviderGroup?.supportedRuntimeModes ?? RUNTIME_MODE_CHOICES.map((choice) => choice.mode); const applyOptionChange = useCallback( (id: string, value: string | boolean) => { @@ -452,6 +459,7 @@ function ThreadSettingsSessionProvider( () => ({ providerGroups: props.providerGroups, runtimeMode: props.runtimeMode, + supportedRuntimeModes, onUpdateRuntimeMode: props.onUpdateRuntimeMode, displayedDescriptors, providerExpansionOverrides, @@ -484,6 +492,7 @@ function ThreadSettingsSessionProvider( props.onUpdateRuntimeMode, props.providerGroups, props.runtimeMode, + supportedRuntimeModes, searchQuery, showLegacyToggle, toggleProvider, @@ -858,7 +867,9 @@ function ThreadSettingsChoiceContent(props: { const submenuContent = props.submenu.kind === "runtime" ? { - rows: RUNTIME_MODE_CHOICES.map((choice) => ({ + rows: RUNTIME_MODE_CHOICES.filter((choice) => + session.supportedRuntimeModes.includes(choice.mode), + ).map((choice) => ({ id: choice.mode, label: choice.label, description: choice.description, diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 14f0fcc95a22..8016048a825f 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -31,6 +31,9 @@ import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { buildModelOptions, + getUnsupportedProviderAttachmentReason, + getUnsupportedProviderModeReason, + getUnavailableProviderModelReason, groupByProvider, resolveDefaultableModelSelection, resolveSelectableModelSelection, @@ -143,6 +146,7 @@ type NewTaskFlowContextValue = { readonly currentCheckoutBranchName: string | null; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; + readonly unsupportedProviderModeReason: string | null; readonly planModeEnabled: boolean; readonly expandedProvider: string | null; readonly environments: ReadonlyArray<{ @@ -433,6 +437,22 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { modelOptions.find((option) => option.isDefault)?.selection ?? modelOptions[0]?.selection ?? null; + const unsupportedProviderModeReason = + getUnavailableProviderModelReason({ + config: selectedEnvironmentServerConfig, + selection: selectedModel, + }) ?? + getUnsupportedProviderModeReason({ + config: selectedEnvironmentServerConfig, + selection: selectedModel, + runtimeMode, + interactionMode, + }) ?? + getUnsupportedProviderAttachmentReason({ + config: selectedEnvironmentServerConfig, + selection: selectedModel, + attachmentCount: attachments.length, + }); const selectedModelKey = selectedModel ? `${selectedModel.instanceId}:${selectedModel.model}` : null; @@ -846,6 +866,32 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { // Fall back to the resolved mode (server default) so queued tasks drain // with the same mode the composer displayed. const mode = workspaceSelection?.mode ?? workspaceMode; + const draftRuntimeMode = draft.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const draftInteractionMode = resolvePendingTaskInteractionMode({ + preferenceLoaded: planModePreferenceLoaded, + planModeEnabled, + draftInteractionMode: draft.interactionMode, + queuedInteractionMode: editingPendingTask?.interactionMode, + }); + if ( + getUnavailableProviderModelReason({ + config: selectedEnvironmentServerConfig, + selection: draftModelSelection, + }) !== null || + getUnsupportedProviderModeReason({ + config: selectedEnvironmentServerConfig, + selection: draftModelSelection, + runtimeMode: draftRuntimeMode, + interactionMode: draftInteractionMode, + }) !== null || + getUnsupportedProviderAttachmentReason({ + config: selectedEnvironmentServerConfig, + selection: draftModelSelection, + attachmentCount: draft.attachments.length, + }) !== null + ) { + return null; + } // When the selection is the stand-in built from the queued snapshot, // persist the original (possibly absent) snapshot values — the // stand-in's placeholder title/workspaceRoot must never be written back @@ -865,13 +911,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { text, attachments: draft.attachments, modelSelection: draftModelSelection, - runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE, - interactionMode: resolvePendingTaskInteractionMode({ - preferenceLoaded: planModePreferenceLoaded, - planModeEnabled, - draftInteractionMode: draft.interactionMode, - queuedInteractionMode: editingPendingTask?.interactionMode, - }), + runtimeMode: draftRuntimeMode, + interactionMode: draftInteractionMode, creation: { projectId: selectedProject.id, ...(projectTitle !== undefined ? { projectTitle } : {}), @@ -1021,6 +1062,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { currentCheckoutBranchName, runtimeMode, interactionMode, + unsupportedProviderModeReason, planModeEnabled, expandedProvider, environments, @@ -1073,6 +1115,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { filteredBranches, finishEditingPendingTask, interactionMode, + unsupportedProviderModeReason, planModeEnabled, loadBranches, loadMoreBranches, diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts index 2e8fee98572a..c1091f04526b 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts @@ -19,6 +19,9 @@ function modelOption( isDefault: false, isLegacy: false, capabilities: null, + supportedRuntimeModes: ["approval-required", "auto-accept-edits", "auto", "full-access"], + showInteractionModeToggle: true, + supportsImageAttachments: true, selection: { instanceId: ProviderInstanceId.make("codex"), model, diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 8a9dabbe034f..86159f9190f3 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -4,7 +4,12 @@ import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; import { buildModelOptions, + getUnsupportedProviderAttachmentReason, + getUnsupportedProviderModeReason, + getUnavailableProviderModelReason, + getProviderSendBlockReason, groupByProvider, + providerSupportsImageAttachments, resolveDefaultableModelSelection, resolveSelectableModelSelection, } from "./modelOptions"; @@ -138,6 +143,184 @@ describe("mobile model options", () => { expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled); }); + it("does not synthesize Cline selections missing from the online catalog", () => { + const config = { + providers: [ + { + instanceId: "cline", + driver: "cline", + enabled: true, + installed: true, + status: "error", + auth: { status: "authenticated" }, + models: [], + }, + ], + } as unknown as ServerConfig; + const stale = { + instanceId: ProviderInstanceId.make("cline"), + model: "composer-2", + }; + + expect(resolveSelectableModelSelection(config, stale)).toBeNull(); + expect(buildModelOptions(config, stale)).toEqual([]); + const changedCatalog = { + ...config, + providers: [ + { + ...config.providers[0]!, + status: "ready", + models: [ + { + slug: "current-account-model", + name: "Current account model", + isCustom: false, + capabilities: null, + }, + ], + }, + ], + } as unknown as ServerConfig; + expect(resolveSelectableModelSelection(changedCatalog, stale)).toBeNull(); + expect( + buildModelOptions(changedCatalog, stale).map((option) => option.selection.model), + ).toEqual(["current-account-model"]); + // Offline config cannot be validated, so the existing draft remains + // available until a server snapshot arrives. + expect(resolveSelectableModelSelection(null, stale)).toBe(stale); + expect(buildModelOptions(null, stale)).toHaveLength(1); + }); + + it("preserves Cline routing while its model catalog is still checking", () => { + const selection = { + instanceId: ProviderInstanceId.make("cline"), + model: "last-known-cline-model", + }; + const config = { + providers: [ + { + instanceId: "cline", + driver: "cline", + displayName: "Cline", + enabled: true, + installed: true, + status: "warning", + auth: { status: "unknown" }, + supportsImageAttachments: false, + models: [], + }, + { + instanceId: "codex", + driver: "codex", + displayName: "Codex", + enabled: true, + installed: true, + status: "ready", + auth: { status: "authenticated" }, + models: [ + { + slug: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + isCustom: false, + capabilities: null, + }, + ], + }, + ], + } as unknown as ServerConfig; + + expect(resolveSelectableModelSelection(config, selection)).toEqual(selection); + expect( + buildModelOptions(config, selection).some((option) => option.providerKey === "cline"), + ).toBe(false); + expect(getUnavailableProviderModelReason({ config, selection })).toContain("still checking"); + expect(providerSupportsImageAttachments({ config, selection })).toBe(false); + expect( + getProviderSendBlockReason({ + config, + selection, + runtimeMode: "full-access", + interactionMode: "default", + attachmentCount: 1, + }), + ).not.toBeNull(); + }); + + it("preserves carried Cline modes but blocks them until the user chooses supported modes", () => { + const config = { + providers: [ + { + instanceId: "cline", + driver: "cline", + displayName: "Cline", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + supportedRuntimeModes: ["full-access"], + showInteractionModeToggle: false, + supportsImageAttachments: false, + models: [ + { + slug: "current-account-model", + name: "Current account model", + isCustom: false, + capabilities: null, + }, + ], + }, + ], + } as unknown as ServerConfig; + const selection = { + instanceId: ProviderInstanceId.make("cline"), + model: "current-account-model", + }; + + expect( + getUnsupportedProviderModeReason({ + config, + selection, + runtimeMode: "approval-required", + interactionMode: "default", + }), + ).toContain("Choose Full access"); + expect( + getUnsupportedProviderModeReason({ + config, + selection, + runtimeMode: "full-access", + interactionMode: "plan", + }), + ).toContain("Choose Build"); + expect( + getUnsupportedProviderModeReason({ + config, + selection, + runtimeMode: "full-access", + interactionMode: "default", + }), + ).toBeNull(); + expect(groupByProvider(buildModelOptions(config, selection))[0]).toMatchObject({ + supportedRuntimeModes: ["full-access"], + showInteractionModeToggle: false, + supportsImageAttachments: false, + }); + expect( + getUnsupportedProviderAttachmentReason({ config, selection, attachmentCount: 1 }), + ).toContain("Remove the images"); + expect( + getUnsupportedProviderAttachmentReason({ config, selection, attachmentCount: 0 }), + ).toBeNull(); + expect( + getProviderSendBlockReason({ + config, + selection, + runtimeMode: "full-access", + interactionMode: "default", + attachmentCount: 1, + }), + ).toContain("Remove the images"); + }); + it("keeps legacy models out of implicit defaults", () => { const config = { providers: [ diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index cb7a8c4198ec..b59f0326b373 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -1,6 +1,8 @@ import type { ModelCapabilities, ModelSelection, + ProviderInteractionMode, + RuntimeMode, ServerConfig as T3ServerConfig, } from "@t3tools/contracts"; import { @@ -18,15 +20,103 @@ export type ModelOption = { readonly isDefault: boolean; readonly isLegacy: boolean; readonly capabilities: ModelCapabilities | null; + readonly supportedRuntimeModes: ReadonlyArray; + readonly showInteractionModeToggle: boolean; + readonly supportsImageAttachments: boolean; readonly selection: ModelSelection; }; export type ProviderGroup = { readonly providerKey: string; readonly providerLabel: string; + readonly supportedRuntimeModes: ReadonlyArray; + readonly showInteractionModeToggle: boolean; + readonly supportsImageAttachments: boolean; readonly models: ReadonlyArray; }; +export const ALL_RUNTIME_MODES: ReadonlyArray = [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access", +]; + +export function getUnsupportedProviderModeReason(input: { + readonly config: T3ServerConfig | null | undefined; + readonly selection: ModelSelection | null | undefined; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; +}): string | null { + const provider = input.config?.providers.find( + (candidate) => candidate.instanceId === input.selection?.instanceId, + ); + if (!provider) return null; + const supported = provider.supportedRuntimeModes ?? ALL_RUNTIME_MODES; + if (!supported.includes(input.runtimeMode)) { + return `${providerDisplayLabel(provider)} does not support the selected access mode. Choose Full access to continue.`; + } + if (input.interactionMode === "plan" && provider.showInteractionModeToggle === false) { + return `${providerDisplayLabel(provider)} does not support Plan mode. Choose Build to continue.`; + } + return null; +} + +export function getUnsupportedProviderAttachmentReason(input: { + readonly config: T3ServerConfig | null | undefined; + readonly selection: ModelSelection | null | undefined; + readonly attachmentCount: number; +}): string | null { + if (input.attachmentCount === 0) return null; + const provider = input.config?.providers.find( + (candidate) => candidate.instanceId === input.selection?.instanceId, + ); + if (provider?.supportsImageAttachments !== false) return null; + return `${providerDisplayLabel(provider)} does not support image attachments. Remove the images to continue.`; +} + +export function providerSupportsImageAttachments(input: { + readonly config: T3ServerConfig | null | undefined; + readonly selection: ModelSelection | null | undefined; +}): boolean { + const provider = input.config?.providers.find( + (candidate) => candidate.instanceId === input.selection?.instanceId, + ); + return provider?.supportsImageAttachments !== false; +} + +export function getProviderSendBlockReason(input: { + readonly config: T3ServerConfig | null | undefined; + readonly selection: ModelSelection | null | undefined; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; + readonly attachmentCount: number; +}): string | null { + return ( + getUnavailableProviderModelReason(input) ?? + getUnsupportedProviderModeReason(input) ?? + getUnsupportedProviderAttachmentReason(input) + ); +} + +export function getUnavailableProviderModelReason(input: { + readonly config: T3ServerConfig | null | undefined; + readonly selection: ModelSelection | null | undefined; +}): string | null { + const provider = input.config?.providers.find( + (candidate) => candidate.instanceId === input.selection?.instanceId, + ); + if ( + provider?.driver === "cline" && + provider.status === "warning" && + provider.auth.status === "unknown" && + !provider.models.some((candidate) => candidate.slug === input.selection?.model) + ) { + return `${providerDisplayLabel(provider)} is still checking its model catalog. Wait for the provider check to finish.`; + } + return null; +} + function providerDisplayLabel(provider: { readonly displayName?: string | undefined; readonly driver: string; @@ -59,6 +149,20 @@ function normalizeSelectionOptions( }; } +function hasUnadvertisedClineSelection( + config: T3ServerConfig | null | undefined, + selection: ModelSelection, +): boolean { + const provider = config?.providers.find( + (candidate) => candidate.instanceId === selection.instanceId, + ); + return ( + provider?.driver === "cline" && + !(provider.status === "warning" && provider.auth.status === "unknown") && + !provider.models.some((candidate) => candidate.slug === selection.model) + ); +} + /** * A stored model selection is only usable when its provider instance is * currently enabled, installed, and authenticated on the server. Returns the @@ -76,6 +180,9 @@ export function resolveSelectableModelSelection( const provider = config.providers.find( (candidate) => candidate.instanceId === selection.instanceId, ); + if (hasUnadvertisedClineSelection(config, selection)) { + return null; + } return provider && provider.enabled && provider.installed && @@ -128,6 +235,9 @@ export function buildModelOptions( isDefault: model.isDefault === true, isLegacy: model.isLegacy === true, capabilities: model.capabilities, + supportedRuntimeModes: provider.supportedRuntimeModes ?? ALL_RUNTIME_MODES, + showInteractionModeToggle: provider.showInteractionModeToggle ?? true, + supportsImageAttachments: provider.supportsImageAttachments ?? true, selection: normalizeSelectionOptions( { instanceId: provider.instanceId, @@ -139,7 +249,17 @@ export function buildModelOptions( } } - if (fallbackModelSelection) { + const fallbackProvider = config?.providers.find( + (provider) => provider.instanceId === fallbackModelSelection?.instanceId, + ); + const canInjectFallback = + fallbackProvider?.driver !== "cline" || + fallbackProvider.models.some((model) => model.slug === fallbackModelSelection?.model); + if ( + fallbackModelSelection && + canInjectFallback && + !hasUnadvertisedClineSelection(config, fallbackModelSelection) + ) { const key = `${fallbackModelSelection.instanceId}:${fallbackModelSelection.model}`; const existing = options.get(key); if (existing) { @@ -159,6 +279,9 @@ export function buildModelOptions( isDefault: false, isLegacy: false, capabilities: null, + supportedRuntimeModes: ALL_RUNTIME_MODES, + showInteractionModeToggle: true, + supportsImageAttachments: true, selection: fallbackModelSelection, }); } @@ -168,7 +291,16 @@ export function buildModelOptions( } export function groupByProvider(options: ReadonlyArray): ReadonlyArray { - const groups = new Map(); + const groups = new Map< + string, + { + providerLabel: string; + supportedRuntimeModes: ReadonlyArray; + showInteractionModeToggle: boolean; + supportsImageAttachments: boolean; + models: ModelOption[]; + } + >(); for (const option of options) { const existing = groups.get(option.providerKey); if (existing) { @@ -176,6 +308,9 @@ export function groupByProvider(options: ReadonlyArray): ReadonlyAr } else { groups.set(option.providerKey, { providerLabel: option.providerLabel, + supportedRuntimeModes: option.supportedRuntimeModes, + showInteractionModeToggle: option.showInteractionModeToggle, + supportsImageAttachments: option.supportsImageAttachments, models: [option], }); } @@ -184,6 +319,9 @@ export function groupByProvider(options: ReadonlyArray): ReadonlyAr return [...groups.entries()].map(([providerKey, group]) => ({ providerKey, providerLabel: group.providerLabel, + supportedRuntimeModes: group.supportedRuntimeModes, + showInteractionModeToggle: group.showInteractionModeToggle, + supportsImageAttachments: group.supportsImageAttachments, models: group.models, })); } diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index eede506976a7..6e1c6a973433 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -14,12 +14,18 @@ import { type ProjectId as ProjectIdType, type ProviderInteractionMode as ProviderInteractionModeType, type RuntimeMode as RuntimeModeType, + type ServerConfig, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; +import { + getUnsupportedProviderAttachmentReason, + getUnsupportedProviderModeReason, + getUnavailableProviderModelReason, +} from "../lib/modelOptions"; const THREAD_OUTBOX_SCHEMA_VERSION = 3; const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; @@ -80,6 +86,32 @@ export interface QueuedThreadMessage { readonly createdAt: string; } +export function getQueuedDeliveryUnsupportedProviderInputReason(input: { + readonly config: ServerConfig; + readonly message: QueuedThreadMessage; + readonly modelSelection: ModelSelectionType | undefined; + readonly runtimeMode: RuntimeModeType; + readonly interactionMode: ProviderInteractionModeType; +}): string | null { + return ( + getUnavailableProviderModelReason({ + config: input.config, + selection: input.modelSelection, + }) ?? + getUnsupportedProviderModeReason({ + config: input.config, + selection: input.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + }) ?? + getUnsupportedProviderAttachmentReason({ + config: input.config, + selection: input.modelSelection, + attachmentCount: input.message.attachments.length, + }) + ); +} + export interface ThreadSettingsSnapshot { readonly modelSelection: ModelSelectionType; readonly runtimeMode: RuntimeModeType; diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index b12ad2dc5843..ead6c81e415d 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -5,6 +5,7 @@ import { MessageId, ProjectId, ProviderInstanceId, + type ServerConfig, ThreadId, } from "@t3tools/contracts"; import { AtomRegistry } from "effect/unstable/reactivity"; @@ -13,6 +14,7 @@ import { decodeQueuedThreadMessage, encodeQueuedThreadMessage, groupQueuedThreadMessages, + getQueuedDeliveryUnsupportedProviderInputReason, isQueuedThreadCreationSendable, modelSelectionsEqual, resolveThreadOutboxDeliveryAction, @@ -43,6 +45,61 @@ function queuedMessage(input: { } describe("thread outbox", () => { + it("keeps queued input blocked when the provider rejects its mode or attachments", () => { + const message = { + ...queuedMessage({ messageId: "message-cline", createdAt: "2026-08-23T00:00:00.000Z" }), + attachments: [ + { + id: "image-1", + previewUri: "file:///image.png", + type: "image", + name: "image.png", + mimeType: "image/png", + sizeBytes: 4, + dataUrl: "data:image/png;base64,AAAA", + }, + ], + } satisfies QueuedThreadMessage; + const modelSelection = { + instanceId: ProviderInstanceId.make("cline"), + model: "current-account-model", + }; + const config = { + providers: [ + { + instanceId: "cline", + driver: "cline", + displayName: "Cline", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + supportedRuntimeModes: ["full-access"], + supportsImageAttachments: false, + models: [], + }, + ], + } as unknown as ServerConfig; + + expect( + getQueuedDeliveryUnsupportedProviderInputReason({ + config, + message, + modelSelection, + runtimeMode: "approval-required", + interactionMode: "default", + }), + ).toContain("Full access"); + expect( + getQueuedDeliveryUnsupportedProviderInputReason({ + config, + message, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + }), + ).toContain("Remove the images"); + }); + it("groups messages by scoped thread and preserves creation order", () => { const later = queuedMessage({ messageId: "message-2", diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index dd7ace60ad99..d71c7932f48f 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -32,6 +32,11 @@ import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; import { buildThreadFeed } from "../lib/threadActivity"; +import { + getUnsupportedProviderAttachmentReason, + getUnsupportedProviderModeReason, + getUnavailableProviderModelReason, +} from "../lib/modelOptions"; import { appAtomRegistry } from "../state/atom-registry"; import { appendComposerDraftAttachments, @@ -175,6 +180,36 @@ export function useThreadComposerState() { const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find( (entry) => entry.instanceId === thread.modelSelection.instanceId, ); + const modelSelection = draft.modelSelection ?? thread.modelSelection; + const runtimeMode = draft.runtimeMode ?? thread.runtimeMode; + const interactionMode = draft.interactionMode ?? thread.interactionMode; + const unavailableModelReason = getUnavailableProviderModelReason({ + config: selectedEnvironmentRuntime?.serverConfig, + selection: modelSelection, + }); + if (unavailableModelReason !== null) { + Alert.alert("Provider still checking", unavailableModelReason); + return null; + } + const unsupportedProviderModeReason = getUnsupportedProviderModeReason({ + config: selectedEnvironmentRuntime?.serverConfig, + selection: modelSelection, + runtimeMode, + interactionMode, + }); + if (unsupportedProviderModeReason !== null) { + Alert.alert("Change provider mode", unsupportedProviderModeReason); + return null; + } + const unsupportedAttachmentReason = getUnsupportedProviderAttachmentReason({ + config: selectedEnvironmentRuntime?.serverConfig, + selection: modelSelection, + attachmentCount: attachments.length, + }); + if (unsupportedAttachmentReason !== null) { + Alert.alert("Remove attachments", unsupportedAttachmentReason); + return null; + } const feedbackCommand = attachments.length === 0 && (provider?.driver === "codex" || thread.session?.providerName === "codex") @@ -250,9 +285,9 @@ export function useThreadComposerState() { commandId: CommandId.make(metadata.commandId), text, attachments, - modelSelection: draft.modelSelection ?? thread.modelSelection, - runtimeMode: draft.runtimeMode ?? thread.runtimeMode, - interactionMode: draft.interactionMode ?? thread.interactionMode, + modelSelection, + runtimeMode, + interactionMode, createdAt: metadata.createdAt, }); clearComposerDraftContent(threadKey); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 68c973ff97e3..4ef3704b753e 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -14,13 +14,14 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; +import { Alert } from "react-native"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; import { toUploadChatImageAttachments } from "../lib/composerImages"; import { randomHex } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; -import { useProjects, useThreadShells } from "./entities"; +import { useProjects, useServerConfigs, useThreadShells } from "./entities"; import { confirmThreadOutboxMessageQueued, ensureThreadOutboxLoaded, @@ -28,6 +29,7 @@ import { } from "./thread-outbox"; import { isQueuedThreadCreationSendable, + getQueuedDeliveryUnsupportedProviderInputReason, modelSelectionsEqual, resolveThreadOutboxDeliveryAction, resolveThreadOutboxFailureAction, @@ -102,11 +104,13 @@ export function useThreadOutboxDrain(): void { const shellStatuses = useThreadOutboxShellStatuses(); const threads = useThreadShells(); const projects = useProjects(); + const serverConfigs = useServerConfigs(); const { connectedEnvironments } = useRemoteConnectionStatus(); const [retryTick, setRetryTick] = useState(0); const retryAttemptRef = useRef(new Map()); const retryNotBeforeRef = useRef(new Map()); const retryTimersRef = useRef(new Map>()); + const modeWarningShownRef = useRef(new Set()); useEffect(() => { ensureThreadOutboxLoaded(); @@ -319,6 +323,35 @@ export function useThreadOutboxDrain(): void { if (deliveryAction === "wait") { continue; } + if (deliveryAction === "send") { + const serverConfig = serverConfigs.get(nextQueuedMessage.environmentId); + if (!serverConfig) { + continue; + } + const queuedSettings = thread + ? resolveQueuedThreadSettings(nextQueuedMessage, thread) + : { + modelSelection: nextQueuedMessage.modelSelection, + runtimeMode: nextQueuedMessage.runtimeMode ?? DEFAULT_RUNTIME_MODE, + interactionMode: + nextQueuedMessage.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, + }; + const unsupportedReason = getQueuedDeliveryUnsupportedProviderInputReason({ + config: serverConfig, + message: nextQueuedMessage, + modelSelection: queuedSettings.modelSelection, + runtimeMode: queuedSettings.runtimeMode, + interactionMode: queuedSettings.interactionMode, + }); + if (unsupportedReason !== null) { + if (!modeWarningShownRef.current.has(nextQueuedMessage.messageId)) { + modeWarningShownRef.current.add(nextQueuedMessage.messageId); + Alert.alert("Change provider mode", unsupportedReason); + } + continue; + } + modeWarningShownRef.current.delete(nextQueuedMessage.messageId); + } // The live project shell is preferred for the workspace path, with the // snapshot taken at enqueue time as the fallback so a task never dies // just because its project shell is not loaded. @@ -419,6 +452,7 @@ export function useThreadOutboxDrain(): void { retryTick, sendQueuedCreation, sendQueuedMessage, + serverConfigs, shellStatuses, threads, ]); diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index bc7828dd8547..e83657fad730 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -13,7 +13,16 @@ import type * as AcpSchema from "effect-acp/schema"; const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH; +const ignoreSigterm = process.env.T3_ACP_IGNORE_SIGTERM === "1"; +const hangInitializeForever = process.env.T3_ACP_HANG_INITIALIZE_FOREVER === "1"; +const hangCreateSessionForever = process.env.T3_ACP_HANG_CREATE_SESSION_FOREVER === "1"; +const requireAuthentication = process.env.T3_ACP_REQUIRE_AUTHENTICATION === "1"; +const advertiseImagePromptCapability = process.env.T3_ACP_ADVERTISE_IMAGE_PROMPT === "1"; const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; +const alternatePermissionTool = process.env.T3_ACP_ALTERNATE_PERMISSION_TOOL === "1"; +const cycleEditPermissionKinds = process.env.T3_ACP_CYCLE_EDIT_PERMISSION_KINDS === "1"; +const omitAllowPermissionOptions = process.env.T3_ACP_NO_ALLOW_OPTIONS === "1"; +const emptyModelCatalog = process.env.T3_ACP_EMPTY_MODEL_CATALOG === "1"; const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; @@ -54,6 +63,7 @@ let currentReasoning = "medium"; let currentContext = "272k"; let currentFast = false; let promptCount = 0; +let authenticated = !requireAuthentication; let overlappingFirstPromptId: string | undefined; const cancelledSessions = new Set(); @@ -81,7 +91,9 @@ function writeJsonRpcNotification(method: string, params: unknown): void { process.once("SIGTERM", () => { logExit("SIGTERM"); - process.exit(0); + if (!ignoreSigterm) { + process.exit(0); + } }); process.once("SIGINT", () => { @@ -94,6 +106,18 @@ process.once("exit", (code) => { }); function configOptions(): ReadonlyArray { + if (emptyModelCatalog) { + return [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "", + options: [], + }, + ]; + } if (parameterizedModelPicker) { const baseOptions: Array = [ { @@ -297,26 +321,52 @@ const program = Effect.gen(function* () { const agent = yield* EffectAcpAgent.AcpAgent; yield* agent.handleInitialize((request) => + hangInitializeForever + ? Effect.never + : Effect.sync(() => { + parameterizedModelPicker = + request.clientCapabilities?._meta?.parameterizedModelPicker === true; + return { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + ...(advertiseImagePromptCapability + ? { + promptCapabilities: { + image: true, + audio: false, + embeddedContext: false, + }, + } + : {}), + }, + }; + }), + ); + + yield* agent.handleAuthenticate(() => Effect.sync(() => { - parameterizedModelPicker = - request.clientCapabilities?._meta?.parameterizedModelPicker === true; - return { - protocolVersion: 1, - agentCapabilities: { loadSession: true }, - }; + authenticated = true; + return {}; }), ); - yield* agent.handleAuthenticate(() => Effect.succeed({})); - - yield* agent.handleCreateSession(() => - Effect.succeed({ + yield* agent.handleCreateSession(() => { + if (hangCreateSessionForever) { + return Effect.never; + } + if (!authenticated) { + return Effect.fail( + AcpError.AcpRequestError.authRequired("Call authenticate before starting a session"), + ); + } + return Effect.succeed({ sessionId, modes: modeState(), models: modelState(), configOptions: configOptions(), - }), - ); + }); + }); const emitLoadReplayNotifications = (requestedSessionId: string) => { writeJsonRpcNotification("session/update", { @@ -518,7 +568,18 @@ const program = Effect.gen(function* () { return yield* Effect.never; } - if (hangPromptForever || (hangFirstPromptForever && promptCount === 1)) { + if (hangFirstPromptForever && promptCount === 1) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "first prompt started" }, + }, + }); + return yield* Effect.never; + } + + if (hangPromptForever) { return yield* Effect.never; } @@ -631,15 +692,29 @@ const program = Effect.gen(function* () { } if (emitToolCalls) { - const toolCallId = "tool-call-1"; + const toolCallId = `tool-call-${promptCount}`; + const cycledPermission = [ + { kind: "edit" as const, toolName: "Write" }, + { kind: "delete" as const, toolName: "Delete" }, + { kind: "move" as const, toolName: "Move" }, + { kind: "execute" as const, toolName: "Bash" }, + ][(promptCount - 1) % 4]; + const permissionKind = cycleEditPermissionKinds + ? (cycledPermission?.kind ?? "execute") + : "execute"; + const permissionToolName = cycleEditPermissionKinds + ? (cycledPermission?.toolName ?? "Bash") + : alternatePermissionTool && promptCount >= 3 + ? "run_commands" + : "Bash"; yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, update: { sessionUpdate: "tool_call", toolCallId, - title: "Terminal", - kind: "execute", + title: permissionToolName, + kind: permissionKind, status: "pending", rawInput: { command: ["cat", "server/package.json"], @@ -660,8 +735,8 @@ const program = Effect.gen(function* () { sessionId: requestedSessionId, toolCall: { toolCallId, - title: "`cat server/package.json`", - kind: "execute", + title: `${permissionToolName}: cat server/package.json`, + kind: permissionKind, status: "pending", content: [ { @@ -674,12 +749,20 @@ const program = Effect.gen(function* () { ], }, options: [ - { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" }, - { - optionId: permissionOptionIds.allowAlways, - name: "Allow always", - kind: "allow_always", - }, + ...(omitAllowPermissionOptions + ? [] + : [ + { + optionId: permissionOptionIds.allowOnce, + name: "Allow once", + kind: "allow_once" as const, + }, + { + optionId: permissionOptionIds.allowAlways, + name: "Allow always", + kind: "allow_always" as const, + }, + ]), { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" }, ], }); diff --git a/apps/server/src/provider/Drivers/ClineDriver.ts b/apps/server/src/provider/Drivers/ClineDriver.ts new file mode 100644 index 000000000000..1cb39fb69206 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClineDriver.ts @@ -0,0 +1,160 @@ +import { ClineSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeClineAdapter } from "../Layers/ClineAdapter.ts"; +import { + buildInitialClineProviderSnapshot, + checkClineProviderStatus, + enrichClineSnapshot, +} from "../Layers/ClineProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makePackageManagedProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +const decodeClineSettings = Schema.decodeSync(ClineSettings); + +const DRIVER_KIND = ProviderDriverKind.make("cline"); + +const UPDATE = makePackageManagedProviderMaintenanceResolver({ + provider: DRIVER_KIND, + npmPackageName: "cline", + homebrewFormula: null, + nativeUpdate: null, +}); + +export type ClineDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const ClineDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Cline", + supportsMultipleInstances: true, + }, + configSchema: ClineSettings, + defaultConfig: (): ClineSettings => decodeClineSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies ClineSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeClineAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + + const checkProvider = checkClineProviderStatus(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>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialClineProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichClineSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Cline snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/ClineAdapter.test.ts b/apps/server/src/provider/Layers/ClineAdapter.test.ts new file mode 100644 index 000000000000..74f3886599fe --- /dev/null +++ b/apps/server/src/provider/Layers/ClineAdapter.test.ts @@ -0,0 +1,879 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import { + ApprovalRequestId, + ClineSettings, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import { + type ClineAdapterLiveOptions, + makeClineAdapter, + makeClineThreadLockPool, +} from "./ClineAdapter.ts"; +const decodeClineSettings = Schema.decodeSync(ClineSettings); + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const mockAgentCommand = process.execPath; + +async function makeMockClineWrapper(extraEnv?: Record) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cline-acp-mock-")); + const wrapperPath = NodePath.join(dir, "fake-cline.sh"); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +${envExports} +exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@" +`; + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + await NodeFSP.chmod(wrapperPath, 0o755); + return wrapperPath; +} + +async function readJsonLines(filePath: string) { + const raw = await NodeFSP.readFile(filePath, "utf8"); + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + +const clineAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-cline-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +const makeTestAdapter = ( + binaryPath: string, + options?: Pick, +) => makeClineAdapter(decodeClineSettings({ binaryPath }), options).pipe(Effect.orDie); + +it.effect("releases Cline thread locks after serialized work and thread churn", () => + Effect.gen(function* () { + const locks = yield* makeClineThreadLockPool(); + const active = yield* Ref.make(0); + const maximumActive = yield* Ref.make(0); + const criticalSection = Effect.gen(function* () { + const activeNow = yield* Ref.updateAndGet(active, (value) => value + 1); + yield* Ref.update(maximumActive, (value) => Math.max(value, activeNow)); + yield* Effect.yieldNow; + yield* Ref.update(active, (value) => value - 1); + }); + + yield* Effect.all( + Array.from({ length: 32 }, () => locks.withLock("shared", criticalSection)), + { concurrency: "unbounded", discard: true }, + ); + assert.equal(yield* Ref.get(maximumActive), 1); + assert.equal(yield* locks.size, 0); + + yield* Effect.forEach( + Array.from({ length: 100 }, (_, index) => `thread-${index}`), + (threadId) => locks.withLock(threadId, Effect.void), + { discard: true }, + ); + assert.equal(yield* locks.size, 0); + }), +); + +it.layer(clineAdapterTestLayer)("ClineAdapterLive", (it) => { + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cline-mock-thread"); + const wrapperPath = yield* Effect.promise(() => + makeMockClineWrapper({ T3_ACP_ADVERTISE_IMAGE_PROMPT: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + assert.equal(session.provider, "cline"); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + assert.equal(session.model, "default"); + + yield* adapter.sendTurn({ + threadId, + input: "hello cline", + attachments: [], + }); + + yield* Deferred.await(turnCompleted); + yield* Fiber.interrupt(runtimeEventsFiber); + const types = runtimeEvents.map((e) => e.type); + + assert.includeMembers(types, [ + "session.started", + "session.state.changed", + "thread.started", + "turn.started", + "content.delta", + "turn.completed", + ] as const); + + const sessionStarted = runtimeEvents.find((event) => event.type === "session.started"); + assert.isDefined(sessionStarted); + if (sessionStarted?.type === "session.started") { + const resume = sessionStarted.payload.resume as { + readonly agentCapabilities?: { + readonly promptCapabilities?: { readonly image?: boolean }; + }; + }; + assert.isFalse(resume.agentCapabilities?.promptCapabilities?.image); + } + + const delta = runtimeEvents.find((e) => e.type === "content.delta"); + assert.isDefined(delta); + if (delta?.type === "content.delta") { + assert.equal(delta.payload.delta, "hello from mock"); + } + + const turnStarted = runtimeEvents.find((event) => event.type === "turn.started"); + assert.isDefined(turnStarted); + if (turnStarted?.type === "turn.started") { + assert.equal(turnStarted.payload.model, "default"); + } + assert.equal((yield* adapter.listSessions())[0]?.model, "default"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("resumes the exact Cline ACP session through session/load", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cline-resume-session"); + const sessionId = "cline-existing-session"; + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cline-acp-resume-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockClineWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId }, + }); + yield* adapter.stopSession(threadId); + + assert.deepStrictEqual(session.resumeCursor, { schemaVersion: 1, sessionId }); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const sessionLoad = requests.find((entry) => entry.method === "session/load"); + assert.isDefined(sessionLoad); + assert.equal( + (sessionLoad?.params as { readonly sessionId?: unknown } | undefined)?.sessionId, + sessionId, + ); + assert.isFalse(requests.some((entry) => entry.method === "session/new")); + }), + ); + + it.effect("rejects mixed and image-only turns before prompting Cline ACP", () => + Effect.forEach( + [ + { label: "mixed", input: "describe this image" }, + { label: "image-only", input: "" }, + ], + ({ label, input }) => + Effect.gen(function* () { + const threadId = ThreadId.make(`cline-${label}-attachment`); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cline-acp-image-rejection-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockClineWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const error = yield* Effect.flip( + adapter.sendTurn({ + threadId, + input, + attachments: [ + { + type: "image", + id: `${label}-image`, + name: "example.png", + mimeType: "image/png", + sizeBytes: 1, + }, + ], + }), + ); + + assert.equal(error._tag, "ProviderAdapterValidationError"); + if (error._tag === "ProviderAdapterValidationError") { + assert.equal( + error.issue, + "Cline CLI currently does not accept image attachments over ACP.", + ); + } + + yield* adapter.stopSession(threadId); + const logged = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isFalse(logged.some((entry) => entry.method === "session/prompt")); + }), + { discard: true }, + ), + ); + + it.effect("rejects whitespace before mutating turn state or prompting Cline ACP", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cline-whitespace-turn"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cline-acp-whitespace-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockClineWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const error = yield* Effect.flip( + adapter.sendTurn({ + threadId, + input: " \n\t ", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("cline"), + model: "composer-2", + }, + }), + ); + assert.equal(error._tag, "ProviderAdapterValidationError"); + + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + assert.isFalse( + runtimeEvents.some( + (event) => event.type === "turn.started" || event.type === "turn.completed", + ), + ); + const logged = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isFalse(logged.some((entry) => entry.method === "session/prompt")); + assert.isFalse( + logged.some( + (entry) => + entry.method === "session/set_config_option" && + (entry.params as { readonly value?: unknown }).value === "composer-2", + ), + ); + }), + ); + + it.effect("rejects an empty Cline model catalog before setting a model or prompting", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cline-empty-model-catalog"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cline-acp-empty-models-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockClineWrapper({ + T3_ACP_EMPTY_MODEL_CATALOG: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const error = yield* Effect.flip( + adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId: ProviderInstanceId.make("cline"), + model: "gpt-5.6-sol", + }, + }), + ); + + assert.equal(error._tag, "ProviderAdapterValidationError"); + if (error._tag === "ProviderAdapterValidationError") { + assert.include(error.issue, "did not advertise any usable models"); + } + const logged = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isFalse(logged.some((entry) => entry.method === "session/set_config_option")); + assert.isFalse(logged.some((entry) => entry.method === "session/prompt")); + }), + ); + + it.effect("rejects non-full-access modes before spawning Cline ACP", () => + Effect.forEach( + ["approval-required", "auto-accept-edits", "auto"] as const, + (runtimeMode) => + Effect.gen(function* () { + const adapter = yield* makeTestAdapter("/definitely/missing/cline"); + const error = yield* Effect.flip( + adapter.startSession({ + threadId: ThreadId.make(`cline-unsupported-mode-${runtimeMode}`), + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode, + }), + ); + + assert.equal(error._tag, "ProviderAdapterValidationError"); + if (error._tag === "ProviderAdapterValidationError") { + assert.include(error.issue, "requires Full access"); + } + }), + { discard: true }, + ), + ); + + it.effect("times out a hung ACP startup and force-closes its child", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cline-hung-startup"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cline-acp-hung-startup-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + const wrapperPath = yield* Effect.promise(() => + makeMockClineWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + T3_ACP_HANG_INITIALIZE_FOREVER: "1", + T3_ACP_IGNORE_SIGTERM: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { sessionStartTimeout: "500 millis" }); + + const error = yield* Effect.flip( + adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + }), + ); + + assert.equal(error._tag, "ProviderAdapterProcessError"); + if (error._tag === "ProviderAdapterProcessError") { + assert.include(error.detail, "startup timed out"); + } + assert.isFalse(yield* adapter.hasSession(threadId)); + assert.include(yield* Effect.promise(() => NodeFSP.readFile(exitLogPath, "utf8")), "SIGTERM"); + }).pipe(TestClock.withLive), + ); + + it.effect("selects models through ACP config options on start and steer", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cline-model-config-thread"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cline-acp-request-log-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.jsonl"); + const wrapperPath = yield* Effect.promise(() => + makeMockClineWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cline"), model: "composer-2" }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "switch models mid-thread", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("cline"), + model: "composer-2[fast=true]", + }, + }); + + // The mock resolves prompts immediately; stopping the session gives a + // deterministic observation point before reading what the mock logged. + yield* adapter.stopSession(threadId); + + const logged = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isFalse(logged.some((entry) => entry.method === "authenticate")); + const sessionNew = logged.find((entry) => entry.method === "session/new"); + assert.deepStrictEqual( + (sessionNew?.params as { readonly mcpServers?: ReadonlyArray } | undefined) + ?.mcpServers, + [], + ); + const setConfigRequests = logged + .filter((entry) => entry.method === "session/set_config_option") + .map((entry) => entry.params as Record); + + // Model writes happen at session start and again when the steer lands; + // the adapter may interleave a mode write between them. + assert.deepStrictEqual( + setConfigRequests + .filter((params) => params.configId === "model") + .map((params) => params.value), + ["composer-2", "composer-2[fast=true]"], + ); + + const modeRequests = setConfigRequests.filter((params) => params.configId === "mode"); + assert.isTrue(modeRequests.every((request) => request.value !== "plan")); + }), + ); + + it.effect("does not retain prompt bodies in the adapter thread snapshot", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cline-lightweight-thread-snapshot"); + const wrapperPath = yield* Effect.promise(() => makeMockClineWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + const privatePrompt = `private-marker-${"x".repeat(100_000)}`; + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: privatePrompt, + attachments: [], + }); + + const snapshot = yield* adapter.readThread(threadId); + assert.equal(snapshot.turns.length, 1); + assert.deepEqual(snapshot.turns[0]?.items, []); + }), + ); + + it.effect("rejects Plan mode before mutating turn state or prompting Cline", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cline-plan-mode-rejected"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cline-acp-plan-mode-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockClineWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const error = yield* Effect.flip( + adapter.sendTurn({ + threadId, + input: "plan this change", + attachments: [], + interactionMode: "plan", + }), + ); + assert.equal(error._tag, "ProviderAdapterValidationError"); + if (error._tag === "ProviderAdapterValidationError") { + assert.include(error.issue, "Plan mode is unavailable"); + } + + yield* adapter.stopSession(threadId); + yield* Fiber.interrupt(eventsFiber); + assert.isFalse(runtimeEvents.some((event) => event.type === "turn.started")); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isFalse(requests.some((entry) => entry.method === "session/prompt")); + }), + ); + + it.effect("rejects an explicit model outside Cline's advertised catalog before RPC", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cline-invalid-explicit-model"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cline-acp-invalid-model-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockClineWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const error = yield* Effect.flip( + adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId: ProviderInstanceId.make("cline"), + model: "cline/retired-model", + }, + }), + ); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + if (error._tag === "ProviderAdapterRequestError") { + assert.include(error.detail, "Invalid value"); + } + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isFalse(requests.some((entry) => entry.method === "session/set_config_option")); + }), + ); + + it.effect("auto-approves tool permissions in full-access mode", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cline-full-access-thread"); + const wrapperPath = yield* Effect.promise(() => + makeMockClineWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "run it", attachments: [] }); + yield* Deferred.await(turnCompleted); + yield* Fiber.interrupt(runtimeEventsFiber); + + const approvalOpened = runtimeEvents.some((e) => e.type === "request.opened"); + assert.isFalse(approvalOpened); + const completed = runtimeEvents.find((e) => e.type === "turn.completed"); + if (completed?.type !== "turn.completed") { + return assert.fail("expected a turn.completed runtime event"); + } + assert.equal(completed.payload.state, "completed"); + }), + ); + + it.effect("accepts only the first response to a pending approval", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cline-first-approval-response"); + const wrapperPath = yield* Effect.promise(() => + makeMockClineWrapper({ + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_NO_ALLOW_OPTIONS: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + let duplicateErrorTag: string | undefined; + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (event.type !== "request.opened") return; + const requestId = ApprovalRequestId.make(String(event.requestId)); + yield* adapter.respondToRequest(threadId, requestId, "accept"); + const duplicateError = yield* Effect.flip( + adapter.respondToRequest(threadId, requestId, "cancel"), + ); + duplicateErrorTag = duplicateError._tag; + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "approve once", attachments: [] }); + + yield* adapter.stopSession(threadId); + yield* Fiber.interrupt(eventsFiber); + assert.equal(duplicateErrorTag, "ProviderAdapterRequestError"); + }), + ); + + it.effect("rejects an approval response after interruption has cancelled it", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cline-late-approval-response"); + const wrapperPath = yield* Effect.promise(() => + makeMockClineWrapper({ + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_NO_ALLOW_OPTIONS: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + let lateErrorTag: string | undefined; + const lateResponseChecked = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + if (event.type !== "request.opened") return; + const requestId = ApprovalRequestId.make(String(event.requestId)); + yield* adapter.interruptTurn(threadId); + const lateError = yield* Effect.flip( + adapter.respondToRequest(threadId, requestId, "accept"), + ); + lateErrorTag = lateError._tag; + yield* Deferred.succeed(lateResponseChecked, undefined); + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "interrupt approval", attachments: [] }); + yield* Deferred.await(lateResponseChecked); + + yield* adapter.stopSession(threadId); + yield* Fiber.interrupt(eventsFiber); + assert.equal(lateErrorTag, "ProviderAdapterRequestError"); + }), + ); + + it.effect("rejects structured user input because Cline ACP does not request it", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cline-no-structured-user-input"); + const wrapperPath = yield* Effect.promise(() => makeMockClineWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const error = yield* Effect.flip( + adapter.respondToUserInput(threadId, ApprovalRequestId.make("unknown-user-input"), {}), + ); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + if (error._tag === "ProviderAdapterRequestError") { + assert.include(error.detail, "no pending structured user-input request"); + } + }), + ); + + it.effect("cancels an already queued steer without sending a late prompt", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cline-cancel-queued-steer"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cline-acp-cancel-queued-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockClineWrapper({ + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const firstPromptStarted = yield* Deferred.make(); + const turnCompleted = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (event.type === "content.delta" && event.payload.delta === "first prompt started") { + yield* Deferred.succeed(firstPromptStarted, undefined); + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, undefined); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const firstTurnFiber = yield* adapter + .sendTurn({ threadId, input: "hang first", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(firstPromptStarted); + + const queuedTurnFiber = yield* adapter + .sendTurn({ threadId, input: "queued steer", attachments: [] }) + .pipe(Effect.forkChild); + // Give the queued turn a scheduler turn so it reaches the runtime's + // prompt semaphore before cancellation advances the generation. + yield* Effect.yieldNow; + yield* adapter.interruptTurn(threadId); + yield* Fiber.join(firstTurnFiber); + yield* Fiber.join(queuedTurnFiber); + yield* Deferred.await(turnCompleted); + + yield* adapter.stopSession(threadId); + yield* Fiber.interrupt(eventsFiber); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.lengthOf( + requests.filter((entry) => entry.method === "session/prompt"), + 1, + ); + const completed = runtimeEvents.filter((event) => event.type === "turn.completed"); + assert.lengthOf(completed, 1); + if (completed[0]?.type === "turn.completed") { + assert.equal(completed[0].payload.state, "cancelled"); + } + }), + ); + + it.effect("closes the ACP child process when a session stops", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cline-stop-session-close"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cline-adapter-exit-log-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + + const wrapperPath = yield* Effect.promise(() => + makeMockClineWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + assert.isTrue(yield* adapter.hasSession(threadId)); + + yield* adapter.stopSession(threadId); + assert.isFalse(yield* adapter.hasSession(threadId)); + + const exitLog = yield* Effect.tryPromise(() => NodeFSP.readFile(exitLogPath, "utf8")).pipe( + Effect.orElseSucceed(() => ""), + ); + assert.include(exitLog, "exit:"); + }), + ); + + it.effect("force-kills an ACP child that ignores TERM during session stop", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cline-stop-session-force-close"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cline-adapter-force-exit-log-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + const wrapperPath = yield* Effect.promise(() => + makeMockClineWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + T3_ACP_IGNORE_SIGTERM: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.stopSession(threadId); + + assert.isFalse(yield* adapter.hasSession(threadId)); + assert.include(yield* Effect.promise(() => NodeFSP.readFile(exitLogPath, "utf8")), "SIGTERM"); + }), + ); + + it.effect("reports rollback as unsupported instead of mutating only T3's local view", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cline-rollback-unsupported"); + const wrapperPath = yield* Effect.promise(() => makeMockClineWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const error = yield* Effect.flip(adapter.rollbackThread(threadId, 1)); + assert.equal(error._tag, "ProviderAdapterRequestError"); + if (error._tag === "ProviderAdapterRequestError") { + assert.include(error.detail, "do not support provider-side rollback"); + } + + yield* adapter.stopSession(threadId); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/ClineAdapter.ts b/apps/server/src/provider/Layers/ClineAdapter.ts new file mode 100644 index 000000000000..00206913700b --- /dev/null +++ b/apps/server/src/provider/Layers/ClineAdapter.ts @@ -0,0 +1,1087 @@ +/** + * ClineAdapterLive — Cline CLI (`cline --acp`) via ACP. + * + * @module ClineAdapterLive + */ + +import { + ApprovalRequestId, + type ClineSettings, + EventId, + type ProviderApprovalDecision, + type ProviderInteractionMode, + type ProviderRuntimeEvent, + type ProviderSession, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type RuntimeMode, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import type * 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 Path from "effect/Path"; +import * as Option from "effect/Option"; +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 { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { + type AcpSessionMode, + type AcpSessionModeState, + parsePermissionRequest, +} from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + applyClineAcpModelSelection, + CLINE_PROCESS_FORCE_KILL_AFTER, + clineModelsFromSessionConfigOptions, + currentClineModelIdFromSessionSetup, + makeClineAcpRuntime, + startClineAcpRuntimeWithTimeout, +} from "../acp/ClineAcpSupport.ts"; +import { type ClineAdapterShape } from "../Services/ClineAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +const PROVIDER = ProviderDriverKind.make("cline"); +const CLINE_RESUME_VERSION = 1 as const; +const ACP_PLAN_MODE_ALIASES = ["plan", "architect"]; +const ACP_IMPLEMENT_MODE_ALIASES = ["act", "code", "agent", "default", "chat", "implement"]; +const ACP_APPROVAL_MODE_ALIASES = ["ask"]; +const CLINE_SESSION_START_TIMEOUT = "30 seconds"; + +function clineInitializeResultForSnapshot( + result: EffectAcpSchema.InitializeResponse, +): EffectAcpSchema.InitializeResponse { + return { + ...result, + agentCapabilities: { + ...result.agentCapabilities, + promptCapabilities: { + ...result.agentCapabilities?.promptCapabilities, + // Current Cline advertises image prompts but filters every non-text + // block before dispatch. Keep the T3 snapshot truthful until the CLI + // actually consumes ACP image blocks. + image: false, + }, + }, + }; +} + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +const mapHandlerFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Cline ACP handler.", + cause, + }), + ), + ); + +export interface ClineAdapterLiveOptions { + 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 (`cline`). + */ + 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 `clineSettings` 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 pass a resolver + * that reads the latest snapshot so the closure isn't stale. + */ + readonly resolveSettings?: Effect.Effect; + /** Override only for deterministic startup timeout tests. */ + readonly sessionStartTimeout?: Duration.Input; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; + readonly kind: string | "unknown"; +} + +interface ClineSessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + /** Number of sendTurn prompts currently in flight or being prepared. */ + promptsInFlight: number; + stopped: boolean; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: Map, +): Effect.Effect { + return Effect.suspend(() => { + const pendingEntries = Array.from(pendingApprovals.values()); + pendingApprovals.clear(); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { + discard: true, + }, + ); + }); +} + +const decodeClineResumeExit = Schema.decodeUnknownExit( + Schema.Struct({ + schemaVersion: Schema.Literal(CLINE_RESUME_VERSION), + sessionId: Schema.String, + }), +); + +function parseClineResume(raw: unknown): { sessionId: string } | undefined { + const decoded = decodeClineResumeExit(raw); + if (Exit.isFailure(decoded)) return undefined; + const sessionId = decoded.value.sessionId.trim(); + return sessionId.length > 0 ? { sessionId } : undefined; +} + +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; + } + + if (input.runtimeMode === "approval-required") { + return ( + findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? + findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + modeState.currentModeId + ); + } + + return ( + findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? + findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + modeState.currentModeId + ); +} + +function selectPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + const kind = + decision === "acceptForSession" + ? "allow_always" + : decision === "accept" + ? "allow_once" + : "reject_once"; + const option = request.options.find((entry) => entry.kind === kind); + return option?.optionId.trim() || undefined; +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + return ( + selectPermissionOptionId(request, "acceptForSession") ?? + selectPermissionOptionId(request, "accept") + ); +} + +interface ClineThreadLockEntry { + readonly semaphore: Semaphore.Semaphore; + readonly users: number; +} + +export interface ClineThreadLockPool { + readonly withLock: ( + threadId: string, + effect: Effect.Effect, + ) => Effect.Effect; + readonly size: Effect.Effect; +} + +/** A keyed mutex pool that retains keys only while holders or waiters exist. */ +export const makeClineThreadLockPool = Effect.fn("makeClineThreadLockPool")(function* () { + const locksRef = yield* SynchronizedRef.make(new Map()); + + const acquireLock = Effect.fn("clineThreadLockPool.acquireLock")(function* (threadId: string) { + return yield* SynchronizedRef.modifyEffect(locksRef, (current) => { + const existing = current.get(threadId); + if (existing) { + const next = new Map(current); + next.set(threadId, { ...existing, users: existing.users + 1 }); + return Effect.succeed([existing.semaphore, next] as const); + } + return Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, { semaphore, users: 1 }); + return [semaphore, next] as const; + }), + ); + }); + }); + + const releaseLock = Effect.fn("clineThreadLockPool.releaseLock")(function* ( + threadId: string, + semaphore: Semaphore.Semaphore, + ) { + yield* SynchronizedRef.update(locksRef, (current) => { + const existing = current.get(threadId); + if (!existing || existing.semaphore !== semaphore) { + return current; + } + const next = new Map(current); + if (existing.users === 1) { + next.delete(threadId); + } else { + next.set(threadId, { ...existing, users: existing.users - 1 }); + } + return next; + }); + }); + + const withLock = (threadId: string, effect: Effect.Effect) => + Effect.acquireUseRelease( + acquireLock(threadId), + (semaphore) => semaphore.withPermit(effect), + (semaphore) => releaseLock(threadId, semaphore), + ); + + return { + withLock, + size: SynchronizedRef.get(locksRef).pipe(Effect.map((locks) => locks.size)), + } satisfies ClineThreadLockPool; +}); + +export const makeClineAdapter = Effect.fn("makeClineAdapter")(function* ( + clineSettings: ClineSettings, + options?: ClineAdapterLiveOptions, +) { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("cline"); + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + 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 threadLocks = yield* makeClineThreadLockPool(); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate Cline runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const withThreadLock = threadLocks.withLock; + + const logNative = Effect.fn("ClineAdapter.logNative")(function* ( + threadId: ThreadId, + method: string, + payload: unknown, + ) { + 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 = Effect.fn("ClineAdapter.emitPlanUpdate")(function* ( + ctx: ClineSessionContext, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + method: string, + ) { + const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload, + source: "acp.jsonrpc", + method, + 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 = Effect.fn("ClineAdapter.stopSessionInternal")(function* ( + ctx: ClineSessionContext, + ) { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + 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 applyRequestedSessionConfiguration = Effect.fn( + "ClineAdapter.applyRequestedSessionConfiguration", + )(function* (input: { + readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly threadId: ThreadId; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode | undefined; + readonly requestedModelId: string | undefined; + }) { + yield* applyClineAcpModelSelection({ + runtime: input.runtime, + requestedModelId: input.requestedModelId, + mapError: (cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_config_option", cause), + }).pipe(Effect.asVoid); + + 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) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_mode", cause), + ), + ); + }); + + const startSession: ClineAdapterShape["startSession"] = Effect.fn("ClineAdapter.startSession")( + function* (input) { + return yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + if (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.", + }); + } + if (input.runtimeMode !== "full-access") { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: + "Cline currently requires Full access because ACP loads executable workspace and account extensions outside T3's permission requests.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const clineModelSelection = + 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 sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + let ctx!: ClineSessionContext; + + const resumeSessionId = parseClineResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + const effectiveClineSettings = options?.resolveSettings + ? yield* options.resolveSettings + : clineSettings; + + const acp = yield* makeClineAcpRuntime({ + clineSettings: effectiveClineSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + forceKillAfter: CLINE_PROCESS_FORCE_KILL_AFTER, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + + const started = yield* Effect.gen(function* () { + yield* acp.handleRequestPermission((params) => + mapHandlerFailure( + Effect.gen(function* () { + yield* logNative(input.threadId, "session/request_permission", params); + const permissionRequest = parsePermissionRequest(params); + 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(); + pendingApprovals.set(requestId, { + decision, + kind: permissionRequest.kind, + }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + const selectedOptionId = + resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved); + return { + outcome: selectedOptionId + ? { + outcome: "selected" as const, + optionId: selectedOptionId, + } + : ({ outcome: "cancelled" } as const), + }; + }), + ), + ); + const started = yield* startClineAcpRuntimeWithTimeout({ + runtime: acp, + timeout: options?.sessionStartTimeout ?? CLINE_SESSION_START_TIMEOUT, + forceKillAfter: CLINE_PROCESS_FORCE_KILL_AFTER, + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + if (Option.isNone(started)) { + return yield* new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: + "Cline ACP session startup timed out. Check the Cline CLI configuration and try again.", + }); + } + return started.value; + }); + + if (clineModelsFromSessionConfigOptions(started.sessionSetupResult).length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: + "Cline ACP did not advertise any usable models. Configure a provider and model in Cline, then start a new session.", + }); + } + + yield* applyRequestedSessionConfiguration({ + runtime: acp, + threadId: input.threadId, + runtimeMode: input.runtimeMode, + interactionMode: undefined, + requestedModelId: clineModelSelection?.model, + }); + + const requestedSessionModel = clineModelSelection?.model.trim(); + const sessionModel = + requestedSessionModel || + currentClineModelIdFromSessionSetup(started.sessionSetupResult); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: sessionModel, + threadId: input.threadId, + resumeCursor: { + schemaVersion: CLINE_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + ctx = { + threadId: input.threadId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + promptsInFlight: 0, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + switch (event._tag) { + case "EventStreamBarrier": + yield* Deferred.succeed(event.acknowledge, undefined); + return; + case "ModeChanged": + return; + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* emitPlanUpdate(ctx, event.payload, event.rawPayload, "session/update"); + return; + case "ToolCallUpdated": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Cline runtime notification.", { cause }), + ), + Effect.forkIn(ctx.scope), + ); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: clineInitializeResultForSnapshot(started.initializeResult) }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Cline ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + }, + ); + + const sendTurn: ClineAdapterShape["sendTurn"] = Effect.fn("ClineAdapter.sendTurn")( + function* (input) { + const ctx = yield* requireSession(input.threadId); + if (input.interactionMode === "plan") { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: + "Cline Plan mode is unavailable because ACP loads executable workspace and account extensions outside T3's permission requests.", + }); + } + if (input.attachments && input.attachments.length > 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Cline CLI currently does not accept image attachments over ACP.", + }); + } + const promptText = input.input?.trim(); + if (!promptText) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text.", + }); + } + // A sendTurn while a prompt is in flight is a steer: the agent folds + // the new prompt into the ongoing work, so the active turn id is + // reused instead of opening a new turn. + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + // Count this prompt immediately so a superseded in-flight prompt + // resolving from here on does not settle the turn; the matching + // decrement is the `ensuring` below. + ctx.promptsInFlight += 1; + + return yield* Effect.gen(function* () { + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const requestedTurnModel = turnModelSelection?.model.trim(); + const model = requestedTurnModel || ctx.session.model; + yield* applyRequestedSessionConfiguration({ + runtime: ctx.acp, + threadId: input.threadId, + runtimeMode: ctx.session.runtimeMode, + interactionMode: input.interactionMode, + requestedModelId: model, + }); + ctx.activeTurnId = turnId; + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model }, + }); + } + + const promptParts: Array = [ + { type: "text", text: promptText }, + ]; + + const result = yield* ctx.acp + .prompt({ + prompt: promptParts, + }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + if (!ctx.turns.some((turn) => turn.id === turnId)) { + // Cline remains the durable conversation owner. Keep only the + // lightweight turn identity needed by this adapter's read shape; + // retaining prompt bodies here would duplicate unbounded history. + ctx.turns.push({ id: turnId, items: [] }); + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + model, + }; + + // Only the last remaining prompt settles the turn — a steer- + // superseded prompt resolving (usually cancelled) while another is + // in flight or pending must leave the merged turn running. + if (ctx.promptsInFlight === 1) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason ?? null, + }, + }); + } + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }).pipe( + Effect.ensuring( + Effect.sync(() => { + ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); + }), + ), + ); + }, + ); + + const interruptTurn: ClineAdapterShape["interruptTurn"] = Effect.fn("ClineAdapter.interruptTurn")( + function* (threadId) { + const ctx = yield* requireSession(threadId); + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + }, + ); + + const respondToRequest: ClineAdapterShape["respondToRequest"] = Effect.fn( + "ClineAdapter.respondToRequest", + )(function* (threadId, requestId, decision) { + 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}`, + }); + } + ctx.pendingApprovals.delete(requestId); + const settled = yield* Deferred.succeed(pending.decision, decision); + if (!settled) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Approval request was already resolved: ${requestId}`, + }); + } + }); + + const respondToUserInput: ClineAdapterShape["respondToUserInput"] = Effect.fn( + "ClineAdapter.respondToUserInput", + )(function* (threadId, requestId) { + yield* requireSession(threadId); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "user-input", + detail: `Cline ACP has no pending structured user-input request: ${requestId}`, + }); + }); + + const readThread: ClineAdapterShape["readThread"] = Effect.fn("ClineAdapter.readThread")( + function* (threadId) { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }, + ); + + const rollbackThread: ClineAdapterShape["rollbackThread"] = Effect.fn( + "ClineAdapter.rollbackThread", + )(function* (threadId, numTurns) { + yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "Cline ACP sessions do not support provider-side rollback yet.", + }); + }); + + const stopSession: ClineAdapterShape["stopSession"] = Effect.fn("ClineAdapter.stopSession")( + function* (threadId) { + yield* withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + }, + ); + + const listSessions: ClineAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: ClineAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: ClineAdapterShape["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 Cline session shutdown event.", { cause }), + ), + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies ClineAdapterShape; +}); diff --git a/apps/server/src/provider/Layers/ClineProvider.test.ts b/apps/server/src/provider/Layers/ClineProvider.test.ts new file mode 100644 index 000000000000..6ac7ec98da4e --- /dev/null +++ b/apps/server/src/provider/Layers/ClineProvider.test.ts @@ -0,0 +1,251 @@ +// @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 { it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import * as EffectAcpErrors from "effect-acp/errors"; +import { describe, expect } from "vite-plus/test"; + +import { ClineSettings } from "@t3tools/contracts"; + +import { + buildInitialClineProviderSnapshot, + checkClineProviderStatus, + classifyClineDiscoveryFailure, +} from "./ClineProvider.ts"; + +const decodeClineSettings = Schema.decodeSync(ClineSettings); + +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const mockAgentCommand = process.execPath; + +async function makeMockClineWrapper(extraEnv?: Record) { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cline-provider-mock-")); + const wrapperPath = NodePath.join(dir, "fake-cline.sh"); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +${envExports} +if [ "$1" = "--version" ]; then + echo "cline 3.0.56" + exit 0 +fi +exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@" +`; + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + await NodeFSP.chmod(wrapperPath, 0o755); + return wrapperPath; +} + +describe("classifyClineDiscoveryFailure", () => { + it("treats the ACP authentication-required error code as unauthenticated", () => { + expect( + classifyClineDiscoveryFailure( + Cause.fail( + new EffectAcpErrors.AcpRequestError({ + code: -32000, + errorMessage: "Call authenticate before starting a session", + method: "session/new", + }), + ), + ), + ).toEqual({ + kind: "unauthenticated", + }); + }); + + it("classifies other failures with their error tag", () => { + expect( + classifyClineDiscoveryFailure(Cause.fail(EffectAcpErrors.AcpRequestError.internalError())), + ).toEqual({ + kind: "failed", + errorTag: "AcpRequestError", + }); + expect(classifyClineDiscoveryFailure(Cause.die("boom"))).toEqual({ + kind: "failed", + errorTag: "Die", + }); + }); + + it("does not treat unrelated server errors as authentication failures", () => { + expect( + classifyClineDiscoveryFailure( + Cause.fail( + new EffectAcpErrors.AcpRequestError({ + code: -32000, + errorMessage: "Provider is still warming up", + method: "session/new", + }), + ), + ), + ).toEqual({ kind: "failed", errorTag: "AcpRequestError" }); + expect( + classifyClineDiscoveryFailure( + Cause.fail( + new EffectAcpErrors.AcpRequestError({ + code: -32000, + errorMessage: "Authentication required", + method: "initialize", + }), + ), + ), + ).toEqual({ kind: "failed", errorTag: "AcpRequestError" }); + }); +}); + +describe("ClineProvider", () => { + it.effect("builds a disabled initial snapshot", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialClineProviderSnapshot(decodeClineSettings({})); + expect(snapshot.displayName).toBe("Cline"); + expect(snapshot.badgeLabel).toBe("Early Access"); + expect(snapshot.showInteractionModeToggle).toBe(false); + expect(snapshot.supportedRuntimeModes).toEqual(["full-access"]); + expect(snapshot.requiresNewThreadForModelChange).toBe(false); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + }), + ); + + it.effect("builds a checking initial snapshot when enabled", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialClineProviderSnapshot( + decodeClineSettings({ enabled: true }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.message).toContain("Checking Cline CLI availability"); + }), + ); + + it.effect("reports an error when the CLI is missing", () => + checkClineProviderStatus( + decodeClineSettings({ + enabled: true, + binaryPath: NodePath.join(NodeOS.tmpdir(), "cline-does-not-exist"), + }), + ).pipe( + Effect.provide(NodeServices.layer), + Effect.map((snapshot) => { + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toContain("npm install -g cline"); + }), + ), + ); + + it.effect("discovers models over ACP when the CLI is healthy", () => + Effect.gen(function* () { + const binaryPath = yield* Effect.promise(() => makeMockClineWrapper()); + const snapshot = yield* checkClineProviderStatus( + decodeClineSettings({ enabled: true, binaryPath }), + ).pipe(Effect.provide(NodeServices.layer)); + expect(snapshot.version).toBe("3.0.56"); + expect(snapshot.status).toBe("ready"); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.supportsImageAttachments).toBe(false); + + const slugs = snapshot.models.map((model) => model.slug); + expect(slugs).toContain("composer-2"); + expect(slugs).toContain("gpt-5.3-codex[reasoning=medium,fast=false]"); + expect(snapshot.models.find((model) => model.slug === "composer-2")?.name).toBe("Composer 2"); + const defaults = snapshot.models.filter((model) => model.isDefault === true); + expect(defaults).toHaveLength(1); + expect(defaults[0]?.slug).toBe("default"); + }), + ); + + it.effect("bounds hung ACP model discovery and terminates its child", () => + Effect.gen(function* () { + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cline-provider-hung-discovery-")), + ); + const exitLogPath = NodePath.join(tempDir, "exit.log"); + const binaryPath = yield* Effect.promise(() => + makeMockClineWrapper({ + T3_ACP_EXIT_LOG_PATH: exitLogPath, + T3_ACP_HANG_INITIALIZE_FOREVER: "1", + T3_ACP_IGNORE_SIGTERM: "1", + }), + ); + + const snapshot = yield* checkClineProviderStatus( + decodeClineSettings({ enabled: true, binaryPath }), + process.env, + { acpModelDiscoveryTimeout: "500 millis" }, + ).pipe(Effect.provide(NodeServices.layer)); + + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toContain("ACP startup failed"); + expect(yield* Effect.promise(() => NodeFSP.readFile(exitLogPath, "utf8"))).toContain( + "SIGTERM", + ); + }).pipe(TestClock.withLive), + ); + + it.effect( + "reports saved-credential guidance without starting interactive ACP authentication", + () => + Effect.gen(function* () { + const binaryPath = yield* Effect.promise(() => + makeMockClineWrapper({ T3_ACP_REQUIRE_AUTHENTICATION: "1" }), + ); + const snapshot = yield* checkClineProviderStatus( + decodeClineSettings({ enabled: true, binaryPath }), + ).pipe(Effect.provide(NodeServices.layer)); + + expect(snapshot.status).toBe("warning"); + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.message).toContain("`cline auth`"); + expect(snapshot.message).not.toContain("auth login"); + }), + ); + + it.effect("keeps advertised models available without exposing configured unadvertised ids", () => + Effect.gen(function* () { + const binaryPath = yield* Effect.promise(() => makeMockClineWrapper()); + const snapshot = yield* checkClineProviderStatus( + decodeClineSettings({ + enabled: true, + binaryPath, + customModels: ["composer-2", "cline/custom/unadvertised"], + }), + ).pipe(Effect.provide(NodeServices.layer)); + const slugs = snapshot.models.map((model) => model.slug); + expect(slugs).toContain("composer-2"); + expect(slugs).not.toContain("cline/custom/unadvertised"); + const customModel = snapshot.models.find((model) => model.slug === "composer-2"); + expect(customModel?.isCustom).toBe(false); + }), + ); + + it.effect("reports an authenticated error when Cline advertises no usable models", () => + Effect.gen(function* () { + const binaryPath = yield* Effect.promise(() => + makeMockClineWrapper({ T3_ACP_EMPTY_MODEL_CATALOG: "1" }), + ); + const snapshot = yield* checkClineProviderStatus( + decodeClineSettings({ + enabled: true, + binaryPath, + customModels: ["cline/custom/unadvertised"], + }), + ).pipe(Effect.provide(NodeServices.layer)); + + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.models).toEqual([]); + expect(snapshot.message).toContain("did not advertise any usable models"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/ClineProvider.ts b/apps/server/src/provider/Layers/ClineProvider.ts new file mode 100644 index 000000000000..1718ade212b8 --- /dev/null +++ b/apps/server/src/provider/Layers/ClineProvider.ts @@ -0,0 +1,366 @@ +import { + type ClineSettings, + type ModelCapabilities, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import type * as Duration from "effect/Duration"; +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 { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as EffectAcpErrors from "effect-acp/errors"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { + CLINE_PROCESS_FORCE_KILL_AFTER, + clineModelsFromSessionConfigOptions, + makeClineAcpRuntime, + startClineAcpRuntimeWithTimeout, +} from "../acp/ClineAcpSupport.ts"; + +const CLINE_PRESENTATION = { + displayName: "Cline", + badgeLabel: "Early Access", + showInteractionModeToggle: false, + supportedRuntimeModes: ["full-access"], + supportsImageAttachments: false, + requiresNewThreadForModelChange: false, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const CLINE_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; + +export const buildInitialClineProviderSnapshot = Effect.fn("buildInitialClineProviderSnapshot")( + function* (clineSettings: ClineSettings): Effect.fn.Return { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models: ReadonlyArray = []; + + if (!clineSettings.enabled) { + return buildServerProvider({ + presentation: CLINE_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Cline is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: CLINE_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Cline CLI availability...", + }, + }); + }, +); + +export type ClineDiscoveryOutcome = + | { readonly kind: "ok"; readonly models: ReadonlyArray } + | { readonly kind: "unauthenticated" } + | { readonly kind: "failed"; readonly errorTag: string }; + +const isAcpRequestError = Schema.is(EffectAcpErrors.AcpRequestError); +const CLINE_SESSION_SETUP_METHODS = new Set(["session/new", "session/load", "session/resume"]); +const CLINE_AUTH_REQUIRED_MESSAGE = /authenticat|sign(?:ed)?[ -]?in|credential|api[ _-]?key/i; + +export const classifyClineDiscoveryFailure = ( + cause: Cause.Cause, +): ClineDiscoveryOutcome => { + const error = Cause.findErrorOption(cause); + if ( + Option.isSome(error) && + isAcpRequestError(error.value) && + error.value.code === -32000 && + error.value.method !== undefined && + CLINE_SESSION_SETUP_METHODS.has(error.value.method) && + CLINE_AUTH_REQUIRED_MESSAGE.test(error.value.errorMessage) + ) { + return { kind: "unauthenticated" }; + } + return { kind: "failed", errorTag: causeErrorTag(cause) }; +}; + +const discoverClineModelsViaAcp = Effect.fn("discoverClineModelsViaAcp")(function* ( + clineSettings: ClineSettings, + environment: NodeJS.ProcessEnv = process.env, + timeout: Duration.Input = CLINE_ACP_MODEL_DISCOVERY_TIMEOUT_MS, +) { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* Effect.gen(function* () { + const acp = yield* makeClineAcpRuntime({ + clineSettings, + environment, + childProcessSpawner, + cwd: process.cwd(), + forceKillAfter: CLINE_PROCESS_FORCE_KILL_AFTER, + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + + const started = yield* startClineAcpRuntimeWithTimeout({ + runtime: acp, + timeout, + forceKillAfter: CLINE_PROCESS_FORCE_KILL_AFTER, + }); + + if (Option.isNone(started)) { + return { kind: "failed", errorTag: "Timeout" } satisfies ClineDiscoveryOutcome; + } + + const models = clineModelsFromSessionConfigOptions(started.value.sessionSetupResult).map( + (model): ServerProviderModel => ({ + slug: model.slug, + name: model.name, + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + ...(model.isDefault ? { isDefault: true } : {}), + }), + ); + return { kind: "ok", models } satisfies ClineDiscoveryOutcome; + }).pipe( + Effect.catchCause((cause) => Effect.succeed(classifyClineDiscoveryFailure(cause))), + Effect.scoped, + ); +}); + +const runClineVersionCommand = Effect.fn("runClineVersionCommand")(function* ( + clineSettings: ClineSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const command = clineSettings.binaryPath || "cline"; + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + forceKillAfter: CLINE_PROCESS_FORCE_KILL_AFTER, + shell: spawnCommand.shell, + }), + ); +}); + +export const checkClineProviderStatus = Effect.fn("checkClineProviderStatus")(function* ( + clineSettings: ClineSettings, + environment: NodeJS.ProcessEnv = process.env, + options?: { readonly acpModelDiscoveryTimeout?: Duration.Input }, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels: ReadonlyArray = []; + + if (!clineSettings.enabled) { + return buildServerProvider({ + presentation: CLINE_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Cline is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runClineVersionCommand(clineSettings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning("Cline CLI health check failed.", { + errorTag: error._tag, + }); + return buildServerProvider({ + presentation: CLINE_PRESENTATION, + enabled: clineSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Cline CLI (`cline`) is not installed or not on PATH. Install it with `npm install -g cline`." + : "Failed to execute Cline CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: CLINE_PRESENTATION, + enabled: clineSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Cline CLI is installed but timed out while running `cline --version`.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + yield* Effect.logWarning("Cline CLI version probe exited with a non-zero status.", { + exitCode: versionOutput.code, + stdoutLength: versionOutput.stdout.length, + stderrLength: versionOutput.stderr.length, + }); + return buildServerProvider({ + presentation: CLINE_PRESENTATION, + enabled: clineSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Cline CLI is installed but failed to run.", + }, + }); + } + + const outcome = yield* discoverClineModelsViaAcp( + clineSettings, + environment, + options?.acpModelDiscoveryTimeout, + ); + if (outcome.kind === "unauthenticated") { + return buildServerProvider({ + presentation: CLINE_PRESENTATION, + enabled: clineSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "warning", + auth: { status: "unauthenticated" }, + message: + "Cline CLI is installed but not signed in. Run `cline auth` in a terminal, then re-check.", + }, + }); + } + if (outcome.kind === "failed") { + yield* Effect.logWarning("Cline ACP model discovery failed", { + errorTag: outcome.errorTag, + }); + return buildServerProvider({ + presentation: CLINE_PRESENTATION, + enabled: clineSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Cline CLI is installed but ACP startup failed. Check server logs for details.", + }, + }); + } + + const models = outcome.models; + + if (models.length === 0) { + return buildServerProvider({ + presentation: CLINE_PRESENTATION, + enabled: clineSettings.enabled, + checkedAt, + models: [], + probe: { + installed: true, + version, + status: "error", + auth: { status: "authenticated" }, + message: + "Cline is signed in but did not advertise any usable models. Configure a provider and model in Cline, then re-check.", + }, + }); + } + + return buildServerProvider({ + presentation: CLINE_PRESENTATION, + enabled: clineSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "authenticated" }, + }, + }); +}); + +export const enrichClineSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { snapshot, publishSnapshot } = input; + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("Cline version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index a429367bfeb0..60a785450b25 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -10,7 +10,7 @@ * * 2. **Many drivers, one registry** — the "all drivers slice" describe * block below configures one instance of every shipped driver - * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single + * (`codex`, `claudeAgent`, `cline`, `cursor`, `grok`, `opencode`) in a single * `ProviderInstanceConfigMap` and asserts the registry boots them all * without cross-contamination. This proves the driver SPI is uniform * across every provider — any driver plugs into the registry through @@ -18,7 +18,7 @@ * * Every instance in these tests is configured with `enabled: false` so the * provider-status checks short-circuit to pending/disabled snapshots - * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode` + * without trying to spawn real `codex` / `claude` / `cline` / `agent` / `grok` / `opencode` * binaries. That keeps the assertions focused on registry routing * behaviour rather than the runtime details of each provider. */ @@ -26,6 +26,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { type ClaudeSettings, + type ClineSettings, type CodexSettings, type CursorSettings, type GrokSettings, @@ -44,6 +45,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; +import { ClineDriver } from "../Drivers/ClineDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; @@ -117,6 +119,13 @@ const makeCursorConfig = (overrides: Partial): CursorSettings => ...overrides, }); +const makeClineConfig = (overrides: Partial): ClineSettings => ({ + enabled: false, + binaryPath: "cline", + customModels: [], + ...overrides, +}); + const makeGrokConfig = (overrides: Partial): GrokSettings => ({ enabled: false, binaryPath: "grok", @@ -318,12 +327,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { Effect.gen(function* () { const codexId = ProviderInstanceId.make("codex_default"); const claudeId = ProviderInstanceId.make("claude_default"); + const clineId = ProviderInstanceId.make("cline_default"); const cursorId = ProviderInstanceId.make("cursor_default"); const grokId = ProviderInstanceId.make("grok_default"); const openCodeId = ProviderInstanceId.make("opencode_default"); const codexDriverKind = ProviderDriverKind.make("codex"); const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); + const clineDriverKind = ProviderDriverKind.make("cline"); const cursorDriverKind = ProviderDriverKind.make("cursor"); const grokDriverKind = ProviderDriverKind.make("grok"); const openCodeDriverKind = ProviderDriverKind.make("opencode"); @@ -344,6 +355,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { launchArgs: "--verbose", }), }, + [clineId]: { + driver: clineDriverKind, + displayName: "Cline", + enabled: false, + config: makeClineConfig({}), + }, [cursorId]: { driver: cursorDriverKind, displayName: "Cursor", @@ -365,7 +382,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], + drivers: [CodexDriver, ClaudeDriver, ClineDriver, CursorDriver, GrokDriver, OpenCodeDriver], configMap, }); @@ -375,9 +392,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(unavailable).toEqual([]); const instances = yield* registry.listInstances; - expect(instances).toHaveLength(5); + expect(instances).toHaveLength(6); expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), + [codexId, claudeId, clineId, cursorId, grokId, openCodeId].toSorted(), ); // Instance lookup by id resolves each instance to its own bundle — @@ -385,28 +402,30 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { // model. Each driver's bundle carries its advertised `driverKind`. const codex = yield* registry.getInstance(codexId); const claude = yield* registry.getInstance(claudeId); + const cline = yield* registry.getInstance(clineId); const cursor = yield* registry.getInstance(cursorId); const grok = yield* registry.getInstance(grokId); const openCode = yield* registry.getInstance(openCodeId); expect(codex?.driverKind).toBe(codexDriverKind); expect(claude?.driverKind).toBe(claudeDriverKind); + expect(cline?.driverKind).toBe(clineDriverKind); expect(cursor?.driverKind).toBe(cursorDriverKind); expect(grok?.driverKind).toBe(grokDriverKind); expect(openCode?.driverKind).toBe(openCodeDriverKind); expect(codex?.displayName).toBe("Codex"); expect(claude?.displayName).toBe("Claude"); + expect(cline?.displayName).toBe("Cline"); expect(cursor?.displayName).toBe("Cursor"); expect(grok?.displayName).toBe("Grok"); expect(openCode?.displayName).toBe("OpenCode"); - // Every instance owns its own set of closures — no sharing across - // drivers. `adapter` / `textGeneration` / `snapshot` are all - // distinct references even when two instances happen to share a - // trait (e.g. Cursor + others all use a stub-or-real - // `textGeneration`; they must still be different object values). + // Every instance owns its own adapter and snapshot closures. Providers + // with safe background text generation own distinct closures too; + // Cline intentionally omits one because ACP loads executable hooks. const adapters = [ codex!.adapter, claude!.adapter, + cline!.adapter, cursor!.adapter, grok!.adapter, openCode!.adapter, @@ -420,9 +439,11 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { openCode!.textGeneration, ]; expect(new Set(textGenerations).size).toBe(textGenerations.length); + expect(cline!.textGeneration).toBeUndefined(); const snapshots = [ codex!.snapshot, claude!.snapshot, + cline!.snapshot, cursor!.snapshot, grok!.snapshot, openCode!.snapshot, @@ -447,6 +468,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(claudeSnapshot.enabled).toBe(false); expect(claudeSnapshot.continuation?.groupKey).toBe("claude:home:/home/julius/.claude-work"); + const clineSnapshot = yield* cline!.snapshot.getSnapshot; + expect(clineSnapshot.instanceId).toBe(clineId); + expect(clineSnapshot.driver).toBe(clineDriverKind); + expect(clineSnapshot.enabled).toBe(false); + expect(clineSnapshot.continuation?.groupKey).toBe(`${clineDriverKind}:instance:${clineId}`); + const cursorSnapshot = yield* cursor!.snapshot.getSnapshot; expect(cursorSnapshot.instanceId).toBe(cursorId); expect(cursorSnapshot.driver).toBe(cursorDriverKind); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index f7ae95d8a927..759210206d13 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -861,7 +861,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te streamChanges: Stream.empty, }, adapter: {} as ProviderInstance["adapter"], - textGeneration: {} as ProviderInstance["textGeneration"], + textGeneration: {} as NonNullable, } satisfies ProviderInstance; const instanceRegistryLayer = Layer.succeed( ProviderInstanceRegistry.ProviderInstanceRegistry, @@ -950,7 +950,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te streamChanges: Stream.fromPubSub(changes), }, adapter: {} as ProviderInstance["adapter"], - textGeneration: {} as ProviderInstance["textGeneration"], + textGeneration: {} as NonNullable, } satisfies ProviderInstance; const instanceRegistryLayer = Layer.succeed( ProviderInstanceRegistry.ProviderInstanceRegistry, @@ -1079,7 +1079,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te streamChanges: Stream.fromPubSub(changes), }, adapter: {} as ProviderInstance["adapter"], - textGeneration: {} as ProviderInstance["textGeneration"], + textGeneration: {} as NonNullable, } satisfies ProviderInstance; const instanceRegistryLayer = Layer.succeed( ProviderInstanceRegistry.ProviderInstanceRegistry, @@ -1186,7 +1186,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te streamChanges: Stream.empty, }, adapter: {} as ProviderInstance["adapter"], - textGeneration: {} as ProviderInstance["textGeneration"], + textGeneration: {} as NonNullable, } satisfies ProviderInstance; const instanceRegistryLayer = Layer.succeed( ProviderInstanceRegistry.ProviderInstanceRegistry, @@ -1279,7 +1279,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te streamChanges: Stream.empty, }, adapter: {} as ProviderInstance["adapter"], - textGeneration: {} as ProviderInstance["textGeneration"], + textGeneration: {} as NonNullable, }); const codexInstance = makeInstance(codexProvider); const claudeInstance = makeInstance(claudeProvider); @@ -1742,6 +1742,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ "claudeAgent", + "cline", "codex", "cursor", "grok", diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index bd89dc4f8812..12829ab15944 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -14,7 +14,6 @@ import type { } from "@t3tools/contracts"; import { ApprovalRequestId, - EnvironmentId, EventId, ProviderDriverKind, ProviderInstanceId, @@ -77,6 +76,7 @@ const claudeAgentInstanceId = ProviderInstanceId.make("claudeAgent"); const CODEX_DRIVER = ProviderDriverKind.make("codex"); const CLAUDE_AGENT_DRIVER = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); +const CLINE_DRIVER = ProviderDriverKind.make("cline"); type LegacyProviderRuntimeEvent = { readonly type: string; @@ -2122,13 +2122,17 @@ validation.layer("ProviderServiceLive validation", (it) => { describe("agent browser access", () => { const revokedThreads: Array = []; - const startSessionWith = (enableAgentBrowserAccess: boolean, threadId: ThreadId) => + const startSessionWith = ( + enableAgentBrowserAccess: boolean, + threadId: ThreadId, + driver: ProviderDriverKind = CODEX_DRIVER, + ) => Effect.gen(function* () { const issued: Array = []; - const codex = makeFakeCodexAdapter(); + const adapter = makeFakeCodexAdapter(driver); const providerAdapterLayer = Layer.succeed( ProviderAdapterRegistry.ProviderAdapterRegistry, - makeAdapterRegistryMock({ [CODEX_DRIVER]: codex.adapter }), + makeAdapterRegistryMock({ [driver]: adapter.adapter }), ); const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), @@ -2160,8 +2164,8 @@ describe("agent browser access", () => { yield* Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; return yield* provider.startSession(threadId, { - provider: CODEX_DRIVER, - providerInstanceId: codexInstanceId, + provider: driver, + providerInstanceId: ProviderInstanceId.make(driver), threadId, runtimeMode: "full-access", }); @@ -2204,4 +2208,16 @@ describe("agent browser access", () => { assert.deepEqual(issued, [threadId]); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect("withholds and revokes MCP credentials for Cline even when browser access is on", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-browser-cline-unsupported"); + revokedThreads.length = 0; + + const issued = yield* startSessionWith(true, threadId, CLINE_DRIVER); + + assert.deepEqual(issued, []); + assert.deepEqual(revokedThreads, [threadId]); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index b8cd0df539ac..b27a0e316483 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -233,14 +233,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread; const runtimeEventPubSub = yield* PubSub.unbounded(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - /** - * Attach the `t3-code` MCP server to the session that is about to start. - * - * This is the only place a credential is minted, so withholding one here is - * what disables agent browser access everywhere: every adapter already - * treats a missing session as "no MCP server", and the `/mcp` endpoint - * accepts nothing but tokens issued from this path. - */ /** * Deny on an unreadable settings file rather than letting the read failure * escape: adding `ServerSettingsError` to `ProviderServiceError` would widen @@ -259,9 +251,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ); - const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => + /** + * Attach the `t3-code` MCP server to the session that is about to start. + * + * This is the only place a credential is minted, so withholding one here + * disables agent browser access everywhere. Cline is withheld even when + * the setting is on because its ACP stores but never consumes `mcpServers`. + */ + const prepareMcpSession = ( + threadId: ThreadId, + providerInstanceId: ProviderInstanceId, + provider: ProviderDriverKind, + ) => Effect.gen(function* () { - if (!(yield* agentBrowserAccessEnabled)) { + if (provider === "cline" || !(yield* agentBrowserAccessEnabled)) { // Revoke as well as clear. Every other prepare path reaches // `issueActiveMcpCredential`, which revokes the thread first, so // skipping it here would leave a previously issued bearer token valid @@ -453,7 +456,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const persistedCwd = readPersistedCwd(input.binding.runtimePayload); const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); - yield* prepareMcpSession(input.binding.threadId, bindingInstanceId); + yield* prepareMcpSession(input.binding.threadId, bindingInstanceId, input.binding.provider); const resumed = yield* adapter .startSession({ threadId: input.binding.threadId, @@ -649,7 +652,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.cwd.effective": effectiveCwd ?? "", }); const adapter = yield* registry.getByInstance(resolvedInstanceId); - yield* prepareMcpSession(threadId, resolvedInstanceId); + yield* prepareMcpSession(threadId, resolvedInstanceId, resolvedProvider); const session = yield* adapter .startSession({ ...input, diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index c738882c23a4..a493cf8c68ff 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -3,7 +3,7 @@ * * `ProviderDriver` is a record, not a Context.Service. The thing it produces * (`ProviderInstance`) is also a record — three captured closures - * (`snapshot`, `adapter`, `textGeneration`), an id, and a driver kind. There + * (`snapshot`, `adapter`, and optional `textGeneration`), an id, and a driver kind. There * are intentionally no per-driver Context tags because tags are * singleton-per-runtime and we need many instances of the same driver. * @@ -56,7 +56,7 @@ export interface ProviderDriverMetadata { * One materialized provider instance. Held by the registry, looked up by * `instanceId`, torn down by closing the scope it was created in. * - * The three "shape" fields are captured closures owned by this instance — + * The shape fields are captured closures owned by this instance — * stopping one instance cannot affect another, and starting a second * instance of the same driver does not reach into the first instance's * state. @@ -70,7 +70,8 @@ export interface ProviderInstance { readonly enabled: boolean; readonly snapshot: ServerProviderShape; readonly adapter: ProviderAdapterShape; - readonly textGeneration: TextGeneration.TextGeneration["Service"]; + /** Omitted when the provider has no safe non-interactive generation mode. */ + readonly textGeneration?: TextGeneration.TextGeneration["Service"]; } export interface ProviderContinuationIdentity { diff --git a/apps/server/src/provider/Services/ClineAdapter.ts b/apps/server/src/provider/Services/ClineAdapter.ts new file mode 100644 index 000000000000..08f7e664c517 --- /dev/null +++ b/apps/server/src/provider/Services/ClineAdapter.ts @@ -0,0 +1,16 @@ +/** + * ClineAdapter — shape type for the Cline provider adapter. + * + * The driver model ({@link ../Drivers/ClineDriver}) bundles one adapter per + * instance as a captured closure, so this module only retains the shape + * interface as a naming anchor for the driver bundle. + * + * @module ClineAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * ClineAdapterShape — per-instance Cline adapter contract. + */ +export interface ClineAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index b1ef0d3e5953..f1b43fdf3679 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -39,6 +39,10 @@ describe("AcpSessionRuntime", () => { _meta: { parameterizedModelPicker: true }, }, }); + const authenticateStarted = requestEvents.find( + (event) => event.method === "authenticate" && event.status === "started", + ); + expect(authenticateStarted?.payload).toEqual({ methodId: "test" }); }).pipe( Effect.provide( AcpSessionRuntime.layer({ @@ -65,6 +69,35 @@ describe("AcpSessionRuntime", () => { ); }); + it.effect("skips active authentication when no auth method is configured", () => { + const requestEvents: Array = []; + return Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + + expect( + requestEvents.filter((event) => event.status === "started").map((event) => event.method), + ).toEqual(["initialize", "session/new"]); + }).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), + ); + }); + it.effect("starts a session, prompts, and emits normalized events against the mock agent", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 09fce6d56f9d..c26c15aa74c1 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -55,6 +55,7 @@ export interface AcpSpawnInput { readonly args: ReadonlyArray; readonly cwd?: string; readonly env?: NodeJS.ProcessEnv; + readonly forceKillAfter?: Duration.Input; } export interface AcpSessionRuntimeOptions { @@ -68,7 +69,12 @@ export interface AcpSessionRuntimeOptions { readonly name: string; readonly version: string; }; - readonly authMethodId: string; + /** + * ACP authentication is an active operation and may launch an interactive + * OAuth flow. Providers that restore credentials during session setup should + * omit this instead of authenticating as part of every runtime start. + */ + readonly authMethodId?: string; readonly mcpServers?: ReadonlyArray; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { @@ -175,10 +181,20 @@ export class AcpSessionRuntime extends Context.Service< */ readonly handleExtNotification: EffectAcpClient.AcpClient["Service"]["handleExtNotification"]; /** - * Initializes the ACP connection, authenticates, and loads, resumes, or creates the session. - * Concurrent calls share the same in-flight startup and a failed startup may be retried. + * Initializes the ACP connection, optionally authenticates when an auth + * method was configured, and loads, resumes, or creates the session. + * Concurrent calls share the same in-flight startup and a failed startup + * may be retried. */ readonly start: () => Effect.Effect; + /** + * Terminates the exact child process owned by this runtime. This is used + * when a peer stops responding before its surrounding scope can close + * cleanly. + */ + readonly terminate: ( + forceKillAfter: Duration.Input, + ) => Effect.Effect; /** Stream of parsed ACP session events emitted after startup. */ readonly getEvents: () => Stream.Stream; /** Waits until the current event consumer has processed every queued event. */ @@ -293,6 +309,7 @@ export const make = ( const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); const promptSerializationSemaphore = yield* Semaphore.make(1); + const promptCancellationGenerationRef = yield* Ref.make(0); const activePromptFiberRef = yield* Ref.make< Option.Option> >(Option.none()); @@ -339,6 +356,7 @@ export const make = ( ChildProcess.make(spawnCommand.command, spawnCommand.args, { ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}), + ...(options.spawn.forceKillAfter ? { forceKillAfter: options.spawn.forceKillAfter } : {}), shell: spawnCommand.shell, }), ) @@ -541,15 +559,17 @@ export const make = ( acp.agent.initialize(initializePayload), ); - const authenticatePayload = { - methodId: options.authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; + if (options.authMethodId !== undefined) { + 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: @@ -705,6 +725,16 @@ export const make = ( handleExtRequest: acp.handleExtRequest, handleExtNotification: acp.handleExtNotification, start: () => start, + terminate: (forceKillAfter) => + child.kill({ forceKillAfter }).pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to terminate the ACP child process", + cause, + }), + ), + ), getEvents: () => Stream.fromQueue(eventQueue), drainEvents: Effect.gen(function* () { const acknowledge = yield* Deferred.make(); @@ -717,50 +747,57 @@ export const make = ( getModeState: Ref.get(modeStateRef), getConfigOptions: Ref.get(configOptionsRef), prompt: (payload) => - promptSerializationSemaphore.withPermit( - Effect.gen(function* () { - const started = yield* getStartedState; - yield* closeActiveAssistantSegment({ - queue: eventQueue, - assistantSegmentRef, - }); - const requestPayload = { - sessionId: started.sessionId, - ...payload, - } satisfies EffectAcpSchema.PromptRequest; - const cancelledResponse = { - stopReason: "cancelled", - } satisfies EffectAcpSchema.PromptResponse; - const promptRpcFiber = yield* runLoggedRequest( - "session/prompt", - requestPayload, - acp.agent.prompt(requestPayload), - ).pipe(Effect.forkIn(runtimeScope)); - yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber)); - return yield* Fiber.join(promptRpcFiber).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.succeed(cancelledResponse) - : Effect.failCause(cause), - ), - Effect.ensuring( - Effect.gen(function* () { - yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore); - yield* Ref.set(activePromptFiberRef, Option.none()); - }), - ), - Effect.tap(() => - closeActiveAssistantSegment({ - queue: eventQueue, - assistantSegmentRef, - }), - ), - ); - }), - ), + Effect.gen(function* () { + const cancellationGeneration = yield* Ref.get(promptCancellationGenerationRef); + return yield* promptSerializationSemaphore.withPermit( + Effect.gen(function* () { + const cancelledResponse = { + stopReason: "cancelled", + } satisfies EffectAcpSchema.PromptResponse; + if ((yield* Ref.get(promptCancellationGenerationRef)) !== cancellationGeneration) { + return cancelledResponse; + } + const started = yield* getStartedState; + yield* closeActiveAssistantSegment({ + queue: eventQueue, + assistantSegmentRef, + }); + const requestPayload = { + sessionId: started.sessionId, + ...payload, + } satisfies EffectAcpSchema.PromptRequest; + const promptRpcFiber = yield* runLoggedRequest( + "session/prompt", + requestPayload, + acp.agent.prompt(requestPayload), + ).pipe(Effect.forkIn(runtimeScope)); + yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber)); + return yield* Fiber.join(promptRpcFiber).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.succeed(cancelledResponse) + : Effect.failCause(cause), + ), + Effect.ensuring( + Effect.gen(function* () { + yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore); + yield* Ref.set(activePromptFiberRef, Option.none()); + }), + ), + Effect.tap(() => + closeActiveAssistantSegment({ + queue: eventQueue, + assistantSegmentRef, + }), + ), + ); + }), + ); + }), cancel: getStartedState.pipe( Effect.flatMap((started) => Effect.gen(function* () { + yield* Ref.update(promptCancellationGenerationRef, (generation) => generation + 1); const activePromptFiber = yield* Ref.get(activePromptFiberRef); if (Option.isSome(activePromptFiber)) { yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore); diff --git a/apps/server/src/provider/acp/ClineAcpCliProbe.test.ts b/apps/server/src/provider/acp/ClineAcpCliProbe.test.ts new file mode 100644 index 000000000000..39870add4c1b --- /dev/null +++ b/apps/server/src/provider/acp/ClineAcpCliProbe.test.ts @@ -0,0 +1,74 @@ +/** + * Optional integration check against a real `cline --acp` install. + * Enable with: T3_CLINE_ACP_PROBE=1 bun run test ClineAcpCliProbe + * + * Cline's ACP mode requires credentials before session/new. Authenticate with + * `cline auth` first or provide CLINE_API_KEY; the T3 runtime intentionally + * does not call ACP authenticate because that starts an interactive OAuth flow. + */ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { describe, expect } from "vite-plus/test"; + +import { + applyClineAcpModelSelection, + currentClineModelIdFromSessionSetup, + clineModelsFromSessionConfigOptions, + makeClineAcpRuntime, +} from "./ClineAcpSupport.ts"; + +const makeProbeRuntime = Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* makeClineAcpRuntime({ + clineSettings: { binaryPath: "cline" }, + environment: process.env, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-cline-probe", version: "0.0.0" }, + }); +}); + +describe.runIf(process.env.T3_CLINE_ACP_PROBE === "1")("Cline ACP CLI probe", () => { + it.effect("initializes and creates a session against real cline acp", () => + Effect.gen(function* () { + const runtime = yield* makeProbeRuntime; + const started = yield* runtime.start(); + expect(started.initializeResult).toBeDefined(); + expect(typeof started.sessionId).toBe("string"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("session/new advertises a model select in configOptions", () => + Effect.gen(function* () { + const runtime = yield* makeProbeRuntime; + const started = yield* runtime.start(); + + const models = clineModelsFromSessionConfigOptions(started.sessionSetupResult); + expect(models.length).toBeGreaterThan(0); + + const current = currentClineModelIdFromSessionSetup(started.sessionSetupResult); + expect(current).toBeDefined(); + if (current === undefined) return; + expect(models.some((model) => model.slug === current)).toBe(true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("no-op model selection succeeds against the live catalog", () => + Effect.gen(function* () { + const runtime = yield* makeProbeRuntime; + const started = yield* runtime.start(); + + // Selecting the model the session already runs on must resolve without + // issuing a session/set_config_option round-trip against every Cline + // build that implements config-option selects. + const selected = yield* applyClineAcpModelSelection({ + runtime, + requestedModelId: currentClineModelIdFromSessionSetup(started.sessionSetupResult), + mapError: (cause) => cause, + }); + expect(selected).toBeDefined(); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/acp/ClineAcpSupport.test.ts b/apps/server/src/provider/acp/ClineAcpSupport.test.ts new file mode 100644 index 000000000000..d4e0aef00760 --- /dev/null +++ b/apps/server/src/provider/acp/ClineAcpSupport.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as itx from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import type * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { + applyClineAcpModelSelection, + buildClineAcpSpawnInput, + CLINE_PROCESS_FORCE_KILL_AFTER, + currentClineModelIdFromSessionSetup, + clineModelsFromSessionConfigOptions, +} from "./ClineAcpSupport.ts"; + +function modelSelectOption( + overrides?: Partial>, +) { + return { + id: "model", + name: "Model", + category: "model" as const, + type: "select" as const, + currentValue: "cline/anthropic/claude-opus-4.7", + options: [ + { value: "cline/anthropic/claude-opus-4.7", name: "Claude Opus 4.7" }, + { value: "cline/google/gemini-3-pro", name: "Gemini 3 Pro" }, + ], + ...overrides, + }; +} + +function providerSelectOption(): Extract { + return { + id: "provider", + name: "Provider", + category: "model", + type: "select", + currentValue: "cline", + options: [ + { value: "cline", name: "Cline" }, + { value: "cline-pass", name: "ClinePass" }, + { value: "openai-codex", name: "ChatGPT Subscription" }, + ], + }; +} + +const providerFirstConfigOptions = () => [providerSelectOption(), modelSelectOption()] as const; + +function fakeRuntime(input: { + readonly configOptions: ReadonlyArray; + readonly setCalls?: Array<{ configId: string; value: string | boolean }>; +}) { + const setCalls = input.setCalls ?? []; + return { + getConfigOptions: Effect.succeed(input.configOptions), + setConfigOption: (configId: string, value: string | boolean) => { + setCalls.push({ configId, value }); + return Effect.succeed({ + configOptions: input.configOptions, + } satisfies EffectAcpSchema.SetSessionConfigOptionResponse); + }, + } as const; +} + +describe("cline ACP support", () => { + it("applies a force-kill bound only when the caller requests one", () => { + expect(buildClineAcpSpawnInput({ binaryPath: "cline" }, "/workspace", undefined)).toEqual({ + command: "cline", + args: ["--acp"], + cwd: "/workspace", + }); + expect( + buildClineAcpSpawnInput( + { binaryPath: "cline" }, + "/workspace", + undefined, + CLINE_PROCESS_FORCE_KILL_AFTER, + ), + ).toEqual({ + command: "cline", + args: ["--acp"], + cwd: "/workspace", + forceKillAfter: "2 seconds", + }); + }); + + it("reads the current model id from model-category config options", () => { + const current = currentClineModelIdFromSessionSetup({ + sessionId: "ses_1", + configOptions: [ + { + id: "mode", + name: "Mode", + category: "mode", + type: "select", + currentValue: "code", + options: [], + }, + modelSelectOption(), + ], + }); + expect(current).toBe("cline/anthropic/claude-opus-4.7"); + }); + + it("reads the exact model id when no option is tagged with the model category", () => { + const current = currentClineModelIdFromSessionSetup({ + sessionId: "ses_1", + configOptions: [modelSelectOption({ category: null })], + }); + expect(current).toBe("cline/anthropic/claude-opus-4.7"); + }); + + it("falls back to a non-provider model-category option for older agents", () => { + const current = currentClineModelIdFromSessionSetup({ + sessionId: "ses_1", + configOptions: [modelSelectOption({ id: "model-picker" })], + }); + expect(current).toBe("cline/anthropic/claude-opus-4.7"); + }); + + it("derives discovered models from config options and marks the current one default", () => { + const models = clineModelsFromSessionConfigOptions({ + sessionId: "ses_1", + // Official Cline emits its provider selector before its model selector, + // and both use the ACP model category. + configOptions: providerFirstConfigOptions(), + }); + expect(models).toEqual([ + { + slug: "cline/anthropic/claude-opus-4.7", + name: "Claude Opus 4.7", + isDefault: true, + }, + { slug: "cline/google/gemini-3-pro", name: "Gemini 3 Pro" }, + ]); + }); + + it("does not expose provider values as models when no model option exists", () => { + const models = clineModelsFromSessionConfigOptions({ + sessionId: "ses_1", + configOptions: [providerSelectOption()], + }); + expect(models).toEqual([]); + }); + + it("preserves model names from grouped config options", () => { + const models = clineModelsFromSessionConfigOptions({ + sessionId: "ses_1", + configOptions: [ + modelSelectOption({ + options: [ + { + group: "anthropic", + name: "Anthropic", + options: [{ value: "cline/anthropic/claude-opus-4.7", name: "Claude Opus 4.7" }], + }, + ], + }), + ], + }); + expect(models).toEqual([ + { + slug: "cline/anthropic/claude-opus-4.7", + name: "Claude Opus 4.7", + isDefault: true, + }, + ]); + }); + + it("returns no models without a select config option for models", () => { + const models = clineModelsFromSessionConfigOptions({ + sessionId: "ses_1", + configOptions: [ + { id: "autoApprove", name: "Auto approve", type: "boolean", currentValue: false }, + ], + }); + expect(models).toEqual([]); + }); + + itx.it.effect("skips selection when the requested model is already current", () => + Effect.gen(function* () { + const setCalls: Array<{ configId: string; value: string | boolean }> = []; + const result = yield* applyClineAcpModelSelection({ + runtime: fakeRuntime({ + configOptions: providerFirstConfigOptions(), + setCalls, + }), + requestedModelId: "cline/anthropic/claude-opus-4.7", + mapError: (cause): EffectAcpErrors.AcpError => cause, + }); + expect(result).toBe("cline/anthropic/claude-opus-4.7"); + expect(setCalls).toEqual([]); + }), + ); + + itx.it.effect("sets the model config option when the request differs", () => + Effect.gen(function* () { + const setCalls: Array<{ configId: string; value: string | boolean }> = []; + const result = yield* applyClineAcpModelSelection({ + runtime: fakeRuntime({ + configOptions: providerFirstConfigOptions(), + setCalls, + }), + requestedModelId: " cline/google/gemini-3-pro ", + mapError: (cause): EffectAcpErrors.AcpError => cause, + }); + expect(result).toBe("cline/google/gemini-3-pro"); + expect(setCalls).toEqual([{ configId: "model", value: "cline/google/gemini-3-pro" }]); + }), + ); + + itx.it.effect("does not write the provider option when no model option exists", () => + Effect.gen(function* () { + const setCalls: Array<{ configId: string; value: string | boolean }> = []; + const result = yield* applyClineAcpModelSelection({ + runtime: fakeRuntime({ configOptions: [providerSelectOption()], setCalls }), + requestedModelId: "cline/google/gemini-3-pro", + mapError: (cause): EffectAcpErrors.AcpError => cause, + }); + expect(result).toBeUndefined(); + expect(setCalls).toEqual([]); + }), + ); + + itx.it.effect("does not write a requested model when Cline advertises an empty catalog", () => + Effect.gen(function* () { + const setCalls: Array<{ configId: string; value: string | boolean }> = []; + const result = yield* applyClineAcpModelSelection({ + runtime: fakeRuntime({ + configOptions: [modelSelectOption({ currentValue: "", options: [] })], + setCalls, + }), + requestedModelId: "gpt-5.6-sol", + mapError: (cause): EffectAcpErrors.AcpError => cause, + }); + expect(result).toBeUndefined(); + expect(setCalls).toEqual([]); + }), + ); + + itx.it.effect("does nothing when the agent exposes no model config option", () => + Effect.gen(function* () { + const result = yield* applyClineAcpModelSelection({ + runtime: fakeRuntime({ configOptions: [] }), + requestedModelId: "cline/google/gemini-3-pro", + mapError: (cause): EffectAcpErrors.AcpError => cause, + }); + expect(result).toBeUndefined(); + }), + ); + + itx.it.effect("ignores empty model requests", () => + Effect.gen(function* () { + const result = yield* applyClineAcpModelSelection({ + runtime: fakeRuntime({ configOptions: [modelSelectOption()] }), + requestedModelId: " ", + mapError: (cause): EffectAcpErrors.AcpError => cause, + }); + expect(result).toBeUndefined(); + }), + ); + + itx.it.effect("delegates explicit model selection to the runtime validation boundary", () => + Effect.gen(function* () { + const setCalls: Array<{ configId: string; value: string | boolean }> = []; + const result = yield* applyClineAcpModelSelection({ + runtime: fakeRuntime({ configOptions: providerFirstConfigOptions(), setCalls }), + requestedModelId: "cline/retired-model", + mapError: (cause): EffectAcpErrors.AcpError => cause, + }); + expect(result).toBe("cline/retired-model"); + expect(setCalls).toEqual([{ configId: "model", value: "cline/retired-model" }]); + }), + ); +}); diff --git a/apps/server/src/provider/acp/ClineAcpSupport.ts b/apps/server/src/provider/acp/ClineAcpSupport.ts new file mode 100644 index 000000000000..6fe24707fa7c --- /dev/null +++ b/apps/server/src/provider/acp/ClineAcpSupport.ts @@ -0,0 +1,178 @@ +import { type ClineSettings } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import type * 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 Layer from "effect/Layer"; +import * as Option from "effect/Option"; +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 { findSessionConfigOption } from "./AcpRuntimeModel.ts"; + +type ClineAcpSetupResponse = + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse; + +type ClineAcpRuntimeClineSettings = Pick; + +export const CLINE_PROCESS_FORCE_KILL_AFTER = "2 seconds"; + +export interface ClineAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly clineSettings: ClineAcpRuntimeClineSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; + readonly forceKillAfter?: Duration.Input; +} + +export function buildClineAcpSpawnInput( + clineSettings: ClineAcpRuntimeClineSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, + forceKillAfter?: Duration.Input, +): AcpSessionRuntime.AcpSpawnInput { + return { + command: clineSettings?.binaryPath || "cline", + args: ["--acp"], + cwd, + ...(forceKillAfter ? { forceKillAfter } : {}), + ...(environment ? { env: environment } : {}), + }; +} + +export const makeClineAcpRuntime = Effect.fn("makeClineAcpRuntime")(function* ( + input: ClineAcpRuntimeInput, +): Effect.fn.Return< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildClineAcpSpawnInput( + input.clineSettings, + input.cwd, + input.environment, + input.forceKillAfter, + ), + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); +}); + +export const startClineAcpRuntimeWithTimeout = Effect.fn("startClineAcpRuntimeWithTimeout")( + function* (input: { + readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly timeout: Duration.Input; + readonly forceKillAfter: Duration.Input; + }) { + // An unresponsive JSON-RPC request can make interruption wait forever. + // Observe detached startup as data so the timeout path can terminate the + // exact owned child before the surrounding runtime scope is closed. + const startFiber = yield* input.runtime.start().pipe(Effect.forkDetach); + const startedExit = yield* Fiber.await(startFiber).pipe(Effect.timeoutOption(input.timeout)); + if (Option.isNone(startedExit)) { + yield* input.runtime.terminate(input.forceKillAfter).pipe(Effect.ignore); + return Option.none(); + } + if (Exit.isFailure(startedExit.value)) { + return yield* Effect.failCause(startedExit.value.cause); + } + return Option.some(startedExit.value.value); + }, +); + +type ClineModelSelectOption = Extract; + +function findClineModelConfigOptionIn( + configOptions: ReadonlyArray | null | undefined, +): ClineModelSelectOption | undefined { + if (!configOptions) return undefined; + const byId = findSessionConfigOption(configOptions, "model"); + if (byId && byId.type === "select") return byId; + const byCategory = configOptions.find( + (option) => option.type === "select" && option.category === "model" && option.id !== "provider", + ); + return byCategory && byCategory.type === "select" ? byCategory : undefined; +} + +function findClineModelConfigOption( + sessionSetupResult: ClineAcpSetupResponse, +): ClineModelSelectOption | undefined { + return findClineModelConfigOptionIn(sessionSetupResult.configOptions); +} + +export function currentClineModelIdFromSessionSetup( + sessionSetupResult: ClineAcpSetupResponse, +): string | undefined { + const option = findClineModelConfigOption(sessionSetupResult); + if (!option) return undefined; + const current = option.currentValue.trim(); + return current.length > 0 ? current : undefined; +} + +export interface ClineDiscoveredModel { + readonly slug: string; + readonly name: string; + readonly isDefault?: boolean; +} + +export function clineModelsFromSessionConfigOptions( + sessionSetupResult: ClineAcpSetupResponse, +): ReadonlyArray { + const option = findClineModelConfigOption(sessionSetupResult); + if (!option) return []; + const current = currentClineModelIdFromSessionSetup(sessionSetupResult); + return option.options.flatMap((entry) => { + const values = "value" in entry ? [entry] : entry.options; + return values.map(({ value, name }) => ({ + slug: value, + name, + ...(value === current ? { isDefault: true } : {}), + })); + }); +} + +export const applyClineAcpModelSelection = Effect.fn("applyClineAcpModelSelection")(function* < + E, +>(input: { + readonly runtime: Pick< + AcpSessionRuntime.AcpSessionRuntime["Service"], + "getConfigOptions" | "setConfigOption" + >; + readonly requestedModelId: string | null | undefined; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.fn.Return { + const requested = input.requestedModelId?.trim(); + if (!requested) { + return; + } + const configOptions = yield* input.runtime.getConfigOptions; + const option = findClineModelConfigOptionIn(configOptions); + if (!option) { + return undefined; + } + if (option.options.every((entry) => !("value" in entry) && entry.options.length === 0)) { + return undefined; + } + if (option.currentValue.trim() === requested) { + return requested; + } + yield* input.runtime.setConfigOption(option.id, requested).pipe(Effect.mapError(input.mapError)); + return requested; +}); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c..a67ed039c8f5 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 { ClineDriver, type ClineDriverEnv } from "./Drivers/ClineDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -34,6 +35,7 @@ import type { AnyProviderDriver } from "./ProviderDriver.ts"; */ export type BuiltInDriversEnv = | ClaudeDriverEnv + | ClineDriverEnv | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv @@ -47,6 +49,7 @@ export type BuiltInDriversEnv = export const BUILT_IN_DRIVERS: ReadonlyArray> = [ CodexDriver, ClaudeDriver, + ClineDriver, CursorDriver, GrokDriver, OpenCodeDriver, diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 03b4cf4a3b6e..598067cd05a5 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -1,6 +1,7 @@ import type { ProviderDriverKind, ModelCapabilities, + RuntimeMode, ServerProvider, ServerProviderAuth, ServerProviderSkill, @@ -56,6 +57,8 @@ export interface ServerProviderPresentation { readonly displayName: string; readonly badgeLabel?: string; readonly showInteractionModeToggle?: boolean; + readonly supportedRuntimeModes?: ReadonlyArray; + readonly supportsImageAttachments?: boolean; readonly requiresNewThreadForModelChange?: boolean; } @@ -236,6 +239,12 @@ export function buildServerProvider(input: { ...(typeof input.presentation.showInteractionModeToggle === "boolean" ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle } : {}), + ...(input.presentation.supportedRuntimeModes + ? { supportedRuntimeModes: [...input.presentation.supportedRuntimeModes] } + : {}), + ...(typeof input.presentation.supportsImageAttachments === "boolean" + ? { supportsImageAttachments: input.presentation.supportsImageAttachments } + : {}), ...(typeof input.presentation.requiresNewThreadForModelChange === "boolean" ? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange } : {}), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 35ef5e976223..ee0ad9dfaeb6 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -266,6 +266,120 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("does not select Cline when it is the only enabled fallback", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + + const next = yield* serverSettings.updateSettings({ + providers: { + codex: { enabled: false }, + claudeAgent: { enabled: false }, + cursor: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + cline: { enabled: true }, + }, + }); + + assert.deepEqual( + next.textGenerationModelSelection, + DEFAULT_SERVER_SETTINGS.textGenerationModelSelection, + ); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("heals an explicit Cline text-generation selection to a safe provider", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + + const next = yield* serverSettings.updateSettings({ + providers: { cline: { enabled: true } }, + textGenerationModelSelection: { + instanceId: ProviderInstanceId.make("cline"), + model: "advertised-interactive-model", + }, + }); + + assert.deepEqual( + next.textGenerationModelSelection, + DEFAULT_SERVER_SETTINGS.textGenerationModelSelection, + ); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("falls back to an enabled custom provider instance when built-ins are disabled", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const customCodexId = ProviderInstanceId.make("codex_work"); + + const next = yield* serverSettings.updateSettings({ + providers: { + codex: { enabled: false }, + claudeAgent: { enabled: false }, + cursor: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + cline: { enabled: true }, + }, + providerInstances: { + [customCodexId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + config: {}, + }, + }, + textGenerationModelSelection: { + instanceId: ProviderInstanceId.make("cline"), + model: "advertised-interactive-model", + }, + }); + + assert.deepEqual(next.textGenerationModelSelection, { + instanceId: customCodexId, + model: "gpt-5.6-luna", + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("honors an explicit disabled default instance when choosing a fallback", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const customClaudeId = ProviderInstanceId.make("claude_work"); + + const next = yield* serverSettings.updateSettings({ + providers: { + codex: { enabled: true }, + claudeAgent: { enabled: false }, + cursor: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + cline: { enabled: true }, + }, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: {}, + }, + [customClaudeId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + config: {}, + }, + }, + textGenerationModelSelection: { + instanceId: ProviderInstanceId.make("cline"), + model: "advertised-interactive-model", + }, + }); + + assert.deepEqual(next.textGenerationModelSelection, { + instanceId: customClaudeId, + model: "claude-haiku-4-5", + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("preserves custom provider instance text generation selections", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 1bf37335271b..8831388fd9bd 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -20,6 +20,7 @@ import { type ProviderInstanceEnvironmentVariable, ProviderDriverKind, ProviderInstanceId, + resolveProviderInstanceEnabled, ServerSettings, ServerSettingsError, type ServerSettingsPatch, @@ -232,27 +233,58 @@ const ServerSettingsJson = fromLenientJson(ServerSettings); const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson); function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { - return isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection) + return isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection) && + textGenerationSelectionIsSupported(settings, settings.textGenerationModelSelection) ? settings : fallbackTextGenerationProvider(settings); } +function textGenerationSelectionIsSupported( + settings: ServerSettings, + selection: ModelSelection, +): boolean { + const configuredDriver = settings.providerInstances[selection.instanceId]?.driver; + const driver = configuredDriver ?? selection.instanceId; + return driver !== "cline"; +} + function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { - const fallbackEntry = Object.entries(settings.providers).find(([, provider]) => provider.enabled); - const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; - if (!fallback) { + const fallbackEntry = Object.entries(settings.providers).find( + ([driver]) => + driver !== "cline" && + isModelSelectionProviderEnabled(settings, { + instanceId: ProviderInstanceId.make(driver), + model: DEFAULT_TEXT_GENERATION_MODEL, + }), + ); + const legacyDriver = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; + const instanceFallback = legacyDriver + ? undefined + : Object.entries(settings.providerInstances).find( + ([, instance]) => instance.driver !== "cline" && resolveProviderInstanceEnabled(instance), + ); + const instanceId = instanceFallback + ? ProviderInstanceId.make(instanceFallback[0]) + : legacyDriver + ? ProviderInstanceId.make(legacyDriver) + : undefined; + const driver = instanceFallback?.[1].driver ?? legacyDriver; + if (!instanceId || !driver) { return settings; } + const model = + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_TEXT_GENERATION_MODEL; + const selection = + instanceId === DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId && + model === DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model + ? DEFAULT_SERVER_SETTINGS.textGenerationModelSelection + : ({ instanceId, model } satisfies ModelSelection); return { ...settings, - textGenerationModelSelection: { - instanceId: ProviderInstanceId.make(fallback), - model: - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback] ?? - DEFAULT_MODEL_BY_PROVIDER[fallback] ?? - DEFAULT_TEXT_GENERATION_MODEL, - } satisfies ModelSelection, + textGenerationModelSelection: selection, }; } diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts index 9bccb9c1fc5b..5ac201f59d98 100644 --- a/apps/server/src/textGeneration/TextGeneration.test.ts +++ b/apps/server/src/textGeneration/TextGeneration.test.ts @@ -26,7 +26,8 @@ const makeStubTextGeneration = ( const makeStubInstance = ( instanceId: ProviderInstanceId, - textGeneration: TextGeneration.TextGeneration["Service"], + textGeneration?: TextGeneration.TextGeneration["Service"], + enabled = true, ): ProviderInstance => ({ instanceId, @@ -36,10 +37,10 @@ const makeStubInstance = ( continuationKey: `${instanceId}:test`, }, displayName: undefined, - enabled: true, + enabled, snapshot: {} as ProviderInstance["snapshot"], adapter: {} as ProviderInstance["adapter"], - textGeneration, + ...(textGeneration ? { textGeneration } : {}), }) satisfies ProviderInstance; const makeStubRegistry = ( @@ -118,4 +119,67 @@ describe("makeTextGenerationFromRegistry", () => { } }), ); + + it.effect("fails before dispatch when an instance has no text-generation backend", () => + Effect.gen(function* () { + const clineId = ProviderInstanceId.make("cline"); + const tg = TextGeneration.makeTextGenerationFromRegistry( + makeStubRegistry([makeStubInstance(clineId)]), + ); + + const result = yield* tg + .generateThreadTitle({ + cwd: process.cwd(), + message: "must not start Cline ACP hooks", + modelSelection: createModelSelection(clineId, "advertised-model"), + }) + .pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure.detail).toContain("does not support background text generation"); + } + }), + ); + + it.effect("never dispatches title or source-control generation to a disabled instance", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("codex"); + let calls = 0; + const backend = makeStubTextGeneration({ + generateThreadTitle: () => { + calls += 1; + return Effect.succeed({ title: "must not run" }); + }, + generateCommitMessage: () => { + calls += 1; + return Effect.succeed({ subject: "must not run", body: "" }); + }, + }); + const tg = TextGeneration.makeTextGenerationFromRegistry( + makeStubRegistry([makeStubInstance(instanceId, backend, false)]), + ); + + const title = yield* tg + .generateThreadTitle({ + cwd: process.cwd(), + message: "title", + modelSelection: createModelSelection(instanceId, "gpt-5"), + }) + .pipe(Effect.result); + const commit = yield* tg + .generateCommitMessage({ + cwd: process.cwd(), + branch: null, + stagedSummary: "summary", + stagedPatch: "patch", + modelSelection: createModelSelection(instanceId, "gpt-5"), + }) + .pipe(Effect.result); + + expect(Result.isFailure(title)).toBe(true); + expect(Result.isFailure(commit)).toBe(true); + expect(calls).toBe(0); + }), + ); }); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 66b7ccd465f1..3ee5092a91c8 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -129,15 +129,19 @@ const resolveInstance = ( registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"], operation: TextGenerationOp, instanceId: ProviderInstanceId, -): Effect.Effect => +): Effect.Effect, TextGenerationError> => registry.getInstance(instanceId).pipe( Effect.flatMap((instance) => - instance + instance?.enabled && instance.textGeneration ? Effect.succeed(instance.textGeneration) : Effect.fail( new TextGenerationError({ operation, - detail: `No provider instance registered for id '${instanceId}'.`, + detail: !instance + ? `No provider instance registered for id '${instanceId}'.` + : !instance.enabled + ? `Provider instance '${instanceId}' is disabled.` + : `Provider instance '${instanceId}' does not support background text generation.`, }), ), ), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 46ed051154a6..7dd9f91505ac 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -195,7 +195,11 @@ import { import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { registerFaviconProjectForThread } from "~/browserFaviconStore"; -import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; +import { + getProviderModelCapabilities, + getUnsupportedProviderModeReason, + resolveSelectableProvider, +} from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import { useClientSettings, @@ -2758,6 +2762,11 @@ function ChatViewContent(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + const unsupportedProviderModeReason = getUnsupportedProviderModeReason({ + provider: activeProviderStatus, + runtimeMode, + interactionMode, + }); const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< string | null @@ -6792,7 +6801,7 @@ function ChatViewContent(props: ChatViewProps) { ? "Sending feedback" : threadDetailLoading ? "Messages loading" - : null + : unsupportedProviderModeReason } isPreparingWorktree={isPreparingWorktree} externalDrawerAttached={externalComposerDrawerAttached} diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index cd0854e176b7..983444c964fd 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -214,6 +214,24 @@ export const GrokIcon: Icon = ({ className, ...props }) => ( ); +export const ClineIcon: Icon = ({ className, ...props }) => ( + + + + + + + + +); + export const TraeIcon: Icon = (props) => ( {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f29d6c2b4f6a..c3f9b8744e8d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -95,6 +95,7 @@ import { } from "../composerFooterLayout"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; import { ProviderModelPicker } from "./ProviderModelPicker"; +import { hasSelectableProviderModel } from "./ProviderModelPicker.logic"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; @@ -236,7 +237,12 @@ import { XIcon, } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; -import { getProviderInteractionModeToggle } from "../../providerModels"; +import { + canUseProviderInteractionModeShortcut, + getUnsupportedProviderAttachmentReason, + getProviderInteractionModeToggle, + getProviderSupportedRuntimeModes, +} from "../../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -287,7 +293,6 @@ const runtimeModeConfig: Record< }, }; -const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; const COMPOSER_FLOATING_LAYER_SELECTOR = [ '[data-composer-drawer-layer="true"]', '[data-slot="popover-popup"]', @@ -333,6 +338,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; + supportedRuntimeModes: ReadonlyArray; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { @@ -392,7 +398,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop {runtimeModeOption.label} - {runtimeModeOptions.map((mode) => { + {props.supportedRuntimeModes.map((mode) => { const option = runtimeModeConfig[mode]; const OptionIcon = option.icon; return ( @@ -711,8 +717,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setThreadError, onExpandImage, } = props; - const isSendDisabled = sendDisabledReason !== null; - // ------------------------------------------------------------------ // Store subscriptions (prompt / images / terminal contexts) // ------------------------------------------------------------------ @@ -870,7 +874,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId), [providerInstanceEntries, selectedInstanceId], ); - const noProviderAvailable = selectedProviderEntry === undefined; + const noProviderEntryAvailable = selectedProviderEntry === undefined; // The driver kind follows the instance that will actually run the turn, // which can differ from the persisted selection when that selection is // disabled. @@ -886,10 +890,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) projectModelSelection: activeProjectDefaultModelSelection, settings, }); + const noProviderAvailable = + noProviderEntryAvailable || !hasSelectableProviderModel(selectedModel); const selectedProviderStatus = useMemo( () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], ); + const effectiveSendDisabledReason = + sendDisabledReason ?? + getUnsupportedProviderAttachmentReason(selectedProviderStatus, composerImages.length); + const isSendDisabled = effectiveSendDisabledReason !== null; const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], @@ -923,15 +933,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; // Plan mode is a legacy feature behind Settings → Beta. With the flag off, - // ChatView forces the effective mode to "default", so hiding the toggle - // can't trap anyone in plan mode. + // Keep the Build escape visible for a carried Plan draft. Entering Plan is + // hidden when the selected provider does not advertise that interaction. const planModeUiEnabled = settings.planModeEnabled; const composerProviderControls = useMemo( () => ({ showInteractionModeToggle: - planModeUiEnabled && getProviderInteractionModeToggle(providerStatuses, selectedProvider), + planModeUiEnabled && + (interactionMode === "plan" || + getProviderInteractionModeToggle(providerStatuses, selectedProvider)), + supportedRuntimeModes: getProviderSupportedRuntimeModes(selectedProviderStatus), }), - [planModeUiEnabled, providerStatuses, selectedProvider], + [ + interactionMode, + planModeUiEnabled, + providerStatuses, + selectedProvider, + selectedProviderStatus, + ], ); const selectedModelSelection = useMemo( () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch), @@ -1092,7 +1111,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) label: "/model", description: "Switch response model for this thread", }, - ...(planModeUiEnabled + ...(planModeUiEnabled && selectedProviderStatus?.showInteractionModeToggle !== false ? ([ { id: "slash:plan", @@ -1101,6 +1120,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) label: "/plan", description: "Switch this thread into plan mode", }, + ] as const) + : []), + ...(planModeUiEnabled && + (selectedProviderStatus?.showInteractionModeToggle !== false || interactionMode === "plan") + ? ([ { id: "slash:default", type: "slash-command", @@ -1163,6 +1187,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return []; }, [ composerTrigger, + interactionMode, planModeUiEnabled, selectedProvider, selectedProviderStatus, @@ -1973,7 +1998,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) event: KeyboardEvent, ) => { if (key === "Tab" && event.shiftKey) { - if (!planModeUiEnabled) return false; + if ( + !canUseProviderInteractionModeShortcut({ + planModeUiEnabled, + showInteractionModeToggle: composerProviderControls.showInteractionModeToggle, + interactionMode, + }) + ) { + return false; + } toggleInteractionMode(); return true; } @@ -2474,6 +2507,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ const addComposerImages = async (files: File[]) => { if (!activeThreadId || files.length === 0) return; + const unsupportedAttachmentReason = getUnsupportedProviderAttachmentReason( + selectedProviderStatus, + files.length, + ); + if (unsupportedAttachmentReason !== null) { + toastManager.add({ + type: "error", + title: unsupportedAttachmentReason, + }); + return; + } if (pendingUserInputs.length > 0) { toastManager.add({ type: "error", @@ -2946,7 +2990,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showPlanFollowUpPrompt={false} promptHasText={false} isSendBusy={isSendBusy} - sendDisabledReason={sendDisabledReason} + sendDisabledReason={effectiveSendDisabledReason} isConnecting={isConnecting} isEnvironmentUnavailable={ environmentUnavailable !== null || @@ -3273,7 +3317,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showPlanFollowUpPrompt={false} promptHasText={false} isSendBusy={isSendBusy} - sendDisabledReason={sendDisabledReason} + sendDisabledReason={effectiveSendDisabledReason} isConnecting={isConnecting} isEnvironmentUnavailable={ environmentUnavailable !== null || @@ -3352,6 +3396,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) @@ -3402,7 +3448,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } promptHasText={prompt.trim().length > 0} isSendBusy={isSendBusy} - sendDisabledReason={sendDisabledReason} + sendDisabledReason={effectiveSendDisabledReason} isConnecting={isConnecting} isEnvironmentUnavailable={ environmentUnavailable !== null || diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 20b57dea8c31..135f2e3ec7cc 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -14,6 +14,7 @@ import { export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; + supportedRuntimeModes: ReadonlyArray; showInteractionModeToggle: boolean; traitsMenuContent?: ReactNode; onToggleInteractionMode: () => void; @@ -64,10 +65,18 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls props.onRuntimeModeChange(value as RuntimeMode); }} > - Supervised - Auto-accept edits - Auto - Full access + {props.supportedRuntimeModes.includes("approval-required") ? ( + Supervised + ) : null} + {props.supportedRuntimeModes.includes("auto-accept-edits") ? ( + Auto-accept edits + ) : null} + {props.supportedRuntimeModes.includes("auto") ? ( + Auto + ) : null} + {props.supportedRuntimeModes.includes("full-access") ? ( + Full access + ) : null} diff --git a/apps/web/src/components/chat/ProviderModelPicker.logic.test.ts b/apps/web/src/components/chat/ProviderModelPicker.logic.test.ts new file mode 100644 index 000000000000..0489354df309 --- /dev/null +++ b/apps/web/src/components/chat/ProviderModelPicker.logic.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + getFallbackProviderModelLabel, + hasSelectableProviderModel, +} from "./ProviderModelPicker.logic"; + +describe("getFallbackProviderModelLabel", () => { + it("presents an empty provider model state without exposing an internal value", () => { + expect(getFallbackProviderModelLabel("")).toBe("No model available"); + expect(getFallbackProviderModelLabel(" ")).toBe("No model available"); + expect(getFallbackProviderModelLabel("composer-2")).toBe("composer-2"); + }); + + it("treats an empty model as unavailable for composer submission", () => { + expect(hasSelectableProviderModel("")).toBe(false); + expect(hasSelectableProviderModel(" ")).toBe(false); + expect(hasSelectableProviderModel("composer-2")).toBe(true); + }); +}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.logic.ts b/apps/web/src/components/chat/ProviderModelPicker.logic.ts new file mode 100644 index 000000000000..df3b5ac16d47 --- /dev/null +++ b/apps/web/src/components/chat/ProviderModelPicker.logic.ts @@ -0,0 +1,7 @@ +export function getFallbackProviderModelLabel(model: string): string { + return model.trim() || "No model available"; +} + +export function hasSelectableProviderModel(model: string): boolean { + return model.trim().length > 0; +} diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 5566160bcf3e..1834c6285ea5 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -18,6 +18,7 @@ import { } from "./providerIconUtils"; import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; import { ComposerControl, ComposerControlChevron } from "./ComposerControl"; +import { getFallbackProviderModelLabel } from "./ProviderModelPicker.logic"; export const ProviderModelPicker = memo(function ProviderModelPicker(props: { /** @@ -65,8 +66,13 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const selectedModel = selectedInstanceOptions.find((option) => option.slug === props.model) ?? selectedInstanceOptions[0]; - const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; - const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model; + const fallbackModelLabel = getFallbackProviderModelLabel(props.model); + const triggerTitle = selectedModel + ? getTriggerDisplayModelName(selectedModel) + : fallbackModelLabel; + const triggerLabel = selectedModel + ? getTriggerDisplayModelLabel(selectedModel) + : fallbackModelLabel; const showInstanceBadge = activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries); diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 842c616fe1fe..5c7da479caaf 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,5 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, ClineIcon, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("cline")]: ClineIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 9c36d32ff51a..32f241492d2e 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -299,7 +299,7 @@ function formatProcessName(command: string): string { function formatProcessType(process: ServerProcessDiagnosticsEntry): string { if (process.depth > 0) return "Subprocess"; - if (/\b(codex|claude|opencode|cursor)\b/i.test(process.command)) return "Agent"; + if (/\b(codex|claude|cline|opencode|cursor)\b/i.test(process.command)) return "Agent"; return "Process"; } diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index a663aa90990d..4f04597e0bd1 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -443,12 +443,13 @@ export function ProviderInstanceCard({ : null; const customModels = readConfigStringArray(instance.config, "customModels"); + const supportsCustomModels = driverOption?.supportsCustomModels !== false; // Server-returned models may lag behind settings writes. Treat probe // models as the source for built-ins only; custom rows come directly // from the current instance config so add/remove reflects immediately. const modelsForDisplay = deriveProviderModelsForDisplay({ liveModels: liveProvider?.models, - customModels, + customModels: supportsCustomModels ? customModels : [], }); const updateDisplayName = (value: string) => { @@ -781,6 +782,7 @@ export function ProviderInstanceCard({ hiddenModels={hiddenModels} favoriteModels={favoriteModels} modelOrder={modelOrder} + supportsCustomModels={supportsCustomModels} onChange={updateCustomModels} onHiddenModelsChange={onHiddenModelsChange} onFavoriteModelsChange={onFavoriteModelsChange} diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 9a42961d13ee..a551e6a7558b 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -62,6 +62,8 @@ interface ProviderModelsSectionProps { readonly favoriteModels: ReadonlyArray; /** Explicit user-authored model ordering for this provider instance. */ readonly modelOrder: ReadonlyArray; + /** Whether the provider accepts user-authored IDs outside its live catalog. */ + readonly supportsCustomModels?: boolean; /** * Commit the new custom-model list. Caller is responsible for routing the * write to the correct storage (legacy `settings.providers[kind]` vs. @@ -92,6 +94,7 @@ export function ProviderModelsSection({ hiddenModels, favoriteModels, modelOrder, + supportsCustomModels = true, onChange, onHiddenModelsChange, onFavoriteModelsChange, @@ -376,27 +379,33 @@ export function ProviderModelsSection({ })}
-
- { - setInput(event.target.value); - if (error) setError(null); - }} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - handleAdd(); - }} - placeholder={driverKind ? CUSTOM_MODEL_PLACEHOLDER_BY_KIND[driverKind] : "model-slug"} - spellCheck={false} - /> - -
+ {supportsCustomModels ? ( +
+ { + setInput(event.target.value); + if (error) setError(null); + }} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + handleAdd(); + }} + placeholder={driverKind ? CUSTOM_MODEL_PLACEHOLDER_BY_KIND[driverKind] : "model-slug"} + spellCheck={false} + /> + +
+ ) : ( +

+ Models are discovered from this provider and cannot be added manually. +

+ )} {error ?

{error}

: null}
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e77c05549265..6b6092577ccb 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -75,6 +75,7 @@ import { import { applyProviderInstanceSettings, deriveProviderInstanceEntries, + filterTextGenerationProviderInstanceEntries, sortProviderInstanceEntries, } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; @@ -1874,8 +1875,10 @@ export function GeneralSettingsPanel() { const textGenInstanceId = textGenerationModelSelection.instanceId; const textGenModel = textGenerationModelSelection.model; const textGenModelOptions = textGenerationModelSelection.options; - const textGenerationModelInstanceEntries = sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), + const textGenerationModelInstanceEntries = filterTextGenerationProviderInstanceEntries( + sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), + ), ); const textGenInstanceEntry = textGenerationModelInstanceEntries.find( (entry) => entry.instanceId === textGenInstanceId, diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.test.ts b/apps/web/src/components/settings/SourceControlWritingSettings.test.ts new file mode 100644 index 000000000000..19321736b479 --- /dev/null +++ b/apps/web/src/components/settings/SourceControlWritingSettings.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { sourceControlWriterToggleState } from "./SourceControlWritingSettings"; + +describe("source control writer model toggle", () => { + it("allows clearing a stale override when no supported model is available", () => { + expect( + sourceControlWriterToggleState({ + hasOverride: true, + hasSupportedModel: false, + }), + ).toEqual({ checked: true, disabled: false }); + }); + + it("prevents enabling a new override when no supported model is available", () => { + expect( + sourceControlWriterToggleState({ + hasOverride: false, + hasSupportedModel: false, + }), + ).toEqual({ checked: false, disabled: true }); + }); +}); diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index d7c094af372b..10bf89d5195d 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -9,6 +9,7 @@ import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSet import { applyProviderInstanceSettings, deriveProviderInstanceEntries, + filterTextGenerationProviderInstanceEntries, sortProviderInstanceEntries, } from "../../providerInstances"; import { @@ -40,6 +41,16 @@ const MODE_OPTIONS: Record 0; + const dedicatedWriterToggle = sourceControlWriterToggleState({ + hasOverride: usesDedicatedModel, + hasSupportedModel: canUseDedicatedModel, + }); const modelOptionsByInstance = getCustomModelOptionsByInstance( settings, serverProviders, @@ -173,7 +191,7 @@ export function SourceControlWritingSettingsSection() { description="Optional model override for change descriptions, change request titles and descriptions, and branch or bookmark names. Off uses the global text generation model." control={
- {usesDedicatedModel ? ( + {usesDedicatedModel && canUseDedicatedModel ? ( ) : null} updateSettings({ sourceControlWriterModelSelection: checked diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d6807..355514eab755 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -1,5 +1,6 @@ import { ClaudeSettings, + ClineSettings, CodexSettings, CursorSettings, GrokSettings, @@ -7,7 +8,15 @@ import { ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + ClineIcon, + CursorIcon, + GrokIcon, + type Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -32,6 +41,8 @@ export interface ProviderClientDefinition { * built-in default or custom — advertises the same marker. */ readonly badgeLabel?: string; + /** Whether Settings may author model IDs not advertised by the provider. */ + readonly supportsCustomModels?: boolean; } export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ @@ -61,6 +72,14 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = badgeLabel: "Early Access", settingsSchema: GrokSettings, }, + { + value: ProviderDriverKind.make("cline"), + label: "Cline", + icon: ClineIcon, + badgeLabel: "Early Access", + supportsCustomModels: false, + settingsSchema: ClineSettings, + }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 80f7d31cf2f9..bd422459c8b7 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -38,6 +38,8 @@ export function formatProviderDisplayName(provider: string | null | undefined): return "Cursor"; case "opencode": return "OpenCode"; + case "cline": + return "Cline"; default: { // Title-case unknown driver kinds so they read reasonably. const trimmed = provider.replace(/Agent$/i, "").trim(); diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 405366d9fcbe..d7e36f7ff0fe 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -3,7 +3,9 @@ import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contrac import { describe, expect, it } from "vite-plus/test"; import { createModelSelection } from "@t3tools/shared/model"; import { deriveProviderInstanceEntries } from "./providerInstances"; +import { getDefaultServerModel } from "./providerModels"; import { + getAppModelOptions, getAppModelOptionsForInstance, resolveAppModelSelectionForInstance, resolveAppModelSelectionState, @@ -58,6 +60,44 @@ function settingsWithProviderInstances(): UnifiedSettings { } describe("instance-scoped model selection", () => { + it("does not synthesize Codex's default for an empty Cline catalog", () => { + const cline = ProviderDriverKind.make("cline"); + expect( + getDefaultServerModel( + [provider({ provider: cline, instanceId: "cline", models: [] })], + cline, + ), + ).toBe(""); + + expect( + resolveAppModelSelectionState(DEFAULT_UNIFIED_SETTINGS, [ + provider({ provider: cline, instanceId: "cline", models: [] }), + ]), + ).toEqual({ instanceId: "t3code_no_provider", model: "" }); + }); + + it("ignores authored Cline model IDs that ACP did not advertise", () => { + const cline = ProviderDriverKind.make("cline"); + const providers = [provider({ provider: cline, instanceId: "cline", models: ["advertised"] })]; + const settings: UnifiedSettings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [ProviderInstanceId.make("cline")]: { + driver: cline, + config: { customModels: ["unadvertised"] }, + }, + }, + }; + const entry = deriveProviderInstanceEntries(providers)[0]!; + + expect(getAppModelOptions(settings, providers, cline).map((option) => option.slug)).toEqual([ + "advertised", + ]); + expect(getAppModelOptionsForInstance(settings, entry).map((option) => option.slug)).toEqual([ + "advertised", + ]); + }); + it("preserves server-provided legacy model metadata", () => { const baseProvider = provider({ instanceId: "claudeAgent", @@ -323,6 +363,35 @@ describe("instance-scoped model selection", () => { model: "openai/gpt-5.5", }); }); + + it("returns no background selection when Cline is the only usable provider", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("cline"), + instanceId: "cline", + models: ["advertised-interactive-model"], + }), + ]; + const settings: UnifiedSettings = { + ...settingsWithProviderInstances(), + providerInstances: { + [ProviderInstanceId.make("cline")]: { + driver: ProviderDriverKind.make("cline"), + enabled: true, + config: {}, + }, + }, + textGenerationModelSelection: { + instanceId: ProviderInstanceId.make("cline"), + model: "advertised-interactive-model", + }, + }; + + expect(resolveAppModelSelectionState(settings, providers)).toEqual({ + instanceId: "t3code_no_provider", + model: "", + }); + }); }); describe("withoutPlanAgentSelection", () => { diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index ccdffdda1004..ada928d45ada 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -23,7 +23,11 @@ import { resolveSelectableProvider, } from "./providerModels"; import { ModelEsque } from "./components/chat/providerIconUtils"; -import { type ProviderInstanceEntry, deriveProviderInstanceEntries } from "./providerInstances"; +import { + type ProviderInstanceEntry, + deriveProviderInstanceEntries, + NO_PROVIDER_MODEL_SELECTION, +} from "./providerInstances"; import { sortModelsForProviderInstance } from "./modelOrdering"; const MAX_CUSTOM_MODEL_COUNT = 32; @@ -166,7 +170,8 @@ export function getAppModelOptions( // settings and the initial render before the first write both still // see the user's authored custom models. const defaultInstanceId = defaultInstanceIdForDriver(provider); - const customModels = readInstanceCustomModels(settings, defaultInstanceId, provider); + const customModels = + provider === "cline" ? [] : readInstanceCustomModels(settings, defaultInstanceId, provider); for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) { if (seen.has(slug)) { continue; @@ -209,7 +214,10 @@ export function getAppModelOptionsForInstance( ), ); - const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind); + const customModels = + entry.driverKind === "cline" + ? [] + : readInstanceCustomModels(settings, entry.instanceId, entry.driverKind); for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) { if (seen.has(slug)) { continue; @@ -331,13 +339,18 @@ export function resolveAppModelSelectionState( instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID, model: DEFAULT_TEXT_GENERATION_MODEL, }; - const entries = deriveProviderInstanceEntries(providers); + const entries = deriveProviderInstanceEntries(providers).filter( + (entry) => entry.driverKind !== "cline", + ); const selectedEntry = entries.find( (entry) => entry.instanceId === selection.instanceId && entry.enabled && entry.isAvailable, ); const entry = selectedEntry ?? entries.find((candidate) => candidate.enabled && candidate.isAvailable); if (entry) { + if (entry.driverKind === "cline" && entry.models.length === 0) { + return createModelSelection(entry.instanceId, "", []); + } // When the instance changed due to fallback (e.g. selected instance was disabled), // don't carry over the old instance's model — use the fallback instance's default. const selectedModel = selectedEntry ? selection.model : null; @@ -360,7 +373,14 @@ export function resolveAppModelSelectionState( return createModelSelection(entry.instanceId, model, modelOptionsForDispatch); } - const provider = resolveSelectableProvider(providers, null); + if (entries.length === 0) { + return NO_PROVIDER_MODEL_SELECTION; + } + + const provider = resolveSelectableProvider( + providers.filter((candidate) => candidate.driver !== "cline"), + null, + ); const keptSelectedProvider = false; // When the provider changed due to fallback (e.g. selected provider was disabled), diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index b64a5e25d508..551485d1b1b5 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -4,6 +4,7 @@ import { applyProviderInstanceSettings, deriveProviderEntriesByEnvironment, deriveProviderInstanceEntries, + filterTextGenerationProviderInstanceEntries, getDefaultProviderInstanceModel, isProviderInstancePickerReady, isProviderInstancePickerVisible, @@ -87,6 +88,20 @@ describe("isProviderInstancePickerVisible", () => { }); }); +describe("filterTextGenerationProviderInstanceEntries", () => { + it("excludes built-in and custom Cline instances", () => { + const entries = deriveProviderInstanceEntries([ + provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), + provider({ provider: ProviderDriverKind.make("cline"), instanceId: "cline" }), + provider({ provider: ProviderDriverKind.make("cline"), instanceId: "cline_work" }), + ]); + + expect( + filterTextGenerationProviderInstanceEntries(entries).map((entry) => entry.instanceId), + ).toEqual(["codex"]); + }); +}); + describe("applyProviderInstanceSettings", () => { it("uses settings when a streamed snapshot still reports a disabled default as enabled", () => { const entries = deriveProviderInstanceEntries([ @@ -420,6 +435,42 @@ describe("resolveDefaultProviderModelSelection", () => { expect(resolveDefaultProviderModelSelection(providers, stored)).toBe(stored); }); + it("heals a stale Cline model against the current advertised catalog", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("cline"), + instanceId: "cline", + models: [model("current-account-model", false, true)], + }), + ]; + + expect( + resolveDefaultProviderModelSelection(providers, { + instanceId: ProviderInstanceId.make("cline"), + model: "retired-account-model", + options: [{ id: "unused", value: "stale" }], + }), + ).toEqual({ instanceId: "cline", model: "current-account-model" }); + }); + + it("returns no project selection for a Cline instance with an empty catalog", () => { + const providers = [ + provider({ + provider: ProviderDriverKind.make("cline"), + instanceId: "cline", + status: "error", + models: [], + }), + ]; + + expect( + resolveDefaultProviderModelSelection(providers, { + instanceId: ProviderInstanceId.make("cline"), + model: "retired-account-model", + }), + ).toBeNull(); + }); + it("replaces a stale stored instance with the first ready instance and its model", () => { const providers = [ provider({ diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index ef60d554dd78..30d485dd7efd 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -288,6 +288,13 @@ export function sortProviderInstanceEntries( return sorted; } +/** Background text generation must not offer drivers without a safe backend. */ +export function filterTextGenerationProviderInstanceEntries( + entries: ReadonlyArray, +): ReadonlyArray { + return entries.filter((entry) => entry.driverKind !== "cline"); +} + /** * Look up a single instance entry by exact `instanceId`. Missing snapshots * are not inferred from driver kind in UI routing code. @@ -383,7 +390,17 @@ export function resolveDefaultProviderModelSelection( ): ModelSelection | null { const instanceId = resolveSelectableProviderInstance(providers, selection?.instanceId); if (instanceId === undefined) return null; - if (selection?.instanceId === instanceId) return selection; + if (selection?.instanceId === instanceId) { + const entry = deriveProviderInstanceEntries(providers).find( + (candidate) => candidate.instanceId === instanceId, + ); + if ( + entry?.driverKind !== "cline" || + entry.models.some((model) => model.slug === selection.model) + ) { + return selection; + } + } const model = getDefaultProviderInstanceModel(providers, instanceId); return model ? { instanceId, model } : null; } diff --git a/apps/web/src/providerModels.test.ts b/apps/web/src/providerModels.test.ts new file mode 100644 index 000000000000..707bf341136b --- /dev/null +++ b/apps/web/src/providerModels.test.ts @@ -0,0 +1,92 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + canUseProviderInteractionModeShortcut, + getUnsupportedProviderAttachmentReason, + getProviderSupportedRuntimeModes, + getUnsupportedProviderModeReason, +} from "./providerModels"; + +const provider = (overrides: Partial = {}): ServerProvider => ({ + instanceId: ProviderInstanceId.make("cline"), + driver: ProviderDriverKind.make("cline"), + displayName: "Cline", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-23T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...overrides, +}); + +describe("provider mode capabilities", () => { + it("keeps legacy providers compatible when runtime modes are absent", () => { + expect(getProviderSupportedRuntimeModes(provider())).toEqual([ + "approval-required", + "auto-accept-edits", + "auto", + "full-access", + ]); + }); + + it("requires an explicit safe mode change for restricted providers", () => { + const cline = provider({ + showInteractionModeToggle: false, + supportedRuntimeModes: ["full-access"], + }); + + expect( + getUnsupportedProviderModeReason({ + provider: cline, + runtimeMode: "approval-required", + interactionMode: "default", + }), + ).toContain("Choose Full access"); + expect( + getUnsupportedProviderModeReason({ + provider: cline, + runtimeMode: "full-access", + interactionMode: "plan", + }), + ).toContain("Choose Build"); + expect( + getUnsupportedProviderModeReason({ + provider: cline, + runtimeMode: "full-access", + interactionMode: "default", + }), + ).toBeNull(); + }); + + it("blocks entering Plan by shortcut but preserves the Build escape", () => { + expect( + canUseProviderInteractionModeShortcut({ + planModeUiEnabled: true, + showInteractionModeToggle: false, + interactionMode: "default", + }), + ).toBe(false); + expect( + canUseProviderInteractionModeShortcut({ + planModeUiEnabled: true, + showInteractionModeToggle: false, + interactionMode: "plan", + }), + ).toBe(true); + }); + + it("rejects carried images only when the provider explicitly disallows them", () => { + expect( + getUnsupportedProviderAttachmentReason(provider({ supportsImageAttachments: false }), 1), + ).toContain("Remove the images"); + expect(getUnsupportedProviderAttachmentReason(provider(), 1)).toBeNull(); + expect( + getUnsupportedProviderAttachmentReason(provider({ supportsImageAttachments: false }), 0), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index 6fc8b5e122a9..5fe8e6381cf4 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -5,6 +5,7 @@ import { ProviderDriverKind, type ModelCapabilities, type ProviderInstanceId, + type RuntimeMode, type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; @@ -14,6 +15,12 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], }); const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); +export const ALL_RUNTIME_MODES: ReadonlyArray = [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access", +]; export function formatProviderDriverKindLabel(provider: ProviderDriverKind): string { return provider @@ -53,6 +60,50 @@ export function getProviderInteractionModeToggle( return getProviderSnapshot(providers, provider)?.showInteractionModeToggle ?? true; } +export function getProviderSupportedRuntimeModes( + provider: ServerProvider | null | undefined, +): ReadonlyArray { + return provider?.supportedRuntimeModes ?? ALL_RUNTIME_MODES; +} + +export function getUnsupportedProviderModeReason(input: { + readonly provider: ServerProvider | null | undefined; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: "default" | "plan"; +}): string | null { + const label = + input.provider?.displayName?.trim() || + (input.provider ? formatProviderDriverKindLabel(input.provider.driver) : "This provider"); + if (!getProviderSupportedRuntimeModes(input.provider).includes(input.runtimeMode)) { + return `${label} does not support the selected access mode. Choose Full access to continue.`; + } + if (input.interactionMode === "plan" && input.provider?.showInteractionModeToggle === false) { + return `${label} does not support Plan mode. Choose Build to continue.`; + } + return null; +} + +export function canUseProviderInteractionModeShortcut(input: { + readonly planModeUiEnabled: boolean; + readonly showInteractionModeToggle: boolean; + readonly interactionMode: "default" | "plan"; +}): boolean { + return ( + input.planModeUiEnabled && (input.showInteractionModeToggle || input.interactionMode === "plan") + ); +} + +export function getUnsupportedProviderAttachmentReason( + provider: ServerProvider | null | undefined, + attachmentCount: number, +): string | null { + if (attachmentCount === 0 || provider?.supportsImageAttachments !== false) { + return null; + } + const label = provider.displayName?.trim() || formatProviderDriverKindLabel(provider.driver); + return `${label} does not support image attachments. Remove the images to continue.`; +} + export function isProviderEnabled( providers: ReadonlyArray, provider: ProviderDriverKind, @@ -122,6 +173,11 @@ export function getDefaultServerModel( provider: ProviderDriverKind, ): string { const models = getProviderModels(providers, provider); + if (provider === "cline" && models.length === 0) { + // Cline's model catalog is account-scoped. An empty catalog is an + // actionable provider error, not a reason to synthesize a Codex model. + return ""; + } return ( models.find((model) => model.isDefault && !model.isCustom)?.slug ?? models.find((model) => !model.isCustom)?.slug ?? diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4824258422fb..bc849f82da8b 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -52,6 +52,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("cline"), + label: "Cline", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index da16f74d339f..bc6f6273289f 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -94,7 +94,7 @@ The live backend agent implementation and its event stream. The main service is #### Provider -The backend agent runtime that actually performs work. Five drivers ship built in: Codex, Claude, Cursor, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. +The backend agent runtime that actually performs work. Six drivers ship built in: Codex, Claude, Cline, Cursor, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. #### Session diff --git a/docs/internals/overview.md b/docs/internals/overview.md index b9454f7b58d0..7c36eaeaf5f8 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -18,13 +18,13 @@ there, never in the client. ┌──────────────────▼─────────────────────────────┐ │ apps/server │ │ orchestration engine (event-sourced) │ -│ provider driver registry (5 built-in drivers) │ +│ provider driver registry (6 built-in drivers) │ │ checkpointing, VCS, terminals, filesystem │ └──────────────────┬─────────────────────────────┘ │ per-driver transport ┌──────────────────▼─────────────────────────────┐ -│ Agent CLIs: Codex, Claude, Cursor, Grok, │ -│ OpenCode │ +│ Agent CLIs: Codex, Claude, Cline, Cursor, │ +│ Grok, OpenCode │ └────────────────────────────────────────────────┘ ``` @@ -106,8 +106,8 @@ build production behavior on receipts. ## Provider drivers -Five drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: -Codex, Claude, Cursor, Grok, and OpenCode. A driver declares its kind and config schema and creates a +Six drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: +Codex, Claude, Cline, Cursor, Grok, and OpenCode. A driver declares its kind and config schema and creates a scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves an instance to its adapter, so `ProviderService` routes session and turn operations without knowing which agent is behind them. See [providers.md](./providers.md). diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03de..b7bfb6b0e11f 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -7,12 +7,13 @@ orchestration layer does not know which one is behind a thread. ## Built-in drivers -[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries: +[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries: | Driver kind | Driver source | | ------------- | --------------------------------------- | | `codex` | [`Drivers/CodexDriver.ts`][codex] | | `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | +| `cline` | [`Drivers/ClineDriver.ts`][cline] | | `cursor` | [`Drivers/CursorDriver.ts`][cursor] | | `grok` | [`Drivers/GrokDriver.ts`][grok] | | `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | @@ -39,6 +40,24 @@ directory to route session and turn operations for a thread, so callers name a t Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No orchestration, contract, or client change is required for the common case. +### Current Cline ACP limitations + +Cline ACP accepts per-session `mcpServers` during setup but does not currently consume them. The +provider service therefore withholds T3 MCP credentials for Cline, and agent browser/preview tools +are unsupported in Cline sessions. Do not work around this by writing Cline's global configuration: +provider processes may run on remote hosts and multiple configured instances may share that state. + +Cline also omits the optional `ProviderInstance.textGeneration` capability. Current ACP creates its +normal local runtime with default config extensions, which can execute project/account lifecycle +hooks before any ACP tool-permission request. Until ACP exposes a verified extension-disable +boundary, server settings must never select Cline for background title, branch, commit, or pull +request text generation. + +The same extension boundary means only `full-access` interactive sessions are currently truthful. +`ClineAdapter` rejects every narrower runtime mode before spawning the CLI, and the provider +snapshot does not advertise the plan/build interaction toggle. Do not describe ACP permission +responses as supervising workspace/account hooks; they are separate execution paths. + ## How provider work is requested Clients never call a provider directly. They dispatch orchestration commands over the RPC method @@ -78,6 +97,7 @@ when a request opens (approval) or user input is requested, via [drivers]: ../../apps/server/src/provider/builtInDrivers.ts [codex]: ../../apps/server/src/provider/Drivers/CodexDriver.ts [claude]: ../../apps/server/src/provider/Drivers/ClaudeDriver.ts +[cline]: ../../apps/server/src/provider/Drivers/ClineDriver.ts [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts diff --git a/docs/user/install.md b/docs/user/install.md index 15f96e00d4f3..4d2ccd172617 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -54,20 +54,38 @@ yay -S t3code-nightly-bin T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want to use, then authenticate it. -| Provider | CLI | Default binary | Log in with | -| ---------- | ----------------------------------------------------- | -------------- | --------------------- | -| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | -| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | -| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | -| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | -| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | - -Codex and Claude are on by default. Cursor, Grok Build, and OpenCode are off by default; turn -them on in **Settings** → the provider's card when you want to use them. +| Provider | CLI | Default binary | Log in with | +| ---------- | ------------------------------------------------------ | -------------- | --------------------- | +| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | +| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | +| Cline | [Cline CLI](https://docs.cline.bot/usage/cli-overview) | `cline` | `cline auth` | +| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | +| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | +| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | + +Codex, Claude, and Cursor are on by default. Grok Build, OpenCode, and Cline are off by default; +turn them on in **Settings** → the provider's card when you want to use them. Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. +Cline reuses credentials saved by `cline auth`. T3 Code does not launch Cline's interactive ACP +sign-in flow, so authenticate it on the server machine before enabling it. + +Image attachments are currently unavailable with Cline. T3 Code rejects Cline turns that contain +images because the current Cline CLI drops image input over ACP instead of sending it to the model. + +T3 Code's agent browser and preview tools are also unavailable in Cline sessions. Current Cline ACP +does not consume per-session MCP servers, so T3 Code withholds the otherwise unused MCP credential. + +Cline is not used for T3 Code's background title, branch, commit, or pull-request text generation. +Current Cline ACP loads workspace and account extensions, including executable lifecycle hooks, and +does not expose a way for T3 Code to disable them for non-interactive metadata generation. + +Cline sessions currently require **Full access**. Supervised and auto-accept modes cannot cover +extensions that run outside ACP tool-permission requests, so T3 Code rejects those modes before it +starts Cline. Plan mode is not advertised for the same reason. + Run the login command on the machine running the T3 Code server, not on the device you browse from. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..b8d7df918817 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const CLINE_DRIVER_KIND = ProviderDriverKind.make("cline"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -146,7 +147,6 @@ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray = [ ]; export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-5.6-luna"; export const DEFAULT_TEXT_GENERATION_REASONING_EFFORT = "low"; - export const DEFAULT_MODEL_BY_PROVIDER: Partial> = { [CODEX_DRIVER_KIND]: DEFAULT_MODEL, [CLAUDE_DRIVER_KIND]: "claude-sonnet-5", @@ -222,4 +222,5 @@ export const PROVIDER_DISPLAY_NAMES: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [CLINE_DRIVER_KIND]: "Cline", }; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 9791a4f62185..b6da649cc9f9 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -19,6 +19,7 @@ import { } from "./keybindings.ts"; import { EditorId, RemoteOpenTarget } from "./editor.ts"; import { ModelCapabilities } from "./model.ts"; +import { RuntimeMode } from "./orchestration.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ServerSettings } from "./settings.ts"; @@ -170,6 +171,12 @@ export const ServerProvider = Schema.Struct({ badgeLabel: Schema.optional(TrimmedNonEmptyString), continuation: Schema.optional(ServerProviderContinuation), showInteractionModeToggle: Schema.optional(Schema.Boolean), + // Optional for backward compatibility. An absent list means every runtime + // mode is supported; providers with stricter boundaries advertise them. + supportedRuntimeModes: Schema.optional(Schema.Array(RuntimeMode)), + // Optional for backward compatibility. An absent value keeps the legacy + // behavior where clients allow image attachments. + supportsImageAttachments: Schema.optional(Schema.Boolean), requiresNewThreadForModelChange: Schema.optional(Schema.Boolean), enabled: Schema.Boolean, installed: Schema.Boolean, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 55023bcc48e7..deb9cbc3e4c7 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -203,12 +203,14 @@ describe("provider enabled defaults", () => { expect(decoded.providers.cursor.enabled).toBe(true); expect(decoded.providers.grok.enabled).toBe(false); expect(decoded.providers.opencode.enabled).toBe(false); + expect(decoded.providers.cline.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(true); expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false); + expect(defaultEnabledForDriver(ProviderDriverKind.make("cline"))).toBe(false); // Unknown fork drivers stay enabled; their own build decides otherwise. expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 80e03b8c879e..5b5715fa8200 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -455,7 +455,7 @@ export type CursorSettings = typeof CursorSettings.Type; export const GrokSettings = makeProviderSettingsSchema( { - // Off by default (like Cursor and OpenCode): the binding is not yet + // Off by default (like OpenCode and Cline): the binding is not yet // stable enough to probe on every install. Users opt in from Settings. enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), @@ -481,7 +481,7 @@ export type GrokSettings = typeof GrokSettings.Type; export const OpenCodeSettings = makeProviderSettingsSchema( { - // Off by default (like Cursor and Grok): the binding is not yet stable + // Off by default (like Grok and Cline): the binding is not yet stable // enough to probe on every install. Users opt in from Settings. enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), @@ -531,6 +531,32 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const ClineSettings = makeProviderSettingsSchema( + { + // Off by default (like Grok and OpenCode): the binding is not yet stable + // enough to probe on every install. Users opt in from Settings. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("cline").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Cline CLI binary.", + providerSettingsForm: { placeholder: "cline", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type ClineSettings = typeof ClineSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -673,6 +699,7 @@ export const ServerSettings = Schema.Struct({ cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + cline: ClineSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob @@ -820,6 +847,12 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const ClineSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), @@ -861,6 +894,7 @@ export const ServerSettingsPatch = Schema.Struct({ cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), + cline: Schema.optionalKey(ClineSettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index baa84a4e1aa8..c8b5ee8fb3d1 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -264,6 +264,36 @@ describe("serverSettings helpers", () => { expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection); }); + it("falls back from built-in and custom Cline source control writer selections", () => { + const customClineId = ProviderInstanceId.make("cline_work"); + const settings = { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + cline: { ...DEFAULT_SERVER_SETTINGS.providers.cline, enabled: true }, + }, + providerInstances: { + [customClineId]: { + driver: ProviderDriverKind.make("cline"), + enabled: true, + config: {}, + }, + }, + }; + + for (const instanceId of [ProviderInstanceId.make("cline"), customClineId]) { + expect( + resolveSourceControlWriterModelSelection({ + ...settings, + sourceControlWriterModelSelection: createModelSelection( + instanceId, + "advertised-interactive-model", + ), + }), + ).toBe(settings.textGenerationModelSelection); + } + }); + it("replaces providerInstances maps so omitted instance fields are cleared", () => { const codexId = ProviderInstanceId.make("codex"); const current = { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 69fc9eaacbcc..0024b117cb92 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -46,12 +46,24 @@ export function isModelSelectionProviderEnabled( ); } +function isTextGenerationModelSelectionSupported( + settings: ServerSettings, + selection: ModelSelection, +): boolean { + const driver = settings.providerInstances[selection.instanceId]?.driver ?? selection.instanceId; + return driver !== "cline"; +} + export function resolveSourceControlWriterModelSelection( settings: ServerSettings, providers?: ReadonlyArray, ): ModelSelection { const selection = settings.sourceControlWriterModelSelection; - if (!selection || !isModelSelectionProviderEnabled(settings, selection)) { + if ( + !selection || + !isModelSelectionProviderEnabled(settings, selection) || + !isTextGenerationModelSelectionSupported(settings, selection) + ) { return settings.textGenerationModelSelection; } if (providers === undefined) { From efd764866e8d967db50e2ad3bfb7e732a5429f3a Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Sun, 23 Aug 2026 22:10:37 -0400 Subject: [PATCH 2/9] fix(providers): harden Cline integration --- apps/marketing/src/pages/index.astro | 1 + .../features/threads/NewTaskDraftScreen.tsx | 8 +- .../src/features/threads/ThreadComposer.tsx | 7 +- .../threads/threadComposerSubmission.test.ts | 35 ++++ .../threads/threadComposerSubmission.ts | 8 + apps/mobile/src/lib/modelOptions.test.ts | 28 ++- apps/mobile/src/lib/modelOptions.ts | 34 ++- apps/mobile/src/state/thread-outbox-model.ts | 38 +--- apps/mobile/src/state/thread-outbox.test.ts | 68 ++++-- apps/server/scripts/acp-mock-agent.ts | 22 +- .../src/provider/Drivers/ClineDriver.ts | 148 +++++++------- .../src/provider/Layers/ClineAdapter.test.ts | 193 +++++++++++++++++- .../src/provider/Layers/ClineAdapter.ts | 118 ++++++----- .../provider/acp/AcpJsonRpcConnection.test.ts | 40 ++++ .../src/provider/acp/AcpSessionRuntime.ts | 80 ++++++-- .../src/provider/acp/ClineAcpCliProbe.test.ts | 2 +- .../src/provider/acp/ClineAcpSupport.test.ts | 35 ++++ .../src/provider/acp/ClineAcpSupport.ts | 24 ++- .../src/provider/acp/XAiAcpExtension.ts | 9 +- apps/server/src/serverSettings.test.ts | 33 +++ apps/server/src/serverSettings.ts | 17 +- .../web/src/components/ChatView.logic.test.ts | 41 ++++ apps/web/src/components/ChatView.logic.ts | 13 ++ apps/web/src/components/ChatView.tsx | 112 +++++++--- apps/web/src/components/Icons.test.tsx | 17 ++ apps/web/src/components/Icons.tsx | 14 +- apps/web/src/components/chat/ChatComposer.tsx | 100 ++++----- .../chat/ProviderModelPicker.logic.test.ts | 26 +++ .../chat/ProviderModelPicker.logic.ts | 15 ++ .../SourceControlWritingSettings.test.ts | 30 ++- .../settings/SourceControlWritingSettings.tsx | 14 +- apps/web/src/composerDraftStore.test.ts | 96 +++++++++ apps/web/src/composerDraftStore.ts | 73 ++++--- apps/web/src/modelSelection.test.ts | 49 ++++- apps/web/src/modelSelection.ts | 66 ++---- apps/web/src/providerInstances.test.ts | 41 ++++ apps/web/src/providerInstances.ts | 40 ++++ 37 files changed, 1283 insertions(+), 412 deletions(-) create mode 100644 apps/mobile/src/features/threads/threadComposerSubmission.test.ts create mode 100644 apps/mobile/src/features/threads/threadComposerSubmission.ts create mode 100644 apps/web/src/components/Icons.test.tsx diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index f995c745de33..41be9d86c88c 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -1273,6 +1273,7 @@ const mobileEndorsementRows = [ .harness-grid { grid-template-columns: 1fr 1fr; } .harness { border-right: 0; border-bottom: 1px solid var(--border); } .harness:nth-child(odd) { border-right: 1px solid var(--border); } + .harness:nth-last-child(-n + 2) { border-bottom: 0; } .git-inner { grid-template-columns: 1fr; gap: 40px; } .open-grid { grid-template-columns: 1fr; } } diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 120d608c48be..4f3c1c4976a5 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -58,6 +58,7 @@ import { getUnavailableProviderModelReason, providerSupportsImageAttachments, resolveSelectableModelSelection, + shouldShowProviderInteractionModeToggle, } from "../../lib/modelOptions"; import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; @@ -1056,8 +1057,11 @@ export function NewTaskDraftScreen(props: { onPress={settingsSheetPresentation.open} /> {flow.planModeEnabled && - (flow.interactionMode === "plan" || - flow.selectedModelOption?.showInteractionModeToggle !== false) ? ( + shouldShowProviderInteractionModeToggle({ + config: selectedEnvironmentServerConfig, + selection: flow.selectedModel, + interactionMode: flow.interactionMode, + }) ? ( { + it("preserves the draft when native submission is blocked", async () => { + let draft = "keep this prompt"; + const submit = vi.fn(async () => { + draft = ""; + return MessageId.make("message-1"); + }); + + await expect( + submitThreadComposerIfAllowed({ + canSend: false, + submit, + }), + ).resolves.toBeNull(); + expect(submit).not.toHaveBeenCalled(); + expect(draft).toBe("keep this prompt"); + }); + + it("forwards an allowed submission", async () => { + const messageId = MessageId.make("message-1"); + + await expect( + submitThreadComposerIfAllowed({ + canSend: true, + submit: async () => messageId, + }), + ).resolves.toBe(messageId); + }); +}); diff --git a/apps/mobile/src/features/threads/threadComposerSubmission.ts b/apps/mobile/src/features/threads/threadComposerSubmission.ts new file mode 100644 index 000000000000..e33a512f7b8c --- /dev/null +++ b/apps/mobile/src/features/threads/threadComposerSubmission.ts @@ -0,0 +1,8 @@ +import type { MessageId } from "@t3tools/contracts"; + +export function submitThreadComposerIfAllowed(input: { + readonly canSend: boolean; + readonly submit: () => Promise; +}): Promise { + return input.canSend ? input.submit() : Promise.resolve(null); +} diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 86159f9190f3..12162e2f0fe8 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -12,6 +12,7 @@ import { providerSupportsImageAttachments, resolveDefaultableModelSelection, resolveSelectableModelSelection, + shouldShowProviderInteractionModeToggle, } from "./modelOptions"; describe("mobile model options", () => { @@ -143,7 +144,7 @@ describe("mobile model options", () => { expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled); }); - it("does not synthesize Cline selections missing from the online catalog", () => { + it("preserves but blocks Cline selections missing from the online catalog", () => { const config = { providers: [ { @@ -162,7 +163,10 @@ describe("mobile model options", () => { model: "composer-2", }; - expect(resolveSelectableModelSelection(config, stale)).toBeNull(); + expect(resolveSelectableModelSelection(config, stale)).toBe(stale); + expect(getUnavailableProviderModelReason({ config, selection: stale })).toContain( + "Choose another model", + ); expect(buildModelOptions(config, stale)).toEqual([]); const changedCatalog = { ...config, @@ -181,7 +185,10 @@ describe("mobile model options", () => { }, ], } as unknown as ServerConfig; - expect(resolveSelectableModelSelection(changedCatalog, stale)).toBeNull(); + expect(resolveSelectableModelSelection(changedCatalog, stale)).toBe(stale); + expect( + getUnavailableProviderModelReason({ config: changedCatalog, selection: stale }), + ).toContain("Choose another model"); expect( buildModelOptions(changedCatalog, stale).map((option) => option.selection.model), ).toEqual(["current-account-model"]); @@ -206,6 +213,7 @@ describe("mobile model options", () => { installed: true, status: "warning", auth: { status: "unknown" }, + showInteractionModeToggle: false, supportsImageAttachments: false, models: [], }, @@ -244,6 +252,20 @@ describe("mobile model options", () => { attachmentCount: 1, }), ).not.toBeNull(); + expect( + shouldShowProviderInteractionModeToggle({ + config, + selection, + interactionMode: "default", + }), + ).toBe(false); + expect( + shouldShowProviderInteractionModeToggle({ + config, + selection, + interactionMode: "plan", + }), + ).toBe(true); }); it("preserves carried Cline modes but blocks them until the user chooses supported modes", () => { diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index b59f0326b373..57c548e21058 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -62,6 +62,20 @@ export function getUnsupportedProviderModeReason(input: { return null; } +export function shouldShowProviderInteractionModeToggle(input: { + readonly config: T3ServerConfig | null | undefined; + readonly selection: ModelSelection | null | undefined; + readonly interactionMode: ProviderInteractionMode; +}): boolean { + if (input.interactionMode === "plan") { + return true; + } + const provider = input.config?.providers.find( + (candidate) => candidate.instanceId === input.selection?.instanceId, + ); + return provider?.showInteractionModeToggle !== false; +} + export function getUnsupportedProviderAttachmentReason(input: { readonly config: T3ServerConfig | null | undefined; readonly selection: ModelSelection | null | undefined; @@ -107,14 +121,15 @@ export function getUnavailableProviderModelReason(input: { (candidate) => candidate.instanceId === input.selection?.instanceId, ); if ( - provider?.driver === "cline" && - provider.status === "warning" && - provider.auth.status === "unknown" && - !provider.models.some((candidate) => candidate.slug === input.selection?.model) + provider?.driver !== "cline" || + provider.models.some((candidate) => candidate.slug === input.selection?.model) ) { + return null; + } + if (provider.status === "warning" && provider.auth.status === "unknown") { return `${providerDisplayLabel(provider)} is still checking its model catalog. Wait for the provider check to finish.`; } - return null; + return `${providerDisplayLabel(provider)} no longer offers the selected model. Choose another model to continue.`; } function providerDisplayLabel(provider: { @@ -167,8 +182,10 @@ function hasUnadvertisedClineSelection( * A stored model selection is only usable when its provider instance is * currently enabled, installed, and authenticated on the server. Returns the * selection unchanged when usable, otherwise `null` so callers fall through to - * the server's default model. A missing config (environment offline) cannot be - * validated, so stored selections pass through untouched. + * the server's default model. A usable Cline instance keeps an unadvertised + * selection intact so the UI can identify and block the stale model without + * silently rerouting the draft. A missing config (environment offline) cannot + * be validated, so stored selections pass through untouched. */ export function resolveSelectableModelSelection( config: T3ServerConfig | null | undefined, @@ -180,9 +197,6 @@ export function resolveSelectableModelSelection( const provider = config.providers.find( (candidate) => candidate.instanceId === selection.instanceId, ); - if (hasUnadvertisedClineSelection(config, selection)) { - return null; - } return provider && provider.enabled && provider.installed && diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 6e1c6a973433..a8b15a474ae4 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -1,4 +1,3 @@ -import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; import { CommandId, @@ -218,42 +217,17 @@ export function isQueuedThreadCreationSendable(message: QueuedThreadMessage): bo return message.creation.workspaceMode !== "worktree" || Boolean(message.creation.branch); } -function errorMessage(error: unknown): string | null { - if (error instanceof Error) { - return error.message; - } - if (typeof error === "object" && error !== null && "message" in error) { - return typeof error.message === "string" ? error.message : null; - } - return typeof error === "string" ? error : null; -} - -export function shouldRetryThreadOutboxDelivery(error: unknown): boolean { - if ( - typeof error === "object" && - error !== null && - "_tag" in error && - error._tag === "ConnectionTransientError" - ) { - return true; - } - return isTransportConnectionErrorMessage(errorMessage(error)); -} - export type ThreadOutboxCommandStage = "settings-sync" | "start-turn"; -export type ThreadOutboxFailureAction = "retry" | "discard"; +export type ThreadOutboxFailureAction = "retry"; export function resolveThreadOutboxFailureAction(input: { readonly stage: ThreadOutboxCommandStage; readonly error: unknown; readonly interrupted: boolean; }): ThreadOutboxFailureAction { - if ( - input.stage === "settings-sync" || - input.interrupted || - shouldRetryThreadOutboxDelivery(input.error) - ) { - return "retry"; - } - return "discard"; + // The outbox is the durable copy of user-authored input. A command failure + // can become recoverable after a provider refresh or user edit, so delivery + // failures back off but never discard the queued message. + void input; + return "retry"; } diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index ead6c81e415d..c1ad7086c1b4 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -20,7 +20,6 @@ import { resolveThreadOutboxDeliveryAction, resolveThreadOutboxFailureAction, resolveQueuedThreadSettings, - shouldRetryThreadOutboxDelivery, threadOutboxRetryDelayMs, type QueuedThreadMessage, } from "./thread-outbox-model"; @@ -75,7 +74,14 @@ describe("thread outbox", () => { auth: { status: "authenticated" }, supportedRuntimeModes: ["full-access"], supportsImageAttachments: false, - models: [], + models: [ + { + slug: "current-account-model", + name: "Current account model", + isCustom: false, + capabilities: null, + }, + ], }, ], } as unknown as ServerConfig; @@ -100,6 +106,49 @@ describe("thread outbox", () => { ).toContain("Remove the images"); }); + it("keeps queued Cline input when its selected model leaves the live catalog", () => { + const message = queuedMessage({ + messageId: "message-stale-cline-model", + createdAt: "2026-08-23T00:00:00.000Z", + }); + const modelSelection = { + instanceId: ProviderInstanceId.make("cline"), + model: "retired-account-model", + }; + const config = { + providers: [ + { + instanceId: "cline", + driver: "cline", + displayName: "Cline", + enabled: true, + installed: true, + status: "ready", + auth: { status: "authenticated" }, + supportedRuntimeModes: ["full-access"], + models: [ + { + slug: "current-account-model", + name: "Current account model", + isCustom: false, + capabilities: null, + }, + ], + }, + ], + } as unknown as ServerConfig; + + expect( + getQueuedDeliveryUnsupportedProviderInputReason({ + config, + message, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + }), + ).toContain("Choose another model"); + }); + it("groups messages by scoped thread and preserves creation order", () => { const later = queuedMessage({ messageId: "message-2", @@ -648,18 +697,7 @@ describe("thread outbox", () => { expect(isQueuedThreadCreationSendable(base)).toBe(false); }); - it("retries transport failures but drops deterministic command failures", () => { - expect(shouldRetryThreadOutboxDelivery(new Error("Socket is not connected"))).toBe(true); - expect( - shouldRetryThreadOutboxDelivery({ - _tag: "ConnectionTransientError", - message: "temporarily unavailable", - }), - ).toBe(true); - expect(shouldRetryThreadOutboxDelivery(new Error("Thread no longer exists"))).toBe(false); - }); - - it("retains queued messages when settings synchronization fails before startTurn", () => { + it("retains queued messages when command delivery fails", () => { const deterministicFailure = new Error("Thread no longer exists"); expect( @@ -675,6 +713,6 @@ describe("thread outbox", () => { error: deterministicFailure, interrupted: false, }), - ).toBe("discard"); + ).toBe("retry"); }); }); diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index e83657fad730..cfe944871846 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -505,7 +505,7 @@ const program = Effect.gen(function* () { yield* agent.handlePrompt((request) => Effect.gen(function* () { const requestedSessionId = String(request.sessionId ?? sessionId); - promptCount += 1; + const currentPromptCount = ++promptCount; if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) { yield* Effect.sleep(`${promptDelayMs} millis`); @@ -515,7 +515,7 @@ const program = Effect.gen(function* () { return yield* AcpError.AcpRequestError.internalError("Mock prompt failure"); } - if (emitStaleXAiPromptCompleteBeforeSecondHang && promptCount === 1) { + if (emitStaleXAiPromptCompleteBeforeSecondHang && currentPromptCount === 1) { return { stopReason: "end_turn", _meta: { @@ -525,7 +525,7 @@ const program = Effect.gen(function* () { }; } - if (emitStaleXAiPromptCompleteBeforeSecondHang && promptCount === 2) { + if (emitStaleXAiPromptCompleteBeforeSecondHang && currentPromptCount === 2) { const currentPromptId = promptIdFromRequestMeta(request) ?? "mock-current-xai-prompt-2"; writeJsonRpcNotification("_x.ai/session/prompt_complete", { sessionId: requestedSessionId, @@ -544,12 +544,12 @@ const program = Effect.gen(function* () { return yield* Effect.never; } - if (emitOverlappingXAiPromptCompleteOutOfOrder && promptCount === 1) { + if (emitOverlappingXAiPromptCompleteOutOfOrder && currentPromptCount === 1) { overlappingFirstPromptId = promptIdFromRequestMeta(request); return yield* Effect.never; } - if (emitOverlappingXAiPromptCompleteOutOfOrder && promptCount === 2) { + if (emitOverlappingXAiPromptCompleteOutOfOrder && currentPromptCount === 2) { const secondPromptId = promptIdFromRequestMeta(request); if (overlappingFirstPromptId !== undefined && secondPromptId !== undefined) { writeJsonRpcNotification("_x.ai/session/prompt_complete", { @@ -568,7 +568,7 @@ const program = Effect.gen(function* () { return yield* Effect.never; } - if (hangFirstPromptForever && promptCount === 1) { + if (hangFirstPromptForever && currentPromptCount === 1) { yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, update: { @@ -692,19 +692,19 @@ const program = Effect.gen(function* () { } if (emitToolCalls) { - const toolCallId = `tool-call-${promptCount}`; + const toolCallId = `tool-call-${currentPromptCount}`; const cycledPermission = [ { kind: "edit" as const, toolName: "Write" }, { kind: "delete" as const, toolName: "Delete" }, { kind: "move" as const, toolName: "Move" }, { kind: "execute" as const, toolName: "Bash" }, - ][(promptCount - 1) % 4]; + ][(currentPromptCount - 1) % 4]; const permissionKind = cycleEditPermissionKinds ? (cycledPermission?.kind ?? "execute") : "execute"; const permissionToolName = cycleEditPermissionKinds ? (cycledPermission?.toolName ?? "Bash") - : alternatePermissionTool && promptCount >= 3 + : alternatePermissionTool && currentPromptCount >= 3 ? "run_commands" : "Bash"; @@ -776,8 +776,8 @@ const program = Effect.gen(function* () { update: { sessionUpdate: "tool_call_update", toolCallId, - title: "Terminal", - kind: "execute", + title: permissionToolName, + kind: permissionKind, status: "completed", rawOutput: { exitCode: 0, diff --git a/apps/server/src/provider/Drivers/ClineDriver.ts b/apps/server/src/provider/Drivers/ClineDriver.ts index 1cb39fb69206..c1a0a3a0bcf0 100644 --- a/apps/server/src/provider/Drivers/ClineDriver.ts +++ b/apps/server/src/provider/Drivers/ClineDriver.ts @@ -81,80 +81,86 @@ export const ClineDriver: ProviderDriver = { }, configSchema: ClineSettings, defaultConfig: (): ClineSettings => decodeClineSettings({}), - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => - Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const httpClient = yield* HttpClient.HttpClient; - const serverSettings = yield* ServerSettingsService; - const eventLoggers = yield* ProviderEventLoggers; - const processEnv = mergeProviderInstanceEnvironment(environment); - const continuationIdentity = defaultProviderContinuationIdentity({ - driverKind: DRIVER_KIND, - instanceId, - }); - const stampIdentity = withInstanceIdentity({ - instanceId, - displayName, - accentColor, - continuationGroupKey: continuationIdentity.continuationKey, - }); - const effectiveConfig = { ...config, enabled } satisfies ClineSettings; - const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { - binaryPath: effectiveConfig.binaryPath, - env: processEnv, - }); + create: Effect.fn("ClineDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }) { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies ClineSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); - const adapter = yield* makeClineAdapter(effectiveConfig, { - environment: processEnv, - ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), - instanceId, - }); + const adapter = yield* makeClineAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); - const checkProvider = checkClineProviderStatus(effectiveConfig, processEnv).pipe( - Effect.map(stampIdentity), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); + const checkProvider = checkClineProviderStatus(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>({ - maintenanceCapabilities, - getSettings: snapshotSettings.getSettings, - streamSettings: snapshotSettings.streamSettings, - haveSettingsChanged: haveProviderSnapshotSettingsChanged, - initialSnapshot: (settings) => - buildInitialClineProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), - checkProvider, - enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => - enrichClineSnapshot({ - snapshot: currentSnapshot, - maintenanceCapabilities, - enableProviderUpdateChecks: settings.enableProviderUpdateChecks, - publishSnapshot, - httpClient, + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialClineProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichClineSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Cline snapshot: ${cause.message ?? String(cause)}`, + cause, }), - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to build Cline snapshot: ${cause.message ?? String(cause)}`, - cause, - }), - ), - ); + ), + ); - return { - instanceId, - driverKind: DRIVER_KIND, - continuationIdentity, - displayName, - accentColor, - enabled, - snapshot, - adapter, - } satisfies ProviderInstance; - }), + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + } satisfies ProviderInstance; + }), }; diff --git a/apps/server/src/provider/Layers/ClineAdapter.test.ts b/apps/server/src/provider/Layers/ClineAdapter.test.ts index 74f3886599fe..a6c394f9991c 100644 --- a/apps/server/src/provider/Layers/ClineAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClineAdapter.test.ts @@ -66,7 +66,7 @@ const clineAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { const makeTestAdapter = ( binaryPath: string, - options?: Pick, + options?: Pick, ) => makeClineAdapter(decodeClineSettings({ binaryPath }), options).pipe(Effect.orDie); it.effect("releases Cline thread locks after serialized work and thread churn", () => @@ -181,6 +181,113 @@ it.layer(clineAdapterTestLayer)("ClineAdapterLive", (it) => { }), ); + it.effect("pairs each concurrent Cline model selection with its serialized prompt", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cline-concurrent-send-reservation"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cline-acp-concurrent-send-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockClineWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const firstAdmissionReached = yield* Deferred.make(); + const secondAdmissionReached = yield* Deferred.make(); + const admissionCount = yield* Ref.make(0); + const adapter = yield* makeTestAdapter(wrapperPath, { + beforePromptSerialization: Ref.updateAndGet(admissionCount, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Deferred.succeed(firstAdmissionReached, undefined).pipe( + Effect.andThen(Deferred.await(secondAdmissionReached)), + ) + : Deferred.succeed(secondAdmissionReached, undefined), + ), + ), + }); + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)).pipe( + Effect.andThen( + event.type === "turn.completed" + ? Deferred.succeed(turnCompleted, undefined) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const firstTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "first concurrent prompt", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("cline"), + model: "composer-2", + }, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(firstAdmissionReached); + const secondTurnFiber = yield* adapter + .sendTurn({ + threadId, + input: "second concurrent prompt", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("cline"), + model: "composer-2[fast=true]", + }, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + const results = yield* Effect.all([Fiber.join(firstTurnFiber), Fiber.join(secondTurnFiber)]); + yield* Deferred.await(turnCompleted); + + yield* adapter.stopSession(threadId); + yield* Fiber.interrupt(eventsFiber); + assert.equal(results[0].turnId, results[1].turnId); + assert.lengthOf( + runtimeEvents.filter((event) => event.type === "turn.started"), + 1, + ); + assert.lengthOf( + runtimeEvents.filter((event) => event.type === "turn.completed"), + 1, + ); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const modelAndPromptOrder = requests.flatMap((entry) => { + if (entry.method === "session/set_config_option") { + const params = entry.params as { + readonly configId?: unknown; + readonly value?: unknown; + }; + return params.configId === "model" ? [`model:${String(params.value)}`] : []; + } + if (entry.method === "session/prompt") { + const params = entry.params as { + readonly prompt?: ReadonlyArray<{ readonly text?: unknown }>; + }; + return [`prompt:${String(params.prompt?.[0]?.text)}`]; + } + return []; + }); + assert.deepStrictEqual(modelAndPromptOrder, [ + "model:composer-2", + "prompt:first concurrent prompt", + "model:composer-2[fast=true]", + "prompt:second concurrent prompt", + ]); + }).pipe(TestClock.withLive), + ); + it.effect("resumes the exact Cline ACP session through session/load", () => Effect.gen(function* () { const threadId = ThreadId.make("cline-resume-session"); @@ -215,6 +322,54 @@ it.layer(clineAdapterTestLayer)("ClineAdapterLive", (it) => { }), ); + it.effect("waits for Cline's authoritative model config after session/load replay", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cline-resume-replay-idle"); + const sessionId = "cline-replay-idle-session"; + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cline-acp-resume-replay-idle-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockClineWrapper({ + T3_ACP_DELAY_LOAD_SESSION_AFTER_REPLAY: "1", + T3_ACP_LOAD_SESSION_DELAY_MS: "2500", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath, { + sessionStartTimeout: "5 seconds", + }); + + const session = yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId }, + modelSelection: { + instanceId: ProviderInstanceId.make("cline"), + model: "composer-2", + }, + }); + yield* adapter.stopSession(threadId); + + assert.deepStrictEqual(session.resumeCursor, { schemaVersion: 1, sessionId }); + assert.equal(session.model, "composer-2"); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.isTrue( + requests.some( + (entry) => + entry.method === "session/set_config_option" && + (entry.params as { readonly configId?: unknown; readonly value?: unknown }).configId === + "model" && + (entry.params as { readonly value?: unknown }).value === "composer-2", + ), + ); + assert.isFalse(requests.some((entry) => entry.method === "session/new")); + }).pipe(TestClock.withLive), + ); + it.effect("rejects mixed and image-only turns before prompting Cline ACP", () => Effect.forEach( [ @@ -590,7 +745,10 @@ it.layer(clineAdapterTestLayer)("ClineAdapterLive", (it) => { Effect.gen(function* () { const threadId = ThreadId.make("cline-full-access-thread"); const wrapperPath = yield* Effect.promise(() => - makeMockClineWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), + makeMockClineWrapper({ + T3_ACP_EMIT_TOOL_CALLS: "1", + T3_ACP_CYCLE_EDIT_PERMISSION_KINDS: "1", + }), ); const adapter = yield* makeTestAdapter(wrapperPath); @@ -625,6 +783,20 @@ it.layer(clineAdapterTestLayer)("ClineAdapterLive", (it) => { return assert.fail("expected a turn.completed runtime event"); } assert.equal(completed.payload.state, "completed"); + const toolCompleted = runtimeEvents.find( + (event) => event.type === "item.completed" && event.itemId === "tool-call-1", + ); + assert.isDefined(toolCompleted); + if (toolCompleted?.type === "item.completed") { + assert.equal(toolCompleted.payload.itemType, "file_change"); + const rawUpdate = ( + toolCompleted.raw?.payload as { + readonly update?: { readonly title?: string; readonly kind?: string }; + } + )?.update; + assert.equal(rawUpdate?.title, "Write"); + assert.equal(rawUpdate?.kind, "edit"); + } }), ); @@ -741,7 +913,18 @@ it.layer(clineAdapterTestLayer)("ClineAdapterLive", (it) => { T3_ACP_REQUEST_LOG_PATH: requestLogPath, }), ); - const adapter = yield* makeTestAdapter(wrapperPath); + const promptAdmissionCount = yield* Ref.make(0); + const queuedPromptReached = yield* Deferred.make(); + const adapter = yield* makeTestAdapter(wrapperPath, { + beforePromptSerialization: Ref.updateAndGet( + promptAdmissionCount, + (count) => count + 1, + ).pipe( + Effect.flatMap((count) => + count === 2 ? Deferred.succeed(queuedPromptReached, undefined) : Effect.void, + ), + ), + }); const runtimeEvents: ProviderRuntimeEvent[] = []; const firstPromptStarted = yield* Deferred.make(); const turnCompleted = yield* Deferred.make(); @@ -771,9 +954,7 @@ it.layer(clineAdapterTestLayer)("ClineAdapterLive", (it) => { const queuedTurnFiber = yield* adapter .sendTurn({ threadId, input: "queued steer", attachments: [] }) .pipe(Effect.forkChild); - // Give the queued turn a scheduler turn so it reaches the runtime's - // prompt semaphore before cancellation advances the generation. - yield* Effect.yieldNow; + yield* Deferred.await(queuedPromptReached); yield* adapter.interruptTurn(threadId); yield* Fiber.join(firstTurnFiber); yield* Fiber.join(queuedTurnFiber); diff --git a/apps/server/src/provider/Layers/ClineAdapter.ts b/apps/server/src/provider/Layers/ClineAdapter.ts index 00206913700b..aa5723f71cfc 100644 --- a/apps/server/src/provider/Layers/ClineAdapter.ts +++ b/apps/server/src/provider/Layers/ClineAdapter.ts @@ -71,6 +71,7 @@ import { import { type ClineAdapterShape } from "../Services/ClineAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); +const isAcpError = Schema.is(EffectAcpErrors.AcpError); const PROVIDER = ProviderDriverKind.make("cline"); const CLINE_RESUME_VERSION = 1 as const; @@ -135,6 +136,8 @@ export interface ClineAdapterLiveOptions { readonly resolveSettings?: Effect.Effect; /** Override only for deterministic startup timeout tests. */ readonly sessionStartTimeout?: Duration.Input; + /** Test-only scheduling hook at the shared ACP prompt serialization boundary. */ + readonly beforePromptSerialization?: Effect.Effect; } interface PendingApproval { @@ -569,6 +572,9 @@ export const makeClineAdapter = Effect.fn("makeClineAdapter")(function* ( cwd, forceKillAfter: CLINE_PROCESS_FORCE_KILL_AFTER, ...(resumeSessionId ? { resumeSessionId } : {}), + ...(options?.beforePromptSerialization + ? { beforePromptSerialization: options.beforePromptSerialization } + : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, ...acpNativeLoggers, }).pipe( @@ -673,11 +679,14 @@ export const makeClineAdapter = Effect.fn("makeClineAdapter")(function* ( }); if (clineModelsFromSessionConfigOptions(started.sessionSetupResult).length === 0) { + const replayIdleWithoutConfig = + started.sessionSetupResult._meta?.t3SessionLoadReady === "replay_idle"; return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "startSession", - issue: - "Cline ACP did not advertise any usable models. Configure a provider and model in Cline, then start a new session.", + issue: replayIdleWithoutConfig + ? "Cline ACP session/load replay became idle without returning model configuration. Resume cannot safely bind a model; retry after Cline finishes loading the session." + : "Cline ACP did not advertise any usable models. Configure a provider and model in Cline, then start a new session.", }); } @@ -833,7 +842,7 @@ export const makeClineAdapter = Effect.fn("makeClineAdapter")(function* ( const sendTurn: ClineAdapterShape["sendTurn"] = Effect.fn("ClineAdapter.sendTurn")( function* (input) { - const ctx = yield* requireSession(input.threadId); + yield* requireSession(input.threadId); if (input.interactionMode === "plan") { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, @@ -857,60 +866,72 @@ export const makeClineAdapter = Effect.fn("makeClineAdapter")(function* ( issue: "Turn requires non-empty text.", }); } - // A sendTurn while a prompt is in flight is a steer: the agent folds - // the new prompt into the ongoing work, so the active turn id is - // reused instead of opening a new turn. - const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; - const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); - // Count this prompt immediately so a superseded in-flight prompt - // resolving from here on does not settle the turn; the matching - // decrement is the `ensuring` below. - ctx.promptsInFlight += 1; + const prepared = yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + // Reserve the active turn before any provider/configuration yield. + // Concurrent sends are steers and must observe the first send's id. + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + ctx.promptsInFlight += 1; + ctx.activeTurnId = turnId; + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + return { ctx, steeringTurnId, turnId }; + }), + ); + const { ctx, steeringTurnId, turnId } = prepared; return yield* Effect.gen(function* () { const turnModelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const requestedTurnModel = turnModelSelection?.model.trim(); const model = requestedTurnModel || ctx.session.model; - yield* applyRequestedSessionConfiguration({ - runtime: ctx.acp, - threadId: input.threadId, - runtimeMode: ctx.session.runtimeMode, - interactionMode: input.interactionMode, - requestedModelId: model, - }); - ctx.activeTurnId = turnId; - if (steeringTurnId === undefined) { - ctx.lastPlanFingerprint = undefined; - } - ctx.session = { - ...ctx.session, - activeTurnId: turnId, - updatedAt: yield* nowIso, - }; - - if (steeringTurnId === undefined) { - yield* offerRuntimeEvent({ - type: "turn.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId, - payload: { model }, - }); - } - const promptParts: Array = [ { type: "text", text: promptText }, ]; const result = yield* ctx.acp - .prompt({ - prompt: promptParts, - }) + .prompt( + { prompt: promptParts }, + Effect.gen(function* () { + yield* applyRequestedSessionConfiguration({ + runtime: ctx.acp, + threadId: input.threadId, + runtimeMode: ctx.session.runtimeMode, + interactionMode: input.interactionMode, + requestedModelId: model, + }); + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model }, + }); + } + }), + ) .pipe( Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + isAcpError(error) + ? mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error) + : error, ), ); @@ -951,9 +972,12 @@ export const makeClineAdapter = Effect.fn("makeClineAdapter")(function* ( }; }).pipe( Effect.ensuring( - Effect.sync(() => { - ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); - }), + withThreadLock( + input.threadId, + Effect.sync(() => { + ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); + }), + ), ), ); }, diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index f1b43fdf3679..bd43ef7e8d11 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -6,6 +6,7 @@ import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; @@ -302,6 +303,45 @@ describe("AcpSessionRuntime", () => { ), ); + it.effect("cancels a prompt that is waiting to register without starting its RPC", () => + Effect.gen(function* () { + const registrationReached = yield* Deferred.make(); + const releaseRegistration = yield* Deferred.make(); + const requestEvents: Array = []; + const runtime = yield* AcpSessionRuntime.make({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + requestLogger: (event) => + Effect.sync(() => { + requestEvents.push(event); + }), + beforePromptRegistration: Deferred.succeed(registrationReached, undefined).pipe( + Effect.andThen(Deferred.await(releaseRegistration)), + ), + }); + yield* runtime.start(); + + const promptFiber = yield* runtime + .prompt({ prompt: [{ type: "text", text: "must not start" }] }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(registrationReached); + yield* runtime.cancel; + yield* Deferred.succeed(releaseRegistration, undefined); + + expect(yield* Fiber.join(promptFiber)).toMatchObject({ stopReason: "cancelled" }); + expect( + requestEvents.some( + (event) => event.method === "session/prompt" && event.status === "started", + ), + ).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("segments assistant text around ACP tool calls", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index c26c15aa74c1..472fabec3bc3 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -64,6 +64,8 @@ export interface AcpSessionRuntimeOptions { readonly resumeSessionId?: string; readonly sessionLoadTimeout?: Duration.Input; readonly sessionLoadReplayIdleGap?: Duration.Input; + /** Disable only for agents whose replay never exposes authoritative setup state. */ + readonly sessionLoadReplayIdleFallback?: boolean; readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; readonly clientInfo: { readonly name: string; @@ -77,6 +79,10 @@ export interface AcpSessionRuntimeOptions { readonly authMethodId?: string; readonly mcpServers?: ReadonlyArray; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; + /** Test-only scheduling hook for the prompt registration boundary. */ + readonly beforePromptRegistration?: Effect.Effect; + /** Test-only scheduling hook after cancellation capture and before prompt serialization. */ + readonly beforePromptSerialization?: Effect.Effect; readonly protocolLogging?: { readonly logIncoming?: boolean; readonly logOutgoing?: boolean; @@ -207,9 +213,10 @@ export class AcpSessionRuntime extends Context.Service< * Sends a prompt turn to the active session. * @see https://agentclientprotocol.com/protocol/schema#session/prompt */ - readonly prompt: ( + readonly prompt: ( payload: Omit, - ) => Effect.Effect; + beforePrompt?: Effect.Effect, + ) => Effect.Effect; /** * Sends a real ACP `session/cancel` notification for the active session. * @see https://agentclientprotocol.com/protocol/schema#session/cancel @@ -309,6 +316,7 @@ export const make = ( const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); const promptSerializationSemaphore = yield* Semaphore.make(1); + const promptCancellationSemaphore = yield* Semaphore.make(1); const promptCancellationGenerationRef = yield* Ref.make(0); const activePromptFiberRef = yield* Ref.make< Option.Option> @@ -607,14 +615,19 @@ export const make = ( status: "started", }); - const idleFiber = yield* waitForSessionLoadReplayIdle({ - gateRef: sessionLoadGateRef, - }).pipe(Effect.forkIn(runtimeScope)); - const loaded = yield* Effect.raceFirst( - acp.agent.loadSession(loadPayload), - Fiber.join(idleFiber), + const loaded = yield* ( + options.sessionLoadReplayIdleFallback === false + ? acp.agent.loadSession(loadPayload) + : Effect.gen(function* () { + const idleFiber = yield* waitForSessionLoadReplayIdle({ + gateRef: sessionLoadGateRef, + }).pipe(Effect.forkIn(runtimeScope)); + return yield* Effect.raceFirst( + acp.agent.loadSession(loadPayload), + Fiber.join(idleFiber), + ).pipe(Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore))); + }) ).pipe( - Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore)), Effect.timeoutOption(sessionLoadTimeout), Effect.flatMap((result) => Option.match(result, { @@ -746,9 +759,13 @@ export const make = ( }), getModeState: Ref.get(modeStateRef), getConfigOptions: Ref.get(configOptionsRef), - prompt: (payload) => + prompt: ( + payload: Omit, + beforePrompt?: Effect.Effect, + ) => Effect.gen(function* () { const cancellationGeneration = yield* Ref.get(promptCancellationGenerationRef); + yield* options.beforePromptSerialization ?? Effect.void; return yield* promptSerializationSemaphore.withPermit( Effect.gen(function* () { const cancelledResponse = { @@ -757,6 +774,7 @@ export const make = ( if ((yield* Ref.get(promptCancellationGenerationRef)) !== cancellationGeneration) { return cancelledResponse; } + yield* beforePrompt ?? Effect.void; const started = yield* getStartedState; yield* closeActiveAssistantSegment({ queue: eventQueue, @@ -766,12 +784,34 @@ export const make = ( sessionId: started.sessionId, ...payload, } satisfies EffectAcpSchema.PromptRequest; - const promptRpcFiber = yield* runLoggedRequest( - "session/prompt", - requestPayload, - acp.agent.prompt(requestPayload), - ).pipe(Effect.forkIn(runtimeScope)); - yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber)); + const promptStartGate = yield* Deferred.make(); + const promptRpcFiber = yield* Deferred.await(promptStartGate).pipe( + Effect.andThen( + runLoggedRequest( + "session/prompt", + requestPayload, + acp.agent.prompt(requestPayload), + ), + ), + Effect.forkIn(runtimeScope), + ); + yield* options.beforePromptRegistration ?? Effect.void; + const cancelledBeforeRegistration = yield* promptCancellationSemaphore.withPermit( + Effect.gen(function* () { + if ( + (yield* Ref.get(promptCancellationGenerationRef)) !== cancellationGeneration + ) { + return true; + } + yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber)); + return false; + }), + ); + if (cancelledBeforeRegistration) { + yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore); + return cancelledResponse; + } + yield* Deferred.succeed(promptStartGate, undefined); return yield* Fiber.join(promptRpcFiber).pipe( Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) @@ -797,8 +837,12 @@ export const make = ( cancel: getStartedState.pipe( Effect.flatMap((started) => Effect.gen(function* () { - yield* Ref.update(promptCancellationGenerationRef, (generation) => generation + 1); - const activePromptFiber = yield* Ref.get(activePromptFiberRef); + const activePromptFiber = yield* promptCancellationSemaphore.withPermit( + Effect.gen(function* () { + yield* Ref.update(promptCancellationGenerationRef, (generation) => generation + 1); + return yield* Ref.get(activePromptFiberRef); + }), + ); if (Option.isSome(activePromptFiber)) { yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore); } diff --git a/apps/server/src/provider/acp/ClineAcpCliProbe.test.ts b/apps/server/src/provider/acp/ClineAcpCliProbe.test.ts index 39870add4c1b..ddeaaaef21e5 100644 --- a/apps/server/src/provider/acp/ClineAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/ClineAcpCliProbe.test.ts @@ -25,7 +25,7 @@ const makeProbeRuntime = Effect.gen(function* () { clineSettings: { binaryPath: "cline" }, environment: process.env, childProcessSpawner, - cwd: process.cwd(), + cwd: process.env.T3_CLINE_ACP_PROBE_CWD ?? process.cwd(), clientInfo: { name: "t3-cline-probe", version: "0.0.0" }, }); }); diff --git a/apps/server/src/provider/acp/ClineAcpSupport.test.ts b/apps/server/src/provider/acp/ClineAcpSupport.test.ts index d4e0aef00760..726a5cce69a5 100644 --- a/apps/server/src/provider/acp/ClineAcpSupport.test.ts +++ b/apps/server/src/provider/acp/ClineAcpSupport.test.ts @@ -167,6 +167,41 @@ describe("cline ACP support", () => { ]); }); + it("normalizes model values and names before filtering and deduplicating the catalog", () => { + const models = clineModelsFromSessionConfigOptions({ + sessionId: "ses_1", + configOptions: [ + modelSelectOption({ + currentValue: " model-a ", + options: [ + { + group: "primary", + name: "Primary", + options: [ + { value: " ", name: "Ignored" }, + { value: " model-a ", name: " Model A " }, + { value: "model-a", name: "Duplicate" }, + ], + }, + { + group: "other", + name: "Other", + options: [ + { value: " model-b ", name: " " }, + { value: "model-b", name: "Duplicate B" }, + ], + }, + ], + }), + ], + }); + + expect(models).toEqual([ + { slug: "model-a", name: "Model A", isDefault: true }, + { slug: "model-b", name: "model-b" }, + ]); + }); + it("returns no models without a select config option for models", () => { const models = clineModelsFromSessionConfigOptions({ sessionId: "ses_1", diff --git a/apps/server/src/provider/acp/ClineAcpSupport.ts b/apps/server/src/provider/acp/ClineAcpSupport.ts index 6fe24707fa7c..8537e3dd179c 100644 --- a/apps/server/src/provider/acp/ClineAcpSupport.ts +++ b/apps/server/src/provider/acp/ClineAcpSupport.ts @@ -58,6 +58,9 @@ export const makeClineAcpRuntime = Effect.fn("makeClineAcpRuntime")(function* ( const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ ...input, + // Cline returns its model config only in the authoritative load response; + // replay notifications cannot safely populate the synthetic idle fallback. + sessionLoadReplayIdleFallback: false, spawn: buildClineAcpSpawnInput( input.clineSettings, input.cwd, @@ -138,14 +141,21 @@ export function clineModelsFromSessionConfigOptions( const option = findClineModelConfigOption(sessionSetupResult); if (!option) return []; const current = currentClineModelIdFromSessionSetup(sessionSetupResult); - return option.options.flatMap((entry) => { + const models = new Map(); + for (const entry of option.options) { const values = "value" in entry ? [entry] : entry.options; - return values.map(({ value, name }) => ({ - slug: value, - name, - ...(value === current ? { isDefault: true } : {}), - })); - }); + for (const { value, name } of values) { + const slug = value.trim(); + if (slug.length === 0 || models.has(slug)) continue; + const normalizedName = name.trim(); + models.set(slug, { + slug, + name: normalizedName.length > 0 ? normalizedName : slug, + ...(slug === current ? { isDefault: true } : {}), + }); + } + } + return [...models.values()]; } export const applyClineAcpModelSelection = Effect.fn("applyClineAcpModelSelection")(function* < diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index d36a5fcfc895..35b4a25584a0 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -228,11 +228,14 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion runtime .start() .pipe(Effect.tap((started) => Ref.set(activeSessionIdRef, started.sessionId))), - prompt: (payload) => + prompt: ( + payload: Omit, + beforePrompt?: Effect.Effect, + ) => Effect.gen(function* () { const sessionId = yield* Ref.get(activeSessionIdRef); if (sessionId === undefined) { - return yield* runtime.prompt(payload); + return yield* runtime.prompt(payload, beforePrompt); } const promptId = yield* allocatePromptFallbackId; @@ -251,7 +254,7 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion } satisfies Omit; return yield* Effect.raceFirst( - runtime.prompt(requestPayload), + runtime.prompt(requestPayload, beforePrompt), Deferred.await(fallback.deferred), ).pipe( Effect.tap((response) => diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index ee0ad9dfaeb6..fe749058297e 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -380,6 +380,39 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("does not treat a built-in id overridden by Cline as a text-generation fallback", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + + const next = yield* serverSettings.updateSettings({ + providers: { + codex: { enabled: true }, + claudeAgent: { enabled: true }, + cursor: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + cline: { enabled: true }, + }, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("cline"), + enabled: true, + config: {}, + }, + }, + textGenerationModelSelection: { + instanceId: ProviderInstanceId.make("cline"), + model: "advertised-interactive-model", + }, + }); + + assert.deepEqual(next.textGenerationModelSelection, { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-haiku-4-5", + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("preserves custom provider instance text generation selections", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 8831388fd9bd..6c618ea82749 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -249,14 +249,17 @@ function textGenerationSelectionIsSupported( } function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { - const fallbackEntry = Object.entries(settings.providers).find( - ([driver]) => + const fallbackEntry = Object.entries(settings.providers).find(([driver]) => { + const candidate = { + instanceId: ProviderInstanceId.make(driver), + model: DEFAULT_TEXT_GENERATION_MODEL, + } satisfies ModelSelection; + return ( driver !== "cline" && - isModelSelectionProviderEnabled(settings, { - instanceId: ProviderInstanceId.make(driver), - model: DEFAULT_TEXT_GENERATION_MODEL, - }), - ); + isModelSelectionProviderEnabled(settings, candidate) && + textGenerationSelectionIsSupported(settings, candidate) + ); + }); const legacyDriver = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; const instanceFallback = legacyDriver ? undefined diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index cb814dace2e5..3a5220a365ee 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -2,7 +2,9 @@ import { EnvironmentId, MessageId, ProjectId, + ProviderDriverKind, ProviderInstanceId, + type ServerProvider, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -21,6 +23,7 @@ import { dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, + getDirectAnnotationAttachmentBlockReason, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, @@ -44,6 +47,44 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +const providerWithoutImageAttachments: ServerProvider = { + instanceId: ProviderInstanceId.make("cline"), + driver: ProviderDriverKind.make("cline"), + displayName: "Cline", + enabled: true, + installed: true, + version: "3.0.57", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: now, + models: [], + slashCommands: [], + skills: [], + supportsImageAttachments: false, +}; + +describe("direct preview annotation attachments", () => { + it("blocks a direct screenshot before dispatch when the selected instance rejects images", () => { + expect( + getDirectAnnotationAttachmentBlockReason({ + provider: providerWithoutImageAttachments, + existingImages: [], + image: { id: "annotation-image" }, + }), + ).toBe("Cline does not support image attachments. Remove the images to continue."); + }); + + it("allows a structured annotation when it has no screenshot attachment", () => { + expect( + getDirectAnnotationAttachmentBlockReason({ + provider: providerWithoutImageAttachments, + existingImages: [], + image: null, + }), + ).toBeNull(); + }); +}); + describe("draft hero submission transition", () => { it("does not dock the composer before a background submission", () => { expect( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 83bea23b65e2..ffcf72593fae 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -24,12 +24,25 @@ import { import type { DraftThreadEnvMode } from "../composerDraftStore"; import type { ComposerSubmissionIntent } from "../composer-logic"; import type { TimelineEntry } from "../session-logic"; +import { getUnsupportedProviderAttachmentReason } from "../providerModels"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; +export function getDirectAnnotationAttachmentBlockReason(input: { + readonly provider: ServerProvider | null | undefined; + readonly existingImages: ReadonlyArray>; + readonly image: Pick | null; +}): string | null { + const includesDirectImage = + input.image !== null && input.existingImages.some((image) => image.id === input.image?.id); + const attachmentCount = + input.existingImages.length + (input.image !== null && !includesDirectImage ? 1 : 0); + return getUnsupportedProviderAttachmentReason(input.provider, attachmentCount); +} + export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); export function shouldDockDraftHeroForSubmission(input: { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7dd9f91505ac..166d061c6bdb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,7 +1,6 @@ import { type ApprovalRequestId, DEFAULT_MODEL, - defaultInstanceIdForDriver, type EnvironmentId, type MessageId, type ModelSelection, @@ -195,12 +194,15 @@ import { import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { registerFaviconProjectForThread } from "~/browserFaviconStore"; +import { getProviderModelCapabilities, getUnsupportedProviderModeReason } from "../providerModels"; import { - getProviderModelCapabilities, - getUnsupportedProviderModeReason, - resolveSelectableProvider, -} from "../providerModels"; -import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + NO_PROVIDER_MODEL_SELECTION, + resolveComposerProviderInstanceEntry, + resolveProviderDriverKindForInstanceSelection, + sortProviderInstanceEntries, +} from "../providerInstances"; import { useClientSettings, useClientSettingsHydrated, @@ -335,6 +337,7 @@ import { shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, getStartedThreadModelChangeBlockReason, + getDirectAnnotationAttachmentBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, type LocalDispatchSnapshot, @@ -2083,6 +2086,7 @@ function ChatViewContent(props: ChatViewProps) { const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = + activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection.instanceId ?? activeProject?.defaultModelSelection?.instanceId ?? null; @@ -2283,11 +2287,57 @@ function ChatViewContent(props: ChatViewProps) { versionMismatchServerLabel, ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; - const unlockedSelectedProvider = resolveSelectableProvider( - providerStatuses, - selectedProviderByThreadId ?? threadProvider, + const activeProviderInstanceEntries = useMemo( + () => + sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), + ), + [providerStatuses, settings], + ); + const explicitSelectedProviderInstanceId = selectedProviderByThreadId ?? threadProvider; + const unlockedSelectedProvider = + resolveProviderDriverKindForInstanceSelection( + activeProviderInstanceEntries, + providerStatuses, + explicitSelectedProviderInstanceId, + ) ?? + activeProviderInstanceEntries[0]?.driverKind ?? + ProviderDriverKind.make("unconfigured"); + const requestedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; + const lockedProviderContinuationGroupKey = useMemo(() => { + if (!lockedProvider || !activeThread) return null; + const lockedInstanceId = + activeThread.session?.providerInstanceId ?? activeThread.modelSelection.instanceId; + return ( + activeProviderInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId) + ?.continuationGroupKey ?? null + ); + }, [activeProviderInstanceEntries, activeThread, lockedProvider]); + const activeProviderEntry = useMemo( + () => + resolveComposerProviderInstanceEntry({ + entries: activeProviderInstanceEntries, + candidateInstanceIds: [ + composerActiveProvider, + activeThread?.session?.providerInstanceId, + activeThread?.modelSelection.instanceId, + activeProject?.defaultModelSelection?.instanceId, + ], + requestedDriverKind: requestedProvider, + lockedProvider, + lockedContinuationGroupKey: lockedProviderContinuationGroupKey, + }), + [ + activeProject?.defaultModelSelection?.instanceId, + activeProviderInstanceEntries, + activeThread?.modelSelection.instanceId, + activeThread?.session?.providerInstanceId, + composerActiveProvider, + lockedProvider, + lockedProviderContinuationGroupKey, + requestedProvider, + ], ); - const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); @@ -2740,28 +2790,10 @@ function ChatViewContent(props: ChatViewProps) { ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); - // Prefer an instance-id match so a custom Codex instance (e.g. - // `codex_personal`) surfaces its own status/message in the banner rather - // than the default Codex's. Falls back to first-match-by-kind when no - // saved instance id is available or the instance no longer exists. - const selectedProviderInstanceId = - providerStatuses.find((status) => status.instanceId === selectedProviderByThreadId) - ?.instanceId ?? null; - const activeProviderInstanceId = - selectedProviderInstanceId ?? - activeThread?.session?.providerInstanceId ?? - activeThread?.modelSelection.instanceId ?? - activeProject?.defaultModelSelection?.instanceId ?? - null; - const activeProviderStatus = useMemo(() => { - if (activeProviderInstanceId) { - return ( - providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null - ); - } - const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); - return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; - }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + // The status and capabilities must come from the exact entry that the + // composer will dispatch. A stale disabled draft selection must not impose + // its restrictions while the composer has already fallen back elsewhere. + const activeProviderStatus = activeProviderEntry?.snapshot ?? null; const unsupportedProviderModeReason = getUnsupportedProviderModeReason({ provider: activeProviderStatus, runtimeMode, @@ -5168,11 +5200,27 @@ function ChatViewContent(props: ChatViewProps) { previewAnnotations: sendContextPreviewAnnotations, reviewComments: composerReviewComments, selectedProvider: ctxSelectedProvider, + selectedProviderStatus: ctxSelectedProviderStatus, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, selectedPromptEffort: ctxSelectedPromptEffort, selectedModelSelection: ctxSelectedModelSelection, } = sendCtx; + const directAnnotationAttachmentBlockReason = getDirectAnnotationAttachmentBlockReason({ + provider: ctxSelectedProviderStatus, + existingImages: sendContextImages, + image: directAnnotation?.image ?? null, + }); + if (directAnnotation && directAnnotationAttachmentBlockReason) { + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Annotation attached to draft", + description: directAnnotationAttachmentBlockReason, + }), + ); + return; + } const composerImages = directAnnotation?.image && !sendContextImages.some((image) => image.id === directAnnotation.image?.id) diff --git a/apps/web/src/components/Icons.test.tsx b/apps/web/src/components/Icons.test.tsx new file mode 100644 index 000000000000..dc68f74e7926 --- /dev/null +++ b/apps/web/src/components/Icons.test.tsx @@ -0,0 +1,17 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { ClineIcon } from "./Icons"; + +describe("ClineIcon", () => { + it("keeps its brand paint when a caller supplies a text color", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("fill-[#0F0F0F]"); + expect(markup).toContain("stroke-[#0F0F0F]"); + expect(markup).toContain("text-foreground/80"); + expect(markup).not.toContain("currentColor"); + }); +}); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 983444c964fd..fa239401b9d9 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -218,16 +218,20 @@ export const ClineIcon: Icon = ({ className, ...props }) => ( - + - - - + + + ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c3f9b8744e8d..ec15fafe4254 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -95,7 +95,7 @@ import { } from "../composerFooterLayout"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; import { ProviderModelPicker } from "./ProviderModelPicker"; -import { hasSelectableProviderModel } from "./ProviderModelPicker.logic"; +import { getComposerProviderAvailability } from "./ProviderModelPicker.logic"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; @@ -240,15 +240,14 @@ import { proposedPlanTitle } from "../../proposedPlan"; import { canUseProviderInteractionModeShortcut, getUnsupportedProviderAttachmentReason, - getProviderInteractionModeToggle, getProviderSupportedRuntimeModes, } from "../../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, + resolveComposerProviderInstanceEntry, resolveProviderDriverKindForInstanceSelection, - resolveSelectableProviderInstanceEntry, sortProviderInstanceEntries, type ProviderInstanceEntry, } from "../../providerInstances"; @@ -525,6 +524,7 @@ export interface ChatComposerHandle { selectedModelSelection: ModelSelection; providerAvailable: boolean; selectedProvider: ProviderDriverKind; + selectedProviderStatus: ServerProvider | null; selectedModel: string; selectedProviderModels: ReadonlyArray; }; @@ -818,44 +818,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // 4. First enabled entry matching the current driver kind. // 5. First enabled entry overall / default instance for the kind. // - const selectedInstanceId = useMemo(() => { - const candidates: Array = [ - composerDraft.activeProvider, - activeThread?.session?.providerInstanceId, - activeThreadModelSelection?.instanceId, - activeProjectDefaultModelSelection?.instanceId, - ]; - for (const candidate of candidates) { - if (!candidate) continue; - const match = providerInstanceEntries.find( - (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable, - ); - if (match) { - // When locked to a specific driver kind, ignore persisted instance - // ids from a different kind or continuation group. - if (lockedProvider && match.driverKind !== lockedProvider) continue; - if ( - lockedContinuationGroupKey && - match.continuationGroupKey !== lockedContinuationGroupKey - ) { - continue; - } - return match.instanceId; - } - } - const compatibleEntries = providerInstanceEntries.filter( - (entry) => - (!lockedProvider || entry.driverKind === lockedProvider) && - (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), - ); - const requestedDriverEntries = compatibleEntries.filter( - (entry) => entry.driverKind === requestedDriverKind, - ); - return ( - resolveSelectableProviderInstanceEntry(requestedDriverEntries, undefined)?.instanceId ?? - resolveSelectableProviderInstanceEntry(compatibleEntries, undefined)?.instanceId ?? - NO_PROVIDER_MODEL_SELECTION.instanceId - ); + const selectedProviderEntry = useMemo(() => { + return resolveComposerProviderInstanceEntry({ + entries: providerInstanceEntries, + candidateInstanceIds: [ + composerDraft.activeProvider, + activeThread?.session?.providerInstanceId, + activeThreadModelSelection?.instanceId, + activeProjectDefaultModelSelection?.instanceId, + ], + requestedDriverKind, + lockedProvider, + lockedContinuationGroupKey, + }); }, [ activeProjectDefaultModelSelection?.instanceId, activeThread?.session?.providerInstanceId, @@ -866,15 +841,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) providerInstanceEntries, requestedDriverKind, ]); + const selectedInstanceId = + selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId; // Resolve the active instance's snapshot by `instanceId` so a custom // instance gets its own slash commands, skills, and model list — not // the first snapshot for the same driver kind. - const selectedProviderEntry = useMemo( - () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId), - [providerInstanceEntries, selectedInstanceId], - ); - const noProviderEntryAvailable = selectedProviderEntry === undefined; // The driver kind follows the instance that will actually run the turn, // which can differ from the persisted selection when that selection is // disabled. @@ -890,8 +862,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) projectModelSelection: activeProjectDefaultModelSelection, settings, }); - const noProviderAvailable = - noProviderEntryAvailable || !hasSelectableProviderModel(selectedModel); + const { + noProviderEntryAvailable, + noSelectableModelAvailable, + noProviderAvailable, + showProviderModelPicker, + } = getComposerProviderAvailability({ + hasProviderEntry: selectedProviderEntry !== undefined, + selectedModel, + }); const selectedProviderStatus = useMemo( () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], @@ -940,17 +919,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => ({ showInteractionModeToggle: planModeUiEnabled && - (interactionMode === "plan" || - getProviderInteractionModeToggle(providerStatuses, selectedProvider)), + (interactionMode === "plan" || selectedProviderStatus?.showInteractionModeToggle !== false), supportedRuntimeModes: getProviderSupportedRuntimeModes(selectedProviderStatus), }), - [ - interactionMode, - planModeUiEnabled, - providerStatuses, - selectedProvider, - selectedProviderStatus, - ], + [interactionMode, planModeUiEnabled, selectedProviderStatus], ); const selectedModelSelection = useMemo( () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch), @@ -2824,6 +2796,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModelSelection, providerAvailable: !noProviderAvailable, selectedProvider, + selectedProviderStatus, selectedModel, selectedProviderModels, }), @@ -2865,6 +2838,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) noProviderAvailable, selectedPromptEffort, selectedProvider, + selectedProviderStatus, selectedProviderModels, ], ); @@ -3295,11 +3269,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? "Add feedback to refine the plan, or leave this blank to implement it" : projectSelectionRequired ? "Choose a project above to start a thread" - : noProviderAvailable + : noProviderEntryAvailable ? "Enable a provider in Settings to send a message" - : phase === "disconnected" - ? DISCONNECTED_COMPOSER_PLACEHOLDER - : "Ask anything, @tag files/folders, $use skills, or / for commands" + : noSelectableModelAvailable + ? "No models are available for the selected provider" + : phase === "disconnected" + ? DISCONNECTED_COMPOSER_PLACEHOLDER + : "Ask anything, @tag files/folders, $use skills, or / for commands" } disabled={isConnecting || isComposerApprovalState || projectSelectionRequired} /> @@ -3353,7 +3329,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )} >
- {noProviderAvailable ? ( + {!showProviderModelPicker ? (