Compare commits

...

1 Commits

Author SHA1 Message Date
Dylan Hurd
ae003f6942 add(core); Add GitInfo to EnvironmentContext 2025-10-26 13:43:12 -07:00
4 changed files with 268 additions and 13 deletions

View File

@@ -7,6 +7,7 @@ use std::sync::atomic::AtomicU64;
use crate::AuthManager;
use crate::client_common::REVIEW_PROMPT;
use crate::function_tool::FunctionCallError;
use crate::git_info::collect_git_info;
use crate::mcp::auth::McpAuthStatusEntry;
use crate::mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT;
use crate::parse_command::parse_command;
@@ -22,6 +23,7 @@ use codex_protocol::ConversationId;
use codex_protocol::items::TurnItem;
use codex_protocol::protocol::ConversationPathResponseEvent;
use codex_protocol::protocol::ExitedReviewModeEvent;
use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::ItemStartedEvent;
use codex_protocol::protocol::ReviewRequest;
@@ -177,6 +179,7 @@ impl Codex {
approval_policy: config.approval_policy,
sandbox_policy: config.sandbox_policy.clone(),
cwd: config.cwd.clone(),
git_info: None,
original_config_do_not_use: Arc::clone(&config),
};
@@ -271,6 +274,7 @@ pub(crate) struct TurnContext {
pub(crate) is_review_mode: bool,
pub(crate) final_output_json_schema: Option<Value>,
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
pub(crate) git_info: Option<GitInfo>,
}
impl TurnContext {
@@ -312,6 +316,8 @@ pub(crate) struct SessionConfiguration {
/// operate deterministically.
cwd: PathBuf,
pub(crate) git_info: Option<GitInfo>,
// TODO(pakrym): Remove config from here
original_config_do_not_use: Arc<Config>,
}
@@ -406,11 +412,12 @@ impl Session {
is_review_mode: false,
final_output_json_schema: None,
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
git_info: session_configuration.git_info.clone(),
}
}
async fn new(
session_configuration: SessionConfiguration,
mut session_configuration: SessionConfiguration,
config: Arc<Config>,
auth_manager: Arc<AuthManager>,
tx_event: Sender<Event>,
@@ -467,6 +474,7 @@ impl Session {
config.mcp_servers.iter(),
config.mcp_oauth_credentials_store_mode,
);
let git_info_fut = collect_git_info(&session_configuration.cwd);
// Join all independent futures.
let (
@@ -475,14 +483,18 @@ impl Session {
default_shell,
(history_log_id, history_entry_count),
auth_statuses,
git_info,
) = tokio::join!(
rollout_fut,
mcp_fut,
default_shell_fut,
history_meta_fut,
auth_statuses_fut
auth_statuses_fut,
git_info_fut
);
session_configuration.git_info = git_info;
let rollout_recorder = rollout_recorder.map_err(|e| {
error!("failed to initialize rollout recorder: {e:#}");
anyhow::anyhow!("failed to initialize rollout recorder: {e:#}")
@@ -630,9 +642,7 @@ impl Session {
}
pub(crate) async fn update_settings(&self, updates: SessionSettingsUpdate) {
let mut state = self.state.lock().await;
state.session_configuration = state.session_configuration.apply(&updates);
self.apply_session_updates(&updates).await;
}
pub(crate) async fn new_turn(&self, updates: SessionSettingsUpdate) -> Arc<TurnContext> {
@@ -645,12 +655,7 @@ impl Session {
sub_id: String,
updates: SessionSettingsUpdate,
) -> Arc<TurnContext> {
let session_configuration = {
let mut state = self.state.lock().await;
let session_configuration = state.session_configuration.clone().apply(&updates);
state.session_configuration = session_configuration.clone();
session_configuration
};
let session_configuration = self.apply_session_updates(&updates).await;
let mut turn_context: TurnContext = Self::make_turn_context(
Some(Arc::clone(&self.services.auth_manager)),
@@ -666,6 +671,26 @@ impl Session {
Arc::new(turn_context)
}
async fn apply_session_updates(&self, updates: &SessionSettingsUpdate) -> SessionConfiguration {
let needs_git_refresh = updates.cwd.is_some();
let mut state = self.state.lock().await;
let mut session_configuration = state.session_configuration.clone().apply(updates);
if needs_git_refresh {
session_configuration.git_info = None;
}
state.session_configuration = session_configuration.clone();
drop(state);
if needs_git_refresh {
let git_info = collect_git_info(&session_configuration.cwd).await;
session_configuration.git_info = git_info;
let mut state = self.state.lock().await;
state.session_configuration = session_configuration.clone();
}
session_configuration
}
fn build_environment_update_item(
&self,
previous: Option<&Arc<TurnContext>>,
@@ -919,6 +944,7 @@ impl Session {
Some(turn_context.approval_policy),
Some(turn_context.sandbox_policy.clone()),
Some(self.user_shell().clone()),
turn_context.git_info.clone(),
)));
items
}
@@ -1472,6 +1498,7 @@ async fn spawn_review_thread(
is_review_mode: true,
final_output_json_schema: None,
codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(),
git_info: parent_turn_context.git_info.clone(),
};
// Seed the child task with the review prompt as the initial user message.
@@ -2549,6 +2576,7 @@ mod tests {
approval_policy: config.approval_policy,
sandbox_policy: config.sandbox_policy.clone(),
cwd: config.cwd.clone(),
git_info: None,
original_config_do_not_use: Arc::clone(&config),
};
@@ -2617,6 +2645,7 @@ mod tests {
approval_policy: config.approval_policy,
sandbox_policy: config.sandbox_policy.clone(),
cwd: config.cwd.clone(),
git_info: None,
original_config_do_not_use: Arc::clone(&config),
};

View File

@@ -11,6 +11,7 @@ use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
use codex_protocol::protocol::GitInfo;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, DeriveDisplay)]
@@ -29,6 +30,7 @@ pub(crate) struct EnvironmentContext {
pub network_access: Option<NetworkAccess>,
pub writable_roots: Option<Vec<PathBuf>>,
pub shell: Option<Shell>,
pub git_info: Option<GitInfo>,
}
impl EnvironmentContext {
@@ -37,6 +39,7 @@ impl EnvironmentContext {
approval_policy: Option<AskForApproval>,
sandbox_policy: Option<SandboxPolicy>,
shell: Option<Shell>,
git_info: Option<GitInfo>,
) -> Self {
Self {
cwd,
@@ -70,6 +73,7 @@ impl EnvironmentContext {
_ => None,
},
shell,
git_info,
}
}
@@ -85,6 +89,7 @@ impl EnvironmentContext {
writable_roots,
// should compare all fields except shell
shell: _,
git_info,
} = other;
self.cwd == *cwd
@@ -92,6 +97,7 @@ impl EnvironmentContext {
&& self.sandbox_mode == *sandbox_mode
&& self.network_access == *network_access
&& self.writable_roots == *writable_roots
&& self.git_info == *git_info
}
pub fn diff(before: &TurnContext, after: &TurnContext) -> Self {
@@ -110,7 +116,12 @@ impl EnvironmentContext {
} else {
None
};
EnvironmentContext::new(cwd, approval_policy, sandbox_policy, None)
let git_info = if before.git_info != after.git_info {
after.git_info.clone()
} else {
None
};
EnvironmentContext::new(cwd, approval_policy, sandbox_policy, None, git_info)
}
}
@@ -122,6 +133,7 @@ impl From<&TurnContext> for EnvironmentContext {
Some(turn_context.sandbox_policy.clone()),
// Shell is not configurable from turn to turn
None,
turn_context.git_info.clone(),
)
}
}
@@ -139,6 +151,7 @@ impl EnvironmentContext {
/// <writable_roots>...</writable_roots>
/// <network_access>...</network_access>
/// <shell>...</shell>
/// <git_info>...</git_info>
/// </environment_context>
/// ```
pub fn serialize_to_xml(self) -> String {
@@ -174,6 +187,26 @@ impl EnvironmentContext {
{
lines.push(format!(" <shell>{shell_name}</shell>"));
}
if let Some(git_info) = self.git_info {
let has_data = git_info.commit_hash.is_some()
|| git_info.branch.is_some()
|| git_info.repository_url.is_some();
if has_data {
lines.push(" <git_info>".to_string());
if let Some(commit_hash) = git_info.commit_hash {
lines.push(format!(" <commit_hash>{commit_hash}</commit_hash>"));
}
if let Some(branch) = git_info.branch {
lines.push(format!(" <branch>{branch}</branch>"));
}
if let Some(repository_url) = git_info.repository_url {
lines.push(format!(
" <repository_url>{repository_url}</repository_url>"
));
}
lines.push(" </git_info>".to_string());
}
}
lines.push(ENVIRONMENT_CONTEXT_CLOSE_TAG.to_string());
lines.join("\n")
}
@@ -215,6 +248,7 @@ mod tests {
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo", "/tmp"], false)),
None,
None,
);
let expected = r#"<environment_context>
@@ -238,6 +272,7 @@ mod tests {
Some(AskForApproval::Never),
Some(SandboxPolicy::ReadOnly),
None,
None,
);
let expected = r#"<environment_context>
@@ -256,6 +291,7 @@ mod tests {
Some(AskForApproval::OnFailure),
Some(SandboxPolicy::DangerFullAccess),
None,
None,
);
let expected = r#"<environment_context>
@@ -267,6 +303,31 @@ mod tests {
assert_eq!(context.serialize_to_xml(), expected);
}
#[test]
fn serialize_environment_context_with_git_info() {
let context = EnvironmentContext::new(
None,
None,
None,
None,
Some(GitInfo {
commit_hash: Some("abc123".to_string()),
branch: Some("main".to_string()),
repository_url: Some("git@example.com:repo.git".to_string()),
}),
);
let expected = r#"<environment_context>
<git_info>
<commit_hash>abc123</commit_hash>
<branch>main</branch>
<repository_url>git@example.com:repo.git</repository_url>
</git_info>
</environment_context>"#;
assert_eq!(context.serialize_to_xml(), expected);
}
#[test]
fn equals_except_shell_compares_approval_policy() {
// Approval policy
@@ -275,12 +336,14 @@ mod tests {
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo"], false)),
None,
None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::Never),
Some(workspace_write_policy(vec!["/repo"], true)),
None,
None,
);
assert!(!context1.equals_except_shell(&context2));
}
@@ -292,12 +355,14 @@ mod tests {
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::new_read_only_policy()),
None,
None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::new_workspace_write_policy()),
None,
None,
);
assert!(!context1.equals_except_shell(&context2));
@@ -310,12 +375,14 @@ mod tests {
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo", "/tmp", "/var"], false)),
None,
None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo", "/tmp"], true)),
None,
None,
);
assert!(!context1.equals_except_shell(&context2));
@@ -331,6 +398,7 @@ mod tests {
shell_path: "/bin/bash".into(),
bashrc_path: "/home/user/.bashrc".into(),
})),
None,
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
@@ -340,6 +408,7 @@ mod tests {
shell_path: "/bin/zsh".into(),
zshrc_path: "/home/user/.zshrc".into(),
})),
None,
);
assert!(context1.equals_except_shell(&context2));

