From b85cb37898291598af78f7ffc07c9a044117582e Mon Sep 17 00:00:00 2001 From: jiah0231 <2243725197@cug.edu.cn> Date: Thu, 27 Aug 2026 10:30:42 +0800 Subject: [PATCH 01/10] fix: normalize integer-valued shell timeouts --- server/src/cursor/tools/dispatch/mod.rs | 114 ++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/server/src/cursor/tools/dispatch/mod.rs b/server/src/cursor/tools/dispatch/mod.rs index eb4ea88..217c8de 100644 --- a/server/src/cursor/tools/dispatch/mod.rs +++ b/server/src/cursor/tools/dispatch/mod.rs @@ -39,6 +39,9 @@ pub(super) async fn start( context: &ExecContext, store: Option<&Store>, ) -> Result { + let normalized_call = normalize_block_until_ms(call)?; + let call = normalized_call.as_ref().unwrap_or(call); + if let Some(definition) = dynamic_mcp.get(&call.name) { return exec::start_dynamic(runtime, call, definition, context).await; } @@ -64,6 +67,55 @@ pub(super) async fn start( } } +fn normalize_block_until_ms(call: &ToolCall) -> Result> { + if !matches!(normalized(&call.name).as_str(), "shell" | "awaitshell") { + return Ok(None); + } + let Some(value) = call.arguments.get("block_until_ms") else { + return Ok(None); + }; + + let integer = if let Some(value) = value.as_i64() { + value + } else { + let value = value.as_f64().ok_or_else(|| { + Error::Protocol(format!("{} block_until_ms must be an integer", call.name)) + })?; + if !value.is_finite() || value.fract() != 0.0 { + return Err(Error::Protocol(format!( + "{} block_until_ms must be an integer", + call.name + ))); + } + if value < i64::MIN as f64 || value > i64::MAX as f64 { + return Err(Error::Protocol(format!( + "{} block_until_ms is out of range", + call.name + ))); + } + value as i64 + }; + + if integer < 0 { + return Err(Error::Protocol(format!( + "{} block_until_ms is out of range", + call.name + ))); + } + + if value.as_i64().is_some() { + return Ok(None); + } + + let mut normalized_call = call.clone(); + normalized_call + .arguments + .as_object_mut() + .ok_or_else(|| Error::Protocol(format!("{} arguments must be a JSON object", call.name)))? + .insert("block_until_ms".into(), serde_json::Value::from(integer)); + Ok(Some(normalized_call)) +} + fn is_mcp_auth(call: &ToolCall) -> bool { normalized(&call.name) == "callmcptool" && call @@ -89,3 +141,65 @@ pub(super) fn normalized(name: &str) -> String { .flat_map(char::to_lowercase) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + + fn tool(name: &str, arguments: serde_json::Value) -> ToolCall { + ToolCall { + index: 0, + call_id: "call-1".into(), + model_call_id: "model-call-1".into(), + name: name.into(), + arguments_text: arguments.to_string(), + arguments, + } + } + + #[test] + fn shell_accepts_integer_valued_float_timeout() { + let call = tool("Shell", serde_json::json!({ + "command": "echo ok", + "block_until_ms": 45_000.0 + })); + + let call = normalize_block_until_ms(&call).unwrap().unwrap(); + + assert_eq!(call.arguments["block_until_ms"].as_i64(), Some(45_000)); + } + + #[test] + fn await_shell_accepts_integer_valued_scientific_timeout() { + let arguments = serde_json::from_str(r#"{"shell_id":"1","block_until_ms":3e4}"#).unwrap(); + let call = tool("AwaitShell", arguments); + + let call = normalize_block_until_ms(&call).unwrap().unwrap(); + + assert_eq!(call.arguments["block_until_ms"].as_u64(), Some(30_000)); + } + + #[test] + fn shell_rejects_fractional_timeout() { + let call = tool("Shell", serde_json::json!({ + "command": "echo ok", + "block_until_ms": 30_000.5 + })); + + let error = normalize_block_until_ms(&call).unwrap_err(); + + assert_eq!(error.to_string(), "protocol error: Shell block_until_ms must be an integer"); + } + + #[test] + fn await_shell_rejects_negative_timeout_instead_of_defaulting() { + let call = tool("AwaitShell", serde_json::json!({ + "shell_id": "1", + "block_until_ms": -1 + })); + + let error = normalize_block_until_ms(&call).unwrap_err(); + + assert_eq!(error.to_string(), "protocol error: AwaitShell block_until_ms is out of range"); + } +} From 7914f24d8312486caf76ac67e73ff08c22c0a6f4 Mon Sep 17 00:00:00 2001 From: jiah0231 <2243725197@cug.edu.cn> Date: Thu, 27 Aug 2026 10:31:18 +0800 Subject: [PATCH 02/10] fix: preserve dynamic MCP argument schemas --- server/src/cursor/tools/dispatch/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/cursor/tools/dispatch/mod.rs b/server/src/cursor/tools/dispatch/mod.rs index 217c8de..53ce5c1 100644 --- a/server/src/cursor/tools/dispatch/mod.rs +++ b/server/src/cursor/tools/dispatch/mod.rs @@ -39,9 +39,6 @@ pub(super) async fn start( context: &ExecContext, store: Option<&Store>, ) -> Result { - let normalized_call = normalize_block_until_ms(call)?; - let call = normalized_call.as_ref().unwrap_or(call); - if let Some(definition) = dynamic_mcp.get(&call.name) { return exec::start_dynamic(runtime, call, definition, context).await; } @@ -54,6 +51,9 @@ pub(super) async fn start( return local::subagents_disabled(call); } + let normalized_call = normalize_block_until_ms(call)?; + let call = normalized_call.as_ref().unwrap_or(call); + match normalized(&call.name).as_str() { "shell" | "read" | "delete" | "grep" | "glob" | "readlints" | "task" | "callmcptool" | "fetchmcpresource" | "getmcptools" => exec::start(runtime, call, context).await, From 31dfa8d29551470b0117c8d35b7051325b625a84 Mon Sep 17 00:00:00 2001 From: leokun Date: Thu, 27 Aug 2026 11:11:13 +0800 Subject: [PATCH 03/10] refactor: improve MCP parameter normalization and test clarity - Changed `normalize_mcp_parameters` to avoid mutating the input parameters. - Updated the test name to better reflect its purpose of preserving the cursor object union schema. - Adjusted assertions in the test to compare the entire schema instead of individual fields. --- server/src/cursor/request/context.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/server/src/cursor/request/context.rs b/server/src/cursor/request/context.rs index 65ad05b..24cd256 100644 --- a/server/src/cursor/request/context.rs +++ b/server/src/cursor/request/context.rs @@ -478,9 +478,9 @@ pub fn dynamic_mcp( Ok(output) } -fn normalize_mcp_parameters(tool_name: &str, mut parameters: Value) -> Result { +fn normalize_mcp_parameters(tool_name: &str, parameters: Value) -> Result { let schema = parameters - .as_object_mut() + .as_object() .ok_or_else(|| invalid_mcp_parameters(tool_name))?; match schema.get("type") { Some(Value::String(schema_type)) if schema_type == "object" => return Ok(parameters), @@ -505,7 +505,6 @@ fn normalize_mcp_parameters(tool_name: &str, mut parameters: Value) -> Result Date: Thu, 27 Aug 2026 11:24:04 +0800 Subject: [PATCH 04/10] feat(settings): add dock icon visibility setting to desktop settings - Introduced a new setting `show_dock_icon` in the `DesktopSettings` interface. - Updated `AppLifecycleSettingsCard` to manage dock icon visibility with a toggle switch. - Enhanced the API to read and write the new dock icon visibility setting. - Added corresponding translations for the new setting in both English and Chinese locales. - Updated tests to ensure the default behavior of showing the dock icon and proper round-trip functionality for settings. --- .../src-tauri/capabilities/default.json | 1 + apps/desktop/src-tauri/src/desktop.rs | 11 +- apps/desktop/src/api.ts | 1 + .../settings/AppLifecycleSettingsCard.tsx | 54 ++++++-- apps/desktop/src/i18n/generated/catalog.json | 125 +++++++++++------- apps/desktop/src/i18n/locales/en-US.json | 6 +- apps/desktop/src/i18n/locales/zh-CN.json | 6 +- apps/desktop/src/layouts/AppFrame.tsx | 11 +- apps/desktop/src/layouts/AppHeader.tsx | 2 +- apps/desktop/src/native/appLifecycle.ts | 27 +++- apps/desktop/src/native/platform.ts | 8 ++ server/src/store/settings.rs | 41 +++++- 12 files changed, 211 insertions(+), 82 deletions(-) create mode 100644 apps/desktop/src/native/platform.ts diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 07b264a..1954d26 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -16,6 +16,7 @@ "core:window:allow-toggle-maximize", "core:window:allow-is-maximized", "core:window:allow-close", + "core:app:allow-set-dock-visibility", "allow-open-terminal-with-command", "clipboard-manager:allow-write-text", "autostart:default", diff --git a/apps/desktop/src-tauri/src/desktop.rs b/apps/desktop/src-tauri/src/desktop.rs index bb75f6b..e4b767b 100644 --- a/apps/desktop/src-tauri/src/desktop.rs +++ b/apps/desktop/src-tauri/src/desktop.rs @@ -186,9 +186,12 @@ pub fn run() { let listener = tauri::async_runtime::block_on(server.bind())?; let address = listener.local_addr()?; tauri::async_runtime::block_on(server.harness().cleanup_stale_settings())?; - let silent_start = tauri::async_runtime::block_on(server.store().desktop_settings()) - .map(|settings| settings.silent_start) - .unwrap_or(false); + let desktop_settings = + tauri::async_runtime::block_on(server.store().desktop_settings()) + .unwrap_or_default(); + #[cfg(target_os = "macos")] + app.handle() + .set_dock_visibility(desktop_settings.show_dock_icon)?; let shutdown = CancellationToken::new(); let server_shutdown = shutdown.clone(); let app_handle = app.handle().clone(); @@ -206,7 +209,7 @@ pub fn run() { exiting: AtomicBool::new(false), }); let window = create_main_window(app.handle(), address)?; - if silent_start && started_by_autostart { + if desktop_settings.silent_start && started_by_autostart { tracing::info!("silent autostart enabled; keeping the main window hidden"); } else { window.show()?; diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index 9eabcad..7ebaf90 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -143,6 +143,7 @@ export interface TabSettings { export interface DesktopSettings { silent_start: boolean; + show_dock_icon: boolean; } export interface OverviewMetrics { diff --git a/apps/desktop/src/components/settings/AppLifecycleSettingsCard.tsx b/apps/desktop/src/components/settings/AppLifecycleSettingsCard.tsx index 9160069..ef8c5c1 100644 --- a/apps/desktop/src/components/settings/AppLifecycleSettingsCard.tsx +++ b/apps/desktop/src/components/settings/AppLifecycleSettingsCard.tsx @@ -1,10 +1,12 @@ import { useEffect, useState } from "react"; import { currentAppVersion, + hasDockVisibilitySetting, hasNativeAppLifecycle, readAutostart, - readSilentStart, + readDesktopSettings, writeAutostart, + writeDockIconVisibility, writeSilentStart, } from "../../native/appLifecycle"; import { updateStore, useUpdateStore } from "../../store/updateStore"; @@ -17,12 +19,14 @@ import styles from "./AppLifecycleSettingsCard.module.scss"; export function AppLifecycleSettingsCard() { const message = useMessage(); const native = hasNativeAppLifecycle(); + const dockVisibilitySetting = hasDockVisibilitySetting(); const { availableVersion, checking, installing } = useUpdateStore(); const [version, setVersion] = useState("…"); const [autostart, setAutostart] = useState(false); const [loadingAutostart, setLoadingAutostart] = useState(native); const [silentStart, setSilentStart] = useState(false); - const [loadingSilentStart, setLoadingSilentStart] = useState(native); + const [dockIconVisible, setDockIconVisible] = useState(true); + const [loadingDesktopSettings, setLoadingDesktopSettings] = useState(native); useEffect(() => { let disposed = false; @@ -32,10 +36,17 @@ export function AppLifecycleSettingsCard() { .then((enabled) => { if (!disposed) setAutostart(enabled); }) .catch((cause) => message(cause instanceof Error ? cause.message : String(cause))) .finally(() => { if (!disposed) setLoadingAutostart(false); }); - void readSilentStart() - .then((silent) => { if (!disposed) setSilentStart(silent); }) + void readDesktopSettings() + .then((settings) => { + if (disposed) return; + setSilentStart(settings.silent_start); + setDockIconVisible(settings.show_dock_icon); + }) .catch(() => {}) - .finally(() => { if (!disposed) setLoadingSilentStart(false); }); + .finally(() => { + if (disposed) return; + setLoadingDesktopSettings(false); + }); } return () => { disposed = true; }; }, [message, native]); @@ -55,14 +66,27 @@ export function AppLifecycleSettingsCard() { const toggleSilentStart = async (enabled: boolean) => { try { - setLoadingSilentStart(true); + setLoadingDesktopSettings(true); await writeSilentStart(enabled); - setSilentStart(await readSilentStart()); + setSilentStart(enabled); message(enabled ? t("已开启静默启动") : t("已关闭静默启动")); } catch (cause) { message(cause instanceof Error ? cause.message : String(cause)); } finally { - setLoadingSilentStart(false); + setLoadingDesktopSettings(false); + } + }; + + const toggleDockIcon = async (visible: boolean) => { + try { + setLoadingDesktopSettings(true); + await writeDockIconVisibility(visible); + setDockIconVisible(visible); + message(visible ? t("已显示 Dock 栏图标") : t("已隐藏 Dock 栏图标")); + } catch (cause) { + message(cause instanceof Error ? cause.message : String(cause)); + } finally { + setLoadingDesktopSettings(false); } }; @@ -103,11 +127,23 @@ export function AppLifecycleSettingsCard() { void toggleSilentStart(enabled)} /> } + {dockVisibilitySetting &&
+
+ {t("在 Dock 栏显示")} + {t("关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。")} +
+ void toggleDockIcon(visible)} + /> +
}
{t("软件更新")} diff --git a/apps/desktop/src/i18n/generated/catalog.json b/apps/desktop/src/i18n/generated/catalog.json index 6c6d5ea..a278fc2 100644 --- a/apps/desktop/src/i18n/generated/catalog.json +++ b/apps/desktop/src/i18n/generated/catalog.json @@ -516,7 +516,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 120, + "line": 156, "column": 27 } ] @@ -735,7 +735,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 72, + "line": 96, "column": 29 } ] @@ -773,7 +773,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 116, + "line": 152, "column": 13 } ] @@ -1095,18 +1095,6 @@ } ] }, - "393df9bb13ea4900": { - "source": "命中", - "kind": "text", - "placeholders": [], - "refs": [ - { - "file": "components/metrics/CacheHitRateChart.tsx", - "line": 60, - "column": 14 - } - ] - }, "393e1241552b1870": { "source": "请求", "kind": "text", @@ -1196,6 +1184,18 @@ } ] }, + "3b67824289b5fa1e": { + "source": "已隐藏 Dock 栏图标", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/settings/AppLifecycleSettingsCard.tsx", + "line": 85, + "column": 45 + } + ] + }, "3c94b4c75940c178": { "source": "输入 Token", "kind": "text", @@ -1392,7 +1392,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 102, + "line": 126, "column": 17 } ] @@ -1432,7 +1432,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 115, + "line": 151, "column": 13 } ] @@ -1868,7 +1868,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 48, + "line": 59, "column": 40 } ] @@ -2004,7 +2004,7 @@ "refs": [ { "file": "api.ts", - "line": 314, + "line": 315, "column": 43 } ] @@ -2016,7 +2016,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 113, + "line": 149, "column": 18 } ] @@ -2095,6 +2095,23 @@ } ] }, + "680680288a6d2ad2": { + "source": "在 Dock 栏显示", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/settings/AppLifecycleSettingsCard.tsx", + "line": 137, + "column": 18 + }, + { + "file": "components/settings/AppLifecycleSettingsCard.tsx", + "line": 143, + "column": 16 + } + ] + }, "68ad603fafe4e0d6": { "source": "导入旧版配置", "kind": "text", @@ -2189,7 +2206,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 72, + "line": 96, "column": 78 } ] @@ -2280,7 +2297,7 @@ "refs": [ { "file": "api.ts", - "line": 309, + "line": 310, "column": 43 } ] @@ -2395,7 +2412,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 124, + "line": 160, "column": 37 } ] @@ -2407,7 +2424,7 @@ "refs": [ { "file": "api.ts", - "line": 258, + "line": 259, "column": 21 } ] @@ -2446,12 +2463,12 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 89, + "line": 113, "column": 18 }, { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 95, + "line": 119, "column": 16 } ] @@ -2475,7 +2492,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 61, + "line": 72, "column": 25 } ] @@ -2810,7 +2827,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 61, + "line": 72, "column": 40 } ] @@ -2896,7 +2913,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 86, + "line": 110, "column": 29 } ] @@ -2908,12 +2925,12 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 101, + "line": 125, "column": 18 }, { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 107, + "line": 131, "column": 16 } ] @@ -3142,7 +3159,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 48, + "line": 59, "column": 25 } ] @@ -3166,7 +3183,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 120, + "line": 156, "column": 39 } ] @@ -3199,6 +3216,18 @@ } ] }, + "aecb952b1e6cce36": { + "source": "关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/settings/AppLifecycleSettingsCard.tsx", + "line": 138, + "column": 17 + } + ] + }, "aee88743413144a2": { "source": "刷新", "kind": "text", @@ -3308,6 +3337,18 @@ } ] }, + "b6725f218ebaef26": { + "source": "已显示 Dock 栏图标", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "components/settings/AppLifecycleSettingsCard.tsx", + "line": 85, + "column": 25 + } + ] + }, "b75a46aad3e7c132": { "source": "非缓存输入:{tokens}", "kind": "template", @@ -3493,7 +3534,7 @@ "refs": [ { "file": "components/metrics/CacheHitRateChart.tsx", - "line": 71, + "line": 58, "column": 62 } ] @@ -3724,7 +3765,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 90, + "line": 114, "column": 17 } ] @@ -4323,18 +4364,6 @@ } ] }, - "f3a76d896853c1df": { - "source": "未命中", - "kind": "text", - "placeholders": [], - "refs": [ - { - "file": "components/metrics/CacheHitRateChart.tsx", - "line": 60, - "column": 23 - } - ] - }, "f4694c46b1e19602": { "source": "最终请求类型", "kind": "text", @@ -4493,7 +4522,7 @@ "refs": [ { "file": "components/settings/AppLifecycleSettingsCard.tsx", - "line": 124, + "line": 160, "column": 25 } ] diff --git a/apps/desktop/src/i18n/locales/en-US.json b/apps/desktop/src/i18n/locales/en-US.json index 317ebfa..af4c6f1 100644 --- a/apps/desktop/src/i18n/locales/en-US.json +++ b/apps/desktop/src/i18n/locales/en-US.json @@ -71,7 +71,6 @@ "37125ef2e1d707cb": "Server address or complete request URL, API Key, model name, display name, and note are required", "378bb0eec39fa8a2": "Last page", "37cb98ff4d5dcfcc": "Successful {successful} / failed {failed}", - "393df9bb13ea4900": "Hit", "393e1241552b1870": "Request", "398f8e6c6f0a0b97": "Continue selecting or typing", "39f52eee100131d7": "Cached input", @@ -79,6 +78,7 @@ "3a3f595df70ec8ff": "Clear storage", "3a5040b68abf75f9": "Select all", "3a8c76b2ce785f96": "Review and import", + "3b67824289b5fa1e": "Dock icon hidden", "3c94b4c75940c178": "Input Tokens", "3cfae5728b92b334": "Token usage: {tokens}", "3d13868593ae4eeb": "Display language", @@ -143,6 +143,7 @@ "656ab25e264cc4e4": "No models are available to Cursor yet", "65a6318e07ec1e07": "Tools", "65cb9a7b4f620b6b": "Prompt {tokens}", + "680680288a6d2ad2": "Show in Dock", "68ad603fafe4e0d6": "Import legacy configuration", "68ea5dd4d7af20e6": "System settings", "6a9906c79f26c0ba": "Start time", @@ -222,12 +223,14 @@ "ac69f68b7010ec79": "Download and install", "ad6a60ee93d3ba3e": "Loading call details…", "ae2d0b7f79cea4a3": "Model output: {tokens} × ${price}/1M = {cost}", + "aecb952b1e6cce36": "Hide the Dock icon when disabled. You can still open the app from the menu bar icon.", "aee88743413144a2": "Refresh", "b06325c5660f0c29": "Direct", "b16c3b2ecedd6fe1": "Cursor integration is active. Add a model configuration to use a BYOK model.", "b4411558b932266f": "Provider type", "b502b1d414664337": "Prompt: {tokens}", "b5141d3d19e9a048": "Yes", + "b6725f218ebaef26": "Dock icon shown", "b75a46aad3e7c132": "Non-cached input: {tokens}", "b79354009c614ae9": "Statistics", "b86967982067d295": " (estimated)", @@ -303,7 +306,6 @@ "ee239f3943293f87": "Sunday", "ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.", "f2bdc88464c51c2e": "Show API Key", - "f3a76d896853c1df": "Miss", "f4694c46b1e19602": "Final request type", "f4dcb6a3ceb32247": "Page {page} of {count}", "f4fa9f31ea2ae58d": "Token usage calendar for the past year", diff --git a/apps/desktop/src/i18n/locales/zh-CN.json b/apps/desktop/src/i18n/locales/zh-CN.json index 6682ace..caac4a4 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/i18n/locales/zh-CN.json @@ -71,7 +71,6 @@ "37125ef2e1d707cb": "服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空", "378bb0eec39fa8a2": "最后一页", "37cb98ff4d5dcfcc": "成功 {successful} / 异常 {failed}", - "393df9bb13ea4900": "命中", "393e1241552b1870": "请求", "398f8e6c6f0a0b97": "继续选择或输入", "39f52eee100131d7": "缓存输入", @@ -79,6 +78,7 @@ "3a3f595df70ec8ff": "清理存储空间", "3a5040b68abf75f9": "全选", "3a8c76b2ce785f96": "查看并导入", + "3b67824289b5fa1e": "已隐藏 Dock 栏图标", "3c94b4c75940c178": "输入 Token", "3cfae5728b92b334": "Token 用量:{tokens}", "3d13868593ae4eeb": "界面语言", @@ -143,6 +143,7 @@ "656ab25e264cc4e4": "还没有可供 Cursor 使用的模型", "65a6318e07ec1e07": "工具数", "65cb9a7b4f620b6b": "提示词 {tokens}", + "680680288a6d2ad2": "在 Dock 栏显示", "68ad603fafe4e0d6": "导入旧版配置", "68ea5dd4d7af20e6": "系统设置", "6a9906c79f26c0ba": "开始时间", @@ -222,12 +223,14 @@ "ac69f68b7010ec79": "下载并安装", "ad6a60ee93d3ba3e": "正在加载调用详情…", "ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}", + "aecb952b1e6cce36": "关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。", "aee88743413144a2": "刷新", "b06325c5660f0c29": "直连", "b16c3b2ecedd6fe1": "Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。", "b4411558b932266f": "上游类型", "b502b1d414664337": "提示词:{tokens}", "b5141d3d19e9a048": "是", + "b6725f218ebaef26": "已显示 Dock 栏图标", "b75a46aad3e7c132": "非缓存输入:{tokens}", "b79354009c614ae9": "统计数据", "b86967982067d295": "(估算)", @@ -303,7 +306,6 @@ "ee239f3943293f87": "周日", "ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。", "f2bdc88464c51c2e": "显示 API Key", - "f3a76d896853c1df": "未命中", "f4694c46b1e19602": "最终请求类型", "f4dcb6a3ceb32247": "第 {page} / {count} 页", "f4fa9f31ea2ae58d": "过去一年的 Token 用量日历", diff --git a/apps/desktop/src/layouts/AppFrame.tsx b/apps/desktop/src/layouts/AppFrame.tsx index 969fd1b..5b84a4c 100644 --- a/apps/desktop/src/layouts/AppFrame.tsx +++ b/apps/desktop/src/layouts/AppFrame.tsx @@ -1,4 +1,5 @@ import { Outlet } from "react-router-dom"; +import { desktopPlatform } from "../native/platform"; import styles from "./AppFrame.module.scss"; import { AppHeader } from "./AppHeader"; @@ -17,13 +18,3 @@ export function AppFrame() {
); } - -export type DesktopPlatform = "macos" | "windows" | "linux"; - -function desktopPlatform(): DesktopPlatform { - // return "windows"; - const agent = navigator.userAgent; - if (/Macintosh|Mac OS X/.test(agent)) return "macos"; - if (/Windows/.test(agent)) return "windows"; - return "linux"; -} diff --git a/apps/desktop/src/layouts/AppHeader.tsx b/apps/desktop/src/layouts/AppHeader.tsx index accf2c8..7932fe6 100644 --- a/apps/desktop/src/layouts/AppHeader.tsx +++ b/apps/desktop/src/layouts/AppHeader.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import appIcon from "../../src-tauri/icons/32x32.png"; import { currentAppVersion } from "../native/appLifecycle"; -import type { DesktopPlatform } from "./AppFrame"; +import type { DesktopPlatform } from "../native/platform"; import { WindowControls } from "./WindowControls"; import styles from "./AppHeader.module.scss"; diff --git a/apps/desktop/src/native/appLifecycle.ts b/apps/desktop/src/native/appLifecycle.ts index 8c3f12e..da7379c 100644 --- a/apps/desktop/src/native/appLifecycle.ts +++ b/apps/desktop/src/native/appLifecycle.ts @@ -1,14 +1,19 @@ -import { getVersion } from "@tauri-apps/api/app"; +import { getVersion, setDockVisibility } from "@tauri-apps/api/app"; import { isTauri } from "@tauri-apps/api/core"; import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart"; import { relaunch } from "@tauri-apps/plugin-process"; import { check, type Update } from "@tauri-apps/plugin-updater"; -import { api } from "../api"; +import { api, type DesktopSettings } from "../api"; +import { desktopPlatform } from "./platform"; export function hasNativeAppLifecycle(): boolean { return isTauri(); } +export function hasDockVisibilitySetting(): boolean { + return hasNativeAppLifecycle() && desktopPlatform() === "macos"; +} + export async function currentAppVersion(): Promise { return hasNativeAppLifecycle() ? getVersion() : "dev"; } @@ -21,12 +26,24 @@ export async function writeAutostart(enabled: boolean): Promise { await (enabled ? enable() : disable()); } -export async function readSilentStart(): Promise { - return (await api.desktopSettings()).silent_start; +export async function readDesktopSettings(): Promise { + return api.desktopSettings(); } export async function writeSilentStart(silentStart: boolean): Promise { - await api.setDesktopSettings({ silent_start: silentStart }); + const settings = await readDesktopSettings(); + await api.setDesktopSettings({ ...settings, silent_start: silentStart }); +} + +export async function writeDockIconVisibility(visible: boolean): Promise { + const settings = await readDesktopSettings(); + await setDockVisibility(visible); + try { + await api.setDesktopSettings({ ...settings, show_dock_icon: visible }); + } catch (cause) { + await setDockVisibility(settings.show_dock_icon).catch(() => {}); + throw cause; + } } export async function checkForUpdate(): Promise { diff --git a/apps/desktop/src/native/platform.ts b/apps/desktop/src/native/platform.ts new file mode 100644 index 0000000..c7c9a9d --- /dev/null +++ b/apps/desktop/src/native/platform.ts @@ -0,0 +1,8 @@ +export type DesktopPlatform = "macos" | "windows" | "linux"; + +export function desktopPlatform(): DesktopPlatform { + const agent = navigator.userAgent; + if (/Macintosh|Mac OS X/.test(agent)) return "macos"; + if (/Windows/.test(agent)) return "windows"; + return "linux"; +} diff --git a/server/src/store/settings.rs b/server/src/store/settings.rs index 3377a46..496cd11 100644 --- a/server/src/store/settings.rs +++ b/server/src/store/settings.rs @@ -47,10 +47,25 @@ pub struct TabSettings { pub address: String, } -#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)] pub struct DesktopSettings { #[serde(default)] pub silent_start: bool, + #[serde(default = "default_true")] + pub show_dock_icon: bool, +} + +impl Default for DesktopSettings { + fn default() -> Self { + Self { + silent_start: false, + show_dock_icon: true, + } + } +} + +fn default_true() -> bool { + true } impl TabSettings { @@ -324,6 +339,30 @@ mod tests { assert_eq!(store.port_settings().await.unwrap(), settings); } + #[tokio::test] + async fn desktop_settings_show_the_dock_icon_by_default_and_round_trip() { + let store = Store::connect("sqlite::memory:").await.unwrap(); + + assert_eq!( + store.desktop_settings().await.unwrap(), + DesktopSettings::default() + ); + assert_eq!( + serde_json::from_str::(r#"{"silent_start":true}"#).unwrap(), + DesktopSettings { + silent_start: true, + show_dock_icon: true, + } + ); + let settings = DesktopSettings { + silent_start: true, + show_dock_icon: false, + }; + store.set_desktop_settings(settings).await.unwrap(); + + assert_eq!(store.desktop_settings().await.unwrap(), settings); + } + #[tokio::test] async fn proxy_settings_are_write_only_and_preserve_an_unchanged_password() { let store = Store::connect("sqlite::memory:").await.unwrap(); From 14d6a501a0799ef261a0768e04b03a37f192f980 Mon Sep 17 00:00:00 2001 From: leokun Date: Thu, 27 Aug 2026 11:30:54 +0800 Subject: [PATCH 05/10] chore: release v0.1.4 --- Cargo.lock | 2 +- apps/desktop/package-lock.json | 4 ++-- apps/desktop/package.json | 2 +- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 50f296b..80aad04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1128,7 +1128,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" [[package]] name = "cursor-byok-desktop" -version = "0.1.3" +version = "0.1.4" dependencies = [ "axum", "cursor-server", diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index af7962c..75daa20 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "cursor-byok-desktop", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cursor-byok-desktop", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.8.0", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2cc6a7d..f4658bc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "cursor-byok-desktop", - "version": "0.1.3", + "version": "0.1.4", "description": "Cursor BYOK desktop management application", "type": "module", "scripts": { diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 84ca450..4c2db08 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cursor-byok-desktop" -version = "0.1.3" +version = "0.1.4" edition = "2021" publish = false diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 9f04274..a4cb5a3 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Cursor BYOK", - "version": "0.1.3", + "version": "0.1.4", "identifier": "dev.cursorbyok.desktop", "build": { "beforeDevCommand": "npm run dev", From aa6820573536656d33dac59ed4c1ff99aa326013 Mon Sep 17 00:00:00 2001 From: leokun Date: Thu, 27 Aug 2026 15:54:08 +0800 Subject: [PATCH 06/10] fix: extend provider request timeout --- server/src/config.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/config.rs b/server/src/config.rs index ba82881..1d30aea 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -9,6 +9,7 @@ const DATA_DIR_NAME: &str = ".cursor-byok-v3"; const DATABASE_FILE_NAME: &str = "cursor-byok.db"; const V0049_DATA_DIR_NAME: &str = ".cursor-local-assistant-v2"; const V0049_CONFIG_FILE_NAME: &str = "config.yaml"; +const DEFAULT_PROVIDER_REQUEST_TIMEOUT: Duration = Duration::from_secs(3000); pub fn managed_data_dir() -> Result { let home_dir = dirs::home_dir() @@ -70,7 +71,7 @@ impl Config { Ok(value) => Duration::from_secs(value.parse().map_err(|error| { Error::Config(format!("invalid CURSOR_PROVIDER_TIMEOUT_SECONDS: {error}")) })?), - Err(env::VarError::NotPresent) => Duration::from_secs(300), + Err(env::VarError::NotPresent) => DEFAULT_PROVIDER_REQUEST_TIMEOUT, Err(error) => { return Err(Error::Config(format!( "invalid CURSOR_PROVIDER_TIMEOUT_SECONDS: {error}" @@ -111,7 +112,7 @@ impl Config { .parse() .expect("desktop listen address is static"), database_url: default_database_url()?, - provider_request_timeout: Duration::from_secs(300), + provider_request_timeout: DEFAULT_PROVIDER_REQUEST_TIMEOUT, console: None, use_persisted_ports: true, }) From ee915ee7607f435012a5962281cee1c61f873f6c Mon Sep 17 00:00:00 2001 From: leokun Date: Thu, 27 Aug 2026 17:01:03 +0800 Subject: [PATCH 07/10] fix: preempt root loop on context injection --- server/src/client/command.rs | 2 +- server/src/client/event.rs | 2 +- server/src/cursor/actor.rs | 4 + server/src/cursor/interaction/mod.rs | 13 + server/src/cursor/session.rs | 120 +++- server/src/cursor/tools/codec/response.rs | 26 + server/src/cursor/tools/mod.rs | 8 + server/src/cursor/tools/runtime.rs | 37 +- server/src/cursor/tools/schedule.rs | 5 + server/src/run/engine.rs | 96 +++- server/src/run/tool_round.rs | 132 ++++- server/tests/interrupt.rs | 661 +++++++++++++++++++++- 12 files changed, 1027 insertions(+), 79 deletions(-) diff --git a/server/src/client/command.rs b/server/src/client/command.rs index d29067c..3ae1be4 100644 --- a/server/src/client/command.rs +++ b/server/src/client/command.rs @@ -11,7 +11,7 @@ pub struct MessageInsertion { #[derive(Debug)] pub enum ClientCommand { ToolResult(ToolResult), - RuntimeMessage(CanonicalMessage), + InterruptWithMessage(CanonicalMessage), RuntimeEvent(RuntimeEvent), InsertMessages(MessageInsertion), ClientClosed { error: String }, diff --git a/server/src/client/event.rs b/server/src/client/event.rs index 85470f2..b84e653 100644 --- a/server/src/client/event.rs +++ b/server/src/client/event.rs @@ -9,7 +9,7 @@ use crate::run::RunOutcome; pub enum CommitCause { InitialMessages, ToolRoundStarted(ToolRoundId), - ToolResult { call_id: String }, + ToolResult { call_id: String, interrupted: bool }, FinalTurn, Compaction { summary: String }, RuntimeEvent { event_id: String }, diff --git a/server/src/cursor/actor.rs b/server/src/cursor/actor.rs index 85c158a..679cc81 100644 --- a/server/src/cursor/actor.rs +++ b/server/src/cursor/actor.rs @@ -285,6 +285,10 @@ impl CursorActor { { continue; } + if tool_runtime.is_interrupted(throw.id).await { + tool_runtime.discard_exec(throw.id).await; + continue; + } match tool_runtime.take_exec(throw.id).await { Some(pending) => results_tx.send_error( crate::Error::Protocol(format!( diff --git a/server/src/cursor/interaction/mod.rs b/server/src/cursor/interaction/mod.rs index b2e6d7d..7861eeb 100644 --- a/server/src/cursor/interaction/mod.rs +++ b/server/src/cursor/interaction/mod.rs @@ -152,6 +152,19 @@ pub fn context_injection_queued(injection_id: String) -> pb::AgentServerMessage )) } +pub fn context_injection_rejected(injection_id: String, reason: String) -> pb::AgentServerMessage { + server_interaction(pb::interaction_update::Message::ContextInjectionState( + pb::ContextInjectionStateUpdate { + injection_id, + state: Some(pb::ContextInjectionState { + state: Some(pb::context_injection_state::State::Rejected( + pb::ContextInjectionRejected { reason }, + )), + }), + }, + )) +} + pub fn context_injection_delivered( injection_id: String, delivery_batch_id: String, diff --git a/server/src/cursor/session.rs b/server/src/cursor/session.rs index 2f83275..2999f75 100644 --- a/server/src/cursor/session.rs +++ b/server/src/cursor/session.rs @@ -122,6 +122,9 @@ impl CursorSession { let mut response_text = String::new(); let mut response_thinking = String::new(); let mut active_round = None::; + let mut active_tool_calls = HashSet::::new(); + let mut interrupted_rounds = HashSet::::new(); + let mut interrupted_tool_calls = HashSet::::new(); let mut final_checkpoint = None::; let mut compaction_checkpoint = None::; let mut turn_usage = None::; @@ -130,13 +133,16 @@ impl CursorSession { let mut presentation = Presentation::default(); loop { - let input = if let Some(completion) = ready.pop_front() { + let input = if let Ok(action) = self.runtime_actions.try_recv() { + Input::RuntimeAction(Some(Box::new(action))) + } else if let Some(completion) = ready.pop_front() { Input::Completion(completion) } else { tokio::select! { + biased; + action = self.runtime_actions.recv() => Input::RuntimeAction(action.map(Box::new)), event = self.core.events.recv() => Input::Event(event), completion = self.results.recv() => Input::CompletionResult(completion), - action = self.runtime_actions.recv() => Input::RuntimeAction(action.map(Box::new)), failure = worker.failures.recv(), if checkpoint_worker_open => Input::CheckpointFailure(failure), } }; @@ -147,15 +153,16 @@ impl CursorSession { } Input::Completion(completion) => { if let Some(completion) = self - .forward_completion(completion, &mut completions) + .forward_completion(completion, &mut completions, &interrupted_tool_calls) .await? { ready.push_back(completion); } } Input::CompletionResult(Some(result)) => { - if let Some(completion) = - self.forward_completion(result?, &mut completions).await? + if let Some(completion) = self + .forward_completion(result?, &mut completions, &interrupted_tool_calls) + .await? { ready.push_back(completion); } @@ -164,7 +171,15 @@ impl CursorSession { return Err(Error::Protocol("tool result channel closed".into())); } Input::RuntimeAction(Some(action)) => { - self.forward_injection(*action).await?; + self.forward_injection( + *action, + active_round.as_ref(), + &active_tool_calls, + &completions, + &mut interrupted_rounds, + &mut interrupted_tool_calls, + ) + .await?; } Input::RuntimeAction(None) => { return Err(Error::Protocol("runtime action channel closed".into())); @@ -283,7 +298,23 @@ impl CursorSession { round_id, calls: round_calls, } => { - active_round = Some(round_id); + active_round = Some(round_id.clone()); + active_tool_calls = round_calls + .iter() + .map(|call| call.call_id.clone()) + .collect(); + // Runtime actions are deliberately prioritized over core events. An + // injection can therefore be observed before the already-queued + // ToolRoundStarted event reaches this session. In that case the + // accepted injection is still pending delivery and this round must be + // detached without starting any root tools. + if interrupted_rounds.contains(&round_id) + || !self.pending_injections.is_empty() + { + interrupted_rounds.insert(round_id.clone()); + interrupted_tool_calls.extend(active_tool_calls.iter().cloned()); + continue; + } for dispatched in self .tools .start_batch( @@ -348,12 +379,11 @@ impl CursorSession { active_round = Some(round_id.clone()); } let mut tool_round_settled = false; - if let CommitCause::ToolResult { call_id } = &state.cause { - let completion = completions.remove(call_id).ok_or_else(|| { - Error::Protocol(format!( - "core committed a tool result without typed Cursor state: {call_id}" - )) - })?; + if let CommitCause::ToolResult { + call_id, + interrupted, + } = &state.cause + { let snapshot = self .store .tool_round(active_round.as_ref().ok_or_else(|| { @@ -372,9 +402,16 @@ impl CursorSession { "committed call is absent from tool round: {call_id}" )) })?; - self.handle - .emit(&interaction::tool_completed(call, &completion))?; - presentation.tool_completed(&completion); + if !interrupted { + let completion = completions.remove(call_id).ok_or_else(|| { + Error::Protocol(format!( + "core committed a tool result without typed Cursor state: {call_id}" + )) + })?; + self.handle + .emit(&interaction::tool_completed(call, &completion))?; + presentation.tool_completed(&completion); + } completed.insert(call_id.clone()); tool_round_settled = snapshot.status == ToolRoundStatus::Settled; } @@ -490,7 +527,10 @@ impl CursorSession { return Err(error); } } - active_round = None; + if let Some(round_id) = active_round.take() { + interrupted_rounds.remove(&round_id); + } + active_tool_calls.clear(); self.tool_runtime.clear_completed().await; } else if !matches!(&state.cause, CommitCause::ToolResult { .. }) && active_round.is_some() @@ -603,7 +643,11 @@ impl CursorSession { &self, mut completion: ToolCompletion, completions: &mut HashMap, + interrupted_tool_calls: &HashSet, ) -> Result> { + if interrupted_tool_calls.contains(&completion.result().call_id) { + return Ok(None); + } if let Some(image) = completion.take_read_image() { let blob_id = self.store.put_blob(&image.data, &[]).await?; completion.persist_read_image(&blob_id, &image)?; @@ -635,19 +679,33 @@ impl CursorSession { Ok(dispatched.completion) } - async fn forward_injection(&mut self, action: pb::InjectContextAction) -> Result<()> { + async fn forward_injection( + &mut self, + action: pb::InjectContextAction, + active_round: Option<&ToolRoundId>, + active_tool_calls: &HashSet, + completions: &HashMap, + interrupted_rounds: &mut HashSet, + interrupted_tool_calls: &mut HashSet, + ) -> Result<()> { if action.injection_id.is_empty() { return Err(Error::Protocol( "InjectContextAction has no injection_id".into(), )); } + if self.injection_ids.contains(&action.injection_id) { + return Ok(()); + } if action.expected_run_id != self.context.request_id { - return Err(Error::Protocol(format!( + let reason = format!( "InjectContextAction expected run {}, active run is {}", action.expected_run_id, self.context.request_id - ))); - } - if self.injection_ids.contains(&action.injection_id) { + ); + self.handle.emit(&interaction::context_injection_rejected( + action.injection_id.clone(), + reason, + ))?; + self.injection_ids.insert(action.injection_id); return Ok(()); } let user_message = match action.payload.as_ref() { @@ -675,25 +733,31 @@ impl CursorSession { ); self.handle .emit(&interaction::context_injection_queued(injection_id.clone()))?; + interrupted_tool_calls.extend( + active_tool_calls + .iter() + .filter(|call_id| !completions.contains_key(*call_id)) + .cloned(), + ); + if let Some(round_id) = active_round { + interrupted_rounds.insert(round_id.clone()); + } + self.interrupt_execs().await; if self .core .commands - .send(ClientCommand::RuntimeMessage(message)) + .send(ClientCommand::InterruptWithMessage(message)) .await .is_err() { self.pending_injections.remove(&injection_id); return Err(Error::RunNotFound(self.context.request_id.clone())); } - self.interrupt_execs().await; Ok(()) } async fn interrupt_execs(&self) { - // Keep runtime entries until Cursor returns the aborted result. The core tool - // round needs that terminal result before it can append the injected context - // after the complete assistant/tool pair and continue the same Run. - for id in self.tool_runtime.running_exec_ids().await { + for id in self.tools.interrupt_for_message().await { let _ = self.handle.emit(&codec::abort(id)); } } diff --git a/server/src/cursor/tools/codec/response.rs b/server/src/cursor/tools/codec/response.rs index 93d36f6..e9d73b5 100644 --- a/server/src/cursor/tools/codec/response.rs +++ b/server/src/cursor/tools/codec/response.rs @@ -25,6 +25,12 @@ pub async fn client_event( message: &pb::ExecClientMessage, pending: &CursorToolRuntime, ) -> Result { + if pending.is_interrupted(message.id).await { + if message.message.as_ref().is_some_and(is_terminal) { + pending.discard_exec(message.id).await; + } + return Ok(ClientExecEvent::Pending); + } let call = match pending.exec_call(message.id).await { Some(call) => call, None if pending.completed_call(message.id).await.is_some() => { @@ -131,6 +137,10 @@ pub async fn client_event( } pub async fn stream_closed(id: u32, pending: &CursorToolRuntime) -> Result> { + if pending.is_interrupted(id).await { + pending.discard_exec(id).await; + return Ok(None); + } let Some(entry) = pending.take_exec(id).await else { return Ok(None); }; @@ -177,6 +187,22 @@ pub async fn stream_closed(id: u32, pending: &CursorToolRuntime) -> Result bool { + use pb::{exec_client_message::Message, shell_stream::Event}; + + match message { + Message::ShellStream(stream) => matches!( + stream.event.as_ref(), + Some(Event::Exit(_)) + | Some(Event::Backgrounded(_)) + | Some(Event::Rejected(_)) + | Some(Event::PermissionDenied(_)) + | Some(Event::SandboxUnsupported(_)) + ), + _ => true, + } +} + async fn advance_await( entry: PendingExec, result: &pb::exec_client_message::Message, diff --git a/server/src/cursor/tools/mod.rs b/server/src/cursor/tools/mod.rs index 2003766..251968f 100644 --- a/server/src/cursor/tools/mod.rs +++ b/server/src/cursor/tools/mod.rs @@ -155,6 +155,11 @@ impl ToolDispatcher { .map(Some) } + pub async fn interrupt_for_message(&self) -> Vec { + self.edit_schedule.lock().await.clear(); + self.runtime.interrupt_for_message().await + } + async fn start( &self, call: &ToolCall, @@ -193,6 +198,9 @@ impl ToolDispatcher { &self, response: &pb::InteractionResponse, ) -> Result { + if self.runtime.is_interrupted(response.id).await { + return Ok(ClientToolEvent::Pending); + } let pending = match self.runtime.take_interaction(response.id).await { Some(pending) => pending, None if self.runtime.completed_call(response.id).await.is_some() => { diff --git a/server/src/cursor/tools/runtime.rs b/server/src/cursor/tools/runtime.rs index 6e95a97..798d01f 100644 --- a/server/src/cursor/tools/runtime.rs +++ b/server/src/cursor/tools/runtime.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, sync::{ atomic::{AtomicU32, Ordering}, Arc, @@ -19,6 +19,7 @@ pub struct CursorToolRuntime { execs: Arc>>, interactions: Arc>>, completed: Arc>>, + interrupted: Arc>>, } pub(crate) struct PendingExec { @@ -311,6 +312,10 @@ impl CursorToolRuntime { self.completed.lock().await.get(&id).cloned() } + pub async fn is_interrupted(&self, id: u32) -> bool { + self.interrupted.lock().await.contains(&id) + } + pub async fn clear_completed(&self) { self.completed.lock().await.clear(); } @@ -329,9 +334,39 @@ impl CursorToolRuntime { ids.sort_unstable(); self.interactions.lock().await.clear(); self.completed.lock().await.clear(); + self.interrupted.lock().await.clear(); ids } + pub async fn interrupt_for_message(&self) -> Vec { + let (abort_ids, interrupted_ids) = { + let mut entries = self.execs.lock().await; + let mut abort_ids = Vec::new(); + let mut interrupted_ids = Vec::new(); + entries.retain(|id, entry| { + interrupted_ids.push(*id); + let keep_running = entry.call.name.eq_ignore_ascii_case("Task"); + if !keep_running { + abort_ids.push(*id); + } + keep_running + }); + (abort_ids, interrupted_ids) + }; + let interaction_ids = { + let mut interactions = self.interactions.lock().await; + let ids = interactions.keys().copied().collect::>(); + interactions.clear(); + ids + }; + let mut interrupted = self.interrupted.lock().await; + interrupted.extend(interrupted_ids); + interrupted.extend(interaction_ids); + let mut abort_ids = abort_ids; + abort_ids.sort_unstable(); + abort_ids + } + pub async fn running_exec_ids(&self) -> Vec { let mut ids = self.execs.lock().await.keys().copied().collect::>(); ids.sort_unstable(); diff --git a/server/src/cursor/tools/schedule.rs b/server/src/cursor/tools/schedule.rs index 08ec794..7ee95c0 100644 --- a/server/src/cursor/tools/schedule.rs +++ b/server/src/cursor/tools/schedule.rs @@ -23,6 +23,11 @@ pub(super) struct DeferredEdit { } impl EditSchedule { + pub fn clear(&mut self) { + self.paths.clear(); + self.active_paths.clear(); + } + pub fn start_or_defer(&mut self, path: String, edit: DeferredEdit) -> Option { if let Some(queue) = self.paths.get_mut(&path) { queue.waiting.push_back(edit); diff --git a/server/src/run/engine.rs b/server/src/run/engine.rs index 296309f..6a35cf4 100644 --- a/server/src/run/engine.rs +++ b/server/src/run/engine.rs @@ -249,14 +249,14 @@ impl RunEngine { let mut pending_insertions = Vec::new(); let cycle = loop { tokio::select! { - result = &mut cycle => break result, + biased; command = client.commands.recv() => { let message = match command { Some(ClientCommand::InsertMessages(insertion)) => { pending_insertions.push(insertion); continue; } - Some(ClientCommand::RuntimeMessage(message)) => message, + Some(ClientCommand::InterruptWithMessage(message)) => message, Some(ClientCommand::RuntimeEvent(event)) => event.into_message(), Some(ClientCommand::Cancel) => { cycle_cancellation.cancel(); @@ -321,7 +321,8 @@ impl RunEngine { Err(outcome) => return (outcome, usage), }; continue 'model; - } + }, + result = &mut cycle => break result, } }; let cycle = match cycle { @@ -558,23 +559,68 @@ impl RunEngine { let cycle_cancellation = cancellation.child_token(); let (silent_events, mut discarded_events) = tokio::sync::mpsc::channel(256); let drain = tokio::spawn(async move { while discarded_events.recv().await.is_some() {} }); - let cycle = consume_model_cycle( - self.provider.stream(invocation, cycle_cancellation.clone()), - &silent_events, - &cycle_cancellation, - ) - .await; + let mut pending_insertions = Vec::new(); + let mut interrupted_message = None; + let cycle = { + let cycle = consume_model_cycle( + self.provider.stream(invocation, cycle_cancellation.clone()), + &silent_events, + &cycle_cancellation, + ); + tokio::pin!(cycle); + loop { + tokio::select! { + biased; + command = client.commands.recv() => match command { + Some(ClientCommand::InsertMessages(insertion)) => { + pending_insertions.push(insertion); + } + Some(ClientCommand::InterruptWithMessage(message)) => { + cycle_cancellation.cancel(); + interrupted_message = Some(message); + break cycle.await; + } + Some(ClientCommand::RuntimeEvent(event)) => { + cycle_cancellation.cancel(); + interrupted_message = Some(event.into_message()); + break cycle.await; + } + Some(ClientCommand::Cancel) => { + cycle_cancellation.cancel(); + return Err(RunOutcome::Cancelled); + } + Some(ClientCommand::ClientClosed { error }) => { + cycle_cancellation.cancel(); + return Err(RunOutcome::Failed(RunFailure::Client(error))); + } + Some(ClientCommand::ToolResult(_)) => { + cycle_cancellation.cancel(); + return Err(RunOutcome::Failed(RunFailure::Protocol( + "received a tool result while automatic compaction was running".into(), + ))); + } + None => { + cycle_cancellation.cancel(); + return Err(client_failure()); + } + }, + result = &mut cycle => break result, + } + } + }; drop(silent_events); let _ = drain.await; - let (summary, compaction_usage) = match cycle { - Ok(cycle) if cycle.calls.is_empty() && !cycle.text.trim().is_empty() => { + let (summary, compaction_usage) = match (interrupted_message.is_some(), cycle) { + (true, Ok(cycle)) => (fallback_summary(&compactable), cycle.usage), + (true, Err(failure)) => (fallback_summary(&compactable), failure.usage), + (false, Ok(cycle)) if cycle.calls.is_empty() && !cycle.text.trim().is_empty() => { (cycle.text.trim().to_string(), cycle.usage) } - Ok(cycle) => { + (false, Ok(cycle)) => { tracing::warn!("automatic compaction returned no usable summary; using fallback"); (fallback_summary(&compactable), cycle.usage) } - Err(failure) => { + (false, Err(failure)) => { tracing::warn!(error = ?failure.failure, "automatic compaction model failed; using fallback"); (fallback_summary(&compactable), failure.usage) } @@ -594,7 +640,7 @@ impl RunEngine { let mut replacement = retained_request_context.into_iter().collect::>(); replacement.push(summary_message); replacement.extend(prepared.initial_messages.iter().cloned()); - let revision = self + let mut revision = self .store .replace_revision( &prepared.conversation_id, @@ -620,6 +666,28 @@ impl RunEngine { emit(client, ClientEvent::AutoCompactionCompleted) .await .map_err(|_| client_failure())?; + revision = append_insertions( + &self.store, + prepared, + client, + cancellation, + revision, + pending_insertions, + ) + .await? + .0; + if let Some(message) = interrupted_message { + revision = append_runtime_message( + &self.store, + prepared, + client, + cancellation, + revision, + message, + ) + .await? + .0; + } Ok((revision, compaction_usage)) } } diff --git a/server/src/run/tool_round.rs b/server/src/run/tool_round.rs index 0ae1cf9..a48a1a8 100644 --- a/server/src/run/tool_round.rs +++ b/server/src/run/tool_round.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use tokio_util::sync::CancellationToken; use crate::{ @@ -5,7 +7,7 @@ use crate::{ ClientCommand, ClientEvent, ClientPort, CommitBarrier, CommitCause, MessageInsertion, StateCommitted, }, - model::{PreparedRun, RevisionId, ToolCall, ToolRoundAssistant, ToolRoundId}, + model::{PreparedRun, RevisionId, ToolCall, ToolResult, ToolRoundAssistant, ToolRoundId}, store::Store, }; @@ -70,6 +72,7 @@ pub(super) async fn execute( .await?; let mut remaining = calls.len(); + let mut completed_call_ids = HashSet::new(); let mut pending_runtime_messages = insertions .into_iter() .map(PendingRuntimeMessage::Insertion) @@ -92,6 +95,7 @@ pub(super) async fn execute( .await .map_err(failed)?; revision = committed.revision_id; + completed_call_ids.insert(call_id.clone()); tracing::info!( round_id = %round_id, call_id, @@ -113,7 +117,10 @@ pub(super) async fn execute( ClientEvent::StateCommitted(StateCommitted { revision_id: revision, tool_round_version: committed.tool_round_version, - cause: CommitCause::ToolResult { call_id }, + cause: CommitCause::ToolResult { + call_id, + interrupted: false, + }, barrier, }), ) @@ -125,8 +132,66 @@ pub(super) async fn execute( Some(ClientCommand::RuntimeEvent(event)) => { pending_runtime_messages.push(PendingRuntimeMessage::Message(event.into_message())); } - Some(ClientCommand::RuntimeMessage(message)) => { - pending_runtime_messages.push(PendingRuntimeMessage::Message(message)); + Some(ClientCommand::InterruptWithMessage(message)) => { + for call in calls + .iter() + .filter(|call| !completed_call_ids.contains(&call.call_id)) + { + let result = ToolResult { + call_id: call.call_id.clone(), + content: "Tool execution was interrupted by a newer user message.".into(), + is_error: true, + image: None, + }; + let committed = store + .commit_tool_result( + &prepared.conversation_id, + &prepared.run_id, + &round_id, + &result, + ) + .await + .map_err(failed)?; + revision = committed.revision_id; + let (barrier, ready) = if committed.settled { + let (barrier, ready) = CommitBarrier::before_continue(); + (barrier, Some(ready)) + } else { + (CommitBarrier::None, None) + }; + send( + client, + ClientEvent::StateCommitted(StateCommitted { + revision_id: revision, + tool_round_version: committed.tool_round_version, + cause: CommitCause::ToolResult { + call_id: call.call_id.clone(), + interrupted: true, + }, + barrier, + }), + ) + .await?; + if let Some(ready) = ready { + super::engine::wait_for_state_ready(ready, cancellation).await?; + } + } + for pending in pending_runtime_messages { + revision = + append_pending(store, prepared, client, cancellation, revision, pending) + .await?; + } + revision = super::engine::append_runtime_message( + store, + prepared, + client, + cancellation, + revision, + message, + ) + .await? + .0; + return Ok(revision); } Some(ClientCommand::InsertMessages(insertion)) => { pending_runtime_messages.push(PendingRuntimeMessage::Insertion(insertion)) @@ -139,32 +204,7 @@ pub(super) async fn execute( } } for pending in pending_runtime_messages { - match pending { - PendingRuntimeMessage::Message(message) => { - revision = super::engine::append_runtime_message( - store, - prepared, - client, - cancellation, - revision, - message, - ) - .await? - .0; - } - PendingRuntimeMessage::Insertion(insertion) => { - revision = super::engine::append_insertions( - store, - prepared, - client, - cancellation, - revision, - vec![insertion], - ) - .await? - .0; - } - } + revision = append_pending(store, prepared, client, cancellation, revision, pending).await?; } Ok(revision) } @@ -174,6 +214,38 @@ enum PendingRuntimeMessage { Insertion(MessageInsertion), } +async fn append_pending( + store: &Store, + prepared: &PreparedRun, + client: &mut ClientPort, + cancellation: &CancellationToken, + revision: RevisionId, + pending: PendingRuntimeMessage, +) -> std::result::Result { + match pending { + PendingRuntimeMessage::Message(message) => Ok(super::engine::append_runtime_message( + store, + prepared, + client, + cancellation, + revision, + message, + ) + .await? + .0), + PendingRuntimeMessage::Insertion(insertion) => Ok(super::engine::append_insertions( + store, + prepared, + client, + cancellation, + revision, + vec![insertion], + ) + .await? + .0), + } +} + async fn send(client: &ClientPort, event: ClientEvent) -> std::result::Result<(), RunOutcome> { client .events diff --git a/server/tests/interrupt.rs b/server/tests/interrupt.rs index e214bbe..2e1a6b6 100644 --- a/server/tests/interrupt.rs +++ b/server/tests/interrupt.rs @@ -5,11 +5,15 @@ mod fixtures; use std::sync::Arc; +use bytes::Bytes; use cursor_server::{ cursor::prompting::{PromptAssets, PromptCompiler}, cursor::{connect, proto::agent::v1 as pb}, cursor::{CursorCommand, CursorSessionRegistry}, - model::{ConversationId, ModelSpec, PreparedRun, PromptSpec, RunAction, RunId, RunKind}, + model::{ + ConversationId, ModelConfigInput, ModelSpec, ModelType, PreparedRun, PromptSpec, RunAction, + RunId, RunKind, Usage, OPENAI_CHAT_ENDPOINT, + }, provider::{FinishReason, ModelEvent}, run::RunRegistry, store::RunStatus, @@ -428,6 +432,472 @@ async fn injected_user_context_restarts_only_the_active_model_cycle() { ); } +#[tokio::test] +async fn injected_user_context_aborts_pending_tools_and_ignores_late_results() { + let (_directory, store) = fixtures::temp_store().await; + let provider = fake_provider::FakeProvider::default(); + provider.push(tool_response("call-1", "Read", "{\"path\":\"/tmp/a\"}")); + let release = provider.push_gated(text_response("continued after tool interruption")); + let assets = PromptAssets::load( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("prompt/cursor") + .as_path(), + ) + .unwrap(); + let registry = CursorSessionRegistry::new( + store, + Arc::new(provider.clone()), + PromptCompiler::new(assets), + Default::default(), + ); + let handle = registry + .get_or_create("interrupt-tool-request") + .await + .unwrap(); + let mut output = handle.subscribe(); + handle + .command(CursorCommand::Append { + seqno: 0, + message: Box::new(client_run_for( + "interrupt-tool-request", + "interrupt-tool-conversation", + )), + }) + .await + .unwrap(); + + let mut append_seqno = 1; + let exec_id = wait_for_exec(&handle, &mut output, &mut append_seqno, "Read").await; + handle + .command(CursorCommand::Append { + seqno: append_seqno, + message: Box::new(runtime_injection_for( + "tool-injection", + "interrupt-tool-request", + )), + }) + .await + .unwrap(); + append_seqno += 1; + + let mut saw_abort = false; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + while provider.requests().len() < 2 || !saw_abort { + assert!( + tokio::time::Instant::now() < deadline, + "root model did not restart after tool interruption" + ); + if let Ok(Some(frame)) = + tokio::time::timeout(std::time::Duration::from_millis(20), output.recv()).await + { + let (_, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap(); + let server = pb::AgentServerMessage::decode(payload).unwrap(); + if let Some(pb::agent_server_message::Message::ExecServerControlMessage(control)) = + server.message + { + if let Some(pb::exec_server_control_message::Message::Abort(abort)) = + control.message + { + assert_eq!(abort.id, exec_id); + saw_abort = true; + } + } + acknowledge_kv(&handle, &mut append_seqno, &frame).await; + } + } + + handle + .command(CursorCommand::Append { + seqno: append_seqno, + message: Box::new(read_success(exec_id)), + }) + .await + .unwrap(); + append_seqno += 1; + release.notify_one(); + + drain_successfully(&handle, &mut output, &mut append_seqno).await; + + let requests = provider.requests(); + assert_eq!( + requests[0].history, + requests[1].history[..requests[0].history.len()] + ); + let history = serde_json::to_string(&requests[1].history).unwrap(); + let interrupted = history + .find("Tool execution was interrupted by a newer user message.") + .expect("interrupted tool result missing from provider history"); + let injected = history + .find("injected follow-up") + .expect("injected message missing from provider history"); + assert!(interrupted < injected); +} + +#[tokio::test] +async fn injected_user_context_detaches_subagents_without_cancelling_them() { + let (_directory, store) = fixtures::temp_store().await; + let provider = fake_provider::FakeProvider::default(); + provider.push(tool_response( + "task-call", + "Task", + &serde_json::json!({ + "description": "Inspect protocol", + "prompt": "Inspect the protocol", + "subagent_type": "generalPurpose", + "run_in_background": false + }) + .to_string(), + )); + let release = provider.push_gated(text_response("continued while subagent runs")); + let assets = PromptAssets::load( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("prompt/cursor") + .as_path(), + ) + .unwrap(); + let registry = CursorSessionRegistry::new( + store, + Arc::new(provider.clone()), + PromptCompiler::new(assets), + Default::default(), + ); + let handle = registry + .get_or_create("detach-subagent-request") + .await + .unwrap(); + let mut output = handle.subscribe(); + handle + .command(CursorCommand::Append { + seqno: 0, + message: Box::new(client_run_for( + "detach-subagent-request", + "detach-subagent-conversation", + )), + }) + .await + .unwrap(); + + let mut append_seqno = 1; + let exec_id = wait_for_exec(&handle, &mut output, &mut append_seqno, "Task").await; + handle + .command(CursorCommand::Append { + seqno: append_seqno, + message: Box::new(runtime_injection_for( + "subagent-injection", + "detach-subagent-request", + )), + }) + .await + .unwrap(); + append_seqno += 1; + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + while provider.requests().len() < 2 { + assert!( + tokio::time::Instant::now() < deadline, + "root model did not restart while subagent remained active" + ); + if let Ok(Some(frame)) = + tokio::time::timeout(std::time::Duration::from_millis(20), output.recv()).await + { + let (_, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap(); + let server = pb::AgentServerMessage::decode(payload).unwrap(); + if let Some(pb::agent_server_message::Message::ExecServerControlMessage(control)) = + server.message + { + if let Some(pb::exec_server_control_message::Message::Abort(abort)) = + control.message + { + assert_ne!(abort.id, exec_id, "Task must not be aborted by injection"); + } + } + acknowledge_kv(&handle, &mut append_seqno, &frame).await; + } + } + + handle + .command(CursorCommand::Append { + seqno: append_seqno, + message: Box::new(subagent_success(exec_id)), + }) + .await + .unwrap(); + append_seqno += 1; + release.notify_one(); + + drain_successfully(&handle, &mut output, &mut append_seqno).await; + + let history = serde_json::to_string(&provider.requests()[1].history).unwrap(); + assert!(history.contains("Tool execution was interrupted by a newer user message.")); + assert!(history.contains("injected follow-up")); +} + +#[tokio::test] +async fn injected_user_context_interrupts_automatic_compaction() { + let (_directory, store) = fixtures::temp_store().await; + let model = store + .create_model(&ModelConfigInput { + sort_order: 0, + display_name: "Test Model".into(), + model_type: ModelType::OpenAi, + base_url: "https://example.com/v1/chat/completions".into(), + use_full_url: true, + api_key: "test-key".into(), + tooltip_data: "Test Model".into(), + model_id: "test-model".into(), + reasoning_effort: None, + openai_endpoint: OPENAI_CHAT_ENDPOINT.into(), + openai_extra_params_enabled: false, + openai_extra_params: serde_json::json!({}), + custom_headers_enabled: false, + custom_headers: serde_json::json!({}), + anthropic_extra_params_enabled: false, + anthropic_extra_params: serde_json::json!({}), + context_window_tokens: Some(10_001), + max_completion_tokens: None, + anthropic_max_tokens: None, + anthropic_thinking_effort: None, + thinking_budget_tokens: None, + }) + .await + .unwrap(); + let provider = fake_provider::FakeProvider::default(); + provider.push(text_response("seed answer")); + provider.push_pending(); + provider.push(text_response("continued after compacting injection")); + let assets = PromptAssets::load( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("prompt/cursor") + .as_path(), + ) + .unwrap(); + let registry = CursorSessionRegistry::new( + store, + Arc::new(provider.clone()), + PromptCompiler::new(assets), + Default::default(), + ); + + let seed_state = run_to_end( + ®istry, + "seed-request", + client_run_for_model( + "seed-request", + "compaction-injection-conversation", + &model.model_hash, + ), + ) + .await; + + let handle = registry + .get_or_create("inject-during-compaction") + .await + .unwrap(); + let mut output = handle.subscribe(); + let mut compacting_request = client_run_for_model_with_state( + "inject-during-compaction", + "compaction-injection-conversation", + &model.model_hash, + Some(seed_state), + ); + let Some(pb::agent_client_message::Message::RunRequest(request)) = + compacting_request.message.as_mut() + else { + panic!("expected RunRequest") + }; + request.requested_model.as_mut().unwrap().parameters.push( + pb::requested_model::ModelParameterValue { + id: "context".into(), + value: "10001".into(), + }, + ); + handle + .command(CursorCommand::Append { + seqno: 0, + message: Box::new(compacting_request), + }) + .await + .unwrap(); + + let mut append_seqno = 1; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + while provider.requests().len() < 2 { + assert!( + tokio::time::Instant::now() < deadline, + "automatic compaction did not start" + ); + if let Ok(Some(frame)) = + tokio::time::timeout(std::time::Duration::from_millis(20), output.recv()).await + { + acknowledge_kv(&handle, &mut append_seqno, &frame).await; + } + } + handle + .command(CursorCommand::Append { + seqno: append_seqno, + message: Box::new(runtime_injection_for( + "compaction-injection", + "inject-during-compaction", + )), + }) + .await + .unwrap(); + append_seqno += 1; + + let mut saw_continued = false; + loop { + let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv()) + .await + .unwrap() + .expect("RunSSE closed before successful EndStream"); + let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap(); + if flags & connect::END_STREAM_FLAG != 0 { + assert_eq!(payload.as_ref(), b"{}"); + break; + } + let server = pb::AgentServerMessage::decode(payload).unwrap(); + if let Some(pb::agent_server_message::Message::InteractionUpdate(update)) = server.message { + if let Some(pb::interaction_update::Message::TextDelta(delta)) = update.message { + saw_continued |= delta.text.contains("continued after compacting injection"); + } + } + acknowledge_kv(&handle, &mut append_seqno, &frame).await; + } + + let requests = provider.requests(); + assert_eq!(requests.len(), 3); + assert!(requests[1] + .prompt + .instructions + .starts_with("Summarize the conversation for the next model turn.")); + assert!(!serde_json::to_string(&requests[1].history) + .unwrap() + .contains("injected follow-up")); + assert!(serde_json::to_string(&requests[2].history) + .unwrap() + .contains("injected follow-up")); + assert!(saw_continued); +} + +#[tokio::test] +async fn stale_context_injection_is_rejected_without_failing_the_active_run() { + let (_directory, store) = fixtures::temp_store().await; + let provider = fake_provider::FakeProvider::default(); + let release = provider.push_gated(vec![ + ModelEvent::Start { + model_call_id: "active-cycle".into(), + }, + ModelEvent::TextStart, + ModelEvent::TextDelta("active run completed".into()), + ModelEvent::TextEnd, + ModelEvent::Done(FinishReason::Stop), + ]); + let assets = PromptAssets::load( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("prompt/cursor") + .as_path(), + ) + .unwrap(); + let registry = CursorSessionRegistry::new( + store, + Arc::new(provider.clone()), + PromptCompiler::new(assets), + Default::default(), + ); + let handle = registry.get_or_create("active-request").await.unwrap(); + let mut output = handle.subscribe(); + handle + .command(CursorCommand::Append { + seqno: 0, + message: Box::new(client_run_for( + "active-request", + "stale-injection-conversation", + )), + }) + .await + .unwrap(); + + let mut append_seqno = 1; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + while provider.requests().is_empty() { + assert!( + tokio::time::Instant::now() < deadline, + "provider did not start" + ); + if let Ok(Some(frame)) = + tokio::time::timeout(std::time::Duration::from_millis(20), output.recv()).await + { + acknowledge_kv(&handle, &mut append_seqno, &frame).await; + } + } + handle + .command(CursorCommand::Append { + seqno: append_seqno, + message: Box::new(runtime_injection_for("stale-injection", "replaced-request")), + }) + .await + .unwrap(); + append_seqno += 1; + handle + .command(CursorCommand::Append { + seqno: append_seqno, + message: Box::new(runtime_injection_for("stale-injection", "replaced-request")), + }) + .await + .unwrap(); + append_seqno += 1; + + let mut rejection_count = 0; + let mut released = false; + loop { + let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv()) + .await + .unwrap() + .expect("RunSSE closed before successful EndStream"); + let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap(); + if flags & connect::END_STREAM_FLAG != 0 { + assert_eq!(payload.as_ref(), b"{}"); + break; + } + let server = pb::AgentServerMessage::decode(payload).unwrap(); + let rejected = match server.message { + Some(pb::agent_server_message::Message::InteractionUpdate(pb::InteractionUpdate { + message: + Some(pb::interaction_update::Message::ContextInjectionState( + pb::ContextInjectionStateUpdate { + injection_id, + state: + Some(pb::ContextInjectionState { + state: + Some(pb::context_injection_state::State::Rejected(rejected)), + }), + }, + )), + .. + })) if injection_id == "stale-injection" => { + assert_eq!( + rejected.reason, + "InjectContextAction expected run replaced-request, active run is active-request" + ); + true + } + _ => false, + }; + acknowledge_kv(&handle, &mut append_seqno, &frame).await; + if rejected { + rejection_count += 1; + if !released { + released = true; + release.notify_one(); + } + } + } + + assert!(released, "stale injection was not rejected"); + assert_eq!(rejection_count, 1); + assert_eq!(provider.requests().len(), 1); +} + #[tokio::test] async fn cancel_subagent_action_aborts_the_target_task_and_keeps_the_parent_running() { let (_directory, store) = fixtures::temp_store().await; @@ -614,6 +1084,23 @@ fn client_run() -> pb::AgentClientMessage { } fn client_run_for(request_id: &str, conversation_id: &str) -> pb::AgentClientMessage { + client_run_for_model(request_id, conversation_id, "test-model") +} + +fn client_run_for_model( + request_id: &str, + conversation_id: &str, + model_id: &str, +) -> pb::AgentClientMessage { + client_run_for_model_with_state(request_id, conversation_id, model_id, None) +} + +fn client_run_for_model_with_state( + request_id: &str, + conversation_id: &str, + model_id: &str, + state: Option, +) -> pb::AgentClientMessage { pb::AgentClientMessage { message: Some(pb::agent_client_message::Message::RunRequest( pb::AgentRunRequest { @@ -634,15 +1121,177 @@ fn client_run_for(request_id: &str, conversation_id: &str) -> pb::AgentClientMes conversation_id: Some(conversation_id.into()), run_id: Some(request_id.into()), requested_model: Some(pb::RequestedModel { - model_id: "test-model".into(), + model_id: model_id.into(), ..Default::default() }), + conversation_state: state, ..Default::default() }, )), } } +fn text_response(text: &str) -> Vec { + vec![ + ModelEvent::Start { + model_call_id: format!("call-{text}"), + }, + ModelEvent::TextStart, + ModelEvent::TextDelta(text.into()), + ModelEvent::TextEnd, + ModelEvent::Usage(Usage { + input_tokens: Some(1), + output_tokens: Some(1), + total_tokens: Some(2), + ..Default::default() + }), + ModelEvent::Done(FinishReason::Stop), + ] +} + +fn tool_response(call_id: &str, name: &str, arguments: &str) -> Vec { + vec![ + ModelEvent::Start { + model_call_id: format!("call-{call_id}"), + }, + ModelEvent::ToolCallStart { + index: 0, + call_id: call_id.into(), + name: name.into(), + }, + ModelEvent::ToolCallArgumentsDelta { + index: 0, + delta: arguments.into(), + }, + ModelEvent::ToolCallEnd { index: 0 }, + ModelEvent::Done(FinishReason::ToolUse), + ] +} + +async fn wait_for_exec( + handle: &cursor_server::cursor::CursorSessionHandle, + output: &mut tokio::sync::mpsc::UnboundedReceiver, + append_seqno: &mut i64, + tool: &str, +) -> u32 { + loop { + let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv()) + .await + .unwrap() + .expect("RunSSE closed before Exec"); + let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap(); + assert_eq!(flags & connect::END_STREAM_FLAG, 0); + let server = pb::AgentServerMessage::decode(payload).unwrap(); + if let Some(pb::agent_server_message::Message::ExecServerMessage(exec)) = server.message { + let matches = match exec.message.as_ref() { + Some(pb::exec_server_message::Message::ReadArgs(_)) => tool == "Read", + Some(pb::exec_server_message::Message::SubagentArgs(_)) => tool == "Task", + _ => false, + }; + if matches { + return exec.id; + } + } + acknowledge_kv(handle, append_seqno, &frame).await; + } +} + +async fn drain_successfully( + handle: &cursor_server::cursor::CursorSessionHandle, + output: &mut tokio::sync::mpsc::UnboundedReceiver, + append_seqno: &mut i64, +) { + loop { + let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv()) + .await + .unwrap() + .expect("RunSSE closed before successful EndStream"); + let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap(); + if flags & connect::END_STREAM_FLAG != 0 { + assert_eq!(payload.as_ref(), b"{}"); + return; + } + acknowledge_kv(handle, append_seqno, &frame).await; + } +} + +fn read_success(id: u32) -> pb::AgentClientMessage { + pb::AgentClientMessage { + message: Some(pb::agent_client_message::Message::ExecClientMessage( + pb::ExecClientMessage { + id, + message: Some(pb::exec_client_message::Message::ReadResult( + pb::ReadResult { + result: Some(pb::read_result::Result::Success(pb::ReadSuccess { + path: "/tmp/a".into(), + total_lines: 1, + file_size: 1, + output: Some(pb::read_success::Output::Content("late".into())), + ..Default::default() + })), + }, + )), + ..Default::default() + }, + )), + } +} + +fn subagent_success(id: u32) -> pb::AgentClientMessage { + pb::AgentClientMessage { + message: Some(pb::agent_client_message::Message::ExecClientMessage( + pb::ExecClientMessage { + id, + message: Some(pb::exec_client_message::Message::SubagentResult( + pb::SubagentResult { + result: Some(pb::subagent_result::Result::Success(pb::SubagentSuccess { + agent_id: "detached-child".into(), + ..Default::default() + })), + }, + )), + ..Default::default() + }, + )), + } +} + +async fn run_to_end( + registry: &CursorSessionRegistry, + request_id: &str, + request: pb::AgentClientMessage, +) -> pb::ConversationStateStructure { + let handle = registry.get_or_create(request_id).await.unwrap(); + let mut output = handle.subscribe(); + handle + .command(CursorCommand::Append { + seqno: 0, + message: Box::new(request), + }) + .await + .unwrap(); + let mut append_seqno = 1; + let mut state = None; + loop { + let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv()) + .await + .unwrap() + .expect("RunSSE closed before EndStream"); + let (flags, _) = connect::decode_frames(&frame).unwrap().pop().unwrap(); + if flags & connect::END_STREAM_FLAG != 0 { + return state.expect("Run ended without a checkpoint"); + } + let (_, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap(); + let server = pb::AgentServerMessage::decode(payload).unwrap(); + if let Some(pb::agent_server_message::Message::ConversationCheckpointUpdate(update)) = + server.message + { + state = Some(update); + } + acknowledge_kv(&handle, &mut append_seqno, &frame).await; + } +} + async fn acknowledge_kv( handle: &cursor_server::cursor::CursorSessionHandle, append_seqno: &mut i64, @@ -700,13 +1349,17 @@ fn runtime_user_message() -> pb::AgentClientMessage { } fn runtime_injection() -> pb::AgentClientMessage { + runtime_injection_for("injection-1", "inject-request") +} + +fn runtime_injection_for(injection_id: &str, expected_run_id: &str) -> pb::AgentClientMessage { pb::AgentClientMessage { message: Some(pb::agent_client_message::Message::ConversationAction( pb::ConversationAction { action: Some(pb::conversation_action::Action::InjectContextAction( pb::InjectContextAction { - injection_id: "injection-1".into(), - expected_run_id: "inject-request".into(), + injection_id: injection_id.into(), + expected_run_id: expected_run_id.into(), payload: Some(pb::inject_context_action::Payload::UserContext( pb::UserContextInjection { user_message: Some(pb::UserMessage { From 02a190489a33bc87874ee4694fd7d68081f4101c Mon Sep 17 00:00:00 2001 From: Jilin Chen <162280666+Weizhena@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:23:58 +0800 Subject: [PATCH 08/10] fix: accept reasoning field in OpenAI Chat streams --- server/src/provider/openai_chat.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/provider/openai_chat.rs b/server/src/provider/openai_chat.rs index bea7cb2..bdbd16b 100644 --- a/server/src/provider/openai_chat.rs +++ b/server/src/provider/openai_chat.rs @@ -132,7 +132,7 @@ impl Provider for OpenAiChatProvider { } let Some(choice) = value.get("choices").and_then(Value::as_array).and_then(|values| values.first()) else { continue; }; let delta = choice.get("delta").unwrap_or(&Value::Null); - if let Some(reasoning_delta) = delta.get("reasoning_content").and_then(Value::as_str).filter(|text| !text.is_empty()) { + if let Some(reasoning_delta) = delta.get("reasoning_content").or_else(|| delta.get("reasoning")).and_then(Value::as_str).filter(|text| !text.is_empty()) { if !thinking_open { thinking_open = true; yield ModelEvent::ThinkingStart; } reasoning.push_str(reasoning_delta); yield ModelEvent::ThinkingDelta(reasoning_delta.into()); From c5d578c5b1e526597e41e90c3711adf6437b7be4 Mon Sep 17 00:00:00 2001 From: leokun Date: Thu, 27 Aug 2026 21:45:28 +0800 Subject: [PATCH 09/10] feat(docs): add documentation site and demo features - Introduced a new documentation site for Cursor BYOK using Next.js and Fumadocs. - Added a product demo page with a corresponding Vite configuration. - Implemented a demo API to simulate LLM calls and responses. - Enhanced the Makefile to include new build and development commands for the documentation. - Updated package.json scripts for building and running the documentation site. - Created various components and layouts for the documentation structure, including blog and user documentation sections. - Added styling for the new components and layouts to ensure a cohesive design. - Included a README and other necessary files for local development and deployment. --- Makefile | 9 +- apps/desktop/demo/index.html | 23 + apps/desktop/package.json | 1 + apps/desktop/src/demo/api.ts | 287 + apps/desktop/src/demo/index.tsx | 8 + apps/desktop/src/layouts/AppHeader.tsx | 3 + .../src/layouts/MacTrafficLights.module.scss | 30 + apps/desktop/src/layouts/MacTrafficLights.tsx | 9 + apps/desktop/src/native/platform.ts | 5 + apps/desktop/tsconfig.node.json | 2 +- apps/desktop/vite.demo.config.ts | 22 + apps/docs/.gitignore | 29 + apps/docs/AGENTS.md | 9 + apps/docs/CLAUDE.md | 1 + apps/docs/README.md | 48 + apps/docs/app/(home)/blog/[slug]/page.tsx | 61 + apps/docs/app/(home)/blog/page.tsx | 44 + apps/docs/app/(home)/layout.tsx | 6 + apps/docs/app/(home)/page.tsx | 135 + apps/docs/app/api/search/route.ts | 4 + apps/docs/app/docs/[[...slug]]/page.tsx | 63 + apps/docs/app/docs/layout.tsx | 11 + apps/docs/app/global.css | 16 + apps/docs/app/layout.tsx | 26 + apps/docs/app/llms-full.txt/route.ts | 10 + .../app/llms.mdx/docs/[[...slug]]/route.ts | 23 + apps/docs/app/llms.txt/route.ts | 8 + apps/docs/app/og/docs/[...slug]/route.tsx | 28 + .../components/hero/DesktopDemo.module.css | 45 + apps/docs/components/hero/DesktopDemo.tsx | 18 + apps/docs/components/mdx.tsx | 18 + .../content/blog/2026-08-27-documentation.mdx | 37 + apps/docs/content/docs/index.mdx | 64 + apps/docs/content/docs/installation.mdx | 68 + apps/docs/content/docs/meta.json | 5 + .../docs/content/docs/model-configuration.mdx | 67 + apps/docs/content/docs/troubleshooting.mdx | 62 + apps/docs/eslint.config.mjs | 16 + apps/docs/lib/blog.ts | 35 + apps/docs/lib/cn.ts | 1 + apps/docs/lib/layout.shared.tsx | 31 + apps/docs/lib/shared.ts | 13 + apps/docs/lib/source.ts | 51 + apps/docs/next.config.mjs | 10 + apps/docs/package-lock.json | 10518 ++++++++++++++++ apps/docs/package.json | 39 + apps/docs/postcss.config.mjs | 7 + apps/docs/proxy.ts | 32 + apps/docs/scripts/build-product-demo.mjs | 18 + apps/docs/tsconfig.json | 34 + server/prompt/cursor/modes/agent.json | 2 +- server/prompt/cursor/modes/subagent.json | 2 +- server/prompt/cursor/tools.json | 31 +- server/src/cursor/interaction/render.rs | 12 - server/src/cursor/projection/encode.rs | 1 - server/src/cursor/request/context.rs | 15 +- server/src/cursor/tools/codec/mod.rs | 4 +- server/src/cursor/tools/codec/request.rs | 26 - server/src/cursor/tools/codec/response.rs | 78 +- .../src/cursor/tools/dispatch/await_shell.rs | 54 - server/src/cursor/tools/dispatch/mod.rs | 2 - server/src/cursor/tools/result/await_shell.rs | 145 - server/src/cursor/tools/result/gate.rs | 867 +- server/src/cursor/tools/result/mod.rs | 9 +- server/src/cursor/tools/runtime.rs | 63 - server/src/model/configuration.rs | 78 + server/src/model/mod.rs | 2 + server/src/model/tool_result_replay.rs | 226 + server/tests/prefix_stability.rs | 56 +- server/tests/tool_loop.rs | 83 - 70 files changed, 13299 insertions(+), 567 deletions(-) create mode 100644 apps/desktop/demo/index.html create mode 100644 apps/desktop/src/demo/api.ts create mode 100644 apps/desktop/src/demo/index.tsx create mode 100644 apps/desktop/src/layouts/MacTrafficLights.module.scss create mode 100644 apps/desktop/src/layouts/MacTrafficLights.tsx create mode 100644 apps/desktop/vite.demo.config.ts create mode 100644 apps/docs/.gitignore create mode 100644 apps/docs/AGENTS.md create mode 100644 apps/docs/CLAUDE.md create mode 100644 apps/docs/README.md create mode 100644 apps/docs/app/(home)/blog/[slug]/page.tsx create mode 100644 apps/docs/app/(home)/blog/page.tsx create mode 100644 apps/docs/app/(home)/layout.tsx create mode 100644 apps/docs/app/(home)/page.tsx create mode 100644 apps/docs/app/api/search/route.ts create mode 100644 apps/docs/app/docs/[[...slug]]/page.tsx create mode 100644 apps/docs/app/docs/layout.tsx create mode 100644 apps/docs/app/global.css create mode 100644 apps/docs/app/layout.tsx create mode 100644 apps/docs/app/llms-full.txt/route.ts create mode 100644 apps/docs/app/llms.mdx/docs/[[...slug]]/route.ts create mode 100644 apps/docs/app/llms.txt/route.ts create mode 100644 apps/docs/app/og/docs/[...slug]/route.tsx create mode 100644 apps/docs/components/hero/DesktopDemo.module.css create mode 100644 apps/docs/components/hero/DesktopDemo.tsx create mode 100644 apps/docs/components/mdx.tsx create mode 100644 apps/docs/content/blog/2026-08-27-documentation.mdx create mode 100644 apps/docs/content/docs/index.mdx create mode 100644 apps/docs/content/docs/installation.mdx create mode 100644 apps/docs/content/docs/meta.json create mode 100644 apps/docs/content/docs/model-configuration.mdx create mode 100644 apps/docs/content/docs/troubleshooting.mdx create mode 100644 apps/docs/eslint.config.mjs create mode 100644 apps/docs/lib/blog.ts create mode 100644 apps/docs/lib/cn.ts create mode 100644 apps/docs/lib/layout.shared.tsx create mode 100644 apps/docs/lib/shared.ts create mode 100644 apps/docs/lib/source.ts create mode 100644 apps/docs/next.config.mjs create mode 100644 apps/docs/package-lock.json create mode 100644 apps/docs/package.json create mode 100644 apps/docs/postcss.config.mjs create mode 100644 apps/docs/proxy.ts create mode 100644 apps/docs/scripts/build-product-demo.mjs create mode 100644 apps/docs/tsconfig.json delete mode 100644 server/src/cursor/tools/dispatch/await_shell.rs delete mode 100644 server/src/cursor/tools/result/await_shell.rs create mode 100644 server/src/model/tool_result_replay.rs diff --git a/Makefile b/Makefile index b23ddc2..f937d23 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,20 @@ LOCAL_TAURI_SIGNING_KEY := $(CURDIR)/.tauri/cursor-byok.local.key -.PHONY: check dev-web dev-server dev-desktop build-web build-server build-desktop build-docker +.PHONY: check dev-web dev-docs dev-server dev-desktop build-web build-docs build-server build-desktop build-docker check: cargo fmt --all -- --check cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace --all-targets npm --prefix apps/desktop run check + npm --prefix apps/docs run check dev-web: npm --prefix apps/desktop run dev:web +dev-docs: + npm --prefix apps/docs run dev + dev-server: CURSOR_CONSOLE_DIR=apps/desktop/dist cargo run --package cursor-server --bin cursor-server @@ -20,6 +24,9 @@ dev-desktop: build-web: npm --prefix apps/desktop run build +build-docs: + npm --prefix apps/docs run build + build-server: cargo build --release --package cursor-server --bin cursor-server diff --git a/apps/desktop/demo/index.html b/apps/desktop/demo/index.html new file mode 100644 index 0000000..7d430f0 --- /dev/null +++ b/apps/desktop/demo/index.html @@ -0,0 +1,23 @@ + + + + + + + + Cursor BYOK 产品演示 + + +
+ + + diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f4658bc..985bcb5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -10,6 +10,7 @@ "typecheck:node": "tsc --noEmit -p tsconfig.node.json", "i18n:scan": "STATIC_I18N_SCAN=true vite build", "build": "vite build", + "build:demo": "npm run typecheck && npm run typecheck:node && vite build --config vite.demo.config.ts", "check": "npm run typecheck && npm run typecheck:node && npm run build", "dev:web": "concurrently --kill-others --success first --names server,web \"cross-env CURSOR_CONSOLE_PROXY=http://127.0.0.1:1420 cargo run --manifest-path ../../server/Cargo.toml --bin cursor-server\" \"wait-on http-get://127.0.0.1:3000/__byok-api__/healthz && vite\"", "serve": "vite preview", diff --git a/apps/desktop/src/demo/api.ts b/apps/desktop/src/demo/api.ts new file mode 100644 index 0000000..06ed380 --- /dev/null +++ b/apps/desktop/src/demo/api.ts @@ -0,0 +1,287 @@ +import type { + CallDetail, + CursorHarnessStatus, + LlmCall, + Model, + Overview, + OverviewTokenUsageBucket, + ProxySettings, + StatisticsStorage, + TabSettings, +} from "../api"; + +const API_ROOT = "/__byok-api__/api"; +const FIXED_NOW = Date.UTC(2026, 7, 27, 8, 0, 0); + +const models: Model[] = [ + createModel({ hash: "mock-claude-sonnet", order: 1, name: "Claude Sonnet 4", type: "anthropic", url: "https://api.anthropic.com", modelId: "claude-sonnet-4-20250514" }), + createModel({ hash: "mock-claude-opus", order: 2, name: "Claude Opus 4", type: "anthropic", url: "https://api.anthropic.com", modelId: "claude-opus-4-20250514" }), + createModel({ hash: "mock-gpt", order: 3, name: "GPT-5.2", type: "openai", url: "https://api.openai.com", modelId: "gpt-5.2" }), + createModel({ hash: "mock-o3", order: 4, name: "o3", type: "openai", url: "https://api.openai.com", modelId: "o3" }), + createModel({ hash: "mock-deepseek-v3", order: 5, name: "DeepSeek V3.2", type: "openai", url: "https://api.deepseek.com", modelId: "deepseek-chat", endpoint: "/v1/chat/completions" }), + createModel({ hash: "mock-deepseek-r1", order: 6, name: "DeepSeek R1", type: "openai", url: "https://api.deepseek.com", modelId: "deepseek-reasoner", endpoint: "/v1/chat/completions" }), + createModel({ hash: "mock-gemini-pro", order: 7, name: "Gemini 2.5 Pro", type: "openai", url: "https://generativelanguage.googleapis.com", modelId: "gemini-2.5-pro", endpoint: "/v1beta/openai/chat/completions" }), + createModel({ hash: "mock-gemini-flash", order: 8, name: "Gemini 2.5 Flash", type: "openai", url: "https://generativelanguage.googleapis.com", modelId: "gemini-2.5-flash", endpoint: "/v1beta/openai/chat/completions" }), + createModel({ hash: "mock-qwen-max", order: 9, name: "Qwen3 Max", type: "openai", url: "https://dashscope.aliyuncs.com/compatible-mode", modelId: "qwen3-max", endpoint: "/v1/chat/completions" }), + createModel({ hash: "mock-qwen-plus", order: 10, name: "Qwen Plus", type: "openai", url: "https://dashscope.aliyuncs.com/compatible-mode", modelId: "qwen-plus", endpoint: "/v1/chat/completions" }), + createModel({ hash: "mock-kimi-k2", order: 11, name: "Kimi K2", type: "openai", url: "https://api.moonshot.cn", modelId: "kimi-k2-0711-preview", endpoint: "/v1/chat/completions" }), + createModel({ hash: "mock-kimi-128k", order: 12, name: "Moonshot V1 128K", type: "openai", url: "https://api.moonshot.cn", modelId: "moonshot-v1-128k", endpoint: "/v1/chat/completions" }), + createModel({ hash: "mock-glm-45", order: 13, name: "GLM-4.5", type: "openai", url: "https://open.bigmodel.cn", modelId: "glm-4.5", endpoint: "/api/paas/v4/chat/completions" }), + createModel({ hash: "mock-glm-air", order: 14, name: "GLM-4.5-Air", type: "openai", url: "https://open.bigmodel.cn", modelId: "glm-4.5-air", endpoint: "/api/paas/v4/chat/completions" }), + createModel({ hash: "mock-mistral-large", order: 15, name: "Mistral Large", type: "openai", url: "https://api.mistral.ai", modelId: "mistral-large-latest", endpoint: "/v1/chat/completions" }), + createModel({ hash: "mock-mistral-small", order: 16, name: "Mistral Small", type: "openai", url: "https://api.mistral.ai", modelId: "mistral-small-latest", endpoint: "/v1/chat/completions" }), +]; + +const calls: LlmCall[] = Array.from({ length: 24 }, (_, index) => { + const model = models[index % models.length]; + const failed = index === 7 || index === 19; + return { + call_kind: "provider_llm", + route: "local_byok", + call_id: `mock-call-${String(index + 1).padStart(3, "0")}`, + run_id: `mock-run-${Math.floor(index / 3) + 1}`, + conversation_id: `mock-conversation-${Math.floor(index / 4) + 1}`, + provider_call_index: index + 1, + model_hash: model.model_hash, + provider_type: model.type, + provider_url: model.base_url, + request_type: model.type === "anthropic" ? "messages" : "responses", + request_url: model.type === "anthropic" ? `${model.base_url}/v1/messages` : `${model.base_url}${model.openai_endpoint}`, + model_id: model.model_id, + display_name: model.display_name, + reasoning_effort: index % 2 === 0 ? "high" : null, + fast: index % 3 === 0, + status: failed ? "failed" : "completed", + finish_reason: failed ? null : "stop", + created_at_ms: FIXED_NOW - index * 3 * 60_000, + ttfb_ms: 210 + index * 13, + ttft_ms: 370 + index * 17, + duration_ms: failed ? 812 : 1_420 + index * 71, + input_tokens: 4_800 + index * 337, + output_tokens: failed ? 0 : 820 + index * 43, + total_tokens: failed ? 4_800 + index * 337 : 5_620 + index * 380, + cache_read_tokens: 3_100 + index * 251, + cache_write_tokens: 320 + index * 19, + reasoning_tokens: index % 2 === 0 ? 420 + index * 11 : null, + message_count: 14 + (index % 8), + tool_count: 3 + (index % 5), + http_status: failed ? 429 : 200, + error_kind: failed ? "provider_rate_limit" : null, + error_message: failed ? "Mock provider rate limit" : null, + detailed: true, + }; +}); + +const harnessStatus: CursorHarnessStatus = { + platform: "macos", + ca: "ready", + configured_models: models.length, + enabled_models: models.length, + integration: "enabled", + proxy_url: "http://127.0.0.1:54321", + ca_install_command: null, +}; + +let detailed = true; +let portSettings = { proxy_port: 0, service_port: 0 }; +let proxySettings: ProxySettings = { + mode: "system", + address: "", + auth_enabled: false, + username: "", + has_password: false, +}; +let tabSettings: TabSettings = { mode: "public", address: "" }; +let storage: StatisticsStorage = { bytes: 26_004_480, call_count: calls.length, trace_count: calls.length }; + +export function installDemoApi() { + const nativeFetch = window.fetch.bind(window); + + window.fetch = async (input, init) => { + const requestUrl = input instanceof Request ? input.url : input instanceof URL ? input.href : input; + const url = new URL(requestUrl, window.location.href); + if (!url.pathname.startsWith(API_ROOT)) return nativeFetch(input, init); + + const path = url.pathname.slice(API_ROOT.length) || "/"; + const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); + const body = await readBody(input, init); + + if (path === "/ads") return json({ slots: [] }); + if (path === "/models" && method === "GET") return json(models); + if (path === "/models" && method === "POST") return json(models); + if (path === "/models/order") return json(models); + if (path === "/models/discover") return json({ models: models.map((model) => model.model_id) }); + if (path === "/models/import-v0049" && method === "GET") { + return json({ source: "demo", total: 0, new_models: 0, existing_models: 0, models: [] }); + } + if (path === "/models/import-v0049") return json({ imported: 0, skipped: 0, total: 0 }); + if (/^\/models\/[^/]+\/test\/[^/]+$/.test(path) && method === "POST") { + return json({ duration_ms: 1_284, first_text_ms: 418, output_tokens: 42, tokens_per_second: 38.6, tokens_estimated: false, output: "Mock connectivity test passed." }); + } + if (/^\/models\/[^/]+\/test\/[^/]+$/.test(path) || /^\/models\/[^/]+$/.test(path)) { + return method === "DELETE" ? empty() : json(models[0]); + } + if (path === "/overview") return json(createOverview(url.searchParams)); + if (path === "/llm-calls") return json(calls); + if (path.startsWith("/llm-calls/")) return json(createCallDetail(path.slice("/llm-calls/".length))); + if (path === "/harness/cursor/status") return json(harnessStatus); + if (path === "/harness/cursor/ca/initialize" || path === "/harness/cursor/enabled") return json(harnessStatus); + if (path === "/settings/observability" && method === "GET") return json({ detailed }); + if (path === "/settings/observability") { + detailed = Boolean((body as { detailed?: unknown } | null)?.detailed); + return json({ detailed }); + } + if (path === "/settings/ports" && method === "GET") return json(portSettings); + if (path === "/settings/ports") { + portSettings = body as typeof portSettings; + return json(portSettings); + } + if (path === "/settings/storage/statistics" && method === "GET") return json(storage); + if (path === "/settings/storage/statistics") { + storage = { bytes: 0, call_count: 0, trace_count: 0 }; + return json(storage); + } + if (path === "/settings/proxy" && method === "GET") return json(proxySettings); + if (path === "/settings/proxy") { + const next = body as Partial; + proxySettings = { ...proxySettings, ...next, has_password: Boolean(next.has_password) }; + return json(proxySettings); + } + if (path === "/settings/tab" && method === "GET") return json(tabSettings); + if (path === "/settings/tab") { + tabSettings = body as TabSettings; + return json(tabSettings); + } + if (path === "/settings/desktop" && method === "GET") return json({ silent_start: false, show_dock_icon: true }); + if (path === "/settings/desktop") return json(body); + if (path === "/desktop/open-external-url") { + const target = (body as { url?: string } | null)?.url; + if (target) { + const next = new URL(target, window.location.href); + if (next.origin === window.location.origin && next.hash) window.location.hash = next.hash; + } + return empty(); + } + if (path.endsWith("/dismissals")) return empty(); + + return json({ message: `Unhandled demo endpoint: ${method} ${path}` }, 404); + }; +} + +function createModel({ hash, order, name, type, url, modelId, endpoint = "/v1/responses" }: { + hash: string; + order: number; + name: string; + type: Model["type"]; + url: string; + modelId: string; + endpoint?: string; +}): Model { + return { + model_hash: hash, + sort_order: order, + display_name: name, + type, + base_url: url, + use_full_url: false, + api_key: "demo-key", + tooltip_data: `${name} Mock 通道`, + model_id: modelId, + reasoning_effort: type === "openai" ? "high" : null, + openai_endpoint: type === "openai" ? endpoint : "", + openai_extra_params_enabled: false, + openai_extra_params: {}, + custom_headers_enabled: false, + custom_headers: {}, + anthropic_extra_params_enabled: false, + anthropic_extra_params: {}, + context_window_tokens: 200_000, + max_completion_tokens: type === "openai" ? 32_000 : null, + anthropic_max_tokens: type === "anthropic" ? 32_000 : null, + anthropic_thinking_effort: type === "anthropic" ? "high" : null, + thinking_budget_tokens: null, + created_at_ms: FIXED_NOW - order * 86_400_000, + updated_at_ms: FIXED_NOW, + }; +} + +function createOverview(params: URLSearchParams): Overview { + const start = Number(params.get("start_ms")); + const end = Number(params.get("end_ms")); + const duration = Number.isFinite(start) && Number.isFinite(end) && end > start ? end - start : 365 * 86_400_000; + const granularity = duration <= 2 * 60 * 60_000 ? "minute" : duration <= 2 * 86_400_000 ? "hour" : "day"; + const step = granularity === "minute" ? 60_000 : granularity === "hour" ? 3_600_000 : 86_400_000; + const count = granularity === "minute" ? Math.min(60, Math.max(10, Math.ceil(duration / step))) : granularity === "hour" ? Math.min(24, Math.max(8, Math.ceil(duration / step))) : Math.min(365, Math.max(7, Math.ceil(duration / step))); + const series = createSeries(count, step, Number.isFinite(end) && end > 0 ? end : FIXED_NOW); + const totals = series.reduce((sum, bucket) => ({ + input: sum.input + bucket.input_tokens, + cacheRead: sum.cacheRead + bucket.cache_read_tokens, + cacheWrite: sum.cacheWrite + bucket.cache_write_tokens, + output: sum.output + bucket.output_tokens, + }), { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }); + const llmCalls = Math.max(12, Math.round(count * 5.4)); + + return { + metrics: { + llm_calls: llmCalls, + successful_calls: llmCalls - Math.max(1, Math.floor(llmCalls * 0.008)), + failed_calls: Math.max(1, Math.floor(llmCalls * 0.008)), + token_usage: totals.input + totals.cacheRead + totals.cacheWrite + totals.output, + prompt_tokens: totals.input + totals.cacheRead + totals.cacheWrite, + input_tokens: totals.input, + cache_read_tokens: totals.cacheRead, + cache_write_tokens: totals.cacheWrite, + output_tokens: totals.output, + }, + token_usage_granularity: granularity, + token_usage_series: series, + }; +} + +function createSeries(count: number, step: number, end: number): OverviewTokenUsageBucket[] { + return Array.from({ length: count }, (_, index) => { + const wave = 0.72 + ((index * 17) % 31) / 50; + return { + bucket_start_ms: end - (count - index) * step, + input_tokens: Math.round(18_000 * wave), + cache_read_tokens: Math.round(62_000 * wave), + cache_write_tokens: Math.round(7_500 * wave), + output_tokens: Math.round(12_500 * wave), + }; + }); +} + +function createCallDetail(id: string): CallDetail { + const call = calls.find((item) => item.call_id === decodeURIComponent(id)) ?? calls[0]; + return { + call, + request: { + headers: { authorization: "Bearer sk-demo-••••", "content-type": "application/json" }, + body: { model: call.model_id, stream: true, messages: [{ role: "user", content: "Mock Agent request" }] }, + byte_count: 8_426, + }, + response_chunks: [ + { seq: 1, received_offset_ms: 418, data: "event: response.created", byte_count: 128 }, + { seq: 2, received_offset_ms: 512, data: "event: response.output_text.delta", byte_count: 256 }, + ], + cursor_trace: null, + }; +} + +async function readBody(input: RequestInfo | URL, init?: RequestInit): Promise { + const raw = init?.body ?? (input instanceof Request ? await input.clone().text() : null); + if (typeof raw !== "string" || raw.length === 0) return null; + try { return JSON.parse(raw) as unknown; } + catch { return raw; } +} + +function json(value: unknown, status = 200) { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function empty() { + return new Response(null, { status: 204 }); +} diff --git a/apps/desktop/src/demo/index.tsx b/apps/desktop/src/demo/index.tsx new file mode 100644 index 0000000..3a4164a --- /dev/null +++ b/apps/desktop/src/demo/index.tsx @@ -0,0 +1,8 @@ +import { installDemoApi } from "./api"; + +installDemoApi(); +document.documentElement.dataset.platform = "macos"; +localStorage.setItem("cursor-byok.locale", "zh-CN"); +localStorage.setItem("cursor-byok.theme", "default-dark"); + +void import("../index"); diff --git a/apps/desktop/src/layouts/AppHeader.tsx b/apps/desktop/src/layouts/AppHeader.tsx index 7932fe6..2af714a 100644 --- a/apps/desktop/src/layouts/AppHeader.tsx +++ b/apps/desktop/src/layouts/AppHeader.tsx @@ -3,6 +3,7 @@ import appIcon from "../../src-tauri/icons/32x32.png"; import { currentAppVersion } from "../native/appLifecycle"; import type { DesktopPlatform } from "../native/platform"; import { WindowControls } from "./WindowControls"; +import { MacTrafficLights } from "./MacTrafficLights"; import styles from "./AppHeader.module.scss"; type AppHeaderProps = { @@ -11,6 +12,7 @@ type AppHeaderProps = { }; export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) { + const showMacTrafficLights = !nativeDesktop && platform === "macos"; const showNativeUi = nativeDesktop && platform !== "macos"; const [version, setVersion] = useState("…"); @@ -25,6 +27,7 @@ export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) { return