core: render remote environment cwd natively (#28152)

## Why

Model-visible `<environment_context>` should match the environment of
the executor, not of the app server.

Stacked on #28146.

## What

- Keep selected environment cwd values as `PathUri` while building
environment context.
- Render cwd text using the path convention represented by the URI, with
the canonical URI as a fallback.
- Preserve compatibility with legacy `TurnContextItem.cwd` values when
reconstructing and diffing context.
- Extend the Wine-backed remote Windows test to assert that the model
sees `powershell` and `C:\windows`.
This commit is contained in:
Adam Perry @ OpenAI
2026-06-16 16:17:47 -07:00
committed by GitHub
Unverified
parent a34da3b295
commit 4c79527e31
25 changed files with 457 additions and 185 deletions
+1 -1
View File
@@ -458,7 +458,7 @@ impl CodexThread {
self.codex
.session
.record_context_updates_and_set_reference_context_item(turn_context.as_ref())
.await;
.await?;
}
self.codex
.session
@@ -10,6 +10,7 @@ use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::TurnContextItem;
use codex_protocol::protocol::TurnContextNetworkItem;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use std::collections::HashSet;
use std::path::PathBuf;
@@ -28,12 +29,12 @@ pub(crate) struct EnvironmentContext {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct EnvironmentContextEnvironment {
pub(crate) id: String,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) cwd: PathUri,
pub(crate) shell: String,
}
impl EnvironmentContextEnvironment {
fn legacy(cwd: AbsolutePathBuf, shell: String) -> Self {
fn legacy(cwd: PathUri, shell: String) -> Self {
Self {
id: String::new(),
cwd,
@@ -44,18 +45,14 @@ impl EnvironmentContextEnvironment {
fn from_turn_environments(environments: &[TurnEnvironment], shell: &Shell) -> Vec<Self> {
environments
.iter()
.filter_map(|environment| {
// TODO(anp): Migrate EnvironmentContextEnvironment to PathUri so foreign
// environments remain visible in model context.
Some(Self {
id: environment.environment_id.clone(),
cwd: environment.cwd().to_abs_path().ok()?,
shell: environment
.shell
.as_ref()
.map(|shell| shell.name().to_string())
.unwrap_or_else(|| shell.name().to_string()),
})
.map(|environment| Self {
id: environment.environment_id.clone(),
cwd: environment.cwd().clone(),
shell: environment
.shell
.as_ref()
.map(|shell| shell.name().to_string())
.unwrap_or_else(|| shell.name().to_string()),
})
.collect()
}
@@ -383,12 +380,12 @@ impl EnvironmentContext {
pub(crate) fn diff_from_turn_context_item(
before: &TurnContextItem,
after: &EnvironmentContext,
) -> Self {
) -> std::io::Result<Self> {
let before_network = Self::network_from_turn_context_item(before);
let before_filesystem = Self::filesystem_from_turn_context_item(before);
let before_filesystem = Self::filesystem_from_turn_context_item(before)?;
let environments = match &after.environments {
EnvironmentContextEnvironments::Single(environment) => {
if before.cwd.as_path() != environment.cwd.as_path() {
if before.cwd != environment.cwd {
EnvironmentContextEnvironments::Single(EnvironmentContextEnvironment::legacy(
environment.cwd.clone(),
environment.shell.clone(),
@@ -412,14 +409,14 @@ impl EnvironmentContext {
} else {
before_filesystem
};
EnvironmentContext::new_with_environments(
Ok(EnvironmentContext::new_with_environments(
environments,
after.current_date.clone(),
after.timezone.clone(),
network,
filesystem,
/*subagents*/ None,
)
))
}
pub(crate) fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self {
@@ -443,21 +440,18 @@ impl EnvironmentContext {
pub(crate) fn from_turn_context_item(
turn_context_item: &TurnContextItem,
shell: String,
) -> Self {
let cwd = match AbsolutePathBuf::try_from(turn_context_item.cwd.clone()) {
Ok(cwd) => cwd,
Err(_) => AbsolutePathBuf::resolve_path_against_base(&turn_context_item.cwd, "/"),
};
Self::new_with_environments(
) -> std::io::Result<Self> {
Ok(Self::new_with_environments(
EnvironmentContextEnvironments::from_vec(vec![EnvironmentContextEnvironment::legacy(
cwd, shell,
turn_context_item.cwd.clone(),
shell,
)]),
turn_context_item.current_date.clone(),
turn_context_item.timezone.clone(),
Self::network_from_turn_context_item(turn_context_item),
Self::filesystem_from_turn_context_item(turn_context_item),
Self::filesystem_from_turn_context_item(turn_context_item)?,
/*subagents*/ None,
)
))
}
pub(crate) fn with_subagents(mut self, subagents: String) -> Self {
@@ -504,11 +498,11 @@ impl EnvironmentContext {
fn filesystem_from_turn_context_item(
turn_context_item: &TurnContextItem,
) -> Option<FileSystemContext> {
Some(FileSystemContext::from_permission_profile(
&turn_context_item.permission_profile(),
) -> std::io::Result<Option<FileSystemContext>> {
Ok(Some(FileSystemContext::from_permission_profile(
&turn_context_item.permission_profile()?,
&workspace_roots_from_turn_context_item(turn_context_item),
))
)))
}
}
@@ -521,7 +515,7 @@ fn workspace_roots_from_turn_context_item(
// Older rollout items did not persist workspace roots. Fall back to the
// legacy cwd binding only when reconstructing that historical context.
match AbsolutePathBuf::try_from(turn_context_item.cwd.clone()) {
match turn_context_item.cwd.to_abs_path() {
Ok(cwd) => vec![cwd],
Err(_) => Vec::new(),
}
@@ -547,20 +541,16 @@ impl ContextualUserFragment for EnvironmentContext {
let mut lines = Vec::new();
match &self.environments {
EnvironmentContextEnvironments::Single(environment) => {
lines.push(format!(
" <cwd>{}</cwd>",
environment.cwd.to_string_lossy()
));
let cwd = environment.cwd.inferred_native_path_string();
lines.push(format!(" <cwd>{cwd}</cwd>"));
lines.push(format!(" <shell>{}</shell>", environment.shell));
}
EnvironmentContextEnvironments::Multiple(environments) => {
lines.push(" <environments>".to_string());
for environment in environments {
lines.push(format!(" <environment id=\"{}\">", environment.id));
lines.push(format!(
" <cwd>{}</cwd>",
environment.cwd.to_string_lossy()
));
let cwd = environment.cwd.inferred_native_path_string();
lines.push(format!(" <cwd>{cwd}</cwd>"));
lines.push(format!(" <shell>{}</shell>", environment.shell));
lines.push(" </environment>".to_string());
}
@@ -36,7 +36,7 @@ fn serialize_workspace_write_environment_context() {
let context = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: cwd.abs(),
cwd: PathUri::from_abs_path(&cwd.abs()),
shell: fake_shell_name(),
}],
Some("2026-02-26".to_string()),
@@ -58,6 +58,29 @@ fn serialize_workspace_write_environment_context() {
assert_eq!(context.render(), expected);
}
#[test]
fn serialize_environment_context_with_foreign_windows_cwd() {
let context = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "remote".to_string(),
cwd: PathUri::parse("file:///C:/windows").expect("Windows cwd URI"),
shell: "powershell".to_string(),
}],
/*current_date*/ None,
/*timezone*/ None,
/*network*/ None,
/*subagents*/ None,
);
assert_eq!(
context.render(),
r#"<environment_context>
<cwd>C:\windows</cwd>
<shell>powershell</shell>
</environment_context>"#
);
}
#[test]
fn serialize_environment_context_with_network() {
let network = NetworkContext::new(
@@ -67,7 +90,7 @@ fn serialize_environment_context_with_network() {
let context = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: test_path_buf("/repo").abs(),
cwd: PathUri::from_abs_path(&test_abs_path("/repo")),
shell: fake_shell_name(),
}],
Some("2026-02-26".to_string()),
@@ -129,7 +152,7 @@ fn serialize_environment_context_with_full_filesystem_profile() {
let mut context = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: test_path_buf("/repo").abs(),
cwd: PathUri::from_abs_path(&test_abs_path("/repo")),
shell: fake_shell_name(),
}],
/*current_date*/ None,
@@ -167,7 +190,7 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() {
let repo_private = repo.join("private");
let item = TurnContextItem {
turn_id: None,
cwd: test_path_buf("/not-the-workspace"),
cwd: PathUri::from_abs_path(&test_abs_path("/not-the-workspace")),
workspace_roots: Some(vec![repo.clone(), other_repo.clone()]),
current_date: None,
timezone: None,
@@ -186,7 +209,9 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() {
summary: codex_protocol::config_types::ReasoningSummary::Auto,
};
let context = EnvironmentContext::from_turn_context_item(&item, fake_shell_name()).render();
let context = EnvironmentContext::from_turn_context_item(&item, fake_shell_name())
.expect("turn context should hydrate")
.render();
assert!(
context.contains(&format!(
@@ -234,7 +259,7 @@ fn equals_except_shell_compares_cwd() {
let context1 = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: test_abs_path("/repo"),
cwd: PathUri::from_abs_path(&test_abs_path("/repo")),
shell: fake_shell_name(),
}],
/*current_date*/ None,
@@ -245,7 +270,7 @@ fn equals_except_shell_compares_cwd() {
let context2 = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: test_abs_path("/repo"),
cwd: PathUri::from_abs_path(&test_abs_path("/repo")),
shell: fake_shell_name(),
}],
/*current_date*/ None,
@@ -261,7 +286,7 @@ fn equals_except_shell_compares_cwd_differences() {
let context1 = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: test_abs_path("/repo1"),
cwd: PathUri::from_abs_path(&test_abs_path("/repo1")),
shell: fake_shell_name(),
}],
/*current_date*/ None,
@@ -272,7 +297,7 @@ fn equals_except_shell_compares_cwd_differences() {
let context2 = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: test_abs_path("/repo2"),
cwd: PathUri::from_abs_path(&test_abs_path("/repo2")),
shell: fake_shell_name(),
}],
/*current_date*/ None,
@@ -289,7 +314,7 @@ fn equals_except_shell_ignores_shell() {
let context1 = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: test_abs_path("/repo"),
cwd: PathUri::from_abs_path(&test_abs_path("/repo")),
shell: "bash".to_string(),
}],
/*current_date*/ None,
@@ -300,7 +325,7 @@ fn equals_except_shell_ignores_shell() {
let context2 = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "other".to_string(),
cwd: test_abs_path("/repo"),
cwd: PathUri::from_abs_path(&test_abs_path("/repo")),
shell: "zsh".to_string(),
}],
/*current_date*/ None,
@@ -317,7 +342,7 @@ fn serialize_environment_context_with_subagents() {
let context = EnvironmentContext::new(
vec![EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: test_path_buf("/repo").abs(),
cwd: PathUri::from_abs_path(&test_abs_path("/repo")),
shell: fake_shell_name(),
}],
Some("2026-02-26".to_string()),
@@ -351,12 +376,12 @@ fn serialize_environment_context_with_multiple_selected_environments() {
vec![
EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: local_cwd.abs(),
cwd: PathUri::from_abs_path(&local_cwd.abs()),
shell: "bash".to_string(),
},
EnvironmentContextEnvironment {
id: "remote".to_string(),
cwd: remote_cwd.abs(),
cwd: PathUri::from_abs_path(&remote_cwd.abs()),
shell: "bash".to_string(),
},
],
@@ -396,12 +421,12 @@ fn serialize_environment_context_prefers_environment_shell_when_present() {
vec![
EnvironmentContextEnvironment {
id: "local".to_string(),
cwd: local_cwd.abs(),
cwd: PathUri::from_abs_path(&local_cwd.abs()),
shell: "powershell".to_string(),
},
EnvironmentContextEnvironment {
id: "remote".to_string(),
cwd: remote_cwd.abs(),
cwd: PathUri::from_abs_path(&remote_cwd.abs()),
shell: "cmd".to_string(),
},
],
@@ -23,13 +23,13 @@ use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::TurnContextItem;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_output_truncation::truncate_text;
use codex_utils_path_uri::PathUri;
use image::ImageBuffer;
use image::ImageFormat;
use image::Luma;
use image::Rgba;
use pretty_assertions::assert_eq;
use regex_lite::Regex;
use std::path::PathBuf;
const EXEC_FORMAT_MAX_BYTES: usize = 10_000;
const EXEC_FORMAT_MAX_TOKENS: usize = 2_500;
@@ -127,7 +127,12 @@ fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem {
fn reference_context_item() -> TurnContextItem {
TurnContextItem {
turn_id: Some("reference-turn".to_string()),
cwd: PathBuf::from("/tmp/reference-cwd"),
cwd: PathUri::from_path(
std::env::current_dir()
.expect("current directory")
.join("reference-cwd"),
)
.expect("absolute reference cwd"),
workspace_roots: None,
current_date: Some("2026-03-23".to_string()),
timezone: Some("America/Los_Angeles".to_string()),
+23 -19
View File
@@ -22,40 +22,44 @@ fn build_environment_update_item(
previous: Option<&TurnContextItem>,
next: &TurnContext,
shell: &Shell,
) -> Option<ResponseItem> {
) -> std::io::Result<Option<ResponseItem>> {
if !next.config.include_environment_context {
return None;
return Ok(None);
}
let prev = previous?;
let prev_context = EnvironmentContext::from_turn_context_item(prev, shell.name().to_string());
let Some(prev) = previous else {
return Ok(None);
};
let prev_context = EnvironmentContext::from_turn_context_item(prev, shell.name().to_string())?;
let next_context = EnvironmentContext::from_turn_context(next, shell);
if prev_context.equals_except_shell(&next_context) {
return None;
return Ok(None);
}
Some(ContextualUserFragment::into(
EnvironmentContext::diff_from_turn_context_item(prev, &next_context),
))
Ok(Some(ContextualUserFragment::into(
EnvironmentContext::diff_from_turn_context_item(prev, &next_context)?,
)))
}
fn build_permissions_update_item(
previous: Option<&TurnContextItem>,
next: &TurnContext,
exec_policy: &Policy,
) -> Option<String> {
) -> std::io::Result<Option<String>> {
if !next.config.include_permissions_instructions {
return None;
return Ok(None);
}
let prev = previous?;
if prev.permission_profile() == next.permission_profile()
let Some(prev) = previous else {
return Ok(None);
};
if prev.permission_profile()? == next.permission_profile()
&& prev.approval_policy == next.approval_policy.value()
{
return None;
return Ok(None);
}
Some(
Ok(Some(
PermissionsInstructions::from_permission_profile(
&next.permission_profile,
next.approval_policy.value(),
@@ -67,7 +71,7 @@ fn build_permissions_update_item(
next.features.enabled(Feature::RequestPermissionsTool),
)
.render(),
)
))
}
fn build_collaboration_mode_update_item(
@@ -214,17 +218,17 @@ pub(crate) fn build_settings_update_items(
shell: &Shell,
exec_policy: &Policy,
personality_feature_enabled: bool,
) -> Vec<ResponseItem> {
) -> std::io::Result<Vec<ResponseItem>> {
// TODO(ccunningham): build_settings_update_items still does not cover every
// model-visible item emitted by build_initial_context. Persist the remaining
// inputs or add explicit replay events so fork/resume can diff everything
// deterministically.
let contextual_user_message = build_environment_update_item(previous, next, shell);
let contextual_user_message = build_environment_update_item(previous, next, shell)?;
let developer_update_sections = [
// Keep model-switch instructions first so model-specific guidance is read before
// any other context diffs on this turn.
build_model_instructions_update_item(previous_turn_settings, next),
build_permissions_update_item(previous, next, exec_policy),
build_permissions_update_item(previous, next, exec_policy)?,
build_collaboration_mode_update_item(previous, next),
build_realtime_update_item(previous, previous_turn_settings, next),
build_personality_update_item(previous, next, personality_feature_enabled),
@@ -240,5 +244,5 @@ pub(crate) fn build_settings_update_items(
if let Some(contextual_user_message) = contextual_user_message {
items.push(contextual_user_message);
}
items
Ok(items)
}
+1 -1
View File
@@ -77,7 +77,7 @@ pub(crate) async fn build_prompt_input_from_session(
) -> CodexResult<Vec<ResponseItem>> {
let turn_context = sess.new_default_turn().await;
sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref())
.await;
.await?;
if !input.is_empty() {
let response_item = sess.response_item_from_user_input(turn_context.as_ref(), input);
+13 -10
View File
@@ -1621,7 +1621,7 @@ impl Session {
&self,
reference_context_item: Option<&TurnContextItem>,
current_context: &TurnContext,
) -> Vec<ResponseItem> {
) -> CodexResult<Vec<ResponseItem>> {
// TODO: Make context updates a pure diff of persisted previous/current TurnContextItem
// state so replay/backtracking is deterministic. Runtime inputs that affect model-visible
// context (shell, exec policy, feature gates, previous-turn bridge) should be persisted
@@ -1632,13 +1632,15 @@ impl Session {
};
let shell = self.user_shell();
let exec_policy = self.services.exec_policy.current();
crate::context_manager::updates::build_settings_update_items(
reference_context_item,
previous_turn_settings.as_ref(),
current_context,
shell.as_ref(),
exec_policy.as_ref(),
self.features.enabled(Feature::Personality),
Ok(
crate::context_manager::updates::build_settings_update_items(
reference_context_item,
previous_turn_settings.as_ref(),
current_context,
shell.as_ref(),
exec_policy.as_ref(),
self.features.enabled(Feature::Personality),
)?,
)
}
@@ -3186,7 +3188,7 @@ impl Session {
pub(crate) async fn record_context_updates_and_set_reference_context_item(
&self,
turn_context: &TurnContext,
) {
) -> CodexResult<()> {
let reference_context_item = {
let state = self.state.lock().await;
state.reference_context_item()
@@ -3197,7 +3199,7 @@ impl Session {
} else {
// Steady-state path: append only context diffs to minimize token overhead.
self.build_settings_update_items(reference_context_item.as_ref(), turn_context)
.await
.await?
};
let turn_context_item = turn_context.to_turn_context_item();
if !context_items.is_empty() {
@@ -3213,6 +3215,7 @@ impl Session {
// context items. This keeps later runtime diffing aligned with the current turn state.
let mut state = self.state.lock().await;
state.set_reference_context_item(Some(turn_context_item));
Ok(())
}
pub(crate) async fn update_token_usage_info(
@@ -9,6 +9,7 @@ use codex_protocol::protocol::CompactedItem;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::ResumedHistory;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
@@ -88,7 +89,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
cwd: PathUri::from_abs_path(&turn_context.cwd),
workspace_roots: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
@@ -128,7 +129,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
let mut previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
cwd: PathUri::from_abs_path(&turn_context.cwd),
workspace_roots: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
@@ -989,7 +990,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
cwd: PathUri::from_abs_path(&turn_context.cwd),
workspace_roots: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
@@ -1071,7 +1072,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
serde_json::to_value(Some(TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
cwd: PathUri::from_abs_path(&turn_context.cwd),
workspace_roots: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
@@ -1101,7 +1102,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
cwd: PathUri::from_abs_path(&turn_context.cwd),
workspace_roots: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
@@ -1223,7 +1224,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
let current_context_item = TurnContextItem {
turn_id: Some(current_turn_id.clone()),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
cwd: PathUri::from_abs_path(&turn_context.cwd),
workspace_roots: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
@@ -1343,7 +1344,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
cwd: PathUri::from_abs_path(&turn_context.cwd),
workspace_roots: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
@@ -1506,7 +1507,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
cwd: PathUri::from_abs_path(&turn_context.cwd),
workspace_roots: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
+46 -16
View File
@@ -1874,7 +1874,8 @@ async fn resumed_history_injects_initial_context_on_first_context_update_only()
session
.record_context_updates_and_set_reference_context_item(&turn_context)
.await;
.await
.expect("context updates should hydrate");
let initial_context = session.build_initial_context(&turn_context).await;
expected.extend(initial_context);
let history_after_seed = session.clone_history().await;
@@ -1882,7 +1883,8 @@ async fn resumed_history_injects_initial_context_on_first_context_update_only()
session
.record_context_updates_and_set_reference_context_item(&turn_context)
.await;
.await
.expect("context updates should hydrate");
let history_after_second_seed = session.clone_history().await;
assert_eq!(
history_after_seed.raw_items(),
@@ -2673,7 +2675,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
#[allow(deprecated)]
cwd: turn_context.cwd.to_path_buf(),
cwd: PathUri::from_abs_path(&turn_context.cwd),
workspace_roots: None,
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
@@ -7290,7 +7292,8 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes(
let reference_context_item = previous_context.to_turn_context_item();
let update_items = session
.build_settings_update_items(Some(&reference_context_item), &current_context)
.await;
.await
.expect("settings updates should hydrate");
let environment_update = user_input_texts(&update_items)
.into_iter()
@@ -7360,7 +7363,8 @@ async fn build_settings_update_items_emits_environment_item_for_time_changes() {
let reference_context_item = previous_context.to_turn_context_item();
let update_items = session
.build_settings_update_items(Some(&reference_context_item), &current_context)
.await;
.await
.expect("settings updates should hydrate");
let environment_update = user_input_texts(&update_items)
.into_iter()
@@ -7388,7 +7392,8 @@ async fn build_settings_update_items_omits_environment_item_when_disabled() {
let reference_context_item = previous_context.to_turn_context_item();
let update_items = session
.build_settings_update_items(Some(&reference_context_item), &current_context)
.await;
.await
.expect("settings updates should hydrate");
let user_texts = user_input_texts(&update_items);
assert!(
@@ -7416,7 +7421,8 @@ async fn build_settings_update_items_emits_realtime_start_when_session_becomes_l
Some(&previous_context.to_turn_context_item()),
&current_context,
)
.await;
.await
.expect("settings updates should hydrate");
let developer_texts = developer_input_texts(&update_items);
assert!(
@@ -7444,7 +7450,8 @@ async fn build_settings_update_items_emits_realtime_end_when_session_stops_being
Some(&previous_context.to_turn_context_item()),
&current_context,
)
.await;
.await
.expect("settings updates should hydrate");
let developer_texts = developer_input_texts(&update_items);
assert!(
@@ -7478,7 +7485,8 @@ async fn build_settings_update_items_uses_previous_turn_settings_for_realtime_en
.await;
let update_items = session
.build_settings_update_items(Some(&previous_context_item), &current_context)
.await;
.await
.expect("settings updates should hydrate");
let developer_texts = developer_input_texts(&update_items);
assert!(
@@ -8130,6 +8138,21 @@ async fn turn_context_item_uses_turn_context_comp_hash_snapshot() {
);
}
#[tokio::test]
async fn turn_context_item_stores_primary_environment_cwd_uri() {
let (_session, mut turn_context) = make_session_and_context().await;
let environment = turn_context.environments.turn_environments[0].clone();
let cwd = PathUri::parse("file:///C:/windows").expect("Windows cwd URI");
turn_context.environments.turn_environments[0] = TurnEnvironment::new(
"remote".to_string(),
environment.environment,
cwd.clone(),
environment.shell,
);
assert_eq!(turn_context.to_turn_context_item().cwd, cwd);
}
#[tokio::test]
async fn turn_context_item_omits_legacy_equivalent_file_system_sandbox_policy() {
let (_session, turn_context) = make_session_and_context().await;
@@ -8171,7 +8194,8 @@ async fn record_context_updates_and_set_reference_context_item_injects_full_cont
let (session, turn_context) = make_session_and_context().await;
session
.record_context_updates_and_set_reference_context_item(&turn_context)
.await;
.await
.expect("context updates should hydrate");
let history = session.clone_history().await;
let initial_context = session.build_initial_context(&turn_context).await;
assert_eq!(history.raw_items().to_vec(), initial_context);
@@ -8202,7 +8226,8 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co
.await;
session
.record_context_updates_and_set_reference_context_item(&turn_context)
.await;
.await
.expect("context updates should hydrate");
{
let mut state = session.state.lock().await;
state.set_reference_context_item(/*item*/ None);
@@ -8216,7 +8241,8 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co
session
.record_context_updates_and_set_reference_context_item(&turn_context)
.await;
.await
.expect("context updates should hydrate");
let history = session.clone_history().await;
let mut expected_history = vec![compacted_summary];
@@ -8246,12 +8272,14 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline
let update_items = session
.build_settings_update_items(Some(&previous_context_item), &turn_context)
.await;
.await
.expect("settings updates should hydrate");
assert_eq!(update_items, Vec::new());
session
.record_context_updates_and_set_reference_context_item(&turn_context)
.await;
.await
.expect("context updates should hydrate");
assert_eq!(
session.clone_history().await.raw_items().to_vec(),
@@ -8298,7 +8326,8 @@ async fn record_context_updates_and_set_reference_context_item_persists_split_fi
session
.record_context_updates_and_set_reference_context_item(&turn_context)
.await;
.await
.expect("context updates should hydrate");
session.ensure_rollout_materialized().await;
session.flush_rollout().await.expect("rollout should flush");
@@ -8382,7 +8411,8 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei
.await;
session
.record_context_updates_and_set_reference_context_item(&turn_context)
.await;
.await
.expect("context updates should hydrate");
session.ensure_rollout_materialized().await;
session.flush_rollout().await.expect("rollout should flush");
+10 -2
View File
@@ -159,8 +159,16 @@ pub(crate) async fn run_turn(
return None;
}
sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref())
.await;
if let Err(err) = sess
.record_context_updates_and_set_reference_context_item(turn_context.as_ref())
.await
{
let error = err.to_codex_protocol_error();
sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone())
.await;
error!(%err, "failed to hydrate persisted turn context");
return None;
}
let (injection_items, explicitly_enabled_connectors) =
build_skills_and_plugins(&sess, turn_context.as_ref(), &input, &cancellation_token).await?;
+9 -2
View File
@@ -396,10 +396,17 @@ impl TurnContext {
pub(crate) fn to_turn_context_item(&self) -> TurnContextItem {
let workspace_roots = self.config.effective_workspace_roots();
let cwd = self
.environments
.primary()
.map(|environment| environment.cwd().clone())
.unwrap_or_else(|| {
#[allow(deprecated)]
PathUri::from_abs_path(&self.cwd)
});
TurnContextItem {
turn_id: Some(self.sub_id.clone()),
#[allow(deprecated)]
cwd: self.cwd.to_path_buf(),
cwd,
workspace_roots: (!workspace_roots.is_empty()).then_some(workspace_roots),
current_date: self.current_date.clone(),
timezone: self.timezone.clone(),
@@ -38,6 +38,7 @@ use core_test_support::wait_for_event;
use codex_utils_path_uri::LegacyAppPathString;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeMap;
use tempfile::TempDir;
@@ -246,6 +247,51 @@ async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Resul
)
.await??;
let requests = server
.received_requests()
.await
.context("failed to fetch received requests")?;
let first_request = requests
.iter()
.find(|request| request.url.path().ends_with("/responses"))
.context("turn should send a Responses request")?;
let body = first_request.body_json::<Value>()?;
let environment_context = body["input"]
.as_array()
.into_iter()
.flatten()
.filter(|item| item.get("role").and_then(Value::as_str) == Some("user"))
.filter_map(|item| item.get("content").and_then(Value::as_array))
.flatten()
.filter_map(|content| content.get("text").and_then(Value::as_str))
.find(|text| text.starts_with("<environment_context>"))
.context("environment context should be model visible")?;
// The model should see the remote environment's shell, not the Linux app-server's
// host shell.
assert_eq!(
environment_context
.lines()
.find(|line| line.trim_start().starts_with("<shell>"))
.map(str::trim),
Some("<shell>powershell</shell>"),
);
// The model should see cwd using the remote environment's native path convention, not
// the Linux app-server's host path convention.
assert_eq!(
environment_context
.lines()
.find(|line| line.trim_start().starts_with("<cwd>"))
.map(str::trim),
Some(r"<cwd>C:\windows</cwd>"),
);
let host_workspace_roots = format!(
"<workspace_roots><root>{}</root></workspace_roots>",
codex_home.path().display()
);
// TODO(anp): Derive model-visible workspace roots from the selected remote environment
// and render them using its native path convention.
assert!(environment_context.contains(&host_workspace_roots));
Ok(())
})
.await
+2 -1
View File
@@ -14,6 +14,7 @@ use codex_protocol::protocol::TurnContextItem;
use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::protocol::UserMessageEvent;
use codex_protocol::protocol::WarningEvent;
use codex_utils_path_uri::PathUri;
use core::time::Duration;
use core_test_support::load_default_config_for_test;
use core_test_support::wait_for_event;
@@ -27,7 +28,7 @@ fn resume_history(
let turn_id = "resume-warning-seed-turn".to_string();
let turn_ctx = TurnContextItem {
turn_id: Some(turn_id.clone()),
cwd: config.cwd.to_path_buf(),
cwd: PathUri::from_abs_path(&config.cwd),
workspace_roots: None,
current_date: None,
timezone: None,
+5 -1
View File
@@ -1423,7 +1423,11 @@ async fn parse_latest_turn_context_cwd(path: &Path) -> Option<PathBuf> {
continue;
};
if let RolloutItem::TurnContext(item) = rollout_line.item {
return Some(item.cwd);
return item
.cwd
.to_abs_path()
.ok()
.map(AbsolutePathBuf::into_path_buf);
}
}
None
+57 -14
View File
@@ -2988,7 +2988,7 @@ pub struct TurnContextNetworkItem {
pub struct TurnContextItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_id: Option<String>,
pub cwd: PathBuf,
pub cwd: PathUri,
/// Effective workspace roots used to materialize symbolic
/// `:workspace_roots` filesystem permissions in `permission_profile`.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -3026,21 +3026,35 @@ pub struct TurnContextItem {
}
impl TurnContextItem {
pub fn permission_profile(&self) -> PermissionProfile {
self.permission_profile.clone().unwrap_or_else(|| {
let file_system_sandbox_policy =
self.file_system_sandbox_policy.clone().unwrap_or_else(|| {
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(
&self.sandbox_policy,
&self.cwd,
pub fn permission_profile(&self) -> std::io::Result<PermissionProfile> {
if let Some(permission_profile) = self.permission_profile.clone() {
return Ok(permission_profile);
}
let file_system_sandbox_policy = match self.file_system_sandbox_policy.clone() {
Some(file_system_sandbox_policy) => file_system_sandbox_policy,
None => {
let cwd = self.cwd.to_abs_path().map_err(|err| {
std::io::Error::new(
err.kind(),
format!(
"cannot hydrate legacy permission profile for cwd {}: {err}",
self.cwd
),
)
});
})?;
FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(
&self.sandbox_policy,
cwd.as_path(),
)
}
};
Ok(
PermissionProfile::from_runtime_permissions_with_enforcement(
SandboxEnforcement::from_legacy_sandbox_policy(&self.sandbox_policy),
&file_system_sandbox_policy,
NetworkSandboxPolicy::from(&self.sandbox_policy),
)
})
),
)
}
}
@@ -5301,21 +5315,50 @@ mod tests {
}
#[test]
fn turn_context_item_deserializes_without_network() -> Result<()> {
fn turn_context_item_accepts_legacy_cwd_and_serializes_path_uri() -> Result<()> {
let legacy_cwd = test_path_buf("/tmp");
let item: TurnContextItem = serde_json::from_value(json!({
"cwd": test_path_buf("/tmp"),
"cwd": legacy_cwd,
"approval_policy": "never",
"sandbox_policy": { "type": "danger-full-access" },
"model": "gpt-5",
"summary": "auto",
}))?;
let expected_cwd = PathUri::from_path(legacy_cwd)?;
assert_eq!(item.cwd, expected_cwd);
assert_eq!(
serde_json::to_value(&item)?["cwd"],
json!(expected_cwd.to_string())
);
assert_eq!(item.network, None);
assert_eq!(item.file_system_sandbox_policy, None);
assert_eq!(item.comp_hash, None);
Ok(())
}
#[test]
fn turn_context_item_rejects_legacy_policy_hydration_for_foreign_cwd() -> Result<()> {
let foreign_cwd = if cfg!(windows) {
"file:///tmp"
} else {
"file://server/share"
};
let item: TurnContextItem = serde_json::from_value(json!({
"cwd": foreign_cwd,
"approval_policy": "never",
"sandbox_policy": { "type": "workspace-write" },
"model": "gpt-5",
"summary": "auto",
}))?;
let err = item
.permission_profile()
.expect_err("foreign cwd should not hydrate a legacy permission profile");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
Ok(())
}
#[test]
fn multi_agent_version_uses_newest_present_session_meta_value() -> Result<()> {
let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?;
@@ -5353,7 +5396,7 @@ mod tests {
fn turn_context_item_serializes_network_when_present() -> Result<()> {
let item = TurnContextItem {
turn_id: None,
cwd: test_path_buf("/tmp"),
cwd: PathUri::from_abs_path(&test_path_buf("/tmp").abs()),
workspace_roots: None,
current_date: None,
timezone: None,
+1 -1
View File
@@ -111,7 +111,7 @@ pub async fn extract_metadata_from_rollout(
})?;
let mut metadata = builder.build(default_provider);
for item in &items {
apply_rollout_item(&mut metadata, item, default_provider);
apply_rollout_item(&mut metadata, item, default_provider)?;
}
if let Some(updated_at) = file_modified_time_utc(rollout_path).await {
metadata.updated_at = updated_at;
+2 -1
View File
@@ -69,7 +69,8 @@ async fn extract_metadata_from_rollout_uses_session_meta() {
let builder = builder_from_session_meta(&session_meta_line, path.as_path()).expect("builder");
let mut expected = builder.build("openai");
apply_rollout_item(&mut expected, &rollout_line.item, "openai");
apply_rollout_item(&mut expected, &rollout_line.item, "openai")
.expect("rollout item should apply");
expected.updated_at = file_modified_time_utc(&path).await.expect("mtime");
assert_eq!(outcome.metadata, expected);
+3 -2
View File
@@ -1765,15 +1765,16 @@ async fn resume_candidate_matches_cwd(
if let Ok((items, _, _)) = RolloutRecorder::load_rollout_items(rollout_path).await
&& let Some(latest_turn_context_cwd) = items.iter().rev().find_map(|item| match item {
RolloutItem::TurnContext(turn_context) => Some(turn_context.cwd.as_path()),
RolloutItem::TurnContext(turn_context) => Some(&turn_context.cwd),
RolloutItem::SessionMeta(_)
| RolloutItem::ResponseItem(_)
| RolloutItem::InterAgentCommunication(_)
| RolloutItem::Compacted(_)
| RolloutItem::EventMsg(_) => None,
})
&& let Ok(latest_turn_context_cwd) = latest_turn_context_cwd.to_abs_path()
{
return cwd_matches(latest_turn_context_cwd, cwd);
return cwd_matches(latest_turn_context_cwd.as_path(), cwd);
}
metadata::extract_metadata_from_rollout(rollout_path, default_provider)
+2 -1
View File
@@ -1134,7 +1134,8 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re
timestamp: "2025-01-03T13:00:01Z".to_string(),
item: RolloutItem::TurnContext(TurnContextItem {
turn_id: Some("turn-1".to_string()),
cwd: latest_cwd.clone(),
cwd: serde_json::from_value(serde_json::json!(&latest_cwd))
.expect("absolute latest cwd"),
workspace_roots: None,
current_date: None,
timezone: None,
+61 -23
View File
@@ -16,10 +16,10 @@ pub fn apply_rollout_item(
metadata: &mut ThreadMetadata,
item: &RolloutItem,
default_provider: &str,
) {
) -> std::io::Result<()> {
match item {
RolloutItem::SessionMeta(meta_line) => apply_session_meta_from_item(metadata, meta_line),
RolloutItem::TurnContext(turn_ctx) => apply_turn_context(metadata, turn_ctx),
RolloutItem::TurnContext(turn_ctx) => apply_turn_context(metadata, turn_ctx)?,
RolloutItem::EventMsg(event) => apply_event_msg(metadata, event),
RolloutItem::ResponseItem(item) => apply_response_item(metadata, item),
RolloutItem::InterAgentCommunication(_) => {}
@@ -28,6 +28,7 @@ pub fn apply_rollout_item(
if metadata.model_provider.is_empty() {
metadata.model_provider = default_provider.to_string();
}
Ok(())
}
/// Return whether this rollout item can mutate thread metadata stored in SQLite.
@@ -72,15 +73,21 @@ fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &Sessi
}
}
fn apply_turn_context(metadata: &mut ThreadMetadata, turn_ctx: &TurnContextItem) {
if metadata.cwd.as_os_str().is_empty() {
metadata.cwd = turn_ctx.cwd.clone();
fn apply_turn_context(
metadata: &mut ThreadMetadata,
turn_ctx: &TurnContextItem,
) -> std::io::Result<()> {
if metadata.cwd.as_os_str().is_empty()
&& let Ok(cwd) = turn_ctx.cwd.to_abs_path()
{
metadata.cwd = cwd.into_path_buf();
}
metadata.model = Some(turn_ctx.model.clone());
metadata.reasoning_effort = turn_ctx.effort.clone();
metadata.sandbox_policy =
serde_json::to_string(&turn_ctx.permission_profile()).unwrap_or_default();
serde_json::to_string(&turn_ctx.permission_profile()?).unwrap_or_default();
metadata.approval_mode = enum_to_string(&turn_ctx.approval_policy);
Ok(())
}
fn apply_event_msg(metadata: &mut ThreadMetadata, event: &EventMsg) {
@@ -194,7 +201,8 @@ mod tests {
metadata: None,
});
apply_rollout_item(&mut metadata, &item, "test-provider");
apply_rollout_item(&mut metadata, &item, "test-provider")
.expect("rollout item should apply");
assert_eq!(metadata.first_user_message, None);
assert_eq!(metadata.preview, None);
@@ -213,7 +221,8 @@ mod tests {
..Default::default()
}));
apply_rollout_item(&mut metadata, &item, "test-provider");
apply_rollout_item(&mut metadata, &item, "test-provider")
.expect("rollout item should apply");
assert_eq!(
metadata.first_user_message.as_deref(),
@@ -235,7 +244,8 @@ mod tests {
..Default::default()
}));
apply_rollout_item(&mut metadata, &item, "test-provider");
apply_rollout_item(&mut metadata, &item, "test-provider")
.expect("rollout item should apply");
assert_eq!(
metadata.first_user_message.as_deref(),
@@ -260,7 +270,8 @@ mod tests {
..Default::default()
}));
apply_rollout_item(&mut metadata, &item, "test-provider");
apply_rollout_item(&mut metadata, &item, "test-provider")
.expect("rollout item should apply");
assert_eq!(metadata.first_user_message, None);
assert_eq!(metadata.preview, None);
@@ -286,7 +297,8 @@ mod tests {
},
}));
apply_rollout_item(&mut metadata, &goal_item, "test-provider");
apply_rollout_item(&mut metadata, &goal_item, "test-provider")
.expect("rollout item should apply");
assert_eq!(metadata.preview.as_deref(), Some("optimize the benchmark"));
assert_eq!(metadata.first_user_message, None);
@@ -301,7 +313,8 @@ mod tests {
..Default::default()
}));
apply_rollout_item(&mut metadata, &user_item, "test-provider");
apply_rollout_item(&mut metadata, &user_item, "test-provider")
.expect("rollout item should apply");
assert_eq!(metadata.preview.as_deref(), Some("optimize the benchmark"));
assert_eq!(
@@ -344,12 +357,18 @@ mod tests {
git: None,
}),
"test-provider",
);
)
.expect("rollout item should apply");
apply_rollout_item(
&mut metadata,
&RolloutItem::TurnContext(TurnContextItem {
turn_id: Some("turn-1".to_string()),
cwd: PathBuf::from("/parent/workspace"),
cwd: serde_json::from_value(serde_json::json!(
std::env::current_dir()
.expect("current directory")
.join("parent/workspace")
))
.expect("absolute parent cwd"),
workspace_roots: None,
current_date: None,
timezone: None,
@@ -368,7 +387,8 @@ mod tests {
summary: codex_protocol::config_types::ReasoningSummary::Auto,
}),
"test-provider",
);
)
.expect("rollout item should apply");
assert_eq!(metadata.cwd, PathBuf::from("/child/worktree"));
let permission_profile: PermissionProfile = PermissionProfile::Disabled;
@@ -388,7 +408,12 @@ mod tests {
&mut metadata,
&RolloutItem::TurnContext(TurnContextItem {
turn_id: Some("turn-1".to_string()),
cwd: PathBuf::from("/workspace"),
cwd: serde_json::from_value(serde_json::json!(
std::env::current_dir()
.expect("current directory")
.join("workspace")
))
.expect("absolute workspace cwd"),
workspace_roots: None,
current_date: None,
timezone: None,
@@ -407,7 +432,8 @@ mod tests {
summary: codex_protocol::config_types::ReasoningSummary::Auto,
}),
"test-provider",
);
)
.expect("rollout item should apply");
assert_eq!(
metadata.sandbox_policy,
@@ -419,12 +445,16 @@ mod tests {
fn turn_context_sets_cwd_when_session_cwd_missing() {
let mut metadata = metadata_for_test();
metadata.cwd = PathBuf::new();
let fallback_cwd = std::env::current_dir()
.expect("current directory")
.join("fallback/workspace");
apply_rollout_item(
&mut metadata,
&RolloutItem::TurnContext(TurnContextItem {
turn_id: Some("turn-1".to_string()),
cwd: PathBuf::from("/fallback/workspace"),
cwd: serde_json::from_value(serde_json::json!(&fallback_cwd))
.expect("absolute fallback cwd"),
workspace_roots: None,
current_date: None,
timezone: None,
@@ -443,9 +473,10 @@ mod tests {
summary: codex_protocol::config_types::ReasoningSummary::Auto,
}),
"test-provider",
);
)
.expect("rollout item should apply");
assert_eq!(metadata.cwd, PathBuf::from("/fallback/workspace"));
assert_eq!(metadata.cwd, fallback_cwd);
}
#[test]
@@ -456,7 +487,12 @@ mod tests {
&mut metadata,
&RolloutItem::TurnContext(TurnContextItem {
turn_id: Some("turn-1".to_string()),
cwd: PathBuf::from("/fallback/workspace"),
cwd: serde_json::from_value(serde_json::json!(
std::env::current_dir()
.expect("current directory")
.join("fallback/workspace")
))
.expect("absolute fallback cwd"),
workspace_roots: None,
current_date: None,
timezone: None,
@@ -475,7 +511,8 @@ mod tests {
summary: codex_protocol::config_types::ReasoningSummary::Auto,
}),
"test-provider",
);
)
.expect("rollout item should apply");
assert_eq!(metadata.model.as_deref(), Some("gpt-5"));
assert_eq!(metadata.reasoning_effort, Some(ReasoningEffort::High));
@@ -511,7 +548,8 @@ mod tests {
git: None,
}),
"test-provider",
);
)
.expect("rollout item should apply");
assert_eq!(metadata.model, None);
assert_eq!(metadata.reasoning_effort, None);
+1 -1
View File
@@ -829,7 +829,7 @@ ON CONFLICT(id) DO UPDATE SET
.unwrap_or_else(|| builder.build(&self.default_provider));
metadata.rollout_path = builder.rollout_path.clone();
for item in items {
apply_rollout_item(&mut metadata, item, &self.default_provider);
apply_rollout_item(&mut metadata, item, &self.default_provider)?;
}
if let Some(existing_metadata) = existing_metadata.as_ref() {
metadata.prefer_existing_git_info(existing_metadata);
+2 -2
View File
@@ -125,7 +125,7 @@ impl LiveThread {
}
}
}
let metadata_sync = ThreadMetadataSync::for_resume(&params);
let metadata_sync = ThreadMetadataSync::for_resume(&params)?;
Ok(Self {
thread_id,
thread_store,
@@ -151,7 +151,7 @@ impl LiveThread {
.metadata_sync
.lock()
.await
.observe_appended_items(canonical_items.as_slice());
.observe_appended_items(canonical_items.as_slice())?;
if let Some(update) = update {
self.thread_store
.update_thread_metadata(UpdateThreadMetadataParams {
@@ -18,6 +18,8 @@ use crate::CreateThreadParams;
use crate::GitInfoPatch;
use crate::ResumeThreadParams;
use crate::ThreadMetadataPatch;
use crate::ThreadStoreError;
use crate::ThreadStoreResult;
const IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER: &str = "[Image]";
#[cfg(not(test))]
@@ -90,7 +92,7 @@ impl ThreadMetadataSync {
}
}
pub(crate) fn for_resume(params: &ResumeThreadParams) -> Self {
pub(crate) fn for_resume(params: &ResumeThreadParams) -> ThreadStoreResult<Self> {
let mut sync = Self {
thread_id: params.thread_id,
cwd_seen: params
@@ -108,11 +110,11 @@ impl ThreadMetadataSync {
defer_resume_update_until_append: false,
};
if let Some(history) = params.history.as_deref() {
let update = sync.observe_resume_history(history);
let update = sync.observe_resume_history(history)?;
sync.merge_pending_update(update);
sync.defer_resume_update_until_append = sync.pending_update.is_some();
}
sync
Ok(sync)
}
pub(crate) fn take_pending_update(&self) -> Option<PendingThreadMetadataPatch> {
@@ -148,7 +150,7 @@ impl ThreadMetadataSync {
pub(crate) fn observe_appended_items(
&mut self,
items: &[RolloutItem],
) -> Option<PendingThreadMetadataPatch> {
) -> ThreadStoreResult<Option<PendingThreadMetadataPatch>> {
self.defer_create_update_until_history_exists = false;
self.defer_resume_update_until_append = false;
let affects_metadata = items
@@ -157,7 +159,10 @@ impl ThreadMetadataSync {
let update = if affects_metadata {
self.observe_items(items)?
} else {
thread_updated_at_touch()
Some(thread_updated_at_touch())
};
let Some(update) = update else {
return Ok(None);
};
self.merge_pending_update(Some(update));
if !affects_metadata
@@ -169,12 +174,15 @@ impl ThreadMetadataSync {
Instant::now().duration_since(last_touch) < THREAD_UPDATED_AT_TOUCH_INTERVAL
})
{
return None;
return Ok(None);
}
self.take_pending_update()
Ok(self.take_pending_update())
}
fn observe_items(&mut self, items: &[RolloutItem]) -> Option<ThreadMetadataPatch> {
fn observe_items(
&mut self,
items: &[RolloutItem],
) -> ThreadStoreResult<Option<ThreadMetadataPatch>> {
self.observe_items_with_update(
items,
ThreadMetadataPatch {
@@ -184,7 +192,10 @@ impl ThreadMetadataSync {
)
}
fn observe_resume_history(&mut self, items: &[RolloutItem]) -> Option<ThreadMetadataPatch> {
fn observe_resume_history(
&mut self,
items: &[RolloutItem],
) -> ThreadStoreResult<Option<ThreadMetadataPatch>> {
self.observe_items_with_update(items, ThreadMetadataPatch::default())
}
@@ -192,9 +203,9 @@ impl ThreadMetadataSync {
&mut self,
items: &[RolloutItem],
mut update: ThreadMetadataPatch,
) -> Option<ThreadMetadataPatch> {
) -> ThreadStoreResult<Option<ThreadMetadataPatch>> {
if items.is_empty() {
return None;
return Ok(None);
}
for item in items {
match item {
@@ -227,14 +238,23 @@ impl ThreadMetadataSync {
}
}
RolloutItem::TurnContext(turn_ctx) => {
if !self.cwd_seen && !turn_ctx.cwd.as_os_str().is_empty() {
if !self.cwd_seen
&& let Ok(cwd) = turn_ctx.cwd.to_abs_path()
{
self.cwd_seen = true;
update.cwd = Some(turn_ctx.cwd.clone());
update.cwd = Some(cwd.into_path_buf());
}
update.model = Some(turn_ctx.model.clone());
update.reasoning_effort = turn_ctx.effort.clone();
update.approval_mode = Some(turn_ctx.approval_policy);
update.permission_profile = Some(turn_ctx.permission_profile());
update.permission_profile =
Some(turn_ctx.permission_profile().map_err(|err| {
ThreadStoreError::Internal {
message: format!(
"failed to hydrate turn permission profile: {err}"
),
}
})?);
}
RolloutItem::EventMsg(EventMsg::UserMessage(user)) => {
if let Some(preview) = user_message_preview(user) {
@@ -276,7 +296,7 @@ impl ThreadMetadataSync {
| RolloutItem::Compacted(_) => {}
}
}
Some(update)
Ok(Some(update))
}
fn merge_pending_update(&mut self, update: Option<ThreadMetadataPatch>) {
@@ -394,7 +414,8 @@ mod tests {
RolloutItem::SessionMeta(session_meta(thread_id)),
RolloutItem::EventMsg(EventMsg::UserMessage(user_message("hello metadata"))),
],
));
))
.expect("resume metadata should hydrate");
let update = sync.take_pending_update().expect("pending metadata update");
assert_eq!(
@@ -433,7 +454,8 @@ mod tests {
))),
RolloutItem::EventMsg(EventMsg::UserMessage(user_message("first user text"))),
],
));
))
.expect("resume metadata should hydrate");
let update = sync.take_pending_update().expect("pending metadata update");
assert_eq!(update.patch.preview.as_deref(), Some("ship the refactor"));
@@ -452,7 +474,8 @@ mod tests {
vec![RolloutItem::EventMsg(EventMsg::UserMessage(user_message(
"first user text",
)))],
));
))
.expect("resume metadata should hydrate");
let pending = sync.take_pending_update().expect("pending resume metadata");
sync.mark_pending_update_applied(&pending);
@@ -460,6 +483,7 @@ mod tests {
.observe_appended_items(&[RolloutItem::EventMsg(EventMsg::UserMessage(user_message(
"later user text",
)))])
.expect("appended metadata should hydrate")
.expect("updated_at touch");
assert_eq!(update.patch.preview, None);
@@ -471,7 +495,8 @@ mod tests {
#[test]
fn metadata_irrelevant_items_coalesce_updated_at_touches() {
let thread_id = ThreadId::new();
let mut sync = ThreadMetadataSync::for_resume(&resume_params(thread_id, Vec::new()));
let mut sync = ThreadMetadataSync::for_resume(&resume_params(thread_id, Vec::new()))
.expect("resume metadata should hydrate");
let item = RolloutItem::Compacted(CompactedItem {
message: "compacted".to_string(),
replacement_history: None,
@@ -480,12 +505,14 @@ mod tests {
let first = sync
.observe_appended_items(std::slice::from_ref(&item))
.expect("appended metadata should hydrate")
.expect("first touch should apply immediately");
assert!(first.patch.updated_at.is_some());
sync.mark_pending_update_applied(&first);
assert!(
sync.observe_appended_items(std::slice::from_ref(&item))
.expect("appended metadata should hydrate")
.is_none(),
"second touch inside the coalescing window should wait for a barrier"
);
@@ -504,7 +531,8 @@ mod tests {
RolloutItem::SessionMeta(session_meta(thread_id)),
RolloutItem::EventMsg(EventMsg::UserMessage(user_message("hello metadata"))),
],
));
))
.expect("resume metadata should hydrate");
assert!(
sync.take_pending_update_for_existing_history().is_none(),
@@ -514,6 +542,7 @@ mod tests {
sync.observe_appended_items(&[RolloutItem::EventMsg(EventMsg::UserMessage(
user_message("new append"),
))])
.expect("appended metadata should hydrate")
.is_some(),
"the first append should flush resume metadata together with append metadata"
);
+13
View File
@@ -164,6 +164,19 @@ impl PathUri {
}
}
/// Renders this URI using the native path syntax inferred from its shape.
///
/// This is independent of the current host: a Windows URI renders with
/// Windows separators on every host. If the convention cannot be inferred
/// or the URI cannot be represented using that convention, the canonical
/// URI string is returned instead.
pub fn inferred_native_path_string(&self) -> String {
self.infer_path_convention()
.and_then(|convention| LegacyAppPathString::from_path_uri(self, convention).ok())
.map(LegacyAppPathString::into_string)
.unwrap_or_else(|| self.to_string())
}
/// Returns the decoded final URI path segment, or `None` for the URI root
/// or an opaque fallback URI created by [`Self::from_abs_path`].
///
+22
View File
@@ -100,6 +100,28 @@ fn drive_shaped_posix_uri_is_intentionally_inferred_as_windows() {
assert_eq!(path.infer_path_convention(), Some(PathConvention::Windows));
}
#[test]
fn inferred_native_path_string_uses_the_inferred_convention() {
for (uri, expected) in [
("file:///home/alice/a%20file.rs", "/home/alice/a file.rs"),
(
"file:///C:/Users/Alice%20Smith/main.rs",
r"C:\Users\Alice Smith\main.rs",
),
("file://server/share/main.rs", r"\\server\share\main.rs"),
("file://server/", "file://server/"),
("file:///%00/bad/path/YQ", "file:///%00/bad/path/YQ"),
] {
let path = PathUri::parse(uri).expect("valid path URI");
assert_eq!(
path.inferred_native_path_string(),
expected,
"rendering {uri}"
);
}
}
#[cfg(windows)]
#[test]
fn file_uri_falls_back_for_windows_prefixes_without_a_uri_representation() {