View File

@@ -21,6 +21,9 @@ use core_test_support::load_sse_fixture_with_id;
use core_test_support::skip_if_no_network;
use core_test_support::wait_for_event;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
@@ -52,6 +55,75 @@ fn default_env_context_str(cwd: &str, shell: &Shell) -> String {
)
}
fn git_command(repo_path: &Path) -> Command {
let mut command = Command::new("git");
command.current_dir(repo_path);
command.env("GIT_CONFIG_GLOBAL", "/dev/null");
command.env("GIT_CONFIG_NOSYSTEM", "1");
command
}
fn run_git_command(repo_path: &Path, args: &[&str]) {
let output = git_command(repo_path)
.args(args)
.output()
.unwrap_or_else(|err| panic!("failed to run git command: {err}"));
assert!(
output.status.success(),
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
}
fn init_git_repo_with_remote() -> (TempDir, String) {
let repo = TempDir::new().unwrap();
let repo_path = repo.path();
run_git_command(repo_path, &["init"]);
run_git_command(repo_path, &["config", "user.name", "Integration Test"]);
run_git_command(repo_path, &["config", "user.email", "test@example.com"]);
fs::write(repo_path.join("tracked.txt"), "tracked file").unwrap();
run_git_command(repo_path, &["add", "."]);
run_git_command(repo_path, &["commit", "-m", "initial commit"]);
run_git_command(repo_path, &["checkout", "-b", "integration-test-branch"]);
run_git_command(
repo_path,
&[
"remote",
"add",
"origin",
"https://github.com/example/git-info-test.git",
],
);
let output = git_command(repo_path)
.args(["rev-parse", "HEAD"])
.output()
.unwrap_or_else(|err| panic!("failed to read git commit hash: {err}"));
assert!(output.status.success(), "git rev-parse failed");
let commit_hash = String::from_utf8(output.stdout)
.unwrap_or_else(|err| panic!("commit hash should be utf8: {err}"))
.trim()
.to_string();
(repo, commit_hash)
}
fn find_environment_context(body: &serde_json::Value) -> &str {
body["input"]
.as_array()
.and_then(|items| {
items.iter().find_map(|item| {
let content = item.get("content")?.as_array()?;
let text = content.first()?.get("text")?.as_str()?;
text.starts_with("<environment_context>").then_some(text)
})
})
.unwrap_or_else(|| panic!("environment_context message not found"))
}
/// Build minimal SSE stream with completed marker using the JSON fixture.
fn sse_completed(id: &str) -> String {
load_sse_fixture_with_id("tests/fixtures/completed_template.json", id)
@@ -883,3 +955,88 @@ async fn send_user_turn_with_changes_sends_environment_context() {
]);
assert_eq!(body2["input"], expected_input_2);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn environment_context_includes_git_info_when_available() {
skip_if_no_network!();
let server = MockServer::start().await;
let sse = sse_completed("resp");
let template = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(sse, "text/event-stream");
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(template)
.expect(1)
.mount(&server)
.await;
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let (git_repo, commit_hash) = init_git_repo_with_remote();
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
config.cwd = git_repo.path().to_path_buf();
config.model_provider = model_provider;
let default_cwd = config.cwd.clone();
let default_approval_policy = config.approval_policy;
let default_sandbox_policy = config.sandbox_policy.clone();
let default_model = config.model.clone();
let default_effort = config.model_reasoning_effort;
let default_summary = config.model_reasoning_summary;
let conversation_manager =
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
let codex = conversation_manager
.new_conversation(config)
.await
.expect("create new conversation")
.conversation;
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "git info turn".into(),
}],
cwd: default_cwd,
approval_policy: default_approval_policy,
sandbox_policy: default_sandbox_policy,
model: default_model,
effort: default_effort,
summary: default_summary,
final_output_json_schema: None,
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 1, "expected a single POST request");
let body = requests[0].body_json::<serde_json::Value>().unwrap();
let env_text = find_environment_context(&body);
assert!(env_text.contains("<git_info>"), "git info block missing");
assert!(
env_text.contains(&format!("<commit_hash>{commit_hash}</commit_hash>")),
"commit hash missing from git info"
);
assert!(
env_text.contains("<branch>integration-test-branch</branch>"),
"branch missing from git info"
);
assert!(
env_text.contains(
"<repository_url>https://github.com/example/git-info-test.git</repository_url>"
),
"repository url missing from git info"
);
}

View File

@@ -1002,7 +1002,7 @@ pub struct RolloutLine {
pub item: RolloutItem,
}
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)]
pub struct GitInfo {
/// Current commit hash (SHA)
#[serde(skip_serializing_if = "Option::is_none")]