diff --git a/server/src/cursor/tools/codec/mod.rs b/server/src/cursor/tools/codec/mod.rs index be3f020..3fde01c 100644 --- a/server/src/cursor/tools/codec/mod.rs +++ b/server/src/cursor/tools/codec/mod.rs @@ -12,7 +12,7 @@ use crate::{ }; pub use query::tool_query; -pub(crate) use render::{create_plan_partial, edit_content_delta, edit_path_partial}; +pub(crate) use render::{create_plan_partial, edit_content_delta, edit_path_partial, task_partial}; pub use render::{dynamic_mcp_placeholder, render_dynamic_mcp, tool_completed}; pub use request::{abort, mcp_request, mcp_state_request, request}; pub(crate) use request::{edit_read_request, json_object_to_prost, mcp_meta_request}; diff --git a/server/src/cursor/tools/codec/render.rs b/server/src/cursor/tools/codec/render.rs index 7cc242f..06f3857 100644 --- a/server/src/cursor/tools/codec/render.rs +++ b/server/src/cursor/tools/codec/render.rs @@ -54,6 +54,49 @@ pub(crate) fn edit_content_delta(call: &ToolCall, content: String) -> pb::AgentS ))) } +pub(crate) fn task_partial( + call: &ToolCall, + description: &str, + prompt: &str, + subagent: &str, + model: &str, + resume: &str, + environment: &str, +) -> pb::AgentServerMessage { + server_interaction(pb::interaction_update::Message::PartialToolCall( + pb::PartialToolCallUpdate { + call_id: call.call_id.clone(), + tool_call: Some(pb::ToolCall { + hook_additional_contexts: Vec::new(), + tool_call_id: Some(call.call_id.clone()), + started_at_ms: None, + completed_at_ms: None, + tool: Some(pb::tool_call::Tool::TaskToolCall(pb::TaskToolCall { + args: Some(pb::TaskArgs { + description: description.into(), + prompt: prompt.into(), + subagent_type: Some(subagent_type(subagent)), + model: (!model.is_empty()).then(|| model.into()), + resume: (!resume.is_empty()).then(|| resume.into()), + agent_id: None, + attachments: Vec::new(), + mode: 0, + responding_to_message_ids: Vec::new(), + environment: execution_environment( + (!environment.is_empty()).then_some(environment), + ), + machine: None, + }), + result: None, + ..Default::default() + })), + }), + args_text_delta: String::new(), + model_call_id: call.model_call_id.clone(), + }, + )) +} + pub(crate) fn create_plan_partial( call: &ToolCall, name: &str, diff --git a/server/src/cursor/tools/stream.rs b/server/src/cursor/tools/stream.rs index 78a97a9..831ddfa 100644 --- a/server/src/cursor/tools/stream.rs +++ b/server/src/cursor/tools/stream.rs @@ -20,6 +20,7 @@ enum Presentation { DynamicMcp(pb::McpToolDefinition), Edit(EditProjection), CreatePlan(CreatePlanProjection), + Task(TaskProjection), } struct EditProjection { @@ -38,6 +39,17 @@ struct CreatePlanProjection { overview: String, } +#[derive(Default)] +struct TaskProjection { + fields: JsonStringFields, + description: String, + prompt: String, + subagent_type: String, + model: String, + resume: String, + environment: String, +} + impl ToolCallStream { pub fn new(name: &str, dynamic_mcp: Option<&pb::McpToolDefinition>) -> Self { let presentation = match dynamic_mcp { @@ -49,6 +61,7 @@ impl ToolCallStream { Presentation::Edit(EditProjection::new("target_notebook", "new_string")) } "createplan" => Presentation::CreatePlan(CreatePlanProjection::default()), + "task" => Presentation::Task(TaskProjection::default()), _ => Presentation::Plain, }, }; @@ -73,10 +86,48 @@ impl ToolCallStream { Ok(messages) } Presentation::CreatePlan(plan) => plan.project(call, raw_delta), + Presentation::Task(task) => task.project(call, raw_delta), } } } +impl TaskProjection { + fn project(&mut self, call: &ToolCall, raw_delta: &str) -> Result> { + let mut description_completed = false; + for event in self.fields.push(raw_delta)? { + match event { + StringFieldEvent::Delta { name, text } => match name.as_str() { + "description" => self.description.push_str(&text), + "prompt" => self.prompt.push_str(&text), + "subagent_type" => self.subagent_type.push_str(&text), + "model" => self.model.push_str(&text), + "resume" => self.resume.push_str(&text), + "environment" => self.environment.push_str(&text), + _ => {} + }, + StringFieldEvent::End { name } if name == "description" => { + description_completed = true + } + _ => {} + } + } + Ok(description_completed + .then(|| { + interaction::task_partial( + call, + &self.description, + &self.prompt, + &self.subagent_type, + &self.model, + &self.resume, + &self.environment, + ) + }) + .into_iter() + .collect()) + } +} + impl CreatePlanProjection { fn project(&mut self, call: &ToolCall, raw_delta: &str) -> Result> { let mut completed_field = false; @@ -184,3 +235,53 @@ fn normalized(value: &str) -> String { .flat_map(char::to_lowercase) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + + fn task_call(arguments_text: &str) -> ToolCall { + ToolCall { + index: 0, + call_id: "task-1".into(), + model_call_id: "model-1".into(), + name: "Task".into(), + arguments_text: arguments_text.into(), + arguments: serde_json::Value::Null, + } + } + + #[test] + fn task_description_projects_a_visible_partial_card_before_execution() { + let mut stream = ToolCallStream::new("Task", None); + let first = r#"{"description":"Review K10"#; + assert!(stream + .arguments_delta(&task_call(first), first) + .unwrap() + .is_empty()); + + let closing = "\","; + let messages = stream + .arguments_delta(&task_call(&format!("{first}{closing}")), closing) + .unwrap(); + assert_eq!(messages.len(), 1); + let Some(pb::agent_server_message::Message::InteractionUpdate(update)) = + messages[0].message.as_ref() + else { + panic!("expected interaction update") + }; + let Some(pb::interaction_update::Message::PartialToolCall(partial)) = + update.message.as_ref() + else { + panic!("expected partial tool call") + }; + let tool_call = partial.tool_call.as_ref().expect("expected tool call"); + assert_eq!(tool_call.started_at_ms, None); + let Some(pb::tool_call::Tool::TaskToolCall(task)) = tool_call.tool.as_ref() else { + panic!("expected Task tool call") + }; + let args = task.args.as_ref().expect("expected partial Task args"); + assert_eq!(args.description, "Review K10"); + assert_eq!(args.prompt, ""); + } +}