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..41be9d86c88c 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,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-child(5):last-child { - grid-column: 1 / -1; - border-right: 0; - } + .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/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..4f3c1c4976a5 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -52,7 +52,14 @@ 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, + shouldShowProviderInteractionModeToggle, +} from "../../lib/modelOptions"; import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; @@ -613,6 +620,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 +677,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 +848,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 && + shouldShowProviderInteractionModeToggle({ + config: selectedEnvironmentServerConfig, + selection: flow.selectedModel, + interactionMode: flow.interactionMode, + }) ? ( 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 +568,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; @@ -552,7 +583,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); try { - const messageId = await onSendMessage(); + const messageId = await submitThreadComposerIfAllowed({ + canSend, + submit: onSendMessage, + }); if (messageId === null) { return; } @@ -569,6 +603,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer inFlightThreadIdsRef.current.delete(threadKey); } }, [ + canSend, onSendMessage, props.environmentId, props.environmentLabel, @@ -870,12 +905,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/features/threads/threadComposerSubmission.test.ts b/apps/mobile/src/features/threads/threadComposerSubmission.test.ts new file mode 100644 index 000000000000..bf01fafc6e47 --- /dev/null +++ b/apps/mobile/src/features/threads/threadComposerSubmission.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { MessageId } from "@t3tools/contracts"; + +import { submitThreadComposerIfAllowed } from "./threadComposerSubmission"; + +describe("thread composer submission", () => { + 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 8a9dabbe034f..12162e2f0fe8 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -4,9 +4,15 @@ import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; import { buildModelOptions, + getUnsupportedProviderAttachmentReason, + getUnsupportedProviderModeReason, + getUnavailableProviderModelReason, + getProviderSendBlockReason, groupByProvider, + providerSupportsImageAttachments, resolveDefaultableModelSelection, resolveSelectableModelSelection, + shouldShowProviderInteractionModeToggle, } from "./modelOptions"; describe("mobile model options", () => { @@ -138,6 +144,205 @@ describe("mobile model options", () => { expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled); }); + it("preserves but blocks 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)).toBe(stale); + expect(getUnavailableProviderModelReason({ config, selection: stale })).toContain( + "Choose another model", + ); + 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)).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"]); + // 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" }, + showInteractionModeToggle: false, + 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(); + 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", () => { + 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..57c548e21058 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,118 @@ 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 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; + 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.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 `${providerDisplayLabel(provider)} no longer offers the selected model. Choose another model to continue.`; +} + function providerDisplayLabel(provider: { readonly displayName?: string | undefined; readonly driver: string; @@ -59,12 +164,28 @@ 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 * 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, @@ -128,6 +249,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 +263,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 +293,9 @@ export function buildModelOptions( isDefault: false, isLegacy: false, capabilities: null, + supportedRuntimeModes: ALL_RUNTIME_MODES, + showInteractionModeToggle: true, + supportsImageAttachments: true, selection: fallbackModelSelection, }); } @@ -168,7 +305,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 +322,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 +333,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..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, @@ -14,12 +13,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 +85,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; @@ -186,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 b12ad2dc5843..c1ad7086c1b4 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,12 +14,12 @@ import { decodeQueuedThreadMessage, encodeQueuedThreadMessage, groupQueuedThreadMessages, + getQueuedDeliveryUnsupportedProviderInputReason, isQueuedThreadCreationSendable, modelSelectionsEqual, resolveThreadOutboxDeliveryAction, resolveThreadOutboxFailureAction, resolveQueuedThreadSettings, - shouldRetryThreadOutboxDelivery, threadOutboxRetryDelayMs, type QueuedThreadMessage, } from "./thread-outbox-model"; @@ -43,6 +44,111 @@ 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: [ + { + slug: "current-account-model", + name: "Current account model", + isCustom: false, + capabilities: null, + }, + ], + }, + ], + } 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("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", @@ -591,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( @@ -618,6 +713,6 @@ describe("thread outbox", () => { error: deterministicFailure, interrupted: false, }), - ).toBe("discard"); + ).toBe("retry"); }); }); 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..f62ee5b12d91 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", { @@ -343,6 +393,11 @@ const program = Effect.gen(function* () { yield* agent.handleLoadSession((request) => Effect.gen(function* () { const requestedSessionId = String(request.sessionId ?? sessionId); + if (!authenticated) { + return yield* AcpError.AcpRequestError.authRequired( + "Call authenticate before starting a session", + ); + } if (failLoadSession) { return yield* AcpError.AcpRequestError.internalError("Mock load session failure"); } @@ -455,7 +510,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`); @@ -465,7 +520,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: { @@ -475,7 +530,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, @@ -494,12 +549,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", { @@ -518,7 +573,18 @@ const program = Effect.gen(function* () { return yield* Effect.never; } - if (hangPromptForever || (hangFirstPromptForever && promptCount === 1)) { + if (hangFirstPromptForever && currentPromptCount === 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 +697,29 @@ const program = Effect.gen(function* () { } if (emitToolCalls) { - const toolCallId = "tool-call-1"; + 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" }, + ][(currentPromptCount - 1) % 4]; + const permissionKind = cycleEditPermissionKinds + ? (cycledPermission?.kind ?? "execute") + : "execute"; + const permissionToolName = cycleEditPermissionKinds + ? (cycledPermission?.toolName ?? "Bash") + : alternatePermissionTool && currentPromptCount >= 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 +740,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 +754,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" }, ], }); @@ -693,8 +781,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 new file mode 100644 index 000000000000..7b3a4b109bdb --- /dev/null +++ b/apps/server/src/provider/Drivers/ClineDriver.ts @@ -0,0 +1,166 @@ +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: 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 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 provider snapshot.", + 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..83dd8d50c4bd --- /dev/null +++ b/apps/server/src/provider/Layers/ClineAdapter.test.ts @@ -0,0 +1,1162 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeURL from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as EffectAcpErrors from "effect-acp/errors"; + +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; +const isAcpSpawnError = Schema.is(EffectAcpErrors.AcpSpawnError); + +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< + ClineAdapterLiveOptions, + | "afterPromptSettlementDecision" + | "beforePromptSerialization" + | "beforeTurnReservation" + | "sessionStartTimeout" + >, +) => 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("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("publishes completion before a send that arrives after settlement decision", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cline-atomic-prompt-settlement"); + const wrapperPath = yield* Effect.promise(() => makeMockClineWrapper()); + const firstSettlementDecided = yield* Deferred.make(); + const secondReservationAttempted = yield* Deferred.make(); + const settlementCount = yield* Ref.make(0); + const reservationCount = yield* Ref.make(0); + const adapter = yield* makeTestAdapter(wrapperPath, { + beforeTurnReservation: Ref.updateAndGet(reservationCount, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 2 ? Deferred.succeed(secondReservationAttempted, undefined) : Effect.void, + ), + ), + afterPromptSettlementDecision: Ref.updateAndGet(settlementCount, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Deferred.succeed(firstSettlementDecided, undefined).pipe( + Effect.andThen(Deferred.await(secondReservationAttempted)), + ) + : Effect.void, + ), + ), + }); + 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 firstTurnFiber = yield* adapter + .sendTurn({ threadId, input: "first turn", attachments: [] }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(firstSettlementDecided); + const secondTurnFiber = yield* adapter + .sendTurn({ threadId, input: "second turn", attachments: [] }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(secondReservationAttempted); + + const [firstResult, secondResult] = yield* Effect.all([ + Fiber.join(firstTurnFiber), + Fiber.join(secondTurnFiber), + ]); + yield* adapter.stopSession(threadId); + yield* Fiber.interrupt(eventsFiber); + + assert.notEqual(firstResult.turnId, secondResult.turnId); + assert.lengthOf( + runtimeEvents.filter((event) => event.type === "turn.started"), + 2, + ); + assert.lengthOf( + runtimeEvents.filter((event) => event.type === "turn.completed"), + 2, + ); + const firstCompletedIndex = runtimeEvents.findIndex( + (event) => event.type === "turn.completed" && event.turnId === firstResult.turnId, + ); + const secondStartedIndex = runtimeEvents.findIndex( + (event) => event.type === "turn.started" && event.turnId === secondResult.turnId, + ); + assert.isAtLeast(firstCompletedIndex, 0); + assert.isAbove(secondStartedIndex, firstCompletedIndex); + }), + ); + + 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("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( + [ + { 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("reports a stable ACP runtime creation stage and preserves the spawn cause", () => + Effect.gen(function* () { + const adapter = yield* makeTestAdapter("/definitely/missing/cline"); + const error = yield* Effect.flip( + adapter.startSession({ + threadId: ThreadId.make("cline-missing-runtime"), + provider: ProviderDriverKind.make("cline"), + cwd: process.cwd(), + runtimeMode: "full-access", + }), + ); + + assert.equal(error._tag, "ProviderAdapterProcessError"); + if (error._tag === "ProviderAdapterProcessError") { + assert.equal(error.detail, "Failed to create Cline ACP runtime."); + assert.isTrue(isAcpSpawnError(error.cause)); + if (isAcpSpawnError(error.cause)) { + assert.equal(error.cause.command, "/definitely/missing/cline"); + } + } + }), + ); + + 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", + T3_ACP_CYCLE_EDIT_PERMISSION_KINDS: "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"); + 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"); + } + }), + ); + + 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 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(); + 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); + yield* Deferred.await(queuedPromptReached); + 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..f3ae883fd59c --- /dev/null +++ b/apps/server/src/provider/Layers/ClineAdapter.ts @@ -0,0 +1,1131 @@ +/** + * 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 isAcpError = Schema.is(EffectAcpErrors.AcpError); + +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; + /** Test-only scheduling hook at the shared ACP prompt serialization boundary. */ + readonly beforePromptSerialization?: Effect.Effect; + /** Test-only scheduling hook before a send reserves its turn. */ + readonly beforeTurnReservation?: Effect.Effect; + /** Test-only scheduling hook inside the atomic prompt-settlement section. */ + readonly afterPromptSettlementDecision?: Effect.Effect; +} + +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 } : {}), + ...(options?.beforePromptSerialization + ? { beforePromptSerialization: options.beforePromptSerialization } + : {}), + 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: "Failed to create Cline ACP runtime.", + 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) { + const replayIdleWithoutConfig = + started.sessionSetupResult._meta?.t3SessionLoadReady === "replay_idle"; + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + 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.", + }); + } + + 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) { + 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.", + }); + } + yield* options?.beforeTurnReservation ?? Effect.void; + 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; + let promptReservationReleased = false; + + 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; + const promptParts: Array = [ + { type: "text", text: promptText }, + ]; + + const result = yield* ctx.acp + .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) => + isAcpError(error) + ? mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error) + : 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, + }; + + // Decide completion and release the prompt reservation under the same + // thread lock used by new sends. A send either joins this turn before + // the decision or starts a new turn after its completion is published. + yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); + promptReservationReleased = true; + const shouldCompleteTurn = ctx.promptsInFlight === 0; + yield* options?.afterPromptSettlementDecision ?? Effect.void; + if (shouldCompleteTurn) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason ?? null, + }, + }); + } + }), + ).pipe(Effect.uninterruptible); + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }).pipe( + Effect.ensuring( + Effect.suspend(() => + promptReservationReleased + ? Effect.void + : withThreadLock( + input.threadId, + Effect.sync(() => { + ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); + promptReservationReleased = true; + }), + ), + ), + ), + ); + }, + ); + + 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..52e7f24c3117 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, @@ -33,6 +34,7 @@ import { ProviderDriverKind, type ProviderInstanceConfigMap, ProviderInstanceId, + ServerSettingsError, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -44,6 +46,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 +120,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", @@ -314,16 +324,50 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); + it.live("preserves the typed cause when Cline snapshot construction fails", () => + Effect.gen(function* () { + const originalCause = new Error("simulated Cline snapshot settings failure"); + const snapshotFailure = new ServerSettingsError({ + settingsPath: "/test/settings.json", + operation: "read-file", + cause: originalCause, + }); + const failingSettingsService = { + start: Effect.void, + ready: Effect.void, + getSettings: Effect.fail(snapshotFailure), + updateSettings: () => Effect.fail(snapshotFailure), + streamChanges: Stream.empty, + subscribeChanges: Effect.succeed(Stream.empty), + } satisfies ServerSettingsService["Service"]; + + const error = yield* ClineDriver.create({ + instanceId: ProviderInstanceId.make("cline_snapshot_failure"), + displayName: "Cline", + environment: [], + enabled: false, + config: makeClineConfig({}), + }).pipe(Effect.provideService(ServerSettingsService, failingSettingsService), Effect.flip); + + expect(error._tag).toBe("ProviderDriverError"); + expect(error.detail).toBe("Failed to build Cline provider snapshot."); + expect(error.cause).toBe(snapshotFailure); + expect(snapshotFailure.cause).toBe(originalCause); + }).pipe(Effect.provide(testLayer)), + ); + it.live("boots one instance of every shipped driver from a single config map", () => 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 +388,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { launchArgs: "--verbose", }), }, + [clineId]: { + driver: clineDriverKind, + displayName: "Cline", + enabled: false, + config: makeClineConfig({}), + }, [cursorId]: { driver: cursorDriverKind, displayName: "Cursor", @@ -365,7 +415,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 +425,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 +435,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 +472,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 +501,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..a008a0a12ac3 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"; @@ -39,6 +40,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 +70,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; @@ -269,6 +303,105 @@ 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("cleans up a forked prompt RPC when registration is interrupted", () => + Effect.gen(function* () { + const registrationReached = yield* Deferred.make(); + const promptRpcFiberExited = yield* Deferred.make(); + const runtime = yield* AcpSessionRuntime.make({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + beforePromptRegistration: Deferred.succeed(registrationReached, undefined).pipe( + Effect.andThen(Effect.never), + ), + onPromptRpcFiberExit: Deferred.succeed(promptRpcFiberExited, undefined).pipe(Effect.asVoid), + }); + yield* runtime.start(); + + const promptFiber = yield* runtime + .prompt({ prompt: [{ type: "text", text: "must clean up" }] }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(registrationReached); + yield* Fiber.interrupt(promptFiber); + + expect(Option.isSome(yield* Deferred.poll(promptRpcFiberExited))).toBe(true); + yield* runtime.terminate("100 millis"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("requires authentication before session/load replay", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + const error = yield* runtime.start().pipe(Effect.flip); + + expect(error._tag).toBe("AcpRequestError"); + if (error._tag === "AcpRequestError") { + expect(error.errorMessage).toContain("authenticate before starting a session"); + } + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_REQUIRE_AUTHENTICATION: "1", + T3_ACP_EMIT_LOAD_REPLAY: "1", + }, + }, + cwd: process.cwd(), + resumeSessionId: "mock-session-1", + clientInfo: { name: "t3-test", version: "0.0.0" }, + }), + ), + 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 09fce6d56f9d..5bc3a55bf9d9 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 { @@ -63,14 +64,27 @@ 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; 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; + /** 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; + /** Test-only observation hook for prompt RPC fiber cleanup. */ + readonly onPromptRpcFiberExit?: Effect.Effect; readonly protocolLogging?: { readonly logIncoming?: boolean; readonly logOutgoing?: boolean; @@ -175,10 +189,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. */ @@ -191,9 +215,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 @@ -293,6 +318,8 @@ 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> >(Option.none()); @@ -339,6 +366,9 @@ 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 !== undefined + ? { forceKillAfter: options.spawn.forceKillAfter } + : {}), shell: spawnCommand.shell, }), ) @@ -541,15 +571,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: @@ -587,14 +619,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, { @@ -705,6 +742,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(); @@ -716,52 +763,93 @@ 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, - }), - ), - ); - }), - ), + 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 = { + stopReason: "cancelled", + } satisfies EffectAcpSchema.PromptResponse; + if ((yield* Ref.get(promptCancellationGenerationRef)) !== cancellationGeneration) { + return cancelledResponse; + } + yield* beforePrompt ?? Effect.void; + const started = yield* getStartedState; + yield* closeActiveAssistantSegment({ + queue: eventQueue, + assistantSegmentRef, + }); + const requestPayload = { + sessionId: started.sessionId, + ...payload, + } satisfies EffectAcpSchema.PromptRequest; + const promptStartGate = yield* Deferred.make(); + const promptRpcFiber = yield* Deferred.await(promptStartGate).pipe( + Effect.andThen( + runLoggedRequest( + "session/prompt", + requestPayload, + acp.agent.prompt(requestPayload), + ), + ), + Effect.ensuring(options.onPromptRpcFiberExit ?? Effect.void), + Effect.forkIn(runtimeScope, { startImmediately: true }), + ); + return yield* Effect.gen(function* () { + 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) { + return cancelledResponse; + } + yield* Deferred.succeed(promptStartGate, undefined); + return yield* Fiber.join(promptRpcFiber).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.succeed(cancelledResponse) + : Effect.failCause(cause), + ), + ); + }).pipe( + 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* () { - 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 new file mode 100644 index 000000000000..ddeaaaef21e5 --- /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.env.T3_CLINE_ACP_PROBE_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..289faa4f6f23 --- /dev/null +++ b/apps/server/src/provider/acp/ClineAcpSupport.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as itx from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +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 { + applyClineAcpModelSelection, + buildClineAcpSpawnInput, + CLINE_PROCESS_FORCE_KILL_AFTER, + currentClineModelIdFromSessionSetup, + clineModelsFromSessionConfigOptions, + makeClineAcpRuntime, +} 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", + }); + expect(buildClineAcpSpawnInput({ binaryPath: "cline" }, "/workspace", undefined, 0)).toEqual({ + command: "cline", + args: ["--acp"], + cwd: "/workspace", + forceKillAfter: 0, + }); + }); + + itx.it.effect("forwards an immediate force-kill bound to the ACP child command", () => + Effect.gen(function* () { + let spawnedCommand: + | { + readonly options: { + readonly forceKillAfter?: unknown; + }; + } + | undefined; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + spawnedCommand = command as unknown as typeof spawnedCommand; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + + yield* makeClineAcpRuntime({ + childProcessSpawner: spawner, + clineSettings: { binaryPath: "cline" }, + cwd: "/workspace", + forceKillAfter: 0, + clientInfo: { name: "t3-test", version: "0.0.0" }, + }); + + expect(spawnedCommand?.options.forceKillAfter).toBe(0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + 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("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", + 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..889030c76858 --- /dev/null +++ b/apps/server/src/provider/acp/ClineAcpSupport.ts @@ -0,0 +1,188 @@ +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 !== undefined ? { 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, + // 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, + 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); + const models = new Map(); + for (const entry of option.options) { + const values = "value" in entry ? [entry] : entry.options; + 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* < + 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/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/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..fe749058297e 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -266,6 +266,153 @@ 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("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 1bf37335271b..6c618ea82749 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,61 @@ 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]) => { + const candidate = { + instanceId: ProviderInstanceId.make(driver), + model: DEFAULT_TEXT_GENERATION_MODEL, + } satisfies ModelSelection; + return ( + driver !== "cline" && + isModelSelectionProviderEnabled(settings, candidate) && + textGenerationSelectionIsSupported(settings, candidate) + ); + }); + 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.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index cb814dace2e5..3b38bf375082 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"; @@ -20,7 +22,11 @@ import { deriveComposerSendState, dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, + getDirectAnnotationBlockReason, getStartedThreadModelChangeBlockReason, + getDirectAnnotationAttachmentBlockReason, + getUnsupportedProviderInputBannerCopy, + getUnsupportedProviderInputReason, hasEnvironmentReconnectWarningGraceElapsed, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, @@ -44,6 +50,137 @@ 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(); + }); + + it("blocks direct annotations when the selected instance rejects the runtime mode", () => { + expect( + getDirectAnnotationBlockReason({ + provider: { + ...providerWithoutImageAttachments, + supportedRuntimeModes: ["full-access"], + }, + runtimeMode: "approval-required", + interactionMode: "default", + existingImages: [], + image: null, + }), + ).toBe("Cline does not support the selected access mode. Choose Full access to continue."); + }); + + it("blocks direct annotations in carried Plan mode when the instance hides Plan", () => { + expect( + getDirectAnnotationBlockReason({ + provider: { + ...providerWithoutImageAttachments, + supportedRuntimeModes: ["full-access"], + showInteractionModeToggle: false, + }, + runtimeMode: "full-access", + interactionMode: "plan", + existingImages: [], + image: null, + }), + ).toBe("Cline does not support Plan mode. Choose Build to continue."); + }); +}); + +describe("unsupported provider input banner", () => { + it("surfaces a carried unsupported image as persistent banner copy", () => { + const reason = getUnsupportedProviderInputReason({ + provider: providerWithoutImageAttachments, + runtimeMode: "approval-required", + interactionMode: "default", + attachmentCount: 1, + }); + + expect(reason).toEqual({ + kind: "attachment", + reason: "Cline does not support image attachments. Remove the images to continue.", + }); + expect(getUnsupportedProviderInputBannerCopy(reason)).toEqual({ + title: "Image attachments unavailable", + description: "Cline does not support image attachments. Remove the images to continue.", + }); + }); + + it("gives an unsupported mode precedence over an unsupported attachment", () => { + const reason = getUnsupportedProviderInputReason({ + provider: { + ...providerWithoutImageAttachments, + supportedRuntimeModes: ["full-access"], + }, + runtimeMode: "approval-required", + interactionMode: "default", + attachmentCount: 1, + }); + + expect(reason).toEqual({ + kind: "mode", + reason: "Cline does not support the selected access mode. Choose Full access to continue.", + }); + expect(getUnsupportedProviderInputBannerCopy(reason)).toEqual({ + title: "Provider mode unavailable", + description: + "Cline does not support the selected access mode. Choose Full access to continue.", + }); + }); + + it("does not show an attachment banner without images or for a supporting provider", () => { + expect( + getUnsupportedProviderInputReason({ + provider: providerWithoutImageAttachments, + runtimeMode: "approval-required", + interactionMode: "default", + attachmentCount: 0, + }), + ).toBeNull(); + expect( + getUnsupportedProviderInputReason({ + provider: { ...providerWithoutImageAttachments, supportsImageAttachments: true }, + runtimeMode: "approval-required", + interactionMode: "default", + attachmentCount: 1, + }), + ).toBeNull(); + expect(getUnsupportedProviderInputBannerCopy(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..c8817d226c61 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -5,6 +5,8 @@ import { type MessageId, type ModelSelection, type ProviderDriverKind, + type ProviderInteractionMode, + type RuntimeMode, type ServerProvider, type ScopedProjectRef, type ScopedThreadRef, @@ -24,12 +26,89 @@ import { import type { DraftThreadEnvMode } from "../composerDraftStore"; import type { ComposerSubmissionIntent } from "../composer-logic"; import type { TimelineEntry } from "../session-logic"; +import { + getUnsupportedProviderAttachmentReason, + getUnsupportedProviderModeReason, +} 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; +function getDirectAnnotationAttachmentCount(input: { + readonly existingImages: ReadonlyArray>; + readonly image: Pick | null; +}): number { + const includesDirectImage = + input.image !== null && input.existingImages.some((image) => image.id === input.image?.id); + return input.existingImages.length + (input.image !== null && !includesDirectImage ? 1 : 0); +} + +export function getDirectAnnotationAttachmentBlockReason(input: { + readonly provider: ServerProvider | null | undefined; + readonly existingImages: ReadonlyArray>; + readonly image: Pick | null; +}): string | null { + return getUnsupportedProviderAttachmentReason( + input.provider, + getDirectAnnotationAttachmentCount(input), + ); +} + +export type UnsupportedProviderInputReason = { + readonly kind: "mode" | "attachment"; + readonly reason: string; +}; + +export function getUnsupportedProviderInputReason(input: { + readonly provider: ServerProvider | null | undefined; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; + readonly attachmentCount: number; +}): UnsupportedProviderInputReason | null { + const modeReason = getUnsupportedProviderModeReason(input); + if (modeReason !== null) { + return { kind: "mode", reason: modeReason }; + } + const attachmentReason = getUnsupportedProviderAttachmentReason( + input.provider, + input.attachmentCount, + ); + return attachmentReason === null ? null : { kind: "attachment", reason: attachmentReason }; +} + +export function getDirectAnnotationBlockReason(input: { + readonly provider: ServerProvider | null | undefined; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; + readonly existingImages: ReadonlyArray>; + readonly image: Pick | null; +}): string | null { + const reason = getUnsupportedProviderInputReason({ + provider: input.provider, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + attachmentCount: getDirectAnnotationAttachmentCount(input), + }); + return reason?.reason ?? null; +} + +export function getUnsupportedProviderInputBannerCopy( + reason: UnsupportedProviderInputReason | null, +): { + readonly title: string; + readonly description: string; +} | null { + return reason === null + ? null + : { + title: + reason.kind === "mode" ? "Provider mode unavailable" : "Image attachments unavailable", + description: reason.reason, + }; +} + 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 46ed051154a6..99172d600960 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, @@ -180,6 +179,7 @@ import { ChevronDownIcon, GitBranchIcon, PaperclipIcon, + ShieldAlertIcon, WifiOffIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; @@ -195,8 +195,15 @@ import { import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { registerFaviconProjectForThread } from "~/browserFaviconStore"; -import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; -import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; +import { getProviderModelCapabilities } from "../providerModels"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + NO_PROVIDER_MODEL_SELECTION, + resolveComposerProviderInstanceEntry, + resolveProviderDriverKindForInstanceSelection, + sortProviderInstanceEntries, +} from "../providerInstances"; import { useClientSettings, useClientSettingsHydrated, @@ -331,6 +338,9 @@ import { shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, getStartedThreadModelChangeBlockReason, + getDirectAnnotationBlockReason, + getUnsupportedProviderInputBannerCopy, + getUnsupportedProviderInputReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, type LocalDispatchSnapshot, @@ -1358,6 +1368,9 @@ function ChatViewContent(props: ChatViewProps) { const composerActiveProvider = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, ); + const composerImageCount = useComposerDraftStore( + (store) => store.getComposerDraft(composerDraftTarget)?.images.length ?? 0, + ); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const setComposerDraftTerminalContexts = useComposerDraftStore( @@ -2079,6 +2092,7 @@ function ChatViewContent(props: ChatViewProps) { const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = + activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection.instanceId ?? activeProject?.defaultModelSelection?.instanceId ?? null; @@ -2279,11 +2293,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]); @@ -2736,28 +2796,16 @@ 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 unsupportedProviderInputReason = getUnsupportedProviderInputReason({ + provider: activeProviderStatus, + runtimeMode, + interactionMode, + attachmentCount: composerImageCount, + }); const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< string | null @@ -4650,6 +4698,27 @@ function ChatViewContent(props: ChatViewProps) { handleStopBackgroundWork, isStoppingBackgroundWork, ]); + const unsupportedProviderInputBannerItem = useMemo(() => { + const copy = getUnsupportedProviderInputBannerCopy(unsupportedProviderInputReason); + if (copy === null) return null; + return { + id: `unsupported-provider-input:${unsupportedProviderInputReason?.kind ?? "unknown"}:${activeProviderStatus?.instanceId ?? "unknown"}:${runtimeMode}:${interactionMode}`, + variant: "warning", + icon: + unsupportedProviderInputReason?.kind === "attachment" ? ( + + ) : ( + + ), + title: copy.title, + description: copy.description, + }; + }, [ + activeProviderStatus?.instanceId, + interactionMode, + runtimeMode, + unsupportedProviderInputReason, + ]); // A woken thread announces itself in the open view, not just the sidebar // pill. Dismissing marks the wake as seen (same acknowledgment as the // pill); sending a message clears it as a side effect of the send path. @@ -4668,7 +4737,8 @@ function ChatViewContent(props: ChatViewProps) { }; }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]); // The stack renders items[0] front-most and tucks the rest behind hover, so - // ordering is priority: urgent system banners (error/warning variants plus + // ordering is priority: unsupported provider inputs, urgent system banners + // (error/warning variants plus // calm-styled live states flagged `urgent`, like update progress), then // background liveness — its Stop button is the only stop affordance for // settled turns, so a passive "update available" notice must not cover it — @@ -4731,8 +4801,11 @@ function ChatViewContent(props: ChatViewProps) { backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem]; const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; + const unsupportedProviderInputItems = + unsupportedProviderInputBannerItem === null ? [] : [unsupportedProviderInputBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ + ...unsupportedProviderInputItems, ...urgentSystemItems, ...backgroundLivenessItems, ...calmSystemItems, @@ -4741,6 +4814,7 @@ function ChatViewContent(props: ChatViewProps) { ]; } return [ + ...unsupportedProviderInputItems, ...urgentSystemItems, ...backgroundLivenessItems, ...calmSystemItems, @@ -4795,6 +4869,7 @@ function ChatViewContent(props: ChatViewProps) { parkedThreadBannerItem, showBranchMismatchBanner, systemComposerBannerItems, + unsupportedProviderInputBannerItem, wokeThreadBannerItem, ]); useEffect(() => { @@ -5159,11 +5234,29 @@ function ChatViewContent(props: ChatViewProps) { previewAnnotations: sendContextPreviewAnnotations, reviewComments: composerReviewComments, selectedProvider: ctxSelectedProvider, + selectedProviderStatus: ctxSelectedProviderStatus, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, selectedPromptEffort: ctxSelectedPromptEffort, selectedModelSelection: ctxSelectedModelSelection, } = sendCtx; + const directAnnotationBlockReason = getDirectAnnotationBlockReason({ + provider: ctxSelectedProviderStatus, + runtimeMode, + interactionMode, + existingImages: sendContextImages, + image: directAnnotation?.image ?? null, + }); + if (directAnnotation && directAnnotationBlockReason) { + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Annotation attached to draft", + description: directAnnotationBlockReason, + }), + ); + return; + } const composerImages = directAnnotation?.image && !sendContextImages.some((image) => image.id === directAnnotation.image?.id) @@ -6792,7 +6885,7 @@ function ChatViewContent(props: ChatViewProps) { ? "Sending feedback" : threadDetailLoading ? "Messages loading" - : null + : (unsupportedProviderInputReason?.reason ?? null) } isPreparingWorktree={isPreparingWorktree} externalDrawerAttached={externalComposerDrawerAttached} 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 cd0854e176b7..fa239401b9d9 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -214,6 +214,28 @@ 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..bbcf681e1af6 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -95,6 +95,11 @@ import { } from "../composerFooterLayout"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; import { ProviderModelPicker } from "./ProviderModelPicker"; +import { + getComposerProviderAvailability, + getNoSelectableProviderModelReason, + updateProviderInputSubmissionError, +} from "./ProviderModelPicker.logic"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; @@ -120,6 +125,7 @@ import { Separator } from "../ui/separator"; import { getComposerPromptLengthValidationMessage, getComposerSubmissionValidationMessage, + shouldBlockComposerSubmissionForProviderAvailability, submitComposerDraft, } from "./composerSubmission"; import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; @@ -236,13 +242,17 @@ import { XIcon, } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; -import { getProviderInteractionModeToggle } from "../../providerModels"; +import { + canUseProviderInteractionModeShortcut, + getUnsupportedProviderAttachmentReason, + getProviderSupportedRuntimeModes, +} from "../../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, + resolveComposerProviderInstanceEntry, resolveProviderDriverKindForInstanceSelection, - resolveSelectableProviderInstanceEntry, sortProviderInstanceEntries, type ProviderInstanceEntry, } from "../../providerInstances"; @@ -287,7 +297,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 +342,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; + supportedRuntimeModes: ReadonlyArray; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { @@ -392,7 +402,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 ( @@ -519,6 +529,7 @@ export interface ChatComposerHandle { selectedModelSelection: ModelSelection; providerAvailable: boolean; selectedProvider: ProviderDriverKind; + selectedProviderStatus: ServerProvider | null; selectedModel: string; selectedProviderModels: ReadonlyArray; }; @@ -711,8 +722,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setThreadError, onExpandImage, } = props; - const isSendDisabled = sendDisabledReason !== null; - // ------------------------------------------------------------------ // Store subscriptions (prompt / images / terminal contexts) // ------------------------------------------------------------------ @@ -814,44 +823,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, @@ -862,15 +846,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 noProviderAvailable = 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 +867,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) projectModelSelection: activeProjectDefaultModelSelection, settings, }); + const { + noProviderEntryAvailable, + noSelectableModelAvailable, + noProviderAvailable, + showProviderModelPicker, + } = getComposerProviderAvailability({ + hasProviderEntry: selectedProviderEntry !== undefined, + selectedModel, + }); const selectedProviderStatus = useMemo( () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], ); + const effectiveSendDisabledReason = + sendDisabledReason ?? + getNoSelectableProviderModelReason(noSelectableModelAvailable) ?? + getUnsupportedProviderAttachmentReason(selectedProviderStatus, composerImages.length); + const isSendDisabled = effectiveSendDisabledReason !== null; const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], @@ -923,15 +918,17 @@ 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" || selectedProviderStatus?.showInteractionModeToggle !== false), + supportedRuntimeModes: getProviderSupportedRuntimeModes(selectedProviderStatus), }), - [planModeUiEnabled, providerStatuses, selectedProvider], + [interactionMode, planModeUiEnabled, selectedProviderStatus], ); const selectedModelSelection = useMemo( () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch), @@ -992,6 +989,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [providerInputSubmissionError, setProviderInputSubmissionError] = useState( null, ); + const previousEffectiveSendDisabledReasonRef = useRef(effectiveSendDisabledReason); const [composerMenuAnchor, setComposerMenuAnchor] = useState(null); const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); const [isTasksDrawerOpen, setIsTasksDrawerOpen] = useState(false); @@ -1092,7 +1090,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 +1099,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 +1166,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return []; }, [ composerTrigger, + interactionMode, planModeUiEnabled, selectedProvider, selectedProviderStatus, @@ -1394,6 +1398,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedProvider, ]); + useEffect(() => { + const previousEffectiveSendDisabledReason = previousEffectiveSendDisabledReasonRef.current; + previousEffectiveSendDisabledReasonRef.current = effectiveSendDisabledReason; + setProviderInputSubmissionError((error) => + updateProviderInputSubmissionError(error, { + type: "effective-send-disabled-reason-changed", + previousEffectiveSendDisabledReason, + effectiveSendDisabledReason, + }), + ); + }, [effectiveSendDisabledReason]); + useEffect(() => { composerImagesRef.current = composerImages; }, [composerImages, composerImagesRef]); @@ -1896,8 +1912,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const submitComposer = useCallback( (event?: { preventDefault: () => void }, intent: ComposerSubmissionIntent = "foreground") => { - if (noProviderAvailable || isSendDisabled) { + const submissionTarget = activePendingProgress ? "pending-user-input" : "provider-turn"; + if ( + shouldBlockComposerSubmissionForProviderAvailability({ + submissionTarget, + noProviderAvailable, + isSendDisabled, + }) + ) { event?.preventDefault(); + setProviderInputSubmissionError((error) => + updateProviderInputSubmissionError(error, { + type: "blocked-submit", + effectiveSendDisabledReason, + }), + ); return; } // A send while a pasted image is still compressing would strand that @@ -1915,7 +1944,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } const submission = submitComposerDraft({ prompt: promptRef.current, - submissionTarget: activePendingProgress ? "pending-user-input" : "provider-turn", + submissionTarget, event, onSend: (sendEvent) => { // ChatView reports its final composed-input preflight through the @@ -1935,6 +1964,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeThreadId, activePendingProgress, blurMobileComposerAfterSend, + effectiveSendDisabledReason, isSendDisabled, noProviderAvailable, onSend, @@ -1973,7 +2003,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 +2512,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", @@ -2780,6 +2829,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModelSelection, providerAvailable: !noProviderAvailable, selectedProvider, + selectedProviderStatus, selectedModel, selectedProviderModels, }), @@ -2821,6 +2871,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) noProviderAvailable, selectedPromptEffort, selectedProvider, + selectedProviderStatus, selectedProviderModels, ], ); @@ -2946,11 +2997,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showPlanFollowUpPrompt={false} promptHasText={false} isSendBusy={isSendBusy} - sendDisabledReason={sendDisabledReason} + sendDisabledReason={effectiveSendDisabledReason} isConnecting={isConnecting} isEnvironmentUnavailable={ environmentUnavailable !== null || - noProviderAvailable || + noProviderEntryAvailable || projectSelectionRequired } isPreparingWorktree={false} @@ -3251,11 +3302,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} /> @@ -3273,11 +3326,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showPlanFollowUpPrompt={false} promptHasText={false} isSendBusy={isSendBusy} - sendDisabledReason={sendDisabledReason} + sendDisabledReason={effectiveSendDisabledReason} isConnecting={isConnecting} isEnvironmentUnavailable={ environmentUnavailable !== null || - noProviderAvailable || + noProviderEntryAvailable || projectSelectionRequired } isPreparingWorktree={false} @@ -3309,7 +3362,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )} >
- {noProviderAvailable ? ( + {!showProviderModelPicker ? (
-
- { - 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..5dddde282d16 --- /dev/null +++ b/apps/web/src/components/settings/SourceControlWritingSettings.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + hasUsableSourceControlWriterModel, + sourceControlWriterUnavailableMessage, + sourceControlWriterToggleState, +} from "./SourceControlWritingSettings"; + +const model = { slug: "model", name: "Model", isCustom: false, capabilities: null } as const; + +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 }); + }); + + it("requires an enabled and available instance for a new override", () => { + expect( + hasUsableSourceControlWriterModel([ + { enabled: false, isAvailable: true, status: "ready", models: [model] }, + { enabled: true, isAvailable: false, status: "ready", models: [model] }, + ]), + ).toBe(false); + expect( + hasUsableSourceControlWriterModel([ + { enabled: true, isAvailable: true, status: "ready", models: [model] }, + ]), + ).toBe(true); + }); + + it("rejects errored and empty provider catalogs for a new override", () => { + expect( + hasUsableSourceControlWriterModel([ + { enabled: true, isAvailable: true, status: "error", models: [model] }, + { enabled: true, isAvailable: true, status: "ready", models: [] }, + ]), + ).toBe(false); + }); + + it("explains the unavailable state whether the override is off or needs clearing", () => { + expect( + sourceControlWriterUnavailableMessage({ + hasOverride: false, + hasSupportedModel: false, + }), + ).toBe("No enabled and available text generation provider with models is available."); + expect( + sourceControlWriterUnavailableMessage({ + hasOverride: true, + hasSupportedModel: false, + }), + ).toBe( + "The selected source control writer is unavailable. Turn off this override or enable a text generation provider with models.", + ); + expect( + sourceControlWriterUnavailableMessage({ + hasOverride: true, + hasSupportedModel: true, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index d7c094af372b..e1f2689a0bad 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -9,7 +9,9 @@ import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSet import { applyProviderInstanceSettings, deriveProviderInstanceEntries, + filterTextGenerationProviderInstanceEntries, sortProviderInstanceEntries, + type ProviderInstanceEntry, } from "../../providerInstances"; import { getCustomModelOptionsByInstance, @@ -40,6 +42,37 @@ const MODE_OPTIONS: Record + >, +): boolean { + return entries.some( + (entry) => + entry.enabled && entry.isAvailable && entry.status === "ready" && entry.models.length > 0, + ); +} + export function SourceControlWritingSettingsSection() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); @@ -60,9 +93,20 @@ export function SourceControlWritingSettingsSection() { resolvedSourceControlWriterSelection === settings.textGenerationModelSelection ? defaultModelSelection : resolvedSourceControlWriterSelection; - const instanceEntries = sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), + const instanceEntries = filterTextGenerationProviderInstanceEntries( + sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), + ), ); + const canUseDedicatedModel = hasUsableSourceControlWriterModel(instanceEntries); + const dedicatedWriterToggle = sourceControlWriterToggleState({ + hasOverride: usesDedicatedModel, + hasSupportedModel: canUseDedicatedModel, + }); + const dedicatedWriterUnavailableMessage = sourceControlWriterUnavailableMessage({ + hasOverride: usesDedicatedModel, + hasSupportedModel: canUseDedicatedModel, + }); const modelOptionsByInstance = getCustomModelOptionsByInstance( settings, serverProviders, @@ -170,10 +214,13 @@ export function SourceControlWritingSettingsSection() { - {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/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 20c6603f773b..9a1ab984ffea 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -6,6 +6,7 @@ import { } from "@t3tools/client-runtime/environment"; import * as Schema from "effect/Schema"; import { + DEFAULT_UNIFIED_SETTINGS, defaultInstanceIdForDriver, EnvironmentId, ProjectId, @@ -14,6 +15,7 @@ import { ThreadId, type ModelSelection, type ProviderOptionSelection, + type ServerProvider, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; @@ -60,6 +62,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { COMPOSER_DRAFT_STORAGE_KEY, clearComposerDraftsEnvironment, + deriveEffectiveComposerModelState, finalizePromotedDraftThreadByRef, markPromotedDraftThread, markPromotedDraftThreadByRef, @@ -76,6 +79,10 @@ import { type TerminalContextDraft, } from "./lib/terminalContext"; import { createDebouncedStorage } from "./lib/storage"; +import { + deriveProviderInstanceEntries, + resolveComposerProviderInstanceEntry, +} from "./providerInstances"; function makeImage(input: { id: string; @@ -1285,6 +1292,95 @@ describe("composerDraftStore modelSelection", () => { resetComposerDraftStore(); }); + it.each([ + ["an empty ready catalog", "ready", "authenticated"], + ["a checking catalog", "warning", "unknown"], + ["an errored catalog", "error", "authenticated"], + ] as const)( + "does not borrow the default instance model when a custom Cline instance has %s", + (_label, status, authStatus) => { + const clineDriver = ProviderDriverKind.make("cline"); + const defaultInstanceId = defaultInstanceIdForDriver(clineDriver); + const customInstanceId = ProviderInstanceId.make("cline_work"); + const provider = (input: { + instanceId: ProviderInstanceId; + status: ServerProvider["status"]; + authStatus: ServerProvider["auth"]["status"]; + models: ServerProvider["models"]; + }): ServerProvider => ({ + instanceId: input.instanceId, + driver: clineDriver, + displayName: input.instanceId === defaultInstanceId ? "Cline" : "Cline Work", + enabled: true, + installed: true, + version: "3.0.57", + status: input.status, + auth: { status: input.authStatus }, + checkedAt: "2026-08-23T00:00:00.000Z", + models: input.models, + slashCommands: [], + skills: [], + }); + const providers = [ + provider({ + instanceId: defaultInstanceId, + status: "ready", + authStatus: "authenticated", + models: [ + { + slug: "default-account-model", + name: "Default account model", + isCustom: false, + isDefault: true, + capabilities: {}, + }, + ], + }), + provider({ + instanceId: customInstanceId, + status, + authStatus, + models: [], + }), + ]; + const selectedEntry = resolveComposerProviderInstanceEntry({ + entries: deriveProviderInstanceEntries(providers), + candidateInstanceIds: [customInstanceId], + requestedDriverKind: clineDriver, + lockedProvider: null, + lockedContinuationGroupKey: null, + }); + const customSelection = createModelSelection(customInstanceId, "retired-custom-model"); + const draft = { + activeProvider: customInstanceId, + modelSelectionByProvider: { + [defaultInstanceId]: createModelSelection(defaultInstanceId, "default-account-model"), + [customInstanceId]: customSelection, + }, + }; + + expect(selectedEntry?.instanceId).toBe(customInstanceId); + expect( + deriveEffectiveComposerModelState({ + draft, + providers, + selectedProvider: clineDriver, + selectedInstanceId: selectedEntry?.instanceId, + threadModelSelection: customSelection, + projectModelSelection: null, + settings: DEFAULT_UNIFIED_SETTINGS, + }).selectedModel, + ).toBe(""); + expect(draft).toEqual({ + activeProvider: customInstanceId, + modelSelectionByProvider: { + [defaultInstanceId]: createModelSelection(defaultInstanceId, "default-account-model"), + [customInstanceId]: customSelection, + }, + }); + }, + ); + it("stores a model selection in the draft", () => { const store = useComposerDraftStore.getState(); store.setModelSelection( diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index f20385ee04f4..b0f8d3e076ed 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1014,23 +1014,26 @@ export function deriveEffectiveComposerModelState(input: { }): EffectiveComposerModelState { const baseModelCandidate = input.threadModelSelection?.model ?? input.projectModelSelection?.model ?? null; - const baseModel = - (input.selectedInstanceId - ? resolveAppModelSelectionForInstance( - input.selectedInstanceId, - input.settings, - input.providers, - baseModelCandidate, - ) - : null) ?? - resolveAppModelSelection( - input.selectedProvider, - input.settings, - input.providers, - baseModelCandidate, - ) ?? - normalizeModelSlug(baseModelCandidate, input.selectedProvider) ?? - getDefaultServerModel(input.providers, input.selectedProvider); + const selectedInstanceId = input.selectedInstanceId + ? input.providers.find((provider) => provider.instanceId === input.selectedInstanceId) + ?.instanceId + : undefined; + const selectedInstanceExists = selectedInstanceId !== undefined; + const baseModel = selectedInstanceExists + ? (resolveAppModelSelectionForInstance( + selectedInstanceId, + input.settings, + input.providers, + baseModelCandidate, + ) ?? "") + : (resolveAppModelSelection( + input.selectedProvider, + input.settings, + input.providers, + baseModelCandidate, + ) ?? + normalizeModelSlug(baseModelCandidate, input.selectedProvider) ?? + getDefaultServerModel(input.providers, input.selectedProvider)); // Look up the instance's saved selection first; fall back to the // driver-kind bucket so legacy kind-keyed drafts still resolve. Every // `ProviderDriverKind` literal is a valid `ProviderInstanceId` slug, so the @@ -1038,25 +1041,33 @@ export function deriveEffectiveComposerModelState(input: { const instanceSelection = input.selectedInstanceId ? input.draft?.modelSelectionByProvider?.[input.selectedInstanceId] : undefined; - const legacySelection = - input.draft?.modelSelectionByProvider?.[ProviderInstanceId.make(input.selectedProvider)]; + const legacySelection = selectedInstanceExists + ? undefined + : input.draft?.modelSelectionByProvider?.[ProviderInstanceId.make(input.selectedProvider)]; const activeSelection = instanceSelection ?? legacySelection; const activeSelectionInstanceId = instanceSelection ? (input.selectedInstanceId ?? ProviderInstanceId.make(input.selectedProvider)) : ProviderInstanceId.make(input.selectedProvider); const selectedModel = activeSelection?.model - ? (resolveAppModelSelectionForInstance( - activeSelectionInstanceId, - input.settings, - input.providers, - activeSelection.model, - ) ?? - resolveAppModelSelection( - input.selectedProvider, - input.settings, - input.providers, - activeSelection.model, - )) + ? selectedInstanceExists + ? (resolveAppModelSelectionForInstance( + activeSelectionInstanceId, + input.settings, + input.providers, + activeSelection.model, + ) ?? "") + : (resolveAppModelSelectionForInstance( + activeSelectionInstanceId, + input.settings, + input.providers, + activeSelection.model, + ) ?? + resolveAppModelSelection( + input.selectedProvider, + input.settings, + input.providers, + activeSelection.model, + )) : baseModel; const modelOptions = modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ?? 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..56ed38b41c82 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, @@ -15,6 +17,9 @@ function provider(input: { provider?: ProviderDriverKind; instanceId: string; models?: ReadonlyArray; + enabled?: boolean; + availability?: ServerProvider["availability"]; + status?: ServerProvider["status"]; }): ServerProvider { const driver = input.provider ?? @@ -24,10 +29,11 @@ function provider(input: { return { instanceId: ProviderInstanceId.make(input.instanceId), driver, - enabled: true, + enabled: input.enabled ?? true, installed: true, version: null, - status: "ready", + status: input.status ?? "ready", + ...(input.availability ? { availability: input.availability } : {}), auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", models: (input.models ?? []).map((slug) => ({ @@ -58,6 +64,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 +367,76 @@ 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: "", + }); + }); + + it("returns no background selection when non-Cline snapshots are all unusable", () => { + const providers = [ + provider({ + instanceId: "codex", + models: ["gpt-5.4"], + enabled: false, + }), + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: ["claude-sonnet-4-6"], + availability: "unavailable", + }), + ]; + + expect(resolveAppModelSelectionState(DEFAULT_UNIFIED_SETTINGS, providers)).toEqual({ + instanceId: "t3code_no_provider", + model: "", + }); + }); + + it("returns no background selection for errored or empty provider catalogs", () => { + const providers = [ + provider({ + instanceId: "codex", + models: ["gpt-5.4"], + status: "error", + }), + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: [], + }), + ]; + + expect(resolveAppModelSelectionState(DEFAULT_UNIFIED_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..4926230a79b1 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -23,7 +23,12 @@ import { resolveSelectableProvider, } from "./providerModels"; import { ModelEsque } from "./components/chat/providerIconUtils"; -import { type ProviderInstanceEntry, deriveProviderInstanceEntries } from "./providerInstances"; +import { + type ProviderInstanceEntry, + deriveProviderInstanceEntries, + isProviderInstancePickerReady, + NO_PROVIDER_MODEL_SELECTION, +} from "./providerInstances"; import { sortModelsForProviderInstance } from "./modelOrdering"; const MAX_CUSTOM_MODEL_COUNT = 32; @@ -166,7 +171,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 +215,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,49 +340,38 @@ 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 supportsBackgroundSelection = (entry: ProviderInstanceEntry) => + isProviderInstancePickerReady(entry) && entry.models.length > 0; const selectedEntry = entries.find( - (entry) => entry.instanceId === selection.instanceId && entry.enabled && entry.isAvailable, + (entry) => entry.instanceId === selection.instanceId && supportsBackgroundSelection(entry), ); - const entry = - selectedEntry ?? entries.find((candidate) => candidate.enabled && candidate.isAvailable); - if (entry) { - // 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; - const model = - resolveAppModelSelectionForInstance(entry.instanceId, settings, providers, selectedModel) ?? - entry.models[0]?.slug ?? - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind]; - if (!model) { - return createModelSelection(entry.instanceId, "", []); - } - const provider = entry.driverKind; - const { modelOptionsForDispatch } = getComposerProviderState({ - provider, - model, - models: entry.models, - modelOptions: selectedEntry ? selection.options : undefined, - planModeEnabled: settings.planModeEnabled, - }); + const entry = selectedEntry ?? entries.find(supportsBackgroundSelection); + // Snapshots can remain present while an instance is disabled or unavailable. + // Background text generation must fail closed instead of synthesizing a + // Codex selection from those unusable rows. + if (!entry) return NO_PROVIDER_MODEL_SELECTION; - return createModelSelection(entry.instanceId, model, modelOptionsForDispatch); + // 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; + const model = + resolveAppModelSelectionForInstance(entry.instanceId, settings, providers, selectedModel) ?? + entry.models[0]?.slug ?? + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind]; + if (!model) { + return createModelSelection(entry.instanceId, "", []); } - - const provider = resolveSelectableProvider(providers, null); - const keptSelectedProvider = false; - - // When the provider changed due to fallback (e.g. selected provider was disabled), - // don't carry over the old provider's model — use the fallback provider's default. - const selectedModel = keptSelectedProvider ? selection.model : null; - const model = resolveAppModelSelection(provider, settings, providers, selectedModel); + const provider = entry.driverKind; const { modelOptionsForDispatch } = getComposerProviderState({ provider, model, - models: getProviderModels(providers, provider), - modelOptions: keptSelectedProvider ? selection.options : undefined, + models: entry.models, + modelOptions: selectedEntry ? selection.options : undefined, planModeEnabled: settings.planModeEnabled, }); - return createModelSelection(defaultInstanceIdForDriver(provider), model, modelOptionsForDispatch); + return createModelSelection(entry.instanceId, model, modelOptionsForDispatch); } diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index b64a5e25d508..423b8daf71f8 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -4,9 +4,11 @@ import { applyProviderInstanceSettings, deriveProviderEntriesByEnvironment, deriveProviderInstanceEntries, + filterTextGenerationProviderInstanceEntries, getDefaultProviderInstanceModel, isProviderInstancePickerReady, isProviderInstancePickerVisible, + resolveComposerProviderInstanceEntry, resolveDefaultProviderModelSelection, resolveSelectableProviderInstance, resolveProviderDriverKindForInstanceSelection, @@ -87,6 +89,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([ @@ -291,6 +307,46 @@ describe("resolveSelectableProviderInstance", () => { }); }); +describe("resolveComposerProviderInstanceEntry", () => { + it("falls back from a disabled draft selection to the same ready instance used for dispatch", () => { + const entries = deriveProviderInstanceEntries([ + provider({ + provider: ProviderDriverKind.make("cline"), + instanceId: "cline", + enabled: false, + }), + provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), + ]); + + expect( + resolveComposerProviderInstanceEntry({ + entries, + candidateInstanceIds: ["cline"], + requestedDriverKind: ProviderDriverKind.make("cline"), + lockedProvider: null, + lockedContinuationGroupKey: null, + })?.instanceId, + ).toBe("codex"); + }); + + it("uses the exact selected custom instance instead of its driver's default snapshot", () => { + const entries = deriveProviderInstanceEntries([ + provider({ provider: ProviderDriverKind.make("cline"), instanceId: "cline" }), + provider({ provider: ProviderDriverKind.make("cline"), instanceId: "cline_work" }), + ]); + + expect( + resolveComposerProviderInstanceEntry({ + entries, + candidateInstanceIds: ["cline_work", "cline"], + requestedDriverKind: ProviderDriverKind.make("cline"), + lockedProvider: null, + lockedContinuationGroupKey: null, + })?.instanceId, + ).toBe("cline_work"); + }); +}); + describe("resolveProviderDriverKindForInstanceSelection", () => { it("maps custom provider instance ids back to their driver kind", () => { const providers = [ @@ -420,6 +476,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..6bd1b2b1019a 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. @@ -357,6 +364,46 @@ export function resolveSelectableProviderInstanceEntry( ); } +/** + * Resolve the provider instance targeted by a composer draft. + * + * ChatView and ChatComposer both consume this result: the former derives + * capability banners and send restrictions, while the latter builds the + * actual turn selection. Keeping the candidate and fallback rules here + * prevents those two surfaces from disagreeing after an instance is disabled, + * becomes unavailable, or is constrained by a started thread. + */ +export function resolveComposerProviderInstanceEntry(input: { + readonly entries: ReadonlyArray; + readonly candidateInstanceIds: ReadonlyArray; + readonly requestedDriverKind: ProviderDriverKind; + readonly lockedProvider: ProviderDriverKind | null; + readonly lockedContinuationGroupKey: string | null; +}): ProviderInstanceEntry | undefined { + const isCompatible = (entry: ProviderInstanceEntry) => + (!input.lockedProvider || entry.driverKind === input.lockedProvider) && + (!input.lockedContinuationGroupKey || + entry.continuationGroupKey === input.lockedContinuationGroupKey); + + for (const candidate of input.candidateInstanceIds) { + if (!candidate) continue; + const match = input.entries.find( + (entry) => + entry.instanceId === candidate && entry.enabled && entry.isAvailable && isCompatible(entry), + ); + if (match) return match; + } + + const compatibleEntries = input.entries.filter(isCompatible); + const requestedDriverEntries = compatibleEntries.filter( + (entry) => entry.driverKind === input.requestedDriverKind, + ); + return ( + resolveSelectableProviderInstanceEntry(requestedDriverEntries, undefined) ?? + resolveSelectableProviderInstanceEntry(compatibleEntries, undefined) + ); +} + /** * Resolve the routing key for a selection that may reference an instance * id that no longer exists (e.g. a persisted thread selection after the @@ -383,7 +430,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) {