Compare commits

...
Author SHA1 Message Date
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
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
10 changed files with 475 additions and 45 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",
+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,
}
}
}
+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 {
+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"))
}
+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(