Merge branch 'main' of github.com:leookun/cursor-byok

This commit is contained in:
leokun
2026-08-31 16:15:58 +08:00
6 changed files with 245 additions and 18 deletions
+22 -15
View File
@@ -431,21 +431,28 @@ impl ConversationOutput {
streams.clear();
}
if let CommitCause::RuntimeEvent { event_id } = &state.cause {
if let Some(injection_id) = event_id.strip_prefix("inject-context:") {
if let Some(pending) = self.pending_injections.remove(injection_id)
{
let delivered_at_ms = crate::cursor::tools::runtime::now_ms()
.min(i64::MAX as u64)
as i64;
self.handle.emit(&events::context_injection_delivered(
injection_id.to_owned(),
pending.delivery_batch_id.clone(),
delivered_at_ms,
))?;
if let Some(user_message) = pending.user_message {
self.handle
.emit(&events::user_message_appended(user_message))?;
}
// Injections key `pending_injections` by their raw
// injection id and commit under `inject-context:{id}`,
// while runtime user messages key it by (and commit
// under) the full `user-message:{id}` event id. Strip
// the injection prefix when present and otherwise use
// the event id verbatim so both are cleared and emit
// their delivered/appended events.
let injection_id = event_id
.strip_prefix("inject-context:")
.unwrap_or(event_id.as_str());
if let Some(pending) = self.pending_injections.remove(injection_id) {
let delivered_at_ms = crate::cursor::tools::runtime::now_ms()
.min(i64::MAX as u64)
as i64;
self.handle.emit(&events::context_injection_delivered(
injection_id.to_owned(),
pending.delivery_batch_id.clone(),
delivered_at_ms,
))?;
if let Some(user_message) = pending.user_message {
self.handle
.emit(&events::user_message_appended(user_message))?;
}
}
}
+21 -1
View File
@@ -210,7 +210,7 @@ pub fn tool_completed(call: &ToolCall, completion: &ToolCompletion) -> pb::Agent
pub fn tool_placeholder(name: &str, call_id: &str) -> Result<pb::ToolCall> {
use pb::tool_call::Tool;
let tool = match normalized(name).as_str() {
"shell" => Tool::ShellToolCall(pb::ShellToolCall::default()),
"shell" | "bash" => Tool::ShellToolCall(pb::ShellToolCall::default()),
"delete" => Tool::DeleteToolCall(pb::DeleteToolCall::default()),
"glob" => Tool::GlobToolCall(pb::GlobToolCall::default()),
"grep" => Tool::GrepToolCall(pb::GrepToolCall::default()),
@@ -570,3 +570,23 @@ fn now_ms() -> u64 {
.unwrap_or_default()
.as_millis() as u64
}
#[cfg(test)]
mod tests {
use super::tool_placeholder;
use crate::cursor::protocol::proto::agent::v1 as pb;
#[test]
fn bash_renders_as_a_shell_placeholder() {
// The dispatcher treats `bash`/`Bash` as a Shell alias, so the streaming
// placeholder must too; otherwise a `Bash` tool call aborts the turn with
// `unsupported tool: bash` before it ever runs.
for name in ["shell", "Shell", "bash", "Bash"] {
let tool = tool_placeholder(name, "call-1").unwrap().tool;
assert!(
matches!(tool, Some(pb::tool_call::Tool::ShellToolCall(_))),
"{name} should render as a Shell tool"
);
}
}
}
+35 -1
View File
@@ -35,7 +35,7 @@ pub fn request(id: u32, call: &ToolCall, context: &ExecContext) -> Result<pb::Ag
.map(|v| v as i32)
};
let message = match normalize(&call.name).as_str() {
"shell" => {
"shell" | "bash" => {
let command = string("command")?;
let (simple_commands, parsing_result) = shell_command_metadata(&command);
Message::ShellStreamArgs(pb::ShellArgs {
@@ -520,3 +520,37 @@ fn prost_value(value: &Value) -> prost_types::Value {
};
ProstValue { kind: Some(kind) }
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::request;
use crate::cursor::protocol::proto::agent::v1 as pb;
use crate::cursor::tools::runtime::ExecContext;
use crate::model::ToolCall;
#[test]
fn bash_is_encoded_as_a_shell_exec_request() {
// The dispatcher routes `bash`/`Bash` to the shell executor, so the
// request codec must encode it as a Shell stream instead of erroring
// with `tool bash is not executed through ExecServerMessage`.
let call = ToolCall {
index: 0,
call_id: "call-1".into(),
model_call_id: "model-1".into(),
name: "Bash".into(),
arguments_text: String::new(),
arguments: json!({ "command": "ls -la" }),
};
let message = request(1, &call, &ExecContext::default()).unwrap();
let Some(pb::agent_server_message::Message::ExecServerMessage(exec)) = message.message
else {
panic!("expected an ExecServerMessage");
};
let Some(pb::exec_server_message::Message::ShellStreamArgs(args)) = exec.message else {
panic!("expected ShellStreamArgs");
};
assert_eq!(args.command, "ls -la");
}
}
+2 -1
View File
@@ -144,7 +144,8 @@ pub async fn stream_closed(id: u32, pending: &CursorToolRuntime) -> Result<Optio
return Ok(None);
};
let error = "Cursor Exec stream closed before returning a terminal result";
if entry.call.name.eq_ignore_ascii_case("Shell") {
if entry.call.name.eq_ignore_ascii_case("Shell") || entry.call.name.eq_ignore_ascii_case("Bash")
{
let command = entry
.call
.arguments
+49
View File
@@ -183,6 +183,9 @@ fn edit_notebook(call: &ToolCall, before: &str) -> std::result::Result<String, S
.unwrap_or_default();
let old =
normalize_newlines(&string(call, "old_string").map_err(|error| error.to_string())?);
if old.is_empty() {
return Err("old_string must not be empty".into());
}
let occurrences = source.match_indices(&old).count();
let edited = match occurrences {
0 => return Err("old_string was not found in the notebook cell".into()),
@@ -241,3 +244,49 @@ fn normalized(value: &str) -> String {
.flat_map(char::to_lowercase)
.collect()
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::edit_notebook;
use crate::model::ToolCall;
fn notebook_call(old_string: &str) -> ToolCall {
ToolCall {
index: 0,
call_id: "call".into(),
model_call_id: "model".into(),
name: "EditNotebook".into(),
arguments_text: String::new(),
arguments: json!({
"target_notebook": "/notebook.ipynb",
"cell_idx": 0,
"old_string": old_string,
"new_string": "replacement",
}),
}
}
fn single_cell_notebook() -> String {
json!({
"cells": [{"cell_type": "code", "source": ["print('hi')\n"]}],
})
.to_string()
}
#[test]
fn edit_notebook_rejects_empty_old_string() {
// StrReplace rejects an empty old_string; EditNotebook must do the same
// instead of prepending new_string (empty cell) or reporting a
// misleading "not unique" error (non-empty cell).
let error = edit_notebook(&notebook_call(""), &single_cell_notebook()).unwrap_err();
assert_eq!(error, "old_string must not be empty");
}
#[test]
fn edit_notebook_replaces_a_unique_old_string() {
let edited = edit_notebook(&notebook_call("hi"), &single_cell_notebook()).unwrap();
assert!(edited.contains("print('replacement')"));
}
}