Compare commits

...
Author SHA1 Message Date
leookun 8100652f87 chore: start provider and model interaction improvements 2026-08-25 00:07:44 +08:00
leokun 7dded0d81f Merge pull request #329 from LyricalNanoha/feat/shell-sandbox-permissions
feat: add required_permissions support for Shell sandbox policy
2026-08-24 23:53:45 +08:00
leokun 283f2296e7 Merge pull request #331 from baichuan335/feat/custom-context-option
feat: 增加自定义上下文选项功能
2026-08-24 23:41:42 +08:00
leookun b61059590f refactor: share token count utilities 2026-08-24 23:41:16 +08:00
baichuan335 5509ac45ca feat: add custom context option 2026-08-24 23:29:24 +08:00
leokun cfd1e740b2 chore: release v0.1.0-beta.10 2026-08-24 19:46:15 +08:00
leokun 65edad6a21 chore: release v0.1.0-beta.9 2026-08-24 19:13:50 +08:00
sunyueandCursor 1368a587ca feat: add required_permissions support for Shell sandbox policy
The Shell tool schema lacked a `required_permissions` parameter,
preventing models from requesting elevated sandbox permissions
(e.g. unrestricted network access). This adds:

- `required_permissions` parameter to the Shell tool schema in tools.json
- Sandboxing instructions in the Shell tool description so models know
  when and how to request permissions
