#[path = "support/fake_provider.rs"] mod fake_provider; #[path = "support/fixtures.rs"] mod fixtures; use std::{collections::HashMap, sync::Arc}; use cursor_server::{ cursor::{ bidi_append::{self, DecodedAppend}, connect, prompting::{PromptAssets, PromptCompiler}, proto::agent::v1 as pb, CursorCommand, CursorSessionHandle, CursorSessionRegistry, }, model::{ContentPart, MessageContent, ProjectedContent, Role}, provider::{FinishReason, ModelEvent}, }; use prost::Message; const FOLLOW_UP: &str = "Perform any necessary follow-up actions in response to the subagent completion above. If no follow-up work is needed, no further action is required. If you mention an agent or subagent in your response, link it with the `[Name](id)` Don't use generic label such as `[agent]`, `[worker]`, or `[subagent]`."; const SHELL_FOLLOW_UP: &str = "Briefly inform the user about the task result and perform any follow-up actions (if needed). If there's no follow-ups needed, don't explicitly say that."; #[tokio::test] async fn cancelled_conversation_drops_late_background_completion() { let (_directory, store) = fixtures::temp_store().await; let provider = fake_provider::FakeProvider::default(); 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 active = registry.get_or_create("cancelled-request").await.unwrap(); active.set_conversation_id("parent-conversation").unwrap(); active.mark_conversation_cancelled(); bidi_append::append( ®istry, DecodedAppend { request_id: "late-completion".into(), seqno: 1, message: completion_run( "child-id", "cancelled-parent-run", pb::ConversationStateStructure::default(), ), }, None, ) .await .unwrap(); assert!(provider.requests().is_empty()); } #[tokio::test] async fn background_subagent_completion_starts_a_simulated_parent_turn() { let (_directory, store) = fixtures::temp_store().await; let provider = fake_provider::FakeProvider::default(); provider.push(stop_response("model-call", "followed up")); let assets = PromptAssets::load( std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("prompt/cursor") .as_path(), ) .unwrap(); let registry = CursorSessionRegistry::new( store.clone(), Arc::new(provider.clone()), PromptCompiler::new(assets), Default::default(), ); let handle = registry.get_or_create("completion-request").await.unwrap(); let (checkpoint, blobs) = drive_completion( &handle, completion_run( "child-id", "reusable-parent-run", pb::ConversationStateStructure { mode: Some(pb::AgentMode::Multitask as i32), ..Default::default() }, ), ) .await; let requests = provider.requests(); assert_eq!(requests.len(), 1); let [runtime] = requests[0].history.as_slice() else { panic!("completion Run must add exactly one runtime message") }; assert_eq!(runtime.role, Role::User); let ProjectedContent::Parts(parts) = &runtime.content else { panic!("completion context must be text") }; let [ContentPart::Text { text }] = parts.as_slice() else { panic!("completion context must have one text part") }; assert!(text.contains("kind: subagent")); assert!(text.contains("agent_id: child-id")); assert!(text.contains("child result")); assert!(text.contains(FOLLOW_UP)); let messages = store .load_current_messages(&cursor_server::model::ConversationId::new( "parent-conversation", )) .await .unwrap(); assert!(messages.iter().any(|message| { message.runtime_event_id.as_deref() == Some("background-completed:BACKGROUND_TASK_KIND_SUBAGENT:child-id:task-call") && matches!(&message.content, MessageContent::Parts { parts } if !parts.is_empty()) })); let turn = pb::ConversationTurnStructure::decode( blobs .get(checkpoint.turns.last().expect("completion Turn")) .expect("completion Turn Blob") .as_slice(), ) .unwrap(); let pb::conversation_turn_structure::Turn::AgentConversationTurn(turn) = turn.turn.unwrap() else { panic!("expected agent conversation Turn") }; let user = pb::UserMessage::decode( blobs .get(&turn.user_message) .expect("simulated UserMessage Blob") .as_slice(), ) .unwrap(); assert!(user.text.contains(FOLLOW_UP)); assert_eq!(user.is_simulated_msg, Some(true)); assert_eq!( user.simulated_msg_reason, Some(pb::SimulatedMsgReason::BackgroundTaskCompletion as i32) ); assert_eq!( user.simulated_message_metadata.unwrap().task_id.as_deref(), Some("child-id") ); provider.push(stop_response("model-call-2", "followed up again")); let second = registry .get_or_create("completion-request-2") .await .unwrap(); drive_completion( &second, completion_run("child-id-2", "reusable-parent-run-2", checkpoint), ) .await; let requests = provider.requests(); assert_eq!(requests.len(), 2); let runtime_ids = requests[1] .history .iter() .map(|message| message.message_id.as_str()) .filter(|id| id.starts_with("runtime:")) .collect::>(); assert_eq!( runtime_ids, [ "runtime:background-completed:BACKGROUND_TASK_KIND_SUBAGENT:child-id:task-call", "runtime:background-completed:BACKGROUND_TASK_KIND_SUBAGENT:child-id-2:task-call" ] ); } #[tokio::test] async fn background_completion_joins_the_active_run_instead_of_replacing_it() { let (_directory, store) = fixtures::temp_store().await; let provider = fake_provider::FakeProvider::default(); let first_ready = provider.push_gated(stop_response("model-call-1", "first response")); provider.push(stop_response("model-call-2", "processed both completions")); let assets = PromptAssets::load( std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("prompt/cursor") .as_path(), ) .unwrap(); let registry = CursorSessionRegistry::new( store.clone(), Arc::new(provider.clone()), PromptCompiler::new(assets), Default::default(), ); let first = registry.get_or_create("active-completion-1").await.unwrap(); let first_run = tokio::spawn(async move { drive_completion( &first, completion_run( "child-1", "parent-run-1", pb::ConversationStateStructure::default(), ), ) .await }); while provider.requests().is_empty() { tokio::task::yield_now().await; } let second = registry.get_or_create("active-completion-2").await.unwrap(); let second_run = tokio::spawn(async move { drive_forwarded_completion( &second, completion_run( "child-2", "parent-run-2", pb::ConversationStateStructure::default(), ), ) .await }); tokio::time::sleep(std::time::Duration::from_millis(50)).await; first_ready.notify_one(); second_run.await.unwrap(); first_run.await.unwrap(); let requests = provider.requests(); assert_eq!(requests.len(), 2); let history = serde_json::to_string(&requests[1].history).unwrap(); assert!(history.contains("child-1")); assert!(history.contains("first response")); assert!(history.contains("child-2")); let statuses: Vec = sqlx::query_scalar( "SELECT status FROM runs WHERE conversation_id = 'parent-conversation' ORDER BY created_at_ms", ) .fetch_all(store.pool()) .await .unwrap(); assert_eq!(statuses, ["completed"]); } #[tokio::test] async fn retrying_one_background_completion_reuses_its_runtime_message() { let (_directory, store) = fixtures::temp_store().await; let provider = fake_provider::FakeProvider::default(); provider.push(stop_response("model-call", "followed up")); let assets = PromptAssets::load( std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("prompt/cursor") .as_path(), ) .unwrap(); let registry = CursorSessionRegistry::new( store.clone(), Arc::new(provider.clone()), PromptCompiler::new(assets), Default::default(), ); let first = registry.get_or_create("completion-retry-1").await.unwrap(); let (checkpoint, _) = drive_completion( &first, completion_run( "retry-child", "completion-retry-run-1", pb::ConversationStateStructure { mode: Some(pb::AgentMode::Multitask as i32), ..Default::default() }, ), ) .await; provider.push(stop_response("model-call-2", "followed up again")); let second = registry.get_or_create("completion-retry-2").await.unwrap(); drive_completion( &second, completion_run("retry-child", "completion-retry-run-2", checkpoint), ) .await; let messages = store .load_current_messages(&cursor_server::model::ConversationId::new( "parent-conversation", )) .await .unwrap(); assert_eq!( messages .iter() .filter(|message| { message.runtime_event_id.as_deref() == Some( "background-completed:BACKGROUND_TASK_KIND_SUBAGENT:retry-child:task-call", ) }) .count(), 1 ); } #[tokio::test] async fn background_shell_completion_wakes_the_parent_with_the_captured_notification() { let (_directory, store) = fixtures::temp_store().await; let provider = fake_provider::FakeProvider::default(); provider.push(stop_response( "shell-wakeup", "The background server was stopped.", )); 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("shell-completion-request") .await .unwrap(); let (checkpoint, blobs) = drive_completion( &handle, shell_completion_run(pb::ConversationStateStructure { mode: Some(pb::AgentMode::Agent as i32), ..Default::default() }), ) .await; let requests = provider.requests(); let [runtime] = requests[0].history.as_slice() else { panic!("Shell completion Run must add exactly one runtime message") }; let ProjectedContent::Parts(parts) = &runtime.content else { panic!("Shell completion context must be text") }; let [ContentPart::Text { text }] = parts.as_slice() else { panic!("Shell completion context must have one text part") }; assert!(text.contains("")); assert!(text.contains("kind: shell")); assert!(text.contains("status: aborted")); assert!(text.contains("task_id: 977679")); assert!(text.contains("detail: terminated_by_user")); assert!(text.contains("output_path: /tmp/977679.txt")); assert!(text.contains(SHELL_FOLLOW_UP)); assert!(text.starts_with("")); assert!(!text.contains("You are still in **Agent Mode**")); assert!(text.find("").unwrap() < text.find("").unwrap()); let turn = pb::ConversationTurnStructure::decode( blobs .get(checkpoint.turns.last().expect("Shell completion Turn")) .expect("Shell completion Turn Blob") .as_slice(), ) .unwrap(); let pb::conversation_turn_structure::Turn::AgentConversationTurn(turn) = turn.turn.unwrap() else { panic!("expected agent conversation Turn") }; let user = pb::UserMessage::decode( blobs .get(&turn.user_message) .expect("simulated Shell UserMessage Blob") .as_slice(), ) .unwrap(); assert_eq!(user.text, *text); assert_eq!(user.is_simulated_msg, Some(true)); assert_eq!( user.simulated_msg_reason, Some(pb::SimulatedMsgReason::BackgroundTaskCompletion as i32) ); let metadata = user.simulated_message_metadata.unwrap(); assert_eq!( metadata.title.as_deref(), Some("Start Python HTTP server on 9000") ); assert_eq!(metadata.task_id.as_deref(), Some("977679")); } async fn drive_completion( handle: &CursorSessionHandle, message: pb::AgentClientMessage, ) -> (pb::ConversationStateStructure, HashMap, Vec>) { let mut output = handle.subscribe(); handle .command(CursorCommand::Append { seqno: 0, message: Box::new(message), }) .await .unwrap(); let mut append_seqno = 1; let mut blobs = HashMap::new(); let mut final_checkpoint = None; loop { let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv()) .await .unwrap() .unwrap(); let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap(); if flags & connect::END_STREAM_FLAG != 0 { break; } let server = pb::AgentServerMessage::decode(payload).unwrap(); match server.message { Some(pb::agent_server_message::Message::ExecServerMessage(exec)) => { assert_eq!(exec.id, 0); assert!(matches!( exec.message, Some(pb::exec_server_message::Message::RequestContextArgs(_)) )); handle .command(CursorCommand::Append { seqno: append_seqno, message: Box::new(pb::AgentClientMessage { message: Some( pb::agent_client_message::Message::ExecClientControlMessage( pb::ExecClientControlMessage { message: Some( pb::exec_client_control_message::Message::StreamClose( pb::ExecClientStreamClose { id: 0 }, ), ), }, ), ), }), }) .await .unwrap(); append_seqno += 1; handle .command(CursorCommand::Append { seqno: append_seqno, message: Box::new(pb::AgentClientMessage { message: Some(pb::agent_client_message::Message::ExecClientMessage( pb::ExecClientMessage { id: 0, message: Some( pb::exec_client_message::Message::RequestContextResult( pb::RequestContextResult { result: Some( pb::request_context_result::Result::Success( pb::RequestContextSuccess { request_context: Some( pb::RequestContext::default(), ), ..Default::default() }, ), ), }, ), ), ..Default::default() }, )), }), }) .await .unwrap(); append_seqno += 1; } Some(pb::agent_server_message::Message::KvServerMessage(kv)) => { if let Some(pb::kv_server_message::Message::SetBlobArgs(set)) = &kv.message { blobs.insert(set.blob_id.clone(), set.blob_data.clone()); } handle .command(CursorCommand::Append { seqno: append_seqno, message: Box::new(kv_ack(kv.id)), }) .await .unwrap(); append_seqno += 1; } Some(pb::agent_server_message::Message::ConversationCheckpointUpdate(state)) if state.pending_tool_calls.is_empty() => { final_checkpoint = Some(state); } _ => {} } } ( final_checkpoint.expect("settled completion checkpoint"), blobs, ) } async fn drive_forwarded_completion(handle: &CursorSessionHandle, message: pb::AgentClientMessage) { let mut output = handle.subscribe(); handle .command(CursorCommand::Append { seqno: 0, message: Box::new(message), }) .await .unwrap(); let mut append_seqno = 1; loop { let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv()) .await .unwrap() .unwrap(); let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap(); if flags & connect::END_STREAM_FLAG != 0 { assert_eq!(payload.as_ref(), b"{}"); return; } let server = pb::AgentServerMessage::decode(payload).unwrap(); match server.message { Some(pb::agent_server_message::Message::ExecServerMessage(exec)) => { assert_eq!(exec.id, 0); handle .command(CursorCommand::Append { seqno: append_seqno, message: Box::new(pb::AgentClientMessage { message: Some( pb::agent_client_message::Message::ExecClientControlMessage( pb::ExecClientControlMessage { message: Some( pb::exec_client_control_message::Message::StreamClose( pb::ExecClientStreamClose { id: 0 }, ), ), }, ), ), }), }) .await .unwrap(); append_seqno += 1; handle .command(CursorCommand::Append { seqno: append_seqno, message: Box::new(pb::AgentClientMessage { message: Some(pb::agent_client_message::Message::ExecClientMessage( pb::ExecClientMessage { id: 0, message: Some( pb::exec_client_message::Message::RequestContextResult( pb::RequestContextResult { result: Some( pb::request_context_result::Result::Success( pb::RequestContextSuccess { request_context: Some( pb::RequestContext::default(), ), ..Default::default() }, ), ), }, ), ), ..Default::default() }, )), }), }) .await .unwrap(); append_seqno += 1; } Some(pb::agent_server_message::Message::KvServerMessage(kv)) => { handle .command(CursorCommand::Append { seqno: append_seqno, message: Box::new(kv_ack(kv.id)), }) .await .unwrap(); append_seqno += 1; } _ => {} } } } fn completion_run( child_id: &str, run_id: &str, conversation_state: pb::ConversationStateStructure, ) -> pb::AgentClientMessage { completion_run_with_detail(child_id, run_id, conversation_state, "child result") } fn completion_run_with_detail( child_id: &str, run_id: &str, conversation_state: pb::ConversationStateStructure, detail: &str, ) -> pb::AgentClientMessage { pb::AgentClientMessage { message: Some(pb::agent_client_message::Message::RunRequest( pb::AgentRunRequest { action: Some(pb::ConversationAction { action: Some( pb::conversation_action::Action::BackgroundTaskCompletionAction( pb::BackgroundTaskCompletionAction { completions: vec![pb::BackgroundTaskCompletion { task_id: child_id.into(), kind: pb::BackgroundTaskKind::Subagent as i32, status: pb::BackgroundTaskStatus::Success as i32, title: "Inspect protocol".into(), detail: Some(detail.into()), output_path: Some("/tmp/child.jsonl".into()), reason: pb::BackgroundTaskCompletionReason::TaskFinished as i32, subagent_id: Some(child_id.into()), tool_call_id: Some("task-call".into()), ..Default::default() }], }, ), ), ..Default::default() }), conversation_id: Some("parent-conversation".into()), requested_model: Some(pb::RequestedModel { model_id: "test-model".into(), ..Default::default() }), conversation_state: Some(conversation_state), run_id: Some(run_id.into()), ..Default::default() }, )), } } fn shell_completion_run( conversation_state: pb::ConversationStateStructure, ) -> pb::AgentClientMessage { pb::AgentClientMessage { message: Some(pb::agent_client_message::Message::RunRequest( pb::AgentRunRequest { action: Some(pb::ConversationAction { action: Some( pb::conversation_action::Action::BackgroundTaskCompletionAction( pb::BackgroundTaskCompletionAction { completions: vec![pb::BackgroundTaskCompletion { task_id: "977679".into(), kind: pb::BackgroundTaskKind::Shell as i32, status: pb::BackgroundTaskStatus::Aborted as i32, title: "Start Python HTTP server on 9000".into(), detail: Some("terminated_by_user".into()), output_path: Some("/tmp/977679.txt".into()), reason: pb::BackgroundTaskCompletionReason::TaskFinished as i32, tool_call_id: Some("shell-call".into()), ..Default::default() }], }, ), ), ..Default::default() }), conversation_id: Some("parent-conversation".into()), requested_model: Some(pb::RequestedModel { model_id: "test-model".into(), ..Default::default() }), conversation_state: Some(conversation_state), run_id: Some("shell-parent-run".into()), ..Default::default() }, )), } } fn stop_response(model_call_id: &str, text: &str) -> Vec { vec![ ModelEvent::Start { model_call_id: model_call_id.into(), }, ModelEvent::TextStart, ModelEvent::TextDelta(text.into()), ModelEvent::TextEnd, ModelEvent::Done(FinishReason::Stop), ] } fn kv_ack(id: u32) -> pb::AgentClientMessage { pb::AgentClientMessage { message: Some(pb::agent_client_message::Message::KvClientMessage( pb::KvClientMessage { id, message: Some(pb::kv_client_message::Message::SetBlobResult( pb::SetBlobResult { error: None }, )), }, )), } }