Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion code-rs/core/src/active_sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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."
))
}

Expand Down
117 changes: 116 additions & 1 deletion code-rs/core/tests/active_session_warnings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>()
.join("\n")
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_session_warns_when_checkout_already_has_write_capable_session() {
Expand Down Expand Up @@ -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"]);
Expand Down