- `shell_sandbox_policy()` in request.rs to map the parameter to the
  protobuf `SandboxPolicy.requested_sandbox_policy` field

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 18:59:25 +08:00
leokun 58daa91c90 chore: release v0.1.0-beta.8 2026-08-24 18:43:44 +08:00
leokun eb26b17ba0 fix: stabilize todo state and responses streams 2026-08-24 18:43:44 +08:00
21 changed files with 705 additions and 102 deletions
Generated
+1 -1
View File
@@ -1128,7 +1128,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "cursor-byok-desktop"
version = "0.1.0-beta.7"
version = "0.1.0-beta.10"
dependencies = [
"axum",
"cursor-server",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.0-beta.7",
"version": "0.1.0-beta.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cursor-byok-desktop",
"version": "0.1.0-beta.7",
"version": "0.1.0-beta.10",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cursor-byok-desktop",
"version": "0.1.0-beta.7",
"version": "0.1.0-beta.10",
"description": "Cursor BYOK desktop management application",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cursor-byok-desktop"
version = "0.1.0-beta.7"
version = "0.1.0-beta.10"
edition = "2021"
publish = false
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cursor BYOK",
"version": "0.1.0-beta.7",
"version": "0.1.0-beta.10",
"identifier": "dev.cursorbyok.desktop",
"build": {
"beforeDevCommand": "npm run dev",
@@ -78,10 +78,13 @@ export function CursorModelEditor({ draft, providers, editing, modelOptions, dis
{ value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon }, { value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon }, { value: "anthropic", label: "Anthropic", icon: claudeIcon },
]} onChange={(endpointType) => setEndpointType(endpointType as ProviderType)} /></FormField>
{(editing || draft.modelIds.length <= 1) && <FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给上游的模型名称。")}><TextInput placeholder="例如:GPT-4.1" value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>}
<FormField className={styles.fullWidth} label={t("模型名称")} hint={editing ? t("可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。") : t("支持选择或输入多个模型;批量添加时显示名称默认使用对应模型名称。")}>{editing
<FormField label={t("模型名称")} hint={editing ? t("可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。") : t("支持选择或输入多个模型;批量添加时显示名称默认使用对应模型名称。")}>{editing
? <Combobox value={draft.model.model_id} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} />
: <MultiCombobox value={draft.modelIds} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={setModelIds} />
}</FormField>
<FormField label={t("自定义上下文")} hint={t("输入 token 数后,将作为额外选项添加到 Cursor 模型的 Context 列表;只有在 Cursor 中选中该选项时才会生效。")}>
<TextInput type="number" min={1} step={1} aria-label={t("自定义上下文 tokens")} placeholder={t("例如:272000")} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: event.target.value === "" ? null : Math.trunc(Number(event.target.value)) })} />
</FormField>
<div className={styles.fullWidth}><Checkbox label={t("自定义请求完整地址")} checked={draft.customRequestUrl} onChange={(customRequestUrl) => onChange({ ...draft, customRequestUrl, model: { ...draft.model, request_url: customRequestUrl ? draft.model.request_url : "" } })} /></div>
{draft.customRequestUrl && <FormField className={styles.fullWidth} label={t("请求完整地址")} hint={t("支持完整 HTTP(S) 地址或以 / 开头、与上游地址组合的相对路径。")}><TextInput placeholder="例如:https://api.example.com/v1/chat/completions" value={draft.model.request_url} onChange={(event) => setModel({ request_url: event.target.value })} /></FormField>}
{!editing && draft.providerMode === "new" && <>
+88 -28
View File
@@ -38,7 +38,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 92,
"line": 95,
"column": 39
}
]
@@ -190,7 +190,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 256,
"line": 257,
"column": 89
}
]
@@ -243,7 +243,7 @@
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 81,
"column": 119
"column": 90
}
]
},
@@ -390,7 +390,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 255,
"line": 256,
"column": 36
},
{
@@ -511,12 +511,12 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 89,
"line": 92,
"column": 56
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 89,
"line": 92,
"column": 141
},
{
@@ -731,12 +731,12 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 88,
"line": 91,
"column": 56
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 88,
"line": 91,
"column": 123
},
{
@@ -912,7 +912,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 85,
"line": 88,
"column": 58
}
]
@@ -1596,7 +1596,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 88,
"line": 91,
"column": 78
},
{
@@ -1632,6 +1632,18 @@
}
]
},
"5cca0b7972a11f3a": {
"source": "自定义上下文必须是大于 0 的整数",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 238,
"column": 171
}
]
},
"5d59857bf039cac9": {
"source": "Cursor 助手 v{version}",
"kind": "template",
@@ -1777,7 +1789,7 @@
"refs": [
{
"file": "api.ts",
"line": 289,
"line": 290,
"column": 43
}
]
@@ -2070,7 +2082,7 @@
"refs": [
{
"file": "api.ts",
"line": 284,
"line": 285,
"column": 43
}
]
@@ -2132,12 +2144,12 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 92,
"line": 95,
"column": 73
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 92,
"line": 95,
"column": 122
}
]
@@ -2209,7 +2221,7 @@
"refs": [
{
"file": "api.ts",
"line": 230,
"line": 231,
"column": 21
}
]
@@ -2255,7 +2267,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 250,
"line": 251,
"column": 21
}
]
@@ -2318,7 +2330,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 89,
"line": 92,
"column": 85
}
]
@@ -2443,7 +2455,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 86,
"line": 89,
"column": 100
}
]
@@ -2479,7 +2491,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 86,
"line": 89,
"column": 81
}
]
@@ -2559,7 +2571,7 @@
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 81,
"column": 81
"column": 52
}
]
},
@@ -2592,6 +2604,18 @@
}
]
},
"9d8f2ef4e85ea665": {
"source": "自定义上下文 tokens",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 86,
"column": 63
}
]
},
"9e46da6923836182": {
"source": "如:2026-08-23 09:00、1小时前",
"kind": "text",
@@ -3117,12 +3141,12 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 93,
"line": 96,
"column": 66
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 93,
"line": 96,
"column": 115
}
]
@@ -3286,16 +3310,28 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 94,
"line": 97,
"column": 66
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 94,
"line": 97,
"column": 115
}
]
},
"c6cc835023617457": {
"source": "自定义上下文",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 85,
"column": 25
}
]
},
"c7ea2c9bc43134bd": {
"source": "编辑模型",
"kind": "text",
@@ -3386,7 +3422,7 @@
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 81,
"column": 54
"column": 25
}
]
},
@@ -3443,7 +3479,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 94,
"line": 97,
"column": 39
}
]
@@ -3477,6 +3513,18 @@
}
]
},
"d27db596b73a0a66": {
"source": "例如:272000",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 86,
"column": 96
}
]
},
"d2d648bd1c94b7f9": {
"source": "认证",
"kind": "text",
@@ -3555,7 +3603,7 @@
{
"file": "components/ProviderEditor.tsx",
"line": 26,
"column": 190
"column": 180
}
]
},
@@ -3758,7 +3806,7 @@
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 93,
"line": 96,
"column": 39
}
]
@@ -3866,6 +3914,18 @@
}
]
},
"e671f8c7598139ef": {
"source": "输入 token 数后,将作为额外选项添加到 Cursor 模型的 Context 列表;只有在 Cursor 中选中该选项时才会生效。",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 85,
"column": 44
}
]
},
"e6ca887f22288cde": {
"source": "Model ID 和显示名称不能为空",
"kind": "text",
+5
View File
@@ -110,6 +110,7 @@
"5ae715656ffbc35d": "Merge into the request body for every model from this provider.",
"5b17f59d33bde39e": "Error: {error}",
"5c55a67935af8f45": "All",
"5cca0b7972a11f3a": "Custom context must be an integer greater than 0",
"5d59857bf039cac9": "Cursor Assistant v{version}",
"5f8d556a9c47da3c": "Launch at login disabled",
"5f9acfb945229062": "Are you sure you no longer want to see this ad?",
@@ -180,6 +181,7 @@
"9a026819dd1af5c5": "Enter a model identifier directly or select one returned by the current provider.",
"9ac0ac940982895d": "Select provider",
"9c41b3a9e12ac994": "Reasoning effort",
"9d8f2ef4e85ea665": "Custom context tokens",
"9e46da6923836182": "For example: 2026-08-23 09:00, 1 hour ago",
"9f6fee1aba17a565": "Language",
"9fb48101d237ff96": "Last week",
@@ -227,6 +229,7 @@
"c1e98892a77f7a19": "{count} per page",
"c3760858cdb6d9f4": "Request body",
"c62a58459251b02c": "Image generation",
"c6cc835023617457": "Custom context",
"c7ea2c9bc43134bd": "Edit model",
"c8df3c14a003bfcd": "Unable to load call details",
"c98e118e0a43f078": "Model",
@@ -239,6 +242,7 @@
"cfe999e50be8ef54": "Whether the model declares image-generation support.",
"d0bfccc77315d887": "Last month",
"d15a909c3490a7e0": "Endpoint type",
"d27db596b73a0a66": "For example: 272000",
"d2d648bd1c94b7f9": "Authentication",
"d30d35c5ec4a888d": "Select or enter at least one model",
"d34335433395cd3a": "Start Cursor BYOK automatically after signing in.",
@@ -269,6 +273,7 @@
"e5043c7a2b408271": "Last 10 minutes",
"e59ae97924d62f01": "First page",
"e5b9961a0d5242e3": "Port settings saved. Restart the app to apply them.",
"e671f8c7598139ef": "After entering a token count, it is added as an extra option to the Cursor model Context list. It only takes effect when selected in Cursor.",
"e6ca887f22288cde": "Model ID and display name are required",
"e77e3d58b0dcffaa": "Duration",
"e828bd3a0151edc2": "The local CA must be trusted by the system",
+5
View File
@@ -110,6 +110,7 @@
"5ae715656ffbc35d": "合并到该上游所有模型的请求体。",
"5b17f59d33bde39e": "错误:{error}",
"5c55a67935af8f45": "全部",
"5cca0b7972a11f3a": "自定义上下文必须是大于 0 的整数",
"5d59857bf039cac9": "Cursor 助手 v{version}",
"5f8d556a9c47da3c": "已关闭开机启动",
"5f9acfb945229062": "你确认不想再看到此广告吗?",
@@ -180,6 +181,7 @@
"9a026819dd1af5c5": "可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。",
"9ac0ac940982895d": "选择上游",
"9c41b3a9e12ac994": "思考强度",
"9d8f2ef4e85ea665": "自定义上下文 tokens",
"9e46da6923836182": "如:2026-08-23 09:00、1小时前",
"9f6fee1aba17a565": "语言",
"9fb48101d237ff96": "近一周",
@@ -227,6 +229,7 @@
"c1e98892a77f7a19": "{count} 条/页",
"c3760858cdb6d9f4": "请求体",
"c62a58459251b02c": "图片生成",
"c6cc835023617457": "自定义上下文",
"c7ea2c9bc43134bd": "编辑模型",
"c8df3c14a003bfcd": "无法加载调用详情",
"c98e118e0a43f078": "模型",
@@ -239,6 +242,7 @@
"cfe999e50be8ef54": "是否声明模型支持图片生成。",
"d0bfccc77315d887": "近一个月",
"d15a909c3490a7e0": "端点类型",
"d27db596b73a0a66": "例如:272000",
"d2d648bd1c94b7f9": "认证",
"d30d35c5ec4a888d": "请至少选择或输入一个模型",
"d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。",
@@ -269,6 +273,7 @@
"e5043c7a2b408271": "近10分钟",
"e59ae97924d62f01": "第一页",
"e5b9961a0d5242e3": "端口设置已保存,重启软件后生效",
"e671f8c7598139ef": "输入 token 数后,将作为额外选项添加到 Cursor 模型的 Context 列表;只有在 Cursor 中选中该选项时才会生效。",
"e6ca887f22288cde": "Model ID 和显示名称不能为空",
"e77e3d58b0dcffaa": "耗时",
"e828bd3a0151edc2": "需要在系统中信任本地 CA",
@@ -55,7 +55,7 @@ export function CursorSettingsPage() {
next.model = {
model_id: model.model_id, display_name: model.display_name, enabled: model.enabled, sort_order: model.sort_order,
endpoint_type: model.endpoint_type, request_url: model.request_url,
context_window_tokens: null, max_output_tokens: null,
context_window_tokens: model.context_window_tokens, max_output_tokens: null,
reasoning_enabled: model.reasoning_enabled, reasoning_effort: null,
supports_image_generation: model.supports_image_generation,
};
@@ -235,6 +235,7 @@ function cursorModelInputs(draft: CursorModelDraft, editing: boolean) {
if (!modelIds.length) throw new Error(t("请至少选择或输入一个模型"));
if (editing && !draft.model.display_name.trim()) throw new Error(t("Model ID 和显示名称不能为空"));
if (draft.customRequestUrl && !draft.model.request_url.trim()) throw new Error(t("请求完整地址不能为空"));
if (draft.model.context_window_tokens !== null && (!Number.isSafeInteger(draft.model.context_window_tokens) || draft.model.context_window_tokens <= 0)) throw new Error(t("自定义上下文必须是大于 0 的整数"));
return modelIds.map((modelId, index) => ({
...draft.model,
model_id: modelId,
+9 -1
View File
@@ -580,7 +580,7 @@
"type": "function",
"function": {
"name": "Shell",
"description": "Executes a given command in a shell session, waiting for output for `block_until_ms` millis.\nYou can monitor commands by configuring `notify_on_output`. You will be notified at the end of your turn whenever stdout/stderr output matches the regex `pattern`. Output redirected only to a file will not trigger it. Configure a 5-or-fewer-word `reason` explaining what you are watching for, and optionally configure `debounce_ms`.",
"description": "Executes a given command in a shell session with optional foreground timeout.\n\nIMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files, sleeping) - use the specialized tools for this instead.\n\nYou can monitor commands by configuring `notify_on_output`. You will be notified at the end of your turn whenever stdout/stderr output matches the regex `pattern`. Output redirected only to a file will not trigger it. Configure a 5-or-fewer-word `reason` explaining what you are watching for, and optionally configure `debounce_ms`.\n\n<sandboxing>\nBy default, your commands will run in a sandbox. The sandbox allows most writes to the workspace and reads to the rest of the filesystem. Some other syscalls are also disallowed like access to USB devices.\n\nThe sandbox includes network access for common package managers and version control providers (e.g. npm, pypi, crates.io, Maven Central, GitHub, etc.). Standard operations like package installs and fetching dependencies will work without requesting additional permissions.\n\nFor broader network access beyond the allowed domains, you may still need to request 'full_network' permissions.\n\nThe required_permissions argument is used to request additional permissions. If you know you will need a permission, request it. Requesting permissions will slow down the command execution as it will ask the user for approval. Do not hesitate to request permissions if you are certain you need them. For commands you know will need unrestricted network access, request the full_network permission rather than waiting for the command to fail and asking for it later.\n\nThe following permissions are supported:\n\n- full_network: Grants unrestricted network access. This is useful for any commands that need to contact the outside internet, outside of the allowed domains.\n- all: Disables the sandbox entirely. If all is requested the command will run outside of the sandbox.\n\nIf you think a command failed due to sandbox restrictions, run the command again with the required_permissions argument to request what you need.\n</sandboxing>",
"parameters": {
"type": "object",
"properties": {
@@ -629,6 +629,14 @@
"working_directory": {
"description": "The absolute path to the working directory to execute the command in (defaults to current directory)",
"type": "string"
},
"required_permissions": {
"description": "Optional list of permissions to request if the command needs them. Use \"full_network\" for unrestricted network access beyond the sandbox allowlist, or \"all\" to disable the sandbox entirely.",
"type": "array",
"items": {
"type": "string",
"enum": ["full_network", "all"]
}
}
},
"required": [
+48 -15
View File
@@ -14,7 +14,7 @@ use crate::{
proxy::{self, CursorProxy},
CursorSessionRegistry,
},
model::ProviderModel,
model::{format_token_count, parse_token_count, ProviderModel},
Error, Result,
};
@@ -205,6 +205,23 @@ const EFFORTS: [(&str, &str); 5] = [
];
const DEFAULT_CONTEXT: &str = "200k";
fn context_options(model: &ProviderModel) -> Vec<(String, String)> {
let mut contexts = CONTEXTS
.into_iter()
.map(|(value, display_name)| (value.to_owned(), display_name.to_owned()))
.collect::<Vec<_>>();
if let Some(tokens) = model.context_window_tokens {
let value = tokens.to_string();
let duplicate = contexts
.iter()
.any(|(existing, _)| parse_token_count(existing) == Some(tokens));
if !duplicate {
contexts.push((value, format!("{} (Custom)", format_token_count(tokens))));
}
}
contexts
}
pub async fn available_models(
State(registry): State<CursorSessionRegistry>,
Extension(proxy): Extension<CursorProxy>,
@@ -324,7 +341,8 @@ fn unary_payload(body: &Bytes) -> Result<(bool, &[u8])> {
}
fn available_model(model: &ProviderModel, provider_name: &str) -> AvailableModel {
let variants = model_variants(model);
let contexts = context_options(model);
let variants = model_variants(model, &contexts);
let legacy_slugs = variants
.iter()
.filter_map(|variant| variant.legacy_slug.clone())
@@ -348,7 +366,7 @@ fn available_model(model: &ProviderModel, provider_name: &str) -> AvailableModel
inputbox_short_model_name: Some(model.display_name.clone()),
supports_sandboxing: Some(true),
supports_cmd_k: Some(false),
parameter_definitions: model_parameters(),
parameter_definitions: model_parameters(&contexts),
variants,
legacy_slugs,
named_model_section_index: Some(1),
@@ -365,7 +383,7 @@ fn available_model(model: &ProviderModel, provider_name: &str) -> AvailableModel
}
}
fn model_parameters() -> Vec<ModelParameterDefinition> {
fn model_parameters(contexts: &[(String, String)]) -> Vec<ModelParameterDefinition> {
vec![
ModelParameterDefinition {
id: "context".into(),
@@ -374,11 +392,11 @@ fn model_parameters() -> Vec<ModelParameterDefinition> {
parameter_type: Some(ModelParameterType {
boolean_parameter: None,
enum_parameter: Some(EnumParameter {
values: CONTEXTS
.into_iter()
values: contexts
.iter()
.map(|(value, display_name)| EnumParameterValue {
value: value.into(),
display_name: Some(display_name.into()),
value: value.clone(),
display_name: Some(display_name.clone()),
})
.collect(),
}),
@@ -429,9 +447,9 @@ fn model_parameters() -> Vec<ModelParameterDefinition> {
]
}
fn model_variants(model: &ProviderModel) -> Vec<ModelVariant> {
let mut variants = Vec::with_capacity(CONTEXTS.len() * EFFORTS.len() * 2);
for (context, context_name) in CONTEXTS {
fn model_variants(model: &ProviderModel, contexts: &[(String, String)]) -> Vec<ModelVariant> {
let mut variants = Vec::with_capacity(contexts.len() * EFFORTS.len() * 2);
for (context, context_name) in contexts {
for (effort, effort_name) in EFFORTS {
for fast in [false, true] {
variants.push(model_variant(
@@ -546,7 +564,7 @@ mod tests {
request_url: String::new(),
enabled: true,
sort_order: 0,
context_window_tokens: Some(200_000),
context_window_tokens: Some(272_000),
max_output_tokens: None,
reasoning_enabled: false,
reasoning_effort: None,
@@ -591,7 +609,22 @@ mod tests {
.iter()
.map(|value| value.value.as_str())
.collect::<Vec<_>>();
assert_eq!(context_values, ["200k", "356k", "800k", "1m"]);
assert_eq!(context_values, ["200k", "356k", "800k", "1m", "272000"]);
let custom_context = context
.parameter_type
.as_ref()
.unwrap()
.enum_parameter
.as_ref()
.unwrap()
.values
.iter()
.find(|value| value.value == "272000")
.unwrap();
assert_eq!(
custom_context.display_name.as_deref(),
Some("272K (Custom)")
);
let effort = mapped
.parameter_definitions
.iter()
@@ -607,8 +640,8 @@ mod tests {
.values
.iter()
.any(|value| value.value == "max"));
assert_eq!(mapped.variants.len(), 40);
assert_eq!(mapped.legacy_slugs.len(), 40);
assert_eq!(mapped.variants.len(), 50);
assert_eq!(mapped.legacy_slugs.len(), 50);
assert_eq!(mapped.model_picker_badges.len(), 1);
assert_eq!(mapped.model_picker_badges[0].label, "OpenRouter");
assert!(!mapped.model_picker_badges[0].dismiss_on_selection);
+120 -1
View File
@@ -27,7 +27,9 @@ pub fn fold_derived_state(messages: &[CanonicalMessage]) -> DerivedState {
continue;
};
match normalize(&name).as_str() {
"todowrite" | "updatetodos" => state.todos = Some(input),
"todowrite" | "updatetodos" => {
state.todos = Some(apply_todo_write(state.todos.take(), input));
}
"createplan" | "updateplan" | "writeplan" => state.plan = Some(input),
_ => {}
}
@@ -38,6 +40,39 @@ pub fn fold_derived_state(messages: &[CanonicalMessage]) -> DerivedState {
state
}
fn apply_todo_write(current: Option<Value>, mut input: Value) -> Value {
if !input.get("merge").and_then(Value::as_bool).unwrap_or(false) {
return input;
}
let mut todos = current
.as_ref()
.and_then(|value| value.get("todos"))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let patches = input
.get("todos")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
for patch in patches {
let existing = patch.get("id").and_then(Value::as_str).and_then(|id| {
todos
.iter_mut()
.find(|todo| todo.get("id").and_then(Value::as_str) == Some(id))
});
match (existing, patch) {
(Some(Value::Object(todo)), Value::Object(patch)) => todo.extend(patch),
(_, patch) => todos.push(patch),
}
}
if let Some(object) = input.as_object_mut() {
object.insert("merge".into(), Value::Bool(false));
object.insert("todos".into(), Value::Array(todos));
}
input
}
fn normalize(value: &str) -> String {
value
.chars()
@@ -45,3 +80,87 @@ fn normalize(value: &str) -> String {
.flat_map(char::to_lowercase)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Origin, Role, ToolCallContent, ToolResultContent};
#[test]
fn todo_write_merge_materializes_complete_existing_items_and_appends_new_ids() {
let messages = vec![
assistant_call(
"create",
serde_json::json!({
"merge": false,
"todos": [
{"id": "first", "content": "First", "status": "in_progress"},
{"id": "second", "content": "Second", "status": "pending"}
]
}),
),
successful_result("create"),
assistant_call(
"merge",
serde_json::json!({
"merge": true,
"todos": [
{"id": "first", "status": "completed"},
{"id": "second", "content": "Second updated"},
{"id": "third", "content": "Third", "status": "cancelled"}
]
}),
),
successful_result("merge"),
];
let state = fold_derived_state(&messages);
assert_eq!(
state.todos.unwrap()["todos"],
serde_json::json!([
{"id": "first", "content": "First", "status": "completed"},
{"id": "second", "content": "Second updated", "status": "pending"},
{"id": "third", "content": "Third", "status": "cancelled"}
])
);
}
fn assistant_call(call_id: &str, arguments: Value) -> CanonicalMessage {
CanonicalMessage {
message_id: format!("assistant-{call_id}"),
role: Role::Assistant,
origin: Origin::Assistant,
content: MessageContent::Assistant {
text: String::new(),
thinking: String::new(),
tool_round_id: Some(format!("round-{call_id}").into()),
replay_state: None,
tool_calls: vec![ToolCallContent {
index: 0,
call_id: call_id.into(),
name: "TodoWrite".into(),
arguments,
}],
},
runtime_event_id: None,
}
}
fn successful_result(call_id: &str) -> CanonicalMessage {
CanonicalMessage {
message_id: format!("result-{call_id}"),
role: Role::Tool,
origin: Origin::Tool,
content: MessageContent::ToolResult(ToolResultContent {
call_id: call_id.into(),
name: "TodoWrite".into(),
content: "{}".into(),
is_error: false,
image: None,
provider_parts: Vec::new(),
}),
runtime_event_id: None,
}
}
}
+4 -11
View File
@@ -1,6 +1,9 @@
use crate::{
cursor::proto::agent::v1 as pb,
model::{ModelLatency, ModelSpec, ReasoningSpec, SubagentKind, SubagentModelOverride},
model::{
parse_token_count, ModelLatency, ModelSpec, ReasoningSpec, SubagentKind,
SubagentModelOverride,
},
Error, Result,
};
@@ -136,16 +139,6 @@ fn parse_bool(parameter: &pb::requested_model::ModelParameterValue) -> Result<bo
}
}
fn parse_token_count(value: &str) -> Option<u64> {
let value = value.trim().to_ascii_lowercase();
let (number, multiplier) = match value.chars().last()? {
'k' => (&value[..value.len() - 1], 1_000),
'm' => (&value[..value.len() - 1], 1_000_000),
_ => (value.as_str(), 1),
};
number.parse::<u64>().ok()?.checked_mul(multiplier)
}
#[cfg(test)]
mod tests {
use super::*;
+33 -14
View File
@@ -274,20 +274,7 @@ pub(crate) async fn prepare(
};
RunAction::Resume { pending_tool_round }
};
let kind = match (request.subagent_type_name.as_deref(), parent) {
(None, _) => RunKind::Root,
(Some(name), Some((parent_run_id, parent_tool_call_id))) => RunKind::Subagent {
parent_run_id,
parent_tool_call_id,
kind: model::subagent_kind(name),
background: false,
},
(Some(_), None) => {
return Err(Error::Protocol(
"subagent Run is missing its parent Run and tool call".into(),
));
}
};
let kind = run_kind(request.subagent_type_name.as_deref(), parent)?;
let exec = exec_context(
request,
&request_context,
@@ -322,6 +309,21 @@ pub(crate) async fn prepare(
))
}
fn run_kind(subagent_type_name: Option<&str>, parent: Option<(RunId, String)>) -> Result<RunKind> {
match (subagent_type_name, parent) {
(None | Some("side-chat"), _) => Ok(RunKind::Root),
(Some(name), Some((parent_run_id, parent_tool_call_id))) => Ok(RunKind::Subagent {
parent_run_id,
parent_tool_call_id,
kind: model::subagent_kind(name),
background: false,
}),
(Some(_), None) => Err(Error::Protocol(
"subagent Run is missing its parent Run and tool call".into(),
)),
}
}
fn validate_prompt_root(messages: &[CanonicalMessage]) -> Result<()> {
let prompts = messages
.iter()
@@ -579,6 +581,23 @@ mod tests {
assert!(mode_from_proto(99).is_err());
}
#[test]
fn side_chat_without_task_parent_is_an_independent_root_run() {
assert!(matches!(
run_kind(Some("side-chat"), None).unwrap(),
RunKind::Root
));
}
#[test]
fn task_subagent_without_parent_is_still_rejected() {
assert!(matches!(
run_kind(Some("explore"), None),
Err(Error::Protocol(message))
if message == "subagent Run is missing its parent Run and tool call"
));
}
#[test]
fn current_user_message_consumes_the_mode_instead_of_history_mode() {
let request = pb::AgentRunRequest {
+24
View File
@@ -49,6 +49,7 @@ pub fn request(id: u32, call: &ToolCall, context: &ExecContext) -> Result<pb::Ag
"request_smart_mode_approval",
"smart_mode_block_reason",
)?,
requested_sandbox_policy: shell_sandbox_policy(call),
close_stdin: true,
conversation_id: Some(context.conversation_id.clone()),
admin_command_denylist: context.admin_command_denylist.clone(),
@@ -365,6 +366,29 @@ pub fn abort(id: u32) -> pb::AgentServerMessage {
}
}
fn shell_sandbox_policy(call: &ToolCall) -> Option<pb::SandboxPolicy> {
let permissions = call.arguments.get("required_permissions")?.as_array()?;
let perms: Vec<&str> = permissions
.iter()
.filter_map(Value::as_str)
.collect();
if perms.contains(&"all") {
Some(pb::SandboxPolicy {
r#type: pb::sandbox_policy::Type::InsecureNone as i32,
network_access: Some(true),
..Default::default()
})
} else if perms.contains(&"full_network") {
Some(pb::SandboxPolicy {
r#type: pb::sandbox_policy::Type::WorkspaceReadwrite as i32,
network_access: Some(true),
..Default::default()
})
} else {
None
}
}
fn shell_timeout(call: &ToolCall) -> Result<i32> {
let value = call
.arguments
+259 -14
View File
@@ -119,17 +119,98 @@ fn delete(value: &pb::DeleteResult) -> Result<(String, bool)> {
fn grep(value: &pb::GrepResult) -> Result<(String, bool)> {
use pb::grep_result::Result as R;
match value.result.as_ref().ok_or_else(|| missing("grep"))? {
R::Success(value) => Ok((
format!(
"grep success pattern={} mode={}",
value.pattern, value.output_mode
),
false,
)),
R::Success(value) => Ok((grep_success(value), false)),
R::Error(value) => Ok((value.error.clone(), true)),
}
}
fn grep_success(value: &pb::GrepSuccess) -> String {
let mut lines = Vec::new();
if let Some(result) = &value.active_editor_result {
grep_union(result, &mut lines);
}
let mut workspaces = value.workspace_results.iter().collect::<Vec<_>>();
workspaces.sort_unstable_by_key(|(name, _)| *name);
for (_, result) in workspaces {
grep_union(result, &mut lines);
}
if lines.is_empty() {
format!(
"No matches found for pattern `{}` in {}",
value.pattern, value.path
)
} else {
lines.join("\n")
}
}
fn grep_union(value: &pb::GrepUnionResult, lines: &mut Vec<String>) {
use pb::grep_union_result::Result as R;
match value.result.as_ref() {
Some(R::Files(value)) => {
lines.extend(value.files.iter().cloned());
grep_truncation(
value.client_truncated,
value.ripgrep_truncated,
value.total_files,
"files",
lines,
);
}
Some(R::Count(value)) => {
lines.extend(
value
.counts
.iter()
.map(|count| format!("{}:{}", count.file, count.count)),
);
grep_truncation(
value.client_truncated,
value.ripgrep_truncated,
value.total_matches,
"matches",
lines,
);
}
Some(R::Content(value)) => {
for file in &value.matches {
lines.extend(file.matches.iter().map(|matched| {
let separator = if matched.is_context_line { '-' } else { ':' };
let truncated = if matched.content_truncated {
" [line truncated]"
} else {
""
};
format!(
"{}{separator}{}{separator}{}{truncated}",
file.file, matched.line_number, matched.content
)
}));
}
grep_truncation(
value.client_truncated,
value.ripgrep_truncated,
value.total_matched_lines,
"matched lines",
lines,
);
}
None => {}
}
}
fn grep_truncation(
client_truncated: bool,
ripgrep_truncated: bool,
total: i32,
unit: &str,
lines: &mut Vec<String>,
) {
if client_truncated || ripgrep_truncated {
lines.push(format!("[Results truncated; {total} total {unit}]"));
}
}
fn diagnostics(value: &pb::DiagnosticsResult) -> Result<(String, bool)> {
use pb::diagnostics_result::Result as R;
match value
@@ -137,13 +218,7 @@ fn diagnostics(value: &pb::DiagnosticsResult) -> Result<(String, bool)> {
.as_ref()
.ok_or_else(|| missing("diagnostics"))?
{
R::Success(value) => Ok((
format!(
"diagnostics path={} count={}",
value.path, value.total_diagnostics
),
false,
)),
R::Success(value) => Ok((diagnostics_success(value), false)),
R::Error(value) => Ok((value.error.clone(), true)),
R::Rejected(value) => Ok((value.reason.clone(), true)),
R::FileNotFound(value) => Ok((format!("file not found: {}", value.path), true)),
@@ -151,6 +226,76 @@ fn diagnostics(value: &pb::DiagnosticsResult) -> Result<(String, bool)> {
}
}
fn diagnostics_success(value: &pb::DiagnosticsSuccess) -> String {
if value.diagnostics.is_empty() {
return format!("No diagnostics found in {}", value.path);
}
let mut lines = value
.diagnostics
.iter()
.map(|diagnostic| {
let location = diagnostic_location(&value.path, diagnostic.range.as_ref());
let mut labels = vec![diagnostic_severity(diagnostic.severity)];
if !diagnostic.source.is_empty() {
labels.push(diagnostic.source.as_str());
}
if !diagnostic.code.is_empty() {
labels.push(diagnostic.code.as_str());
}
if diagnostic.is_stale {
labels.push("stale");
}
format!(
"{}: [{}] {}",
location,
labels.join(" "),
diagnostic.message
)
})
.collect::<Vec<_>>();
if value.total_diagnostics != value.diagnostics.len() as i32 {
lines.push(format!(
"[Reported {} diagnostics; received {} details]",
value.total_diagnostics,
value.diagnostics.len()
));
}
lines.join("\n")
}
fn diagnostic_location(path: &str, range: Option<&pb::Range>) -> String {
let Some(range) = range else {
return path.into();
};
let Some(start) = &range.start else {
return path.into();
};
let mut location = format!(
"{}:{}:{}",
path,
start.line.saturating_add(1),
start.column.saturating_add(1)
);
if let Some(end) = &range.end {
location.push_str(&format!(
"-{}:{}",
end.line.saturating_add(1),
end.column.saturating_add(1)
));
}
location
}
fn diagnostic_severity(value: i32) -> &'static str {
match pb::DiagnosticSeverity::try_from(value) {
Ok(pb::DiagnosticSeverity::Error) => "error",
Ok(pb::DiagnosticSeverity::Warning) => "warning",
Ok(pb::DiagnosticSeverity::Information) => "information",
Ok(pb::DiagnosticSeverity::Hint) => "hint",
Ok(pb::DiagnosticSeverity::Unspecified) | Err(_) => "diagnostic",
}
}
fn mcp(value: &pb::McpResult) -> Result<(String, bool)> {
use pb::mcp_result::Result as R;
match value.result.as_ref().ok_or_else(|| missing("mcp"))? {
@@ -264,6 +409,106 @@ fn creates_subagent(call: &ToolCall) -> bool {
)
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
#[test]
fn grep_output_contains_file_and_match_details() {
let value = pb::GrepResult {
result: Some(pb::grep_result::Result::Success(pb::GrepSuccess {
pattern: "Cursor".into(),
path: "/workspace".into(),
output_mode: "content".into(),
workspace_results: HashMap::from([
(
"workspace-b".into(),
pb::GrepUnionResult {
result: Some(pb::grep_union_result::Result::Files(
pb::GrepFilesResult {
files: vec!["/workspace/Cargo.toml".into()],
total_files: 1,
..Default::default()
},
)),
},
),
(
"workspace-a".into(),
pb::GrepUnionResult {
result: Some(pb::grep_union_result::Result::Content(
pb::GrepContentResult {
matches: vec![pb::GrepFileMatch {
file: "/workspace/README.md".into(),
matches: vec![pb::GrepContentMatch {
line_number: 7,
content: "Cursor BYOK".into(),
..Default::default()
}],
}],
total_lines: 1,
total_matched_lines: 1,
..Default::default()
},
)),
},
),
]),
active_editor_result: None,
})),
};
let (content, is_error) = grep(&value).unwrap();
assert!(!is_error);
assert!(content.contains("/workspace/README.md:7:Cursor BYOK"));
assert!(content.contains("/workspace/Cargo.toml"));
assert!(
content.find("/workspace/README.md").unwrap()
< content.find("/workspace/Cargo.toml").unwrap(),
"workspace map output must be deterministic"
);
}
#[test]
fn diagnostics_output_contains_each_diagnostic_detail() {
let value = pb::DiagnosticsResult {
result: Some(pb::diagnostics_result::Result::Success(
pb::DiagnosticsSuccess {
path: "/workspace/src/main.rs".into(),
diagnostics: vec![pb::Diagnostic {
severity: pb::DiagnosticSeverity::Error as i32,
range: Some(pb::Range {
start: Some(pb::Position { line: 4, column: 8 }),
end: Some(pb::Position {
line: 4,
column: 12,
}),
}),
message: "cannot find value `name`".into(),
source: "rustc".into(),
code: "E0425".into(),
is_stale: false,
}],
total_diagnostics: 1,
},
)),
};
let (content, is_error) = diagnostics(&value).unwrap();
assert!(!is_error);
assert!(content.contains("/workspace/src/main.rs:5:9"));
assert!(content.contains("-5:13"));
assert!(content.contains("error"));
assert!(content.contains("rustc"));
assert!(content.contains("E0425"));
assert!(content.contains("cannot find value `name`"));
}
}
fn missing(name: &str) -> Error {
Error::Protocol(format!("{name} returned no result"))
}
+2
View File
@@ -9,6 +9,7 @@ mod projection;
mod provider;
mod run;
mod runtime_tag;
mod token_count;
mod tool;
mod usage;
@@ -23,5 +24,6 @@ pub use projection::*;
pub use provider::*;
pub use run::*;
pub use runtime_tag::*;
pub(crate) use token_count::*;
pub use tool::*;
pub use usage::*;
+39
View File
@@ -0,0 +1,39 @@
pub(crate) fn parse_token_count(value: &str) -> Option<u64> {
let value = value.trim().to_ascii_lowercase();
let (number, multiplier) = match value.chars().last()? {
'k' => (&value[..value.len() - 1], 1_000),
'm' => (&value[..value.len() - 1], 1_000_000),
_ => (value.as_str(), 1),
};
number.parse::<u64>().ok()?.checked_mul(multiplier)
}
pub(crate) fn format_token_count(tokens: u64) -> String {
if tokens >= 1_000_000 && tokens.is_multiple_of(1_000_000) {
format!("{}M", tokens / 1_000_000)
} else if tokens >= 1_000 && tokens.is_multiple_of(1_000) {
format!("{}K", tokens / 1_000)
} else {
tokens.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_counts_parse_plain_and_abbreviated_values() {
assert_eq!(parse_token_count("272000"), Some(272_000));
assert_eq!(parse_token_count("272K"), Some(272_000));
assert_eq!(parse_token_count("1m"), Some(1_000_000));
assert_eq!(parse_token_count("invalid"), None);
}
#[test]
fn token_counts_format_exact_thousands_and_millions() {
assert_eq!(format_token_count(272_000), "272K");
assert_eq!(format_token_count(1_000_000), "1M");
assert_eq!(format_token_count(272_001), "272001");
}
}
+23 -10
View File
@@ -119,7 +119,6 @@ impl Provider for OpenAiResponsesProvider {
let mut reasoning_items = Vec::new();
let mut saw_tool = false;
let mut saw_completed_item = false;
let mut saw_done_marker = false;
let mut terminal = false;
loop {
let event = tokio::select! {
@@ -128,7 +127,7 @@ impl Provider for OpenAiResponsesProvider {
};
let Some(event) = event else { break };
let event = event.map_err(|error| Error::Provider(format!("OpenAI Responses SSE: {error}")))?;
if event.data == "[DONE]" { saw_done_marker = true; break; }
if event.data == "[DONE]" { break; }
let value: Value = serde_json::from_str(&event.data)?;
let kind = value.get("type").and_then(Value::as_str).unwrap_or(&event.event);
match kind {
@@ -163,19 +162,20 @@ impl Provider for OpenAiResponsesProvider {
}
"response.output_item.done" => {
let item = value.get("item").unwrap_or(&Value::Null);
saw_completed_item = true;
match item.get("type").and_then(Value::as_str) {
Some("reasoning") => {
if thinking_open { thinking_open = false; yield ModelEvent::ThinkingEnd; }
reasoning_items.push(item.clone());
}
Some("message") => {
saw_completed_item = true;
if let Some(final_text) = response_item_text(item) {
for event in reconcile_response_text(&mut text_open, &mut text, &final_text) { yield event; }
}
if text_open { text_open = false; yield ModelEvent::TextEnd; }
}
Some("function_call") => {
saw_completed_item = true;
let index = required_u64(&value, "output_index")? as usize;
saw_tool = true;
let arguments = item
@@ -196,11 +196,24 @@ impl Provider for OpenAiResponsesProvider {
}
"response.function_call_arguments.done" => {
let index = required_u64(&value, "output_index")? as usize;
let arguments = value
.get("arguments")
.and_then(Value::as_str)
.map_or(ResponseToolArguments::None, ResponseToolArguments::Snapshot);
for event in update_response_tool(index, &Value::Null, arguments, true, &mut tools)? { yield event; }
match value.get("arguments").and_then(Value::as_str) {
Some("") => {
for event in update_response_tool(
index,
&Value::Null,
ResponseToolArguments::None,
false,
&mut tools,
)? { yield event; }
}
arguments => {
let arguments = arguments.map_or(
ResponseToolArguments::None,
ResponseToolArguments::Snapshot,
);
for event in update_response_tool(index, &Value::Null, arguments, true, &mut tools)? { yield event; }
}
}
}
"response.completed" => {
if let Some(usage) = value.pointer("/response/usage") { yield ModelEvent::Usage(responses_usage(usage)); }
@@ -238,11 +251,11 @@ impl Provider for OpenAiResponsesProvider {
_ => {}
}
}
if !terminal && saw_done_marker && saw_completed_item {
if !terminal && saw_completed_item {
if thinking_open { yield ModelEvent::ThinkingEnd; }
if text_open { yield ModelEvent::TextEnd; }
if tools.values().any(|tool| !tool.ended) {
Err(Error::Provider("OpenAI Responses [DONE] arrived with an incomplete tool call".into()))?;
Err(Error::Provider("OpenAI Responses stream ended with an incomplete tool call".into()))?;
}
terminal = true;
if !reasoning_items.is_empty() {
+34
View File
@@ -485,6 +485,40 @@ async fn openai_responses_preserves_delta_that_repeats_the_streamed_suffix() {
assert_eq!(result.unwrap().calls[0].arguments["block_until_ms"], 30000);
}
#[tokio::test]
async fn openai_responses_accepts_empty_arguments_done_and_eof_after_completed_tool() {
let arguments =
r#"{"merge":false,"todos":[{"id":"first","content":"First","status":"pending"}]}"#;
let stream = format!(
concat!(
"data: {{\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"TodoWrite\"}}}}\n\n",
"data: {{\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":{0:?}}}\n\n",
"data: {{\"type\":\"response.function_call_arguments.done\",\"output_index\":0,\"arguments\":\"\"}}\n\n",
"data: {{\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"TodoWrite\",\"arguments\":{0:?}}}}}\n\n",
),
arguments,
);
let stream = Box::leak(stream.into_boxed_str());
let (base_url, _requests, server) = fixture_server("/v1/responses", stream).await;
let provider = OpenAiResponsesProvider::new(
reqwest::Client::new(),
config(ProviderKind::OpenAiResponses, base_url, None),
);
let (sender, _receiver) = tokio::sync::mpsc::channel(32);
let cycle = consume_model_cycle(
provider.stream(invocation(), CancellationToken::new()),
&sender,
&CancellationToken::new(),
)
.await
.unwrap();
server.abort();
assert_eq!(cycle.calls[0].name, "TodoWrite");
assert_eq!(cycle.calls[0].arguments["todos"][0]["content"], "First");
}
#[tokio::test]
async fn openai_responses_completed_snapshot_does_not_reindex_streamed_tool() {
let (base_url, _requests, server) = fixture_server(