core: load AGENTS.md from foreign environments (#28958)

## Why

Make it possible to load AGENTS.md from remote exec-servers whose OS is
different than app-server.

## What

- keep `AGENTS.md` discovery and provenance as `PathUri`, with
root-aware parent and ancestor traversal
- expose lifecycle instruction sources as legacy app-server path strings
in events while retaining `PathUri` internally
- preserve and test mixed POSIX and Windows paths in model context and
TUI status output
- cover remote Windows loading end to end by seeding the Wine prefix
through host filesystem APIs
- fix bug in `PathUri`'s parent() implementation that would erase
Windows drive letters
This commit is contained in:
Adam Perry @ OpenAI
2026-06-18 15:06:23 -07:00
committed by GitHub
Unverified
parent 406062c3af
commit dce673905a
38 changed files with 550 additions and 203 deletions
+8
View File
@@ -105,6 +105,14 @@ impl WineTestCommand {
}
impl WineTestProcess {
/// Returns the host path to this process's isolated Wine prefix.
pub fn prefix_path(&self) -> &Path {
let Some(processes) = self.processes.as_ref() else {
panic!("Wine process guard is missing");
};
processes.prefix.path()
}
/// Takes the piped standard output of the Wine process.
///
/// This may only be called once for a process created by
@@ -16973,9 +16973,9 @@
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"description": "Environment-native paths to instruction source files currently loaded for this thread.",
"items": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
"$ref": "#/definitions/v2/LegacyAppPathString"
},
"type": "array"
},
@@ -18688,9 +18688,9 @@
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"description": "Environment-native paths to instruction source files currently loaded for this thread.",
"items": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
"$ref": "#/definitions/v2/LegacyAppPathString"
},
"type": "array"
},
@@ -19105,9 +19105,9 @@
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"description": "Environment-native paths to instruction source files currently loaded for this thread.",
"items": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
"$ref": "#/definitions/v2/LegacyAppPathString"
},
"type": "array"
},
@@ -14752,9 +14752,9 @@
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"description": "Environment-native paths to instruction source files currently loaded for this thread.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
},
"type": "array"
},
@@ -16467,9 +16467,9 @@
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"description": "Environment-native paths to instruction source files currently loaded for this thread.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
},
"type": "array"
},
@@ -16884,9 +16884,9 @@
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"description": "Environment-native paths to instruction source files currently loaded for this thread.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
},
"type": "array"
},
@@ -2368,9 +2368,9 @@
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"description": "Environment-native paths to instruction source files currently loaded for this thread.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
},
"type": "array"
},
@@ -2394,9 +2394,9 @@
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"description": "Environment-native paths to instruction source files currently loaded for this thread.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
},
"type": "array"
},
@@ -2368,9 +2368,9 @@
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"description": "Environment-native paths to instruction source files currently loaded for this thread.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
"$ref": "#/definitions/LegacyAppPathString"
},
"type": "array"
},
@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { LegacyAppPathString } from "../LegacyAppPathString";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AskForApproval } from "./AskForApproval";
@@ -9,9 +10,9 @@ import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /**
* Instruction source files currently loaded for this thread.
* Environment-native paths to instruction source files currently loaded for this thread.
*/
instructionSources: Array<AbsolutePathBuf>, approvalPolicy: AskForApproval, /**
instructionSources: Array<LegacyAppPathString>, approvalPolicy: AskForApproval, /**
* Reviewer currently used for approval requests on this thread.
*/
approvalsReviewer: ApprovalsReviewer, /**
@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { LegacyAppPathString } from "../LegacyAppPathString";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AskForApproval } from "./AskForApproval";
@@ -9,9 +10,9 @@ import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadResumeResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /**
* Instruction source files currently loaded for this thread.
* Environment-native paths to instruction source files currently loaded for this thread.
*/
instructionSources: Array<AbsolutePathBuf>, approvalPolicy: AskForApproval, /**
instructionSources: Array<LegacyAppPathString>, approvalPolicy: AskForApproval, /**
* Reviewer currently used for approval requests on this thread.
*/
approvalsReviewer: ApprovalsReviewer, /**
@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { LegacyAppPathString } from "../LegacyAppPathString";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AskForApproval } from "./AskForApproval";
@@ -9,9 +10,9 @@ import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadStartResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /**
* Instruction source files currently loaded for this thread.
* Environment-native paths to instruction source files currently loaded for this thread.
*/
instructionSources: Array<AbsolutePathBuf>, approvalPolicy: AskForApproval, /**
instructionSources: Array<LegacyAppPathString>, approvalPolicy: AskForApproval, /**
* Reviewer currently used for approval requests on this thread.
*/
approvalsReviewer: ApprovalsReviewer, /**
@@ -2573,7 +2573,11 @@ mod tests {
service_tier: None,
cwd,
runtime_workspace_roots: Vec::new(),
instruction_sources: vec![absolute_path("/tmp/AGENTS.md")],
instruction_sources: vec![
codex_utils_path_uri::LegacyAppPathString::from_abs_path(&absolute_path(
"/tmp/AGENTS.md",
)),
],
approval_policy: v2::AskForApproval::OnFailure,
approvals_reviewer: v2::ApprovalsReviewer::User,
sandbox: v2::SandboxPolicy::DangerFullAccess,
@@ -36,6 +36,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use codex_utils_path_uri::LegacyAppPathString;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use serde_json::Value as JsonValue;
use serde_json::json;
@@ -3685,17 +3686,49 @@ fn thread_lifecycle_responses_default_missing_optional_fields() {
serde_json::from_value(response.clone()).expect("thread/start response");
let resume: ThreadResumeResponse =
serde_json::from_value(response.clone()).expect("thread/resume response");
let fork: ThreadForkResponse = serde_json::from_value(response).expect("thread/fork response");
let fork: ThreadForkResponse =
serde_json::from_value(response.clone()).expect("thread/fork response");
assert_eq!(start.instruction_sources, Vec::<AbsolutePathBuf>::new());
assert_eq!(start.instruction_sources, Vec::<LegacyAppPathString>::new());
assert_eq!(start.thread.parent_thread_id, None);
assert_eq!(start.thread.recency_at, None);
assert_eq!(resume.instruction_sources, Vec::<AbsolutePathBuf>::new());
assert_eq!(fork.instruction_sources, Vec::<AbsolutePathBuf>::new());
assert_eq!(
resume.instruction_sources,
Vec::<LegacyAppPathString>::new()
);
assert_eq!(fork.instruction_sources, Vec::<LegacyAppPathString>::new());
assert_eq!(start.active_permission_profile, None);
assert_eq!(resume.active_permission_profile, None);
assert_eq!(resume.initial_turns_page, None);
assert_eq!(fork.active_permission_profile, None);
let foreign_source: LegacyAppPathString =
serde_json::from_value(json!(r"C:\workspace\AGENTS.md")).expect("foreign source");
let mut response_with_foreign_source = response;
response_with_foreign_source["instructionSources"] = json!([foreign_source.as_str()]);
let start: ThreadStartResponse = serde_json::from_value(response_with_foreign_source.clone())
.expect("thread/start response with foreign source");
let resume: ThreadResumeResponse = serde_json::from_value(response_with_foreign_source.clone())
.expect("thread/resume response with foreign source");
let fork: ThreadForkResponse = serde_json::from_value(response_with_foreign_source)
.expect("thread/fork response with foreign source");
assert_eq!(start.instruction_sources, vec![foreign_source.clone()]);
assert_eq!(resume.instruction_sources, vec![foreign_source.clone()]);
assert_eq!(fork.instruction_sources, vec![foreign_source]);
let foreign_source_uri =
PathUri::parse("file:///C:/workspace/AGENTS.md").expect("foreign source URI");
assert_eq!(
start.instruction_source_path_uris(),
vec![foreign_source_uri.clone()]
);
assert_eq!(
resume.instruction_source_path_uris(),
vec![foreign_source_uri.clone()]
);
assert_eq!(
fork.instruction_source_path_uris(),
vec![foreign_source_uri]
);
}
#[test]
@@ -26,6 +26,8 @@ use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus;
use codex_protocol::protocol::TokenUsage as CoreTokenUsage;
use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::LegacyAppPathString;
use codex_utils_path_uri::PathUri;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -161,9 +163,9 @@ pub struct ThreadStartResponse {
#[experimental("thread/start.runtimeWorkspaceRoots")]
#[serde(default)]
pub runtime_workspace_roots: Vec<AbsolutePathBuf>,
/// Instruction source files currently loaded for this thread.
/// Environment-native paths to instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<AbsolutePathBuf>,
pub instruction_sources: Vec<LegacyAppPathString>,
#[experimental(nested)]
pub approval_policy: AskForApproval,
/// Reviewer currently used for approval requests on this thread.
@@ -179,6 +181,13 @@ pub struct ThreadStartResponse {
pub reasoning_effort: Option<ReasoningEffort>,
}
impl ThreadStartResponse {
/// Parses valid absolute instruction source paths and omits malformed legacy values.
pub fn instruction_source_path_uris(&self) -> Vec<PathUri> {
instruction_source_path_uris(&self.instruction_sources)
}
}
#[derive(
Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi,
)]
@@ -375,9 +384,9 @@ pub struct ThreadResumeResponse {
#[experimental("thread/resume.runtimeWorkspaceRoots")]
#[serde(default)]
pub runtime_workspace_roots: Vec<AbsolutePathBuf>,
/// Instruction source files currently loaded for this thread.
/// Environment-native paths to instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<AbsolutePathBuf>,
pub instruction_sources: Vec<LegacyAppPathString>,
#[experimental(nested)]
pub approval_policy: AskForApproval,
/// Reviewer currently used for approval requests on this thread.
@@ -397,6 +406,13 @@ pub struct ThreadResumeResponse {
pub initial_turns_page: Option<TurnsPage>,
}
impl ThreadResumeResponse {
/// Parses valid absolute instruction source paths and omits malformed legacy values.
pub fn instruction_source_path_uris(&self) -> Vec<PathUri> {
instruction_source_path_uris(&self.instruction_sources)
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -523,9 +539,9 @@ pub struct ThreadForkResponse {
#[experimental("thread/fork.runtimeWorkspaceRoots")]
#[serde(default)]
pub runtime_workspace_roots: Vec<AbsolutePathBuf>,
/// Instruction source files currently loaded for this thread.
/// Environment-native paths to instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<AbsolutePathBuf>,
pub instruction_sources: Vec<LegacyAppPathString>,
#[experimental(nested)]
pub approval_policy: AskForApproval,
/// Reviewer currently used for approval requests on this thread.
@@ -541,6 +557,30 @@ pub struct ThreadForkResponse {
pub reasoning_effort: Option<ReasoningEffort>,
}
impl ThreadForkResponse {
/// Parses valid absolute instruction source paths and omits malformed legacy values.
pub fn instruction_source_path_uris(&self) -> Vec<PathUri> {
instruction_source_path_uris(&self.instruction_sources)
}
}
fn instruction_source_path_uris(sources: &[LegacyAppPathString]) -> Vec<PathUri> {
// Instruction sources are advisory diagnostics. Warn and fail open so a malformed legacy
// path cannot fail thread start, resume, or fork.
sources
.iter()
.filter_map(|source| {
source.to_inferred_path_uri().or_else(|| {
tracing::warn!(
path = source.as_str(),
"ignoring invalid instruction source path from app-server"
);
None
})
})
.collect()
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+1 -1
View File
@@ -140,7 +140,7 @@ Example with notification opt-out:
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are also started in that environment; HTTP MCP declarations remain inactive.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`.
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known.
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known.
- `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` to filter direct spawned children represented by persisted spawn-edge state. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known.
- `thread/loaded/list` — list the thread ids currently loaded in memory.
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
@@ -1181,7 +1181,7 @@ impl ThreadRequestProcessor {
)
.await?;
let instruction_sources = thread.instruction_sources().await;
let instruction_sources = thread.legacy_instruction_sources().await;
let config_snapshot = thread
.config_snapshot()
.instrument(tracing::info_span!(
@@ -2681,7 +2681,7 @@ impl ThreadRequestProcessor {
self.outgoing.send_error(request_id, err).await;
return Ok(());
}
let instruction_sources = codex_thread.instruction_sources().await;
let instruction_sources = codex_thread.legacy_instruction_sources().await;
let SessionConfiguredEvent { rollout_path, .. } = session_configured;
let Some(rollout_path) = rollout_path else {
let error =
@@ -2989,7 +2989,7 @@ impl ThreadRequestProcessor {
/*include_turns*/ false,
);
thread_summary.session_id = existing_thread.session_configured().session_id.to_string();
let instruction_sources = existing_thread.instruction_sources().await;
let instruction_sources = existing_thread.legacy_instruction_sources().await;
let listener_command_tx = {
let thread_state = thread_state.lock().await;
@@ -3422,7 +3422,7 @@ impl ThreadRequestProcessor {
.map_err(|err| core_thread_write_error("inherit source thread name", err))?;
}
let instruction_sources = forked_thread.instruction_sources().await;
let instruction_sources = forked_thread.legacy_instruction_sources().await;
// Auto-attach a conversation listener when forking a thread.
log_listener_attach_result(
+3 -2
View File
@@ -13,7 +13,7 @@ use codex_protocol::ThreadId;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem;
use codex_rollout::state_db::StateDbHandle;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::LegacyAppPathString;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
@@ -31,7 +31,7 @@ pub(crate) struct PendingThreadResumeRequest {
pub(crate) request_id: ConnectionRequestId,
pub(crate) history_items: Vec<RolloutItem>,
pub(crate) config_snapshot: ThreadConfigSnapshot,
pub(crate) instruction_sources: Vec<AbsolutePathBuf>,
pub(crate) instruction_sources: Vec<LegacyAppPathString>,
pub(crate) thread_summary: codex_app_server_protocol::Thread,
pub(crate) emit_thread_goal_update: bool,
pub(crate) thread_goal_state_db: Option<StateDbHandle>,
@@ -200,6 +200,7 @@ mod tests {
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
#[test]
@@ -82,6 +82,7 @@ use codex_rollout::append_rollout_item_to_path;
use codex_rollout::read_session_meta_line;
use codex_state::StateRuntime;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::LegacyAppPathString;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
@@ -288,7 +289,8 @@ async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Resul
..
} = to_response::<ThreadStartResponse>(start_resp)?;
let project_agents = AbsolutePathBuf::try_from(project_agents)?;
assert_eq!(instruction_sources, vec![project_agents.clone()]);
let project_agents_source = LegacyAppPathString::from_abs_path(&project_agents);
assert_eq!(instruction_sources, vec![project_agents_source.clone()]);
let turn_id = mcp
.send_turn_start_request(TurnStartParams {
@@ -330,7 +332,7 @@ async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Resul
..
} = to_response::<ThreadResumeResponse>(resume_resp)?;
assert_eq!(instruction_sources, vec![project_agents]);
assert_eq!(instruction_sources, vec![project_agents_source]);
Ok(())
}
@@ -381,7 +381,7 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<(
let instruction_sources = instruction_sources
.into_iter()
.map(normalize_path_for_comparison)
.map(|path| normalize_path_for_comparison(path.as_str()))
.collect::<Vec<_>>();
let expected_instruction_sources = vec![
std::fs::canonicalize(global_agents_path)?,
@@ -428,7 +428,7 @@ async fn thread_start_response_excludes_empty_project_instruction_source() -> Re
let instruction_sources = instruction_sources
.into_iter()
.map(normalize_path_for_comparison)
.map(|path| normalize_path_for_comparison(path.as_str()))
.collect::<Vec<_>>();
let expected_instruction_sources = vec![normalize_path_for_comparison(std::fs::canonicalize(
global_agents_path,
@@ -474,7 +474,7 @@ async fn thread_start_without_selected_environment_includes_only_global_instruct
assert_eq!(
instruction_sources
.into_iter()
.map(normalize_path_for_comparison)
.map(|path| normalize_path_for_comparison(path.as_str()))
.collect::<Vec<_>>(),
vec![normalize_path_for_comparison(std::fs::canonicalize(
global_agents_path,
+29 -34
View File
@@ -53,16 +53,11 @@ pub(crate) async fn load_project_instructions(
let mut loaded = LoadedAgentsMd::from_user_instructions(user_instructions);
for turn_environment in &environments.turn_environments {
let filesystem = turn_environment.environment.get_filesystem();
// TODO(anp): Migrate AGENTS.md discovery to PathUri so instructions can be loaded from
// environment-native foreign working directories.
let Ok(cwd) = turn_environment.cwd().to_abs_path() else {
continue;
};
match read_agents_md(
config,
filesystem.as_ref(),
&turn_environment.environment_id,
&cwd,
turn_environment.cwd(),
)
.await
{
@@ -97,7 +92,7 @@ async fn read_agents_md(
config: &Config,
fs: &dyn ExecutorFileSystem,
environment_id: &str,
cwd: &AbsolutePathBuf,
cwd: &PathUri,
) -> io::Result<Option<LoadedAgentsMd>> {
let max_total = config.project_doc_max_bytes;
@@ -118,15 +113,14 @@ async fn read_agents_md(
break;
}
let path_uri = PathUri::from_abs_path(&p);
match fs.get_metadata(&path_uri, /*sandbox*/ None).await {
match fs.get_metadata(&p, /*sandbox*/ None).await {
Ok(metadata) if !metadata.is_file => continue,
Ok(_) => {}
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) => return Err(err),
}
let mut data = match fs.read_file(&path_uri, /*sandbox*/ None).await {
let mut data = match fs.read_file(&p, /*sandbox*/ None).await {
Ok(data) => data,
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) => return Err(err),
@@ -139,7 +133,7 @@ async fn read_agents_md(
if size > remaining {
tracing::warn!(
"Project doc `{}` exceeds remaining budget ({} bytes) - truncating.",
p.display(),
p.inferred_native_path_string(),
remaining,
);
}
@@ -169,9 +163,9 @@ async fn read_agents_md(
/// directory, inclusive. Symlinks are allowed.
async fn agents_md_paths(
config: &Config,
cwd: &AbsolutePathBuf,
cwd: &PathUri,
fs: &dyn ExecutorFileSystem,
) -> io::Result<Vec<AbsolutePathBuf>> {
) -> io::Result<Vec<PathUri>> {
let dir = cwd.clone();
let mut merged = TomlValue::Table(toml::map::Map::new());
@@ -194,18 +188,18 @@ async fn agents_md_paths(
};
let mut project_root = None;
if !project_root_markers.is_empty() {
for ancestor in dir.ancestors() {
for current in dir.ancestors() {
for marker in &project_root_markers {
let marker_path = ancestor.join(marker);
let marker_path_uri = PathUri::from_abs_path(&marker_path);
let marker_exists = match fs.get_metadata(&marker_path_uri, /*sandbox*/ None).await
{
let marker_path = current
.join(marker)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let marker_exists = match fs.get_metadata(&marker_path, /*sandbox*/ None).await {
Ok(_) => true,
Err(err) if err.kind() == io::ErrorKind::NotFound => false,
Err(err) => return Err(err),
};
if marker_exists {
project_root = Some(ancestor.clone());
project_root = Some(current.clone());
break;
}
}
@@ -215,7 +209,7 @@ async fn agents_md_paths(
}
}
let search_dirs: Vec<AbsolutePathBuf> = if let Some(root) = project_root {
let search_dirs: Vec<PathUri> = if let Some(root) = project_root {
let mut dirs = Vec::new();
let mut cursor = dir.clone();
loop {
@@ -234,13 +228,14 @@ async fn agents_md_paths(
vec![dir]
};
let mut found: Vec<AbsolutePathBuf> = Vec::new();
let mut found: Vec<PathUri> = Vec::new();
let candidate_filenames = candidate_filenames(config);
for d in search_dirs {
for name in &candidate_filenames {
let candidate = d.join(name);
let candidate_uri = PathUri::from_abs_path(&candidate);
match fs.get_metadata(&candidate_uri, /*sandbox*/ None).await {
let candidate = d
.join(name)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
match fs.get_metadata(&candidate, /*sandbox*/ None).await {
Ok(md) if md.is_file => {
found.push(candidate);
break;
@@ -371,7 +366,7 @@ impl LoadedAgentsMd {
fn environment_labeled_text(&self) -> String {
let mut output = String::new();
let mut has_previous = false;
let mut previous_environment: Option<(&str, &AbsolutePathBuf)> = None;
let mut previous_environment: Option<(&str, &PathUri)> = None;
if let Some(instructions) = &self.user_instructions {
output.push_str(&instructions.text);
has_previous = true;
@@ -394,7 +389,7 @@ impl LoadedAgentsMd {
output.push_str(&format!(
"for `{}` with root {}\n\n",
environment_id,
cwd.display()
cwd.inferred_native_path_string()
));
}
output.push_str(&entry.contents);
@@ -421,7 +416,7 @@ impl LoadedAgentsMd {
None
} else {
self.single_project_cwd()
.map(|cwd| cwd.to_string_lossy().into_owned())
.map(PathUri::inferred_native_path_string)
};
ContextUserInstructions {
directory,
@@ -436,10 +431,10 @@ impl LoadedAgentsMd {
}
/// Returns the AGENTS.md files that supplied instruction entries.
pub fn sources(&self) -> impl Iterator<Item = &AbsolutePathBuf> {
pub fn sources(&self) -> impl Iterator<Item = PathUri> + '_ {
self.user_instructions
.iter()
.map(|instructions| &instructions.source)
.map(|instructions| PathUri::from_abs_path(&instructions.source))
.chain(
self.entries
.iter()
@@ -463,7 +458,7 @@ impl LoadedAgentsMd {
})
}
fn single_project_cwd(&self) -> Option<&AbsolutePathBuf> {
fn single_project_cwd(&self) -> Option<&PathUri> {
self.entries
.iter()
.find_map(|entry| match &entry.provenance {
@@ -488,9 +483,9 @@ enum InstructionProvenance {
/// Workspace instructions discovered from project AGENTS.md files.
Project {
/// Exact AGENTS.md file, distinct from the environment's selected cwd.
source_path: AbsolutePathBuf,
source_path: PathUri,
environment_id: String,
cwd: AbsolutePathBuf,
cwd: PathUri,
},
/// Instructions without a file source, including internally defined guidance.
@@ -498,9 +493,9 @@ enum InstructionProvenance {
}
impl InstructionProvenance {
fn path(&self) -> Option<&AbsolutePathBuf> {
fn path(&self) -> Option<PathUri> {
match self {
Self::Project { source_path, .. } => Some(source_path),
Self::Project { source_path, .. } => Some(source_path.clone()),
Self::Internal => None,
}
}
+141 -40
View File
@@ -250,8 +250,13 @@ async fn load_agents_md(config: &TestConfig) -> Option<LoadedAgentsMd> {
.await
}
async fn agents_md_paths(config: &TestConfig) -> std::io::Result<Vec<AbsolutePathBuf>> {
super::agents_md_paths(&config.config, &config.cwd, LOCAL_FS.as_ref()).await
async fn agents_md_paths(config: &TestConfig) -> std::io::Result<Vec<PathUri>> {
super::agents_md_paths(
&config.config,
&PathUri::from_abs_path(&config.cwd),
LOCAL_FS.as_ref(),
)
.await
}
fn resolved_local_environments<const N: usize>(
@@ -277,12 +282,101 @@ fn resolved_local_environments<const N: usize>(
fn project_provenance(path: AbsolutePathBuf, cwd: AbsolutePathBuf) -> InstructionProvenance {
InstructionProvenance::Project {
source_path: path,
source_path: PathUri::from_abs_path(&path),
environment_id: "local".to_string(),
cwd,
cwd: PathUri::from_abs_path(&cwd),
}
}
#[test]
fn foreign_agents_md_uses_environment_native_paths() {
let (cwd, rendered_cwd) = if cfg!(windows) {
(
PathUri::parse("file:///codex%20runtime").expect("POSIX cwd URI"),
"/codex runtime",
)
} else {
(
PathUri::parse("file:///C:/codex%20runtime").expect("Windows cwd URI"),
r"C:\codex runtime",
)
};
let source_path = cwd.join("AGENTS.md").expect("AGENTS.md URI");
let loaded = LoadedAgentsMd {
user_instructions: None,
entries: vec![InstructionEntry {
contents: "remote instructions".to_string(),
provenance: InstructionProvenance::Project {
source_path: source_path.clone(),
environment_id: "remote".to_string(),
cwd,
},
}],
};
assert_eq!(
loaded.render(),
format!(
"# AGENTS.md instructions for {rendered_cwd}
<INSTRUCTIONS>
remote instructions
</INSTRUCTIONS>"
)
);
assert_eq!(loaded.sources().collect::<Vec<_>>(), vec![source_path]);
}
#[test]
fn multi_environment_agents_md_renders_mixed_path_conventions() {
let posix_cwd = PathUri::parse("file:///srv/project").expect("POSIX cwd URI");
let windows_cwd = PathUri::parse("file:///C:/workspace").expect("Windows cwd URI");
let posix_source = posix_cwd.join("AGENTS.md").expect("POSIX AGENTS.md URI");
let windows_source = windows_cwd
.join("AGENTS.md")
.expect("Windows AGENTS.md URI");
let loaded = LoadedAgentsMd {
user_instructions: None,
entries: vec![
InstructionEntry {
contents: "POSIX instructions".to_string(),
provenance: InstructionProvenance::Project {
source_path: posix_source.clone(),
environment_id: "posix".to_string(),
cwd: posix_cwd,
},
},
InstructionEntry {
contents: "Windows instructions".to_string(),
provenance: InstructionProvenance::Project {
source_path: windows_source.clone(),
environment_id: "windows".to_string(),
cwd: windows_cwd,
},
},
],
};
assert_eq!(
loaded.render(),
r#"# AGENTS.md instructions
<INSTRUCTIONS>
for `posix` with root /srv/project
POSIX instructions
for `windows` with root C:\workspace
Windows instructions
</INSTRUCTIONS>"#
);
assert_eq!(
loaded.sources().collect::<Vec<_>>(),
vec![posix_source, windows_source]
);
}
/// Helper that returns a `Config` pointing at `root` and using `limit` as
/// the maximum number of bytes to embed from AGENTS.md. The caller can
/// optionally specify a custom `instructions` string when `None` the
@@ -508,7 +602,7 @@ async fn read_agents_md_propagates_metadata_errors() {
};
let cwd = config.cwd.clone();
let err = read_agents_md(&config.config, &fs, "local", &cwd)
let err = read_agents_md(&config.config, &fs, "local", &PathUri::from_abs_path(&cwd))
.await
.expect_err("metadata error");
@@ -526,7 +620,7 @@ async fn read_agents_md_propagates_read_errors() {
};
let cwd = config.cwd.clone();
let err = read_agents_md(&config.config, &fs, "local", &cwd)
let err = read_agents_md(&config.config, &fs, "local", &PathUri::from_abs_path(&cwd))
.await
.expect_err("read error");
@@ -544,7 +638,7 @@ async fn read_agents_md_ignores_files_removed_after_discovery() {
};
let cwd = config.cwd.clone();
let loaded = read_agents_md(&config.config, &fs, "local", &cwd)
let loaded = read_agents_md(&config.config, &fs, "local", &PathUri::from_abs_path(&cwd))
.await
.expect("removed file is recoverable");
@@ -659,17 +753,18 @@ secondary doc"#,
);
assert_eq!(loaded.render(), expected_fragment);
assert_eq!(
loaded.sources().cloned().collect::<Vec<_>>(),
loaded.sources().collect::<Vec<_>>(),
vec![
config
.user_instructions
.as_ref()
.expect("global instructions")
.source
.clone(),
primary.path().join("AGENTS.md").abs(),
primary_nested.join("AGENTS.md").abs(),
secondary.path().join("AGENTS.md").abs(),
PathUri::from_abs_path(
&config
.user_instructions
.as_ref()
.expect("global instructions")
.source,
),
PathUri::from_abs_path(&primary.path().join("AGENTS.md").abs()),
PathUri::from_abs_path(&primary_nested.join("AGENTS.md").abs()),
PathUri::from_abs_path(&secondary.path().join("AGENTS.md").abs()),
]
);
}
@@ -898,7 +993,10 @@ async fn concatenates_root_and_cwd_docs() {
assert_eq!(loaded.text(), "root doc\n\ncrate doc");
assert_eq!(
loaded.sources().collect::<Vec<_>>(),
vec![&root_agents, &crate_agents]
vec![
PathUri::from_abs_path(&root_agents),
PathUri::from_abs_path(&crate_agents),
]
);
}
@@ -925,8 +1023,8 @@ async fn project_root_markers_are_honored_for_agents_discovery() {
let expected_parent = root.path().join("AGENTS.md").abs();
let expected_child = cfg.cwd.join("AGENTS.md");
assert_eq!(discovery.len(), 2);
assert_eq!(discovery[0], expected_parent);
assert_eq!(discovery[1], expected_child);
assert_eq!(discovery[0], PathUri::from_abs_path(&expected_parent));
assert_eq!(discovery[1], PathUri::from_abs_path(&expected_child));
let res = get_user_instructions(&cfg).await.expect("doc expected");
assert_eq!(res, "parent doc\n\nchild doc");
@@ -971,8 +1069,8 @@ async fn project_layers_do_not_override_project_root_markers() {
assert_eq!(
discovery,
vec![
root.path().join("AGENTS.md").abs(),
config.cwd.join("AGENTS.md"),
PathUri::from_abs_path(&root.path().join("AGENTS.md").abs()),
PathUri::from_abs_path(&config.cwd.join("AGENTS.md")),
]
);
}
@@ -991,7 +1089,10 @@ async fn agents_md_paths_preserve_symlinked_cwd() {
cfg.cwd = linked_cwd.abs();
let discovery = agents_md_paths(&cfg).await.expect("discover paths");
assert_eq!(discovery, vec![cfg.cwd.join("AGENTS.md")]);
assert_eq!(
discovery,
vec![PathUri::from_abs_path(&cfg.cwd.join("AGENTS.md"))]
);
let res = get_user_instructions(&cfg).await.expect("doc expected");
assert_eq!(res, "project doc");
@@ -1050,7 +1151,10 @@ async fn instruction_sources_include_global_before_agents_md_docs() {
assert_eq!(loaded.user_instructions(), cfg.user_instructions.as_ref());
assert_eq!(
loaded.sources().collect::<Vec<_>>(),
vec![&global_agents, &project_agents]
vec![
PathUri::from_abs_path(&global_agents),
PathUri::from_abs_path(&project_agents),
]
);
assert_eq!(
loaded.text(),
@@ -1091,7 +1195,10 @@ async fn child_agents_message_after_project_docs_is_not_an_instruction_source()
assert_eq!(loaded, expected);
assert_eq!(
loaded.sources().collect::<Vec<_>>(),
vec![&global_agents, &project_agents]
vec![
PathUri::from_abs_path(&global_agents),
PathUri::from_abs_path(&project_agents),
]
);
assert_eq!(
loaded.text(),
@@ -1117,8 +1224,8 @@ async fn agents_local_md_preferred() {
let discovery = agents_md_paths(&cfg).await.expect("discover paths");
assert_eq!(discovery.len(), 1);
assert_eq!(
discovery[0].file_name().unwrap().to_string_lossy(),
LOCAL_AGENTS_MD_FILENAME
discovery[0].basename().as_deref(),
Some(LOCAL_AGENTS_MD_FILENAME)
);
}
@@ -1166,12 +1273,9 @@ async fn agents_md_preferred_over_fallbacks() {
let discovery = agents_md_paths(&cfg).await.expect("discover paths");
assert_eq!(discovery.len(), 1);
assert!(
discovery[0]
.file_name()
.unwrap()
.to_string_lossy()
.eq(DEFAULT_AGENTS_MD_FILENAME)
assert_eq!(
discovery[0].basename().as_deref(),
Some(DEFAULT_AGENTS_MD_FILENAME)
);
}
@@ -1186,7 +1290,7 @@ async fn agents_md_directory_is_ignored() {
assert_eq!(res, None);
let discovery = agents_md_paths(&cfg).await.expect("discover paths");
assert_eq!(discovery, Vec::<AbsolutePathBuf>::new());
assert_eq!(discovery, Vec::<PathUri>::new());
}
#[cfg(unix)]
@@ -1209,7 +1313,7 @@ async fn agents_md_special_file_is_ignored() {
assert_eq!(res, None);
let discovery = agents_md_paths(&cfg).await.expect("discover paths");
assert_eq!(discovery, Vec::<AbsolutePathBuf>::new());
assert_eq!(discovery, Vec::<PathUri>::new());
}
#[tokio::test]
@@ -1228,11 +1332,8 @@ async fn override_directory_falls_back_to_agents_md_file() {
let discovery = agents_md_paths(&cfg).await.expect("discover paths");
assert_eq!(discovery.len(), 1);
assert_eq!(
discovery[0]
.file_name()
.expect("file name")
.to_string_lossy(),
DEFAULT_AGENTS_MD_FILENAME
discovery[0].basename().as_deref(),
Some(DEFAULT_AGENTS_MD_FILENAME)
);
}
+11 -1
View File
@@ -42,6 +42,7 @@ use codex_thread_store::ThreadMetadataPatch;
use codex_thread_store::ThreadStoreError;
use codex_thread_store::ThreadStoreResult;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::LegacyAppPathString;
use codex_utils_path_uri::PathUri;
use rmcp::model::ReadResourceRequestParams;
use std::collections::BTreeMap;
@@ -564,10 +565,19 @@ impl CodexThread {
}
/// Returns the files that supplied the thread's loaded model instructions.
pub async fn instruction_sources(&self) -> Vec<AbsolutePathBuf> {
pub async fn instruction_sources(&self) -> Vec<PathUri> {
self.codex.instruction_sources().await
}
/// Returns loaded instruction sources rendered as legacy app-server path strings.
pub async fn legacy_instruction_sources(&self) -> Vec<LegacyAppPathString> {
self.instruction_sources()
.await
.into_iter()
.map(Into::into)
.collect()
}
pub async fn config(&self) -> Arc<crate::config::Config> {
self.codex.session.get_config().await
}
+2 -4
View File
@@ -830,15 +830,13 @@ impl Codex {
state.session_configuration.thread_config_snapshot()
}
pub(crate) async fn instruction_sources(&self) -> Vec<AbsolutePathBuf> {
pub(crate) async fn instruction_sources(&self) -> Vec<PathUri> {
let state = self.session.state.lock().await;
state
.session_configuration
.loaded_agents_md
.as_ref()
.map_or_else(Vec::new, |instructions| {
instructions.sources().cloned().collect()
})
.map_or_else(Vec::new, |instructions| instructions.sources().collect())
}
pub(crate) async fn thread_environment_selections(&self) -> Vec<TurnEnvironmentSelection> {
+15 -1
View File
@@ -58,6 +58,7 @@ use tempfile::TempDir;
use wiremock::MockServer;
use crate::TempDirExt;
use crate::TestEnvironment;
use crate::get_remote_test_env;
use crate::load_default_config_for_test;
use crate::load_default_config_for_test_with_cloud_config_bundle;
@@ -179,7 +180,20 @@ pub async fn test_env() -> Result<TestEnv> {
/*sandbox*/ None,
)
.await?;
let cwd = cwd_uri.to_abs_path()?;
let cwd = if remote_env == TestEnvironment::WineExec {
// TODO(anp): Convert `Config::cwd` to `LegacyAppPathString` and remove this
// compatibility projection.
// `Config::cwd` still requires `AbsolutePathBuf`. Preserve the test harness's
// Linux-absolute `/C:/...` compatibility spelling so converting it back to a
// `PathUri` recovers the remote Windows convention. Production conversions stay
// strict: `PathUri::to_abs_path` intentionally rejects foreign paths.
let path = cwd_uri.to_url().to_file_path().map_err(|()| {
anyhow!("remote test cwd URI cannot be projected onto the host: {cwd_uri}")
})?;
AbsolutePathBuf::try_from(path)?
} else {
cwd_uri.to_abs_path()?
};
Ok(TestEnv {
environment,
exec_server_url: Some(websocket_url),
@@ -22,7 +22,6 @@ wine_rust_test(
"//codex-rs/protocol",
"//codex-rs/utils/path-uri",
"@crates//:anyhow",
"@crates//:base64",
"@crates//:pretty_assertions",
"@crates//:serde_json",
"@crates//:tempfile",
@@ -36,11 +36,13 @@ use core_test_support::test_codex::test_codex;
use core_test_support::test_codex::turn_permission_fields;
use core_test_support::wait_for_event;
use codex_utils_path_uri::LegacyAppPathString;
use codex_utils_path_uri::PathConvention;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeMap;
use std::fs;
use tempfile::TempDir;
use tokio::time::timeout;
use wine_exec_server_test_support::WineExecServer;
@@ -57,7 +59,7 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> {
const VERIFY_COMMAND: &str = r#"$path = Join-Path (Get-Location) 'codex-apply-patch-smoke.txt'; if (-not (Test-Path $path)) { exit 1 }; if ([IO.File]::ReadAllText($path) -ne "patched through unified exec`n") { exit 2 }; Remove-Item $path; Write-Output 'PATCH_VERIFIED'"#;
WineExecServer
.scope(|exec_server_url| async move {
.scope(|exec_server_url, _wine_prefix| async move {
let server = start_mock_server().await;
let arguments = serde_json::to_string(&json!({
"cmd": COMMAND,
@@ -245,8 +247,17 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Result<()> {
const AGENTS_INSTRUCTIONS: &str = "remote Windows workspace instructions";
const NATIVE_CWD: &str = r"C:\windows";
WineExecServer
.scope(|exec_server_url| async move {
.scope(|exec_server_url, wine_prefix| async move {
let agents_path = PathUri::parse("file:///C:/windows/AGENTS.md")?;
fs::write(
wine_prefix.join("drive_c").join("windows").join("AGENTS.md"),
AGENTS_INSTRUCTIONS,
)?;
let codex_home = TempDir::new()?;
let server = create_mock_responses_server_repeating_assistant("done").await;
write_mock_responses_config_toml(
@@ -272,7 +283,7 @@ async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Resul
.send_thread_start_request(ThreadStartParams {
environments: Some(vec![TurnEnvironmentParams {
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: serde_json::from_value::<LegacyAppPathString>(json!(r"C:\windows"))?,
cwd: serde_json::from_value::<LegacyAppPathString>(json!(NATIVE_CWD))?,
}]),
..Default::default()
})
@@ -289,8 +300,13 @@ async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Resul
assert_eq!(response.cwd, host_cwd);
// TODO(anp): Derive runtime workspace roots from the selected remote environment.
assert_eq!(response.runtime_workspace_roots, vec![host_cwd]);
// TODO(anp): Discover and report instruction sources from the remote filesystem.
assert_eq!(response.instruction_sources, Vec::new());
assert_eq!(
response.instruction_sources,
vec![LegacyAppPathString::from_path_uri(
&agents_path,
PathConvention::Windows,
)?]
);
// TODO(anp): Report the implicit built-in permission profile instead of None.
assert_eq!(response.active_permission_profile, None);
@@ -326,6 +342,17 @@ async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Resul
.find(|request| request.url.path().ends_with("/responses"))
.context("turn should send a Responses request")?;
let body = first_request.body_json::<Value>()?;
let remote_instructions = 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.contains(AGENTS_INSTRUCTIONS))
.context("remote workspace instructions should be model visible")?;
assert!(remote_instructions.contains(r"# AGENTS.md instructions for C:\windows"));
let environment_context = body["input"]
.as_array()
.into_iter()
+32 -30
View File
@@ -25,7 +25,6 @@ use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_wine_exec;
use core_test_support::test_codex::RecordingUserInstructionsProvider;
use core_test_support::test_codex::TestCodexBuilder;
use core_test_support::test_codex::test_codex;
@@ -88,7 +87,7 @@ fn instruction_fragments(request: &responses::ResponsesRequest) -> Vec<String> {
}
fn expected_instruction_fragment(cwd: &AbsolutePathBuf, contents: &str) -> String {
let cwd = cwd.as_path().display();
let cwd = PathUri::from_abs_path(cwd).inferred_native_path_string();
format!("# AGENTS.md instructions for {cwd}\n\n<INSTRUCTIONS>\n{contents}\n</INSTRUCTIONS>")
}
@@ -138,8 +137,6 @@ fn request_body_contains(request: &wiremock::Request, text: &str) -> bool {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agents_override_is_preferred_over_agents_md() -> Result<()> {
// TODO(anp): Remove after instruction-source helpers use target-native paths.
skip_if_wine_exec!(Ok(()), "requires native cross-OS instruction-source paths");
let instructions =
agents_instructions(test_codex().with_workspace_setup(|cwd, fs| async move {
let agents_md = cwd.join("AGENTS.md");
@@ -172,8 +169,6 @@ async fn agents_override_is_preferred_over_agents_md() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn configured_fallback_is_used_when_agents_candidate_is_directory() -> Result<()> {
// TODO(anp): Remove after instruction-source helpers use target-native paths.
skip_if_wine_exec!(Ok(()), "requires native cross-OS instruction-source paths");
let instructions = agents_instructions(
test_codex()
.with_config(|config| {
@@ -334,8 +329,8 @@ async fn symlinked_cwd_uses_logical_parent_for_agents_discovery() -> Result<()>
assert_eq!(
test.codex.instruction_sources().await,
vec![
logical_root.join("AGENTS.md"),
test.config.cwd.join("AGENTS.md")
PathUri::from_abs_path(&logical_root.join("AGENTS.md")),
PathUri::from_abs_path(&test.config.cwd.join("AGENTS.md"))
]
);
@@ -355,8 +350,6 @@ async fn symlinked_cwd_uses_logical_parent_for_agents_discovery() -> Result<()>
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn selected_environment_sources_match_model_visible_instructions() -> Result<()> {
// TODO(anp): Remove after instruction-source helpers use target-native paths.
skip_if_wine_exec!(Ok(()), "requires native cross-OS instruction-source paths");
let server = start_mock_server().await;
let resp_mock = mount_sse_once(
&server,
@@ -385,7 +378,10 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu
assert_eq!(
test.codex.instruction_sources().await,
vec![global_agents, project_agents]
vec![
PathUri::from_abs_path(&global_agents),
PathUri::from_abs_path(&project_agents),
]
);
test.submit_turn("hello").await?;
@@ -454,7 +450,7 @@ async fn loads_user_instructions_without_a_primary_environment() -> Result<()> {
assert_eq!(provider.load_count(), 2);
assert_eq!(
no_environment_thread.thread.instruction_sources().await,
vec![global_source]
vec![PathUri::from_abs_path(&global_source)]
);
no_environment_thread
@@ -485,8 +481,6 @@ async fn loads_user_instructions_without_a_primary_environment() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Result<()> {
// TODO(anp): Remove after instruction-source helpers use target-native paths.
skip_if_wine_exec!(Ok(()), "requires native cross-OS instruction-source paths");
// Set up one global source, one project source, and two ordinary model turns.
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_sequence(
@@ -521,7 +515,10 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re
});
let test = builder.build_with_remote_env(&server).await?;
let project_source = test.config.cwd.join(GLOBAL_AGENTS_FILENAME);
let creation_sources = vec![global_source.clone(), project_source.clone()];
let creation_sources = vec![
PathUri::from_abs_path(&global_source),
PathUri::from_abs_path(&project_source),
];
// Confirm the thread records both creation-time sources in composition order.
assert_eq!(test.codex.instruction_sources().await, creation_sources);
@@ -597,8 +594,6 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapshot() -> Result<()> {
// TODO(anp): Remove after instruction-source helpers use target-native paths.
skip_if_wine_exec!(Ok(()), "requires native cross-OS instruction-source paths");
skip_if_no_network!(Ok(()));
let Some(_remote_env) = get_remote_test_env() else {
return Ok(());
@@ -672,9 +667,9 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho
assert_eq!(
thread.thread.instruction_sources().await,
vec![
global_source.clone(),
remote_source.clone(),
local_source.clone().try_into()?,
PathUri::from_abs_path(&global_source),
PathUri::from_abs_path(&remote_source),
PathUri::from_path(&local_source)?,
]
);
@@ -700,7 +695,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho
let contents = format!(
"{GLOBAL_INSTRUCTIONS}\n\nfor `{REMOTE_ENVIRONMENT_ID}` with root {}\n\nremote project instructions\n\nfor `{LOCAL_ENVIRONMENT_ID}` with root {}\n\nlocal project instructions",
test.config.cwd.display(),
PathUri::from_abs_path(&test.config.cwd).inferred_native_path_string(),
local_root.path().display(),
);
let expected =
@@ -712,7 +707,11 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho
assert_eq!(provider.load_count(), 2);
assert_eq!(
thread.thread.instruction_sources().await,
vec![global_source, remote_source, local_source.try_into()?]
vec![
PathUri::from_abs_path(&global_source),
PathUri::from_abs_path(&remote_source),
PathUri::from_path(&local_source)?,
]
);
Ok(())
@@ -741,7 +740,10 @@ async fn invalid_utf8_global_instructions_are_lossy() -> Result<()> {
test.submit_turn("inspect lossy global instructions")
.await?;
assert_eq!(test.codex.instruction_sources().await, vec![source.clone()]);
assert_eq!(
test.codex.instruction_sources().await,
vec![PathUri::from_abs_path(&source)]
);
let expected_fragment =
expected_provider_only_instruction_fragment("global\u{FFFD}instructions");
assert_single_instruction_fragment(&response_mock.single_request(), &expected_fragment);
@@ -784,7 +786,7 @@ async fn cold_resume_replays_rendered_instructions_but_reports_current_config_so
// Assert the pre-resume thread reports the source used to create its snapshot.
assert_eq!(
initial.codex.instruction_sources().await,
vec![old_source.clone()],
vec![PathUri::from_abs_path(&old_source)],
"initial thread reports the creation-time global source"
);
initial.submit_turn("persist instructions").await?;
@@ -814,7 +816,7 @@ async fn cold_resume_replays_rendered_instructions_but_reports_current_config_so
// Assert the API reports the new source while model history replays the old structured prefix.
assert_eq!(
resumed.codex.instruction_sources().await,
vec![new_source],
vec![PathUri::from_abs_path(&new_source)],
"resume reports sources from the newly loaded config"
);
@@ -868,7 +870,7 @@ async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()>
// Assert the parent reports the source used to create its snapshot.
assert_eq!(
parent.codex.instruction_sources().await,
vec![source.clone()],
vec![PathUri::from_abs_path(&source)],
"parent reports the creation-time global source"
);
parent.submit_turn("persist instructions").await?;
@@ -903,7 +905,7 @@ async fn fork_replays_rendered_instructions_from_shared_history() -> Result<()>
// Assert the fork reports the new source before issuing its first turn.
assert_eq!(
forked.thread.instruction_sources().await,
vec![new_source],
vec![PathUri::from_abs_path(&new_source)],
"fork config should reflect the newly loaded global source"
);
@@ -1033,7 +1035,7 @@ async fn run_subagent_global_instruction_case(fork_context: bool) -> Result<()>
// Assert the parent reports the creation-time source before spawning.
assert_eq!(
test.codex.instruction_sources().await,
vec![source.clone()],
vec![PathUri::from_abs_path(&source)],
"parent reports the creation-time global source before spawning"
);
test.submit_turn(SPAWN_SEED_PROMPT).await?;
@@ -1076,12 +1078,12 @@ async fn run_subagent_global_instruction_case(fork_context: bool) -> Result<()>
assert_single_instruction_fragment(&child_request, &expected_fragment);
assert_eq!(
test.codex.instruction_sources().await,
vec![source.clone()],
vec![PathUri::from_abs_path(&source)],
"running parent retains the creation-time global source after spawning"
);
assert_eq!(
child_thread.instruction_sources().await,
vec![source],
vec![PathUri::from_abs_path(&source)],
"subagent reports the parent's creation-time source"
);
if fork_context {
+7 -6
View File
@@ -25,6 +25,7 @@ use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use core_test_support::PathBufExt;
use core_test_support::context_snapshot;
use core_test_support::context_snapshot::ContextSnapshotOptions;
@@ -4593,7 +4594,7 @@ async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Resu
// Assert the pre-compaction source list points at the creation-time file.
assert_eq!(
test.codex.instruction_sources().await,
vec![source.clone()],
vec![PathUri::from_abs_path(&source)],
"thread reports the creation-time global source before compaction"
);
@@ -4623,7 +4624,7 @@ async fn manual_compaction_keeps_the_creation_time_global_instructions() -> Resu
assert_single_instruction_fragment(&requests[2], &expected_fragment);
assert_eq!(
test.codex.instruction_sources().await,
vec![source],
vec![PathUri::from_abs_path(&source)],
"thread retains the creation-time global source after compaction"
);
@@ -4673,7 +4674,7 @@ async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Re
// Assert the pre-compaction source list points at the creation-time file.
assert_eq!(
test.codex.instruction_sources().await,
vec![source.clone()],
vec![PathUri::from_abs_path(&source)],
"thread reports the creation-time global source before mid-turn compaction"
);
@@ -4695,7 +4696,7 @@ async fn mid_turn_compaction_keeps_the_creation_time_global_instructions() -> Re
assert_single_instruction_fragment(&requests[2], &expected_fragment);
assert_eq!(
test.codex.instruction_sources().await,
vec![source],
vec![PathUri::from_abs_path(&source)],
"thread retains the creation-time global source after mid-turn compaction"
);
@@ -4780,7 +4781,7 @@ async fn remote_v2_compaction_keeps_creation_time_instructions_after_same_path_m
);
assert_eq!(
test.codex.instruction_sources().await,
vec![source.clone()],
vec![PathUri::from_abs_path(&source)],
"running thread retains the selected same-path source"
);
assert_eq!(
@@ -4829,7 +4830,7 @@ async fn remote_v2_compaction_keeps_creation_time_instructions_after_same_path_m
);
assert_eq!(
resumed.codex.instruction_sources().await,
vec![source],
vec![PathUri::from_abs_path(&source)],
"cold-resumed thread reports the same rewritten source path"
);
@@ -5,7 +5,6 @@ use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_wine_exec;
use core_test_support::test_codex::test_codex;
const HIERARCHICAL_AGENTS_SNIPPET: &str =
@@ -13,8 +12,6 @@ const HIERARCHICAL_AGENTS_SNIPPET: &str =
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn hierarchical_agents_appends_to_project_doc_in_user_instructions() {
// TODO(anp): Remove after instruction-source helpers use target-native paths.
skip_if_wine_exec!("requires native cross-OS instruction-source paths");
let server = start_mock_server().await;
let resp_mock = mount_sse_once(
&server,
@@ -1,6 +1,7 @@
//! Test support for running the Windows exec-server under Wine.
use std::future::Future;
use std::path::PathBuf;
use anyhow::Context;
use anyhow::Result;
@@ -12,16 +13,18 @@ use wine_test_support::WineTestCommand;
pub struct WineExecServer;
impl WineExecServer {
/// Starts the server, passes its WebSocket URL to `operation`, and tears it down afterward.
/// Starts the server, passes its WebSocket URL and Wine prefix to `operation`, and tears it
/// down afterward.
pub async fn scope<T, F, Fut>(self, operation: F) -> Result<T>
where
F: FnOnce(String) -> Fut,
F: FnOnce(String, PathBuf) -> Fut,
Fut: Future<Output = Result<T>>,
{
let executable = codex_utils_cargo_bin::cargo_bin("wine-windows-exec-server")?;
let mut exec_server = WineTestCommand::new(executable)
.env("CODEX_HOME", r"C:\codex-home")
.spawn()?;
let wine_prefix = exec_server.prefix_path().to_path_buf();
let stdout = exec_server.take_stdout();
exec_server
@@ -36,7 +39,7 @@ impl WineExecServer {
break line;
}
};
operation(exec_server_url).await
operation(exec_server_url, wine_prefix).await
})
.await
}
@@ -33,7 +33,7 @@ async fn main() -> Result<()> {
}
WineExecServer
.scope(|exec_server_url| async move {
.scope(|exec_server_url, _wine_prefix| async move {
let mut command = Command::new(test_binary);
command
.env(TEST_ENVIRONMENT_ENV_VAR, "wine-exec")
+1 -1
View File
@@ -62,6 +62,7 @@ codex-utils-fuzzy-match = { workspace = true }
codex-utils-home-dir = { workspace = true }
codex-utils-oss = { workspace = true }
codex-utils-path = { workspace = true }
codex-utils-path-uri = { workspace = true }
codex-utils-plugins = { workspace = true }
codex-utils-sandbox-summary = { workspace = true }
codex-utils-sleep-inhibitor = { workspace = true }
@@ -151,7 +152,6 @@ codex-cli = { workspace = true }
codex-mcp = { workspace = true }
core_test_support = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
codex-utils-path-uri = { workspace = true }
assert_matches = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
insta = { workspace = true }
+10 -6
View File
@@ -120,6 +120,7 @@ use codex_protocol::openai_models::ModelServiceTier;
use codex_protocol::openai_models::ModelUpgrade;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use color_eyre::eyre::ContextCompat;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
@@ -1561,7 +1562,7 @@ async fn thread_session_state_from_thread_start_response(
response.active_permission_profile.clone().map(Into::into),
response.cwd.clone(),
response.runtime_workspace_roots.clone(),
response.instruction_sources.clone(),
response.instruction_source_path_uris(),
response.reasoning_effort.clone(),
config,
)
@@ -1602,7 +1603,7 @@ async fn thread_session_state_from_thread_resume_response(
response.active_permission_profile.clone().map(Into::into),
response.cwd.clone(),
response.runtime_workspace_roots.clone(),
response.instruction_sources.clone(),
response.instruction_source_path_uris(),
response.reasoning_effort.clone(),
config,
)
@@ -1634,7 +1635,7 @@ async fn thread_session_state_from_thread_fork_response(
response.active_permission_profile.clone().map(Into::into),
response.cwd.clone(),
response.runtime_workspace_roots.clone(),
response.instruction_sources.clone(),
response.instruction_source_path_uris(),
response.reasoning_effort.clone(),
config,
)
@@ -1673,7 +1674,7 @@ async fn thread_session_state_from_thread_response(
active_permission_profile: Option<ActivePermissionProfile>,
cwd: AbsolutePathBuf,
runtime_workspace_roots: Vec<AbsolutePathBuf>,
instruction_source_paths: Vec<AbsolutePathBuf>,
instruction_source_paths: Vec<PathUri>,
reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
config: &Config,
) -> Result<ThreadSessionState, String> {
@@ -1759,6 +1760,7 @@ mod tests {
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use codex_utils_path_uri::LegacyAppPathString;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
@@ -2367,7 +2369,9 @@ mod tests {
test_path_buf("/tmp/project").abs(),
test_path_buf("/tmp/project/extra").abs(),
],
instruction_sources: vec![test_path_buf("/tmp/project/AGENTS.md").abs()],
instruction_sources: vec![LegacyAppPathString::from_abs_path(
&test_path_buf("/tmp/project/AGENTS.md").abs(),
)],
approval_policy: codex_app_server_protocol::AskForApproval::Never,
approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User,
sandbox: read_only_profile
@@ -2393,7 +2397,7 @@ mod tests {
);
assert_eq!(
started.session.instruction_source_paths,
response.instruction_sources
response.instruction_source_path_uris()
);
assert_eq!(started.session.permission_profile, read_only_profile);
assert_eq!(started.turns.len(), 1);
+2 -1
View File
@@ -163,6 +163,7 @@ use codex_terminal_detection::TerminalName;
use codex_terminal_detection::terminal_info;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::resume_hint;
use codex_utils_path_uri::PathUri;
use codex_utils_plugins::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL;
use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL;
use crossterm::event::KeyCode;
@@ -687,7 +688,7 @@ pub(crate) struct ChatWidget {
// App-server-backed command runner for status-line workspace metadata lookups.
workspace_command_runner: Option<WorkspaceCommandRunner>,
// Instruction source files loaded for the current session, supplied by app-server.
instruction_source_paths: Vec<AbsolutePathBuf>,
instruction_source_paths: Vec<PathUri>,
// Runtime network proxy bind addresses from SessionConfigured.
session_network_proxy: Option<SessionNetworkProxyRuntime>,
// Shared latch so we only warn once about invalid status-line item IDs.
@@ -589,7 +589,9 @@ async fn ctrl_d_with_modal_open_does_not_quit() {
#[tokio::test]
async fn slash_init_does_not_depend_on_loaded_instruction_sources() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.instruction_source_paths = vec![chat.config.cwd.join("project-instructions.md")];
chat.instruction_source_paths = vec![codex_utils_path_uri::PathUri::from_abs_path(
&chat.config.cwd.join("project-instructions.md"),
)];
submit_composer_text(&mut chat, "/init");
@@ -1,5 +1,6 @@
use super::*;
use assert_matches::assert_matches;
use codex_utils_path_uri::PathUri;
#[tokio::test]
async fn status_command_renders_immediately_and_refreshes_rate_limits_for_chatgpt_auth() {
@@ -96,9 +97,23 @@ async fn status_command_uses_catalog_default_reasoning_when_config_empty() {
}
#[tokio::test]
async fn status_command_renders_instruction_sources_from_thread_session() {
async fn status_command_renders_native_and_foreign_instruction_sources() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.instruction_source_paths = vec![chat.config.cwd.join("AGENTS.md")];
let (foreign_source, foreign_display) = if cfg!(windows) {
(
PathUri::parse("file:///remote/AGENTS.md").expect("POSIX instruction source"),
"/remote/AGENTS.md",
)
} else {
(
PathUri::parse("file:///C:/remote/AGENTS.md").expect("Windows instruction source"),
r"C:\remote\AGENTS.md",
)
};
chat.instruction_source_paths = vec![
PathUri::from_abs_path(&chat.config.cwd.join("AGENTS.md")),
foreign_source,
];
chat.dispatch_command(SlashCommand::Status);
@@ -109,8 +124,8 @@ async fn status_command_renders_instruction_sources_from_thread_session() {
other => panic!("expected status output, got {other:?}"),
};
assert!(
rendered.contains("Agents.md"),
"expected /status to render app-server instruction sources, got: {rendered}"
rendered.contains(&format!("AGENTS.md, {foreign_display}")),
"expected /status to show native-relative and environment-native foreign paths, got: {rendered}"
);
assert!(
!rendered.contains("Agents.md <none>"),
+2 -1
View File
@@ -12,6 +12,7 @@ use codex_protocol::config_types::Personality;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct SessionNetworkProxyRuntime {
@@ -47,7 +48,7 @@ pub(crate) struct ThreadSessionState {
pub(crate) active_permission_profile: Option<ActivePermissionProfile>,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) runtime_workspace_roots: Vec<AbsolutePathBuf>,
pub(crate) instruction_source_paths: Vec<AbsolutePathBuf>,
pub(crate) instruction_source_paths: Vec<PathUri>,
pub(crate) reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
pub(crate) collaboration_mode: Option<Box<CollaborationMode>>,
pub(crate) personality: Option<Personality>,
+43 -7
View File
@@ -5,7 +5,8 @@ use crate::text_formatting;
use chrono::DateTime;
use chrono::Local;
use codex_protocol::account::PlanType;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathConvention;
use codex_utils_path_uri::PathUri;
use std::path::Path;
use unicode_width::UnicodeWidthStr;
@@ -33,10 +34,20 @@ pub(crate) fn compose_model_display(
(model_name.to_string(), details)
}
pub(crate) fn compose_agents_summary(config: &Config, paths: &[AbsolutePathBuf]) -> String {
pub(crate) fn compose_agents_summary(config: &Config, paths: &[PathUri]) -> String {
let mut rels: Vec<String> = Vec::new();
for p in paths {
for path in paths {
// TODO(anp): Rationalize instruction-source summaries with the TUI's broader foreign-path
// display strategy once other status surfaces can retain environment-native paths.
if path.infer_path_convention() != Some(PathConvention::native()) {
rels.push(path.inferred_native_path_string());
continue;
}
let Ok(p) = path.to_abs_path() else {
rels.push(path.inferred_native_path_string());
continue;
};
let p = p.as_path();
let file_name = p
.file_name()
@@ -229,7 +240,10 @@ mod tests {
let config = test_config(&codex_home, &cwd).await;
assert_eq!(
compose_agents_summary(&config, &[global_agents_path.abs()]),
compose_agents_summary(
&config,
&[PathUri::from_abs_path(&global_agents_path.abs())]
),
format_directory_display(&global_agents_path, /*max_width*/ None)
);
}
@@ -242,11 +256,33 @@ mod tests {
let config = test_config(&codex_home, &cwd).await;
assert_eq!(
compose_agents_summary(&config, &[override_path.abs()]),
compose_agents_summary(&config, &[PathUri::from_abs_path(&override_path.abs())]),
format_directory_display(&override_path, /*max_width*/ None)
);
}
#[tokio::test]
async fn compose_agents_summary_shows_relative_native_and_full_foreign_paths() {
let codex_home = TempDir::new().expect("temp codex home");
let cwd = TempDir::new().expect("temp cwd");
let config = test_config(&codex_home, &cwd).await;
let native_source = PathUri::from_abs_path(&config.cwd.join("AGENTS.md"));
let foreign_source = if cfg!(windows) {
PathUri::parse("file:///remote%20workspace/AGENTS.md")
.expect("POSIX instruction source")
} else {
PathUri::parse("file:///C:/remote%20workspace/AGENTS.md")
.expect("Windows instruction source")
};
let summary = compose_agents_summary(&config, &[native_source, foreign_source]);
if cfg!(windows) {
insta::assert_snapshot!(summary, @r"AGENTS.md, /remote workspace/AGENTS.md");
} else {
insta::assert_snapshot!(summary, @r"AGENTS.md, C:\remote workspace\AGENTS.md");
}
}
#[tokio::test]
async fn compose_agents_summary_orders_global_before_project_agents() {
let codex_home = TempDir::new().expect("temp codex home");
@@ -258,8 +294,8 @@ mod tests {
let summary = compose_agents_summary(
&config,
&[
global_agents_path.clone().abs(),
project_agents_path.clone().abs(),
PathUri::from_abs_path(&global_agents_path.clone().abs()),
PathUri::from_abs_path(&project_agents_path.clone().abs()),
],
);
let mut paths = summary.split(", ");
+23 -3
View File
@@ -209,13 +209,28 @@ impl PathUri {
PathBuf::from(self.inferred_native_path_string())
}
/// Returns the parent URI, or `None` for the URI root or an opaque fallback
/// URI created by [`Self::from_abs_path`].
/// Returns the lexical parent without crossing the inferred native path root.
///
/// POSIX `/`, Windows drive roots, Windows UNC share roots, and opaque fallback
/// URIs created by [`Self::from_abs_path`] have no parent.
pub fn parent(&self) -> Option<Self> {
if self.encoded_path() == "/" || decode_bad_path_uri(&self.0).is_some() {
if decode_bad_path_uri(&self.0).is_some() {
return None;
}
let convention = self.infer_path_convention()?;
// In URI form, both a Windows drive root (`file:///C:`) and a UNC share root
// (`file://server/share`) retain one non-empty path segment. Keep that segment as the
// anchor so parent traversal cannot produce a URI that is not an absolute Windows path.
let anchor_depth = usize::from(convention == PathConvention::Windows);
let depth = self
.0
.path_segments()?
.filter(|segment| !segment.is_empty())
.count();
if depth <= anchor_depth {
return None;
}
let mut url = self.0.clone();
{
let mut segments = match url.path_segments_mut() {
@@ -227,6 +242,11 @@ impl PathUri {
Some(Self(url))
}
/// Returns this URI and each lexical parent up to its inferred native path root.
pub fn ancestors(&self) -> impl Iterator<Item = Self> {
std::iter::successors(Some(self.clone()), Self::parent)
}
/// Lexically resolves native absolute or relative path text against this URI.
///
/// Path text is interpreted using the POSIX or Windows convention inferred
+33 -3
View File
@@ -522,7 +522,7 @@ fn path_buf_uses_the_inferred_native_spelling() {
}
#[test]
fn parent_uses_uri_hierarchy_and_preserves_authority() {
fn parent_stops_at_posix_drive_and_unc_roots() {
for (input, expected) in [
(
"file:///workspace/src/lib.rs",
@@ -531,12 +531,13 @@ fn parent_uses_uri_hierarchy_and_preserves_authority() {
("file:///workspace", Some("file:///")),
("file:///", None),
("file:///C:/Users", Some("file:///C:")),
("file:///C:/", Some("file:///")),
("file:///C:/", None),
("file:///C:", None),
(
"file://server/share/src/main.rs",
Some("file://server/share/src"),
),
("file://server/share", Some("file://server/")),
("file://server/share", None),
] {
let uri = PathUri::parse(input).expect("valid file URI");
let expected = expected.map(|value| PathUri::parse(value).expect("valid expected URI"));
@@ -544,6 +545,35 @@ fn parent_uses_uri_hierarchy_and_preserves_authority() {
}
}
#[test]
fn ancestors_include_self_and_stop_at_native_path_roots() {
for (input, expected) in [
(
"file:///workspace/src",
vec!["file:///workspace/src", "file:///workspace", "file:///"],
),
(
"file:///C:/workspace/src",
vec![
"file:///C:/workspace/src",
"file:///C:/workspace",
"file:///C:",
],
),
(
"file://server/share/project",
vec!["file://server/share/project", "file://server/share"],
),
] {
let uri = PathUri::parse(input).expect("valid file URI");
let ancestors = uri
.ancestors()
.map(|path| path.to_string())
.collect::<Vec<_>>();
assert_eq!(ancestors, expected, "ancestors for {input}");
}
}
#[test]
fn join_normalizes_relative_uri_segments() {
for (base, relative, expected) in [