From d33ae52adad2c4f531d4503e87d45d74c354680a Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Mon, 1 Jun 2026 11:43:17 -0400 Subject: [PATCH] fix(core): require visible worktree choice --- code-rs/core/src/active_sessions.rs | 2 +- code-rs/core/tests/active_session_warnings.rs | 117 +++++++++++++++++- 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/code-rs/core/src/active_sessions.rs b/code-rs/core/src/active_sessions.rs index 7ff378700a3..efaad36db1c 100644 --- a/code-rs/core/src/active_sessions.rs +++ b/code-rs/core/src/active_sessions.rs @@ -147,7 +147,7 @@ pub fn active_session_model_notice(conflicts: &[ActiveSessionRecord]) -> Option< }; Some(format!( - "CONCURRENT CHECKOUT SESSION DETECTED: {subject}, including {detail}, at {root}. Treat this checkout as concurrently edited. Prefer doing implementation work in a separate git worktree before modifying files. If you stay in this checkout, re-read target files immediately before editing and keep edits tightly scoped. Do not revert, overwrite, stage, or spend turns cataloging unrelated working-tree changes unless the user explicitly asks. Mention concurrent edits only when they affect the requested task." + "CONCURRENT CHECKOUT SESSION DETECTED: {subject}, including {detail}, at {root}. Treat this checkout as concurrently edited. Before editing files, either create/switch to an isolated git worktree for implementation work, or explicitly state why you are staying in this checkout. If you stay in this checkout, re-read target files immediately before editing and keep edits tightly scoped. Do not revert, overwrite, stage, or spend turns cataloging unrelated working-tree changes unless the user explicitly asks. Mention concurrent edits only when they affect the requested task." )) } diff --git a/code-rs/core/tests/active_session_warnings.rs b/code-rs/core/tests/active_session_warnings.rs index 222b62efb90..23f26cb39b2 100644 --- a/code-rs/core/tests/active_session_warnings.rs +++ b/code-rs/core/tests/active_session_warnings.rs @@ -3,14 +3,50 @@ mod common; use common::load_default_config_for_test; +use common::mount_sse_once; +use common::wait_for_event; +use code_core::built_in_model_providers; +use code_core::model_family::find_family_for_model; use code_core::protocol::{AskForApproval, EventMsg, SandboxPolicy}; -use code_core::{AuthManager, CodexAuth, ConversationManager}; +use code_core::protocol::{InputItem, Op}; +use code_core::{AuthManager, CodexAuth, ConversationManager, ModelProviderInfo}; use code_protocol::protocol::SessionSource; +use serde_json::Value; +use serde_json::json; use std::path::Path; use std::process::Command; use tempfile::TempDir; use tokio::time::{timeout, Duration}; +use wiremock::MockServer; + +fn completed_sse(response_id: &str) -> String { + let completed = json!({ + "type": "response.completed", + "response": { + "id": response_id, + "usage": { + "input_tokens": 0, + "input_tokens_details": null, + "output_tokens": 0, + "output_tokens_details": null, + "total_tokens": 0 + }, + "output": [] + } + }); + format!("event: response.completed\ndata: {completed}\n\n") +} + +fn message_text(item: &Value) -> String { + item["content"] + .as_array() + .expect("message content should be an array") + .iter() + .filter_map(|content| content["text"].as_str()) + .collect::>() + .join("\n") +} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn exec_session_warns_when_checkout_already_has_write_capable_session() { @@ -53,6 +89,85 @@ async fn exec_session_warns_when_checkout_already_has_write_capable_session() { } } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn concurrent_checkout_notice_in_model_request_requires_visible_worktree_choice() { + let server = MockServer::start().await; + let request = mount_sse_once(&server, completed_sse("resp-active-session-notice")).await; + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers(None)["openai"].clone() + }; + + let code_home = TempDir::new().unwrap(); + let repo = TempDir::new().unwrap(); + init_git_repo(repo.path()); + + let mut config = load_default_config_for_test(&code_home); + config.cwd = repo.path().to_path_buf(); + config.approval_policy = AskForApproval::Never; + config.sandbox_policy = SandboxPolicy::DangerFullAccess; + config.model_provider = model_provider; + config.model = "gpt-5.1-codex".to_string(); + config.model_family = find_family_for_model(&config.model).unwrap(); + config.include_apply_patch_tool = false; + config.include_view_image_tool = false; + config.tools_web_search_request = false; + config.include_plan_tool = false; + + let auth = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let cli_manager = ConversationManager::new(auth.clone(), SessionSource::Cli); + let exec_manager = ConversationManager::new(auth, SessionSource::Exec); + + let _existing = cli_manager + .new_conversation(config.clone()) + .await + .expect("create existing cli conversation"); + let exec_conversation = exec_manager + .new_conversation(config) + .await + .expect("create exec conversation") + .conversation; + + exec_conversation + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "make a small code change".to_string(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + wait_for_event(&exec_conversation, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let body = request.single_body_json(); + let input = body["input"] + .as_array() + .expect("request input should be an array"); + let notice_index = input + .iter() + .position(|item| { + item["role"].as_str() == Some("developer") + && message_text(item).contains("CONCURRENT CHECKOUT SESSION DETECTED") + }) + .expect("concurrent checkout notice should be a developer message"); + let user_index = input + .iter() + .position(|item| { + item["role"].as_str() == Some("user") + && message_text(item).contains("make a small code change") + }) + .expect("request should include the user task"); + assert!( + notice_index < user_index, + "notice should be prepended before the user task" + ); + + let text = message_text(&input[notice_index]); + assert!(text.contains("either create/switch to an isolated git worktree")); + assert!(text.contains("explicitly state why you are staying in this checkout")); + assert!(text.contains("Do not revert, overwrite, stage")); +} + fn init_git_repo(path: &Path) { run_git(path, &["init"]); run_git(path, &["checkout", "-b", "main"]);