From 585398d473f553bfadcf4e3636ec810d7c8fcce2 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 14 Aug 2026 20:52:25 +0800 Subject: [PATCH 1/6] fix(cursor): expose native model variants --- src-tauri/src/acp/connection.rs | 97 +++++++++++++++++++++- src/components/chat/message-input.test.tsx | 58 +++++++++++++ src/components/chat/message-input.tsx | 50 ++++++++--- src/i18n/messages/ar.json | 2 + src/i18n/messages/de.json | 2 + src/i18n/messages/en.json | 2 + src/i18n/messages/es.json | 2 + src/i18n/messages/fr.json | 2 + src/i18n/messages/ja.json | 2 + src/i18n/messages/ko.json | 2 + src/i18n/messages/pt.json | 2 + src/i18n/messages/zh-CN.json | 2 + src/i18n/messages/zh-TW.json | 2 + src/lib/model-config-groups.test.ts | 83 ++++++++++++++++++ src/lib/model-config-groups.ts | 42 ++++++++++ 15 files changed, 337 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 174a27b76..e266cb0be 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -2917,6 +2917,7 @@ async fn apply_and_emit_session_config_options( session, state, emitter, + agent_type, preferred_mode_id, preferred_config_values, initial_config_options, @@ -3037,6 +3038,11 @@ fn claude_raw_sdk_session_meta( /// `claude_chunk_parent_tool_use_id`). The adapter checks strictly /// `=== true`, and a pre-0.63 binary ignores the unknown key, so this is /// inert everywhere it isn't understood. +/// - Cursor only: `_meta["parameterizedModelPicker"] = true` — request the +/// native base-model catalog plus model-specific reasoning / thinking / +/// context / fast config options. Cursor checks this key strictly; without +/// it the ACP adapter exposes only one compatibility tuple per base model. +/// Older Cursor builds ignore the extension metadata. fn build_client_capabilities( agent_type: AgentType, host_tools: HostToolsPolicy, @@ -3058,6 +3064,14 @@ fn build_client_capabilities( meta.insert("subagent-transcript".to_string(), serde_json::Value::Bool(true)); client_capabilities = client_capabilities.meta(meta); } + if agent_type == AgentType::Cursor { + let mut meta = serde_json::Map::new(); + meta.insert( + "parameterizedModelPicker".to_string(), + serde_json::Value::Bool(true), + ); + client_capabilities = client_capabilities.meta(meta); + } client_capabilities } @@ -5683,6 +5697,7 @@ async fn apply_preferred_session_options( session: &mut sacp::ActiveSession<'_, Agent>, state: &Arc>, emitter: &EventEmitter, + agent_type: AgentType, preferred_mode_id: Option<&str>, preferred_config_values: &BTreeMap, initial_config_options: Vec, @@ -5706,7 +5721,7 @@ async fn apply_preferred_session_options( let session_id = session.session_id().clone(); let mut options = initial_config_options; - for (config_id, value_id) in preferred_config_values { + for (config_id, value_id) in preferred_config_apply_order(agent_type, preferred_config_values) { // Skip the round-trip when the agent's current value already matches. // Note: codex-acp 1.0.0 advertises "mode" as a config option (so the // match check below normally fires), but we still do NOT skip when a @@ -5737,6 +5752,29 @@ async fn apply_preferred_session_options( options } +/// Cursor's parameterized picker advertises only `model` while Auto is active. +/// Applying a saved model makes Cursor return that model's reasoning / thinking +/// / context / fast options, so model must be the first preference on a new or +/// restored connection. Every other agent retains the BTreeMap's historical +/// stable ordering. +fn preferred_config_apply_order( + agent_type: AgentType, + values: &BTreeMap, +) -> Vec<(&String, &String)> { + let mut ordered = Vec::with_capacity(values.len()); + if agent_type == AgentType::Cursor { + if let Some(entry) = values.get_key_value("model") { + ordered.push(entry); + } + } + ordered.extend( + values + .iter() + .filter(|(key, _)| agent_type != AgentType::Cursor || key.as_str() != "model"), + ); + ordered +} + const TERMINAL_POLL_INTERVAL_MS: u64 = 200; const TERMINAL_POLL_MISSING_LIMIT: u8 = 10; @@ -11387,6 +11425,17 @@ mod tests { assert!(codex.get("elicitation").is_some()); assert!(codex.get("_meta").is_none()); + // Cursor: opt into its parameterized model picker. Without this exact + // capability Cursor falls back to a compatibility catalog that exposes + // only one preselected parameter tuple per base model, hiding the + // model-specific reasoning / thinking / context / fast controls. + let cursor = caps_of(AgentType::Cursor); + assert_eq!( + cursor["_meta"]["parameterizedModelPicker"], + serde_json::Value::Bool(true) + ); + assert!(cursor.get("elicitation").is_none()); + // Everyone else: neither gate; fs + terminal always advertised. let other = caps_of(AgentType::Gemini); assert!(other.get("_meta").is_none()); @@ -11488,6 +11537,30 @@ mod tests { assert!(!permissive.confines_reads()); } + #[test] + fn cursor_saved_model_is_applied_before_its_dynamic_parameters() { + let values = BTreeMap::from([ + ("context".to_string(), "1m".to_string()), + ("fast".to_string(), "true".to_string()), + ("model".to_string(), "provider/model-v1".to_string()), + ("reasoning".to_string(), "extra-high".to_string()), + ]); + + let cursor_ids: Vec<&str> = preferred_config_apply_order(AgentType::Cursor, &values) + .into_iter() + .map(|(id, _)| id.as_str()) + .collect(); + assert_eq!(cursor_ids, ["model", "context", "fast", "reasoning"]); + + // No global behavior change: other agents retain the prior BTreeMap + // ordering even when they happen to expose a `model` config option. + let other_ids: Vec<&str> = preferred_config_apply_order(AgentType::Gemini, &values) + .into_iter() + .map(|(id, _)| id.as_str()) + .collect(); + assert_eq!(other_ids, ["context", "fast", "model", "reasoning"]); + } + #[test] fn claude_raw_sdk_meta_enabled_only_for_claude() { let claude_meta = claude_raw_sdk_session_meta(AgentType::ClaudeCode) @@ -15147,6 +15220,28 @@ mod tests { ); } + #[test] + fn cursor_parameterized_values_reach_the_wire_without_rewriting() { + let cases = [ + ("model", "provider/model-v1"), + ("model", "default"), + ("reasoning", "extra-high"), + ("context", "1m"), + ("fast", "true"), + ]; + + for (config_id, raw_value) in cases { + let req = SetSessionConfigOptionRequest::new( + SessionId::new("cursor-session"), + SessionConfigId::new(config_id), + encode_config_option_value(false, raw_value), + ); + let wire = serde_json::to_value(&req).unwrap(); + assert_eq!(wire["configId"], config_id); + assert_eq!(wire["value"], raw_value); + } + } + #[test] fn boolean_values_carry_a_type_discriminator() { let on = SetSessionConfigOptionRequest::new( diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index 6abd716ec..a1d14b56a 100644 --- a/src/components/chat/message-input.test.tsx +++ b/src/components/chat/message-input.test.tsx @@ -572,6 +572,64 @@ describe("MessageInput collapsed selectors popover", () => { ) }) + it("groups Cursor Auto as the CLI default and commits the raw model value", async () => { + const user = userEvent.setup() + const onConfigOptionChange = vi.fn() + const options = [ + { value: "default", name: "Auto", description: null }, + ...Array.from({ length: 2 }, (_, i) => ({ + value: `cursor-model-id-${i}`, + name: `Account model ${i}`, + description: null, + })), + ] + const cursorModel: SessionConfigOptionInfo = { + id: "model", + name: "Model", + description: null, + category: "model", + kind: { + type: "select", + current_value: "default", + options, + groups: [], + }, + } + const { container } = renderInput({ + agentType: "cursor", + configOptions: [cursorModel], + onConfigOptionChange, + }) + await waitFor(() => + expect(container.querySelector('[role="textbox"]')).not.toBeNull() + ) + + const settingsLabel = enMessages.Folder.chat.messageInput.agentSettings + await user.click(screen.getByRole("button", { name: settingsLabel })) + const popover = await screen.findByRole("dialog", { name: settingsLabel }) + + expect( + within(popover).getByText( + enMessages.Folder.chat.messageInput.cliDefaultSettings + ) + ).toBeInTheDocument() + expect( + within(popover).getAllByText( + enMessages.Folder.chat.messageInput.autoDefault + ).length + ).toBeGreaterThan(0) + + const search = within(popover).getByRole("combobox") + await user.type(search, "Account model 1") + await user.click( + within(popover).getByRole("option", { name: /Account model 1/ }) + ) + expect(onConfigOptionChange).toHaveBeenCalledWith( + "model", + "cursor-model-id-1" + ) + }) + it("selects a mode from the cog Popover and closes it", async () => { const user = userEvent.setup() const onModeChange = vi.fn() diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 73da0539f..17fa9a8e9 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -84,6 +84,7 @@ import { type SessionSelectorSetting, } from "@/components/chat/session-selectors-panel" import { + cursorModelListGroups, deriveModelGroups, isModelConfigOption, modelListGroups, @@ -250,17 +251,31 @@ function SelectorLoadingChip({ label }: { label: string }) { } // Groups for the searchable + virtualized model picker, or `null` when the -// option should keep the lightweight selectors. Only the MODEL option, and only -// when its list is long enough to jank, qualifies. Falls back to a single -// headerless group for a long flat (un-prefixed) list. +// option should keep the lightweight selectors. Cursor's dynamic model catalog +// is always searchable; other agents opt in only above the jank threshold. +// Falls back to a single headerless group for a flat (un-prefixed) list. function modelPickerGroups( - option: SessionConfigOptionInfo + option: SessionConfigOptionInfo, + agentType: AgentType | null | undefined, + cursorLabels: { groupName: string; optionName: string } ): ModelOptionGroup[] | null { if (!isModelConfigOption(option)) return null if (option.kind.type !== "select") return null - if (option.kind.options.length <= MODEL_LIST_VIRTUALIZE_THRESHOLD) return null - // Preserve derived `provider/` groups, server-provided groups, or a flat list - // (never silently flatten server groups — keeps wide/collapsed consistent). + // Cursor always gets the searchable picker: its account/subscription catalog + // can shrink below the generic virtualization threshold, but native model + // discovery must remain searchable regardless of catalog size. + if ( + agentType !== "cursor" && + option.kind.options.length <= MODEL_LIST_VIRTUALIZE_THRESHOLD + ) + return null + // Cursor's dynamic catalog gives its real default sentinel a dedicated group + // while preserving the raw value. Other agents retain their existing groups. + if (agentType === "cursor") { + const cursorGroups = cursorModelListGroups(option, cursorLabels) + if (cursorGroups) return cursorGroups + } + // Preserve derived `provider/` groups, server-provided groups, or a flat list. return modelListGroups(option) } @@ -1349,7 +1364,10 @@ export function MessageInput({ // Long model lists get the searchable + virtualized popover (a Radix // menu of hundreds of items is the scroll jank); every other option — // and short model lists — keep the lightweight inline dropdown. - const listGroups = modelPickerGroups(option) + const listGroups = modelPickerGroups(option, agentType, { + groupName: t("cliDefaultSettings"), + optionName: t("autoDefault"), + }) if (listGroups) { return ( ({ + const displayGroups = + agentType === "cursor" + ? (cursorModelListGroups(option, { + groupName: t("cliDefaultSettings"), + optionName: t("autoDefault"), + }) ?? deriveModelGroups(option)) + : deriveModelGroups(option) + const groups: SessionSelectorGroup[] = displayGroups + ? displayGroups.map((group) => ({ key: group.key, name: group.name, options: group.options.map((item) => ({ @@ -1461,7 +1485,8 @@ export function MessageInput({ // list of hundreds of buttons janks); short lists keep plain buttons. const searchable = isModelConfigOption(option) && - kind.options.length > MODEL_LIST_VIRTUALIZE_THRESHOLD + (agentType === "cursor" || + kind.options.length > MODEL_LIST_VIRTUALIZE_THRESHOLD) result.push({ key: `config:${option.id}`, title: option.name, @@ -1510,6 +1535,7 @@ export function MessageInput({ showModeSelector, availableModes, effectiveModeId, + agentType, onConfigOptionChange, handleModeSelect, t, diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index e671b8c96..2eeed2e91 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2657,6 +2657,8 @@ "toggleOn": "تشغيل", "toggleOff": "إيقاف", "agentSettings": "إعدادات الوكيل", + "cliDefaultSettings": "إعدادات CLI الافتراضية", + "autoDefault": "Auto (default)", "searchModel": "البحث عن النماذج...", "searchModelAria": "البحث عن النماذج", "modelListLabel": "النماذج", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 4259a2357..f618a6775 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2657,6 +2657,8 @@ "toggleOn": "Ein", "toggleOff": "Aus", "agentSettings": "Agent-Einstellungen", + "cliDefaultSettings": "CLI-Standardeinstellungen", + "autoDefault": "Auto (default)", "searchModel": "Modelle suchen...", "searchModelAria": "Modelle suchen", "modelListLabel": "Modelle", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 7573c1ba1..ee8318d18 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2659,6 +2659,8 @@ "toggleOn": "On", "toggleOff": "Off", "agentSettings": "Agent settings", + "cliDefaultSettings": "CLI defaults", + "autoDefault": "Auto (default)", "searchModel": "Search models...", "searchModelAria": "Search models", "modelListLabel": "Models", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 6f669a2cb..1cdb5524e 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2657,6 +2657,8 @@ "toggleOn": "Activado", "toggleOff": "Desactivado", "agentSettings": "Ajustes del agente", + "cliDefaultSettings": "Valores predeterminados de CLI", + "autoDefault": "Auto (default)", "searchModel": "Buscar modelos...", "searchModelAria": "Buscar modelos", "modelListLabel": "Modelos", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 04d07d722..10105fa3c 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2657,6 +2657,8 @@ "toggleOn": "Activé", "toggleOff": "Désactivé", "agentSettings": "Paramètres de l'agent", + "cliDefaultSettings": "Paramètres CLI par défaut", + "autoDefault": "Auto (default)", "searchModel": "Rechercher des modèles...", "searchModelAria": "Rechercher des modèles", "modelListLabel": "Modèles", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 4a41acfb0..0dbf93693 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2657,6 +2657,8 @@ "toggleOn": "オン", "toggleOff": "オフ", "agentSettings": "エージェント設定", + "cliDefaultSettings": "CLI のデフォルト設定", + "autoDefault": "Auto (default)", "searchModel": "モデルを検索...", "searchModelAria": "モデルを検索", "modelListLabel": "モデル", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 1c861273e..9c70aad8a 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2657,6 +2657,8 @@ "toggleOn": "켬", "toggleOff": "끔", "agentSettings": "에이전트 설정", + "cliDefaultSettings": "CLI 기본 설정", + "autoDefault": "Auto (default)", "searchModel": "모델 검색...", "searchModelAria": "모델 검색", "modelListLabel": "모델", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 4768c87f0..baca0f234 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2657,6 +2657,8 @@ "toggleOn": "Ligado", "toggleOff": "Desligado", "agentSettings": "Configurações do agente", + "cliDefaultSettings": "Padrões da CLI", + "autoDefault": "Auto (default)", "searchModel": "Buscar modelos...", "searchModelAria": "Buscar modelos", "modelListLabel": "Modelos", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 54472386a..b9d0a3e93 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2659,6 +2659,8 @@ "toggleOn": "开", "toggleOff": "关", "agentSettings": "智能体设置", + "cliDefaultSettings": "CLI 默认设置", + "autoDefault": "Auto (default)", "searchModel": "搜索模型...", "searchModelAria": "搜索模型", "modelListLabel": "模型", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 874c3b22f..a226cc77e 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2657,6 +2657,8 @@ "toggleOn": "開", "toggleOff": "關", "agentSettings": "智能體設定", + "cliDefaultSettings": "CLI 預設設定", + "autoDefault": "Auto (default)", "searchModel": "搜尋模型...", "searchModelAria": "搜尋模型", "modelListLabel": "模型", diff --git a/src/lib/model-config-groups.test.ts b/src/lib/model-config-groups.test.ts index c698b41a5..e9fb227f7 100644 --- a/src/lib/model-config-groups.test.ts +++ b/src/lib/model-config-groups.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest" import { + cursorModelListGroups, deriveModelGroups, filterModelGroups, flattenModelGroups, @@ -337,6 +338,56 @@ describe("modelListGroups", () => { }) }) +describe("cursorModelListGroups", () => { + it("puts Cursor's real default value in a CLI-default group without rewriting ids", () => { + const option = modelOption([ + opt("default", "Auto"), + opt("gpt-5.3-codex", "Codex 5.3"), + opt("claude-opus-5", "Opus 5"), + ]) + + const groups = cursorModelListGroups(option, { + groupName: "CLI defaults", + optionName: "Auto (default)", + }) + + expect(groups).toEqual([ + { + key: "__cursor_cli_defaults__", + name: "CLI defaults", + options: [ + { value: "default", name: "Auto (default)", description: null }, + ], + }, + { + key: "__cursor_models__", + name: null, + options: [ + { value: "gpt-5.3-codex", name: "Codex 5.3", description: null }, + { value: "claude-opus-5", name: "Opus 5", description: null }, + ], + }, + ]) + }) + + it("recognizes Cursor's compatibility-mode default tuple but leaves other agents alone", () => { + const option = modelOption([ + opt("default[]", "Auto"), + opt("gpt-5.3-codex[reasoning=high,fast=true]", "Codex 5.3 High Fast"), + ]) + const labels = { + groupName: "CLI defaults", + optionName: "Auto (default)", + } + + expect(cursorModelListGroups(option, labels)?.[0].options[0].value).toBe( + "default[]" + ) + expect(modelListGroups(option)[0].name).toBeNull() + expect(modelListGroups(option)[0].options[0].name).toBe("Auto") + }) +}) + describe("filterModelGroups", () => { it("returns the groups unchanged for an empty query", () => { expect(filterModelGroups(SAMPLE_GROUPS, " ")).toBe(SAMPLE_GROUPS) @@ -360,6 +411,38 @@ describe("filterModelGroups", () => { it("returns an empty list when nothing matches", () => { expect(filterModelGroups(SAMPLE_GROUPS, "zzz")).toEqual([]) }) + + it("keeps and searches every distinct Cursor compatibility variant", () => { + const variants: ModelOptionGroup[] = [ + { + key: "cursor", + name: null, + options: [ + opt("base[reasoning=high,fast=true]", "Base High Fast"), + opt("base[context=1m,thinking=true]", "Base 1M Thinking"), + opt("base[reasoning=extra-high]", "Base Extra High"), + opt("private-base", "Private Base (NO ZDR)"), + ], + }, + ] + + expect(flattenModelGroups(variants)).toHaveLength(4) + expect(filterModelGroups(variants, "fast")[0].options[0].value).toBe( + "base[reasoning=high,fast=true]" + ) + expect(filterModelGroups(variants, "thinking")[0].options[0].value).toBe( + "base[context=1m,thinking=true]" + ) + expect(filterModelGroups(variants, "1m")[0].options[0].value).toBe( + "base[context=1m,thinking=true]" + ) + expect(filterModelGroups(variants, "extra high")[0].options[0].value).toBe( + "base[reasoning=extra-high]" + ) + expect(filterModelGroups(variants, "no zdr")[0].options[0].value).toBe( + "private-base" + ) + }) }) describe("flattenModelGroups", () => { diff --git a/src/lib/model-config-groups.ts b/src/lib/model-config-groups.ts index 147ade3bd..37ae7cf10 100644 --- a/src/lib/model-config-groups.ts +++ b/src/lib/model-config-groups.ts @@ -191,6 +191,48 @@ export function modelListGroups( return [{ key: "__all__", name: null, options: kind.options }] } +export interface CursorModelDefaultLabels { + groupName: string + optionName: string +} + +// Cursor's native catalog reserves `default` for "use the CLI default". Older +// compatibility-mode responses spell the same sentinel `default[]`. Keep that +// protocol value byte-for-byte while making its meaning explicit and placing +// it ahead of the dynamic account-specific catalog. This is intentionally a +// Cursor-only presentation helper; it neither invents model entries nor +// changes grouping for any other agent. +export function cursorModelListGroups( + option: SessionConfigOptionInfo, + labels: CursorModelDefaultLabels +): ModelOptionGroup[] | null { + if (!isModelConfigOption(option) || option.kind.type !== "select") return null + if (option.kind.groups.length > 0) return null + + const defaults: SessionConfigSelectOptionInfo[] = [] + const models: SessionConfigSelectOptionInfo[] = [] + for (const item of option.kind.options) { + if (item.value === "default" || item.value === "default[]") { + defaults.push({ ...item, name: labels.optionName }) + } else { + models.push(item) + } + } + if (defaults.length === 0) return null + + const groups: ModelOptionGroup[] = [ + { + key: "__cursor_cli_defaults__", + name: labels.groupName, + options: defaults, + }, + ] + if (models.length > 0) { + groups.push({ key: "__cursor_models__", name: null, options: models }) + } + return groups +} + // Filter a group list by a search query, matching each option's display name OR // its value id (case-insensitive substring). Groups left with no matching option // are dropped. An empty/whitespace query returns the groups unchanged. From c2744ea182c1561d1a374087b57251d555ac463c Mon Sep 17 00:00:00 2001 From: null Date: Fri, 14 Aug 2026 22:32:48 +0800 Subject: [PATCH 2/6] feat(cursor): add composite model picker --- src-tauri/src/acp/connection.rs | 1270 ++++++++++++++++- src-tauri/src/acp/manager.rs | 29 +- src-tauri/src/acp/session_state.rs | 25 +- src-tauri/src/acp/types.rs | 12 + src-tauri/src/commands/acp.rs | 22 + src/components/chat/message-input.test.tsx | 76 + src/contexts/acp-connections-context.test.tsx | 175 ++- src/contexts/acp-connections-context.tsx | 142 +- src/lib/selector-prefs-storage.ts | 13 + 9 files changed, 1712 insertions(+), 52 deletions(-) diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index e266cb0be..d5b463975 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -43,8 +43,8 @@ use crate::acp::terminal_runtime::{ TerminalRuntime, TerminalRuntimeError, TerminalShellRuntimeConfig, }; use crate::acp::types::{ - AcpEvent, AvailableCommandInfo, ConnectionInfo, ConnectionStatus, GrokModelSpec, - PermissionOptionInfo, PlanEntryInfo, PromptCapabilitiesInfo, PromptInputBlock, + AcpEvent, AvailableCommandInfo, ConnectionInfo, ConnectionStatus, CursorCompositeModel, + GrokModelSpec, PermissionOptionInfo, PlanEntryInfo, PromptCapabilitiesInfo, PromptInputBlock, SessionConfigBooleanInfo, SessionConfigKindInfo, SessionConfigOptionInfo, SessionConfigSelectGroupInfo, SessionConfigSelectInfo, SessionConfigSelectOptionInfo, SessionModeInfo, SessionModeStateInfo, ToolCallImageInfo, UserMessageBlock, @@ -300,6 +300,9 @@ pub enum ConnectionCommand { SetConfigOption { config_id: String, value_id: String, + /// Cursor-only monotonic request generation; zero for every other + /// agent. Used to suppress stale completion events after rapid picks. + request_seq: u64, }, GoalControl { action: GoalControlAction, @@ -1268,6 +1271,20 @@ pub async fn spawn_agent_connection( // turn is diagnosed as silently empty. Created here so both the spawn side // and the conversation loop share the same buffer. let stderr_tail = Arc::new(StderrTail::new()); + let cursor_cli_models = if agent_type == AgentType::Cursor { + match crate::commands::acp::cursor_models_for_runtime(&runtime_env).await { + Ok(models) => models, + Err(error) => { + tracing::warn!( + "[ACP][Cursor] native CLI model catalog unavailable; \ + falling back to parameterized controls: {error}" + ); + Vec::new() + } + } + } else { + Vec::new() + }; let agent = build_agent(agent_type, &runtime_env, &launch_cwd, &stderr_tail) .await? .on_spawn({ @@ -1387,6 +1404,7 @@ pub async fn spawn_agent_connection( terminal_shell_config, preferred_mode_id, preferred_config_values, + cursor_cli_models, delegation_injection, fs_policy, host_tools, @@ -2043,6 +2061,391 @@ fn map_session_config_options( .collect() } +const CURSOR_COMPOSITE_VALUE_PREFIX: &str = "__codeg_cursor_composite__:"; +const CURSOR_CURRENT_UNAVAILABLE_VALUE: &str = "__codeg_cursor_current_unavailable__"; + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct CursorAvailableModelCatalog { + value: String, + name: String, + #[serde(default)] + config_options: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct CursorCatalogConfigOption { + id: String, + name: String, + current_value: String, + #[serde(default)] + options: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize)] +struct CursorCatalogValue { + value: String, + name: String, +} + +#[derive(serde::Deserialize)] +struct CursorAvailableModelsResponse { + #[serde(default)] + models: Vec, +} + +fn parse_cursor_available_models( + raw: serde_json::Value, +) -> Result, serde_json::Error> { + serde_json::from_value::(raw).map(|response| response.models) +} + +async fn request_cursor_available_models( + cx: &ConnectionTo, +) -> Result, sacp::Error> { + let request = UntypedMessage::new("cursor/list_available_models", serde_json::json!({})) + .map_err(|error| { + sacp::util::internal_error(format!( + "Failed to build Cursor model-catalog request: {error}" + )) + })?; + let raw = tokio::time::timeout( + std::time::Duration::from_secs(15), + cx.send_request_to(Agent, request).block_task(), + ) + .await + .map_err(|_| sacp::util::internal_error("Cursor model-catalog request timed out"))??; + parse_cursor_available_models(raw).map_err(|error| { + sacp::util::internal_error(format!( + "Failed to parse Cursor model-catalog response: {error}" + )) + }) +} + +/// Cursor's CLI catalog is explicit (one row per valid combination), while its +/// parameterized ACP catalog is structural (base model + legal values). Build a +/// safe intersection without parsing or sending the CLI alias itself: +/// +/// - the longest ACP display-name prefix identifies the base model; +/// - suffix phrases must be names published by that model's ACP options; +/// - boolean parameters use the ACP option name when enabled and are false when +/// absent, matching Cursor's own current CLI formatter; +/// - every leftover non-parenthetical word rejects the row. +/// +/// This is intentionally fail-closed. It never invents a Cartesian product and +/// never derives an ACP value from a CLI slug. +fn build_cursor_composite_catalog( + cli_models: &[crate::acp::types::CursorModelInfo], + available: &[CursorAvailableModelCatalog], +) -> Vec { + let mut result = Vec::new(); + let mut seen_values = HashSet::new(); + + for cli in cli_models { + let label = cli.label.trim(); + if label.is_empty() { + continue; + } + let label_words = cursor_label_words(label, true); + let mut model_candidates: Vec<_> = available + .iter() + .filter(|model| { + let base = cursor_label_words(&model.name, false); + !base.is_empty() && label_words.starts_with(&base) + }) + .collect(); + model_candidates + .sort_by_key(|model| std::cmp::Reverse(cursor_label_words(&model.name, false).len())); + let Some(model) = model_candidates.first().copied() else { + continue; + }; + let base_words = cursor_label_words(&model.name, false); + if model_candidates.iter().skip(1).any(|candidate| { + cursor_label_words(&candidate.name, false).len() == base_words.len() + && candidate.value != model.value + }) { + tracing::debug!( + cli_alias = %cli.id, + cli_label = %cli.label, + "[ACP][Cursor] rejected an ambiguous CLI model base" + ); + continue; + } + let mut suffix: Vec> = label_words[base_words.len()..] + .iter() + .cloned() + .map(Some) + .collect(); + let mut parameters = BTreeMap::new(); + let mut valid = true; + + for config in &model.config_options { + let is_boolean = config.options.len() == 2 + && config.options.iter().any(|option| option.value == "false") + && config.options.iter().any(|option| option.value == "true"); + let selected = if is_boolean { + let phrase = cursor_label_words(&config.name, false); + match cursor_take_unique_phrase(&mut suffix, &[phrase]) { + Ok(Some(_)) => Some("true".to_string()), + Ok(None) => Some("false".to_string()), + Err(()) => None, + } + } else { + let phrases: Vec> = config + .options + .iter() + .map(|option| cursor_label_words(&option.name, false)) + .collect(); + match cursor_take_unique_phrase(&mut suffix, &phrases) { + Ok(Some(index)) => Some(config.options[index].value.clone()), + Ok(None) => config + .options + .iter() + .any(|option| option.value == config.current_value) + .then(|| config.current_value.clone()), + Err(()) => None, + } + }; + let Some(selected) = selected else { + valid = false; + break; + }; + parameters.insert(config.id.clone(), selected); + } + + if !valid || suffix.iter().any(Option::is_some) { + tracing::debug!( + cli_alias = %cli.id, + cli_label = %cli.label, + "[ACP][Cursor] rejected an unmappable CLI model row" + ); + continue; + } + + let value = if model.value == "default" && parameters.is_empty() { + // Keep the real ACP default sentinel so the existing Cursor grouping + // logic can label it `Auto (default)` without another frontend API. + "default".to_string() + } else { + format!("{CURSOR_COMPOSITE_VALUE_PREFIX}{}", cli.id) + }; + if seen_values.insert(value.clone()) { + result.push(CursorCompositeModel { + value, + label: label.to_string(), + model_value: model.value.clone(), + parameters, + }); + } + } + result +} + +/// Lowercase word tokens for strict label matching. Parenthetical suffixes are +/// display-only capability annotations (for example account policy badges), so +/// callers may omit them while mapping; the original label is still displayed. +fn cursor_label_words(input: &str, omit_parenthetical: bool) -> Vec { + let mut words = Vec::new(); + let mut word = String::new(); + let mut depth = 0_u32; + for ch in input.chars() { + if omit_parenthetical { + if ch == '(' { + depth = depth.saturating_add(1); + continue; + } + if ch == ')' && depth > 0 { + depth -= 1; + continue; + } + if depth > 0 { + continue; + } + } + if ch.is_alphanumeric() { + for lower in ch.to_lowercase() { + word.push(lower); + } + } else if !word.is_empty() { + words.push(std::mem::take(&mut word)); + } + } + if !word.is_empty() { + words.push(word); + } + words +} + +/// Consume exactly one of `phrases` from the remaining suffix. Longer phrases +/// win (`Extra High` before `High`); two distinct matching values are ambiguous +/// and therefore rejected. +fn cursor_take_unique_phrase( + suffix: &mut [Option], + phrases: &[Vec], +) -> Result, ()> { + let mut candidates: Vec<(usize, usize, usize)> = Vec::new(); + for (phrase_index, phrase) in phrases.iter().enumerate() { + if phrase.is_empty() || phrase.len() > suffix.len() { + continue; + } + for start in 0..=suffix.len() - phrase.len() { + if phrase.iter().enumerate().all(|(offset, expected)| { + suffix[start + offset].as_deref() == Some(expected.as_str()) + }) { + candidates.push((phrase_index, start, phrase.len())); + } + } + } + candidates.sort_by_key(|(_, _, len)| std::cmp::Reverse(*len)); + let Some(&(selected_index, selected_start, selected_len)) = candidates.first() else { + return Ok(None); + }; + if candidates + .iter() + .take_while(|(_, _, len)| *len == selected_len) + .any(|(index, _, _)| *index != selected_index) + { + return Err(()); + } + for word in &mut suffix[selected_start..selected_start + selected_len] { + *word = None; + } + Ok(Some(selected_index)) +} + +fn cursor_composite_for_current<'a>( + raw_options: &[SessionConfigOptionInfo], + catalog: &'a [CursorCompositeModel], +) -> Option<&'a CursorCompositeModel> { + let model_value = raw_options + .iter() + .find(|option| option.id == "model") + .map(cursor_config_option_current_value)?; + catalog.iter().find(|composite| { + composite.model_value == model_value + && composite.parameters.iter().all(|(id, expected)| { + raw_options + .iter() + .any(|option| option.id == *id && cursor_config_option_holds(option, expected)) + }) + }) +} + +fn cursor_config_option_current_value(option: &SessionConfigOptionInfo) -> &str { + match &option.kind { + SessionConfigKindInfo::Select(select) => select.current_value.as_str(), + SessionConfigKindInfo::Boolean(toggle) => { + if toggle.current_value { + "true" + } else { + "false" + } + } + } +} + +fn cursor_config_option_holds(option: &SessionConfigOptionInfo, expected: &str) -> bool { + cursor_config_option_current_value(option) == expected +} + +fn cursor_composite_is_still_advertised( + composite: &CursorCompositeModel, + raw_options: &[SessionConfigOptionInfo], +) -> bool { + let current_model = raw_options.iter().find_map(|option| { + if option.id != "model" { + return None; + } + match &option.kind { + SessionConfigKindInfo::Select(select) => Some(select.current_value.as_str()), + SessionConfigKindInfo::Boolean(_) => None, + } + }); + if current_model != Some(composite.model_value.as_str()) { + // Cursor only publishes parameter definitions for the selected model; + // choices for other bases remain provisionally available and are + // revalidated after their mandatory model-first request. + return true; + } + composite.parameters.iter().all(|(id, expected)| { + raw_options + .iter() + .any(|option| option.id == *id && cursor_config_option_accepts_info(option, expected)) + }) +} + +fn cursor_config_option_accepts_info(option: &SessionConfigOptionInfo, value: &str) -> bool { + match &option.kind { + SessionConfigKindInfo::Select(select) => select + .options + .iter() + .any(|candidate| candidate.value == value), + SessionConfigKindInfo::Boolean(_) => matches!(value, "true" | "false"), + } +} + +fn synthesize_cursor_composite_options( + raw_options: &[SessionConfigOptionInfo], + catalog: &[CursorCompositeModel], +) -> Vec { + if catalog.is_empty() { + return raw_options.to_vec(); + } + let available_catalog: Vec<&CursorCompositeModel> = catalog + .iter() + .filter(|model| cursor_composite_is_still_advertised(model, raw_options)) + .collect(); + let current = cursor_composite_for_current(raw_options, catalog) + .filter(|model| cursor_composite_is_still_advertised(model, raw_options)); + let parameter_ids: HashSet<&str> = catalog + .iter() + .flat_map(|model| model.parameters.keys().map(String::as_str)) + .collect(); + let model_option = SessionConfigOptionInfo { + id: "model".to_string(), + name: "Model".to_string(), + description: Some("Controls which Cursor CLI model variant is used".to_string()), + category: Some("model".to_string()), + kind: SessionConfigKindInfo::Select(SessionConfigSelectInfo { + current_value: current + .map(|model| model.value.clone()) + .unwrap_or_else(|| CURSOR_CURRENT_UNAVAILABLE_VALUE.to_string()), + options: available_catalog + .into_iter() + .map(|model| SessionConfigSelectOptionInfo { + value: model.value.clone(), + name: model.label.clone(), + description: None, + }) + .chain(current.is_none().then(|| SessionConfigSelectOptionInfo { + value: CURSOR_CURRENT_UNAVAILABLE_VALUE.to_string(), + name: "Current Cursor configuration (not in CLI catalog)".to_string(), + description: None, + })) + .collect(), + groups: Vec::new(), + }), + }; + + let mut visible = Vec::new(); + let mut inserted_model = false; + for option in raw_options { + if option.id == "model" { + if !inserted_model { + visible.push(model_option.clone()); + inserted_model = true; + } + } else if !parameter_ids.contains(option.id.as_str()) { + visible.push(option.clone()); + } + } + if !inserted_model { + visible.push(model_option); + } + visible +} + /// Defensive fallback for Codex's approval-preset selector. /// /// codex-acp 1.0.0 advertises its modes through *both* standard ACP @@ -2110,6 +2513,14 @@ async fn emit_session_config_options_values( agent_type: AgentType, config_options: Vec, ) { + if agent_type == AgentType::Cursor { + { + let mut session = state.write().await; + session.cursor_raw_config_options = Some(config_options); + } + publish_cursor_config_options_if_current(state, emitter).await; + return; + } let mut mapped = map_session_config_options(&config_options); if agent_type == AgentType::Codex { ensure_codex_mode_option(&mut mapped); @@ -2124,6 +2535,36 @@ async fn emit_session_config_options_values( .await; } +async fn publish_cursor_config_options_if_current( + state: &Arc>, + emitter: &EventEmitter, +) { + let visible = { + let session = state.read().await; + if !cursor_config_request_is_current( + session.cursor_config_completed_seq, + session.cursor_config_request_seq, + ) { + return; + } + let Some(raw) = session.cursor_raw_config_options.as_deref() else { + return; + }; + let raw = map_session_config_options(raw); + match session.cursor_composite_models.as_deref() { + Some(catalog) if !catalog.is_empty() => { + synthesize_cursor_composite_options(&raw, catalog) + } + _ => raw, + } + }; + emit_session_config_options_info(state, emitter, visible).await; +} + +fn cursor_config_request_is_current(completed: u64, requested: u64) -> bool { + completed == requested +} + async fn emit_selectors_ready(state: &Arc>, emitter: &EventEmitter) { emit_with_state(state, emitter, AcpEvent::SelectorsReady).await; } @@ -2912,6 +3353,84 @@ async fn apply_and_emit_session_config_options( // No x.ai/sessionConfig (unexpected): fall through to the standard path, // which for Grok emits an empty list (no selectors) — same as before. } + if agent_type == AgentType::Cursor { + let catalog = state + .read() + .await + .cursor_composite_models + .clone() + .unwrap_or_default(); + if !catalog.is_empty() { + let requested_value = preferred_config_values.get("model"); + let is_composite_preference = requested_value.is_some_and(|value| { + value == "default" || value.starts_with(CURSOR_COMPOSITE_VALUE_PREFIX) + }); + let empty_preferences = BTreeMap::new(); + let preferences = if is_composite_preference { + // Old standalone reasoning/context/fast preferences may still + // exist in localStorage. A composite row is authoritative, so + // none of those stale keys may overwrite it after application. + &empty_preferences + } else { + preferred_config_values + }; + let mut updated = apply_preferred_session_options( + cx, + session, + state, + emitter, + agent_type, + preferred_mode_id, + preferences, + initial_config_options, + ) + .await; + + if let Some(requested) = requested_value.filter(|_| is_composite_preference) { + if let Some(target) = catalog.iter().find(|model| model.value == **requested) { + let session_id = session.session_id().clone(); + if let Err(error) = apply_cursor_composite_selection_inner( + cx, + &session_id, + target, + &mut updated, + ) + .await + { + emit_with_state( + state, + emitter, + AcpEvent::Error { + message: format!("Failed to restore Cursor model variant: {error}"), + agent_type: agent_type.to_string(), + code: Some("cursor_model_restore_failed".to_string()), + details: None, + terminal: false, + }, + ) + .await; + } + } else { + emit_with_state( + state, + emitter, + AcpEvent::Error { + message: format!( + "Saved Cursor model variant is no longer available: {requested}" + ), + agent_type: agent_type.to_string(), + code: Some("cursor_model_variant_unavailable".to_string()), + details: None, + terminal: false, + }, + ) + .await; + } + } + emit_session_config_options_values(state, emitter, agent_type, updated).await; + return; + } + } let updated = apply_preferred_session_options( cx, session, @@ -3756,6 +4275,7 @@ async fn run_connection( terminal_shell_config: TerminalShellRuntimeConfig, preferred_mode_id: Option, preferred_config_values: BTreeMap, + cursor_cli_models: Vec, delegation_injection: Option, fs_policy: FsAccessPolicy, host_tools: HostToolsPolicy, @@ -4141,6 +4661,37 @@ async fn run_connection( ) .await; + // Cursor exposes the two complementary halves of its native model + // picker through separate dynamic surfaces: `cursor-agent models` + // lists explicit valid rows in account order, while this ACP method + // publishes base ids plus legal parameter values. Their strict + // intersection is the only catalog Codeg will present. + if agent_type == AgentType::Cursor && !cursor_cli_models.is_empty() { + match request_cursor_available_models(&cx).await { + Ok(available) => { + let catalog = + build_cursor_composite_catalog(&cursor_cli_models, &available); + if catalog.is_empty() { + tracing::warn!( + "[ACP][Cursor] CLI and ACP model catalogs had no safely \ + mappable rows; retaining parameterized controls" + ); + } else { + tracing::info!( + cli_rows = cursor_cli_models.len(), + composite_rows = catalog.len(), + "[ACP][Cursor] built dynamic composite model catalog" + ); + state.write().await.cursor_composite_models = Some(catalog); + } + } + Err(error) => tracing::warn!( + "[ACP][Cursor] cursor/list_available_models unavailable; \ + retaining parameterized controls: {error}" + ), + } + } + let supports_fork = init_resp .agent_capabilities .session_capabilities @@ -5526,19 +6077,34 @@ async fn set_session_config_option( agent_type: AgentType, config_id: String, value_id: String, + request_seq: u64, ) -> Result<(), sacp::Error> { // The whole selector transport carries values as opaque strings; only here, // at the wire, does the option's advertised kind decide how to encode it. - let is_boolean = state - .read() - .await - .config_options - .as_ref() - .and_then(|opts| opts.iter().find(|o| o.id == config_id)) - .is_some_and(|o| matches!(o.kind, SessionConfigKindInfo::Boolean(_))); + let is_boolean = { + let session = state.read().await; + if agent_type == AgentType::Cursor { + session + .cursor_raw_config_options + .as_deref() + .map(map_session_config_options) + .and_then(|options| options.into_iter().find(|option| option.id == config_id)) + .is_some_and(|option| matches!(option.kind, SessionConfigKindInfo::Boolean(_))) + } else { + session + .config_options + .as_ref() + .and_then(|opts| opts.iter().find(|o| o.id == config_id)) + .is_some_and(|o| matches!(o.kind, SessionConfigKindInfo::Boolean(_))) + } + }; let value = encode_config_option_value(is_boolean, &value_id); let updated = set_session_config_option_inner(cx, session_id, config_id.clone(), value).await?; + if agent_type == AgentType::Cursor { + finish_cursor_config_request(state, emitter, request_seq, Some(updated)).await; + return Ok(()); + } // Compare BEFORE emitting: the agent's answer is the only place a request and // its outcome are correlated. Once the option list is broadcast it is // indistinguishable from an unsolicited update. @@ -5546,7 +6112,6 @@ async fn set_session_config_option( config_option_rejection(&map_session_config_options(&updated), &config_id, &value_id) { emit_with_state(state, emitter, rejection).await; - } emit_session_config_options_values(state, emitter, agent_type, updated).await; Ok(()) } @@ -5589,6 +6154,75 @@ fn config_option_rejection( }) } +async fn finish_cursor_config_request( + state: &Arc>, + emitter: &EventEmitter, + request_seq: u64, + raw_options: Option>, +) -> bool { + let is_latest = { + let mut session = state.write().await; + if request_seq >= session.cursor_config_completed_seq { + if let Some(raw_options) = raw_options { + session.cursor_raw_config_options = Some(raw_options); + } + } + session.cursor_config_completed_seq = session.cursor_config_completed_seq.max(request_seq); + request_seq == session.cursor_config_request_seq + }; + if is_latest { + publish_cursor_config_options_if_current(state, emitter).await; + } + is_latest +} + +async fn set_cursor_composite_option( + cx: &ConnectionTo, + session_id: &SessionId, + state: &Arc>, + emitter: &EventEmitter, + value_id: &str, + request_seq: u64, +) -> Result<(), sacp::Error> { + if value_id == CURSOR_CURRENT_UNAVAILABLE_VALUE { + finish_cursor_config_request(state, emitter, request_seq, None).await; + return Ok(()); + } + let (target, mut raw_options) = { + let session = state.read().await; + let target = session + .cursor_composite_models + .as_deref() + .and_then(|catalog| catalog.iter().find(|model| model.value == value_id)) + .cloned() + .ok_or_else(|| { + sacp::util::internal_error(format!( + "Unknown or removed Cursor composite model: {value_id}" + )) + })?; + let raw_options = session + .cursor_raw_config_options + .clone() + .ok_or_else(|| sacp::util::internal_error("Cursor config options are not ready"))?; + (target, raw_options) + }; + + let result = + apply_cursor_composite_selection_inner(cx, session_id, &target, &mut raw_options).await; + if result.is_ok() { + finish_cursor_config_request(state, emitter, request_seq, Some(raw_options)).await; + } else { + // Preserve the last step Cursor actually confirmed. The caller marks + // this generation complete, publishes that snapshot once, and only + // then emits the paired error used by the frontend rollback guard. + let mut session = state.write().await; + if request_seq >= session.cursor_config_completed_seq { + session.cursor_raw_config_options = Some(raw_options); + } + } + result +} + /// Encode a selector value for `session/set_config_option`. /// /// codeg keeps config values as opaque `String`s end to end (Tauri command, web @@ -5644,6 +6278,117 @@ async fn set_session_config_option_inner( Ok(response.config_options) } +fn cursor_parameter_apply_order(parameters: &BTreeMap) -> Vec<(&String, &String)> { + fn priority(id: &str) -> u8 { + match id { + "reasoning" | "effort" => 0, + "context" => 1, + "thinking" => 2, + "fast" => 3, + _ => 4, + } + } + let mut ordered: Vec<_> = parameters.iter().collect(); + ordered.sort_by_key(|(id, _)| (priority(id), id.as_str())); + ordered +} + +fn cursor_composite_wire_plan(target: &CursorCompositeModel) -> Vec<(String, String)> { + let mut plan = Vec::with_capacity(target.parameters.len() + 1); + plan.push(("model".to_string(), target.model_value.clone())); + plan.extend( + cursor_parameter_apply_order(&target.parameters) + .into_iter() + .map(|(id, value)| (id.clone(), value.clone())), + ); + plan +} + +#[cfg(test)] +fn cursor_config_option_accepts( + options: &[SessionConfigOption], + config_id: &str, + value: &str, +) -> bool { + map_session_config_options(options) + .iter() + .any(|option| option.id == config_id && cursor_config_option_accepts_info(option, value)) +} + +fn cursor_advertised_wire_value( + options: &[SessionConfigOption], + config_id: &str, + value: &str, +) -> Option { + let option = map_session_config_options(options) + .into_iter() + .find(|option| option.id == config_id)?; + if !cursor_config_option_accepts_info(&option, value) { + return None; + } + Some(encode_config_option_value( + matches!(option.kind, SessionConfigKindInfo::Boolean(_)), + value, + )) +} + +fn cursor_configuration_matches_target( + options: &[SessionConfigOption], + target: &CursorCompositeModel, +) -> bool { + let mapped = map_session_config_options(options); + mapped.iter().any(|option| { + option.id == "model" && cursor_config_option_holds(option, &target.model_value) + }) && target.parameters.iter().all(|(id, expected)| { + mapped + .iter() + .any(|option| option.id == *id && cursor_config_option_holds(option, expected)) + }) +} + +/// Apply one composite row using only parameterized ACP values. Every request +/// waits for Cursor's response before the next value is validated, because the +/// model request is what publishes that model's dynamic option set. `options` +/// is updated after every successful step so callers can honestly roll the UI +/// back to the last confirmed state on a later failure. +async fn apply_cursor_composite_selection_inner( + cx: &ConnectionTo, + session_id: &SessionId, + target: &CursorCompositeModel, + options: &mut Vec, +) -> Result<(), sacp::Error> { + let Some(model_wire_value) = + cursor_advertised_wire_value(options, "model", &target.model_value) + else { + return Err(sacp::util::internal_error(format!( + "Cursor no longer advertises model value '{}'", + target.model_value + ))); + }; + let plan = cursor_composite_wire_plan(target); + *options = + set_session_config_option_inner(cx, session_id, "model".to_string(), model_wire_value) + .await?; + + for (config_id, value) in plan.iter().skip(1) { + let Some(wire_value) = cursor_advertised_wire_value(options, config_id, value) else { + return Err(sacp::util::internal_error(format!( + "Cursor no longer advertises {config_id}={value} for model '{}'", + target.model_value + ))); + }; + *options = + set_session_config_option_inner(cx, session_id, config_id.clone(), wire_value).await?; + } + if !cursor_configuration_matches_target(options, target) { + return Err(sacp::util::internal_error(format!( + "Cursor did not confirm the complete configuration for '{}'", + target.label + ))); + } + Ok(()) +} + /// Send codex's bespoke `_codex/session/goal_control` extension request to pause /// or clear the session's active goal (codex-acp #293, v1.1.4). Start / resume / /// re-objective are NOT this method — they go through the `/goal` prompt. @@ -7687,8 +8432,28 @@ async fn run_conversation_loop<'a>( Some(ConnectionCommand::SetConfigOption { config_id, value_id, + request_seq, }) => { - let set_result = if agent_type == AgentType::Grok { + let has_composite_catalog = agent_type == AgentType::Cursor + && state + .read() + .await + .cursor_composite_models + .as_ref() + .is_some_and(|catalog| !catalog.is_empty()); + let set_result = if has_composite_catalog + && config_id == "model" + { + set_cursor_composite_option( + &cx, + &sid, + state, + emitter, + &value_id, + request_seq, + ) + .await + } else if agent_type == AgentType::Grok { set_grok_config_option( &cx, &sid, state, emitter, config_id, value_id, ) @@ -7696,24 +8461,41 @@ async fn run_conversation_loop<'a>( } else { set_session_config_option( &cx, &sid, state, emitter, agent_type, config_id, - value_id, + value_id, request_seq, ) .await }; if let Err(e) = set_result { - emit_with_state( - state, - emitter, - AcpEvent::Error { - message: format!("Failed to set config option: {e}"), - agent_type: agent_type.to_string(), - code: None, - details: None, - // Recoverable: just a failed config-option toggle. - terminal: false, - }, - ) - .await; + let latest = if agent_type == AgentType::Cursor { + finish_cursor_config_request( + state, + emitter, + request_seq, + None, + ) + .await + } else { + true + }; + if latest { + emit_with_state( + state, + emitter, + AcpEvent::Error { + message: format!( + "Failed to set config option: {e}" + ), + agent_type: agent_type.to_string(), + code: (agent_type == AgentType::Cursor).then(|| { + "cursor_config_option_failed".to_string() + }), + details: None, + // Recoverable: just a failed config-option toggle. + terminal: false, + }, + ) + .await; + } } } Some(ConnectionCommand::GoalControl { action }) => { @@ -7948,32 +8730,58 @@ async fn run_conversation_loop<'a>( Some(ConnectionCommand::SetConfigOption { config_id, value_id, + request_seq, }) => { let cx = session.connection(); let sid = session.session_id().clone(); - let set_result = if agent_type == AgentType::Grok { + let has_composite_catalog = agent_type == AgentType::Cursor + && state + .read() + .await + .cursor_composite_models + .as_ref() + .is_some_and(|catalog| !catalog.is_empty()); + let set_result = if has_composite_catalog && config_id == "model" { + set_cursor_composite_option(&cx, &sid, state, emitter, &value_id, request_seq) + .await + } else if agent_type == AgentType::Grok { set_grok_config_option(&cx, &sid, state, emitter, config_id, value_id).await } else { set_session_config_option( - &cx, &sid, state, emitter, agent_type, config_id, value_id, + &cx, + &sid, + state, + emitter, + agent_type, + config_id, + value_id, + request_seq, ) .await }; if let Err(e) = set_result { - emit_with_state( - state, - emitter, - AcpEvent::Error { - message: format!("Failed to set config option: {e}"), - agent_type: agent_type.to_string(), - code: None, - details: None, - // Recoverable: idle SetConfigOption failure leaves - // the connection alive. - terminal: false, - }, - ) - .await; + let latest = if agent_type == AgentType::Cursor { + finish_cursor_config_request(state, emitter, request_seq, None).await + } else { + true + }; + if latest { + emit_with_state( + state, + emitter, + AcpEvent::Error { + message: format!("Failed to set config option: {e}"), + agent_type: agent_type.to_string(), + code: (agent_type == AgentType::Cursor) + .then(|| "cursor_config_option_failed".to_string()), + details: None, + // Recoverable: idle SetConfigOption failure leaves + // the connection alive. + terminal: false, + }, + ) + .await; + } } } Some(ConnectionCommand::GoalControl { action }) => { @@ -15358,4 +16166,384 @@ mod tests { strip_unknown_config_options(&mut untyped, "session/new"); assert_eq!(untyped["configOptions"].as_array().unwrap().len(), 1); } + + #[test] + fn cursor_cli_variants_become_structured_single_row_choices() { + let cli_models = vec![ + crate::acp::types::CursorModelInfo { + id: "gpt-5.3-codex-high-fast".into(), + label: "Codex 5.3 High Fast".into(), + is_default: false, + }, + crate::acp::types::CursorModelInfo { + id: "claude-opus-5-thinking-high".into(), + label: "Opus 5 1M Thinking".into(), + is_default: false, + }, + crate::acp::types::CursorModelInfo { + id: "gpt-5.3-codex-xhigh-fast".into(), + label: "Codex 5.3 Extra High Fast".into(), + is_default: false, + }, + crate::acp::types::CursorModelInfo { + id: "fable-5-thinking".into(), + label: "Fable 5 1M Thinking (NO ZDR)".into(), + is_default: false, + }, + // Deliberately not described by the ACP catalog: an explicit CLI + // row must still be rejected when it cannot be mapped safely. + crate::acp::types::CursorModelInfo { + id: "unknown-warp".into(), + label: "Codex 5.3 Max Warp".into(), + is_default: false, + }, + ]; + let raw = serde_json::json!({ + "models": [ + { + "value": "gpt-5.3-codex", + "name": "Codex 5.3", + "configOptions": [ + { + "id": "reasoning", "name": "Reasoning", + "currentValue": "medium", + "options": [ + {"value": "medium", "name": "Medium"}, + {"value": "high", "name": "High"}, + {"value": "extra-high", "name": "Extra High"} + ] + }, + { + "id": "fast", "name": "Fast", + "currentValue": "false", + "options": [ + {"value": "false", "name": "Off"}, + {"value": "true", "name": "Fast"} + ] + } + ] + }, + { + "value": "claude-opus-5", + "name": "Opus 5", + "configOptions": [ + { + "id": "thinking", "name": "Thinking", + "currentValue": "true", + "options": [ + {"value": "false", "name": "Off"}, + {"value": "true", "name": "On"} + ] + }, + { + "id": "context", "name": "Context", + "currentValue": "300k", + "options": [ + {"value": "300k", "name": "300K"}, + {"value": "1m", "name": "1M"} + ] + }, + { + "id": "effort", "name": "Effort", + "currentValue": "high", + "options": [ + {"value": "low", "name": "Low"}, + {"value": "high", "name": "High"} + ] + } + ] + }, + { + "value": "fable-5", + "name": "Fable 5", + "configOptions": [ + { + "id": "thinking", "name": "Thinking", + "currentValue": "true", + "options": [ + {"value": "false", "name": "Off"}, + {"value": "true", "name": "On"} + ] + }, + { + "id": "context", "name": "Context", + "currentValue": "300k", + "options": [ + {"value": "300k", "name": "300K"}, + {"value": "1m", "name": "1M"} + ] + } + ] + } + ] + }); + + let available = parse_cursor_available_models(raw).expect("catalog parses"); + let composites = build_cursor_composite_catalog(&cli_models, &available); + + assert_eq!(composites.len(), 4, "unknown suffixes must fail closed"); + assert_eq!(composites[0].label, "Codex 5.3 High Fast"); + assert_eq!(composites[0].model_value, "gpt-5.3-codex"); + assert_eq!(composites[0].parameters.get("reasoning").unwrap(), "high"); + assert_eq!(composites[0].parameters.get("fast").unwrap(), "true"); + assert_ne!(composites[0].value, cli_models[0].id); + + assert_eq!(composites[1].label, "Opus 5 1M Thinking"); + assert_eq!(composites[1].model_value, "claude-opus-5"); + assert_eq!(composites[1].parameters.get("context").unwrap(), "1m"); + assert_eq!(composites[1].parameters.get("thinking").unwrap(), "true"); + assert_eq!(composites[1].parameters.get("effort").unwrap(), "high"); + + assert_eq!(composites[2].label, "Codex 5.3 Extra High Fast"); + assert_eq!( + composites[2].parameters.get("reasoning").unwrap(), + "extra-high" + ); + assert_eq!(composites[2].parameters.get("fast").unwrap(), "true"); + + assert_eq!(composites[3].label, "Fable 5 1M Thinking (NO ZDR)"); + assert_eq!(composites[3].parameters.get("context").unwrap(), "1m"); + assert_eq!(composites[3].parameters.get("thinking").unwrap(), "true"); + } + + #[test] + fn cursor_ambiguous_display_name_does_not_guess_an_acp_model_id() { + let cli_models = vec![crate::acp::types::CursorModelInfo { + id: "shared-cli-alias".into(), + label: "Shared Model High".into(), + is_default: false, + }]; + let raw = serde_json::json!({ + "models": [ + { + "value": "provider-a-model", "name": "Shared Model", + "configOptions": [{ + "id": "reasoning", "name": "Reasoning", "currentValue": "low", + "options": [{"value": "high", "name": "High"}] + }] + }, + { + "value": "provider-b-model", "name": "Shared Model", + "configOptions": [{ + "id": "reasoning", "name": "Reasoning", "currentValue": "low", + "options": [{"value": "high", "name": "High"}] + }] + } + ] + }); + let available = parse_cursor_available_models(raw).expect("catalog parses"); + assert!(build_cursor_composite_catalog(&cli_models, &available).is_empty()); + } + + fn cursor_wire_options(value: serde_json::Value) -> Vec { + serde_json::from_value(value).expect("Cursor config options parse") + } + + #[test] + fn cursor_composite_plan_is_model_first_and_never_sends_cli_alias() { + let target = CursorCompositeModel { + value: "__codeg_cursor_composite__:cli-flat-alias".into(), + label: "Opus 5 1M Extra High Thinking Fast".into(), + model_value: "claude-opus-5".into(), + parameters: [ + ("fast".into(), "true".into()), + ("thinking".into(), "true".into()), + ("context".into(), "1m".into()), + ("effort".into(), "xhigh".into()), + ] + .into(), + }; + + let plan = cursor_composite_wire_plan(&target); + assert_eq!( + plan, + vec![ + ("model".into(), "claude-opus-5".into()), + ("effort".into(), "xhigh".into()), + ("context".into(), "1m".into()), + ("thinking".into(), "true".into()), + ("fast".into(), "true".into()), + ] + ); + assert!(plan.iter().all(|(_, value)| value != "cli-flat-alias")); + } + + #[test] + fn cursor_parameters_are_validated_only_after_model_options_arrive() { + let before = cursor_wire_options(serde_json::json!([{ + "type": "select", "id": "model", "name": "Model", + "currentValue": "default", + "options": [ + {"value": "default", "name": "Auto"}, + {"value": "gpt-5.3-codex", "name": "Codex 5.3"} + ] + }])); + assert!(cursor_config_option_accepts( + &before, + "model", + "gpt-5.3-codex" + )); + assert!(!cursor_config_option_accepts(&before, "reasoning", "high")); + + let after = cursor_wire_options(serde_json::json!([ + { + "type": "select", "id": "model", "name": "Model", + "currentValue": "gpt-5.3-codex", + "options": [ + {"value": "default", "name": "Auto"}, + {"value": "gpt-5.3-codex", "name": "Codex 5.3"} + ] + }, + { + "type": "select", "id": "reasoning", "name": "Reasoning", + "currentValue": "medium", + "options": [ + {"value": "medium", "name": "Medium"}, + {"value": "high", "name": "High"} + ] + } + ])); + assert!(cursor_config_option_accepts(&after, "reasoning", "high")); + assert!(!cursor_config_option_accepts(&after, "reasoning", "max")); + + let boolean = cursor_wire_options(serde_json::json!([{ + "type": "boolean", "id": "fast", "name": "Fast", + "currentValue": false + }])); + assert_eq!( + cursor_advertised_wire_value(&boolean, "fast", "true") + .expect("advertised boolean") + .as_bool(), + Some(true) + ); + assert!(cursor_advertised_wire_value(&boolean, "fast", "turbo").is_none()); + } + + #[test] + fn cursor_reverse_matches_restored_model_and_parameters() { + let catalog = vec![ + CursorCompositeModel { + value: "default".into(), + label: "Auto".into(), + model_value: "default".into(), + parameters: BTreeMap::new(), + }, + CursorCompositeModel { + value: "__codeg_cursor_composite__:high-fast".into(), + label: "Codex 5.3 High Fast".into(), + model_value: "gpt-5.3-codex".into(), + parameters: [ + ("reasoning".into(), "high".into()), + ("fast".into(), "true".into()), + ] + .into(), + }, + ]; + let raw = cursor_wire_options(serde_json::json!([ + { + "type": "select", "id": "model", "name": "Model", + "currentValue": "gpt-5.3-codex", + "options": [ + {"value": "default", "name": "Auto"}, + {"value": "gpt-5.3-codex", "name": "Codex 5.3"} + ] + }, + { + "type": "select", "id": "reasoning", "name": "Reasoning", + "currentValue": "high", + "options": [{"value": "high", "name": "High"}] + }, + { + "type": "select", "id": "fast", "name": "Fast", + "currentValue": "true", + "options": [ + {"value": "false", "name": "Off"}, + {"value": "true", "name": "Fast"} + ] + } + ])); + let mapped = map_session_config_options(&raw); + let restored = cursor_composite_for_current(&mapped, &catalog).expect("restored row"); + assert_eq!(restored.label, "Codex 5.3 High Fast"); + + let visible = synthesize_cursor_composite_options(&mapped, &catalog); + assert_eq!( + visible.len(), + 1, + "parameter controls are folded into one row" + ); + match &visible[0].kind { + SessionConfigKindInfo::Select(select) => { + assert_eq!(select.current_value, catalog[1].value); + assert_eq!(select.options[0].value, "default"); + assert_eq!(select.options[0].name, "Auto"); + } + other => panic!("expected composite select, got {other:?}"), + } + } + + #[test] + fn cursor_removed_parameter_invalidates_only_the_affected_current_rows() { + let catalog = vec![ + CursorCompositeModel { + value: "__codeg_cursor_composite__:high".into(), + label: "Codex 5.3 High".into(), + model_value: "gpt-5.3-codex".into(), + parameters: [("reasoning".into(), "high".into())].into(), + }, + CursorCompositeModel { + value: "__codeg_cursor_composite__:high-fast".into(), + label: "Codex 5.3 High Fast".into(), + model_value: "gpt-5.3-codex".into(), + parameters: [ + ("reasoning".into(), "high".into()), + ("fast".into(), "true".into()), + ] + .into(), + }, + ]; + let raw = cursor_wire_options(serde_json::json!([ + { + "type": "select", "id": "model", "name": "Model", + "currentValue": "gpt-5.3-codex", + "options": [{"value": "gpt-5.3-codex", "name": "Codex 5.3"}] + }, + { + "type": "select", "id": "reasoning", "name": "Reasoning", + "currentValue": "high", + "options": [{"value": "high", "name": "High"}] + } + ])); + let visible = + synthesize_cursor_composite_options(&map_session_config_options(&raw), &catalog); + match &visible[0].kind { + SessionConfigKindInfo::Select(select) => { + assert_eq!(select.current_value, catalog[0].value); + assert_eq!(select.options.len(), 1); + assert_eq!(select.options[0].name, "Codex 5.3 High"); + } + other => panic!("expected composite select, got {other:?}"), + } + } + + #[test] + fn cursor_auto_plan_restores_only_the_real_default_model_value() { + let target = CursorCompositeModel { + value: "default".into(), + label: "Auto".into(), + model_value: "default".into(), + parameters: BTreeMap::new(), + }; + assert_eq!( + cursor_composite_wire_plan(&target), + vec![("model".into(), "default".into())] + ); + } + + #[test] + fn cursor_request_generation_rejects_stale_completions() { + assert!(!cursor_config_request_is_current(1, 2)); + assert!(cursor_config_request_is_current(2, 2)); + assert!(!cursor_config_request_is_current(3, 2)); + } } diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index 743eec204..0c4e4ae4a 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -1222,17 +1222,42 @@ impl ConnectionManager { config_id: String, value_id: String, ) -> Result<(), AcpError> { - let cmd_tx = { + let (cmd_tx, state, agent_type) = { let connections = self.connections.lock().await; let conn = connections .get(conn_id) .ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?; - conn.cmd_tx.clone() + ( + conn.cmd_tx.clone(), + Arc::clone(&conn.state), + conn.agent_type, + ) }; + if agent_type == AgentType::Cursor { + // Keep sequence assignment and channel insertion in one critical + // section. Concurrent API calls can otherwise acquire a sequence + // in one order but reach the command queue in another. + let enqueue_lock = state.read().await.cursor_config_enqueue_lock.clone(); + let _enqueue_guard = enqueue_lock.lock_owned().await; + let mut session = state.write().await; + session.cursor_config_request_seq = session.cursor_config_request_seq.saturating_add(1); + let request_seq = session.cursor_config_request_seq; + drop(session); + cmd_tx + .send(ConnectionCommand::SetConfigOption { + config_id, + value_id, + request_seq, + }) + .await + .map_err(|_| AcpError::ProcessExited)?; + return Ok(()); + } cmd_tx .send(ConnectionCommand::SetConfigOption { config_id, value_id, + request_seq: 0, }) .await .map_err(|_| AcpError::ProcessExited) diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index 19da9ebf0..cceada9ed 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -14,9 +14,9 @@ use crate::acp::feedback::{FeedbackItem, FeedbackStatus}; use crate::acp::plan_approval::PendingPlanApprovalState; use crate::acp::question::PendingQuestionState; use crate::acp::types::{ - AcpEvent, AvailableCommandInfo, ConfigStaleKind, ConnectionStatus, EventEnvelope, - GrokModelSpec, PromptCapabilitiesInfo, SessionConfigOptionInfo, SessionModeStateInfo, - ToolCallImageInfo, + AcpEvent, AvailableCommandInfo, ConfigStaleKind, ConnectionStatus, CursorCompositeModel, + EventEnvelope, GrokModelSpec, PromptCapabilitiesInfo, SessionConfigOptionInfo, + SessionModeStateInfo, ToolCallImageInfo, }; use crate::models::agent::AgentType; use crate::models::message::MessageRole; @@ -323,6 +323,20 @@ pub struct SessionState { pub modes: Option, pub current_mode: Option, pub config_options: Option>, + /// Cursor only: the parameterized ACP options in their original shape. + /// `config_options` above carries the one-row composite picker shown to the + /// frontend, so wire validation and reverse matching must use this copy. + pub cursor_raw_config_options: Option>, + /// Cursor only: explicit rows obtained by intersecting `cursor-agent + /// models` with `cursor/list_available_models`. Selector values are local + /// opaque keys; each row retains its exact ACP base model + parameters. + pub cursor_composite_models: Option>, + /// Monotonic request bookkeeping for Cursor composite switches. A newer + /// queued selection suppresses stale completion/update events from an + /// older request, preventing rapid clicks from snapping the UI backward. + pub cursor_config_enqueue_lock: Arc>, + pub cursor_config_request_seq: u64, + pub cursor_config_completed_seq: u64, /// Grok only: per-model reasoning-effort specs, parsed from the top-level /// `models` of the session-establishment response (guaranteed on /// `session/new`; opportunistic on resume/fork). Grok never re-sends this on @@ -501,6 +515,11 @@ impl SessionState { modes: None, current_mode: None, config_options: None, + cursor_raw_config_options: None, + cursor_composite_models: None, + cursor_config_enqueue_lock: Arc::new(tokio::sync::Mutex::new(())), + cursor_config_request_seq: 0, + cursor_config_completed_seq: 0, grok_model_specs: None, prompt_capabilities: None, fork_supported: false, diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index 0106935c6..aa27f40ea 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -1107,6 +1107,18 @@ pub struct CursorModelsResult { pub error: Option, } +/// One Codeg-visible Cursor model row backed by Cursor's parameterized ACP +/// protocol. `value` is a Codeg-local selector key (never an ACP model id), +/// while `model_value` and `parameters` are the exact values Cursor advertised. +/// Backend-internal: the frontend receives only the synthesized select option. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CursorCompositeModel { + pub value: String, + pub label: String, + pub model_value: String, + pub parameters: std::collections::BTreeMap, +} + /// Lightweight status info for a single agent, used by connect() pre-check. #[derive(Debug, Clone, Serialize)] pub struct AcpAgentStatus { diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 5cdd88e0d..e489ce958 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -8418,6 +8418,28 @@ fn parse_cursor_models(stdout: &str) -> (Vec (models, default_model) } +/// Read the same dynamic Cursor model directory used by the native CLI picker, +/// but with the already-resolved per-connection environment. This is consumed +/// by the ACP connection to build display-only composite rows; none of the CLI +/// aliases returned here are sent to parameterized ACP sessions. +pub(crate) async fn cursor_models_for_runtime( + runtime_env: &BTreeMap, +) -> Result, String> { + let mut probe_env = runtime_env.clone(); + // Match the spawned Cursor process's subscription policy: an inherited API + // key must not hijack a browser-login session. Empty means env_remove in + // run_cursor_probe. + if runtime_env.get("CURSOR_AUTH_MODE").map(String::as_str) == Some("subscription") + && !runtime_env + .get("CURSOR_API_KEY") + .is_some_and(|value| !value.trim().is_empty()) + { + probe_env.insert("CURSOR_API_KEY".to_string(), String::new()); + } + let stdout = run_cursor_probe(&["models"], 30, &probe_env).await?; + Ok(parse_cursor_models(&stdout).0) +} + /// Strip ANSI SGR escape sequences from CLI output. fn strip_ansi(input: &str) -> String { let mut out = String::with_capacity(input.len()); diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index a1d14b56a..53cf31f8c 100644 --- a/src/components/chat/message-input.test.tsx +++ b/src/components/chat/message-input.test.tsx @@ -630,6 +630,82 @@ describe("MessageInput collapsed selectors popover", () => { ) }) + it("shows Cursor parameter combinations as searchable single-row choices", async () => { + const user = userEvent.setup() + const onConfigOptionChange = vi.fn() + const composite = (alias: string) => `__codeg_cursor_composite__:${alias}` + const cursorModel: SessionConfigOptionInfo = { + id: "model", + name: "Model", + description: null, + category: "model", + kind: { + type: "select", + current_value: composite("gpt-5.3-codex-high-fast"), + options: [ + { value: "default", name: "Auto", description: null }, + { + value: composite("gpt-5.3-codex-high-fast"), + name: "Codex 5.3 High Fast", + description: null, + }, + { + value: composite("gpt-5.3-codex-xhigh-fast"), + name: "Codex 5.3 Extra High Fast", + description: null, + }, + { + value: composite("claude-opus-5-thinking"), + name: "Opus 5 1M Thinking", + description: null, + }, + { + value: composite("fable-5-thinking"), + name: "Fable 5 1M Thinking (NO ZDR)", + description: null, + }, + ], + groups: [], + }, + } + const { container } = renderInput({ + agentType: "cursor", + configOptions: [cursorModel], + onConfigOptionChange, + }) + await waitFor(() => + expect(container.querySelector('[role="textbox"]')).not.toBeNull() + ) + + const settingsLabel = enMessages.Folder.chat.messageInput.agentSettings + await user.click(screen.getByRole("button", { name: settingsLabel })) + const popover = await screen.findByRole("dialog", { name: settingsLabel }) + const search = within(popover).getByRole("combobox") + + await user.type(search, "1m thinking") + expect( + within(popover).getByRole("option", { name: /Opus 5 1M Thinking/ }) + ).toBeInTheDocument() + await user.clear(search) + await user.type(search, "no zdr") + expect( + within(popover).getByRole("option", { + name: /Fable 5 1M Thinking \(NO ZDR\)/, + }) + ).toBeInTheDocument() + await user.clear(search) + await user.type(search, "extra high fast") + await user.click( + within(popover).getByRole("option", { + name: /Codex 5.3 Extra High Fast/, + }) + ) + expect(onConfigOptionChange).toHaveBeenCalledWith( + "model", + composite("gpt-5.3-codex-xhigh-fast") + ) + }) + it("selects a mode from the cog Popover and closes it", async () => { const user = userEvent.setup() const onModeChange = vi.fn() diff --git a/src/contexts/acp-connections-context.test.tsx b/src/contexts/acp-connections-context.test.tsx index 4e6d523ee..e0f0a5f72 100644 --- a/src/contexts/acp-connections-context.test.tsx +++ b/src/contexts/acp-connections-context.test.tsx @@ -9,7 +9,10 @@ import { } from "@/contexts/acp-connections-context" import { parsePermissionToolCall } from "@/lib/permission-request" import { subscribe } from "@/lib/platform" -import { saveConfigPreference } from "@/lib/selector-prefs-storage" +import { + clearConfigPreference, + saveConfigPreference, +} from "@/lib/selector-prefs-storage" import type { AttachHandlers } from "@/lib/transport/types" import type { EventEnvelope, @@ -40,6 +43,7 @@ const h = vi.hoisted(() => { acpConnect: vi.fn(), acpDisconnect: vi.fn(), acpGetSessionSnapshot: vi.fn(), + acpSetConfigOption: vi.fn(), buildDelegationSeedEnvelopes: vi.fn(() => []), denormalizeSnapshot: vi.fn(), // Stable across renders so tests can assert on what the error handler @@ -83,6 +87,7 @@ vi.mock("@/lib/selector-prefs-storage", () => ({ getSavedPrefsForConnect: () => ({ modeId: undefined, configValues: {} }), saveModePreference: vi.fn(), saveConfigPreference: vi.fn(), + clearConfigPreference: vi.fn(), })) vi.mock("@/lib/snapshot-denormalize", () => ({ @@ -97,7 +102,7 @@ vi.mock("@/lib/api", () => ({ acpGetSessionSnapshot: h.acpGetSessionSnapshot, acpPrompt: vi.fn(), acpSetMode: vi.fn(), - acpSetConfigOption: vi.fn(), + acpSetConfigOption: h.acpSetConfigOption, acpCancel: vi.fn(), acpRespondPermission: vi.fn(), acpTouchConnection: vi.fn(), @@ -144,6 +149,7 @@ beforeEach(() => { h.acpConnect.mockReset() h.acpDisconnect.mockReset() h.acpGetSessionSnapshot.mockReset() + h.acpSetConfigOption.mockReset() h.denormalizeSnapshot.mockReset() h.denormalizeSnapshot.mockReturnValue({ connectionId: "owner-conn", @@ -177,6 +183,7 @@ beforeEach(() => { h.acpConnect.mockResolvedValue("spawned-conn") h.acpDisconnect.mockResolvedValue(undefined) h.acpGetSessionSnapshot.mockResolvedValue(null) + h.acpSetConfigOption.mockResolvedValue(undefined) }) function latestAttachHandlers(): AttachHandlers { @@ -1990,6 +1997,170 @@ describe("AcpConnectionsProvider Grok cross-agent-type model switch", () => { }) }) +describe("AcpConnectionsProvider Cursor composite model switching", () => { + const LOW = "__codeg_cursor_composite__:gpt-low" + const HIGH_FAST = "__codeg_cursor_composite__:gpt-high-fast" + const EXTRA_HIGH = "__codeg_cursor_composite__:gpt-xhigh" + + function cursorCompositeOptions(current: string): SessionConfigOptionInfo[] { + return [ + { + id: "model", + name: "Model", + category: "model", + kind: { + type: "select", + current_value: current, + options: [ + { value: "default", name: "Auto" }, + { value: LOW, name: "Codex 5.3 Low" }, + { value: HIGH_FAST, name: "Codex 5.3 High Fast" }, + { value: EXTRA_HIGH, name: "Codex 5.3 Extra High" }, + ], + groups: [], + }, + }, + ] + } + + async function connectCursorOwner(): Promise { + h.acpGetAgentStatus.mockResolvedValue({ + agent_type: "cursor", + enabled: true, + available: true, + installed_version: "2026.08.11-e8db854", + is_acp_adapter: false, + }) + await mountProvider() + await act(async () => { + await h.actions!.connect(TAB, "cursor", "/tmp/x", "sess-1") + }) + return latestAttachHandlers() + } + + it("does not let an older completion overwrite a rapid newer choice", async () => { + const handlers = await connectCursorOwner() + emitAcpEvent(handlers, { + seq: 1, + connection_id: "spawned-conn", + type: "session_config_options", + config_options: cursorCompositeOptions(LOW), + }) + + await act(async () => { + await h.actions!.setConfigOption(TAB, "model", HIGH_FAST) + await h.actions!.setConfigOption(TAB, "model", EXTRA_HIGH) + }) + expect( + h.store!.getConnection(TAB)!.configOptions?.[0]?.kind.current_value + ).toBe(EXTRA_HIGH) + + // Completion for the first click arrives after the second optimistic pick. + emitAcpEvent(handlers, { + seq: 2, + connection_id: "spawned-conn", + type: "session_config_options", + config_options: cursorCompositeOptions(HIGH_FAST), + }) + expect( + h.store!.getConnection(TAB)!.configOptions?.[0]?.kind.current_value + ).toBe(EXTRA_HIGH) + + emitAcpEvent(handlers, { + seq: 3, + connection_id: "spawned-conn", + type: "session_config_options", + config_options: cursorCompositeOptions(EXTRA_HIGH), + }) + expect( + h.store!.getConnection(TAB)!.configOptions?.[0]?.kind.current_value + ).toBe(EXTRA_HIGH) + }) + + it("rolls back the optimistic row when a composite parameter fails", async () => { + const handlers = await connectCursorOwner() + emitAcpEvent(handlers, { + seq: 1, + connection_id: "spawned-conn", + type: "session_config_options", + config_options: cursorCompositeOptions(LOW), + }) + + await act(async () => { + await h.actions!.setConfigOption(TAB, "model", HIGH_FAST) + }) + emitAcpEvent(handlers, { + seq: 2, + connection_id: "spawned-conn", + type: "session_config_options", + config_options: cursorCompositeOptions(LOW), + }) + // The rollback is held until the paired error proves the newest request + // failed; this same guard is what suppresses a stale older completion. + expect( + h.store!.getConnection(TAB)!.configOptions?.[0]?.kind.current_value + ).toBe(HIGH_FAST) + + emitAcpEvent(handlers, { + seq: 3, + connection_id: "spawned-conn", + type: "error", + message: "Failed to set config option: fast was removed", + agent_type: "cursor", + }) + expect( + h.store!.getConnection(TAB)!.configOptions?.[0]?.kind.current_value + ).toBe(LOW) + expect(saveConfigPreference).toHaveBeenLastCalledWith( + "cursor", + "model", + LOW + ) + }) + + it("rolls back immediately when the config request cannot be queued", async () => { + const handlers = await connectCursorOwner() + emitAcpEvent(handlers, { + seq: 1, + connection_id: "spawned-conn", + type: "session_config_options", + config_options: cursorCompositeOptions(LOW), + }) + h.acpSetConfigOption.mockRejectedValueOnce(new Error("connection gone")) + + let failure: unknown + await act(async () => { + try { + await h.actions!.setConfigOption(TAB, "model", HIGH_FAST) + } catch (error) { + failure = error + } + }) + expect(failure).toEqual(new Error("connection gone")) + expect( + h.store!.getConnection(TAB)!.configOptions?.[0]?.kind.current_value + ).toBe(LOW) + expect(saveConfigPreference).toHaveBeenLastCalledWith( + "cursor", + "model", + LOW + ) + }) + + it("clears a restored composite preference removed by the current catalog", async () => { + const handlers = await connectCursorOwner() + emitAcpEvent(handlers, { + seq: 1, + connection_id: "spawned-conn", + type: "error", + message: + "Saved Cursor model variant is no longer available: old-composite", + agent_type: "cursor", + }) + expect(clearConfigPreference).toHaveBeenCalledWith("cursor", "model") + }) +}) + describe("empty-turn error diagnostics", () => { async function connectOwner(): Promise { await mountProvider() diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index 91da4d8bb..017709911 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -81,6 +81,7 @@ import { withEventSoundsSuppressed, } from "@/lib/notification-sound" import { + clearConfigPreference, getSavedPrefsForConnect, saveModePreference, saveConfigPreference, @@ -2714,6 +2715,24 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { // the alert each time. const alertedErrorDetailsRef = useRef(new Map()) + // Cursor composite picks are optimistic in the UI but multi-step on the ACP + // wire. Keep the newest desired row until the backend publishes that exact + // value. An older completion that races a rapid second click is deferred; if + // the newest request fails, the following error releases the backend's last + // confirmed snapshot instead of leaving a falsely-successful highlight. + const pendingCursorConfigRef = useRef( + new Map() + ) + const deferredCursorConfigRef = useRef( + new Map() + ) + const confirmedCursorModelRef = useRef(new Map()) + const clearCursorConfigTracking = useCallback((contextKey: string) => { + pendingCursorConfigRef.current.delete(contextKey) + deferredCursorConfigRef.current.delete(contextKey) + confirmedCursorModelRef.current.delete(contextKey) + }, []) + // contextKey → active EventStream subscription handle. Populated only for // connections established via the Subscribe-with-Snapshot attach // protocol (web + remote-desktop). Used to (a) detach on disconnect / @@ -3501,6 +3520,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { break case "session_started": flushStreamingQueue() + clearCursorConfigTracking(contextKey) dispatch({ type: "SESSION_STARTED", contextKey, @@ -3557,12 +3577,44 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { flushStreamingQueue() // Same as `session_modes`: backend already merged saved prefs // into `current_value` before emitting. + const cfgConn = storeRef.current.connections.get(contextKey) + const pending = pendingCursorConfigRef.current.get(contextKey) + if (cfgConn?.agentType === "cursor" && pending) { + const incoming = e.config_options.find( + (option) => option.id === pending.configId + ) + const incomingValue = + incoming?.kind.type === "select" + ? incoming.kind.current_value + : undefined + if (incomingValue !== pending.valueId) { + deferredCursorConfigRef.current.set(contextKey, e.config_options) + break + } + pendingCursorConfigRef.current.delete(contextKey) + deferredCursorConfigRef.current.delete(contextKey) + } + if (cfgConn?.agentType === "cursor") { + const confirmedModel = e.config_options.find( + (option) => option.id === "model" + ) + if ( + confirmedModel?.kind.type === "select" && + !confirmedModel.kind.current_value.startsWith( + "__codeg_cursor_current_unavailable__" + ) + ) { + confirmedCursorModelRef.current.set( + contextKey, + confirmedModel.kind.current_value + ) + } + } dispatch({ type: "SESSION_CONFIG_OPTIONS", contextKey, configOptions: e.config_options, }) - const cfgConn = storeRef.current.connections.get(contextKey) if (cfgConn) { const entry = selectorsCache.get(cfgConn.agentType) ?? { modes: null, @@ -3717,6 +3769,48 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { case "error": { flushStreamingQueue() const nc = storeRef.current.connections.get(contextKey) + const cursorConfigFailure = + e.code === "cursor_config_option_failed" || + e.code === "cursor_model_restore_failed" || + e.code === "cursor_model_variant_unavailable" || + e.message.startsWith("Failed to set config option:") || + e.message.startsWith("Failed to restore Cursor model variant:") || + e.message.startsWith( + "Saved Cursor model variant is no longer available:" + ) + if (nc?.agentType === "cursor" && cursorConfigFailure) { + pendingCursorConfigRef.current.delete(contextKey) + const confirmed = deferredCursorConfigRef.current.get(contextKey) + deferredCursorConfigRef.current.delete(contextKey) + if (confirmed) { + dispatch({ + type: "SESSION_CONFIG_OPTIONS", + contextKey, + configOptions: confirmed, + }) + const entry = selectorsCache.get(nc.agentType) ?? { + modes: null, + configOptions: null, + } + entry.configOptions = confirmed + selectorsCache.set(nc.agentType, entry) + } + const confirmedValue = confirmed?.find( + (option) => option.id === "model" + )?.kind + const persistedValue = + confirmedValue?.type === "select" && + !confirmedValue.current_value.startsWith( + "__codeg_cursor_current_unavailable__" + ) + ? confirmedValue.current_value + : confirmedCursorModelRef.current.get(contextKey) + if (persistedValue) { + saveConfigPreference("cursor", "model", persistedValue) + } else { + clearConfigPreference("cursor", "model") + } + } const agentLabel = nc ? getAgentLabel(nc.agentType) : (e.agent_type as string) @@ -3892,6 +3986,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { } }, [ + clearCursorConfigTracking, dispatch, enqueueStreamingAction, flushPendingToolCallUpdates, @@ -4914,6 +5009,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { reverseMapRef.current.delete(conn.connectionId) pendingUnmappedEventsRef.current.delete(conn.connectionId) lastActivityRef.current.delete(contextKey) + clearCursorConfigTracking(contextKey) dispatch({ type: "CONNECTION_REMOVED", contextKey }) return true } @@ -4940,10 +5036,16 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { teardownAttachSubscription(contextKey) lastActivityRef.current.delete(contextKey) pendingUnmappedEventsRef.current.delete(conn.connectionId) + clearCursorConfigTracking(contextKey) dispatch({ type: "CONNECTION_REMOVED", contextKey }) return tornDown }, - [captureIdentityBeforeRemoval, dispatch, teardownAttachSubscription] + [ + captureIdentityBeforeRemoval, + clearCursorConfigTracking, + dispatch, + teardownAttachSubscription, + ] ) // Lifecycle release for a surface that vanished on its own — currently the @@ -5135,6 +5237,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { reverseMapRef.current.delete(conn.connectionId) teardownAttachSubscription(contextKey) pendingUnmappedEventsRef.current.delete(conn.connectionId) + clearCursorConfigTracking(contextKey) } lastActivityRef.current.clear() // Context keys are reused across backends, so a surviving entry here would @@ -5145,7 +5248,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { lastConnectParamsRef.current.clear() await Promise.all(promises) dispatch({ type: "REMOVE_ALL" }) - }, [dispatch, teardownAttachSubscription]) + }, [clearCursorConfigTracking, dispatch, teardownAttachSubscription]) const sendPrompt = useCallback( async ( @@ -5191,6 +5294,11 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { async (contextKey: string, configId: string, valueId: string) => { const conn = storeRef.current.connections.get(contextKey) if (!conn) return + const previousConfigOptions = conn.configOptions + if (conn.agentType === "cursor" && configId === "model") { + pendingCursorConfigRef.current.set(contextKey, { configId, valueId }) + deferredCursorConfigRef.current.delete(contextKey) + } dispatch({ type: "CONFIG_OPTION_CHANGED", contextKey, @@ -5201,7 +5309,33 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { // can ship it back to the backend as a preferred config value. saveConfigPreference(conn.agentType, configId, valueId) lastActivityRef.current.set(contextKey, Date.now()) - await acpSetConfigOption(conn.connectionId, configId, valueId) + try { + await acpSetConfigOption(conn.connectionId, configId, valueId) + } catch (error) { + const pending = pendingCursorConfigRef.current.get(contextKey) + if ( + conn.agentType === "cursor" && + configId === "model" && + pending?.valueId === valueId + ) { + pendingCursorConfigRef.current.delete(contextKey) + deferredCursorConfigRef.current.delete(contextKey) + if (previousConfigOptions) { + dispatch({ + type: "SESSION_CONFIG_OPTIONS", + contextKey, + configOptions: previousConfigOptions, + }) + } + const confirmed = confirmedCursorModelRef.current.get(contextKey) + if (confirmed) { + saveConfigPreference("cursor", "model", confirmed) + } else { + clearConfigPreference("cursor", "model") + } + } + throw error + } }, [dispatch] ) diff --git a/src/lib/selector-prefs-storage.ts b/src/lib/selector-prefs-storage.ts index 6c20368a1..847d9c84a 100644 --- a/src/lib/selector-prefs-storage.ts +++ b/src/lib/selector-prefs-storage.ts @@ -122,3 +122,16 @@ export function saveConfigPreference( configValues: { ...prefs.configValues, [configId]: valueId }, })) } + +/** Drop one stale config preference after the agent rejects it. */ +export function clearConfigPreference(agentType: string, configId: string) { + updatePrefs(agentType, (prefs) => { + const configValues = { ...prefs.configValues } + delete configValues[configId] + return { + ...prefs, + configValues: + Object.keys(configValues).length > 0 ? configValues : undefined, + } + }) +} From 22973b132486bee4cf9047674184e81fa7715e0b Mon Sep 17 00:00:00 2001 From: null Date: Sat, 15 Aug 2026 04:33:03 +0800 Subject: [PATCH 3/6] fix(cursor): harden composite model application --- src-tauri/src/acp/connection.rs | 910 +++++++++++++++--- src-tauri/src/acp/manager.rs | 11 +- src-tauri/src/acp/session_state.rs | 4 +- src-tauri/src/acp/types.rs | 43 +- src-tauri/src/commands/acp.rs | 774 +++++++++++++-- src-tauri/src/web/handlers/acp.rs | 15 +- src/components/chat/message-input.test.tsx | 8 +- src/components/chat/message-input.tsx | 1 + src/components/chat/model-option-picker.tsx | 13 +- .../chat/session-selectors-panel.tsx | 10 +- .../settings/cursor-config-panel.test.tsx | 18 + .../settings/cursor-config-panel.tsx | 31 +- src/contexts/acp-connections-context.test.tsx | 316 +++++- src/contexts/acp-connections-context.tsx | 268 ++++-- src/i18n/messages/ar.json | 11 + src/i18n/messages/de.json | 11 + src/i18n/messages/en.json | 11 + src/i18n/messages/es.json | 11 + src/i18n/messages/fr.json | 11 + src/i18n/messages/ja.json | 11 + src/i18n/messages/ko.json | 11 + src/i18n/messages/pt.json | 11 + src/i18n/messages/zh-CN.json | 11 + src/i18n/messages/zh-TW.json | 11 + src/lib/api.ts | 6 +- src/lib/tauri.ts | 12 +- src/lib/types.ts | 8 + 27 files changed, 2240 insertions(+), 318 deletions(-) diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index d5b463975..7e8cb41b2 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -99,17 +99,15 @@ fn merge_agent_env( /// Gated on the explicit `CURSOR_AUTH_MODE` knob (written by the Cursor panel), /// so legacy rows and operator-provided container env are left untouched. In /// custom mode the credentials are present and non-empty, so nothing is cleared. -fn apply_cursor_env_policy(merged: &mut Vec<(String, String)>, runtime_env: &BTreeMap) { - if runtime_env.get("CURSOR_AUTH_MODE").map(String::as_str) != Some("subscription") { - return; - } +fn apply_cursor_env_policy( + merged: &mut Vec<(String, String)>, + runtime_env: &BTreeMap, +) { + let effective = crate::commands::acp::cursor_effective_runtime_env(runtime_env); for key in ["CURSOR_API_KEY", "CURSOR_API_BASE_URL"] { - let already_set = merged - .iter() - .any(|(k, v)| k == key && !v.trim().is_empty()); - if !already_set { + if let Some(value) = effective.get(key) { merged.retain(|(k, _)| k != key); - merged.push((key.to_string(), String::new())); + merged.push((key.to_string(), value.clone())); } } } @@ -300,6 +298,8 @@ pub enum ConnectionCommand { SetConfigOption { config_id: String, value_id: String, + /// Opaque frontend id echoed with the terminal Cursor snapshot. + operation_id: Option, /// Cursor-only monotonic request generation; zero for every other /// agent. Used to suppress stale completion events after rapid picks. request_seq: u64, @@ -1271,19 +1271,19 @@ pub async fn spawn_agent_connection( // turn is diagnosed as silently empty. Created here so both the spawn side // and the conversation loop share the same buffer. let stderr_tail = Arc::new(StderrTail::new()); - let cursor_cli_models = if agent_type == AgentType::Cursor { + let (cursor_cli_models, cursor_cli_catalog_error) = if agent_type == AgentType::Cursor { match crate::commands::acp::cursor_models_for_runtime(&runtime_env).await { - Ok(models) => models, + Ok(models) => (models, None), Err(error) => { tracing::warn!( "[ACP][Cursor] native CLI model catalog unavailable; \ falling back to parameterized controls: {error}" ); - Vec::new() + (Vec::new(), Some("cursor_model_catalog_unavailable")) } } } else { - Vec::new() + (Vec::new(), None) }; let agent = build_agent(agent_type, &runtime_env, &launch_cwd, &stderr_tail) .await? @@ -1405,6 +1405,7 @@ pub async fn spawn_agent_connection( preferred_mode_id, preferred_config_values, cursor_cli_models, + cursor_cli_catalog_error, delegation_injection, fs_policy, host_tools, @@ -2172,56 +2173,16 @@ fn build_cursor_composite_catalog( ); continue; } - let mut suffix: Vec> = label_words[base_words.len()..] - .iter() - .cloned() - .map(Some) - .collect(); - let mut parameters = BTreeMap::new(); - let mut valid = true; - - for config in &model.config_options { - let is_boolean = config.options.len() == 2 - && config.options.iter().any(|option| option.value == "false") - && config.options.iter().any(|option| option.value == "true"); - let selected = if is_boolean { - let phrase = cursor_label_words(&config.name, false); - match cursor_take_unique_phrase(&mut suffix, &[phrase]) { - Ok(Some(_)) => Some("true".to_string()), - Ok(None) => Some("false".to_string()), - Err(()) => None, - } - } else { - let phrases: Vec> = config - .options - .iter() - .map(|option| cursor_label_words(&option.name, false)) - .collect(); - match cursor_take_unique_phrase(&mut suffix, &phrases) { - Ok(Some(index)) => Some(config.options[index].value.clone()), - Ok(None) => config - .options - .iter() - .any(|option| option.value == config.current_value) - .then(|| config.current_value.clone()), - Err(()) => None, - } - }; - let Some(selected) = selected else { - valid = false; - break; - }; - parameters.insert(config.id.clone(), selected); - } - - if !valid || suffix.iter().any(Option::is_some) { + let suffix = &label_words[base_words.len()..]; + let Some(parameters) = cursor_unique_parameter_mapping(suffix, &model.config_options) + else { tracing::debug!( cli_alias = %cli.id, cli_label = %cli.label, "[ACP][Cursor] rejected an unmappable CLI model row" ); continue; - } + }; let value = if model.value == "default" && parameters.is_empty() { // Keep the real ACP default sentinel so the existing Cursor grouping @@ -2277,41 +2238,159 @@ fn cursor_label_words(input: &str, omit_parenthetical: bool) -> Vec { words } -/// Consume exactly one of `phrases` from the remaining suffix. Longer phrases -/// win (`Extra High` before `High`); two distinct matching values are ambiguous -/// and therefore rejected. -fn cursor_take_unique_phrase( - suffix: &mut [Option], - phrases: &[Vec], -) -> Result, ()> { - let mut candidates: Vec<(usize, usize, usize)> = Vec::new(); - for (phrase_index, phrase) in phrases.iter().enumerate() { - if phrase.is_empty() || phrase.len() > suffix.len() { - continue; +const CURSOR_MAPPING_MAX_SUFFIX_WORDS: usize = 63; +const CURSOR_MAPPING_MAX_CONFIGS: usize = 16; +const CURSOR_MAPPING_MAX_OPTIONS_PER_CONFIG: usize = 128; +const CURSOR_MAPPING_MAX_CHOICES_PER_CONFIG: usize = 512; +const CURSOR_MAPPING_MAX_STATES: usize = 8_192; + +#[derive(Clone)] +struct CursorParameterChoice { + value: String, + consumed: u64, +} + +/// Interpret the complete suffix across all config options at once. A local +/// greedy match can assign a shared word such as `High` to whichever option +/// happens to arrive first; this bounded search accepts a row only when exactly +/// one structured parameter map consumes every suffix token. +fn cursor_unique_parameter_mapping( + suffix: &[String], + configs: &[CursorCatalogConfigOption], +) -> Option> { + if suffix.len() > CURSOR_MAPPING_MAX_SUFFIX_WORDS + || configs.len() > CURSOR_MAPPING_MAX_CONFIGS + || configs + .iter() + .any(|config| config.options.len() > CURSOR_MAPPING_MAX_OPTIONS_PER_CONFIG) + { + return None; + } + let full_mask = if suffix.is_empty() { + 0 + } else { + (1_u64 << suffix.len()) - 1 + }; + let mut ordered: Vec<_> = configs.iter().collect(); + ordered.sort_by(|left, right| left.id.cmp(&right.id)); + let choices: Vec<_> = ordered + .iter() + .map(|config| cursor_parameter_choices(suffix, config)) + .collect(); + if choices.iter().any(Vec::is_empty) { + return None; + } + + struct SearchInputs<'a> { + ordered: &'a [&'a CursorCatalogConfigOption], + choices: &'a [Vec], + full_mask: u64, + } + struct SearchState { + current: BTreeMap, + solutions: Vec>, + states: usize, + } + fn visit(index: usize, used: u64, inputs: &SearchInputs<'_>, state: &mut SearchState) { + state.states += 1; + if state.states > CURSOR_MAPPING_MAX_STATES || state.solutions.len() > 1 { + return; } - for start in 0..=suffix.len() - phrase.len() { - if phrase.iter().enumerate().all(|(offset, expected)| { - suffix[start + offset].as_deref() == Some(expected.as_str()) - }) { - candidates.push((phrase_index, start, phrase.len())); + if index == inputs.ordered.len() { + if used == inputs.full_mask && !state.solutions.contains(&state.current) { + state.solutions.push(state.current.clone()); + } + return; + } + let config = inputs.ordered[index]; + for choice in &inputs.choices[index] { + if used & choice.consumed != 0 { + continue; + } + state + .current + .insert(config.id.clone(), choice.value.clone()); + visit(index + 1, used | choice.consumed, inputs, state); + state.current.remove(&config.id); + if state.states > CURSOR_MAPPING_MAX_STATES || state.solutions.len() > 1 { + return; } } } - candidates.sort_by_key(|(_, _, len)| std::cmp::Reverse(*len)); - let Some(&(selected_index, selected_start, selected_len)) = candidates.first() else { - return Ok(None); + + let inputs = SearchInputs { + ordered: &ordered, + choices: &choices, + full_mask, + }; + let mut state = SearchState { + current: BTreeMap::new(), + solutions: Vec::new(), + states: 0, }; - if candidates + visit(0, 0, &inputs, &mut state); + (state.states <= CURSOR_MAPPING_MAX_STATES && state.solutions.len() == 1) + .then(|| state.solutions.remove(0)) +} + +fn cursor_parameter_choices( + suffix: &[String], + config: &CursorCatalogConfigOption, +) -> Vec { + let is_boolean = config.options.len() == 2 + && config.options.iter().any(|option| option.value == "false") + && config.options.iter().any(|option| option.value == "true"); + let mut result = Vec::new(); + if is_boolean { + result.push(CursorParameterChoice { + value: "false".to_string(), + consumed: 0, + }); + let phrase = cursor_label_words(&config.name, false); + cursor_push_phrase_choices(suffix, &phrase, "true", &mut result); + } else if config + .options .iter() - .take_while(|(_, _, len)| *len == selected_len) - .any(|(index, _, _)| *index != selected_index) + .any(|option| option.value == config.current_value) { - return Err(()); + result.push(CursorParameterChoice { + value: config.current_value.clone(), + consumed: 0, + }); + for option in &config.options { + let phrase = cursor_label_words(&option.name, false); + cursor_push_phrase_choices(suffix, &phrase, &option.value, &mut result); + } + } + if result.len() >= CURSOR_MAPPING_MAX_CHOICES_PER_CONFIG { + return Vec::new(); + } + result.sort_by_key(|choice| std::cmp::Reverse(choice.consumed.count_ones())); + result.dedup_by(|left, right| left.value == right.value && left.consumed == right.consumed); + result +} + +fn cursor_push_phrase_choices( + suffix: &[String], + phrase: &[String], + value: &str, + result: &mut Vec, +) { + if phrase.is_empty() || phrase.len() > suffix.len() { + return; } - for word in &mut suffix[selected_start..selected_start + selected_len] { - *word = None; + for start in 0..=suffix.len() - phrase.len() { + if result.len() >= CURSOR_MAPPING_MAX_CHOICES_PER_CONFIG { + return; + } + if suffix[start..start + phrase.len()] == *phrase { + let consumed = ((1_u64 << phrase.len()) - 1) << start; + result.push(CursorParameterChoice { + value: value.to_string(), + consumed, + }); + } } - Ok(Some(selected_index)) } fn cursor_composite_for_current<'a>( @@ -2518,7 +2597,7 @@ async fn emit_session_config_options_values( let mut session = state.write().await; session.cursor_raw_config_options = Some(config_options); } - publish_cursor_config_options_if_current(state, emitter).await; + publish_cursor_config_options_if_current(state, emitter, None, None).await; return; } let mut mapped = map_session_config_options(&config_options); @@ -2530,6 +2609,8 @@ async fn emit_session_config_options_values( emitter, AcpEvent::SessionConfigOptions { config_options: mapped, + operation_id: None, + operation_status: None, }, ) .await; @@ -2538,6 +2619,8 @@ async fn emit_session_config_options_values( async fn publish_cursor_config_options_if_current( state: &Arc>, emitter: &EventEmitter, + operation_id: Option, + operation_status: Option<&'static str>, ) { let visible = { let session = state.read().await; @@ -2558,7 +2641,14 @@ async fn publish_cursor_config_options_if_current( _ => raw, } }; - emit_session_config_options_info(state, emitter, visible).await; + emit_session_config_options_operation_info( + state, + emitter, + visible, + operation_id, + operation_status, + ) + .await; } fn cursor_config_request_is_current(completed: u64, requested: u64) -> bool { @@ -2894,11 +2984,25 @@ async fn emit_session_config_options_info( state: &Arc>, emitter: &EventEmitter, config_options: Vec, +) { + emit_session_config_options_operation_info(state, emitter, config_options, None, None).await; +} + +async fn emit_session_config_options_operation_info( + state: &Arc>, + emitter: &EventEmitter, + config_options: Vec, + operation_id: Option, + operation_status: Option<&'static str>, ) { emit_with_state( state, emitter, - AcpEvent::SessionConfigOptions { config_options }, + AcpEvent::SessionConfigOptions { + config_options, + operation_id, + operation_status: operation_status.map(str::to_string), + }, ) .await; } @@ -4276,6 +4380,7 @@ async fn run_connection( preferred_mode_id: Option, preferred_config_values: BTreeMap, cursor_cli_models: Vec, + cursor_cli_catalog_error: Option<&'static str>, delegation_injection: Option, fs_policy: FsAccessPolicy, host_tools: HostToolsPolicy, @@ -4661,6 +4766,22 @@ async fn run_connection( ) .await; + if let Some(code) = cursor_cli_catalog_error { + emit_with_state( + &state, + &emitter_clone, + AcpEvent::Error { + message: "Cursor model catalog is unavailable; using parameterized controls." + .to_string(), + agent_type: agent_type.to_string(), + code: Some(code.to_string()), + details: None, + terminal: false, + }, + ) + .await; + } + // Cursor exposes the two complementary halves of its native model // picker through separate dynamic surfaces: `cursor-agent models` // lists explicit valid rows in account order, while this ACP method @@ -4672,10 +4793,33 @@ async fn run_connection( let catalog = build_cursor_composite_catalog(&cursor_cli_models, &available); if catalog.is_empty() { + let (code, message) = if available.is_empty() { + ( + "cursor_model_catalog_unavailable", + "Cursor model catalog is unavailable; using parameterized controls.", + ) + } else { + ( + "cursor_model_variant_ambiguous", + "Cursor model variants could not be mapped safely; using parameterized controls.", + ) + }; tracing::warn!( "[ACP][Cursor] CLI and ACP model catalogs had no safely \ mappable rows; retaining parameterized controls" ); + emit_with_state( + &state, + &emitter_clone, + AcpEvent::Error { + message: message.to_string(), + agent_type: agent_type.to_string(), + code: Some(code.to_string()), + details: None, + terminal: false, + }, + ) + .await; } else { tracing::info!( cli_rows = cursor_cli_models.len(), @@ -4685,10 +4829,25 @@ async fn run_connection( state.write().await.cursor_composite_models = Some(catalog); } } - Err(error) => tracing::warn!( - "[ACP][Cursor] cursor/list_available_models unavailable; \ - retaining parameterized controls: {error}" - ), + Err(error) => { + tracing::warn!( + "[ACP][Cursor] cursor/list_available_models unavailable; \ + retaining parameterized controls: {error}" + ); + emit_with_state( + &state, + &emitter_clone, + AcpEvent::Error { + message: "Cursor model catalog is unavailable; using parameterized controls." + .to_string(), + agent_type: agent_type.to_string(), + code: Some("cursor_model_catalog_unavailable".to_string()), + details: None, + terminal: false, + }, + ) + .await; + } } } @@ -6078,6 +6237,7 @@ async fn set_session_config_option( config_id: String, value_id: String, request_seq: u64, + operation_id: Option, ) -> Result<(), sacp::Error> { // The whole selector transport carries values as opaque strings; only here, // at the wire, does the option's advertised kind decide how to encode it. @@ -6102,7 +6262,15 @@ async fn set_session_config_option( let updated = set_session_config_option_inner(cx, session_id, config_id.clone(), value).await?; if agent_type == AgentType::Cursor { - finish_cursor_config_request(state, emitter, request_seq, Some(updated)).await; + finish_cursor_config_request( + state, + emitter, + request_seq, + Some(updated), + operation_id, + "applied", + ) + .await; return Ok(()); } // Compare BEFORE emitting: the agent's answer is the only place a request and @@ -6159,6 +6327,8 @@ async fn finish_cursor_config_request( emitter: &EventEmitter, request_seq: u64, raw_options: Option>, + operation_id: Option, + operation_status: &'static str, ) -> bool { let is_latest = { let mut session = state.write().await; @@ -6171,7 +6341,13 @@ async fn finish_cursor_config_request( request_seq == session.cursor_config_request_seq }; if is_latest { - publish_cursor_config_options_if_current(state, emitter).await; + publish_cursor_config_options_if_current( + state, + emitter, + operation_id, + Some(operation_status), + ) + .await; } is_latest } @@ -6183,17 +6359,22 @@ async fn set_cursor_composite_option( emitter: &EventEmitter, value_id: &str, request_seq: u64, + operation_id: Option, ) -> Result<(), sacp::Error> { if value_id == CURSOR_CURRENT_UNAVAILABLE_VALUE { - finish_cursor_config_request(state, emitter, request_seq, None).await; + finish_cursor_config_request(state, emitter, request_seq, None, operation_id, "applied") + .await; return Ok(()); } - let (target, mut raw_options) = { + let (target, previous, mut raw_options) = { let session = state.read().await; - let target = session + let catalog = session .cursor_composite_models .as_deref() - .and_then(|catalog| catalog.iter().find(|model| model.value == value_id)) + .ok_or_else(|| sacp::util::internal_error("Cursor composite catalog is not ready"))?; + let target = catalog + .iter() + .find(|model| model.value == value_id) .cloned() .ok_or_else(|| { sacp::util::internal_error(format!( @@ -6204,17 +6385,52 @@ async fn set_cursor_composite_option( .cursor_raw_config_options .clone() .ok_or_else(|| sacp::util::internal_error("Cursor config options are not ready"))?; - (target, raw_options) + let previous = + cursor_composite_for_current(&map_session_config_options(&raw_options), catalog) + .cloned(); + (target, previous, raw_options) }; let result = apply_cursor_composite_selection_inner(cx, session_id, &target, &mut raw_options).await; if result.is_ok() { - finish_cursor_config_request(state, emitter, request_seq, Some(raw_options)).await; + finish_cursor_config_request( + state, + emitter, + request_seq, + Some(raw_options), + operation_id, + "applied", + ) + .await; } else { - // Preserve the last step Cursor actually confirmed. The caller marks - // this generation complete, publishes that snapshot once, and only - // then emits the paired error used by the frontend rollback guard. + // A composite is one user-visible setting even though Cursor applies it + // in several ACP requests. If the newest request partially succeeds, + // make a best-effort structured rollback to the last fully confirmed + // row. Stale A in an A→B race is not rolled back because B is already + // queued and owns the next authoritative state. + let should_rollback = state.read().await.cursor_config_request_seq == request_seq; + if should_rollback { + if let Some(previous) = previous.filter(|item| item.value != target.value) { + if apply_cursor_composite_selection_inner( + cx, + session_id, + &previous, + &mut raw_options, + ) + .await + .is_err() + { + tracing::warn!( + "[ACP][Cursor] failed to restore the previous composite after a partial apply" + ); + } + } + } + // Preserve exactly what Cursor last confirmed (the restored row when + // rollback succeeded, otherwise the honest partial state). The caller + // publishes it as a correlated failed terminal snapshot before the + // localized error. let mut session = state.write().await; if request_seq >= session.cursor_config_completed_seq { session.cursor_raw_config_options = Some(raw_options); @@ -6278,6 +6494,31 @@ async fn set_session_config_option_inner( Ok(response.config_options) } +#[async_trait::async_trait] +trait CursorConfigTransport { + async fn set_config_option( + &mut self, + config_id: String, + value: SessionConfigOptionValue, + ) -> Result, sacp::Error>; +} + +struct AcpCursorConfigTransport<'a> { + connection: &'a ConnectionTo, + session_id: &'a SessionId, +} + +#[async_trait::async_trait] +impl CursorConfigTransport for AcpCursorConfigTransport<'_> { + async fn set_config_option( + &mut self, + config_id: String, + value: SessionConfigOptionValue, + ) -> Result, sacp::Error> { + set_session_config_option_inner(self.connection, self.session_id, config_id, value).await + } +} + fn cursor_parameter_apply_order(parameters: &BTreeMap) -> Vec<(&String, &String)> { fn priority(id: &str) -> u8 { match id { @@ -6356,6 +6597,18 @@ async fn apply_cursor_composite_selection_inner( session_id: &SessionId, target: &CursorCompositeModel, options: &mut Vec, +) -> Result<(), sacp::Error> { + let mut transport = AcpCursorConfigTransport { + connection: cx, + session_id, + }; + apply_cursor_composite_selection_with_transport(&mut transport, target, options).await +} + +async fn apply_cursor_composite_selection_with_transport( + transport: &mut T, + target: &CursorCompositeModel, + options: &mut Vec, ) -> Result<(), sacp::Error> { let Some(model_wire_value) = cursor_advertised_wire_value(options, "model", &target.model_value) @@ -6366,9 +6619,9 @@ async fn apply_cursor_composite_selection_inner( ))); }; let plan = cursor_composite_wire_plan(target); - *options = - set_session_config_option_inner(cx, session_id, "model".to_string(), model_wire_value) - .await?; + *options = transport + .set_config_option("model".to_string(), model_wire_value) + .await?; for (config_id, value) in plan.iter().skip(1) { let Some(wire_value) = cursor_advertised_wire_value(options, config_id, value) else { @@ -6377,8 +6630,9 @@ async fn apply_cursor_composite_selection_inner( target.model_value ))); }; - *options = - set_session_config_option_inner(cx, session_id, config_id.clone(), wire_value).await?; + *options = transport + .set_config_option(config_id.clone(), wire_value) + .await?; } if !cursor_configuration_matches_target(options, target) { return Err(sacp::util::internal_error(format!( @@ -8432,6 +8686,7 @@ async fn run_conversation_loop<'a>( Some(ConnectionCommand::SetConfigOption { config_id, value_id, + operation_id, request_seq, }) => { let has_composite_catalog = agent_type == AgentType::Cursor @@ -8451,6 +8706,7 @@ async fn run_conversation_loop<'a>( emitter, &value_id, request_seq, + operation_id.clone(), ) .await } else if agent_type == AgentType::Grok { @@ -8461,7 +8717,7 @@ async fn run_conversation_loop<'a>( } else { set_session_config_option( &cx, &sid, state, emitter, agent_type, config_id, - value_id, request_seq, + value_id, request_seq, operation_id.clone(), ) .await }; @@ -8472,6 +8728,8 @@ async fn run_conversation_loop<'a>( emitter, request_seq, None, + operation_id, + "failed", ) .await } else { @@ -8730,6 +8988,7 @@ async fn run_conversation_loop<'a>( Some(ConnectionCommand::SetConfigOption { config_id, value_id, + operation_id, request_seq, }) => { let cx = session.connection(); @@ -8742,8 +9001,16 @@ async fn run_conversation_loop<'a>( .as_ref() .is_some_and(|catalog| !catalog.is_empty()); let set_result = if has_composite_catalog && config_id == "model" { - set_cursor_composite_option(&cx, &sid, state, emitter, &value_id, request_seq) - .await + set_cursor_composite_option( + &cx, + &sid, + state, + emitter, + &value_id, + request_seq, + operation_id.clone(), + ) + .await } else if agent_type == AgentType::Grok { set_grok_config_option(&cx, &sid, state, emitter, config_id, value_id).await } else { @@ -8756,12 +9023,21 @@ async fn run_conversation_loop<'a>( config_id, value_id, request_seq, + operation_id.clone(), ) .await }; if let Err(e) = set_result { let latest = if agent_type == AgentType::Cursor { - finish_cursor_config_request(state, emitter, request_seq, None).await + finish_cursor_config_request( + state, + emitter, + request_seq, + None, + operation_id, + "failed", + ) + .await } else { true }; @@ -11897,15 +12173,26 @@ mod tests { // No configured creds → both injected empty (⇒ spawn strips inherited). let mut merged = vec![("PATH".to_string(), "/usr/bin".to_string())]; apply_cursor_env_policy(&mut merged, &sub); - assert!(merged.iter().any(|(k, v)| k == "CURSOR_API_KEY" && v.is_empty())); + assert!(merged + .iter() + .any(|(k, v)| k == "CURSOR_API_KEY" && v.is_empty())); assert!(merged .iter() .any(|(k, v)| k == "CURSOR_API_BASE_URL" && v.is_empty())); - // A configured key is preserved; only the absent base URL is cleared. - let mut with_key = vec![("CURSOR_API_KEY".to_string(), "sk-x".to_string())]; + // Subscription is authoritative: even an explicitly inherited/saved + // custom credential is scrubbed so browser login cannot be hijacked. + let mut with_key = vec![ + ("CURSOR_API_KEY".to_string(), "sk-x".to_string()), + ( + "CURSOR_API_BASE_URL".to_string(), + "https://stale.example".to_string(), + ), + ]; apply_cursor_env_policy(&mut with_key, &sub); - assert!(with_key.iter().any(|(k, v)| k == "CURSOR_API_KEY" && v == "sk-x")); + assert!(with_key + .iter() + .any(|(k, v)| k == "CURSOR_API_KEY" && v.is_empty())); assert!(with_key .iter() .any(|(k, v)| k == "CURSOR_API_BASE_URL" && v.is_empty())); @@ -14392,7 +14679,7 @@ mod tests { assert!(cfg_idx < err_idx, "revert must precede the error"); // The reverted options carry the original model. - if let AcpEvent::SessionConfigOptions { config_options } = &events[cfg_idx].payload { + if let AcpEvent::SessionConfigOptions { config_options, .. } = &events[cfg_idx].payload { let sel = expect_select(&config_options[0].kind); assert_eq!(sel.current_value, "grok-4.5"); } @@ -16335,6 +16622,131 @@ mod tests { assert!(build_cursor_composite_catalog(&cli_models, &available).is_empty()); } + #[test] + fn cursor_suffix_mapping_is_globally_unique_across_config_options() { + let cli_models = vec![crate::acp::types::CursorModelInfo { + id: "shared-high-alias".into(), + label: "Shared Model High".into(), + is_default: false, + }]; + let raw = serde_json::json!({ + "models": [{ + "value": "shared-model", "name": "Shared Model", + "configOptions": [ + { + "id": "reasoning", "name": "Reasoning", "currentValue": "low", + "options": [ + {"value": "low", "name": "Low"}, + {"value": "high", "name": "High"} + ] + }, + { + "id": "effort", "name": "Effort", "currentValue": "medium", + "options": [ + {"value": "medium", "name": "Medium"}, + {"value": "high", "name": "High"} + ] + } + ] + }] + }); + let available = parse_cursor_available_models(raw).expect("catalog parses"); + let mut reversed = available.clone(); + reversed[0].config_options.reverse(); + for catalog in [&available, &reversed] { + assert!( + build_cursor_composite_catalog(&cli_models, catalog).is_empty(), + "defaults must not hide two equal-length High interpretations" + ); + } + } + + #[test] + fn cursor_suffix_mapping_is_order_independent_and_prefers_complete_extra_high_phrase() { + let cli_models = vec![crate::acp::types::CursorModelInfo { + id: "shared-extra-high".into(), + label: "Shared Model Extra High".into(), + is_default: false, + }]; + let configs = serde_json::json!([ + { + "id": "reasoning", "name": "Reasoning", "currentValue": "low", + "options": [ + {"value": "low", "name": "Low"}, + {"value": "high", "name": "High"} + ] + }, + { + "id": "effort", "name": "Effort", "currentValue": "normal", + "options": [ + {"value": "normal", "name": "Normal"}, + {"value": "xhigh", "name": "Extra High"} + ] + } + ]); + let catalog = |config_options: serde_json::Value| { + parse_cursor_available_models(serde_json::json!({ + "models": [{ + "value": "shared-model", "name": "Shared Model", + "configOptions": config_options + }] + })) + .expect("catalog parses") + }; + let forward = catalog(configs.clone()); + let mut reversed_values = configs.as_array().expect("array").clone(); + reversed_values.reverse(); + let reversed = catalog(serde_json::Value::Array(reversed_values)); + let first = build_cursor_composite_catalog(&cli_models, &forward); + let second = build_cursor_composite_catalog(&cli_models, &reversed); + assert_eq!(first, second, "ACP option order must not change mapping"); + assert_eq!( + first[0].parameters.get("effort").map(String::as_str), + Some("xhigh") + ); + assert_eq!( + first[0].parameters.get("reasoning").map(String::as_str), + Some("low") + ); + } + + #[test] + fn cursor_suffix_mapping_rejects_inputs_over_the_search_bound() { + let suffix = vec!["high".to_string(); CURSOR_MAPPING_MAX_SUFFIX_WORDS + 1]; + let config = CursorCatalogConfigOption { + id: "reasoning".into(), + name: "Reasoning".into(), + current_value: "low".into(), + options: vec![ + CursorCatalogValue { + value: "low".into(), + name: "Low".into(), + }, + CursorCatalogValue { + value: "high".into(), + name: "High".into(), + }, + ], + }; + assert!(cursor_unique_parameter_mapping(&suffix, &[config]).is_none()); + + let oversized = CursorCatalogConfigOption { + id: "reasoning".into(), + name: "Reasoning".into(), + current_value: "value-0".into(), + options: (0..=CURSOR_MAPPING_MAX_OPTIONS_PER_CONFIG) + .map(|index| CursorCatalogValue { + value: format!("value-{index}"), + name: format!("Value {index}"), + }) + .collect(), + }; + assert!( + cursor_unique_parameter_mapping(&[], &[oversized]).is_none(), + "abnormally large option sets must fail before backtracking" + ); + } + fn cursor_wire_options(value: serde_json::Value) -> Vec { serde_json::from_value(value).expect("Cursor config options parse") } @@ -16368,6 +16780,179 @@ mod tests { assert!(plan.iter().all(|(_, value)| value != "cli-flat-alias")); } + struct FakeCursorConfigTransport { + calls: Vec<(String, String)>, + responses: std::collections::VecDeque, sacp::Error>>, + } + + #[async_trait::async_trait] + impl CursorConfigTransport for FakeCursorConfigTransport { + async fn set_config_option( + &mut self, + config_id: String, + value: SessionConfigOptionValue, + ) -> Result, sacp::Error> { + let value = value + .as_value_id() + .map(ToString::to_string) + .or_else(|| value.as_bool().map(|item| item.to_string())) + .expect("supported Cursor config value"); + self.calls.push((config_id, value)); + self.responses + .pop_front() + .expect("one dynamic response per request") + } + } + + fn progressive_cursor_wire_options(stage: usize) -> Vec { + let mut options = vec![serde_json::json!({ + "type": "select", "id": "model", "name": "Model", + "currentValue": if stage == 0 { "default" } else { "opus-5" }, + "options": [ + {"value": "default", "name": "Auto"}, + {"value": "opus-5", "name": "Opus 5"} + ] + })]; + if stage >= 1 { + options.push(serde_json::json!({ + "type": "select", "id": "effort", "name": "Effort", + "currentValue": if stage >= 2 { "xhigh" } else { "medium" }, + "options": [ + {"value": "medium", "name": "Medium"}, + {"value": "xhigh", "name": "Extra High"} + ] + })); + } + if stage >= 2 { + options.push(serde_json::json!({ + "type": "select", "id": "context", "name": "Context", + "currentValue": if stage >= 3 { "1m" } else { "300k" }, + "options": [ + {"value": "300k", "name": "300K"}, + {"value": "1m", "name": "1M"} + ] + })); + } + if stage >= 3 { + options.push(serde_json::json!({ + "type": "boolean", "id": "thinking", "name": "Thinking", + "currentValue": stage >= 4 + })); + } + if stage >= 4 { + options.push(serde_json::json!({ + "type": "boolean", "id": "fast", "name": "Fast", + "currentValue": stage >= 5 + })); + } + cursor_wire_options(serde_json::Value::Array(options)) + } + + #[tokio::test] + async fn cursor_fake_transport_is_model_first_and_waits_for_each_dynamic_response() { + let target = CursorCompositeModel { + value: "__codeg_cursor_composite__:opaque-cli-alias".into(), + label: "Opus 5 1M Extra High Thinking Fast".into(), + model_value: "opus-5".into(), + parameters: [ + ("fast".into(), "true".into()), + ("thinking".into(), "true".into()), + ("context".into(), "1m".into()), + ("effort".into(), "xhigh".into()), + ] + .into(), + }; + let mut options = progressive_cursor_wire_options(0); + let mut transport = FakeCursorConfigTransport { + calls: Vec::new(), + responses: (1..=5) + .map(|stage| Ok(progressive_cursor_wire_options(stage))) + .collect(), + }; + + apply_cursor_composite_selection_with_transport(&mut transport, &target, &mut options) + .await + .expect("complete dynamic transaction"); + + assert_eq!( + transport.calls, + vec![ + ("model".into(), "opus-5".into()), + ("effort".into(), "xhigh".into()), + ("context".into(), "1m".into()), + ("thinking".into(), "true".into()), + ("fast".into(), "true".into()), + ] + ); + assert!(transport + .calls + .iter() + .all(|(_, value)| value != "opaque-cli-alias")); + } + + #[tokio::test] + async fn cursor_fake_transport_stops_on_parameter_failure_and_auto_is_model_only() { + let target = CursorCompositeModel { + value: "__codeg_cursor_composite__:high".into(), + label: "Opus 5 Extra High".into(), + model_value: "opus-5".into(), + parameters: [("effort".into(), "xhigh".into())].into(), + }; + let mut options = progressive_cursor_wire_options(0); + let mut transport = FakeCursorConfigTransport { + calls: Vec::new(), + responses: [ + Ok(progressive_cursor_wire_options(1)), + Err(sacp::util::internal_error("controlled failure")), + Ok(progressive_cursor_wire_options(0)), + ] + .into(), + }; + assert!(apply_cursor_composite_selection_with_transport( + &mut transport, + &target, + &mut options, + ) + .await + .is_err()); + assert_eq!(transport.calls.len(), 2, "no request follows a failure"); + + let auto = CursorCompositeModel { + value: "default".into(), + label: "Auto".into(), + model_value: "default".into(), + parameters: BTreeMap::new(), + }; + apply_cursor_composite_selection_with_transport(&mut transport, &auto, &mut options) + .await + .expect("the previous Auto row can be restored after partial failure"); + assert_eq!( + transport.calls, + vec![ + ("model".into(), "opus-5".into()), + ("effort".into(), "xhigh".into()), + ("model".into(), "default".into()), + ] + ); + + let mut auto_options = progressive_cursor_wire_options(0); + let mut auto_transport = FakeCursorConfigTransport { + calls: Vec::new(), + responses: [Ok(progressive_cursor_wire_options(0))].into(), + }; + apply_cursor_composite_selection_with_transport( + &mut auto_transport, + &auto, + &mut auto_options, + ) + .await + .expect("Auto restores only the real default sentinel"); + assert_eq!( + auto_transport.calls, + vec![("model".into(), "default".into())] + ); + } + #[test] fn cursor_parameters_are_validated_only_after_model_options_arrive() { let before = cursor_wire_options(serde_json::json!([{ @@ -16546,4 +17131,99 @@ mod tests { assert!(cursor_config_request_is_current(2, 2)); assert!(!cursor_config_request_is_current(3, 2)); } + + #[tokio::test] + async fn cursor_terminal_config_event_is_correlated_and_stale_operations_emit_nothing() { + let mut session = SessionState::new( + "cursor-config-event".into(), + AgentType::Cursor, + None, + "test".into(), + None, + ); + session.cursor_config_request_seq = 2; + session.cursor_raw_config_options = Some(progressive_cursor_wire_options(0)); + let state = Arc::new(RwLock::new(session)); + let emitter = EventEmitter::Noop; + + assert!( + !finish_cursor_config_request( + &state, + &emitter, + 1, + Some(progressive_cursor_wire_options(1)), + Some("old-operation".into()), + "applied", + ) + .await + ); + assert!(state + .read() + .await + .recent_events_after(0) + .map_or(true, |events| events.is_empty())); + + assert!( + finish_cursor_config_request( + &state, + &emitter, + 2, + Some(progressive_cursor_wire_options(1)), + Some("latest-operation".into()), + "failed", + ) + .await + ); + let events = state + .read() + .await + .recent_events_after(0) + .expect("event ring"); + assert_eq!( + events.len(), + 1, + "one terminal snapshot per latest operation" + ); + match &events[0].payload { + AcpEvent::SessionConfigOptions { + operation_id, + operation_status, + .. + } => { + assert_eq!(operation_id.as_deref(), Some("latest-operation")); + assert_eq!(operation_status.as_deref(), Some("failed")); + } + other => panic!("unexpected terminal event: {other:?}"), + } + + state.write().await.cursor_config_request_seq = 3; + assert!( + finish_cursor_config_request( + &state, + &emitter, + 3, + Some(progressive_cursor_wire_options(5)), + Some("successful-operation".into()), + "applied", + ) + .await + ); + let events = state + .read() + .await + .recent_events_after(0) + .expect("terminal event ring"); + assert_eq!(events.len(), 2); + match &events[1].payload { + AcpEvent::SessionConfigOptions { + operation_id, + operation_status, + .. + } => { + assert_eq!(operation_id.as_deref(), Some("successful-operation")); + assert_eq!(operation_status.as_deref(), Some("applied")); + } + other => panic!("unexpected terminal event: {other:?}"), + } + } } diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index 0c4e4ae4a..d2158f806 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -1221,7 +1221,8 @@ impl ConnectionManager { conn_id: &str, config_id: String, value_id: String, - ) -> Result<(), AcpError> { + operation_id: Option, + ) -> Result, AcpError> { let (cmd_tx, state, agent_type) = { let connections = self.connections.lock().await; let conn = connections @@ -1234,6 +1235,7 @@ impl ConnectionManager { ) }; if agent_type == AgentType::Cursor { + let accepted_operation_id = operation_id.clone(); // Keep sequence assignment and channel insertion in one critical // section. Concurrent API calls can otherwise acquire a sequence // in one order but reach the command queue in another. @@ -1247,20 +1249,23 @@ impl ConnectionManager { .send(ConnectionCommand::SetConfigOption { config_id, value_id, + operation_id, request_seq, }) .await .map_err(|_| AcpError::ProcessExited)?; - return Ok(()); + return Ok(accepted_operation_id); } cmd_tx .send(ConnectionCommand::SetConfigOption { config_id, value_id, + operation_id: None, request_seq: 0, }) .await - .map_err(|_| AcpError::ProcessExited) + .map_err(|_| AcpError::ProcessExited)?; + Ok(None) } /// Pause or clear the session's active Codex goal via the connection loop diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index cceada9ed..fce98448a 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -641,7 +641,7 @@ impl SessionState { modes.current_mode_id = mode_id.clone(); } } - AcpEvent::SessionConfigOptions { config_options } => { + AcpEvent::SessionConfigOptions { config_options, .. } => { self.config_options = Some(config_options.clone()); } AcpEvent::SessionConfigStale { stale, kind } => { @@ -3084,6 +3084,8 @@ mod tests { groups: vec![], }), }], + operation_id: None, + operation_status: None, }); s.apply_event(&AcpEvent::UsageUpdate { used: 1234, diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index aa27f40ea..7d83e1603 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -1,6 +1,10 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +fn is_false(value: &bool) -> bool { + !*value +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum PromptInputBlock { @@ -191,6 +195,10 @@ pub enum AcpEvent { /// Session configuration options are available/updated for this connection SessionConfigOptions { config_options: Vec, + #[serde(skip_serializing_if = "Option::is_none", default)] + operation_id: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + operation_status: Option, }, /// The agent settled a `session/set_config_option` on a value other than the /// one that was requested. @@ -262,14 +270,10 @@ pub enum AcpEvent { /// failure, `session/load` fallback, empty-prompt rejection) /// leave the connection alive and the next prompt will still work. /// - /// Skipped from serialization — the wire-format payload sent to - /// the frontend (Tauri / WebSocket) is unchanged. This is purely - /// an in-process signal between `connection.rs` and the lifecycle - /// worker so the worker can avoid wrongly cancelling the - /// conversation row or polluting the broker's cancel reason with - /// a stale, non-terminal error detail. (Stays `false` after any - /// JSON round-trip; only the original emitter sees `true`.) - #[serde(skip, default)] + /// Serialized only when true so the frontend can abandon a pending + /// multi-step Cursor configuration even if no trailing status event + /// arrives. Ordinary recoverable errors retain the legacy wire shape. + #[serde(default, skip_serializing_if = "is_false")] terminal: bool, }, /// A retryable turn error that keeps the turn alive (codex-acp #289, @@ -1075,6 +1079,8 @@ pub struct CursorAuthStatus { pub membership: Option, /// Probe failure detail (spawn error / timeout / non-JSON output). pub error: Option, + /// Stable code for localized rendering; absent on successful/legacy data. + pub error_code: Option, /// Absolute path to the cursor-agent binary codeg would launch (managed /// cache or system install). The settings panel builds a copy-pasteable /// `"" login` command from it — the managed binary lives in @@ -1105,6 +1111,7 @@ pub struct CursorModelsResult { pub models: Vec, pub default_model: Option, pub error: Option, + pub error_code: Option, } /// One Codeg-visible Cursor model row backed by Cursor's parameterized ACP @@ -1327,6 +1334,26 @@ mod envelope_tests { } } + #[test] + fn terminal_error_exposes_only_the_true_wire_marker() { + let error = |terminal| AcpEvent::Error { + message: "controlled error".to_string(), + agent_type: "cursor".to_string(), + code: Some("process_exited".to_string()), + details: None, + terminal, + }; + + let terminal = serde_json::to_value(error(true)).expect("serialize terminal error"); + assert_eq!(terminal.get("terminal"), Some(&serde_json::json!(true))); + + let recoverable = serde_json::to_value(error(false)).expect("serialize recoverable error"); + assert!( + recoverable.get("terminal").is_none(), + "the compatibility wire stays unchanged for ordinary errors" + ); + } + #[test] fn user_blocks_promote_image_resource_and_fold_other_resources() { let blocks = vec![ diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index e489ce958..9afd084e9 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -1,11 +1,15 @@ use std::collections::{BTreeMap, HashMap}; use std::fs; use std::path::{Path, PathBuf}; -use std::time::Duration; +use std::process::Stdio; +use std::sync::{Arc, OnceLock}; +use std::time::{Duration, Instant, SystemTime}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; #[cfg(feature = "tauri-runtime")] use tauri::{Manager, State}; +use tokio::io::AsyncReadExt; use crate::acp::binary_cache; use crate::acp::custom_registry; @@ -31,6 +35,11 @@ use crate::web::event_bridge::EventEmitter; const ACP_AGENTS_UPDATED_EVENT: &str = "app://acp-agents-updated"; const NPM_PREFIX_TIMEOUT: Duration = Duration::from_millis(1500); +const CURSOR_PROBE_STDOUT_LIMIT: usize = 1024 * 1024; +const CURSOR_PROBE_STDERR_LIMIT: usize = 256 * 1024; +const CURSOR_PROBE_CACHE_MAX_ENTRIES: usize = 16; +const CURSOR_PROBE_SUCCESS_TTL: Duration = Duration::from_secs(300); +const CURSOR_PROBE_FAILURE_BACKOFF: Duration = Duration::from_secs(3); static NPM_GLOBAL_PREFIX_CACHE: tokio::sync::OnceCell = tokio::sync::OnceCell::const_new(); @@ -8199,12 +8208,37 @@ fn persist_cursor_cli_config(text: &str) -> Result<(), AcpError> { /// The cursor-agent binary codeg would launch: managed cache first, then the /// user's own install (PATH / ~/.local/bin) — the same order as `build_agent`. fn resolve_cursor_binary() -> Option { - if let Ok(Some((path, _))) = + resolve_cursor_binary_identity().map(|(path, _)| path) +} + +fn resolve_cursor_binary_identity() -> Option<(PathBuf, Option)> { + if let Ok(Some((path, version))) = binary_cache::find_best_cached_binary_for_agent(AgentType::Cursor, "cursor-agent") { - return Some(path); + return Some((path, Some(version))); } - resolve_system_agent_binary("cursor-agent") + resolve_system_agent_binary("cursor-agent").map(|path| (path, None)) +} + +/// Resolve the Cursor credential environment once for both the long-lived ACP +/// process and transient catalog/status probes. Empty values are an internal +/// `env_remove` sentinel. Subscription mode is authoritative and always clears +/// both custom credential variables; custom/API-key mode preserves explicit +/// values byte-for-byte. +pub(crate) fn cursor_effective_runtime_env( + runtime_env: &BTreeMap, +) -> BTreeMap { + let mut effective = runtime_env.clone(); + // `merge_agent_env` applies Codeg's live proxy overrides after saved env; + // probes must observe the same network route and precedence. + for (key, value) in crate::network::proxy::current_proxy_env_vars() { + effective.insert(key, value); + } + if runtime_env.get("CURSOR_AUTH_MODE").map(String::as_str) == Some("subscription") { + effective.insert("CURSOR_API_KEY".to_string(), String::new()); + effective.insert("CURSOR_API_BASE_URL".to_string(), String::new()); + } + effective } /// The Cursor agent's effective probe env: the saved env (env_json) with the @@ -8216,8 +8250,6 @@ fn resolve_cursor_binary() -> Option { /// `CURSOR_API_KEY` is always materialized (empty when unset) so /// `run_cursor_probe` makes an explicit set-or-remove decision and a stale /// inherited key can never leak in and produce a bogus "invalid API key". -/// `CURSOR_API_BASE_URL` is always cleared — the CLI has no custom-endpoint -/// support, so a base URL is never a valid probe input. async fn cursor_probe_env(db: &AppDatabase, api_key: Option<&str>) -> BTreeMap { let mut env: BTreeMap = agent_setting_service::get_by_agent_type(&db.conn, AgentType::Cursor) @@ -8228,25 +8260,125 @@ async fn cursor_probe_env(db: &AppDatabase, api_key: Option<&str>) -> BTreeMap>(&raw).ok()) .unwrap_or_default(); if let Some(key) = api_key { - env.insert("CURSOR_API_KEY".to_string(), key.trim().to_string()); + let key = key.trim(); + env.insert("CURSOR_API_KEY".to_string(), key.to_string()); + env.insert( + "CURSOR_AUTH_MODE".to_string(), + if key.is_empty() { + "subscription" + } else { + "custom" + } + .to_string(), + ); + } + cursor_effective_runtime_env(&env) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CursorProbeError { + NotInstalled, + Spawn, + Timeout, + OutputTooLarge, + NonZeroExit, + InvalidUtf8, + EmptyOutput, + InvalidFormat, +} + +impl CursorProbeError { + fn code(&self) -> &'static str { + match self { + Self::NotInstalled => "cursor_probe_not_installed", + Self::Spawn => "cursor_probe_spawn_failed", + Self::Timeout => "cursor_probe_timeout", + Self::OutputTooLarge => "cursor_probe_output_too_large", + Self::NonZeroExit => "cursor_probe_nonzero_exit", + Self::InvalidUtf8 => "cursor_probe_invalid_utf8", + Self::EmptyOutput => "cursor_probe_empty_output", + Self::InvalidFormat => "cursor_probe_invalid_format", + } + } +} + +impl std::fmt::Display for CursorProbeError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::NotInstalled => "Cursor CLI is not installed.", + Self::Spawn => "Cursor CLI probe could not be started.", + Self::Timeout => "Cursor CLI probe timed out.", + Self::OutputTooLarge => "Cursor CLI probe output exceeded the safety limit.", + Self::NonZeroExit => "Cursor CLI probe exited unsuccessfully.", + Self::InvalidUtf8 => "Cursor CLI probe returned invalid UTF-8.", + Self::EmptyOutput => "Cursor CLI probe returned no output.", + Self::InvalidFormat => "Cursor CLI probe returned an unsupported format.", + }; + formatter.write_str(message) } - // Materialize the key so an unset one becomes an explicit empty ⇒ removed. - env.entry("CURSOR_API_KEY".to_string()).or_default(); - // Scrub any stale base URL (legacy env_json row or inherited dev-shell - // export): empty ⇒ removed by run_cursor_probe. - env.insert("CURSOR_API_BASE_URL".to_string(), String::new()); - env } -/// Run a cursor-agent subcommand with a timeout, capturing stdout. -async fn run_cursor_probe( +async fn read_cursor_probe_stream( + mut reader: R, + limit: usize, +) -> Result, CursorProbeError> { + let mut output = Vec::with_capacity(limit.min(16 * 1024)); + let mut chunk = [0_u8; 8 * 1024]; + loop { + let read = reader + .read(&mut chunk) + .await + .map_err(|_| CursorProbeError::Spawn)?; + if read == 0 { + return Ok(output); + } + if output.len().saturating_add(read) > limit { + return Err(CursorProbeError::OutputTooLarge); + } + output.extend_from_slice(&chunk[..read]); + } +} + +async fn terminate_cursor_probe(child: &mut tokio::process::Child, pid: Option) { + let mut signalled = Vec::new(); + if let Some(pid) = pid { + let config = kill_tree::Config { + signal: "SIGTERM".to_string(), + include_target: true, + }; + if let Ok(outputs) = kill_tree::tokio::kill_tree_with_config(pid, &config).await { + signalled.extend(outputs.into_iter().filter_map(|output| match output { + kill_tree::Output::Killed { process_id, .. } => Some(process_id), + kill_tree::Output::MaybeAlreadyTerminated { .. } => None, + })); + } + tokio::time::sleep(Duration::from_millis(100)).await; + for process_id in signalled { + let config = kill_tree::Config { + signal: "SIGKILL".to_string(), + include_target: true, + }; + let _ = kill_tree::tokio::kill_tree_with_config(process_id, &config).await; + } + } + let _ = child.start_kill(); + let _ = tokio::time::timeout(Duration::from_secs(2), child.wait()).await; +} + +/// Spawn one probe without a shell, drain both pipes concurrently with hard +/// limits, and actively reap the process tree on every abort path. +async fn run_cursor_probe_binary( + bin: &Path, args: &[&str], - timeout_secs: u64, + timeout_duration: Duration, extra_env: &BTreeMap, -) -> Result { - let bin = resolve_cursor_binary().ok_or_else(|| "cursor-agent is not installed".to_string())?; - let mut cmd = crate::process::tokio_command(&bin); - cmd.args(args); +) -> Result { + let mut cmd = crate::process::tokio_command(bin); + cmd.args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); for (key, value) in extra_env { if value.trim().is_empty() { // This process's env is inherited by the child; an empty value means @@ -8256,25 +8388,259 @@ async fn run_cursor_probe( cmd.env(key, value); } } - let output = tokio::time::timeout( - std::time::Duration::from_secs(timeout_secs), - cmd.output(), - ) - .await - .map_err(|_| format!("cursor-agent {} timed out", args.join(" ")))? - .map_err(|e| format!("failed to run cursor-agent: {e}"))?; - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - if !output.status.success() && stdout.trim().is_empty() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "cursor-agent {} failed: {}", - args.join(" "), - stderr.trim() - )); + let mut child = cmd.spawn().map_err(|_| CursorProbeError::Spawn)?; + let pid = child.id(); + let stdout = child.stdout.take().ok_or(CursorProbeError::Spawn)?; + let stderr = child.stderr.take().ok_or(CursorProbeError::Spawn)?; + let mut stdout_task = tokio::spawn(read_cursor_probe_stream(stdout, CURSOR_PROBE_STDOUT_LIMIT)); + let mut stderr_task = tokio::spawn(read_cursor_probe_stream(stderr, CURSOR_PROBE_STDERR_LIMIT)); + + let outcome = { + let mut stdout_value = None; + let mut stderr_value = None; + let mut status_value = None; + let deadline = tokio::time::sleep(timeout_duration); + tokio::pin!(deadline); + loop { + tokio::select! { + result = &mut stdout_task, if stdout_value.is_none() => { + match result { + Ok(Ok(value)) => stdout_value = Some(value), + Ok(Err(error)) => break Err(error), + Err(_) => break Err(CursorProbeError::Spawn), + } + } + result = &mut stderr_task, if stderr_value.is_none() => { + match result { + Ok(Ok(value)) => stderr_value = Some(value), + Ok(Err(error)) => break Err(error), + Err(_) => break Err(CursorProbeError::Spawn), + } + } + result = child.wait(), if status_value.is_none() => { + match result { + Ok(status) => status_value = Some(status), + Err(_) => break Err(CursorProbeError::Spawn), + } + } + _ = &mut deadline => break Err(CursorProbeError::Timeout), + } + if let (Some(status), Some(stdout), Some(stderr)) = + (status_value, stdout_value.as_ref(), stderr_value.as_ref()) + { + break Ok((status, stdout.clone(), stderr.clone())); + } + } + }; + + let (status, stdout, stderr) = match outcome { + Ok(output) => output, + Err(error) => { + stdout_task.abort(); + stderr_task.abort(); + terminate_cursor_probe(&mut child, pid).await; + return Err(error); + } + }; + if !status.success() { + return Err(CursorProbeError::NonZeroExit); + } + let stdout = String::from_utf8(stdout).map_err(|_| CursorProbeError::InvalidUtf8)?; + String::from_utf8(stderr).map_err(|_| CursorProbeError::InvalidUtf8)?; + if stdout.trim().is_empty() { + return Err(CursorProbeError::EmptyOutput); } Ok(stdout) } +async fn run_cursor_probe( + args: &[&str], + timeout_secs: u64, + extra_env: &BTreeMap, +) -> Result { + let bin = resolve_cursor_binary().ok_or(CursorProbeError::NotInstalled)?; + run_cursor_probe_binary(&bin, args, Duration::from_secs(timeout_secs), extra_env).await +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct CursorProbeCacheKey { + binary_path: PathBuf, + binary_version: Option, + binary_len: u64, + binary_modified: Option, + command: String, + environment_fingerprint: String, +} + +#[derive(Clone)] +struct CursorProbeCacheEntry { + result: Result, + expires_at: Instant, +} + +#[derive(Default)] +struct CursorProbeCache { + entries: HashMap, + gates: HashMap>>, +} + +static CURSOR_PROBE_CACHE: OnceLock> = OnceLock::new(); + +fn cursor_probe_cache() -> &'static tokio::sync::Mutex { + CURSOR_PROBE_CACHE.get_or_init(|| tokio::sync::Mutex::new(CursorProbeCache::default())) +} + +fn prune_cursor_probe_cache(cache: &mut CursorProbeCache) { + let now = Instant::now(); + cache.entries.retain(|_, entry| entry.expires_at > now); + cache.gates.retain(|_, gate| Arc::strong_count(gate) > 1); + while cache.entries.len() >= CURSOR_PROBE_CACHE_MAX_ENTRIES { + let Some(oldest) = cache + .entries + .iter() + .min_by_key(|(_, entry)| entry.expires_at) + .map(|(key, _)| key.clone()) + else { + break; + }; + cache.entries.remove(&oldest); + } +} + +fn cursor_probe_environment_fingerprint(env: &BTreeMap) -> String { + let effective = cursor_effective_runtime_env(env); + let home = effective + .get("HOME") + .cloned() + .or_else(|| std::env::var("HOME").ok()) + .unwrap_or_default(); + let config_dir = effective + .get("CURSOR_CONFIG_DIR") + .map(PathBuf::from) + .or_else(|| std::env::var_os("CURSOR_CONFIG_DIR").map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from(&home).join(".cursor")); + + let mut digest = Sha256::new(); + for key in [ + "CURSOR_AUTH_MODE", + "CURSOR_API_KEY", + "CURSOR_API_BASE_URL", + "HOME", + "CURSOR_CONFIG_DIR", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", + ] { + digest.update(key.as_bytes()); + digest.update([0]); + digest.update(effective.get(key).map(String::as_bytes).unwrap_or_default()); + digest.update([0xff]); + } + digest.update(config_dir.to_string_lossy().as_bytes()); + for name in ["cli-config.json", "auth.json"] { + let path = config_dir.join(name); + digest.update(name.as_bytes()); + if let Ok(metadata) = fs::metadata(&path) { + digest.update(metadata.len().to_le_bytes()); + if let Ok(modified) = metadata.modified() { + if let Ok(duration) = modified.duration_since(SystemTime::UNIX_EPOCH) { + digest.update(duration.as_nanos().to_le_bytes()); + } + } + if metadata.len() <= 1024 * 1024 { + if let Ok(bytes) = fs::read(path) { + digest.update(bytes); + } + } + } + } + format!("{:x}", digest.finalize()) +} + +fn cursor_probe_cache_key( + bin: &Path, + binary_version: Option<&str>, + args: &[&str], + env: &BTreeMap, +) -> CursorProbeCacheKey { + let binary_path = fs::canonicalize(bin).unwrap_or_else(|_| bin.to_path_buf()); + let metadata = fs::metadata(&binary_path).ok(); + CursorProbeCacheKey { + binary_len: metadata.as_ref().map_or(0, fs::Metadata::len), + binary_modified: metadata.and_then(|item| item.modified().ok()), + binary_path, + binary_version: binary_version.map(str::to_string), + command: args.join("\0"), + environment_fingerprint: cursor_probe_environment_fingerprint(env), + } +} + +async fn run_cursor_probe_cached( + args: &[&str], + timeout_secs: u64, + extra_env: &BTreeMap, +) -> Result { + let (bin, version) = resolve_cursor_binary_identity().ok_or(CursorProbeError::NotInstalled)?; + run_cursor_probe_cached_for_binary(&bin, version.as_deref(), args, timeout_secs, extra_env) + .await +} + +async fn run_cursor_probe_cached_for_binary( + bin: &Path, + binary_version: Option<&str>, + args: &[&str], + timeout_secs: u64, + extra_env: &BTreeMap, +) -> Result { + let key = cursor_probe_cache_key(bin, binary_version, args, extra_env); + let gate = { + let mut cache = cursor_probe_cache().lock().await; + prune_cursor_probe_cache(&mut cache); + if let Some(entry) = cache.entries.get(&key) { + if entry.expires_at > Instant::now() { + return entry.result.clone(); + } + } + cache + .gates + .entry(key.clone()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() + }; + let _guard = gate.lock().await; + { + let cache = cursor_probe_cache().lock().await; + if let Some(entry) = cache.entries.get(&key) { + if entry.expires_at > Instant::now() { + return entry.result.clone(); + } + } + } + let result = + run_cursor_probe_binary(bin, args, Duration::from_secs(timeout_secs), extra_env).await; + let ttl = if result.is_ok() { + CURSOR_PROBE_SUCCESS_TTL + } else { + CURSOR_PROBE_FAILURE_BACKOFF + }; + let mut cache = cursor_probe_cache().lock().await; + prune_cursor_probe_cache(&mut cache); + cache.entries.insert( + key.clone(), + CursorProbeCacheEntry { + result: result.clone(), + expires_at: Instant::now() + ttl, + }, + ); + cache.gates.remove(&key); + result +} + pub(crate) async fn acp_cursor_auth_status_core( db: &AppDatabase, api_key: Option, @@ -8288,6 +8654,7 @@ pub(crate) async fn acp_cursor_auth_status_core( email: None, membership: None, error: None, + error_code: None, binary_path: None, }; } @@ -8326,16 +8693,18 @@ pub(crate) async fn acp_cursor_auth_status_core( email, membership: get_str(&["membershipType", "membership", "plan"]), error: None, + error_code: None, binary_path: binary_path.clone(), } } - Err(e) => crate::acp::types::CursorAuthStatus { + Err(_) => crate::acp::types::CursorAuthStatus { installed: true, is_authenticated: false, - raw_status: Some(truncate_probe_output(&stdout)), + raw_status: None, email: None, membership: None, - error: Some(format!("unexpected status output: {e}")), + error: Some(CursorProbeError::InvalidFormat.to_string()), + error_code: Some(CursorProbeError::InvalidFormat.code().to_string()), binary_path: binary_path.clone(), }, } @@ -8346,7 +8715,8 @@ pub(crate) async fn acp_cursor_auth_status_core( raw_status: None, email: None, membership: None, - error: Some(err), + error: Some(err.to_string()), + error_code: Some(err.code().to_string()), binary_path, }, } @@ -8357,19 +8727,29 @@ pub(crate) async fn acp_cursor_list_models_core( api_key: Option, ) -> crate::acp::types::CursorModelsResult { let extra_env = cursor_probe_env(db, api_key.as_deref()).await; - match run_cursor_probe(&["models"], 30, &extra_env).await { + match run_cursor_probe_cached(&["models"], 30, &extra_env).await { Ok(stdout) => { let (models, default_model) = parse_cursor_models(&stdout); + if models.is_empty() { + return crate::acp::types::CursorModelsResult { + models, + default_model, + error: Some(CursorProbeError::InvalidFormat.to_string()), + error_code: Some(CursorProbeError::InvalidFormat.code().to_string()), + }; + } crate::acp::types::CursorModelsResult { models, default_model, error: None, + error_code: None, } } Err(err) => crate::acp::types::CursorModelsResult { models: Vec::new(), default_model: None, - error: Some(err), + error: Some(err.to_string()), + error_code: Some(err.code().to_string()), }, } } @@ -8425,19 +8805,15 @@ fn parse_cursor_models(stdout: &str) -> (Vec pub(crate) async fn cursor_models_for_runtime( runtime_env: &BTreeMap, ) -> Result, String> { - let mut probe_env = runtime_env.clone(); - // Match the spawned Cursor process's subscription policy: an inherited API - // key must not hijack a browser-login session. Empty means env_remove in - // run_cursor_probe. - if runtime_env.get("CURSOR_AUTH_MODE").map(String::as_str) == Some("subscription") - && !runtime_env - .get("CURSOR_API_KEY") - .is_some_and(|value| !value.trim().is_empty()) - { - probe_env.insert("CURSOR_API_KEY".to_string(), String::new()); + let probe_env = cursor_effective_runtime_env(runtime_env); + let stdout = run_cursor_probe_cached(&["models"], 30, &probe_env) + .await + .map_err(|error| error.to_string())?; + let models = parse_cursor_models(&stdout).0; + if models.is_empty() { + return Err(CursorProbeError::InvalidFormat.to_string()); } - let stdout = run_cursor_probe(&["models"], 30, &probe_env).await?; - Ok(parse_cursor_models(&stdout).0) + Ok(models) } /// Strip ANSI SGR escape sequences from CLI output. @@ -8461,15 +8837,6 @@ fn strip_ansi(input: &str) -> String { out } -fn truncate_probe_output(s: &str) -> String { - let t = s.trim(); - if t.len() > 400 { - format!("{}…", &t[..t.char_indices().take_while(|(i, _)| *i < 400).last().map(|(i, c)| i + c.len_utf8()).unwrap_or(400)]) - } else { - t.to_string() - } -} - #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn acp_cursor_auth_status( @@ -9327,10 +9694,11 @@ pub async fn acp_set_config_option( connection_id: String, config_id: String, value_id: String, + operation_id: Option, manager: State<'_, ConnectionManager>, -) -> Result<(), AcpError> { +) -> Result, AcpError> { manager - .set_config_option(&connection_id, config_id, value_id) + .set_config_option(&connection_id, config_id, value_id, operation_id) .await } @@ -14474,13 +14842,17 @@ wire_api = "chat" async fn cursor_probe_env_materializes_key_and_scrubs_base_url() { let db = crate::db::test_helpers::fresh_in_memory_db().await; - // API-key mode: the form value wins over saved env and is trimmed; the - // base URL is always scrubbed to empty (⇒ removed by run_cursor_probe). + // API-key mode: the form value wins over saved env and is trimmed; + // an explicit custom base URL would be preserved by the shared policy. let env = cursor_probe_env(&db, Some(" my-key ")).await; - assert_eq!(env.get("CURSOR_API_KEY").map(String::as_str), Some("my-key")); assert_eq!( - env.get("CURSOR_API_BASE_URL").map(String::as_str), - Some("") + env.get("CURSOR_API_KEY").map(String::as_str), + Some("my-key") + ); + assert!(!env.contains_key("CURSOR_API_BASE_URL")); + assert_eq!( + env.get("CURSOR_AUTH_MODE").map(String::as_str), + Some("custom") ); // Subscription passes an empty key → present but empty, so the probe @@ -14492,13 +14864,271 @@ wire_api = "chat" Some("") ); - // No override + empty DB → key still materialized empty. + // No override + empty legacy DB remains legacy; no credential policy is + // invented until the auth mode is explicitly known. let none = cursor_probe_env(&db, None).await; - assert_eq!(none.get("CURSOR_API_KEY").map(String::as_str), Some("")); + assert!(!none.contains_key("CURSOR_API_KEY")); + assert!(!none.contains_key("CURSOR_API_BASE_URL")); + } + + #[cfg(unix)] + fn cursor_probe_script(body: &str) -> (tempfile::TempDir, PathBuf) { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("probe tempdir"); + let path = dir.path().join("fake-cursor-agent"); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write fake CLI"); + let mut permissions = std::fs::metadata(&path).expect("metadata").permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&path, permissions).expect("make executable"); + (dir, path) + } + + #[cfg(unix)] + #[tokio::test] + async fn cursor_probe_runner_enforces_exit_utf8_empty_and_pipe_bounds() { + let env = BTreeMap::new(); + let cases = [ + ( + "printf 'usable output\\n'; exit 7", + CursorProbeError::NonZeroExit, + ), + ("printf '\\377'", CursorProbeError::InvalidUtf8), + ("exit 0", CursorProbeError::EmptyOutput), + ( + "head -c 1048577 /dev/zero", + CursorProbeError::OutputTooLarge, + ), + ( + "head -c 262145 /dev/zero >&2; printf ok", + CursorProbeError::OutputTooLarge, + ), + ]; + for (body, expected) in cases { + let (_dir, path) = cursor_probe_script(body); + let error = run_cursor_probe_binary(&path, &["models"], Duration::from_secs(3), &env) + .await + .expect_err("unsafe output must fail closed"); + assert_eq!(error, expected); + assert!( + !error.to_string().contains("usable output"), + "stdout/stderr and credentials must not enter probe errors" + ); + } + + let (_dir, path) = cursor_probe_script("if read value; then exit 9; fi; printf 'ok\\n'"); + assert_eq!( + run_cursor_probe_binary(&path, &["models"], Duration::from_secs(3), &env) + .await + .expect("stdin is null"), + "ok\n" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn cursor_probe_timeout_kills_and_reaps_descendants() { + let (dir, path) = cursor_probe_script( + "trap '' TERM; (trap '' TERM; while :; do sleep 1; done) & child=$!; \ + printf '%s:%s' \"$$\" \"$child\" > \"$PID_FILE\"; wait", + ); + let pid_file = dir.path().join("child.pid"); + let env: BTreeMap = [( + "PID_FILE".to_string(), + pid_file.to_string_lossy().into_owned(), + )] + .into(); + assert_eq!( + run_cursor_probe_binary(&path, &["models"], Duration::from_millis(150), &env) + .await + .expect_err("probe must time out"), + CursorProbeError::Timeout + ); + let recorded = std::fs::read_to_string(&pid_file).expect("probe and child pids"); + let pids: Vec = recorded + .split(':') + .map(|pid| pid.parse().expect("numeric pid")) + .collect(); + for pid in pids { + let mut reaped = false; + for _ in 0..20 { + if unsafe { libc::kill(pid, 0) } != 0 { + reaped = true; + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + assert!(reaped, "probe process {pid} survived timeout cleanup"); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn cursor_probe_cache_singleflights_and_keys_environment_and_binary_identity() { + let (dir, path) = cursor_probe_script( + "printf x >> \"$COUNT_FILE\"; sleep 0.1; printf 'auto - Auto (default)\\n'", + ); + let count_file = dir.path().join("count"); + let base_env: BTreeMap = [ + ( + "COUNT_FILE".to_string(), + count_file.to_string_lossy().into_owned(), + ), + ("CURSOR_AUTH_MODE".to_string(), "custom".to_string()), + ("CURSOR_API_KEY".to_string(), "secret-a".to_string()), + ( + "CURSOR_CONFIG_DIR".to_string(), + dir.path().to_string_lossy().into_owned(), + ), + ] + .into(); + let (left, right) = tokio::join!( + run_cursor_probe_cached_for_binary(&path, None, &["models"], 3, &base_env), + run_cursor_probe_cached_for_binary(&path, None, &["models"], 3, &base_env) + ); + left.expect("first probe"); + right.expect("singleflight follower"); + assert_eq!(std::fs::read_to_string(&count_file).unwrap(), "x"); + + run_cursor_probe_cached_for_binary(&path, None, &["models"], 3, &base_env) + .await + .expect("success stays cached during its TTL"); + assert_eq!(std::fs::read_to_string(&count_file).unwrap(), "x"); + + let mut other_account = base_env.clone(); + other_account.insert("CURSOR_API_KEY".into(), "secret-b".into()); + run_cursor_probe_cached_for_binary(&path, None, &["models"], 3, &other_account) + .await + .expect("different account is a cache miss"); + assert_eq!(std::fs::read_to_string(&count_file).unwrap(), "xx"); + + let mut other_mode = base_env.clone(); + other_mode.insert("CURSOR_AUTH_MODE".into(), "subscription".into()); + other_mode.insert("CURSOR_API_KEY".into(), String::new()); + run_cursor_probe_cached_for_binary(&path, None, &["models"], 3, &other_mode) + .await + .expect("different auth mode is a cache miss"); + assert_eq!(std::fs::read_to_string(&count_file).unwrap(), "xxx"); + + let mut other_endpoint = other_account.clone(); + other_endpoint.insert( + "CURSOR_API_BASE_URL".into(), + "https://cursor.example.test".into(), + ); + run_cursor_probe_cached_for_binary(&path, None, &["models"], 3, &other_endpoint) + .await + .expect("different endpoint is a cache miss"); + assert_eq!(std::fs::read_to_string(&count_file).unwrap(), "xxxx"); + + let other_config_dir = dir.path().join("other-config"); + std::fs::create_dir(&other_config_dir).expect("other config dir"); + let mut other_config = other_account.clone(); + other_config.insert( + "CURSOR_CONFIG_DIR".into(), + other_config_dir.to_string_lossy().into_owned(), + ); + run_cursor_probe_cached_for_binary(&path, None, &["models"], 3, &other_config) + .await + .expect("different config directory is a cache miss"); + assert_eq!(std::fs::read_to_string(&count_file).unwrap(), "xxxxx"); + + std::fs::write(dir.path().join("cli-config.json"), "{\"changed\":true}") + .expect("config mutation"); + run_cursor_probe_cached_for_binary(&path, None, &["models"], 3, &other_account) + .await + .expect("config identity invalidates cache"); + assert_eq!(std::fs::read_to_string(&count_file).unwrap(), "xxxxxx"); + + run_cursor_probe_cached_for_binary( + &path, + Some("different-version"), + &["models"], + 3, + &other_account, + ) + .await + .expect("different managed binary version is a cache miss"); + assert_eq!(std::fs::read_to_string(&count_file).unwrap(), "xxxxxxx"); + } + + #[cfg(unix)] + #[tokio::test] + async fn cursor_probe_cache_uses_short_failure_backoff_then_recovers() { + let (dir, path) = cursor_probe_script( + "if [ ! -e \"$MARKER\" ]; then : > \"$MARKER\"; exit 9; fi; \ + printf 'auto - Auto (default)\\n'", + ); + let marker = dir.path().join("marker"); + let env: BTreeMap = [ + ("MARKER".to_string(), marker.to_string_lossy().into_owned()), + ( + "CURSOR_CONFIG_DIR".to_string(), + dir.path().to_string_lossy().into_owned(), + ), + ] + .into(); + let key = cursor_probe_cache_key(&path, None, &["models"], &env); + assert_eq!( + run_cursor_probe_cached_for_binary(&path, None, &["models"], 3, &env) + .await + .expect_err("first probe fails"), + CursorProbeError::NonZeroExit + ); + assert_eq!( + cursor_probe_cache_key(&path, None, &["models"], &env), + key, + "probe execution must not mutate its cache identity" + ); + assert!(cursor_probe_cache().lock().await.entries.contains_key(&key)); + assert_eq!( + run_cursor_probe_cached_for_binary(&path, None, &["models"], 3, &env) + .await + .expect_err("failure is briefly backed off"), + CursorProbeError::NonZeroExit + ); + + if let Some(entry) = cursor_probe_cache().lock().await.entries.get_mut(&key) { + entry.expires_at = Instant::now(); + } + assert!( + run_cursor_probe_cached_for_binary(&path, None, &["models"], 3, &env) + .await + .expect("failure is not cached forever") + .contains("Auto") + ); + } + + #[test] + fn cursor_effective_environment_scrubs_subscription_but_preserves_custom_values() { + let subscription: BTreeMap = [ + ("CURSOR_AUTH_MODE".into(), "subscription".into()), + ("CURSOR_API_KEY".into(), "stale-secret".into()), + ("CURSOR_API_BASE_URL".into(), "https://stale.example".into()), + ] + .into(); + let effective = cursor_effective_runtime_env(&subscription); + assert_eq!( + effective.get("CURSOR_API_KEY").map(String::as_str), + Some("") + ); assert_eq!( - none.get("CURSOR_API_BASE_URL").map(String::as_str), + effective.get("CURSOR_API_BASE_URL").map(String::as_str), Some("") ); + + let custom: BTreeMap = [ + ("CURSOR_AUTH_MODE".into(), "custom".into()), + ("CURSOR_API_KEY".into(), "explicit-secret".into()), + ( + "CURSOR_API_BASE_URL".into(), + "https://custom.example".into(), + ), + ] + .into(); + let effective_custom = cursor_effective_runtime_env(&custom); + for key in ["CURSOR_AUTH_MODE", "CURSOR_API_KEY", "CURSOR_API_BASE_URL"] { + assert_eq!(effective_custom.get(key), custom.get(key)); + } } #[test] diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index 234aeee0c..7366eac71 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -352,18 +352,25 @@ pub struct AcpSetConfigOptionParams { pub connection_id: String, pub config_id: String, pub value_id: String, + #[serde(default)] + pub operation_id: Option, } pub async fn acp_set_config_option( Extension(state): Extension>, Json(params): Json, -) -> Result, AppCommandError> { +) -> Result>, AppCommandError> { let manager = &state.connection_manager; - manager - .set_config_option(¶ms.connection_id, params.config_id, params.value_id) + let accepted_operation_id = manager + .set_config_option( + ¶ms.connection_id, + params.config_id, + params.value_id, + params.operation_id, + ) .await .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; - Ok(Json(())) + Ok(Json(accepted_operation_id)) } #[derive(Deserialize)] diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index 53cf31f8c..70698c6ff 100644 --- a/src/components/chat/message-input.test.tsx +++ b/src/components/chat/message-input.test.tsx @@ -603,7 +603,6 @@ describe("MessageInput collapsed selectors popover", () => { await waitFor(() => expect(container.querySelector('[role="textbox"]')).not.toBeNull() ) - const settingsLabel = enMessages.Folder.chat.messageInput.agentSettings await user.click(screen.getByRole("button", { name: settingsLabel })) const popover = await screen.findByRole("dialog", { name: settingsLabel }) @@ -639,6 +638,7 @@ describe("MessageInput collapsed selectors popover", () => { name: "Model", description: null, category: "model", + pending_operation_id: "cursor-operation-test", kind: { type: "select", current_value: composite("gpt-5.3-codex-high-fast"), @@ -676,6 +676,12 @@ describe("MessageInput collapsed selectors popover", () => { await waitFor(() => expect(container.querySelector('[role="textbox"]')).not.toBeNull() ) + expect( + screen.getByRole("button", { name: /Model: Codex 5\.3 High Fast/ }) + ).toHaveAttribute("aria-busy", "true") + expect( + screen.getByLabelText(enMessages.Folder.chat.messageInput.applyingModel) + ).toBeInTheDocument() const settingsLabel = enMessages.Folder.chat.messageInput.agentSettings await user.click(screen.getByRole("button", { name: settingsLabel })) diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 17fa9a8e9..29820f707 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -1494,6 +1494,7 @@ export function MessageInput({ currentLabel: current?.name ?? kind.current_value, groups, onSelect: (value) => onConfigOptionChange?.(option.id, value), + pending: Boolean(option.pending_operation_id), ...(searchable && { search: { placeholder: t("searchModel"), diff --git a/src/components/chat/model-option-picker.tsx b/src/components/chat/model-option-picker.tsx index e3cf39637..6bf5f11f8 100644 --- a/src/components/chat/model-option-picker.tsx +++ b/src/components/chat/model-option-picker.tsx @@ -1,7 +1,7 @@ "use client" import { useMemo, useState } from "react" -import { ChevronDown } from "lucide-react" +import { ChevronDown, LoaderCircle } from "lucide-react" import { useTranslations } from "next-intl" import { Button } from "@/components/ui/button" import { @@ -42,6 +42,7 @@ export function ModelOptionPicker({ useScrollbarSafeDismiss() const kind = option.kind.type === "select" ? option.kind : null const currentValue = kind?.current_value ?? "" + const applying = Boolean(option.pending_operation_id) const currentLabel = useMemo(() => { for (const group of groups) { for (const opt of group.options) { @@ -60,13 +61,21 @@ export function ModelOptionPicker({ variant="ghost" size="xs" title={option.name} + aria-busy={applying} aria-label={ currentLabel ? `${option.name}: ${currentLabel}` : option.name } className="min-w-0 gap-0.5 px-1 text-muted-foreground" > {currentLabel} - + {applying ? ( + + ) : ( + + )} void + pending?: boolean /** When set, the detail pane renders a searchable + virtualized list instead * of the plain button list — used for long model lists that otherwise jank. */ search?: SessionSelectorSearch @@ -101,13 +102,16 @@ export function SessionSelectorsPanel({ - {setting.currentLabel} + {setting.pending ? ( + + ) : null} + {setting.currentLabel} ) diff --git a/src/components/settings/cursor-config-panel.test.tsx b/src/components/settings/cursor-config-panel.test.tsx index 2baf58b7f..412e09511 100644 --- a/src/components/settings/cursor-config-panel.test.tsx +++ b/src/components/settings/cursor-config-panel.test.tsx @@ -184,6 +184,24 @@ describe("CursorConfigPanel", () => { vi.mocked(acpUpdateAgentConfig).mockResolvedValue(0) }) + it("renders stable localized probe codes without exposing raw probe output", async () => { + vi.mocked(acpCursorAuthStatus).mockResolvedValue({ + installed: true, + is_authenticated: false, + raw_status: null, + email: null, + membership: null, + error: "stderr contained account-secret", + error_code: "cursor_probe_timeout", + binary_path: "/cache/cursor-agent", + }) + renderPanel({ env: {} }) + expect( + await screen.findByText(enMessages.AcpAgentSettings.cursor.probeTimeout) + ).toBeInTheDocument() + expect(screen.queryByText(/account-secret/)).not.toBeInTheDocument() + }) + it("rolls the env back when the rules write fails (API-key mode)", async () => { // A saved API key opens the panel in API-key mode. The widening hazard: the // env step already persisted (e.g. Run Everything on) but the deny rules diff --git a/src/components/settings/cursor-config-panel.tsx b/src/components/settings/cursor-config-panel.tsx index eb9dc3663..8515428fc 100644 --- a/src/components/settings/cursor-config-panel.tsx +++ b/src/components/settings/cursor-config-panel.tsx @@ -231,6 +231,25 @@ export function CursorConfigPanel({ onAffectedSessions: (count: number) => void }) { const t = useTranslations("AcpAgentSettings") + const cursorProbeError = useCallback( + (code?: string | null) => { + switch (code) { + case "cursor_probe_timeout": + return t("cursor.probeTimeout") + case "cursor_probe_output_too_large": + return t("cursor.probeOutputTooLarge") + case "cursor_probe_nonzero_exit": + return t("cursor.probeNonzeroExit") + case "cursor_probe_invalid_utf8": + case "cursor_probe_empty_output": + case "cursor_probe_invalid_format": + return t("cursor.probeInvalidOutput") + default: + return t("cursor.probeFailed") + } + }, + [t] + ) // --- authentication method --- const [mode, setMode] = useState(() => @@ -332,17 +351,17 @@ export function CursorConfigPanel({ isDefault: m.is_default, })) ) - setModelsError(result.error) + setModelsError(result.error ? cursorProbeError(result.error_code) : null) setModelsLoaded(true) - } catch (e) { + } catch { if (mountedRef.current) { - setModelsError(e instanceof Error ? e.message : String(e)) + setModelsError(cursorProbeError()) setModelsLoaded(true) } } finally { if (mountedRef.current) setModelsLoading(false) } - }, []) + }, [cursorProbeError]) const authState: "loading" | "missing" | "ok" | "unauthenticated" = authLoading && !auth @@ -619,7 +638,9 @@ export function CursorConfigPanel({ ) : null} {auth?.error ? ( -

{auth.error}

+

+ {cursorProbeError(auth.error_code)} +

) : null} {/* No model list yet (not signed in / empty): tell the user why the diff --git a/src/contexts/acp-connections-context.test.tsx b/src/contexts/acp-connections-context.test.tsx index e0f0a5f72..7d5f296fd 100644 --- a/src/contexts/acp-connections-context.test.tsx +++ b/src/contexts/acp-connections-context.test.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react" -import { act, render } from "@testing-library/react" +import { act, cleanup, render } from "@testing-library/react" import { useTranslations } from "next-intl" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { @@ -129,12 +129,13 @@ function Probe() { } async function mountProvider() { - render( + const view = render( ) await act(async () => {}) + return view } const TAB = "conv-1-claude_code-42" @@ -183,7 +184,14 @@ beforeEach(() => { h.acpConnect.mockResolvedValue("spawned-conn") h.acpDisconnect.mockResolvedValue(undefined) h.acpGetSessionSnapshot.mockResolvedValue(null) - h.acpSetConfigOption.mockResolvedValue(undefined) + h.acpSetConfigOption.mockImplementation( + async ( + _connectionId: string, + _configId: string, + _valueId: string, + operationId?: string + ) => operationId + ) }) function latestAttachHandlers(): AttachHandlers { @@ -2046,35 +2054,68 @@ describe("AcpConnectionsProvider Cursor composite model switching", () => { type: "session_config_options", config_options: cursorCompositeOptions(LOW), }) + vi.mocked(saveConfigPreference).mockClear() await act(async () => { await h.actions!.setConfigOption(TAB, "model", HIGH_FAST) await h.actions!.setConfigOption(TAB, "model", EXTRA_HIGH) }) + expect(saveConfigPreference).not.toHaveBeenCalled() expect( h.store!.getConnection(TAB)!.configOptions?.[0]?.kind.current_value ).toBe(EXTRA_HIGH) + expect( + h.store!.getConnection(TAB)!.configOptions?.[0]?.pending_operation_id + ).toBe("cursor-operation-2") // Completion for the first click arrives after the second optimistic pick. emitAcpEvent(handlers, { seq: 2, connection_id: "spawned-conn", type: "session_config_options", + operation_id: "cursor-operation-1", + operation_status: "applied", config_options: cursorCompositeOptions(HIGH_FAST), }) expect( h.store!.getConnection(TAB)!.configOptions?.[0]?.kind.current_value ).toBe(EXTRA_HIGH) + expect( + h.store!.getConnection(TAB)!.configOptions?.[0]?.pending_operation_id + ).toBe("cursor-operation-2") emitAcpEvent(handlers, { seq: 3, connection_id: "spawned-conn", type: "session_config_options", + operation_id: "cursor-operation-1", + operation_status: "failed", + config_options: cursorCompositeOptions(LOW), + }) + expect( + h.store!.getConnection(TAB)!.configOptions?.[0]?.kind.current_value + ).toBe(EXTRA_HIGH) + + emitAcpEvent(handlers, { + seq: 4, + connection_id: "spawned-conn", + type: "session_config_options", + operation_id: "cursor-operation-2", + operation_status: "applied", config_options: cursorCompositeOptions(EXTRA_HIGH), }) expect( h.store!.getConnection(TAB)!.configOptions?.[0]?.kind.current_value ).toBe(EXTRA_HIGH) + expect(saveConfigPreference).toHaveBeenCalledTimes(1) + expect(saveConfigPreference).toHaveBeenCalledWith( + "cursor", + "model", + EXTRA_HIGH + ) + expect( + h.store!.getConnection(TAB)!.configOptions?.[0]?.pending_operation_id + ).toBeUndefined() }) it("rolls back the optimistic row when a composite parameter fails", async () => { @@ -2085,6 +2126,7 @@ describe("AcpConnectionsProvider Cursor composite model switching", () => { type: "session_config_options", config_options: cursorCompositeOptions(LOW), }) + vi.mocked(saveConfigPreference).mockClear() await act(async () => { await h.actions!.setConfigOption(TAB, "model", HIGH_FAST) @@ -2093,13 +2135,18 @@ describe("AcpConnectionsProvider Cursor composite model switching", () => { seq: 2, connection_id: "spawned-conn", type: "session_config_options", + operation_id: "cursor-operation-1", + operation_status: "failed", config_options: cursorCompositeOptions(LOW), }) - // The rollback is held until the paired error proves the newest request - // failed; this same guard is what suppresses a stale older completion. + // The correlated terminal snapshot is authoritative; the following error + // is display-only and cannot roll a newer operation backward. expect( h.store!.getConnection(TAB)!.configOptions?.[0]?.kind.current_value - ).toBe(HIGH_FAST) + ).toBe(LOW) + expect( + h.store!.getConnection(TAB)!.configOptions?.[0]?.pending_operation_id + ).toBeUndefined() emitAcpEvent(handlers, { seq: 3, @@ -2111,11 +2158,7 @@ describe("AcpConnectionsProvider Cursor composite model switching", () => { expect( h.store!.getConnection(TAB)!.configOptions?.[0]?.kind.current_value ).toBe(LOW) - expect(saveConfigPreference).toHaveBeenLastCalledWith( - "cursor", - "model", - LOW - ) + expect(saveConfigPreference).not.toHaveBeenCalled() }) it("rolls back immediately when the config request cannot be queued", async () => { @@ -2126,6 +2169,7 @@ describe("AcpConnectionsProvider Cursor composite model switching", () => { type: "session_config_options", config_options: cursorCompositeOptions(LOW), }) + vi.mocked(saveConfigPreference).mockClear() h.acpSetConfigOption.mockRejectedValueOnce(new Error("connection gone")) let failure: unknown @@ -2140,10 +2184,224 @@ describe("AcpConnectionsProvider Cursor composite model switching", () => { expect( h.store!.getConnection(TAB)!.configOptions?.[0]?.kind.current_value ).toBe(LOW) - expect(saveConfigPreference).toHaveBeenLastCalledWith( + expect(saveConfigPreference).not.toHaveBeenCalled() + }) + + it("rejects a mismatched non-legacy operation acknowledgement", async () => { + const handlers = await connectCursorOwner() + emitAcpEvent(handlers, { + seq: 1, + connection_id: "spawned-conn", + type: "session_config_options", + config_options: cursorCompositeOptions(LOW), + }) + vi.mocked(saveConfigPreference).mockClear() + h.acpSetConfigOption.mockResolvedValueOnce("different-operation") + + let failure: unknown + await act(async () => { + try { + await h.actions!.setConfigOption(TAB, "model", HIGH_FAST) + } catch (error) { + failure = error + } + }) + + expect(failure).toEqual( + new Error("Cursor operation acknowledgement mismatch") + ) + const model = h.store!.getConnection(TAB)!.configOptions?.[0] + expect(model?.kind.type === "select" && model.kind.current_value).toBe(LOW) + expect(model?.pending_operation_id).toBeUndefined() + expect(saveConfigPreference).not.toHaveBeenCalled() + }) + + it("clears and reverts an applying composite when the connection drops", async () => { + const handlers = await connectCursorOwner() + emitAcpEvent(handlers, { + seq: 1, + connection_id: "spawned-conn", + type: "session_config_options", + config_options: cursorCompositeOptions(LOW), + }) + vi.mocked(saveConfigPreference).mockClear() + await act(async () => { + await h.actions!.setConfigOption(TAB, "model", HIGH_FAST) + }) + emitAcpEvent(handlers, { + seq: 2, + connection_id: "spawned-conn", + type: "status_changed", + status: "disconnected", + }) + const model = h.store!.getConnection(TAB)!.configOptions?.[0] + expect(model?.kind.type === "select" && model.kind.current_value).toBe(LOW) + expect(model?.pending_operation_id).toBeUndefined() + expect(saveConfigPreference).not.toHaveBeenCalled() + + // A terminal event already queued by the disconnected backend cannot + // resurrect the abandoned optimistic operation after tracking is cleared. + emitAcpEvent(handlers, { + seq: 3, + connection_id: "spawned-conn", + type: "session_config_options", + operation_id: "cursor-operation-1", + operation_status: "applied", + config_options: cursorCompositeOptions(HIGH_FAST), + }) + expect( + h.store!.getConnection(TAB)!.configOptions?.[0]?.kind.current_value + ).toBe(LOW) + expect(saveConfigPreference).not.toHaveBeenCalled() + }) + + it("clears and reverts an applying composite on a terminal error", async () => { + const handlers = await connectCursorOwner() + emitAcpEvent(handlers, { + seq: 1, + connection_id: "spawned-conn", + type: "session_config_options", + config_options: cursorCompositeOptions(LOW), + }) + vi.mocked(saveConfigPreference).mockClear() + await act(async () => { + await h.actions!.setConfigOption(TAB, "model", HIGH_FAST) + }) + + emitAcpEvent(handlers, { + seq: 2, + connection_id: "spawned-conn", + type: "error", + message: "agent terminated", + agent_type: "cursor", + code: "process_exited", + terminal: true, + }) + + const model = h.store!.getConnection(TAB)!.configOptions?.[0] + expect(model?.kind.type === "select" && model.kind.current_value).toBe(LOW) + expect(model?.pending_operation_id).toBeUndefined() + expect(saveConfigPreference).not.toHaveBeenCalled() + }) + + it("keeps an uncorrelated model-step update pending until the full operation completes", async () => { + const handlers = await connectCursorOwner() + emitAcpEvent(handlers, { + seq: 1, + connection_id: "spawned-conn", + type: "session_config_options", + config_options: cursorCompositeOptions(LOW), + }) + vi.mocked(saveConfigPreference).mockClear() + await act(async () => { + await h.actions!.setConfigOption(TAB, "model", HIGH_FAST) + }) + + emitAcpEvent(handlers, { + seq: 2, + connection_id: "spawned-conn", + type: "session_config_options", + config_options: cursorCompositeOptions(LOW), + }) + const model = h.store!.getConnection(TAB)!.configOptions?.[0] + expect(model?.kind.type === "select" && model.kind.current_value).toBe( + HIGH_FAST + ) + expect(model?.pending_operation_id).toBe("cursor-operation-1") + expect(saveConfigPreference).not.toHaveBeenCalled() + }) + + it("reverts pending state on a new session and never persists it on unmount", async () => { + const handlers = await connectCursorOwner() + emitAcpEvent(handlers, { + seq: 1, + connection_id: "spawned-conn", + type: "session_config_options", + config_options: cursorCompositeOptions(LOW), + }) + vi.mocked(saveConfigPreference).mockClear() + await act(async () => { + await h.actions!.setConfigOption(TAB, "model", HIGH_FAST) + }) + emitAcpEvent(handlers, { + seq: 2, + connection_id: "spawned-conn", + type: "session_started", + session_id: "reconnected-session", + }) + const model = h.store!.getConnection(TAB)!.configOptions?.[0] + expect(model?.kind.type === "select" && model.kind.current_value).toBe(LOW) + expect(model?.pending_operation_id).toBeUndefined() + expect(saveConfigPreference).not.toHaveBeenCalled() + + await act(async () => { + await h.actions!.setConfigOption(TAB, "model", HIGH_FAST) + }) + act(() => cleanup()) + expect(saveConfigPreference).not.toHaveBeenCalled() + }) + + it("persists Auto only after its correlated full-success snapshot", async () => { + const handlers = await connectCursorOwner() + emitAcpEvent(handlers, { + seq: 1, + connection_id: "spawned-conn", + type: "session_config_options", + config_options: cursorCompositeOptions(LOW), + }) + vi.mocked(saveConfigPreference).mockClear() + await act(async () => { + await h.actions!.setConfigOption(TAB, "model", "default") + }) + expect(saveConfigPreference).not.toHaveBeenCalled() + expect( + h.store!.getConnection(TAB)!.configOptions?.[0]?.pending_operation_id + ).toBe("cursor-operation-1") + + emitAcpEvent(handlers, { + seq: 2, + connection_id: "spawned-conn", + type: "session_config_options", + operation_id: "cursor-operation-1", + operation_status: "applied", + config_options: cursorCompositeOptions("default"), + }) + expect(saveConfigPreference).toHaveBeenCalledWith( + "cursor", + "model", + "default" + ) + expect( + h.store!.getConnection(TAB)!.configOptions?.[0]?.pending_operation_id + ).toBeUndefined() + }) + + it("accepts an exact terminal snapshot from an old backend without operation IDs", async () => { + const handlers = await connectCursorOwner() + emitAcpEvent(handlers, { + seq: 1, + connection_id: "spawned-conn", + type: "session_config_options", + config_options: cursorCompositeOptions(LOW), + }) + vi.mocked(saveConfigPreference).mockClear() + h.acpSetConfigOption.mockResolvedValueOnce(undefined) + await act(async () => { + await h.actions!.setConfigOption(TAB, "model", HIGH_FAST) + }) + emitAcpEvent(handlers, { + seq: 2, + connection_id: "spawned-conn", + type: "session_config_options", + config_options: cursorCompositeOptions(HIGH_FAST), + }) + expect( + h.store!.getConnection(TAB)!.configOptions?.[0]?.kind.current_value + ).toBe(HIGH_FAST) + expect(saveConfigPreference).toHaveBeenCalledWith( "cursor", "model", - LOW + HIGH_FAST ) }) @@ -2156,9 +2414,41 @@ describe("AcpConnectionsProvider Cursor composite model switching", () => { message: "Saved Cursor model variant is no longer available: old-composite", agent_type: "cursor", + code: "cursor_model_variant_unavailable", }) expect(clearConfigPreference).toHaveBeenCalledWith("cursor", "model") }) + + it("localizes stable Cursor failures without exposing backend English", async () => { + const handlers = await connectCursorOwner() + const cases = [ + ["cursor_config_option_failed", "backendErrors.cursorConfigOptionFailed"], + ["cursor_model_restore_failed", "backendErrors.cursorModelRestoreFailed"], + [ + "cursor_model_catalog_unavailable", + "backendErrors.cursorModelCatalogUnavailable", + ], + [ + "cursor_model_variant_unavailable", + "backendErrors.cursorModelVariantUnavailable", + ], + [ + "cursor_model_variant_ambiguous", + "backendErrors.cursorModelVariantAmbiguous", + ], + ] as const + cases.forEach(([code, expected], index) => { + emitAcpEvent(handlers, { + seq: index + 1, + connection_id: "spawned-conn", + type: "error", + message: "raw backend English must stay hidden", + agent_type: "cursor", + code, + }) + expect(h.store!.getConnection(TAB)!.error).toBe(expected) + }) + }) }) describe("empty-turn error diagnostics", () => { diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index 017709911..2912b96c3 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -532,6 +532,7 @@ type Action = contextKey: string configId: string valueId: string + operationId?: string } | { type: "PLAN_UPDATE" @@ -952,7 +953,8 @@ function sameConfigOptions( left.id !== right.id || left.name !== right.name || left.description !== right.description || - left.category !== right.category + left.category !== right.category || + left.pending_operation_id !== right.pending_operation_id ) { return false } @@ -2219,9 +2221,12 @@ function connectionsReducer( const idx = options.findIndex((o) => o.id === action.configId) if (idx === -1) return state const opt = options[idx] + if (opt.kind.type !== "select") { + return state + } if ( - opt.kind.type !== "select" || - opt.kind.current_value === action.valueId + opt.kind.current_value === action.valueId && + (!action.operationId || opt.pending_operation_id === action.operationId) ) { return state } @@ -2229,6 +2234,9 @@ function connectionsReducer( updated[idx] = { ...opt, kind: { ...opt.kind, current_value: action.valueId }, + ...(action.operationId + ? { pending_operation_id: action.operationId } + : {}), } const next = new Map(state) next.set(action.contextKey, { ...conn, configOptions: updated }) @@ -2721,16 +2729,23 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { // the newest request fails, the following error releases the backend's last // confirmed snapshot instead of leaving a falsely-successful highlight. const pendingCursorConfigRef = useRef( - new Map() - ) - const deferredCursorConfigRef = useRef( - new Map() + new Map< + string, + { + configId: string + valueId: string + operationId: string + confirmedOptions: SessionConfigOptionInfo[] | null + legacyAllowed: boolean + deferredOptions?: SessionConfigOptionInfo[] + } + >() ) - const confirmedCursorModelRef = useRef(new Map()) + const latestCursorOperationRef = useRef(new Map()) + const cursorOperationSeqRef = useRef(0) const clearCursorConfigTracking = useCallback((contextKey: string) => { pendingCursorConfigRef.current.delete(contextKey) - deferredCursorConfigRef.current.delete(contextKey) - confirmedCursorModelRef.current.delete(contextKey) + latestCursorOperationRef.current.delete(contextKey) }, []) // contextKey → active EventStream subscription handle. Populated only for @@ -3249,6 +3264,17 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { switch (e.type) { case "status_changed": flushStreamingQueue() + if (e.status === "disconnected" || e.status === "error") { + const pending = pendingCursorConfigRef.current.get(contextKey) + if (pending?.confirmedOptions) { + dispatch({ + type: "SESSION_CONFIG_OPTIONS", + contextKey, + configOptions: pending.confirmedOptions, + }) + } + clearCursorConfigTracking(contextKey) + } dispatch({ type: "STATUS_CHANGED", contextKey, status: e.status }) break case "content_delta": @@ -3520,6 +3546,16 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { break case "session_started": flushStreamingQueue() + { + const pending = pendingCursorConfigRef.current.get(contextKey) + if (pending?.confirmedOptions) { + dispatch({ + type: "SESSION_CONFIG_OPTIONS", + contextKey, + configOptions: pending.confirmedOptions, + }) + } + } clearCursorConfigTracking(contextKey) dispatch({ type: "SESSION_STARTED", @@ -3579,6 +3615,32 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { // into `current_value` before emitting. const cfgConn = storeRef.current.connections.get(contextKey) const pending = pendingCursorConfigRef.current.get(contextKey) + if (cfgConn?.agentType === "cursor") { + const latestOperation = + latestCursorOperationRef.current.get(contextKey) + if ( + e.operation_id && + (!pending || + !latestOperation || + e.operation_id !== latestOperation) + ) { + break + } + // An uncorrelated update is not proof for the modern protocol. Keep + // it only for the old-backend fallback, enabled after the enqueue + // response proves operation correlation is unsupported. + if (pending) { + if (!e.operation_id) { + pending.deferredOptions = e.config_options + if (!pending.legacyAllowed) break + } else if ( + e.operation_id !== pending.operationId || + !e.operation_status + ) { + break + } + } + } if (cfgConn?.agentType === "cursor" && pending) { const incoming = e.config_options.find( (option) => option.id === pending.configId @@ -3587,27 +3649,14 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { incoming?.kind.type === "select" ? incoming.kind.current_value : undefined - if (incomingValue !== pending.valueId) { - deferredCursorConfigRef.current.set(contextKey, e.config_options) - break - } + const fullyApplied = e.operation_id + ? e.operation_status === "applied" && + incomingValue === pending.valueId + : pending.legacyAllowed && incomingValue === pending.valueId + if (!fullyApplied && !e.operation_id) break pendingCursorConfigRef.current.delete(contextKey) - deferredCursorConfigRef.current.delete(contextKey) - } - if (cfgConn?.agentType === "cursor") { - const confirmedModel = e.config_options.find( - (option) => option.id === "model" - ) - if ( - confirmedModel?.kind.type === "select" && - !confirmedModel.kind.current_value.startsWith( - "__codeg_cursor_current_unavailable__" - ) - ) { - confirmedCursorModelRef.current.set( - contextKey, - confirmedModel.kind.current_value - ) + if (fullyApplied) { + saveConfigPreference("cursor", pending.configId, pending.valueId) } } dispatch({ @@ -3769,46 +3818,40 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { case "error": { flushStreamingQueue() const nc = storeRef.current.connections.get(contextKey) - const cursorConfigFailure = - e.code === "cursor_config_option_failed" || - e.code === "cursor_model_restore_failed" || - e.code === "cursor_model_variant_unavailable" || - e.message.startsWith("Failed to set config option:") || - e.message.startsWith("Failed to restore Cursor model variant:") || - e.message.startsWith( - "Saved Cursor model variant is no longer available:" - ) - if (nc?.agentType === "cursor" && cursorConfigFailure) { - pendingCursorConfigRef.current.delete(contextKey) - const confirmed = deferredCursorConfigRef.current.get(contextKey) - deferredCursorConfigRef.current.delete(contextKey) - if (confirmed) { + if (nc?.agentType === "cursor" && e.terminal) { + const pending = pendingCursorConfigRef.current.get(contextKey) + if (pending?.confirmedOptions) { dispatch({ type: "SESSION_CONFIG_OPTIONS", contextKey, - configOptions: confirmed, + configOptions: pending.confirmedOptions, }) - const entry = selectorsCache.get(nc.agentType) ?? { - modes: null, - configOptions: null, - } - entry.configOptions = confirmed - selectorsCache.set(nc.agentType, entry) } - const confirmedValue = confirmed?.find( - (option) => option.id === "model" - )?.kind - const persistedValue = - confirmedValue?.type === "select" && - !confirmedValue.current_value.startsWith( - "__codeg_cursor_current_unavailable__" - ) - ? confirmedValue.current_value - : confirmedCursorModelRef.current.get(contextKey) - if (persistedValue) { - saveConfigPreference("cursor", "model", persistedValue) - } else { - clearConfigPreference("cursor", "model") + clearCursorConfigTracking(contextKey) + } + if ( + nc?.agentType === "cursor" && + (e.code === "cursor_model_restore_failed" || + e.code === "cursor_model_variant_unavailable") + ) { + clearConfigPreference("cursor", "model") + } + if ( + nc?.agentType === "cursor" && + e.code === "cursor_config_option_failed" + ) { + const pending = pendingCursorConfigRef.current.get(contextKey) + if (pending?.legacyAllowed) { + const rollbackOptions = + pending.deferredOptions ?? pending.confirmedOptions + if (rollbackOptions) { + dispatch({ + type: "SESSION_CONFIG_OPTIONS", + contextKey, + configOptions: rollbackOptions, + }) + } + pendingCursorConfigRef.current.delete(contextKey) } } const agentLabel = nc @@ -3883,6 +3926,20 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { return t("backendErrors.grokModelSwitchIncompatibleAgent", { agent: agentLabel, }) + case "cursor_config_option_failed": + return t("backendErrors.cursorConfigOptionFailed", { + agent: agentLabel, + }) + case "cursor_model_restore_failed": + return t("backendErrors.cursorModelRestoreFailed", { + agent: agentLabel, + }) + case "cursor_model_catalog_unavailable": + return t("backendErrors.cursorModelCatalogUnavailable") + case "cursor_model_variant_unavailable": + return t("backendErrors.cursorModelVariantUnavailable") + case "cursor_model_variant_ambiguous": + return t("backendErrors.cursorModelVariantAmbiguous") default: return e.message } @@ -5294,44 +5351,83 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { async (contextKey: string, configId: string, valueId: string) => { const conn = storeRef.current.connections.get(contextKey) if (!conn) return - const previousConfigOptions = conn.configOptions - if (conn.agentType === "cursor" && configId === "model") { - pendingCursorConfigRef.current.set(contextKey, { configId, valueId }) - deferredCursorConfigRef.current.delete(contextKey) + const isCursorComposite = + conn.agentType === "cursor" && configId === "model" + const previousPending = pendingCursorConfigRef.current.get(contextKey) + const operationId = isCursorComposite + ? `cursor-operation-${++cursorOperationSeqRef.current}` + : undefined + if (operationId) { + latestCursorOperationRef.current.set(contextKey, operationId) + pendingCursorConfigRef.current.set(contextKey, { + configId, + valueId, + operationId, + confirmedOptions: + previousPending?.confirmedOptions ?? conn.configOptions, + legacyAllowed: false, + }) } dispatch({ type: "CONFIG_OPTION_CHANGED", contextKey, configId, valueId, + operationId, }) - // Persist user selection to localStorage so the next `acp_connect` - // can ship it back to the backend as a preferred config value. - saveConfigPreference(conn.agentType, configId, valueId) + if (!isCursorComposite) { + // Single-step options retain the established optimistic preference + // behavior. Cursor composites persist only after a correlated full + // success event confirms model plus every parameter. + saveConfigPreference(conn.agentType, configId, valueId) + } lastActivityRef.current.set(contextKey, Date.now()) try { - await acpSetConfigOption(conn.connectionId, configId, valueId) - } catch (error) { + const acceptedOperationId = await acpSetConfigOption( + conn.connectionId, + configId, + valueId, + operationId + ) const pending = pendingCursorConfigRef.current.get(contextKey) if ( - conn.agentType === "cursor" && - configId === "model" && - pending?.valueId === valueId + operationId && + pending?.operationId === operationId && + acceptedOperationId !== operationId ) { - pendingCursorConfigRef.current.delete(contextKey) - deferredCursorConfigRef.current.delete(contextKey) - if (previousConfigOptions) { + if (acceptedOperationId != null) { + throw new Error("Cursor operation acknowledgement mismatch") + } + pending.legacyAllowed = true + const deferred = pending.deferredOptions + const deferredValue = deferred?.find( + (option) => option.id === pending.configId + )?.kind + if ( + deferred && + deferredValue?.type === "select" && + deferredValue.current_value === pending.valueId + ) { + pendingCursorConfigRef.current.delete(contextKey) dispatch({ type: "SESSION_CONFIG_OPTIONS", contextKey, - configOptions: previousConfigOptions, + configOptions: deferred, }) + saveConfigPreference("cursor", pending.configId, pending.valueId) } - const confirmed = confirmedCursorModelRef.current.get(contextKey) - if (confirmed) { - saveConfigPreference("cursor", "model", confirmed) - } else { - clearConfigPreference("cursor", "model") + } + } catch (error) { + const pending = pendingCursorConfigRef.current.get(contextKey) + if (operationId && pending?.operationId === operationId) { + pendingCursorConfigRef.current.delete(contextKey) + latestCursorOperationRef.current.delete(contextKey) + if (pending.confirmedOptions) { + dispatch({ + type: "SESSION_CONFIG_OPTIONS", + contextKey, + configOptions: pending.confirmedOptions, + }) } } throw error diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 2eeed2e91..576f79fe5 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -757,6 +757,11 @@ "modelTitle": "النموذج الافتراضي", "loadModels": "جلب النماذج", "modelsUnavailable": "قائمة النماذج غير متاحة", + "probeTimeout": "انتهت مهلة فحص Cursor CLI.", + "probeOutputTooLarge": "أعاد فحص Cursor CLI بيانات كثيرة جدًا.", + "probeNonzeroExit": "فشل فحص Cursor CLI.", + "probeInvalidOutput": "أعاد فحص Cursor CLI استجابة غير مدعومة.", + "probeFailed": "فشل فحص Cursor CLI.", "modelHint": "يُمرَّر إلى CLI عبر ‎--model عند بدء الجلسة. اتركه افتراضيًا ليختار Cursor بنفسه.", "permissionsTitle": "الأذونات ووضع الحماية", "permissionsDescription": "محرّر بصري لقواعد أذونات CLI‏ (cli-config.json). القواعد المسموحة تُنفَّذ دون تأكيد؛ والمرفوضة تُحظر دائمًا.", @@ -2583,6 +2588,11 @@ "turnFailedMaxTurnRequests": "بلغ {agent} الحد الأقصى لعدد الطلبات المسموح بها في هذه الجولة.", "turnFailedUnknown": "أنهى {agent} الجولة بسبب توقف غير معروف.", "grokModelSwitchIncompatibleAgent": "لا يمكن لـ {agent} التبديل إلى هذا النموذج في محادثة قائمة. ابدأ جلسة جديدة لاستخدامه.", + "cursorConfigOptionFailed": "تعذر على {agent} تطبيق إعداد النموذج بالكامل. تمت استعادة الحالة المؤكدة.", + "cursorModelRestoreFailed": "تعذر على {agent} استعادة إعداد نموذج Cursor المحفوظ.", + "cursorModelCatalogUnavailable": "دليل نماذج Cursor غير متاح. تبقى عناصر التحكم المعلّمة متاحة.", + "cursorModelVariantUnavailable": "لم يعد متغير نموذج Cursor المحفوظ متاحًا.", + "cursorModelVariantAmbiguous": "تعذر تعيين متغير نموذج Cursor بأمان.", "turnFailedEmpty": "أنهى {agent} الجولة دون إنتاج أي رد.", "turnFailedEmptyProtocol": "أنتج {agent} مخرجات تعذّر على codeg تحليلها — قد لا يتوافق إصدار الوكيل مع البروتوكول.", "turnFailedEmptyMetadata": "أرسل {agent} تحديثات حالة فقط في هذه الجولة (الخطة / الوضع / الاستخدام) دون أي رد.", @@ -2659,6 +2669,7 @@ "agentSettings": "إعدادات الوكيل", "cliDefaultSettings": "إعدادات CLI الافتراضية", "autoDefault": "Auto (default)", + "applyingModel": "جارٍ تطبيق إعداد النموذج", "searchModel": "البحث عن النماذج...", "searchModelAria": "البحث عن النماذج", "modelListLabel": "النماذج", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index f618a6775..b00db4a0b 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -757,6 +757,11 @@ "modelTitle": "Standardmodell", "loadModels": "Modelle laden", "modelsUnavailable": "Modellliste nicht verfügbar", + "probeTimeout": "Die Cursor-CLI-Prüfung hat das Zeitlimit überschritten.", + "probeOutputTooLarge": "Die Cursor-CLI-Prüfung hat zu viele Daten zurückgegeben.", + "probeNonzeroExit": "Die Cursor-CLI-Prüfung ist fehlgeschlagen.", + "probeInvalidOutput": "Die Cursor-CLI-Prüfung hat eine nicht unterstützte Antwort zurückgegeben.", + "probeFailed": "Die Cursor-CLI-Prüfung ist fehlgeschlagen.", "modelHint": "Wird der CLI beim Sitzungsstart als --model übergeben. Auf Standard lassen, damit Cursor wählt.", "permissionsTitle": "Berechtigungen & Sandbox", "permissionsDescription": "Visueller Editor für die Berechtigungsregeln der CLI (cli-config.json). Erlaubte Regeln laufen ohne Rückfrage; verweigerte werden immer blockiert.", @@ -2583,6 +2588,11 @@ "turnFailedMaxTurnRequests": "{agent} hat die maximale Anzahl zulässiger Anfragen für diese Runde erreicht.", "turnFailedUnknown": "{agent} hat die Runde mit einem unbekannten Stoppgrund beendet.", "grokModelSwitchIncompatibleAgent": "{agent} kann in einer bestehenden Unterhaltung nicht zu diesem Modell wechseln. Starte eine neue Sitzung, um es zu verwenden.", + "cursorConfigOptionFailed": "{agent} konnte die vollständige Modellkonfiguration nicht anwenden. Der bestätigte Zustand wurde wiederhergestellt.", + "cursorModelRestoreFailed": "{agent} konnte die gespeicherte Cursor-Modellkonfiguration nicht wiederherstellen.", + "cursorModelCatalogUnavailable": "Der Cursor-Modellkatalog ist nicht verfügbar. Parametersteuerungen bleiben verfügbar.", + "cursorModelVariantUnavailable": "Die gespeicherte Cursor-Modellvariante ist nicht mehr verfügbar.", + "cursorModelVariantAmbiguous": "Die Cursor-Modellvariante konnte nicht sicher zugeordnet werden.", "turnFailedEmpty": "{agent} hat die Runde ohne jegliche Antwort beendet.", "turnFailedEmptyProtocol": "{agent} hat eine Ausgabe erzeugt, die codeg nicht auswerten konnte — die Agent-Version passt möglicherweise nicht zum Protokoll.", "turnFailedEmptyMetadata": "{agent} hat in dieser Runde nur Statusaktualisierungen gesendet (Plan / Modus / Verbrauch) und keine Antwort.", @@ -2659,6 +2669,7 @@ "agentSettings": "Agent-Einstellungen", "cliDefaultSettings": "CLI-Standardeinstellungen", "autoDefault": "Auto (default)", + "applyingModel": "Modellkonfiguration wird angewendet", "searchModel": "Modelle suchen...", "searchModelAria": "Modelle suchen", "modelListLabel": "Modelle", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index ee8318d18..b1c2134f7 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -757,6 +757,11 @@ "modelTitle": "Default model", "loadModels": "Load models", "modelsUnavailable": "Model list unavailable", + "probeTimeout": "The Cursor CLI probe timed out.", + "probeOutputTooLarge": "The Cursor CLI probe returned too much output.", + "probeNonzeroExit": "The Cursor CLI probe exited unsuccessfully.", + "probeInvalidOutput": "The Cursor CLI probe returned an unsupported response.", + "probeFailed": "The Cursor CLI probe failed.", "modelHint": "Passed to the CLI as --model at session start. Leave on default to let Cursor choose.", "permissionsTitle": "Permissions & sandbox", "permissionsDescription": "Visual editor for the CLI's permission rules (cli-config.json). Allow rules run without prompting; deny rules are always blocked.", @@ -2583,6 +2588,11 @@ "turnFailedMaxTurnRequests": "{agent} reached the maximum number of allowed requests for this turn.", "turnFailedUnknown": "{agent} ended the turn with an unrecognized stop reason.", "grokModelSwitchIncompatibleAgent": "{agent} can't switch to that model in an existing conversation. Start a new session to use it.", + "cursorConfigOptionFailed": "{agent} couldn't apply the complete model configuration. The confirmed state was restored.", + "cursorModelRestoreFailed": "{agent} couldn't restore the saved Cursor model configuration.", + "cursorModelCatalogUnavailable": "The Cursor model catalog is unavailable. Parameterized controls remain available.", + "cursorModelVariantUnavailable": "The saved Cursor model variant is no longer available.", + "cursorModelVariantAmbiguous": "The Cursor model variant could not be mapped safely.", "turnFailedEmpty": "{agent} ended the turn without producing any response.", "turnFailedEmptyProtocol": "{agent} produced output that codeg could not parse — the agent version may not match the protocol.", "turnFailedEmptyMetadata": "{agent} sent only status updates this turn (plan / mode / usage) and no reply.", @@ -2661,6 +2671,7 @@ "agentSettings": "Agent settings", "cliDefaultSettings": "CLI defaults", "autoDefault": "Auto (default)", + "applyingModel": "Applying model configuration", "searchModel": "Search models...", "searchModelAria": "Search models", "modelListLabel": "Models", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 1cdb5524e..9c290e480 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -757,6 +757,11 @@ "modelTitle": "Modelo predeterminado", "loadModels": "Cargar modelos", "modelsUnavailable": "Lista de modelos no disponible", + "probeTimeout": "La comprobación de Cursor CLI agotó el tiempo de espera.", + "probeOutputTooLarge": "La comprobación de Cursor CLI devolvió demasiados datos.", + "probeNonzeroExit": "La comprobación de Cursor CLI falló.", + "probeInvalidOutput": "La comprobación de Cursor CLI devolvió una respuesta no compatible.", + "probeFailed": "La comprobación de Cursor CLI falló.", "modelHint": "Se pasa a la CLI como --model al iniciar la sesión. Déjalo en predeterminado para que Cursor elija.", "permissionsTitle": "Permisos y sandbox", "permissionsDescription": "Editor visual de las reglas de permisos de la CLI (cli-config.json). Las reglas permitidas se ejecutan sin confirmación; las denegadas se bloquean siempre.", @@ -2583,6 +2588,11 @@ "turnFailedMaxTurnRequests": "{agent} alcanzó el número máximo de solicitudes permitidas para este turno.", "turnFailedUnknown": "{agent} finalizó el turno con un motivo de parada no reconocido.", "grokModelSwitchIncompatibleAgent": "{agent} no puede cambiar a ese modelo en una conversación existente. Inicia una nueva sesión para usarlo.", + "cursorConfigOptionFailed": "{agent} no pudo aplicar la configuración completa del modelo. Se restauró el estado confirmado.", + "cursorModelRestoreFailed": "{agent} no pudo restaurar la configuración guardada del modelo Cursor.", + "cursorModelCatalogUnavailable": "El catálogo de modelos de Cursor no está disponible. Los controles parametrizados siguen disponibles.", + "cursorModelVariantUnavailable": "La variante guardada del modelo Cursor ya no está disponible.", + "cursorModelVariantAmbiguous": "No se pudo asignar de forma segura la variante del modelo Cursor.", "turnFailedEmpty": "{agent} finalizó el turno sin producir ninguna respuesta.", "turnFailedEmptyProtocol": "{agent} produjo una salida que codeg no pudo analizar; la versión del agente podría no coincidir con el protocolo.", "turnFailedEmptyMetadata": "{agent} solo envió actualizaciones de estado en este turno (plan / modo / uso) y ninguna respuesta.", @@ -2659,6 +2669,7 @@ "agentSettings": "Ajustes del agente", "cliDefaultSettings": "Valores predeterminados de CLI", "autoDefault": "Auto (default)", + "applyingModel": "Aplicando la configuración del modelo", "searchModel": "Buscar modelos...", "searchModelAria": "Buscar modelos", "modelListLabel": "Modelos", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 10105fa3c..fefb8ec4b 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -757,6 +757,11 @@ "modelTitle": "Modèle par défaut", "loadModels": "Charger les modèles", "modelsUnavailable": "Liste de modèles indisponible", + "probeTimeout": "La vérification de Cursor CLI a expiré.", + "probeOutputTooLarge": "La vérification de Cursor CLI a renvoyé trop de données.", + "probeNonzeroExit": "La vérification de Cursor CLI a échoué.", + "probeInvalidOutput": "La vérification de Cursor CLI a renvoyé une réponse non prise en charge.", + "probeFailed": "La vérification de Cursor CLI a échoué.", "modelHint": "Transmis à la CLI via --model au démarrage de la session. Laissez par défaut pour laisser Cursor choisir.", "permissionsTitle": "Permissions & bac à sable", "permissionsDescription": "Éditeur visuel des règles de permissions de la CLI (cli-config.json). Les règles autorisées s'exécutent sans confirmation ; les règles refusées sont toujours bloquées.", @@ -2583,6 +2588,11 @@ "turnFailedMaxTurnRequests": "{agent} a atteint le nombre maximal de requêtes autorisées pour ce tour.", "turnFailedUnknown": "{agent} a terminé le tour avec une raison d'arrêt inconnue.", "grokModelSwitchIncompatibleAgent": "{agent} ne peut pas basculer vers ce modèle dans une conversation existante. Démarrez une nouvelle session pour l'utiliser.", + "cursorConfigOptionFailed": "{agent} n’a pas pu appliquer la configuration complète du modèle. L’état confirmé a été restauré.", + "cursorModelRestoreFailed": "{agent} n’a pas pu restaurer la configuration enregistrée du modèle Cursor.", + "cursorModelCatalogUnavailable": "Le catalogue de modèles Cursor est indisponible. Les contrôles paramétrés restent disponibles.", + "cursorModelVariantUnavailable": "La variante enregistrée du modèle Cursor n’est plus disponible.", + "cursorModelVariantAmbiguous": "La variante du modèle Cursor n’a pas pu être associée de manière sûre.", "turnFailedEmpty": "{agent} a terminé le tour sans produire de réponse.", "turnFailedEmptyProtocol": "{agent} a produit une sortie que codeg n'a pas pu analyser — la version de l'agent ne correspond peut-être pas au protocole.", "turnFailedEmptyMetadata": "{agent} n'a envoyé que des mises à jour d'état ce tour-ci (plan / mode / utilisation) et aucune réponse.", @@ -2659,6 +2669,7 @@ "agentSettings": "Paramètres de l'agent", "cliDefaultSettings": "Paramètres CLI par défaut", "autoDefault": "Auto (default)", + "applyingModel": "Application de la configuration du modèle", "searchModel": "Rechercher des modèles...", "searchModelAria": "Rechercher des modèles", "modelListLabel": "Modèles", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 0dbf93693..13ccc4499 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -757,6 +757,11 @@ "modelTitle": "デフォルトモデル", "loadModels": "モデル一覧を取得", "modelsUnavailable": "モデル一覧を取得できません", + "probeTimeout": "Cursor CLI の確認がタイムアウトしました。", + "probeOutputTooLarge": "Cursor CLI の確認結果が大きすぎます。", + "probeNonzeroExit": "Cursor CLI の確認に失敗しました。", + "probeInvalidOutput": "Cursor CLI が未対応の応答を返しました。", + "probeFailed": "Cursor CLI の確認に失敗しました。", "modelHint": "セッション開始時に --model として CLI に渡されます。既定のままなら Cursor が選択します。", "permissionsTitle": "権限とサンドボックス", "permissionsDescription": "CLI の権限ルール(cli-config.json)のビジュアルエディタ。許可ルールは確認なしで実行、拒否ルールは常にブロックされます。", @@ -2583,6 +2588,11 @@ "turnFailedMaxTurnRequests": "{agent} がこのターンで許可された最大リクエスト数に達しました。", "turnFailedUnknown": "{agent} が不明な停止理由でターンを終了しました。", "grokModelSwitchIncompatibleAgent": "{agent} は既存の会話ではこのモデルに切り替えられません。新しいセッションを開始して使用してください。", + "cursorConfigOptionFailed": "{agent} はモデル設定を完全に適用できませんでした。確認済みの状態に戻しました。", + "cursorModelRestoreFailed": "{agent} は保存済みの Cursor モデル設定を復元できませんでした。", + "cursorModelCatalogUnavailable": "Cursor モデルカタログを利用できません。パラメーター設定は引き続き利用できます。", + "cursorModelVariantUnavailable": "保存済みの Cursor モデルバリアントは利用できなくなりました。", + "cursorModelVariantAmbiguous": "Cursor モデルバリアントを安全に対応付けできませんでした。", "turnFailedEmpty": "{agent} は応答を生成せずにターンを終了しました。", "turnFailedEmptyProtocol": "{agent} の出力を codeg が解析できませんでした。エージェントのバージョンがプロトコルと一致していない可能性があります。", "turnFailedEmptyMetadata": "{agent} はこのターンでステータス更新(計画 / モード / 使用量)のみを送信し、応答はありませんでした。", @@ -2659,6 +2669,7 @@ "agentSettings": "エージェント設定", "cliDefaultSettings": "CLI のデフォルト設定", "autoDefault": "Auto (default)", + "applyingModel": "モデル設定を適用中", "searchModel": "モデルを検索...", "searchModelAria": "モデルを検索", "modelListLabel": "モデル", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 9c70aad8a..471c096aa 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -757,6 +757,11 @@ "modelTitle": "기본 모델", "loadModels": "모델 목록 가져오기", "modelsUnavailable": "모델 목록을 가져올 수 없음", + "probeTimeout": "Cursor CLI 확인 시간이 초과되었습니다.", + "probeOutputTooLarge": "Cursor CLI 확인 결과가 너무 큽니다.", + "probeNonzeroExit": "Cursor CLI 확인이 실패했습니다.", + "probeInvalidOutput": "Cursor CLI가 지원되지 않는 응답을 반환했습니다.", + "probeFailed": "Cursor CLI 확인이 실패했습니다.", "modelHint": "세션 시작 시 --model로 CLI에 전달됩니다. 기본값이면 Cursor가 선택합니다.", "permissionsTitle": "권한 및 샌드박스", "permissionsDescription": "CLI 권한 규칙(cli-config.json)의 시각 편집기입니다. 허용 규칙은 확인 없이 실행되고 거부 규칙은 항상 차단됩니다.", @@ -2583,6 +2588,11 @@ "turnFailedMaxTurnRequests": "{agent}이(가) 이번 턴에서 허용된 최대 요청 수에 도달했습니다.", "turnFailedUnknown": "{agent}이(가) 알 수 없는 중지 사유로 턴을 종료했습니다.", "grokModelSwitchIncompatibleAgent": "{agent}은(는) 기존 대화에서는 해당 모델로 전환할 수 없습니다. 새 세션을 시작하여 사용하세요.", + "cursorConfigOptionFailed": "{agent}이(가) 전체 모델 구성을 적용하지 못했습니다. 확인된 상태로 복원했습니다.", + "cursorModelRestoreFailed": "{agent}이(가) 저장된 Cursor 모델 구성을 복원하지 못했습니다.", + "cursorModelCatalogUnavailable": "Cursor 모델 카탈로그를 사용할 수 없습니다. 매개변수 컨트롤은 계속 사용할 수 있습니다.", + "cursorModelVariantUnavailable": "저장된 Cursor 모델 변형을 더 이상 사용할 수 없습니다.", + "cursorModelVariantAmbiguous": "Cursor 모델 변형을 안전하게 매핑할 수 없습니다.", "turnFailedEmpty": "{agent}이(가) 어떤 응답도 생성하지 않고 턴을 종료했습니다.", "turnFailedEmptyProtocol": "{agent}의 출력을 codeg가 구문 분석할 수 없습니다. 에이전트 버전이 프로토콜과 맞지 않을 수 있습니다.", "turnFailedEmptyMetadata": "{agent}이(가) 이번 턴에 상태 업데이트(계획 / 모드 / 사용량)만 보내고 응답은 없었습니다.", @@ -2659,6 +2669,7 @@ "agentSettings": "에이전트 설정", "cliDefaultSettings": "CLI 기본 설정", "autoDefault": "Auto (default)", + "applyingModel": "모델 구성 적용 중", "searchModel": "모델 검색...", "searchModelAria": "모델 검색", "modelListLabel": "모델", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index baca0f234..838f483dd 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -757,6 +757,11 @@ "modelTitle": "Modelo padrão", "loadModels": "Carregar modelos", "modelsUnavailable": "Lista de modelos indisponível", + "probeTimeout": "A verificação do Cursor CLI excedeu o tempo limite.", + "probeOutputTooLarge": "A verificação do Cursor CLI retornou dados demais.", + "probeNonzeroExit": "A verificação do Cursor CLI falhou.", + "probeInvalidOutput": "A verificação do Cursor CLI retornou uma resposta incompatível.", + "probeFailed": "A verificação do Cursor CLI falhou.", "modelHint": "Passado à CLI como --model ao iniciar a sessão. Deixe no padrão para o Cursor escolher.", "permissionsTitle": "Permissões e sandbox", "permissionsDescription": "Editor visual das regras de permissão da CLI (cli-config.json). Regras permitidas executam sem confirmação; regras negadas são sempre bloqueadas.", @@ -2583,6 +2588,11 @@ "turnFailedMaxTurnRequests": "{agent} atingiu o número máximo de solicitações permitidas para este turno.", "turnFailedUnknown": "{agent} encerrou o turno com um motivo de parada desconhecido.", "grokModelSwitchIncompatibleAgent": "{agent} não pode mudar para esse modelo em uma conversa existente. Inicie uma nova sessão para usá-lo.", + "cursorConfigOptionFailed": "{agent} não conseguiu aplicar toda a configuração do modelo. O estado confirmado foi restaurado.", + "cursorModelRestoreFailed": "{agent} não conseguiu restaurar a configuração salva do modelo Cursor.", + "cursorModelCatalogUnavailable": "O catálogo de modelos Cursor está indisponível. Os controles parametrizados continuam disponíveis.", + "cursorModelVariantUnavailable": "A variante salva do modelo Cursor não está mais disponível.", + "cursorModelVariantAmbiguous": "Não foi possível mapear com segurança a variante do modelo Cursor.", "turnFailedEmpty": "{agent} encerrou o turno sem produzir nenhuma resposta.", "turnFailedEmptyProtocol": "{agent} produziu uma saída que o codeg não conseguiu analisar — a versão do agente pode não corresponder ao protocolo.", "turnFailedEmptyMetadata": "{agent} enviou apenas atualizações de status neste turno (plano / modo / uso) e nenhuma resposta.", @@ -2659,6 +2669,7 @@ "agentSettings": "Configurações do agente", "cliDefaultSettings": "Padrões da CLI", "autoDefault": "Auto (default)", + "applyingModel": "Aplicando a configuração do modelo", "searchModel": "Buscar modelos...", "searchModelAria": "Buscar modelos", "modelListLabel": "Modelos", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index b9d0a3e93..96774cda2 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -757,6 +757,11 @@ "modelTitle": "默认模型", "loadModels": "获取模型列表", "modelsUnavailable": "模型列表不可用", + "probeTimeout": "Cursor CLI 探测超时。", + "probeOutputTooLarge": "Cursor CLI 探测返回的数据过大。", + "probeNonzeroExit": "Cursor CLI 探测执行失败。", + "probeInvalidOutput": "Cursor CLI 探测返回了不支持的响应。", + "probeFailed": "Cursor CLI 探测失败。", "modelHint": "会话启动时通过 --model 传给 CLI;保持默认则由 Cursor 自行选择。", "permissionsTitle": "权限与沙箱", "permissionsDescription": "可视化编辑 CLI 权限规则(cli-config.json)。允许规则免确认执行;拒绝规则始终拦截。", @@ -2583,6 +2588,11 @@ "turnFailedMaxTurnRequests": "{agent} 已达到本轮回复允许的最大请求次数。", "turnFailedUnknown": "{agent} 以未知的停止原因结束了本轮回复。", "grokModelSwitchIncompatibleAgent": "{agent} 无法在已有对话中切换到该模型,请新建会话以使用它。", + "cursorConfigOptionFailed": "{agent} 未能完整应用模型配置,已恢复到确认状态。", + "cursorModelRestoreFailed": "{agent} 无法恢复已保存的 Cursor 模型配置。", + "cursorModelCatalogUnavailable": "Cursor 模型目录不可用,仍可使用参数化控件。", + "cursorModelVariantUnavailable": "已保存的 Cursor 模型变体不再可用。", + "cursorModelVariantAmbiguous": "无法安全映射该 Cursor 模型变体。", "turnFailedEmpty": "{agent} 本轮没有产生任何回复就结束了。", "turnFailedEmptyProtocol": "{agent} 本轮的输出 codeg 无法解析,可能是代理版本与协议不匹配。", "turnFailedEmptyMetadata": "{agent} 本轮只收到状态更新(计划 / 模式 / 用量),没有收到任何回复。", @@ -2661,6 +2671,7 @@ "agentSettings": "智能体设置", "cliDefaultSettings": "CLI 默认设置", "autoDefault": "Auto (default)", + "applyingModel": "正在应用模型配置", "searchModel": "搜索模型...", "searchModelAria": "搜索模型", "modelListLabel": "模型", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index a226cc77e..90d58e16e 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -757,6 +757,11 @@ "modelTitle": "預設模型", "loadModels": "取得模型清單", "modelsUnavailable": "模型清單不可用", + "probeTimeout": "Cursor CLI 探測逾時。", + "probeOutputTooLarge": "Cursor CLI 探測回傳的資料過大。", + "probeNonzeroExit": "Cursor CLI 探測執行失敗。", + "probeInvalidOutput": "Cursor CLI 探測回傳了不支援的回應。", + "probeFailed": "Cursor CLI 探測失敗。", "modelHint": "會話啟動時透過 --model 傳給 CLI;保持預設則由 Cursor 自行選擇。", "permissionsTitle": "權限與沙箱", "permissionsDescription": "可視化編輯 CLI 權限規則(cli-config.json)。允許規則免確認執行;拒絕規則一律攔截。", @@ -2583,6 +2588,11 @@ "turnFailedMaxTurnRequests": "{agent} 已達到此輪回覆允許的最大請求次數。", "turnFailedUnknown": "{agent} 以未知的停止原因結束了此輪回覆。", "grokModelSwitchIncompatibleAgent": "{agent} 無法在既有對話中切換到該模型,請新建工作階段以使用它。", + "cursorConfigOptionFailed": "{agent} 未能完整套用模型設定,已還原至確認狀態。", + "cursorModelRestoreFailed": "{agent} 無法還原已儲存的 Cursor 模型設定。", + "cursorModelCatalogUnavailable": "Cursor 模型目錄無法使用,仍可使用參數化控制項。", + "cursorModelVariantUnavailable": "已儲存的 Cursor 模型變體已無法使用。", + "cursorModelVariantAmbiguous": "無法安全對應此 Cursor 模型變體。", "turnFailedEmpty": "{agent} 此輪沒有產生任何回覆就結束了。", "turnFailedEmptyProtocol": "{agent} 此輪的輸出 codeg 無法解析,可能是代理版本與協定不相符。", "turnFailedEmptyMetadata": "{agent} 此輪只收到狀態更新(計畫 / 模式 / 用量),沒有收到任何回覆。", @@ -2659,6 +2669,7 @@ "agentSettings": "智能體設定", "cliDefaultSettings": "CLI 預設設定", "autoDefault": "Auto (default)", + "applyingModel": "正在套用模型設定", "searchModel": "搜尋模型...", "searchModelAria": "搜尋模型", "modelListLabel": "模型", diff --git a/src/lib/api.ts b/src/lib/api.ts index 9b547f2cf..1d57303ee 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -279,12 +279,14 @@ export async function acpSetMode( export async function acpSetConfigOption( connectionId: string, configId: string, - valueId: string -): Promise { + valueId: string, + operationId?: string +): Promise { return getTransport().call("acp_set_config_option", { connectionId, configId, valueId, + operationId, }) } diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 9871c597b..76140aa8b 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -132,9 +132,15 @@ export async function acpSetMode( export async function acpSetConfigOption( connectionId: string, configId: string, - valueId: string -): Promise { - return invoke("acp_set_config_option", { connectionId, configId, valueId }) + valueId: string, + operationId?: string +): Promise { + return invoke("acp_set_config_option", { + connectionId, + configId, + valueId, + operationId, + }) } export async function acpCancel(connectionId: string): Promise { diff --git a/src/lib/types.ts b/src/lib/types.ts index 6a83d0fb4..a85977d36 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1218,6 +1218,8 @@ export interface SessionConfigOptionInfo { description?: string | null category?: string | null kind: SessionConfigKindInfo + /** Cursor-only optimistic operation marker. Never serialized by Rust. */ + pending_operation_id?: string } export interface AgentOptionsSnapshot { @@ -1831,6 +1833,8 @@ export type AcpEvent = | { type: "session_config_options" config_options: SessionConfigOptionInfo[] + operation_id?: string | null + operation_status?: "applied" | "failed" | null } | { // The agent settled a `session/set_config_option` somewhere other than @@ -1873,6 +1877,8 @@ export type AcpEvent = agent_type: string /** Stable backend error identifier for localization (e.g. "initialize_timeout"). */ code: string | null + /** Present only when this error terminates the connection. */ + terminal?: boolean /** * Diagnostic evidence for errors the backend *inferred* rather than * received — the `turn_failed_empty*` family, where the agent reported @@ -2494,6 +2500,7 @@ export interface CursorAuthStatus { email: string | null membership: string | null error: string | null + error_code?: string | null /** Absolute path to the cursor-agent binary codeg would launch; the panel * builds a copy-pasteable `"" login` command from it (the * managed binary isn't on PATH). Null when not installed. */ @@ -2513,6 +2520,7 @@ export interface CursorModelsResult { models: CursorModelInfo[] default_model: string | null error: string | null + error_code?: string | null } // Lightweight agent status returned by acp_get_agent_status From 8db9684258fc34c8fddde2c0e4a558e9bbba490c Mon Sep 17 00:00:00 2001 From: null Date: Sat, 15 Aug 2026 17:48:10 +0800 Subject: [PATCH 4/6] fix(cursor): close composite picker review gaps --- src-tauri/src/acp/connection.rs | 343 +++++++++-- src-tauri/src/commands/acp.rs | 579 +++++++++++++++--- src-tauri/src/web/handlers/acp.rs | 5 +- .../chat/model-option-list.test.tsx | 27 + src/components/chat/model-option-list.tsx | 37 +- .../settings/cursor-config-panel.test.tsx | 43 +- .../settings/cursor-config-panel.tsx | 59 +- src/contexts/acp-connections-context.test.tsx | 270 ++++++++ src/contexts/acp-connections-context.tsx | 107 +++- src/i18n/messages/ar.json | 5 + src/i18n/messages/de.json | 5 + src/i18n/messages/en.json | 5 + src/i18n/messages/es.json | 5 + src/i18n/messages/fr.json | 5 + src/i18n/messages/ja.json | 5 + src/i18n/messages/ko.json | 5 + src/i18n/messages/pt.json | 5 + src/i18n/messages/zh-CN.json | 5 + src/i18n/messages/zh-TW.json | 5 + src/lib/api.ts | 10 +- 20 files changed, 1329 insertions(+), 201 deletions(-) diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 7e8cb41b2..2a38a4ac3 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -2065,6 +2065,22 @@ fn map_session_config_options( const CURSOR_COMPOSITE_VALUE_PREFIX: &str = "__codeg_cursor_composite__:"; const CURSOR_CURRENT_UNAVAILABLE_VALUE: &str = "__codeg_cursor_current_unavailable__"; +fn validate_cursor_model_selector_value( + agent_type: AgentType, + config_id: &str, + value_id: &str, +) -> Result<(), sacp::Error> { + if agent_type == AgentType::Cursor + && config_id == "model" + && value_id == CURSOR_CURRENT_UNAVAILABLE_VALUE + { + return Err(sacp::util::internal_error( + "Cursor's unavailable-current marker is display-only", + )); + } + Ok(()) +} + #[derive(Debug, Clone, serde::Deserialize)] #[serde(rename_all = "camelCase")] struct CursorAvailableModelCatalog { @@ -2128,7 +2144,8 @@ async fn request_cursor_available_models( /// parameterized ACP catalog is structural (base model + legal values). Build a /// safe intersection without parsing or sending the CLI alias itself: /// -/// - the longest ACP display-name prefix identifies the base model; +/// - every ACP display-name prefix is considered, and the whole row is accepted +/// only when exactly one normalized model+parameter interpretation remains; /// - suffix phrases must be names published by that model's ACP options; /// - boolean parameters use the ACP option name when enabled and are false when /// absent, matching Cursor's own current CLI formatter; @@ -2149,40 +2166,60 @@ fn build_cursor_composite_catalog( continue; } let label_words = cursor_label_words(label, true); - let mut model_candidates: Vec<_> = available + let model_candidates: Vec<_> = available .iter() .filter(|model| { let base = cursor_label_words(&model.name, false); !base.is_empty() && label_words.starts_with(&base) }) .collect(); - model_candidates - .sort_by_key(|model| std::cmp::Reverse(cursor_label_words(&model.name, false).len())); - let Some(model) = model_candidates.first().copied() else { - continue; - }; - let base_words = cursor_label_words(&model.name, false); - if model_candidates.iter().skip(1).any(|candidate| { - cursor_label_words(&candidate.name, false).len() == base_words.len() - && candidate.value != model.value - }) { - tracing::debug!( - cli_alias = %cli.id, - cli_label = %cli.label, - "[ACP][Cursor] rejected an ambiguous CLI model base" - ); + if model_candidates.is_empty() { continue; } - let suffix = &label_words[base_words.len()..]; - let Some(parameters) = cursor_unique_parameter_mapping(suffix, &model.config_options) - else { + let mut states = 0; + let mut interpretations: Vec<(&CursorAvailableModelCatalog, BTreeMap)> = + Vec::new(); + let mut bounded_out = false; + for model in model_candidates { + states += 1; + if states > CURSOR_MAPPING_MAX_STATES { + bounded_out = true; + break; + } + let base_words = cursor_label_words(&model.name, false); + let suffix = &label_words[base_words.len()..]; + let Some(mappings) = + cursor_parameter_mappings(suffix, &model.config_options, &mut states) + else { + bounded_out = true; + break; + }; + for parameters in mappings { + if !interpretations + .iter() + .any(|(existing_model, existing_parameters)| { + existing_model.value == model.value && *existing_parameters == parameters + }) + { + interpretations.push((model, parameters)); + if interpretations.len() > 1 { + break; + } + } + } + if interpretations.len() > 1 { + break; + } + } + if bounded_out || interpretations.len() != 1 { tracing::debug!( cli_alias = %cli.id, cli_label = %cli.label, - "[ACP][Cursor] rejected an unmappable CLI model row" + "[ACP][Cursor] rejected a non-unique CLI model interpretation" ); continue; - }; + } + let (model, parameters) = interpretations.pop().expect("one interpretation"); let value = if model.value == "default" && parameters.is_empty() { // Keep the real ACP default sentinel so the existing Cursor grouping @@ -2254,10 +2291,21 @@ struct CursorParameterChoice { /// greedy match can assign a shared word such as `High` to whichever option /// happens to arrive first; this bounded search accepts a row only when exactly /// one structured parameter map consumes every suffix token. +#[cfg(test)] fn cursor_unique_parameter_mapping( suffix: &[String], configs: &[CursorCatalogConfigOption], ) -> Option> { + let mut states = 0; + let mut solutions = cursor_parameter_mappings(suffix, configs, &mut states)?; + (solutions.len() == 1).then(|| solutions.remove(0)) +} + +fn cursor_parameter_mappings( + suffix: &[String], + configs: &[CursorCatalogConfigOption], + states: &mut usize, +) -> Option>> { if suffix.len() > CURSOR_MAPPING_MAX_SUFFIX_WORDS || configs.len() > CURSOR_MAPPING_MAX_CONFIGS || configs @@ -2278,7 +2326,7 @@ fn cursor_unique_parameter_mapping( .map(|config| cursor_parameter_choices(suffix, config)) .collect(); if choices.iter().any(Vec::is_empty) { - return None; + return Some(Vec::new()); } struct SearchInputs<'a> { @@ -2286,19 +2334,21 @@ fn cursor_unique_parameter_mapping( choices: &'a [Vec], full_mask: u64, } - struct SearchState { - current: BTreeMap, - solutions: Vec>, - states: usize, - } - fn visit(index: usize, used: u64, inputs: &SearchInputs<'_>, state: &mut SearchState) { - state.states += 1; - if state.states > CURSOR_MAPPING_MAX_STATES || state.solutions.len() > 1 { + fn visit( + index: usize, + used: u64, + inputs: &SearchInputs<'_>, + current: &mut BTreeMap, + solutions: &mut Vec>, + states: &mut usize, + ) { + *states += 1; + if *states > CURSOR_MAPPING_MAX_STATES || solutions.len() > 1 { return; } if index == inputs.ordered.len() { - if used == inputs.full_mask && !state.solutions.contains(&state.current) { - state.solutions.push(state.current.clone()); + if used == inputs.full_mask && !solutions.contains(current) { + solutions.push(current.clone()); } return; } @@ -2307,12 +2357,17 @@ fn cursor_unique_parameter_mapping( if used & choice.consumed != 0 { continue; } - state - .current - .insert(config.id.clone(), choice.value.clone()); - visit(index + 1, used | choice.consumed, inputs, state); - state.current.remove(&config.id); - if state.states > CURSOR_MAPPING_MAX_STATES || state.solutions.len() > 1 { + current.insert(config.id.clone(), choice.value.clone()); + visit( + index + 1, + used | choice.consumed, + inputs, + current, + solutions, + states, + ); + current.remove(&config.id); + if *states > CURSOR_MAPPING_MAX_STATES || solutions.len() > 1 { return; } } @@ -2323,14 +2378,10 @@ fn cursor_unique_parameter_mapping( choices: &choices, full_mask, }; - let mut state = SearchState { - current: BTreeMap::new(), - solutions: Vec::new(), - states: 0, - }; - visit(0, 0, &inputs, &mut state); - (state.states <= CURSOR_MAPPING_MAX_STATES && state.solutions.len() == 1) - .then(|| state.solutions.remove(0)) + let mut current = BTreeMap::new(); + let mut solutions = Vec::new(); + visit(0, 0, &inputs, &mut current, &mut solutions, states); + (*states <= CURSOR_MAPPING_MAX_STATES).then_some(solutions) } fn cursor_parameter_choices( @@ -6239,6 +6290,7 @@ async fn set_session_config_option( request_seq: u64, operation_id: Option, ) -> Result<(), sacp::Error> { + validate_cursor_model_selector_value(agent_type, &config_id, &value_id)?; // The whole selector transport carries values as opaque strings; only here, // at the wire, does the option's advertised kind decide how to encode it. let is_boolean = { @@ -6361,11 +6413,7 @@ async fn set_cursor_composite_option( request_seq: u64, operation_id: Option, ) -> Result<(), sacp::Error> { - if value_id == CURSOR_CURRENT_UNAVAILABLE_VALUE { - finish_cursor_config_request(state, emitter, request_seq, None, operation_id, "applied") - .await; - return Ok(()); - } + validate_cursor_model_selector_value(AgentType::Cursor, "model", value_id)?; let (target, previous, mut raw_options) = { let session = state.read().await; let catalog = session @@ -12197,7 +12245,34 @@ mod tests { .iter() .any(|(k, v)| k == "CURSOR_API_BASE_URL" && v.is_empty())); - // Custom mode and legacy/no-mode rows are left untouched. + // Custom mode preserves the explicit endpoint/key and overwrites stale + // inherited values with the same effective env used by the probes. + let custom: BTreeMap = [ + ("CURSOR_AUTH_MODE".to_string(), "custom".to_string()), + ("CURSOR_API_KEY".to_string(), "explicit-key".to_string()), + ( + "CURSOR_API_BASE_URL".to_string(), + "https://custom.example".to_string(), + ), + ] + .into(); + let mut custom_env = vec![ + ("CURSOR_API_KEY".to_string(), "inherited-key".to_string()), + ( + "CURSOR_API_BASE_URL".to_string(), + "https://inherited.example".to_string(), + ), + ]; + apply_cursor_env_policy(&mut custom_env, &custom); + assert!(custom_env + .iter() + .any(|(k, v)| k == "CURSOR_API_KEY" && v == "explicit-key")); + assert!(custom_env + .iter() + .any(|(k, v)| k == "CURSOR_API_BASE_URL" && v == "https://custom.example")); + + // Custom mode without explicit values and legacy/no-mode rows preserve + // operator-provided process env rather than inventing credentials. for mode in [Some("custom"), None] { let rt: BTreeMap = mode .map(|m| [("CURSOR_AUTH_MODE".to_string(), m.to_string())].into()) @@ -16622,6 +16697,116 @@ mod tests { assert!(build_cursor_composite_catalog(&cli_models, &available).is_empty()); } + #[test] + fn cursor_mapping_rejects_ambiguity_across_overlapping_base_prefixes() { + let cli_models = vec![crate::acp::types::CursorModelInfo { + id: "shared-1m-thinking".into(), + label: "Shared Model 1M Thinking".into(), + is_default: false, + }]; + let option = |id: &str, name: &str, current: &str, value: &str| { + serde_json::json!({ + "id": id, "name": name, "currentValue": current, + "options": [{"value": current, "name": "Off"}, {"value": value, "name": name}] + }) + }; + let raw = serde_json::json!({"models": [ + {"value": "short-base", "name": "Shared Model", "configOptions": [ + option("context", "1M", "300k", "1m"), option("thinking", "Thinking", "false", "true") + ]}, + {"value": "long-base", "name": "Shared Model 1M", "configOptions": [ + option("thinking", "Thinking", "false", "true") + ]} + ]}); + let available = parse_cursor_available_models(raw).expect("catalog parses"); + let mut reversed = available.clone(); + reversed.reverse(); + assert!(build_cursor_composite_catalog(&cli_models, &available).is_empty()); + assert!(build_cursor_composite_catalog(&cli_models, &reversed).is_empty()); + } + + #[test] + fn cursor_mapping_accepts_only_the_one_complete_base_interpretation() { + let cli_models = vec![crate::acp::types::CursorModelInfo { + id: "shared-1m-thinking".into(), + label: "Shared Model 1M Thinking".into(), + is_default: false, + }]; + let context = serde_json::json!({ + "id": "context", "name": "Context", "currentValue": "300k", + "options": [{"value": "300k", "name": "300K"}, {"value": "1m", "name": "1M"}] + }); + let thinking = serde_json::json!({ + "id": "thinking", "name": "Thinking", "currentValue": "false", + "options": [{"value": "false", "name": "Off"}, {"value": "true", "name": "Thinking"}] + }); + + let short_only = parse_cursor_available_models(serde_json::json!({"models": [ + {"value": "short-base", "name": "Shared Model", "configOptions": [context.clone(), thinking.clone()]}, + {"value": "long-base", "name": "Shared Model 1M", "configOptions": []} + ]})) + .expect("short-only catalog parses"); + let mapped = build_cursor_composite_catalog(&cli_models, &short_only); + assert_eq!(mapped.len(), 1); + assert_eq!(mapped[0].model_value, "short-base"); + + let long_only = parse_cursor_available_models(serde_json::json!({"models": [ + {"value": "short-base", "name": "Shared Model", "configOptions": [context]}, + {"value": "long-base", "name": "Shared Model 1M", "configOptions": [thinking]} + ]})) + .expect("long-only catalog parses"); + let mapped = build_cursor_composite_catalog(&cli_models, &long_only); + assert_eq!(mapped.len(), 1); + assert_eq!(mapped[0].model_value, "long-base"); + } + + #[test] + fn cursor_mapping_treats_equal_labels_with_distinct_acp_values_as_ambiguous() { + let cli_models = vec![crate::acp::types::CursorModelInfo { + id: "shared-high".into(), + label: "Shared Model High".into(), + is_default: false, + }]; + let option = serde_json::json!({ + "id": "effort", "name": "Effort", "currentValue": "low", + "options": [{"value": "low", "name": "Low"}, {"value": "high", "name": "High"}] + }); + let available = parse_cursor_available_models(serde_json::json!({"models": [ + {"value": "first-acp-model", "name": "Shared Model", "configOptions": [option.clone()]}, + {"value": "second-acp-model", "name": "Shared Model", "configOptions": [option]} + ]})) + .expect("ambiguous equal-label catalog parses"); + assert!(build_cursor_composite_catalog(&cli_models, &available).is_empty()); + } + + #[test] + fn cursor_mapping_deduplicates_identical_structured_interpretations() { + let cli_models = vec![crate::acp::types::CursorModelInfo { + id: "shared-high".into(), + label: "Shared Model High".into(), + is_default: false, + }]; + let model = serde_json::json!({ + "value": "same-acp-value", "name": "Shared Model", "configOptions": [{ + "id": "reasoning", "name": "Reasoning", "currentValue": "low", + "options": [{"value": "low", "name": "Low"}, {"value": "high", "name": "High"}] + }] + }); + let available = + parse_cursor_available_models(serde_json::json!({"models": [model.clone(), model]})) + .expect("catalog parses"); + let composites = build_cursor_composite_catalog(&cli_models, &available); + assert_eq!(composites.len(), 1); + assert_eq!(composites[0].model_value, "same-acp-value"); + assert_eq!( + composites[0] + .parameters + .get("reasoning") + .map(String::as_str), + Some("high") + ); + } + #[test] fn cursor_suffix_mapping_is_globally_unique_across_config_options() { let cli_models = vec![crate::acp::types::CursorModelInfo { @@ -16745,6 +16930,36 @@ mod tests { cursor_unique_parameter_mapping(&[], &[oversized]).is_none(), "abnormally large option sets must fail before backtracking" ); + + let cli_models = vec![crate::acp::types::CursorModelInfo { + id: "bounded-base-search".into(), + label: "Shared Model High".into(), + is_default: false, + }]; + let repeated = CursorAvailableModelCatalog { + value: "same-model".into(), + name: "Shared Model".into(), + config_options: vec![CursorCatalogConfigOption { + id: "reasoning".into(), + name: "Reasoning".into(), + current_value: "low".into(), + options: vec![ + CursorCatalogValue { + value: "low".into(), + name: "Low".into(), + }, + CursorCatalogValue { + value: "high".into(), + name: "High".into(), + }, + ], + }], + }; + let available = vec![repeated; CURSOR_MAPPING_MAX_STATES + 1]; + assert!( + build_cursor_composite_catalog(&cli_models, &available).is_empty(), + "base-candidate enumeration must share the bounded search budget" + ); } fn cursor_wire_options(value: serde_json::Value) -> Vec { @@ -17125,6 +17340,28 @@ mod tests { ); } + #[test] + fn cursor_unavailable_marker_is_rejected_before_every_model_wire_path() { + assert!(validate_cursor_model_selector_value( + AgentType::Cursor, + "model", + CURSOR_CURRENT_UNAVAILABLE_VALUE, + ) + .is_err()); + assert!(validate_cursor_model_selector_value( + AgentType::Cursor, + "model", + "__codeg_cursor_composite__:valid-local-choice", + ) + .is_ok()); + assert!(validate_cursor_model_selector_value( + AgentType::Codex, + "model", + CURSOR_CURRENT_UNAVAILABLE_VALUE, + ) + .is_ok()); + } + #[test] fn cursor_request_generation_rejects_stale_completions() { assert!(!cursor_config_request_is_current(1, 2)); diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 9afd084e9..599a062be 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -1,10 +1,14 @@ use std::collections::{BTreeMap, HashMap}; use std::fs; +use std::future::Future; +use std::panic::AssertUnwindSafe; use std::path::{Path, PathBuf}; +use std::pin::Pin; use std::process::Stdio; use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant, SystemTime}; +use futures::FutureExt; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; #[cfg(feature = "tauri-runtime")] @@ -8250,7 +8254,11 @@ pub(crate) fn cursor_effective_runtime_env( /// `CURSOR_API_KEY` is always materialized (empty when unset) so /// `run_cursor_probe` makes an explicit set-or-remove decision and a stale /// inherited key can never leak in and produce a bogus "invalid API key". -async fn cursor_probe_env(db: &AppDatabase, api_key: Option<&str>) -> BTreeMap { +async fn cursor_probe_env( + db: &AppDatabase, + api_key: Option<&str>, + base_url: Option<&str>, +) -> BTreeMap { let mut env: BTreeMap = agent_setting_service::get_by_agent_type(&db.conn, AgentType::Cursor) .await @@ -8272,6 +8280,14 @@ async fn cursor_probe_env(db: &AppDatabase, api_key: Option<&str>) -> BTreeMap "cursor_probe_invalid_utf8", Self::EmptyOutput => "cursor_probe_empty_output", Self::InvalidFormat => "cursor_probe_invalid_format", + Self::Cancelled => "cursor_probe_cancelled", + Self::Busy => "cursor_probe_busy", } } } @@ -8313,33 +8333,74 @@ impl std::fmt::Display for CursorProbeError { Self::InvalidUtf8 => "Cursor CLI probe returned invalid UTF-8.", Self::EmptyOutput => "Cursor CLI probe returned no output.", Self::InvalidFormat => "Cursor CLI probe returned an unsupported format.", + Self::Cancelled => "Cursor CLI probe was cancelled.", + Self::Busy => "Too many Cursor CLI probes are already running.", }; formatter.write_str(message) } } -async fn read_cursor_probe_stream( - mut reader: R, - limit: usize, -) -> Result, CursorProbeError> { - let mut output = Vec::with_capacity(limit.min(16 * 1024)); - let mut chunk = [0_u8; 8 * 1024]; - loop { - let read = reader - .read(&mut chunk) - .await - .map_err(|_| CursorProbeError::Spawn)?; - if read == 0 { - return Ok(output); +#[derive(Clone)] +enum CursorProbeFlightState { + Running, + Finished(Result), +} + +#[cfg(unix)] +struct CursorProbeProcessGroup { + pid: i32, + armed: bool, +} + +#[cfg(unix)] +impl CursorProbeProcessGroup { + fn new(pid: Option) -> Self { + let pid = pid.and_then(|value| i32::try_from(value).ok()); + Self { + pid: pid.unwrap_or_default(), + armed: pid.is_some(), + } + } + fn terminate(&self, signal: i32) { + if self.armed && self.pid > 0 { + // The probe owns this process group, so the negative pid cannot + // target Codeg or an unrelated process. + unsafe { + libc::kill(-self.pid, signal); + } } - if output.len().saturating_add(read) > limit { - return Err(CursorProbeError::OutputTooLarge); + } + fn disarm(&mut self) { + self.armed = false; + } +} + +#[cfg(unix)] +impl Drop for CursorProbeProcessGroup { + fn drop(&mut self) { + if self.armed && self.pid > 0 { + unsafe { + libc::kill(-self.pid, libc::SIGKILL); + } } - output.extend_from_slice(&chunk[..read]); } } -async fn terminate_cursor_probe(child: &mut tokio::process::Child, pid: Option) { +#[cfg(not(unix))] +struct CursorProbeProcessGroup; +#[cfg(not(unix))] +impl CursorProbeProcessGroup { + fn new(_pid: Option) -> Self { + Self + } + fn disarm(&mut self) {} +} + +async fn terminate_cursor_probe( + child: &mut tokio::process::Child, + pid: Option, + process_group: &CursorProbeProcessGroup, +) -> bool { let mut signalled = Vec::new(); if let Some(pid) = pid { let config = kill_tree::Config { @@ -8352,7 +8413,11 @@ async fn terminate_cursor_probe(child: &mut tokio::process::Child, pid: Option None, })); } + #[cfg(unix)] + process_group.terminate(libc::SIGTERM); tokio::time::sleep(Duration::from_millis(100)).await; + #[cfg(unix)] + process_group.terminate(libc::SIGKILL); for process_id in signalled { let config = kill_tree::Config { signal: "SIGKILL".to_string(), @@ -8362,24 +8427,33 @@ async fn terminate_cursor_probe(child: &mut tokio::process::Child, pid: Option = Pin + Send + 'a>>; + +async fn run_cursor_probe_binary_worker( + bin: PathBuf, + args: Vec, timeout_duration: Duration, - extra_env: &BTreeMap, + extra_env: BTreeMap, + mut cancelled: CursorProbeCancellation<'_>, ) -> Result { - let mut cmd = crate::process::tokio_command(bin); - cmd.args(args) + let mut cmd = crate::process::tokio_command(&bin); + cmd.args(&args) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); - for (key, value) in extra_env { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.as_std_mut().process_group(0); + } + for (key, value) in &extra_env { if value.trim().is_empty() { // This process's env is inherited by the child; an empty value means // "ensure absent" so a stale inherited CURSOR_API_KEY can't leak in. @@ -8390,30 +8464,49 @@ async fn run_cursor_probe_binary( } let mut child = cmd.spawn().map_err(|_| CursorProbeError::Spawn)?; let pid = child.id(); - let stdout = child.stdout.take().ok_or(CursorProbeError::Spawn)?; - let stderr = child.stderr.take().ok_or(CursorProbeError::Spawn)?; - let mut stdout_task = tokio::spawn(read_cursor_probe_stream(stdout, CURSOR_PROBE_STDOUT_LIMIT)); - let mut stderr_task = tokio::spawn(read_cursor_probe_stream(stderr, CURSOR_PROBE_STDERR_LIMIT)); - - let outcome = { - let mut stdout_value = None; - let mut stderr_value = None; + let mut process_group = CursorProbeProcessGroup::new(pid); + let mut stdout = child.stdout.take().ok_or(CursorProbeError::Spawn)?; + let mut stderr = child.stderr.take().ok_or(CursorProbeError::Spawn)?; + + // Keep child ownership outside the unwind boundary. If parsing or I/O ever + // panics, this function still reaches the active terminate+wait path below; + // the process-group Drop guard remains only a final backstop. + let outcome = AssertUnwindSafe(async { + #[cfg(test)] + if let Some(marker) = extra_env.get("CODEG_TEST_CURSOR_PROBE_PANIC_AFTER_FILE") { + for _ in 0..200 { + if fs::metadata(marker).is_ok() { + panic!("injected Cursor probe worker panic"); + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + panic!("Cursor probe panic marker was not created"); + } + + let mut stdout_value = Vec::new(); + let mut stderr_value = Vec::new(); + let mut stdout_done = false; + let mut stderr_done = false; let mut status_value = None; + let mut stdout_chunk = [0_u8; 8 * 1024]; + let mut stderr_chunk = [0_u8; 8 * 1024]; let deadline = tokio::time::sleep(timeout_duration); tokio::pin!(deadline); loop { tokio::select! { - result = &mut stdout_task, if stdout_value.is_none() => { + result = stdout.read(&mut stdout_chunk), if !stdout_done => { match result { - Ok(Ok(value)) => stdout_value = Some(value), - Ok(Err(error)) => break Err(error), + Ok(0) => stdout_done = true, + Ok(read) if stdout_value.len().saturating_add(read) <= CURSOR_PROBE_STDOUT_LIMIT => stdout_value.extend_from_slice(&stdout_chunk[..read]), + Ok(_) => break Err(CursorProbeError::OutputTooLarge), Err(_) => break Err(CursorProbeError::Spawn), } } - result = &mut stderr_task, if stderr_value.is_none() => { + result = stderr.read(&mut stderr_chunk), if !stderr_done => { match result { - Ok(Ok(value)) => stderr_value = Some(value), - Ok(Err(error)) => break Err(error), + Ok(0) => stderr_done = true, + Ok(read) if stderr_value.len().saturating_add(read) <= CURSOR_PROBE_STDERR_LIMIT => stderr_value.extend_from_slice(&stderr_chunk[..read]), + Ok(_) => break Err(CursorProbeError::OutputTooLarge), Err(_) => break Err(CursorProbeError::Spawn), } } @@ -8424,24 +8517,33 @@ async fn run_cursor_probe_binary( } } _ = &mut deadline => break Err(CursorProbeError::Timeout), + _ = &mut cancelled => break Err(CursorProbeError::Cancelled), } - if let (Some(status), Some(stdout), Some(stderr)) = - (status_value, stdout_value.as_ref(), stderr_value.as_ref()) - { - break Ok((status, stdout.clone(), stderr.clone())); + if let Some(status) = status_value.filter(|_| stdout_done && stderr_done) { + break Ok((status, stdout_value, stderr_value)); } } - }; + }) + .catch_unwind() + .await + .unwrap_or(Err(CursorProbeError::Spawn)); let (status, stdout, stderr) = match outcome { Ok(output) => output, Err(error) => { - stdout_task.abort(); - stderr_task.abort(); - terminate_cursor_probe(&mut child, pid).await; + if terminate_cursor_probe(&mut child, pid, &process_group).await { + process_group.disarm(); + } return Err(error); } }; + #[cfg(unix)] + // A well-behaved probe exits with all descendants. Still terminate the + // private group before disarming it: a CLI wrapper can close the inherited + // pipes, return output, and leave a background helper behind even though + // the direct child was already reaped by `try_wait`. + process_group.terminate(libc::SIGKILL); + process_group.disarm(); if !status.success() { return Err(CursorProbeError::NonZeroExit); } @@ -8453,6 +8555,49 @@ async fn run_cursor_probe_binary( Ok(stdout) } +async fn wait_cursor_probe_flight( + receiver: &mut tokio::sync::watch::Receiver, +) -> Result { + loop { + if let CursorProbeFlightState::Finished(result) = receiver.borrow().clone() { + return result; + } + receiver + .changed() + .await + .map_err(|_| CursorProbeError::Spawn)?; + } +} + +/// Spawn a supervised worker: caller cancellation closes the result receiver, +/// which makes the worker terminate and reap the complete process tree. +async fn run_cursor_probe_binary( + bin: &Path, + args: &[&str], + timeout_duration: Duration, + extra_env: &BTreeMap, +) -> Result { + let (sender, mut receiver) = tokio::sync::watch::channel(CursorProbeFlightState::Running); + let bin = bin.to_path_buf(); + let args = args.iter().map(|arg| (*arg).to_string()).collect(); + let extra_env = extra_env.clone(); + tokio::spawn(async move { + let cancelled = Box::pin(sender.closed()); + let result = AssertUnwindSafe(run_cursor_probe_binary_worker( + bin, + args, + timeout_duration, + extra_env, + cancelled, + )) + .catch_unwind() + .await + .unwrap_or(Err(CursorProbeError::Spawn)); + let _ = sender.send(CursorProbeFlightState::Finished(result)); + }); + wait_cursor_probe_flight(&mut receiver).await +} + async fn run_cursor_probe( args: &[&str], timeout_secs: u64, @@ -8478,10 +8623,14 @@ struct CursorProbeCacheEntry { expires_at: Instant, } +struct CursorProbeFlight { + sender: tokio::sync::watch::Sender, +} + #[derive(Default)] struct CursorProbeCache { entries: HashMap, - gates: HashMap>>, + flights: HashMap>, } static CURSOR_PROBE_CACHE: OnceLock> = OnceLock::new(); @@ -8493,7 +8642,6 @@ fn cursor_probe_cache() -> &'static tokio::sync::Mutex { fn prune_cursor_probe_cache(cache: &mut CursorProbeCache) { let now = Instant::now(); cache.entries.retain(|_, entry| entry.expires_at > now); - cache.gates.retain(|_, gate| Arc::strong_count(gate) > 1); while cache.entries.len() >= CURSOR_PROBE_CACHE_MAX_ENTRIES { let Some(oldest) = cache .entries @@ -8598,7 +8746,7 @@ async fn run_cursor_probe_cached_for_binary( extra_env: &BTreeMap, ) -> Result { let key = cursor_probe_cache_key(bin, binary_version, args, extra_env); - let gate = { + let mut receiver = { let mut cache = cursor_probe_cache().lock().await; prune_cursor_probe_cache(&mut cache); if let Some(entry) = cache.entries.get(&key) { @@ -8606,44 +8754,70 @@ async fn run_cursor_probe_cached_for_binary( return entry.result.clone(); } } - cache - .gates - .entry(key.clone()) - .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) - .clone() - }; - let _guard = gate.lock().await; - { - let cache = cursor_probe_cache().lock().await; - if let Some(entry) = cache.entries.get(&key) { - if entry.expires_at > Instant::now() { - return entry.result.clone(); + if let Some(flight) = cache.flights.get(&key) { + flight.sender.subscribe() + } else { + if cache.flights.len() >= CURSOR_PROBE_CACHE_MAX_ENTRIES { + return Err(CursorProbeError::Busy); } + let (sender, receiver) = tokio::sync::watch::channel(CursorProbeFlightState::Running); + let flight = Arc::new(CursorProbeFlight { sender }); + cache.flights.insert(key.clone(), flight.clone()); + let worker_key = key.clone(); + let worker_flight = flight.clone(); + let worker_bin = bin.to_path_buf(); + let worker_args = args.iter().map(|arg| (*arg).to_string()).collect(); + let worker_env = extra_env.clone(); + tokio::spawn(async move { + let cancelled = Box::pin(worker_flight.sender.closed()); + let result = AssertUnwindSafe(run_cursor_probe_binary_worker( + worker_bin, + worker_args, + Duration::from_secs(timeout_secs), + worker_env, + cancelled, + )) + .catch_unwind() + .await + .unwrap_or(Err(CursorProbeError::Spawn)); + let mut cache = cursor_probe_cache().lock().await; + if result != Err(CursorProbeError::Cancelled) { + let ttl = if result.is_ok() { + CURSOR_PROBE_SUCCESS_TTL + } else { + CURSOR_PROBE_FAILURE_BACKOFF + }; + prune_cursor_probe_cache(&mut cache); + cache.entries.insert( + worker_key.clone(), + CursorProbeCacheEntry { + result: result.clone(), + expires_at: Instant::now() + ttl, + }, + ); + } + if cache + .flights + .get(&worker_key) + .is_some_and(|current| Arc::ptr_eq(current, &worker_flight)) + { + cache.flights.remove(&worker_key); + } + drop(cache); + let _ = worker_flight + .sender + .send(CursorProbeFlightState::Finished(result)); + }); + receiver } - } - let result = - run_cursor_probe_binary(bin, args, Duration::from_secs(timeout_secs), extra_env).await; - let ttl = if result.is_ok() { - CURSOR_PROBE_SUCCESS_TTL - } else { - CURSOR_PROBE_FAILURE_BACKOFF }; - let mut cache = cursor_probe_cache().lock().await; - prune_cursor_probe_cache(&mut cache); - cache.entries.insert( - key.clone(), - CursorProbeCacheEntry { - result: result.clone(), - expires_at: Instant::now() + ttl, - }, - ); - cache.gates.remove(&key); - result + wait_cursor_probe_flight(&mut receiver).await } pub(crate) async fn acp_cursor_auth_status_core( db: &AppDatabase, api_key: Option, + base_url: Option, ) -> crate::acp::types::CursorAuthStatus { let binary_path = resolve_cursor_binary().map(|p| p.to_string_lossy().to_string()); if binary_path.is_none() { @@ -8658,7 +8832,7 @@ pub(crate) async fn acp_cursor_auth_status_core( binary_path: None, }; } - let extra_env = cursor_probe_env(db, api_key.as_deref()).await; + let extra_env = cursor_probe_env(db, api_key.as_deref(), base_url.as_deref()).await; match run_cursor_probe(&["status", "--format", "json"], 20, &extra_env).await { Ok(stdout) => { // The CLI prints one JSON object; scan to the first `{` so a @@ -8725,8 +8899,9 @@ pub(crate) async fn acp_cursor_auth_status_core( pub(crate) async fn acp_cursor_list_models_core( db: &AppDatabase, api_key: Option, + base_url: Option, ) -> crate::acp::types::CursorModelsResult { - let extra_env = cursor_probe_env(db, api_key.as_deref()).await; + let extra_env = cursor_probe_env(db, api_key.as_deref(), base_url.as_deref()).await; match run_cursor_probe_cached(&["models"], 30, &extra_env).await { Ok(stdout) => { let (models, default_model) = parse_cursor_models(&stdout); @@ -8842,8 +9017,9 @@ fn strip_ansi(input: &str) -> String { pub async fn acp_cursor_auth_status( db: State<'_, AppDatabase>, api_key: Option, + base_url: Option, ) -> Result { - Ok(acp_cursor_auth_status_core(&db, api_key).await) + Ok(acp_cursor_auth_status_core(&db, api_key, base_url).await) } #[cfg(feature = "tauri-runtime")] @@ -8851,8 +9027,9 @@ pub async fn acp_cursor_auth_status( pub async fn acp_cursor_list_models( db: State<'_, AppDatabase>, api_key: Option, + base_url: Option, ) -> Result { - Ok(acp_cursor_list_models_core(&db, api_key).await) + Ok(acp_cursor_list_models_core(&db, api_key, base_url).await) } /// Primary env var keys for each agent type: (api_base_url, api_key, model). @@ -14839,17 +15016,25 @@ wire_api = "chat" } #[tokio::test] - async fn cursor_probe_env_materializes_key_and_scrubs_base_url() { + async fn cursor_probe_env_matches_saved_and_live_cursor_environment() { let db = crate::db::test_helpers::fresh_in_memory_db().await; // API-key mode: the form value wins over saved env and is trimmed; // an explicit custom base URL would be preserved by the shared policy. - let env = cursor_probe_env(&db, Some(" my-key ")).await; + let env = cursor_probe_env( + &db, + Some(" my-key "), + Some(" https://cursor.example.test/// "), + ) + .await; assert_eq!( env.get("CURSOR_API_KEY").map(String::as_str), Some("my-key") ); - assert!(!env.contains_key("CURSOR_API_BASE_URL")); + assert_eq!( + env.get("CURSOR_API_BASE_URL").map(String::as_str), + Some("https://cursor.example.test") + ); assert_eq!( env.get("CURSOR_AUTH_MODE").map(String::as_str), Some("custom") @@ -14857,7 +15042,7 @@ wire_api = "chat" // Subscription passes an empty key → present but empty, so the probe // strips any inherited value instead of leaking it. - let cleared = cursor_probe_env(&db, Some("")).await; + let cleared = cursor_probe_env(&db, Some(""), Some("https://ignored.test")).await; assert_eq!(cleared.get("CURSOR_API_KEY").map(String::as_str), Some("")); assert_eq!( cleared.get("CURSOR_API_BASE_URL").map(String::as_str), @@ -14866,7 +15051,7 @@ wire_api = "chat" // No override + empty legacy DB remains legacy; no credential policy is // invented until the auth mode is explicitly known. - let none = cursor_probe_env(&db, None).await; + let none = cursor_probe_env(&db, None, None).await; assert!(!none.contains_key("CURSOR_API_KEY")); assert!(!none.contains_key("CURSOR_API_BASE_URL")); } @@ -14962,6 +15147,216 @@ wire_api = "chat" } } + #[cfg(unix)] + async fn wait_for_probe_pid_file(path: &Path) -> Vec { + for _ in 0..200 { + if let Ok(recorded) = std::fs::read_to_string(path) { + if !recorded.trim().is_empty() { + return recorded + .trim() + .split(':') + .map(|pid| pid.parse().expect("numeric pid")) + .collect(); + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("probe pid file was not populated"); + } + + #[cfg(unix)] + async fn assert_probe_pids_gone(pids: &[i32]) { + for pid in pids { + let mut gone = false; + for _ in 0..100 { + if unsafe { libc::kill(*pid, 0) } != 0 { + gone = true; + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert!(gone, "probe descendant {pid} survived cleanup"); + } + } + + #[cfg(unix)] + fn cursor_probe_tree_script() -> (tempfile::TempDir, PathBuf) { + cursor_probe_script("printf x >> \"$COUNT_FILE\"; sh -c 'trap \"\" TERM; sh -c '\"'\"'trap \"\" TERM; while [ ! -e \"$RELEASE_FILE\" ]; do sleep 1; done'\"'\"' & grand=$!; printf \"%s:%s\" \"$$\" \"$grand\" > \"$CHILD_PID_FILE\"; wait \"$grand\"' & child=$!; while [ ! -s \"$CHILD_PID_FILE\" ]; do sleep 0.01; done; printf \"%s:%s:%s\" \"$$\" \"$child\" \"$(cat \"$CHILD_PID_FILE\")\" > \"$PID_FILE\"; wait \"$child\"; printf 'auto - Auto (default)\\n'") + } + + #[cfg(unix)] + #[tokio::test] + async fn cursor_probe_singleflight_survives_leader_cancellation_and_cleans_last_waiter() { + let (dir, path) = cursor_probe_tree_script(); + let count_file = dir.path().join("count"); + let pid_file = dir.path().join("pids"); + let release_file = dir.path().join("release"); + let env: BTreeMap = [ + ("COUNT_FILE", count_file.clone()), + ("PID_FILE", pid_file.clone()), + ("CHILD_PID_FILE", dir.path().join("child-pids")), + ("RELEASE_FILE", release_file.clone()), + ("CURSOR_CONFIG_DIR", dir.path().to_path_buf()), + ] + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string_lossy().into_owned())) + .collect(); + let key = cursor_probe_cache_key(&path, None, &["models"], &env); + let leader_path = path.clone(); + let leader_env = env.clone(); + let leader = tokio::spawn(async move { + run_cursor_probe_cached_for_binary(&leader_path, None, &["models"], 30, &leader_env) + .await + }); + let pids = wait_for_probe_pid_file(&pid_file).await; + let follower_path = path.clone(); + let follower_env = env.clone(); + let follower = tokio::spawn(async move { + run_cursor_probe_cached_for_binary(&follower_path, None, &["models"], 30, &follower_env) + .await + }); + tokio::time::sleep(Duration::from_millis(25)).await; + leader.abort(); + let _ = leader.await; + std::fs::write(&release_file, "release").expect("release probe"); + assert!(follower + .await + .expect("follower task") + .expect("shared worker") + .contains("Auto")); + assert_eq!(std::fs::read_to_string(&count_file).unwrap(), "x"); + assert_probe_pids_gone(&pids).await; + assert!(!cursor_probe_cache().lock().await.flights.contains_key(&key)); + + let (cancel_dir, cancel_path) = cursor_probe_tree_script(); + let cancel_pid_file = cancel_dir.path().join("pids"); + let cancel_env: BTreeMap = [ + ("COUNT_FILE", cancel_dir.path().join("count")), + ("PID_FILE", cancel_pid_file.clone()), + ("CHILD_PID_FILE", cancel_dir.path().join("child-pids")), + ("RELEASE_FILE", cancel_dir.path().join("never-release")), + ("CURSOR_CONFIG_DIR", cancel_dir.path().to_path_buf()), + ] + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string_lossy().into_owned())) + .collect(); + let cancel_key = cursor_probe_cache_key(&cancel_path, None, &["models"], &cancel_env); + let mut waiters = Vec::new(); + for _ in 0..2 { + let worker_path = cancel_path.clone(); + let worker_env = cancel_env.clone(); + waiters.push(tokio::spawn(async move { + run_cursor_probe_cached_for_binary(&worker_path, None, &["models"], 30, &worker_env) + .await + })); + } + let cancel_pids = wait_for_probe_pid_file(&cancel_pid_file).await; + for waiter in waiters { + waiter.abort(); + let _ = waiter.await; + } + assert_probe_pids_gone(&cancel_pids).await; + assert_eq!( + std::fs::read_to_string(cancel_dir.path().join("count")).unwrap(), + "x" + ); + assert!(!cursor_probe_cache() + .lock() + .await + .flights + .contains_key(&cancel_key)); + } + + #[cfg(unix)] + #[tokio::test] + async fn cursor_probe_success_terminates_pipe_detached_descendants() { + let (dir, path) = cursor_probe_script( + "sh -c 'exec >/dev/null 2>&1; trap \"\" TERM; sh -c '\"'\"'exec >/dev/null 2>&1; trap \"\" TERM; while :; do sleep 1; done'\"'\"' & grand=$!; printf \"%s:%s\" \"$$\" \"$grand\" > \"$CHILD_PID_FILE\"; wait \"$grand\"' & child=$!; while [ ! -s \"$CHILD_PID_FILE\" ]; do sleep 0.01; done; printf \"%s:%s:%s\" \"$$\" \"$child\" \"$(cat \"$CHILD_PID_FILE\")\" > \"$PID_FILE\"; printf 'auto - Auto (default)\\n'", + ); + let pid_file = dir.path().join("pids"); + let env: BTreeMap = [ + ("PID_FILE", pid_file.clone()), + ("CHILD_PID_FILE", dir.path().join("child-pids")), + ("CURSOR_CONFIG_DIR", dir.path().to_path_buf()), + ] + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string_lossy().into_owned())) + .collect(); + + let output = run_cursor_probe_binary(&path, &["models"], Duration::from_secs(3), &env) + .await + .expect("probe output"); + assert!(output.contains("Auto")); + let pids = wait_for_probe_pid_file(&pid_file).await; + assert_probe_pids_gone(&pids).await; + } + + #[cfg(unix)] + #[tokio::test] + async fn cursor_probe_singleflight_propagates_worker_error_and_panic_without_hanging() { + let (error_dir, error_path) = cursor_probe_script( + "printf x >> \"$COUNT_FILE\"; sleep 0.1; printf private >&2; exit 9", + ); + let error_count = error_dir.path().join("count"); + let error_env: BTreeMap = [ + ( + "COUNT_FILE".to_string(), + error_count.to_string_lossy().into_owned(), + ), + ( + "CURSOR_CONFIG_DIR".to_string(), + error_dir.path().to_string_lossy().into_owned(), + ), + ] + .into(); + let error_key = cursor_probe_cache_key(&error_path, None, &["models"], &error_env); + let (left, right) = tokio::join!( + run_cursor_probe_cached_for_binary(&error_path, None, &["models"], 3, &error_env), + run_cursor_probe_cached_for_binary(&error_path, None, &["models"], 3, &error_env) + ); + assert_eq!(left, Err(CursorProbeError::NonZeroExit)); + assert_eq!(right, Err(CursorProbeError::NonZeroExit)); + assert_eq!(std::fs::read_to_string(error_count).unwrap(), "x"); + assert!(!cursor_probe_cache() + .lock() + .await + .flights + .contains_key(&error_key)); + + let (panic_dir, panic_path) = cursor_probe_tree_script(); + let panic_pid_file = panic_dir.path().join("pids"); + let panic_count = panic_dir.path().join("count"); + let panic_env: BTreeMap = [ + ("COUNT_FILE", panic_count.clone()), + ("PID_FILE", panic_pid_file.clone()), + ("CHILD_PID_FILE", panic_dir.path().join("child-pids")), + ("RELEASE_FILE", panic_dir.path().join("never-release")), + ("CURSOR_CONFIG_DIR", panic_dir.path().to_path_buf()), + ( + "CODEG_TEST_CURSOR_PROBE_PANIC_AFTER_FILE", + panic_pid_file.clone(), + ), + ] + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string_lossy().into_owned())) + .collect(); + let panic_key = cursor_probe_cache_key(&panic_path, None, &["models"], &panic_env); + let (left, right) = tokio::join!( + run_cursor_probe_cached_for_binary(&panic_path, None, &["models"], 3, &panic_env), + run_cursor_probe_cached_for_binary(&panic_path, None, &["models"], 3, &panic_env) + ); + assert_eq!(left, Err(CursorProbeError::Spawn)); + assert_eq!(right, Err(CursorProbeError::Spawn)); + let panic_pids = wait_for_probe_pid_file(&panic_pid_file).await; + assert_probe_pids_gone(&panic_pids).await; + assert_eq!(std::fs::read_to_string(panic_count).unwrap(), "x"); + assert!(!cursor_probe_cache() + .lock() + .await + .flights + .contains_key(&panic_key)); + } + #[cfg(unix)] #[tokio::test] async fn cursor_probe_cache_singleflights_and_keys_environment_and_binary_identity() { diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index 7366eac71..61a487dda 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -706,6 +706,7 @@ pub async fn acp_update_agent_config( #[serde(rename_all = "camelCase", default)] pub struct CursorProbeParams { pub api_key: Option, + pub base_url: Option, } pub async fn acp_cursor_auth_status( @@ -713,7 +714,7 @@ pub async fn acp_cursor_auth_status( Json(params): Json, ) -> Result, AppCommandError> { Ok(Json( - acp_commands::acp_cursor_auth_status_core(&state.db, params.api_key).await, + acp_commands::acp_cursor_auth_status_core(&state.db, params.api_key, params.base_url).await, )) } @@ -722,7 +723,7 @@ pub async fn acp_cursor_list_models( Json(params): Json, ) -> Result, AppCommandError> { Ok(Json( - acp_commands::acp_cursor_list_models_core(&state.db, params.api_key).await, + acp_commands::acp_cursor_list_models_core(&state.db, params.api_key, params.base_url).await, )) } diff --git a/src/components/chat/model-option-list.test.tsx b/src/components/chat/model-option-list.test.tsx index 74182c4f4..1067a0683 100644 --- a/src/components/chat/model-option-list.test.tsx +++ b/src/components/chat/model-option-list.test.tsx @@ -102,6 +102,33 @@ describe("ModelOptionList", () => { ) }) + it("renders Cursor's unavailable-current sentinel as display-only", async () => { + const user = userEvent.setup() + const { onSelect } = renderList({ + groups: [ + { + key: "cursor", + name: null, + options: [ + { + value: "__codeg_cursor_current_unavailable__", + name: "Current Cursor configuration (not in CLI catalog)", + description: null, + }, + { value: "default", name: "Auto", description: null }, + ], + }, + ], + currentValue: "__codeg_cursor_current_unavailable__", + }) + const unavailable = screen.getByRole("option", { + name: /Current Cursor configuration/, + }) + expect(unavailable).toBeDisabled() + await user.click(unavailable) + expect(onSelect).not.toHaveBeenCalled() + }) + it("filters options as you type (matching name or value)", async () => { const user = userEvent.setup() renderList() diff --git a/src/components/chat/model-option-list.tsx b/src/components/chat/model-option-list.tsx index ed7d113d7..4583b7323 100644 --- a/src/components/chat/model-option-list.tsx +++ b/src/components/chat/model-option-list.tsx @@ -29,6 +29,12 @@ interface ModelOptionListProps { // taller) — only sizes the scroll window; virtua measures real rows itself. const ROW_ESTIMATE_PX = 44 const MAX_LIST_HEIGHT_PX = 320 +export const CURSOR_CURRENT_UNAVAILABLE_VALUE = + "__codeg_cursor_current_unavailable__" + +function isSelectableModelValue(value: string): boolean { + return value !== CURSOR_CURRENT_UNAVAILABLE_VALUE +} // Searchable, virtualized model list shared by both selector forms (the wide // popover and the collapsed cog panel). Deliberately NOT a Radix menu and NOT @@ -77,7 +83,12 @@ export function ModelOptionList({ // Flat row indices that are options (skipping headers) — the keyboard cursor // walks these, and they map an option position back to its flat row index. const optionRowIndices = useMemo( - () => rows.flatMap((row, index) => (row.kind === "option" ? [index] : [])), + () => + rows.flatMap((row, index) => + row.kind === "option" && isSelectableModelValue(row.option.value) + ? [index] + : [] + ), [rows] ) const optionCount = optionRowIndices.length @@ -232,23 +243,35 @@ export function ModelOptionList({ ) } - const optionIndex = optionIndexByRow.get(flatIndex) ?? 0 + const optionIndex = optionIndexByRow.get(flatIndex) + const selectable = optionIndex !== undefined const selected = row.option.value === currentValue - const active = optionIndex === activeIndexClamped + const active = + selectable && optionIndex === activeIndexClamped return (