mirror of
https://github.com/openai/codex.git
synced 2026-04-21 21:24:51 +00:00
Compare commits
3 Commits
codex-debu
...
kevinliu/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3a6fede88 | ||
|
|
5528be02e4 | ||
|
|
ec5f5ee209 |
@@ -395,6 +395,9 @@
|
||||
"fast_mode": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"git_workspace_snapshot": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"guardian_approval": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -2001,6 +2004,9 @@
|
||||
"fast_mode": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"git_workspace_snapshot": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"guardian_approval": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -2535,4 +2541,4 @@
|
||||
},
|
||||
"title": "ConfigToml",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1374,6 +1374,9 @@ impl Session {
|
||||
conversation_id.to_string(),
|
||||
sub_id.clone(),
|
||||
cwd.clone(),
|
||||
per_turn_config
|
||||
.features
|
||||
.enabled(Feature::GitWorkspaceSnapshot),
|
||||
session_configuration.sandbox_policy.get(),
|
||||
session_configuration.windows_sandbox_level,
|
||||
));
|
||||
@@ -5297,6 +5300,9 @@ async fn spawn_review_thread(
|
||||
sess.conversation_id.to_string(),
|
||||
review_turn_id.clone(),
|
||||
parent_turn_context.cwd.clone(),
|
||||
parent_turn_context
|
||||
.features
|
||||
.enabled(Feature::GitWorkspaceSnapshot),
|
||||
parent_turn_context.sandbox_policy.get(),
|
||||
parent_turn_context.windows_sandbox_level,
|
||||
));
|
||||
|
||||
136
codex-rs/core/src/git_snapshot.rs
Normal file
136
codex-rs/core/src/git_snapshot.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use std::path::Path;
|
||||
use std::process::Output;
|
||||
|
||||
use tempfile::tempdir;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::Duration as TokioDuration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::git_info::get_git_repo_root;
|
||||
use crate::git_info::get_head_commit_hash;
|
||||
|
||||
const GIT_SNAPSHOT_TIMEOUT: TokioDuration = TokioDuration::from_secs(30);
|
||||
const SNAPSHOT_AUTHOR_DATE: &str = "1970-01-01T00:00:00Z";
|
||||
const SNAPSHOT_AUTHOR_EMAIL: &str = "codex@openai.invalid";
|
||||
const SNAPSHOT_AUTHOR_NAME: &str = "Codex";
|
||||
const SNAPSHOT_MESSAGE: &str = "codex workspace snapshot";
|
||||
|
||||
pub async fn prepare_workspace_snapshot_commit(cwd: &Path) -> Option<String> {
|
||||
let repo_root = get_git_repo_root(cwd)?;
|
||||
let head = get_head_commit_hash(&repo_root).await?;
|
||||
let index_dir = tempdir().ok()?;
|
||||
let index_path = index_dir.path().join("workspace-snapshot.index");
|
||||
|
||||
run_snapshot_command({
|
||||
let mut command = git_command(repo_root.as_path());
|
||||
command
|
||||
.arg("read-tree")
|
||||
.arg(&head)
|
||||
.env("GIT_INDEX_FILE", &index_path);
|
||||
command
|
||||
})
|
||||
.await?;
|
||||
|
||||
run_snapshot_command({
|
||||
let mut command = git_command(repo_root.as_path());
|
||||
command
|
||||
.arg("add")
|
||||
.arg("-A")
|
||||
.env("GIT_INDEX_FILE", &index_path);
|
||||
command
|
||||
})
|
||||
.await?;
|
||||
|
||||
let tree = String::from_utf8(
|
||||
run_snapshot_command({
|
||||
let mut command = git_command(repo_root.as_path());
|
||||
command.arg("write-tree").env("GIT_INDEX_FILE", &index_path);
|
||||
command
|
||||
})
|
||||
.await?
|
||||
.stdout,
|
||||
)
|
||||
.ok()?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let head_tree = String::from_utf8(
|
||||
run_snapshot_command({
|
||||
let mut command = git_command(repo_root.as_path());
|
||||
command.arg("rev-parse").arg(format!("{head}^{{tree}}"));
|
||||
command
|
||||
})
|
||||
.await?
|
||||
.stdout,
|
||||
)
|
||||
.ok()?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if tree == head_tree {
|
||||
return Some(head);
|
||||
}
|
||||
|
||||
String::from_utf8(
|
||||
run_snapshot_command({
|
||||
let mut command = git_command(repo_root.as_path());
|
||||
command
|
||||
.arg("commit-tree")
|
||||
.arg(&tree)
|
||||
.arg("-p")
|
||||
.arg(&head)
|
||||
.arg("-m")
|
||||
.arg(SNAPSHOT_MESSAGE)
|
||||
.env("GIT_AUTHOR_DATE", SNAPSHOT_AUTHOR_DATE)
|
||||
.env("GIT_AUTHOR_EMAIL", SNAPSHOT_AUTHOR_EMAIL)
|
||||
.env("GIT_AUTHOR_NAME", SNAPSHOT_AUTHOR_NAME)
|
||||
.env("GIT_COMMITTER_DATE", SNAPSHOT_AUTHOR_DATE)
|
||||
.env("GIT_COMMITTER_EMAIL", SNAPSHOT_AUTHOR_EMAIL)
|
||||
.env("GIT_COMMITTER_NAME", SNAPSHOT_AUTHOR_NAME);
|
||||
command
|
||||
})
|
||||
.await?
|
||||
.stdout,
|
||||
)
|
||||
.ok()
|
||||
.map(|stdout| stdout.trim().to_string())
|
||||
}
|
||||
|
||||
fn git_command(cwd: &Path) -> Command {
|
||||
let mut command = Command::new("git");
|
||||
command.current_dir(cwd);
|
||||
command
|
||||
}
|
||||
|
||||
async fn run_snapshot_command(mut command: Command) -> Option<Output> {
|
||||
match timeout(GIT_SNAPSHOT_TIMEOUT, command.output()).await {
|
||||
Ok(Ok(output)) => {
|
||||
if output.status.success() {
|
||||
Some(output)
|
||||
} else {
|
||||
tracing::warn!(
|
||||
exit_code = output.status.code(),
|
||||
stderr = %String::from_utf8_lossy(&output.stderr),
|
||||
stdout = %String::from_utf8_lossy(&output.stdout),
|
||||
"git workspace snapshot command failed",
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
tracing::warn!(error = %err, "git workspace snapshot command errored");
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
timeout_sec = GIT_SNAPSHOT_TIMEOUT.as_secs(),
|
||||
"git workspace snapshot command timed out",
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "git_snapshot_tests.rs"]
|
||||
mod tests;
|
||||
97
codex-rs/core/src/git_snapshot_tests.rs
Normal file
97
codex-rs/core/src/git_snapshot_tests.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::git_snapshot::prepare_workspace_snapshot_commit;
|
||||
|
||||
async fn run_git(repo_path: &std::path::Path, args: &[&str]) -> Vec<u8> {
|
||||
let git_config_global = repo_path.join("empty-git-config");
|
||||
std::fs::write(&git_config_global, "").expect("write empty git config");
|
||||
let output = Command::new("git")
|
||||
.env("GIT_CONFIG_GLOBAL", &git_config_global)
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.args(args)
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("git command should run");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git {:?} failed: stdout={} stderr={}",
|
||||
args,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
output.stdout
|
||||
}
|
||||
|
||||
async fn init_repo() -> (TempDir, std::path::PathBuf) {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let repo_path = temp_dir.path().join("repo");
|
||||
std::fs::create_dir_all(&repo_path).expect("create repo");
|
||||
|
||||
run_git(&repo_path, &["init"]).await;
|
||||
std::fs::write(repo_path.join("README.md"), "hello\n").expect("write README");
|
||||
run_git(&repo_path, &["add", "."]).await;
|
||||
run_git(
|
||||
&repo_path,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Test User",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
(temp_dir, repo_path)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_snapshot_commit_returns_head_for_clean_repo() {
|
||||
let (_temp_dir, repo_path) = init_repo().await;
|
||||
|
||||
let head = String::from_utf8(run_git(&repo_path, &["rev-parse", "HEAD"]).await)
|
||||
.expect("head should be valid utf-8")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let snapshot = prepare_workspace_snapshot_commit(&repo_path)
|
||||
.await
|
||||
.expect("snapshot hash");
|
||||
|
||||
assert_eq!(snapshot, head);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_snapshot_commit_includes_untracked_files() {
|
||||
let (_temp_dir, repo_path) = init_repo().await;
|
||||
std::fs::write(repo_path.join("notes.txt"), "new file\n").expect("write untracked file");
|
||||
|
||||
let snapshot = prepare_workspace_snapshot_commit(&repo_path)
|
||||
.await
|
||||
.expect("snapshot hash");
|
||||
let file_contents =
|
||||
String::from_utf8(run_git(&repo_path, &["show", &format!("{snapshot}:notes.txt")]).await)
|
||||
.expect("snapshot file should be valid utf-8");
|
||||
|
||||
assert_eq!(file_contents, "new file\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_snapshot_commit_is_deterministic_for_same_dirty_tree() {
|
||||
let (_temp_dir, repo_path) = init_repo().await;
|
||||
std::fs::write(repo_path.join("README.md"), "updated\n").expect("update README");
|
||||
|
||||
let first = prepare_workspace_snapshot_commit(&repo_path)
|
||||
.await
|
||||
.expect("first snapshot hash");
|
||||
let second = prepare_workspace_snapshot_commit(&repo_path)
|
||||
.await
|
||||
.expect("second snapshot hash");
|
||||
|
||||
assert_eq!(first, second);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ pub mod external_agent_config;
|
||||
mod file_watcher;
|
||||
mod flags;
|
||||
pub mod git_info;
|
||||
mod git_snapshot;
|
||||
mod guardian;
|
||||
mod hook_runtime;
|
||||
pub mod instructions;
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::git_info::get_git_remote_urls_assume_git_repo;
|
||||
use crate::git_info::get_git_repo_root;
|
||||
use crate::git_info::get_has_changes;
|
||||
use crate::git_info::get_head_commit_hash;
|
||||
use crate::git_snapshot::prepare_workspace_snapshot_commit;
|
||||
use crate::sandbox_tags::sandbox_tag;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
@@ -21,6 +22,7 @@ struct WorkspaceGitMetadata {
|
||||
associated_remote_urls: Option<BTreeMap<String, String>>,
|
||||
latest_git_commit_hash: Option<String>,
|
||||
has_changes: Option<bool>,
|
||||
workspace_snapshot_commit_hash: Option<String>,
|
||||
}
|
||||
|
||||
impl WorkspaceGitMetadata {
|
||||
@@ -28,6 +30,7 @@ impl WorkspaceGitMetadata {
|
||||
self.associated_remote_urls.is_none()
|
||||
&& self.latest_git_commit_hash.is_none()
|
||||
&& self.has_changes.is_none()
|
||||
&& self.workspace_snapshot_commit_hash.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +42,8 @@ struct TurnMetadataWorkspace {
|
||||
latest_git_commit_hash: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
has_changes: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
workspace_snapshot_commit_hash: Option<String>,
|
||||
}
|
||||
|
||||
impl From<WorkspaceGitMetadata> for TurnMetadataWorkspace {
|
||||
@@ -47,6 +52,7 @@ impl From<WorkspaceGitMetadata> for TurnMetadataWorkspace {
|
||||
associated_remote_urls: value.associated_remote_urls,
|
||||
latest_git_commit_hash: value.latest_git_commit_hash,
|
||||
has_changes: value.has_changes,
|
||||
workspace_snapshot_commit_hash: value.workspace_snapshot_commit_hash,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,18 +98,21 @@ fn build_turn_metadata_bag(
|
||||
}
|
||||
|
||||
pub async fn build_turn_metadata_header(cwd: &Path, sandbox: Option<&str>) -> Option<String> {
|
||||
let repo_root = get_git_repo_root(cwd).map(|root| root.to_string_lossy().into_owned());
|
||||
build_turn_metadata_header_with_options(
|
||||
cwd, sandbox, /*include_workspace_snapshot_commit_hash*/ false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
let (latest_git_commit_hash, associated_remote_urls, has_changes) = tokio::join!(
|
||||
get_head_commit_hash(cwd),
|
||||
get_git_remote_urls_assume_git_repo(cwd),
|
||||
get_has_changes(cwd),
|
||||
);
|
||||
if latest_git_commit_hash.is_none()
|
||||
&& associated_remote_urls.is_none()
|
||||
&& has_changes.is_none()
|
||||
&& sandbox.is_none()
|
||||
{
|
||||
async fn build_turn_metadata_header_with_options(
|
||||
cwd: &Path,
|
||||
sandbox: Option<&str>,
|
||||
include_workspace_snapshot_commit_hash: bool,
|
||||
) -> Option<String> {
|
||||
let repo_root = get_git_repo_root(cwd).map(|root| root.to_string_lossy().into_owned());
|
||||
let workspace_git_metadata =
|
||||
fetch_workspace_git_metadata(cwd, include_workspace_snapshot_commit_hash).await;
|
||||
if workspace_git_metadata.is_empty() && sandbox.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -112,11 +121,7 @@ pub async fn build_turn_metadata_header(cwd: &Path, sandbox: Option<&str>) -> Op
|
||||
/*turn_id*/ None,
|
||||
sandbox.map(ToString::to_string),
|
||||
repo_root,
|
||||
Some(WorkspaceGitMetadata {
|
||||
associated_remote_urls,
|
||||
latest_git_commit_hash,
|
||||
has_changes,
|
||||
}),
|
||||
Some(workspace_git_metadata),
|
||||
)
|
||||
.to_header_value()
|
||||
}
|
||||
@@ -124,6 +129,7 @@ pub async fn build_turn_metadata_header(cwd: &Path, sandbox: Option<&str>) -> Op
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct TurnMetadataState {
|
||||
cwd: PathBuf,
|
||||
include_workspace_snapshot_commit_hash: bool,
|
||||
repo_root: Option<String>,
|
||||
base_metadata: TurnMetadataBag,
|
||||
base_header: String,
|
||||
@@ -136,6 +142,7 @@ impl TurnMetadataState {
|
||||
session_id: String,
|
||||
turn_id: String,
|
||||
cwd: PathBuf,
|
||||
include_workspace_snapshot_commit_hash: bool,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
) -> Self {
|
||||
@@ -154,6 +161,7 @@ impl TurnMetadataState {
|
||||
|
||||
Self {
|
||||
cwd,
|
||||
include_workspace_snapshot_commit_hash,
|
||||
repo_root,
|
||||
base_metadata,
|
||||
base_header,
|
||||
@@ -231,17 +239,35 @@ impl TurnMetadataState {
|
||||
}
|
||||
|
||||
async fn fetch_workspace_git_metadata(&self) -> WorkspaceGitMetadata {
|
||||
let (latest_git_commit_hash, associated_remote_urls, has_changes) = tokio::join!(
|
||||
get_head_commit_hash(&self.cwd),
|
||||
get_git_remote_urls_assume_git_repo(&self.cwd),
|
||||
get_has_changes(&self.cwd),
|
||||
);
|
||||
fetch_workspace_git_metadata(&self.cwd, self.include_workspace_snapshot_commit_hash).await
|
||||
}
|
||||
}
|
||||
|
||||
WorkspaceGitMetadata {
|
||||
associated_remote_urls,
|
||||
latest_git_commit_hash,
|
||||
has_changes,
|
||||
async fn fetch_workspace_git_metadata(
|
||||
cwd: &Path,
|
||||
include_workspace_snapshot_commit_hash: bool,
|
||||
) -> WorkspaceGitMetadata {
|
||||
let (latest_git_commit_hash, associated_remote_urls, has_changes) = tokio::join!(
|
||||
get_head_commit_hash(cwd),
|
||||
get_git_remote_urls_assume_git_repo(cwd),
|
||||
get_has_changes(cwd),
|
||||
);
|
||||
|
||||
let workspace_snapshot_commit_hash = if include_workspace_snapshot_commit_hash {
|
||||
match (latest_git_commit_hash.as_ref(), has_changes) {
|
||||
(Some(head), Some(false)) => Some(head.clone()),
|
||||
(Some(_), Some(true)) => prepare_workspace_snapshot_commit(cwd).await,
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
WorkspaceGitMetadata {
|
||||
associated_remote_urls,
|
||||
latest_git_commit_hash,
|
||||
has_changes,
|
||||
workspace_snapshot_commit_hash,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,44 +4,40 @@ use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
use tokio::process::Command;
|
||||
|
||||
async fn run_git(repo_path: &std::path::Path, args: &[&str]) -> Vec<u8> {
|
||||
let git_config_global = repo_path.join("empty-git-config");
|
||||
std::fs::write(&git_config_global, "").expect("write empty git config");
|
||||
let output = Command::new("git")
|
||||
.env("GIT_CONFIG_GLOBAL", &git_config_global)
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.args(args)
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("git command should run");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git {:?} failed: stdout={} stderr={}",
|
||||
args,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
output.stdout
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_turn_metadata_header_includes_has_changes_for_clean_repo() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let repo_path = temp_dir.path().join("repo");
|
||||
std::fs::create_dir_all(&repo_path).expect("create repo");
|
||||
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("git init");
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test User"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("git config user.name");
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@example.com"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("git config user.email");
|
||||
run_git(&repo_path, &["init"]).await;
|
||||
run_git(&repo_path, &["config", "user.name", "Test User"]).await;
|
||||
run_git(&repo_path, &["config", "user.email", "test@example.com"]).await;
|
||||
|
||||
std::fs::write(repo_path.join("README.md"), "hello").expect("write file");
|
||||
Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("git add");
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "initial"])
|
||||
.current_dir(&repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("git commit");
|
||||
run_git(&repo_path, &["add", "."]).await;
|
||||
run_git(&repo_path, &["commit", "-m", "initial"]).await;
|
||||
|
||||
let header = build_turn_metadata_header(&repo_path, Some("none"))
|
||||
.await
|
||||
@@ -60,6 +56,59 @@ async fn build_turn_metadata_header_includes_has_changes_for_clean_repo() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_turn_metadata_header_includes_workspace_snapshot_commit_hash_when_enabled() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let repo_path = temp_dir.path().join("repo");
|
||||
std::fs::create_dir_all(&repo_path).expect("create repo");
|
||||
|
||||
run_git(&repo_path, &["init"]).await;
|
||||
|
||||
std::fs::write(repo_path.join("README.md"), "hello").expect("write file");
|
||||
run_git(&repo_path, &["add", "."]).await;
|
||||
run_git(
|
||||
&repo_path,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Test User",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
std::fs::write(repo_path.join("README.md"), "updated").expect("update file");
|
||||
|
||||
let header = build_turn_metadata_header_with_options(
|
||||
&repo_path,
|
||||
Some("none"),
|
||||
/*include_workspace_snapshot_commit_hash*/ true,
|
||||
)
|
||||
.await
|
||||
.expect("header");
|
||||
let parsed: Value = serde_json::from_str(&header).expect("valid json");
|
||||
let workspace = parsed
|
||||
.get("workspaces")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|workspaces| workspaces.values().next())
|
||||
.cloned()
|
||||
.expect("workspace");
|
||||
|
||||
let snapshot_commit_hash = workspace
|
||||
.get("workspace_snapshot_commit_hash")
|
||||
.and_then(Value::as_str)
|
||||
.expect("workspace snapshot commit hash");
|
||||
let head_commit_hash = workspace
|
||||
.get("latest_git_commit_hash")
|
||||
.and_then(Value::as_str)
|
||||
.expect("latest git commit hash");
|
||||
|
||||
assert_ne!(snapshot_commit_hash, head_commit_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_metadata_state_uses_platform_sandbox_tag() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
@@ -70,6 +119,7 @@ fn turn_metadata_state_uses_platform_sandbox_tag() {
|
||||
"session-a".to_string(),
|
||||
"turn-a".to_string(),
|
||||
cwd,
|
||||
/*include_workspace_snapshot_commit_hash*/ false,
|
||||
&sandbox_policy,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ use codex_core::ModelProviderInfo;
|
||||
use codex_core::Prompt;
|
||||
use codex_core::ResponseEvent;
|
||||
use codex_core::WireApi;
|
||||
use codex_features::Feature;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_otel::TelemetryAuthMode;
|
||||
use codex_protocol::ThreadId;
|
||||
@@ -546,3 +547,112 @@ async fn responses_stream_includes_turn_metadata_header_for_git_workspace_e2e()
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_stream_includes_workspace_snapshot_commit_hash_when_feature_enabled() {
|
||||
core_test_support::skip_if_no_network!();
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
|
||||
let test = test_codex()
|
||||
.with_config(|config| {
|
||||
let _ = config.features.enable(Feature::GitWorkspaceSnapshot);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
.expect("build test codex");
|
||||
let cwd = test.cwd_path();
|
||||
|
||||
let git_config_global = cwd.join("empty-git-config");
|
||||
std::fs::write(&git_config_global, "").expect("write empty git config");
|
||||
let run_git = |args: &[&str]| {
|
||||
let output = Command::new("git")
|
||||
.env("GIT_CONFIG_GLOBAL", &git_config_global)
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.expect("git command should run");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git {:?} failed: stdout={} stderr={}",
|
||||
args,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
output
|
||||
};
|
||||
|
||||
run_git(&["init"]);
|
||||
std::fs::write(cwd.join("README.md"), "hello").expect("write README");
|
||||
run_git(&[
|
||||
"-c",
|
||||
"user.name=Test User",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"add",
|
||||
".",
|
||||
]);
|
||||
run_git(&[
|
||||
"-c",
|
||||
"user.name=Test User",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-m",
|
||||
"initial commit",
|
||||
]);
|
||||
std::fs::write(cwd.join("README.md"), "updated").expect("update README");
|
||||
|
||||
let first_response = responses::sse(vec![
|
||||
responses::ev_response_created("resp-2"),
|
||||
responses::ev_reasoning_item("rsn-1", &["thinking"], &[]),
|
||||
responses::ev_shell_command_call("call-1", "echo turn-metadata"),
|
||||
responses::ev_completed("resp-2"),
|
||||
]);
|
||||
let follow_up_response = responses::sse(vec![
|
||||
responses::ev_response_created("resp-3"),
|
||||
responses::ev_assistant_message("msg-1", "done"),
|
||||
responses::ev_completed("resp-3"),
|
||||
]);
|
||||
let request_log = responses::mount_response_sequence(
|
||||
&server,
|
||||
vec![
|
||||
responses::sse_response(first_response),
|
||||
responses::sse_response(follow_up_response),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("hello")
|
||||
.await
|
||||
.expect("submit post-git turn prompt");
|
||||
|
||||
let requests = request_log.requests();
|
||||
assert_eq!(requests.len(), 2, "expected two requests in one turn");
|
||||
|
||||
let second_parsed: serde_json::Value = serde_json::from_str(
|
||||
&requests[1]
|
||||
.header("x-codex-turn-metadata")
|
||||
.expect("second request should include turn metadata"),
|
||||
)
|
||||
.expect("second metadata should be valid json");
|
||||
|
||||
let second_workspace = second_parsed
|
||||
.get("workspaces")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|workspaces| workspaces.values().next())
|
||||
.cloned()
|
||||
.expect("second request should include git workspace metadata");
|
||||
|
||||
let snapshot_commit_hash = second_workspace
|
||||
.get("workspace_snapshot_commit_hash")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.expect("second request should include workspace snapshot commit hash");
|
||||
let head_commit_hash = second_workspace
|
||||
.get("latest_git_commit_hash")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.expect("second request should include latest git commit hash");
|
||||
|
||||
assert_ne!(snapshot_commit_hash, head_commit_hash);
|
||||
}
|
||||
|
||||
@@ -95,6 +95,8 @@ pub enum Feature {
|
||||
ExecPermissionApprovals,
|
||||
/// Enable Claude-style lifecycle hooks loaded from hooks.json files.
|
||||
CodexHooks,
|
||||
/// Attach a synthetic commit hash for the current dirty worktree to turn metadata.
|
||||
GitWorkspaceSnapshot,
|
||||
/// Expose the built-in request_permissions tool.
|
||||
RequestPermissionsTool,
|
||||
/// Allow the model to request web searches that fetch live content.
|
||||
@@ -645,6 +647,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::GitWorkspaceSnapshot,
|
||||
key: "git_workspace_snapshot",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::RequestPermissionsTool,
|
||||
key: "request_permissions_tool",
|
||||
|
||||
@@ -114,6 +114,15 @@ fn request_permissions_tool_is_under_development() {
|
||||
assert_eq!(Feature::RequestPermissionsTool.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_workspace_snapshot_is_under_development() {
|
||||
assert_eq!(
|
||||
Feature::GitWorkspaceSnapshot.stage(),
|
||||
Stage::UnderDevelopment
|
||||
);
|
||||
assert_eq!(Feature::GitWorkspaceSnapshot.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_suggest_is_under_development() {
|
||||
assert_eq!(Feature::ToolSuggest.stage(), Stage::UnderDevelopment);
|
||||
|
||||
Reference in New Issue
Block a user