mirror of
https://github.com/openai/codex.git
synced 2026-03-04 21:53:21 +00:00
Compare commits
6 Commits
fix/notify
...
bschoepke/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31a9c54dba | ||
|
|
e10df4ba10 | ||
|
|
f8838fd6f3 | ||
|
|
7979ce453a | ||
|
|
7709bf32a3 | ||
|
|
3241c1c6cc |
6
.github/workflows/shell-tool-mcp.yml
vendored
6
.github/workflows/shell-tool-mcp.yml
vendored
@@ -146,9 +146,8 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash
|
||||
git clone https://git.savannah.gnu.org/git/bash /tmp/bash
|
||||
cd /tmp/bash
|
||||
git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
|
||||
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
|
||||
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch"
|
||||
./configure --without-bash-malloc
|
||||
@@ -188,9 +187,8 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash
|
||||
git clone https://git.savannah.gnu.org/git/bash /tmp/bash
|
||||
cd /tmp/bash
|
||||
git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
|
||||
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
|
||||
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch"
|
||||
./configure --without-bash-malloc
|
||||
|
||||
@@ -379,6 +379,9 @@
|
||||
"prevent_idle_sleep": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ps_repl": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"realtime_conversation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -502,6 +505,14 @@
|
||||
"plan_mode_reasoning_effort": {
|
||||
"$ref": "#/definitions/ReasoningEffort"
|
||||
},
|
||||
"ps_repl_path": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Optional absolute path to the PowerShell 7 runtime used by `ps_repl`."
|
||||
},
|
||||
"sandbox_mode": {
|
||||
"$ref": "#/definitions/SandboxMode"
|
||||
},
|
||||
@@ -1750,6 +1761,9 @@
|
||||
"prevent_idle_sleep": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ps_repl": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"realtime_conversation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -2088,6 +2102,14 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ps_repl_path": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Optional absolute path to the PowerShell 7 runtime used by `ps_repl`."
|
||||
},
|
||||
"review_model": {
|
||||
"description": "Review model override used by the `/review` feature.",
|
||||
"type": "string"
|
||||
|
||||
@@ -88,6 +88,7 @@ pub(crate) async fn apply_role_to_config(
|
||||
cwd: Some(config.cwd.clone()),
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
|
||||
ps_repl_path: config.ps_repl_path.clone(),
|
||||
js_repl_node_path: config.js_repl_node_path.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
@@ -277,6 +277,8 @@ use crate::tools::network_approval::NetworkApprovalService;
|
||||
use crate::tools::network_approval::build_blocked_request_observer;
|
||||
use crate::tools::network_approval::build_network_policy_decider;
|
||||
use crate::tools::parallel::ToolCallRuntime;
|
||||
use crate::tools::ps_repl::PsReplHandle;
|
||||
use crate::tools::ps_repl::resolve_compatible_pwsh;
|
||||
use crate::tools::sandboxing::ApprovalStore;
|
||||
use crate::tools::spec::ToolsConfig;
|
||||
use crate::tools::spec::ToolsConfigParams;
|
||||
@@ -378,6 +380,16 @@ impl Codex {
|
||||
config.features.disable(Feature::JsReplToolsOnly);
|
||||
config.startup_warnings.push(message);
|
||||
}
|
||||
if config.features.enabled(Feature::PsRepl)
|
||||
&& let Err(err) = resolve_compatible_pwsh(config.ps_repl_path.as_deref()).await
|
||||
{
|
||||
let message = format!(
|
||||
"Disabled `ps_repl` for this session because the configured PowerShell runtime is unavailable or incompatible. {err}"
|
||||
);
|
||||
warn!("{message}");
|
||||
config.features.disable(Feature::PsRepl);
|
||||
config.startup_warnings.push(message);
|
||||
}
|
||||
|
||||
let allowed_skills_for_implicit_invocation =
|
||||
loaded_skills.allowed_skills_for_implicit_invocation();
|
||||
@@ -608,6 +620,7 @@ pub(crate) struct Session {
|
||||
pub(crate) conversation: Arc<RealtimeConversationManager>,
|
||||
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
|
||||
pub(crate) services: SessionServices,
|
||||
ps_repl: Arc<PsReplHandle>,
|
||||
js_repl: Arc<JsReplHandle>,
|
||||
next_internal_sub_id: AtomicU64,
|
||||
}
|
||||
@@ -663,6 +676,7 @@ pub(crate) struct TurnContext {
|
||||
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
pub(crate) tool_call_gate: Arc<ReadinessFlag>,
|
||||
pub(crate) truncation_policy: TruncationPolicy,
|
||||
pub(crate) ps_repl: Arc<PsReplHandle>,
|
||||
pub(crate) js_repl: Arc<JsReplHandle>,
|
||||
pub(crate) dynamic_tools: Vec<DynamicToolSpec>,
|
||||
pub(crate) turn_metadata_state: Arc<TurnMetadataState>,
|
||||
@@ -751,6 +765,7 @@ impl TurnContext {
|
||||
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
|
||||
tool_call_gate: Arc::new(ReadinessFlag::new()),
|
||||
truncation_policy,
|
||||
ps_repl: Arc::clone(&self.ps_repl),
|
||||
js_repl: Arc::clone(&self.js_repl),
|
||||
dynamic_tools: self.dynamic_tools.clone(),
|
||||
turn_metadata_state: self.turn_metadata_state.clone(),
|
||||
@@ -1056,6 +1071,7 @@ impl Session {
|
||||
model_info: ModelInfo,
|
||||
network: Option<NetworkProxy>,
|
||||
sub_id: String,
|
||||
ps_repl: Arc<PsReplHandle>,
|
||||
js_repl: Arc<JsReplHandle>,
|
||||
skills_outcome: Arc<SkillLoadOutcome>,
|
||||
) -> TurnContext {
|
||||
@@ -1125,6 +1141,7 @@ impl Session {
|
||||
codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(),
|
||||
tool_call_gate: Arc::new(ReadinessFlag::new()),
|
||||
truncation_policy: model_info.truncation_policy.into(),
|
||||
ps_repl,
|
||||
js_repl,
|
||||
dynamic_tools: session_configuration.dynamic_tools.clone(),
|
||||
turn_metadata_state,
|
||||
@@ -1519,6 +1536,7 @@ impl Session {
|
||||
config.js_repl_node_path.clone(),
|
||||
config.js_repl_node_module_dirs.clone(),
|
||||
));
|
||||
let ps_repl = Arc::new(PsReplHandle::with_pwsh_path(config.ps_repl_path.clone()));
|
||||
|
||||
let sess = Arc::new(Session {
|
||||
conversation_id,
|
||||
@@ -1530,6 +1548,7 @@ impl Session {
|
||||
conversation: Arc::new(RealtimeConversationManager::new()),
|
||||
active_turn: Mutex::new(None),
|
||||
services,
|
||||
ps_repl,
|
||||
js_repl,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
});
|
||||
@@ -2167,6 +2186,7 @@ impl Session {
|
||||
.as_ref()
|
||||
.map(StartedNetworkProxy::proxy),
|
||||
sub_id,
|
||||
Arc::clone(&self.ps_repl),
|
||||
Arc::clone(&self.js_repl),
|
||||
skills_outcome,
|
||||
);
|
||||
@@ -4695,6 +4715,7 @@ async fn spawn_review_thread(
|
||||
final_output_json_schema: None,
|
||||
codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(),
|
||||
tool_call_gate: Arc::new(ReadinessFlag::new()),
|
||||
ps_repl: Arc::clone(&sess.ps_repl),
|
||||
js_repl: Arc::clone(&sess.js_repl),
|
||||
dynamic_tools: parent_turn_context.dynamic_tools.clone(),
|
||||
truncation_policy: model_info.truncation_policy.into(),
|
||||
@@ -8334,6 +8355,7 @@ mod tests {
|
||||
config.js_repl_node_path.clone(),
|
||||
config.js_repl_node_module_dirs.clone(),
|
||||
));
|
||||
let ps_repl = Arc::new(PsReplHandle::with_pwsh_path(config.ps_repl_path.clone()));
|
||||
|
||||
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config));
|
||||
let turn_context = Session::make_turn_context(
|
||||
@@ -8345,6 +8367,7 @@ mod tests {
|
||||
model_info,
|
||||
None,
|
||||
"turn_id".to_string(),
|
||||
Arc::clone(&ps_repl),
|
||||
Arc::clone(&js_repl),
|
||||
skills_outcome,
|
||||
);
|
||||
@@ -8359,6 +8382,7 @@ mod tests {
|
||||
conversation: Arc::new(RealtimeConversationManager::new()),
|
||||
active_turn: Mutex::new(None),
|
||||
services,
|
||||
ps_repl,
|
||||
js_repl,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
};
|
||||
@@ -8502,6 +8526,7 @@ mod tests {
|
||||
config.js_repl_node_path.clone(),
|
||||
config.js_repl_node_module_dirs.clone(),
|
||||
));
|
||||
let ps_repl = Arc::new(PsReplHandle::with_pwsh_path(config.ps_repl_path.clone()));
|
||||
|
||||
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config));
|
||||
let turn_context = Arc::new(Session::make_turn_context(
|
||||
@@ -8513,6 +8538,7 @@ mod tests {
|
||||
model_info,
|
||||
None,
|
||||
"turn_id".to_string(),
|
||||
Arc::clone(&ps_repl),
|
||||
Arc::clone(&js_repl),
|
||||
skills_outcome,
|
||||
));
|
||||
@@ -8527,6 +8553,7 @@ mod tests {
|
||||
conversation: Arc::new(RealtimeConversationManager::new()),
|
||||
active_turn: Mutex::new(None),
|
||||
services,
|
||||
ps_repl,
|
||||
js_repl,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
});
|
||||
|
||||
@@ -394,6 +394,9 @@ pub struct Config {
|
||||
/// code via [`ConfigOverrides`].
|
||||
pub main_execve_wrapper_exe: Option<PathBuf>,
|
||||
|
||||
/// Optional absolute path to the PowerShell 7 runtime used by `ps_repl`.
|
||||
pub ps_repl_path: Option<PathBuf>,
|
||||
|
||||
/// Optional absolute path to the Node runtime used by `js_repl`.
|
||||
pub js_repl_node_path: Option<PathBuf>,
|
||||
|
||||
@@ -1121,6 +1124,9 @@ pub struct ConfigToml {
|
||||
/// Default: `300000` (5 minutes).
|
||||
pub background_terminal_max_timeout: Option<u64>,
|
||||
|
||||
/// Optional absolute path to the PowerShell 7 runtime used by `ps_repl`.
|
||||
pub ps_repl_path: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Optional absolute path to the Node runtime used by `js_repl`.
|
||||
pub js_repl_node_path: Option<AbsolutePathBuf>,
|
||||
|
||||
@@ -1553,6 +1559,7 @@ pub struct ConfigOverrides {
|
||||
pub config_profile: Option<String>,
|
||||
pub codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
pub main_execve_wrapper_exe: Option<PathBuf>,
|
||||
pub ps_repl_path: Option<PathBuf>,
|
||||
pub js_repl_node_path: Option<PathBuf>,
|
||||
pub js_repl_node_module_dirs: Option<Vec<PathBuf>>,
|
||||
pub zsh_path: Option<PathBuf>,
|
||||
@@ -1683,6 +1690,7 @@ impl Config {
|
||||
config_profile: config_profile_key,
|
||||
codex_linux_sandbox_exe,
|
||||
main_execve_wrapper_exe,
|
||||
ps_repl_path: ps_repl_path_override,
|
||||
js_repl_node_path: js_repl_node_path_override,
|
||||
js_repl_node_module_dirs: js_repl_node_module_dirs_override,
|
||||
zsh_path: zsh_path_override,
|
||||
@@ -1981,6 +1989,9 @@ impl Config {
|
||||
"experimental compact prompt file",
|
||||
)?;
|
||||
let compact_prompt = compact_prompt.or(file_compact_prompt);
|
||||
let ps_repl_path = ps_repl_path_override
|
||||
.or(config_profile.ps_repl_path.map(Into::into))
|
||||
.or(cfg.ps_repl_path.map(Into::into));
|
||||
let js_repl_node_path = js_repl_node_path_override
|
||||
.or(config_profile.js_repl_node_path.map(Into::into))
|
||||
.or(cfg.js_repl_node_path.map(Into::into));
|
||||
@@ -2150,6 +2161,7 @@ impl Config {
|
||||
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
|
||||
codex_linux_sandbox_exe,
|
||||
main_execve_wrapper_exe,
|
||||
ps_repl_path,
|
||||
js_repl_node_path,
|
||||
js_repl_node_module_dirs,
|
||||
zsh_path,
|
||||
@@ -4909,6 +4921,7 @@ model_verbosity = "high"
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
main_execve_wrapper_exe: None,
|
||||
ps_repl_path: None,
|
||||
js_repl_node_path: None,
|
||||
js_repl_node_module_dirs: Vec::new(),
|
||||
zsh_path: None,
|
||||
@@ -5037,6 +5050,7 @@ model_verbosity = "high"
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
main_execve_wrapper_exe: None,
|
||||
ps_repl_path: None,
|
||||
js_repl_node_path: None,
|
||||
js_repl_node_module_dirs: Vec::new(),
|
||||
zsh_path: None,
|
||||
@@ -5163,6 +5177,7 @@ model_verbosity = "high"
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
main_execve_wrapper_exe: None,
|
||||
ps_repl_path: None,
|
||||
js_repl_node_path: None,
|
||||
js_repl_node_module_dirs: Vec::new(),
|
||||
zsh_path: None,
|
||||
@@ -5275,6 +5290,7 @@ model_verbosity = "high"
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
main_execve_wrapper_exe: None,
|
||||
ps_repl_path: None,
|
||||
js_repl_node_path: None,
|
||||
js_repl_node_module_dirs: Vec::new(),
|
||||
zsh_path: None,
|
||||
|
||||
@@ -33,6 +33,8 @@ pub struct ConfigProfile {
|
||||
pub chatgpt_base_url: Option<String>,
|
||||
/// Optional path to a file containing model instructions.
|
||||
pub model_instructions_file: Option<AbsolutePathBuf>,
|
||||
/// Optional absolute path to the PowerShell 7 runtime used by `ps_repl`.
|
||||
pub ps_repl_path: Option<AbsolutePathBuf>,
|
||||
pub js_repl_node_path: Option<AbsolutePathBuf>,
|
||||
/// Ordered list of directories to search for Node modules in `js_repl`.
|
||||
pub js_repl_node_module_dirs: Option<Vec<AbsolutePathBuf>>,
|
||||
|
||||
@@ -6,7 +6,6 @@ mod macos;
|
||||
mod tests;
|
||||
|
||||
use crate::config::ConfigToml;
|
||||
use crate::config::deserialize_config_toml_with_base;
|
||||
use crate::config_loader::layer_io::LoadedConfigLayers;
|
||||
use crate::git_info::resolve_root_git_project_for_trust;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
@@ -576,6 +575,11 @@ struct ProjectTrustContext {
|
||||
user_config_file: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProjectTrustConfigToml {
|
||||
projects: Option<std::collections::HashMap<String, crate::config::ProjectConfig>>,
|
||||
}
|
||||
|
||||
struct ProjectTrustDecision {
|
||||
trust_level: Option<TrustLevel>,
|
||||
trust_key: String,
|
||||
@@ -666,10 +670,16 @@ async fn project_trust_context(
|
||||
config_base_dir: &Path,
|
||||
user_config_file: &AbsolutePathBuf,
|
||||
) -> io::Result<ProjectTrustContext> {
|
||||
let config_toml = deserialize_config_toml_with_base(merged_config.clone(), config_base_dir)?;
|
||||
let project_trust_config: ProjectTrustConfigToml = {
|
||||
let _guard = AbsolutePathBufGuard::new(config_base_dir);
|
||||
merged_config
|
||||
.clone()
|
||||
.try_into()
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?
|
||||
};
|
||||
|
||||
let project_root = find_project_root(cwd, project_root_markers).await?;
|
||||
let projects = config_toml.projects.unwrap_or_default();
|
||||
let projects = project_trust_config.projects.unwrap_or_default();
|
||||
|
||||
let project_root_key = project_root.as_path().to_string_lossy().to_string();
|
||||
let repo_root = resolve_root_git_project_for_trust(cwd.as_path());
|
||||
|
||||
@@ -1114,6 +1114,91 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cli_override_can_update_project_local_mcp_server_when_project_is_trusted()
|
||||
-> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let project_root = tmp.path().join("project");
|
||||
let nested = project_root.join("child");
|
||||
let dot_codex = project_root.join(".codex");
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&nested).await?;
|
||||
tokio::fs::create_dir_all(&dot_codex).await?;
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
tokio::fs::write(project_root.join(".git"), "gitdir: here").await?;
|
||||
tokio::fs::write(
|
||||
dot_codex.join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[mcp_servers.sentry]
|
||||
url = "https://mcp.sentry.dev/mcp"
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home)
|
||||
.cli_overrides(vec![(
|
||||
"mcp_servers.sentry.enabled".to_string(),
|
||||
TomlValue::Boolean(true),
|
||||
)])
|
||||
.fallback_cwd(Some(nested))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
let server = config
|
||||
.mcp_servers
|
||||
.get()
|
||||
.get("sentry")
|
||||
.expect("trusted project MCP server should load");
|
||||
assert!(server.enabled);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cli_override_for_disabled_project_local_mcp_server_returns_invalid_transport()
|
||||
-> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let project_root = tmp.path().join("project");
|
||||
let nested = project_root.join("child");
|
||||
let dot_codex = project_root.join(".codex");
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&nested).await?;
|
||||
tokio::fs::create_dir_all(&dot_codex).await?;
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
tokio::fs::write(project_root.join(".git"), "gitdir: here").await?;
|
||||
tokio::fs::write(
|
||||
dot_codex.join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[mcp_servers.sentry]
|
||||
url = "https://mcp.sentry.dev/mcp"
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let err = ConfigBuilder::default()
|
||||
.codex_home(codex_home)
|
||||
.cli_overrides(vec![(
|
||||
"mcp_servers.sentry.enabled".to_string(),
|
||||
TomlValue::Boolean(true),
|
||||
)])
|
||||
.fallback_cwd(Some(nested))
|
||||
.build()
|
||||
.await
|
||||
.expect_err("untrusted project layer should not provide MCP transport");
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("invalid transport")
|
||||
&& err.to_string().contains("mcp_servers.sentry"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
|
||||
@@ -78,6 +78,8 @@ pub enum Feature {
|
||||
ShellTool,
|
||||
|
||||
// Experimental
|
||||
/// Enable PowerShell REPL tools backed by a persistent pwsh kernel.
|
||||
PsRepl,
|
||||
/// Enable JavaScript REPL tools backed by a persistent Node kernel.
|
||||
JsRepl,
|
||||
/// Only expose js_repl tools directly to the model.
|
||||
@@ -460,6 +462,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::PsRepl,
|
||||
key: "ps_repl",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::JsRepl,
|
||||
key: "js_repl",
|
||||
@@ -825,6 +833,12 @@ mod tests {
|
||||
assert_eq!(Feature::JsRepl.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ps_repl_is_under_development_and_disabled_by_default() {
|
||||
assert_eq!(Feature::PsRepl.stage(), Stage::UnderDevelopment);
|
||||
assert_eq!(Feature::PsRepl.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collab_is_legacy_alias_for_multi_agent() {
|
||||
assert_eq!(feature_for_key("multi_agent"), Some(Feature::Collab));
|
||||
|
||||
@@ -69,6 +69,25 @@ fn render_js_repl_instructions(config: &Config) -> Option<String> {
|
||||
Some(section)
|
||||
}
|
||||
|
||||
fn render_ps_repl_instructions(config: &Config) -> Option<String> {
|
||||
if !config.features.enabled(Feature::PsRepl) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut section = String::from("## PowerShell REPL (pwsh)\n");
|
||||
section.push_str(
|
||||
"- Use `ps_repl` for PowerShell-backed automation in a persistent `pwsh` kernel.\n",
|
||||
);
|
||||
section.push_str("- `ps_repl` is a freeform/custom tool. Direct `ps_repl` calls must send raw PowerShell input (optionally with first-line `# codex-ps-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n");
|
||||
section.push_str("- Helpers: `$CodexTmpDir`, `Invoke-CodexTool -Name <string> -Arguments <object|string>`, `$Codex.TmpDir`, and `$Codex.Tool(<name>, <args>)`.\n");
|
||||
section.push_str("- `Invoke-CodexTool` returns the raw tool output object. Use it for built-in tools, dynamic tools, and MCP tools.\n");
|
||||
section.push_str("- PowerShell session state persists across calls, including variables, functions, aliases, imported modules, environment changes, and `$LASTEXITCODE`. Reset the kernel with `ps_repl_reset` when needed.\n");
|
||||
section.push_str("- To share generated images with the model, write a file under `$CodexTmpDir`, call `Invoke-CodexTool -Name view_image -Arguments @{ path = \"/absolute/path\" }`, then delete the file.\n");
|
||||
section.push_str("- Avoid direct `[Console]::Write*`, raw StdOut/StdErr writes, or other host-level output that bypasses PowerShell streams; they can corrupt the JSON line protocol. Use pipeline output, `Write-Output`, `Write-Host`, `Write-Verbose`, or `Write-Warning` instead.");
|
||||
|
||||
Some(section)
|
||||
}
|
||||
|
||||
/// Combines `Config::instructions` and `AGENTS.md` (if present) into a single
|
||||
/// string of instructions.
|
||||
pub(crate) async fn get_user_instructions(
|
||||
@@ -103,6 +122,13 @@ pub(crate) async fn get_user_instructions(
|
||||
output.push_str(&js_repl_section);
|
||||
}
|
||||
|
||||
if let Some(ps_repl_section) = render_ps_repl_instructions(config) {
|
||||
if !output.is_empty() {
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
output.push_str(&ps_repl_section);
|
||||
}
|
||||
|
||||
let skills_section = skills.and_then(render_skills_section);
|
||||
if let Some(skills_section) = skills_section {
|
||||
if !output.is_empty() {
|
||||
@@ -492,6 +518,19 @@ mod tests {
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ps_repl_instructions_are_appended_when_enabled() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut cfg = make_config(&tmp, 4096, None).await;
|
||||
cfg.features.enable(Feature::PsRepl);
|
||||
|
||||
let res = get_user_instructions(&cfg, None)
|
||||
.await
|
||||
.expect("ps_repl instructions expected");
|
||||
let expected = "## PowerShell REPL (pwsh)\n- Use `ps_repl` for PowerShell-backed automation in a persistent `pwsh` kernel.\n- `ps_repl` is a freeform/custom tool. Direct `ps_repl` calls must send raw PowerShell input (optionally with first-line `# codex-ps-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `$CodexTmpDir`, `Invoke-CodexTool -Name <string> -Arguments <object|string>`, `$Codex.TmpDir`, and `$Codex.Tool(<name>, <args>)`.\n- `Invoke-CodexTool` returns the raw tool output object. Use it for built-in tools, dynamic tools, and MCP tools.\n- PowerShell session state persists across calls, including variables, functions, aliases, imported modules, environment changes, and `$LASTEXITCODE`. Reset the kernel with `ps_repl_reset` when needed.\n- To share generated images with the model, write a file under `$CodexTmpDir`, call `Invoke-CodexTool -Name view_image -Arguments @{ path = \"/absolute/path\" }`, then delete the file.\n- Avoid direct `[Console]::Write*`, raw StdOut/StdErr writes, or other host-level output that bypasses PowerShell streams; they can corrupt the JSON line protocol. Use pipeline output, `Write-Output`, `Write-Host`, `Write-Verbose`, or `Write-Warning` instead.";
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
/// When both system instructions *and* a project doc are present the two
|
||||
/// should be concatenated with the separator.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -360,19 +360,17 @@ alias_count=$(alias -p | wc -l | tr -d ' ')
|
||||
echo "# aliases $alias_count"
|
||||
alias -p
|
||||
echo ''
|
||||
export_lines=$(export -p | awk '
|
||||
/^(export|declare -x|typeset -x) / {
|
||||
line=$0
|
||||
name=line
|
||||
sub(/^(export|declare -x|typeset -x) /, "", name)
|
||||
sub(/=.*/, "", name)
|
||||
if (name ~ /^(EXCLUDED_EXPORTS)$/) {
|
||||
next
|
||||
}
|
||||
if (name ~ /^[A-Za-z_][A-Za-z0-9_]*$/) {
|
||||
print line
|
||||
}
|
||||
}')
|
||||
export_lines=$(
|
||||
while IFS= read -r name; do
|
||||
if [[ "$name" =~ ^(EXCLUDED_EXPORTS)$ ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ ! "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
|
||||
continue
|
||||
fi
|
||||
declare -xp "$name" 2>/dev/null || true
|
||||
done < <(compgen -e)
|
||||
)
|
||||
export_count=$(printf '%s\n' "$export_lines" | sed '/^$/d' | wc -l | tr -d ' ')
|
||||
echo "# exports $export_count"
|
||||
if [ -n "$export_lines" ]; then
|
||||
@@ -671,6 +669,46 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn bash_snapshot_preserves_multiline_exports() -> Result<()> {
|
||||
let multiline_cert = "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----";
|
||||
let output = Command::new("/bin/bash")
|
||||
.arg("-c")
|
||||
.arg(bash_snapshot_script())
|
||||
.env("BASH_ENV", "/dev/null")
|
||||
.env("MULTILINE_CERT", multiline_cert)
|
||||
.output()?;
|
||||
|
||||
assert!(output.status.success());
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
stdout.contains("MULTILINE_CERT=") || stdout.contains("MULTILINE_CERT"),
|
||||
"snapshot should include the multiline export name"
|
||||
);
|
||||
|
||||
let dir = tempdir()?;
|
||||
let snapshot_path = dir.path().join("snapshot.sh");
|
||||
std::fs::write(&snapshot_path, stdout.as_bytes())?;
|
||||
|
||||
let validate = Command::new("/bin/bash")
|
||||
.arg("-c")
|
||||
.arg("set -e; . \"$1\"")
|
||||
.arg("bash")
|
||||
.arg(&snapshot_path)
|
||||
.env("BASH_ENV", "/dev/null")
|
||||
.output()?;
|
||||
|
||||
assert!(
|
||||
validate.status.success(),
|
||||
"snapshot validation failed: {}",
|
||||
String::from_utf8_lossy(&validate.stderr)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn try_new_creates_and_deletes_snapshot_file() -> Result<()> {
|
||||
|
||||
@@ -20,6 +20,7 @@ pub type SharedTurnDiffTracker = Arc<Mutex<TurnDiffTracker>>;
|
||||
pub enum ToolCallSource {
|
||||
Direct,
|
||||
JsRepl,
|
||||
PsRepl,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -8,6 +8,7 @@ mod mcp;
|
||||
mod mcp_resource;
|
||||
pub(crate) mod multi_agents;
|
||||
mod plan;
|
||||
mod ps_repl;
|
||||
mod read_file;
|
||||
mod request_user_input;
|
||||
mod search_tool_bm25;
|
||||
@@ -38,6 +39,8 @@ pub use mcp::McpHandler;
|
||||
pub use mcp_resource::McpResourceHandler;
|
||||
pub use multi_agents::MultiAgentHandler;
|
||||
pub use plan::PlanHandler;
|
||||
pub use ps_repl::PsReplHandler;
|
||||
pub use ps_repl::PsReplResetHandler;
|
||||
pub use read_file::ReadFileHandler;
|
||||
pub use request_user_input::RequestUserInputHandler;
|
||||
pub(crate) use request_user_input::request_user_input_tool_description;
|
||||
|
||||
363
codex-rs/core/src/tools/handlers/ps_repl.rs
Normal file
363
codex-rs/core/src/tools/handlers/ps_repl.rs
Normal file
@@ -0,0 +1,363 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::exec::StreamOutput;
|
||||
use crate::features::Feature;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::protocol::ExecCommandSource;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::events::ToolEmitter;
|
||||
use crate::tools::events::ToolEventCtx;
|
||||
use crate::tools::events::ToolEventFailure;
|
||||
use crate::tools::events::ToolEventStage;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::ps_repl::PS_REPL_PRAGMA_PREFIX;
|
||||
use crate::tools::ps_repl::PsExecResult;
|
||||
use crate::tools::ps_repl::PsReplArgs;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
|
||||
pub struct PsReplHandler;
|
||||
pub struct PsReplResetHandler;
|
||||
|
||||
fn join_outputs(stdout: &str, stderr: &str) -> String {
|
||||
if stdout.is_empty() {
|
||||
stderr.to_string()
|
||||
} else if stderr.is_empty() {
|
||||
stdout.to_string()
|
||||
} else {
|
||||
format!("{stdout}\n{stderr}")
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ps_repl_exec_output(
|
||||
output: &str,
|
||||
error: Option<&str>,
|
||||
duration: Duration,
|
||||
) -> ExecToolCallOutput {
|
||||
let stdout = output.to_string();
|
||||
let stderr = error.unwrap_or("").to_string();
|
||||
let aggregated_output = join_outputs(&stdout, &stderr);
|
||||
ExecToolCallOutput {
|
||||
exit_code: if error.is_some() { 1 } else { 0 },
|
||||
stdout: StreamOutput::new(stdout),
|
||||
stderr: StreamOutput::new(stderr),
|
||||
aggregated_output: StreamOutput::new(aggregated_output),
|
||||
duration,
|
||||
timed_out: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn emit_ps_repl_exec_begin(
|
||||
session: &crate::codex::Session,
|
||||
turn: &crate::codex::TurnContext,
|
||||
call_id: &str,
|
||||
) {
|
||||
let emitter = ToolEmitter::shell(
|
||||
vec!["ps_repl".to_string()],
|
||||
turn.cwd.clone(),
|
||||
ExecCommandSource::Agent,
|
||||
false,
|
||||
);
|
||||
let ctx = ToolEventCtx::new(session, turn, call_id, None);
|
||||
emitter.emit(ctx, ToolEventStage::Begin).await;
|
||||
}
|
||||
|
||||
async fn emit_ps_repl_exec_end(
|
||||
session: &crate::codex::Session,
|
||||
turn: &crate::codex::TurnContext,
|
||||
call_id: &str,
|
||||
output: &str,
|
||||
error: Option<&str>,
|
||||
duration: Duration,
|
||||
) {
|
||||
let exec_output = build_ps_repl_exec_output(output, error, duration);
|
||||
let emitter = ToolEmitter::shell(
|
||||
vec!["ps_repl".to_string()],
|
||||
turn.cwd.clone(),
|
||||
ExecCommandSource::Agent,
|
||||
false,
|
||||
);
|
||||
let ctx = ToolEventCtx::new(session, turn, call_id, None);
|
||||
let stage = if error.is_some() {
|
||||
ToolEventStage::Failure(ToolEventFailure::Output(exec_output))
|
||||
} else {
|
||||
ToolEventStage::Success(exec_output)
|
||||
};
|
||||
emitter.emit(ctx, stage).await;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for PsReplHandler {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
fn matches_kind(&self, payload: &ToolPayload) -> bool {
|
||||
matches!(
|
||||
payload,
|
||||
ToolPayload::Function { .. } | ToolPayload::Custom { .. }
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
tracker,
|
||||
payload,
|
||||
call_id,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
if !session.features().enabled(Feature::PsRepl) {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"ps_repl is disabled by feature flag".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let args = match payload {
|
||||
ToolPayload::Function { arguments } => parse_arguments(&arguments)?,
|
||||
ToolPayload::Custom { input } => parse_freeform_args(&input)?,
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"ps_repl expects custom or function payload".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let manager = turn.ps_repl.manager().await?;
|
||||
let started_at = Instant::now();
|
||||
emit_ps_repl_exec_begin(session.as_ref(), turn.as_ref(), &call_id).await;
|
||||
let result = manager
|
||||
.execute(Arc::clone(&session), Arc::clone(&turn), tracker, args)
|
||||
.await;
|
||||
let result = match result {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
let message = err.to_string();
|
||||
emit_ps_repl_exec_end(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
&call_id,
|
||||
"",
|
||||
Some(&message),
|
||||
started_at.elapsed(),
|
||||
)
|
||||
.await;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
emit_ps_repl_exec_end(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
&call_id,
|
||||
&result.output,
|
||||
None,
|
||||
started_at.elapsed(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(build_tool_output(result))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tool_output(result: PsExecResult) -> ToolOutput {
|
||||
let PsExecResult {
|
||||
output,
|
||||
content_items,
|
||||
} = result;
|
||||
let mut items = Vec::with_capacity(content_items.len() + 1);
|
||||
if !output.is_empty() {
|
||||
items.push(FunctionCallOutputContentItem::InputText {
|
||||
text: output.clone(),
|
||||
});
|
||||
}
|
||||
items.extend(content_items);
|
||||
|
||||
ToolOutput::Function {
|
||||
body: if items.is_empty() {
|
||||
FunctionCallOutputBody::Text(output)
|
||||
} else {
|
||||
FunctionCallOutputBody::ContentItems(items)
|
||||
},
|
||||
success: Some(true),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for PsReplResetHandler {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
|
||||
if !invocation.session.features().enabled(Feature::PsRepl) {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"ps_repl is disabled by feature flag".to_string(),
|
||||
));
|
||||
}
|
||||
let manager = invocation.turn.ps_repl.manager().await?;
|
||||
manager.reset().await?;
|
||||
Ok(ToolOutput::Function {
|
||||
body: FunctionCallOutputBody::Text("ps_repl kernel reset".to_string()),
|
||||
success: Some(true),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_freeform_args(input: &str) -> Result<PsReplArgs, FunctionCallError> {
|
||||
if input.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"ps_repl expects raw PowerShell tool input (non-empty). Provide PowerShell source text, optionally with first-line `# codex-ps-repl: ...`."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut args = PsReplArgs {
|
||||
code: input.to_string(),
|
||||
timeout_ms: None,
|
||||
};
|
||||
|
||||
let mut lines = input.splitn(2, '\n');
|
||||
let first_line = lines.next().unwrap_or_default();
|
||||
let rest = lines.next().unwrap_or_default();
|
||||
let trimmed = first_line.trim_start();
|
||||
let Some(pragma) = trimmed.strip_prefix(PS_REPL_PRAGMA_PREFIX) else {
|
||||
reject_json_or_quoted_source(&args.code)?;
|
||||
return Ok(args);
|
||||
};
|
||||
|
||||
let mut timeout_ms: Option<u64> = None;
|
||||
let directive = pragma.trim();
|
||||
if !directive.is_empty() {
|
||||
for token in directive.split_whitespace() {
|
||||
let (key, value) = token.split_once('=').ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"ps_repl pragma expects space-separated key=value pairs (supported keys: timeout_ms); got `{token}`"
|
||||
))
|
||||
})?;
|
||||
match key {
|
||||
"timeout_ms" => {
|
||||
if timeout_ms.is_some() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"ps_repl pragma specifies timeout_ms more than once".to_string(),
|
||||
));
|
||||
}
|
||||
let parsed = value.parse::<u64>().map_err(|_| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"ps_repl pragma timeout_ms must be an integer; got `{value}`"
|
||||
))
|
||||
})?;
|
||||
timeout_ms = Some(parsed);
|
||||
}
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"ps_repl pragma only supports timeout_ms; got `{key}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rest.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"ps_repl pragma must be followed by PowerShell source on subsequent lines".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
reject_json_or_quoted_source(rest)?;
|
||||
args.code = rest.to_string();
|
||||
args.timeout_ms = timeout_ms;
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn reject_json_or_quoted_source(code: &str) -> Result<(), FunctionCallError> {
|
||||
let trimmed = code.trim();
|
||||
if trimmed.starts_with("```") {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"ps_repl expects raw PowerShell source, not markdown code fences. Resend plain PowerShell only (optional first line `# codex-ps-repl: ...`)."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if is_quoted_source(trimmed) {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"ps_repl is a freeform tool and expects raw PowerShell source. Resend plain PowerShell only (optional first line `# codex-ps-repl: ...`); do not send quoted code or markdown fences."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let Ok(value) = serde_json::from_str::<JsonValue>(trimmed) else {
|
||||
return Ok(());
|
||||
};
|
||||
match value {
|
||||
JsonValue::Object(_) | JsonValue::String(_) => Err(FunctionCallError::RespondToModel(
|
||||
"ps_repl is a freeform tool and expects raw PowerShell source. Resend plain PowerShell only (optional first line `# codex-ps-repl: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences."
|
||||
.to_string(),
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_quoted_source(input: &str) -> bool {
|
||||
input.len() >= 2
|
||||
&& ((input.starts_with('"') && input.ends_with('"'))
|
||||
|| (input.starts_with('\'') && input.ends_with('\'')))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_freeform_args;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_without_pragma() {
|
||||
let args = parse_freeform_args("Write-Output 'ok'").expect("parse args");
|
||||
assert_eq!(args.code, "Write-Output 'ok'");
|
||||
assert_eq!(args.timeout_ms, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_with_pragma() {
|
||||
let input = "# codex-ps-repl: timeout_ms=15000\nWrite-Output 'ok'";
|
||||
let args = parse_freeform_args(input).expect("parse args");
|
||||
assert_eq!(args.code, "Write-Output 'ok'");
|
||||
assert_eq!(args.timeout_ms, Some(15_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_rejects_unknown_key() {
|
||||
let err = parse_freeform_args("# codex-ps-repl: nope=1\nWrite-Output 'ok'")
|
||||
.expect_err("expected error");
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"ps_repl pragma only supports timeout_ms; got `nope`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_rejects_json_wrapped_code() {
|
||||
let err =
|
||||
parse_freeform_args(r#"{"code":"Write-Output 'ok'"}"#).expect_err("expected error");
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"ps_repl is a freeform tool and expects raw PowerShell source. Resend plain PowerShell only (optional first line `# codex-ps-repl: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_rejects_quoted_source() {
|
||||
let err = parse_freeform_args("'Write-Output ok'").expect_err("expected error");
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"ps_repl is a freeform tool and expects raw PowerShell source. Resend plain PowerShell only (optional first line `# codex-ps-repl: ...`); do not send quoted code or markdown fences."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod js_repl;
|
||||
pub(crate) mod network_approval;
|
||||
pub mod orchestrator;
|
||||
pub mod parallel;
|
||||
pub mod ps_repl;
|
||||
pub mod registry;
|
||||
pub mod router;
|
||||
pub mod runtimes;
|
||||
|
||||
187
codex-rs/core/src/tools/ps_repl/kernel.ps1
Normal file
187
codex-rs/core/src/tools/ps_repl/kernel.ps1
Normal file
@@ -0,0 +1,187 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
$script:CodexTmpDir = if ($env:CODEX_PS_TMP_DIR) {
|
||||
$env:CODEX_PS_TMP_DIR
|
||||
} else {
|
||||
(Get-Location).Path
|
||||
}
|
||||
$script:ToolCounter = 0
|
||||
$script:ActiveExecId = $null
|
||||
|
||||
function Send-KernelMessage {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[hashtable]$Message
|
||||
)
|
||||
|
||||
$json = $Message | ConvertTo-Json -Compress -Depth 100
|
||||
[Console]::Out.WriteLine($json)
|
||||
[Console]::Out.Flush()
|
||||
}
|
||||
|
||||
function Read-KernelMessage {
|
||||
while ($true) {
|
||||
$line = [Console]::In.ReadLine()
|
||||
if ($null -eq $line) {
|
||||
return $null
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($line)) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
return $line | ConvertFrom-Json -AsHashtable -Depth 100
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Format-StreamItem {
|
||||
param($Item)
|
||||
|
||||
if ($null -eq $Item) {
|
||||
return $null
|
||||
}
|
||||
if ($Item -is [string]) {
|
||||
return $Item.TrimEnd("`r", "`n")
|
||||
}
|
||||
|
||||
$text = $Item | Out-String -Width 4096
|
||||
$trimmed = $text.TrimEnd("`r", "`n")
|
||||
if ([string]::IsNullOrEmpty($trimmed)) {
|
||||
return $null
|
||||
}
|
||||
$trimmed
|
||||
}
|
||||
|
||||
function Wait-ToolResult {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Id
|
||||
)
|
||||
|
||||
while ($true) {
|
||||
$message = Read-KernelMessage
|
||||
if ($null -eq $message) {
|
||||
throw "ps_repl kernel closed while waiting for tool result"
|
||||
}
|
||||
if ($message.type -ne 'run_tool_result') {
|
||||
throw "ps_repl kernel received unexpected message while waiting for tool result: $($message.type)"
|
||||
}
|
||||
if ($message.id -ne $Id) {
|
||||
throw "ps_repl kernel received mismatched tool result: expected $Id, got $($message.id)"
|
||||
}
|
||||
return $message
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-CodexTool {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Name,
|
||||
[object]$Arguments
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Name)) {
|
||||
throw "Invoke-CodexTool expects a non-empty tool name"
|
||||
}
|
||||
if ($null -eq $script:ActiveExecId) {
|
||||
throw "Invoke-CodexTool can only be used while a ps_repl exec is running"
|
||||
}
|
||||
|
||||
$toolId = "{0}-tool-{1}" -f $script:ActiveExecId, $script:ToolCounter
|
||||
$script:ToolCounter += 1
|
||||
|
||||
$argumentsJson = '{}'
|
||||
if ($PSBoundParameters.ContainsKey('Arguments')) {
|
||||
if ($Arguments -is [string]) {
|
||||
$argumentsJson = $Arguments
|
||||
} else {
|
||||
$argumentsJson = $Arguments | ConvertTo-Json -Compress -Depth 100
|
||||
}
|
||||
}
|
||||
|
||||
Send-KernelMessage @{
|
||||
type = 'run_tool'
|
||||
id = $toolId
|
||||
exec_id = $script:ActiveExecId
|
||||
tool_name = $Name
|
||||
arguments = $argumentsJson
|
||||
}
|
||||
|
||||
$result = Wait-ToolResult -Id $toolId
|
||||
if (-not $result.ok) {
|
||||
if ($null -ne $result.error -and -not [string]::IsNullOrWhiteSpace([string]$result.error)) {
|
||||
throw [System.Exception]::new([string]$result.error)
|
||||
}
|
||||
throw [System.Exception]::new('tool failed')
|
||||
}
|
||||
|
||||
$result.response
|
||||
}
|
||||
|
||||
$script:Codex = [pscustomobject]@{
|
||||
TmpDir = $script:CodexTmpDir
|
||||
}
|
||||
$null = $script:Codex | Add-Member -MemberType ScriptMethod -Name Tool -Value {
|
||||
param($Name, $Arguments)
|
||||
|
||||
if ($PSBoundParameters.ContainsKey('Arguments')) {
|
||||
Invoke-CodexTool -Name $Name -Arguments $Arguments
|
||||
} else {
|
||||
Invoke-CodexTool -Name $Name
|
||||
}
|
||||
}
|
||||
Set-Variable -Name Codex -Scope Script -Value $script:Codex
|
||||
Set-Variable -Name CodexTmpDir -Scope Script -Value $script:CodexTmpDir
|
||||
|
||||
while ($true) {
|
||||
$message = Read-KernelMessage
|
||||
if ($null -eq $message) {
|
||||
break
|
||||
}
|
||||
if ($message.type -ne 'exec') {
|
||||
continue
|
||||
}
|
||||
|
||||
$script:ActiveExecId = [string]$message.id
|
||||
|
||||
try {
|
||||
$scriptBlock = [scriptblock]::Create([string]$message.code)
|
||||
$items = @(. $scriptBlock *>&1)
|
||||
$outputLines = foreach ($item in $items) {
|
||||
$formatted = Format-StreamItem -Item $item
|
||||
if ($null -ne $formatted -and $formatted -ne '') {
|
||||
$formatted
|
||||
}
|
||||
}
|
||||
$output = [string]::Join("`n", @($outputLines))
|
||||
Send-KernelMessage @{
|
||||
type = 'exec_result'
|
||||
id = [string]$message.id
|
||||
ok = $true
|
||||
output = $output
|
||||
error = $null
|
||||
}
|
||||
} catch {
|
||||
$errorMessage = if ($_.Exception -and $_.Exception.Message) {
|
||||
[string]$_.Exception.Message
|
||||
} else {
|
||||
[string]$_
|
||||
}
|
||||
Send-KernelMessage @{
|
||||
type = 'exec_result'
|
||||
id = [string]$message.id
|
||||
ok = $false
|
||||
output = ''
|
||||
error = $errorMessage
|
||||
}
|
||||
} finally {
|
||||
$script:ActiveExecId = $null
|
||||
}
|
||||
}
|
||||
1686
codex-rs/core/src/tools/ps_repl/mod.rs
Normal file
1686
codex-rs/core/src/tools/ps_repl/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,7 @@ pub(crate) struct ToolsConfig {
|
||||
pub agent_roles: BTreeMap<String, AgentRoleConfig>,
|
||||
pub search_tool: bool,
|
||||
pub request_permission_enabled: bool,
|
||||
pub ps_repl_enabled: bool,
|
||||
pub js_repl_enabled: bool,
|
||||
pub js_repl_tools_only: bool,
|
||||
pub collab_tools: bool,
|
||||
@@ -77,6 +78,7 @@ impl ToolsConfig {
|
||||
session_source,
|
||||
} = params;
|
||||
let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform);
|
||||
let include_ps_repl = features.enabled(Feature::PsRepl);
|
||||
let include_js_repl = features.enabled(Feature::JsRepl);
|
||||
let include_js_repl_tools_only =
|
||||
include_js_repl && features.enabled(Feature::JsReplToolsOnly);
|
||||
@@ -136,6 +138,7 @@ impl ToolsConfig {
|
||||
agent_roles: BTreeMap::new(),
|
||||
search_tool: include_search_tool,
|
||||
request_permission_enabled,
|
||||
ps_repl_enabled: include_ps_repl,
|
||||
js_repl_enabled: include_js_repl,
|
||||
js_repl_tools_only: include_js_repl_tools_only,
|
||||
collab_tools: include_collab_tools,
|
||||
@@ -1329,6 +1332,47 @@ JS_SOURCE: /(?:\s*)(?:[^\s{\"`]|`[^`]|``[^`])[\s\S]*/
|
||||
})
|
||||
}
|
||||
|
||||
fn create_ps_repl_tool() -> ToolSpec {
|
||||
const PS_REPL_FREEFORM_GRAMMAR: &str = r#"
|
||||
start: pragma_source | plain_source
|
||||
|
||||
pragma_source: PRAGMA_LINE NEWLINE ps_source
|
||||
plain_source: PS_SOURCE
|
||||
|
||||
ps_source: PS_SOURCE
|
||||
|
||||
PRAGMA_LINE: /[ \t]*# codex-ps-repl:[^\r\n]*/
|
||||
NEWLINE: /\r?\n/
|
||||
PS_SOURCE: /(?:\s*)(?:[^\s{\"'`]|#[^\r\n]|`[^`])[\s\S]*/
|
||||
"#;
|
||||
|
||||
ToolSpec::Freeform(FreeformTool {
|
||||
name: "ps_repl".to_string(),
|
||||
description: "Runs PowerShell in a persistent pwsh kernel. This is a freeform tool: send raw PowerShell source text, optionally with a first-line pragma like `# codex-ps-repl: timeout_ms=15000`; do not send JSON/quotes/markdown fences."
|
||||
.to_string(),
|
||||
format: FreeformToolFormat {
|
||||
r#type: "grammar".to_string(),
|
||||
syntax: "lark".to_string(),
|
||||
definition: PS_REPL_FREEFORM_GRAMMAR.to_string(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_ps_repl_reset_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "ps_repl_reset".to_string(),
|
||||
description:
|
||||
"Restarts the ps_repl kernel for this run and clears persisted PowerShell session state."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_js_repl_reset_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "js_repl_reset".to_string(),
|
||||
@@ -1658,6 +1702,8 @@ pub(crate) fn build_specs(
|
||||
use crate::tools::handlers::McpResourceHandler;
|
||||
use crate::tools::handlers::MultiAgentHandler;
|
||||
use crate::tools::handlers::PlanHandler;
|
||||
use crate::tools::handlers::PsReplHandler;
|
||||
use crate::tools::handlers::PsReplResetHandler;
|
||||
use crate::tools::handlers::ReadFileHandler;
|
||||
use crate::tools::handlers::RequestUserInputHandler;
|
||||
use crate::tools::handlers::SearchToolBm25Handler;
|
||||
@@ -1683,6 +1729,8 @@ pub(crate) fn build_specs(
|
||||
default_mode_request_user_input: config.default_mode_request_user_input,
|
||||
});
|
||||
let search_tool_handler = Arc::new(SearchToolBm25Handler);
|
||||
let ps_repl_handler = Arc::new(PsReplHandler);
|
||||
let ps_repl_reset_handler = Arc::new(PsReplResetHandler);
|
||||
let js_repl_handler = Arc::new(JsReplHandler);
|
||||
let js_repl_reset_handler = Arc::new(JsReplResetHandler);
|
||||
let request_permission_enabled = config.request_permission_enabled;
|
||||
@@ -1737,6 +1785,13 @@ pub(crate) fn build_specs(
|
||||
builder.push_spec(PLAN_TOOL.clone());
|
||||
builder.register_handler("update_plan", plan_handler);
|
||||
|
||||
if config.ps_repl_enabled {
|
||||
builder.push_spec(create_ps_repl_tool());
|
||||
builder.push_spec(create_ps_repl_reset_tool());
|
||||
builder.register_handler("ps_repl", ps_repl_handler);
|
||||
builder.register_handler("ps_repl_reset", ps_repl_reset_handler);
|
||||
}
|
||||
|
||||
if config.js_repl_enabled {
|
||||
builder.push_spec(create_js_repl_tool());
|
||||
builder.push_spec(create_js_repl_reset_tool());
|
||||
@@ -2234,6 +2289,62 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ps_repl_requires_feature_flag() {
|
||||
let config = test_config();
|
||||
let model_info =
|
||||
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
||||
let features = Features::with_defaults();
|
||||
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
||||
|
||||
assert!(
|
||||
!tools.iter().any(|tool| tool.spec.name() == "ps_repl"),
|
||||
"ps_repl should be disabled when the feature is off"
|
||||
);
|
||||
assert!(
|
||||
!tools.iter().any(|tool| tool.spec.name() == "ps_repl_reset"),
|
||||
"ps_repl_reset should be disabled when the feature is off"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ps_repl_enabled_adds_tools() {
|
||||
let config = test_config();
|
||||
let model_info =
|
||||
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::PsRepl);
|
||||
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
||||
assert_contains_tool_names(&tools, &["ps_repl", "ps_repl_reset"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ps_repl_freeform_grammar_mentions_pragma_and_ps_source() {
|
||||
let ToolSpec::Freeform(FreeformTool { format, .. }) = create_ps_repl_tool() else {
|
||||
panic!("ps_repl should use a freeform tool spec");
|
||||
};
|
||||
|
||||
assert_eq!(format.syntax, "lark");
|
||||
assert!(format.definition.contains("PRAGMA_LINE"));
|
||||
assert!(format.definition.contains("PS_SOURCE"));
|
||||
assert!(format.definition.contains("codex-ps-repl:"));
|
||||
assert!(!format.definition.contains("(?!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn js_repl_enabled_adds_tools() {
|
||||
let config = test_config();
|
||||
|
||||
@@ -97,6 +97,7 @@ mod personality;
|
||||
mod personality_migration;
|
||||
mod plugins;
|
||||
mod prompt_caching;
|
||||
mod ps_repl;
|
||||
mod quota_exceeded;
|
||||
mod read_file;
|
||||
mod realtime_conversation;
|
||||
|
||||
484
codex-rs/core/tests/suite/ps_repl.rs
Normal file
484
codex-rs/core/tests/suite/ps_repl.rs
Normal file
@@ -0,0 +1,484 @@
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use anyhow::Result;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_core::features::Feature;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::ResponseMock;
|
||||
use core_test_support::responses::ResponsesRequest;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_custom_tool_call;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
use wiremock::MockServer;
|
||||
|
||||
fn custom_tool_output_text_and_success(
|
||||
req: &ResponsesRequest,
|
||||
call_id: &str,
|
||||
) -> (String, Option<bool>) {
|
||||
let (output, success) = req
|
||||
.custom_tool_call_output_content_and_success(call_id)
|
||||
.expect("custom tool output should be present");
|
||||
(output.unwrap_or_default(), success)
|
||||
}
|
||||
|
||||
fn tool_names(body: &serde_json::Value) -> Vec<String> {
|
||||
body["tools"]
|
||||
.as_array()
|
||||
.expect("tools array should be present")
|
||||
.iter()
|
||||
.map(|tool| {
|
||||
tool.get("name")
|
||||
.and_then(|value| value.as_str())
|
||||
.or_else(|| tool.get("type").and_then(|value| value.as_str()))
|
||||
.expect("tool should have a name or type")
|
||||
.to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write_too_old_pwsh_script(dir: &Path) -> Result<std::path::PathBuf> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let path = dir.join("old-pwsh.cmd");
|
||||
fs::write(&path, "@echo off\r\necho PowerShell 5.1.0\r\n")?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let path = dir.join("old-pwsh.sh");
|
||||
fs::write(&path, "#!/bin/sh\necho PowerShell 5.1.0\n")?;
|
||||
let mut permissions = fs::metadata(&path)?.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&path, permissions)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
anyhow::bail!("unsupported platform for ps_repl test fixture");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_test_png(dir: &Path) -> Result<std::path::PathBuf> {
|
||||
let path = dir.join("dot.png");
|
||||
let png_bytes = BASE64_STANDARD.decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
|
||||
)?;
|
||||
fs::write(&path, png_bytes)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn ps_single_quote(input: &Path) -> String {
|
||||
input.display().to_string().replace('\'', "''")
|
||||
}
|
||||
|
||||
async fn run_ps_repl_turn(
|
||||
server: &MockServer,
|
||||
prompt: &str,
|
||||
calls: &[(&str, &str)],
|
||||
) -> Result<ResponseMock> {
|
||||
let test = test_codex()
|
||||
.with_config(|config| {
|
||||
config.features.enable(Feature::PsRepl);
|
||||
})
|
||||
.build(server)
|
||||
.await?;
|
||||
|
||||
let mut first_events = vec![ev_response_created("resp-1")];
|
||||
for (call_id, ps_input) in calls {
|
||||
first_events.push(ev_custom_tool_call(call_id, "ps_repl", ps_input));
|
||||
}
|
||||
first_events.push(ev_completed("resp-1"));
|
||||
responses::mount_sse_once(server, sse(first_events)).await;
|
||||
|
||||
let second_mock = responses::mount_sse_once(
|
||||
server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn(prompt).await?;
|
||||
Ok(second_mock)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ps_repl_is_not_advertised_when_startup_pwsh_is_incompatible() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
if std::env::var_os("CODEX_PS_REPL_PATH").is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let temp = tempdir()?;
|
||||
let old_pwsh = write_too_old_pwsh_script(temp.path())?;
|
||||
|
||||
let test = test_codex()
|
||||
.with_config(move |config| {
|
||||
config.features.enable(Feature::PsRepl);
|
||||
config.ps_repl_path = Some(old_pwsh);
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
let warning = wait_for_event_match(&test.codex, |event| match event {
|
||||
EventMsg::Warning(ev) if ev.message.contains("Disabled `ps_repl` for this session") => {
|
||||
Some(ev.message.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
warning.contains("PowerShell runtime"),
|
||||
"warning should explain the PowerShell compatibility issue: {warning}"
|
||||
);
|
||||
|
||||
let request_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("hello").await?;
|
||||
|
||||
let body = request_mock.single_request().body_json();
|
||||
let tools = tool_names(&body);
|
||||
assert!(
|
||||
!tools.iter().any(|tool| tool == "ps_repl"),
|
||||
"ps_repl should be omitted when startup validation fails: {tools:?}"
|
||||
);
|
||||
assert!(
|
||||
!tools.iter().any(|tool| tool == "ps_repl_reset"),
|
||||
"ps_repl_reset should be omitted when startup validation fails: {tools:?}"
|
||||
);
|
||||
let instructions = body["instructions"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
!instructions.contains("## PowerShell REPL (pwsh)"),
|
||||
"startup instructions should not mention ps_repl when it is disabled: {instructions}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ps_repl_persists_variables_functions_and_modules() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let test = test_codex()
|
||||
.with_config(|config| {
|
||||
config.features.enable(Feature::PsRepl);
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_custom_tool_call(
|
||||
"call-1",
|
||||
"ps_repl",
|
||||
r#"
|
||||
$x = 41
|
||||
New-Module -Name CodexPsTestModule -ScriptBlock {
|
||||
function Get-CodexValue { 42 }
|
||||
} | Import-Module
|
||||
function Add-One {
|
||||
param([int]$Value)
|
||||
$Value + 1
|
||||
}
|
||||
Write-Output "state-ready"
|
||||
"#,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_custom_tool_call(
|
||||
"call-2",
|
||||
"ps_repl",
|
||||
r#"
|
||||
Write-Output ($x + 1)
|
||||
Write-Output (Add-One -Value 1)
|
||||
Write-Output (Get-CodexValue)
|
||||
"#,
|
||||
),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let third_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("run ps_repl twice").await?;
|
||||
|
||||
let req2 = second_mock.single_request();
|
||||
let (first_output, first_success) = custom_tool_output_text_and_success(&req2, "call-1");
|
||||
assert_ne!(
|
||||
first_success,
|
||||
Some(false),
|
||||
"first ps_repl call failed unexpectedly: {first_output}"
|
||||
);
|
||||
assert!(first_output.contains("state-ready"));
|
||||
|
||||
let req3 = third_mock.single_request();
|
||||
let (second_output, second_success) = custom_tool_output_text_and_success(&req3, "call-2");
|
||||
assert_ne!(
|
||||
second_success,
|
||||
Some(false),
|
||||
"second ps_repl call failed unexpectedly: {second_output}"
|
||||
);
|
||||
let lines = second_output
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
lines.iter().filter(|line| **line == "42").count() >= 2,
|
||||
"expected persisted variable and module output, got: {second_output}"
|
||||
);
|
||||
assert!(
|
||||
lines.contains(&"2"),
|
||||
"expected persisted function output, got: {second_output}"
|
||||
);
|
||||
assert!(
|
||||
!second_output.contains("Get-CodexValue"),
|
||||
"unexpected formatting leak: {second_output}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ps_repl_can_invoke_builtin_tools() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mock = run_ps_repl_turn(
|
||||
&server,
|
||||
"use ps_repl to call a tool",
|
||||
&[(
|
||||
"call-1",
|
||||
"$toolOut = Invoke-CodexTool -Name list_mcp_resources -Arguments @{}; Write-Output $toolOut.type",
|
||||
)],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let req = mock.single_request();
|
||||
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
|
||||
assert_ne!(
|
||||
success,
|
||||
Some(false),
|
||||
"ps_repl call failed unexpectedly: {output}"
|
||||
);
|
||||
assert!(output.contains("function_call_output"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ps_repl_tool_call_rejects_recursive_ps_repl_invocation() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mock = run_ps_repl_turn(
|
||||
&server,
|
||||
"use ps_repl recursively",
|
||||
&[(
|
||||
"call-1",
|
||||
r#"
|
||||
try {
|
||||
Invoke-CodexTool -Name ps_repl -Arguments "Write-Output 'recursive'" | Out-Null
|
||||
Write-Output "unexpected-success"
|
||||
} catch {
|
||||
Write-Output $_.Exception.Message
|
||||
}
|
||||
"#,
|
||||
)],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let req = mock.single_request();
|
||||
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
|
||||
assert_ne!(
|
||||
success,
|
||||
Some(false),
|
||||
"ps_repl call failed unexpectedly: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("ps_repl cannot invoke itself"),
|
||||
"expected recursion guard message, got output: {output}"
|
||||
);
|
||||
assert!(
|
||||
!output.contains("unexpected-success"),
|
||||
"recursive ps_repl call unexpectedly succeeded: {output}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ps_repl_resets_after_timeout_and_accepts_followup_execution() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let test = test_codex()
|
||||
.with_config(|config| {
|
||||
config.features.enable(Feature::PsRepl);
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_custom_tool_call(
|
||||
"call-1",
|
||||
"ps_repl",
|
||||
"# codex-ps-repl: timeout_ms=50\nStart-Sleep -Milliseconds 500",
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_custom_tool_call("call-2", "ps_repl", "Write-Output 'healthy'"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let third_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("run ps_repl after timeout").await?;
|
||||
|
||||
let req2 = second_mock.single_request();
|
||||
let (first_output, first_success) = custom_tool_output_text_and_success(&req2, "call-1");
|
||||
assert_ne!(
|
||||
first_success,
|
||||
Some(true),
|
||||
"timeout should not report success: {first_output}"
|
||||
);
|
||||
assert!(
|
||||
first_output.contains("ps_repl execution timed out"),
|
||||
"expected timeout output, got: {first_output}"
|
||||
);
|
||||
|
||||
let req3 = third_mock.single_request();
|
||||
let (second_output, second_success) = custom_tool_output_text_and_success(&req3, "call-2");
|
||||
assert_ne!(
|
||||
second_success,
|
||||
Some(false),
|
||||
"ps_repl follow-up execution failed unexpectedly: {second_output}"
|
||||
);
|
||||
assert!(second_output.contains("healthy"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ps_repl_captures_standard_powershell_output_streams() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mock = run_ps_repl_turn(
|
||||
&server,
|
||||
"capture powershell output",
|
||||
&[(
|
||||
"call-1",
|
||||
"Write-Output 'stdout'; Write-Warning 'warn-stream'",
|
||||
)],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let req = mock.single_request();
|
||||
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
|
||||
assert_ne!(
|
||||
success,
|
||||
Some(false),
|
||||
"ps_repl call failed unexpectedly: {output}"
|
||||
);
|
||||
assert!(output.contains("stdout"));
|
||||
assert!(output.contains("warn-stream"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ps_repl_view_image_propagates_content_items() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let temp = tempdir()?;
|
||||
let png_path = write_test_png(temp.path())?;
|
||||
let png_path = ps_single_quote(&png_path);
|
||||
let script =
|
||||
format!("$null = Invoke-CodexTool -Name view_image -Arguments @{{ path = '{png_path}' }}");
|
||||
|
||||
let mock = run_ps_repl_turn(
|
||||
&server,
|
||||
"render an image via ps_repl",
|
||||
&[("call-1", &script)],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let req = mock.single_request();
|
||||
let custom_output = req.custom_tool_call_output("call-1");
|
||||
let output_items = custom_output
|
||||
.get("output")
|
||||
.and_then(Value::as_array)
|
||||
.expect("custom_tool_call_output should be a content item array");
|
||||
let image_url = output_items
|
||||
.iter()
|
||||
.find_map(|item| {
|
||||
(item.get("type").and_then(Value::as_str) == Some("input_image"))
|
||||
.then(|| item.get("image_url").and_then(Value::as_str))
|
||||
.flatten()
|
||||
})
|
||||
.expect("image_url present in ps_repl custom tool output");
|
||||
assert!(
|
||||
image_url.starts_with("data:image/png;base64,"),
|
||||
"expected png data URL, got {image_url}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -277,6 +277,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
|
||||
model_provider: model_provider.clone(),
|
||||
codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(),
|
||||
main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(),
|
||||
ps_repl_path: None,
|
||||
js_repl_node_path: None,
|
||||
js_repl_node_module_dirs: None,
|
||||
zsh_path: None,
|
||||
|
||||
@@ -20,7 +20,7 @@ decision to the shell-escalation protocol over a shared file descriptor (specifi
|
||||
We carry a small patch to `execute_cmd.c` (see `patches/bash-exec-wrapper.patch`) that adds support for `EXEC_WRAPPER`. The original commit message is “add support for BASH_EXEC_WRAPPER” and the patch applies cleanly to `a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b` from https://github.com/bminor/bash. To rebuild manually:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bminor/bash
|
||||
git clone https://git.savannah.gnu.org/git/bash
|
||||
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
|
||||
git apply /path/to/patches/bash-exec-wrapper.patch
|
||||
./configure --without-bash-malloc
|
||||
|
||||
@@ -1201,6 +1201,15 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
let has_non_primary_agent_thread = self
|
||||
.agent_picker_threads
|
||||
.keys()
|
||||
.any(|thread_id| Some(*thread_id) != self.primary_thread_id);
|
||||
if !self.config.features.enabled(Feature::Collab) && !has_non_primary_agent_thread {
|
||||
self.chat_widget.open_multi_agent_enable_prompt();
|
||||
return;
|
||||
}
|
||||
|
||||
if self.agent_picker_threads.is_empty() {
|
||||
self.chat_widget
|
||||
.add_info_message("No agents available yet.".to_string(), None);
|
||||
@@ -3601,6 +3610,7 @@ mod tests {
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::history_cell::new_session_info;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
@@ -3966,6 +3976,51 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Result<()> {
|
||||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
|
||||
app.open_agent_picker().await;
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_matches!(
|
||||
app_event_rx.try_recv(),
|
||||
Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)]
|
||||
);
|
||||
let cell = match app_event_rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||||
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
||||
};
|
||||
let rendered = cell
|
||||
.display_lines(120)
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(rendered.contains("Multi-agent will be enabled in the next session."));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_agent_picker_allows_existing_agent_threads_when_feature_is_disabled() -> Result<()>
|
||||
{
|
||||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
let thread_id = ThreadId::new();
|
||||
app.thread_event_channels
|
||||
.insert(thread_id, ThreadEventChannel::new(1));
|
||||
|
||||
app.open_agent_picker().await;
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_matches!(
|
||||
app_event_rx.try_recv(),
|
||||
Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_pending_thread_approvals_only_lists_inactive_threads() {
|
||||
let mut app = make_test_app().await;
|
||||
|
||||
@@ -4108,10 +4108,10 @@ impl ChatComposer {
|
||||
!footer_props.is_task_running && self.collaboration_mode_indicator.is_some();
|
||||
let show_shortcuts_hint = match footer_props.mode {
|
||||
FooterMode::ComposerEmpty => !self.is_in_paste_burst(),
|
||||
FooterMode::ComposerHasDraft => false,
|
||||
FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint
|
||||
| FooterMode::ComposerHasDraft => false,
|
||||
| FooterMode::EscHint => false,
|
||||
};
|
||||
let show_queue_hint = match footer_props.mode {
|
||||
FooterMode::ComposerHasDraft => footer_props.is_task_running,
|
||||
@@ -4141,10 +4141,13 @@ impl ChatComposer {
|
||||
.as_ref()
|
||||
.map(|line| line.clone().dim());
|
||||
let status_line_candidate = footer_props.status_line_enabled
|
||||
&& matches!(
|
||||
footer_props.mode,
|
||||
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
|
||||
);
|
||||
&& match footer_props.mode {
|
||||
FooterMode::ComposerEmpty => true,
|
||||
FooterMode::ComposerHasDraft => !footer_props.is_task_running,
|
||||
FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint => false,
|
||||
};
|
||||
let mut truncated_status_line = if status_line_candidate {
|
||||
status_line.as_ref().map(|line| {
|
||||
truncate_line_with_ellipsis_if_overflow(line.clone(), available_width)
|
||||
@@ -4210,7 +4213,7 @@ impl ChatComposer {
|
||||
can_show_left_with_context(hint_rect, left_width, right_width);
|
||||
let has_override =
|
||||
self.footer_flash_visible() || self.footer_hint_override.is_some();
|
||||
let single_line_layout = if has_override {
|
||||
let single_line_layout = if has_override || status_line_active {
|
||||
None
|
||||
} else {
|
||||
match footer_props.mode {
|
||||
|
||||
@@ -172,10 +172,10 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode {
|
||||
pub(crate) fn footer_height(props: &FooterProps) -> u16 {
|
||||
let show_shortcuts_hint = match props.mode {
|
||||
FooterMode::ComposerEmpty => true,
|
||||
FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint
|
||||
| FooterMode::ComposerHasDraft => false,
|
||||
FooterMode::ComposerHasDraft => false,
|
||||
FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint => {
|
||||
false
|
||||
}
|
||||
};
|
||||
let show_queue_hint = match props.mode {
|
||||
FooterMode::ComposerHasDraft => props.is_task_running,
|
||||
@@ -562,13 +562,18 @@ fn footer_from_props_lines(
|
||||
show_shortcuts_hint: bool,
|
||||
show_queue_hint: bool,
|
||||
) -> Vec<Line<'static>> {
|
||||
// If status line content is present, show it for base modes.
|
||||
// If status line content is present, show it for passive composer states.
|
||||
// Active draft states still prefer the queue hint over the passive status
|
||||
// line so the footer stays actionable while a task is running.
|
||||
if props.status_line_enabled
|
||||
&& let Some(status_line) = &props.status_line_value
|
||||
&& matches!(
|
||||
props.mode,
|
||||
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
|
||||
)
|
||||
&& match props.mode {
|
||||
FooterMode::ComposerEmpty => true,
|
||||
FooterMode::ComposerHasDraft => !props.is_task_running,
|
||||
FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint => false,
|
||||
}
|
||||
{
|
||||
return vec![status_line.clone().dim()];
|
||||
}
|
||||
@@ -601,6 +606,8 @@ fn footer_from_props_lines(
|
||||
let state = LeftSideState {
|
||||
hint: if show_queue_hint {
|
||||
SummaryHintKind::QueueMessage
|
||||
} else if show_shortcuts_hint {
|
||||
SummaryHintKind::Shortcuts
|
||||
} else {
|
||||
SummaryHintKind::None
|
||||
},
|
||||
@@ -1013,10 +1020,10 @@ mod tests {
|
||||
let show_cycle_hint = !props.is_task_running;
|
||||
let show_shortcuts_hint = match props.mode {
|
||||
FooterMode::ComposerEmpty => true,
|
||||
FooterMode::ComposerHasDraft => false,
|
||||
FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint
|
||||
| FooterMode::ComposerHasDraft => false,
|
||||
| FooterMode::EscHint => false,
|
||||
};
|
||||
let show_queue_hint = match props.mode {
|
||||
FooterMode::ComposerHasDraft => props.is_task_running,
|
||||
@@ -1025,13 +1032,21 @@ mod tests {
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint => false,
|
||||
};
|
||||
let left_mode_indicator = if props.status_line_enabled {
|
||||
let status_line_active = props.status_line_enabled
|
||||
&& match props.mode {
|
||||
FooterMode::ComposerEmpty => true,
|
||||
FooterMode::ComposerHasDraft => !props.is_task_running,
|
||||
FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::EscHint => false,
|
||||
};
|
||||
let left_mode_indicator = if status_line_active {
|
||||
None
|
||||
} else {
|
||||
collaboration_mode_indicator
|
||||
};
|
||||
let available_width = area.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize;
|
||||
let mut truncated_status_line = if props.status_line_enabled
|
||||
let mut truncated_status_line = if status_line_active
|
||||
&& matches!(
|
||||
props.mode,
|
||||
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
|
||||
@@ -1044,7 +1059,7 @@ mod tests {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut left_width = if props.status_line_enabled {
|
||||
let mut left_width = if status_line_active {
|
||||
truncated_status_line
|
||||
.as_ref()
|
||||
.map(|line| line.width() as u16)
|
||||
@@ -1058,7 +1073,7 @@ mod tests {
|
||||
show_queue_hint,
|
||||
)
|
||||
};
|
||||
let right_line = if props.status_line_enabled {
|
||||
let right_line = if status_line_active {
|
||||
let full = mode_indicator_line(collaboration_mode_indicator, show_cycle_hint);
|
||||
let compact = mode_indicator_line(collaboration_mode_indicator, false);
|
||||
let full_width = full.as_ref().map(|line| line.width() as u16).unwrap_or(0);
|
||||
@@ -1077,7 +1092,7 @@ mod tests {
|
||||
.as_ref()
|
||||
.map(|line| line.width() as u16)
|
||||
.unwrap_or(0);
|
||||
if props.status_line_enabled
|
||||
if status_line_active
|
||||
&& let Some(max_left) = max_left_width_for_right(area, right_width)
|
||||
&& left_width > max_left
|
||||
&& let Some(line) = props
|
||||
@@ -1097,21 +1112,24 @@ mod tests {
|
||||
props.mode,
|
||||
FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft
|
||||
) {
|
||||
let (summary_left, show_context) = single_line_footer_layout(
|
||||
area,
|
||||
right_width,
|
||||
left_mode_indicator,
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
);
|
||||
match summary_left {
|
||||
SummaryLeft::Default => {
|
||||
if props.status_line_enabled {
|
||||
if let Some(line) = truncated_status_line.clone() {
|
||||
render_footer_line(area, f.buffer_mut(), line);
|
||||
}
|
||||
} else {
|
||||
if status_line_active {
|
||||
if let Some(line) = truncated_status_line.clone() {
|
||||
render_footer_line(area, f.buffer_mut(), line);
|
||||
}
|
||||
if can_show_left_and_context && let Some(line) = &right_line {
|
||||
render_context_right(area, f.buffer_mut(), line);
|
||||
}
|
||||
} else {
|
||||
let (summary_left, show_context) = single_line_footer_layout(
|
||||
area,
|
||||
right_width,
|
||||
left_mode_indicator,
|
||||
show_cycle_hint,
|
||||
show_shortcuts_hint,
|
||||
show_queue_hint,
|
||||
);
|
||||
match summary_left {
|
||||
SummaryLeft::Default => {
|
||||
render_footer_from_props(
|
||||
area,
|
||||
f.buffer_mut(),
|
||||
@@ -1122,14 +1140,14 @@ mod tests {
|
||||
show_queue_hint,
|
||||
);
|
||||
}
|
||||
SummaryLeft::Custom(line) => {
|
||||
render_footer_line(area, f.buffer_mut(), line);
|
||||
}
|
||||
SummaryLeft::None => {}
|
||||
}
|
||||
SummaryLeft::Custom(line) => {
|
||||
render_footer_line(area, f.buffer_mut(), line);
|
||||
if show_context && let Some(line) = &right_line {
|
||||
render_context_right(area, f.buffer_mut(), line);
|
||||
}
|
||||
SummaryLeft::None => {}
|
||||
}
|
||||
if show_context && let Some(line) = &right_line {
|
||||
render_context_right(area, f.buffer_mut(), line);
|
||||
}
|
||||
} else {
|
||||
render_footer_from_props(
|
||||
@@ -1416,6 +1434,38 @@ mod tests {
|
||||
|
||||
snapshot_footer("footer_status_line_overrides_shortcuts", props);
|
||||
|
||||
let props = FooterProps {
|
||||
mode: FooterMode::ComposerHasDraft,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
collaboration_modes_enabled: false,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from("Status line content".to_string())),
|
||||
status_line_enabled: true,
|
||||
};
|
||||
|
||||
snapshot_footer("footer_status_line_yields_to_queue_hint", props);
|
||||
|
||||
let props = FooterProps {
|
||||
mode: FooterMode::ComposerHasDraft,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
collaboration_modes_enabled: false,
|
||||
is_wsl: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
status_line_value: Some(Line::from("Status line content".to_string())),
|
||||
status_line_enabled: true,
|
||||
};
|
||||
|
||||
snapshot_footer("footer_status_line_overrides_draft_idle", props);
|
||||
|
||||
let props = FooterProps {
|
||||
mode: FooterMode::ComposerEmpty,
|
||||
esc_backtrack_hint: false,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" Status line content "
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" tab to queue message 100% context left "
|
||||
@@ -164,6 +164,10 @@ const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?";
|
||||
const PLAN_IMPLEMENTATION_YES: &str = "Yes, implement this plan";
|
||||
const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode";
|
||||
const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan.";
|
||||
const MULTI_AGENT_ENABLE_TITLE: &str = "Enable multi-agent?";
|
||||
const MULTI_AGENT_ENABLE_YES: &str = "Yes, enable";
|
||||
const MULTI_AGENT_ENABLE_NO: &str = "Not now";
|
||||
const MULTI_AGENT_ENABLE_NOTICE: &str = "Multi-agent will be enabled in the next session.";
|
||||
const PLAN_MODE_REASONING_SCOPE_TITLE: &str = "Apply reasoning change";
|
||||
const PLAN_MODE_REASONING_SCOPE_PLAN_ONLY: &str = "Apply to Plan mode override";
|
||||
const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and Plan mode override";
|
||||
@@ -1568,6 +1572,41 @@ impl ChatWidget {
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn open_multi_agent_enable_prompt(&mut self) {
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: MULTI_AGENT_ENABLE_YES.to_string(),
|
||||
description: Some(
|
||||
"Save the setting now. You will need a new session to use it.".to_string(),
|
||||
),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::UpdateFeatureFlags {
|
||||
updates: vec![(Feature::Collab, true)],
|
||||
});
|
||||
tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_warning_event(MULTI_AGENT_ENABLE_NOTICE.to_string()),
|
||||
)));
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: MULTI_AGENT_ENABLE_NO.to_string(),
|
||||
description: Some("Keep multi-agent disabled.".to_string()),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some(MULTI_AGENT_ENABLE_TITLE.to_string()),
|
||||
subtitle: Some("Multi-agent is currently disabled in your config.".to_string()),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn set_token_info(&mut self, info: Option<TokenUsageInfo>) {
|
||||
match info {
|
||||
Some(info) => self.apply_token_info(info),
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 6001
|
||||
expression: popup
|
||||
---
|
||||
Enable multi-agent?
|
||||
Multi-agent is currently disabled in your config.
|
||||
|
||||
› 1. Yes, enable Save the setting now. You will need a new session to use it.
|
||||
2. Not now Keep multi-agent disabled.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -5991,6 +5991,35 @@ async fn experimental_popup_shows_js_repl_node_requirement() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_enable_prompt_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.open_multi_agent_enable_prompt();
|
||||
|
||||
let popup = render_bottom_popup(&chat, 80);
|
||||
assert_snapshot!("multi_agent_enable_prompt", popup);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_enable_prompt_updates_feature_and_emits_notice() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.open_multi_agent_enable_prompt();
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)]
|
||||
);
|
||||
let cell = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||||
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
||||
};
|
||||
let rendered = lines_to_single_string(&cell.display_lines(120));
|
||||
assert!(rendered.contains("Multi-agent will be enabled in the next session."));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_selection_popup_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await;
|
||||
|
||||
68
docs/ps_repl.md
Normal file
68
docs/ps_repl.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# PowerShell REPL (`ps_repl`)
|
||||
|
||||
`ps_repl` runs PowerShell in a persistent `pwsh`-backed kernel.
|
||||
|
||||
## Feature gate
|
||||
|
||||
`ps_repl` is disabled by default and only appears when:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
ps_repl = true
|
||||
```
|
||||
|
||||
The initial rollout stage is under development.
|
||||
|
||||
## PowerShell runtime
|
||||
|
||||
`ps_repl` requires PowerShell 7 or newer.
|
||||
|
||||
Runtime resolution order:
|
||||
|
||||
1. `CODEX_PS_REPL_PATH` environment variable
|
||||
2. `ps_repl_path` in config/profile
|
||||
3. `pwsh` discovered on `PATH`
|
||||
|
||||
You can configure an explicit runtime path:
|
||||
|
||||
```toml
|
||||
ps_repl_path = "/absolute/path/to/pwsh"
|
||||
```
|
||||
|
||||
If only Windows PowerShell (`powershell.exe`) is available, `ps_repl` stays disabled and emits a startup warning telling you to install PowerShell 7.
|
||||
|
||||
## Usage
|
||||
|
||||
- `ps_repl` is a freeform tool: send raw PowerShell source text.
|
||||
- Optional first-line pragma:
|
||||
- `# codex-ps-repl: timeout_ms=15000`
|
||||
- Variables, functions, aliases, imported modules, environment changes, and `$LASTEXITCODE` persist across calls.
|
||||
- Use `ps_repl_reset` to clear the kernel state.
|
||||
|
||||
## Helper APIs inside the kernel
|
||||
|
||||
`ps_repl` exposes these helpers:
|
||||
|
||||
- `$CodexTmpDir`: per-session scratch directory path.
|
||||
- `Invoke-CodexTool -Name <string> -Arguments <object|string>`: executes a normal Codex tool call from inside `ps_repl`.
|
||||
- `$Codex.TmpDir`: alias for the scratch directory.
|
||||
- `$Codex.Tool(<name>, <args>)`: thin alias to `Invoke-CodexTool`.
|
||||
|
||||
`Invoke-CodexTool` returns the raw tool output object. Nested tool calls can also return multimodal content such as `view_image` results.
|
||||
|
||||
To share generated images with the model, write a file under `$CodexTmpDir`, call:
|
||||
|
||||
```powershell
|
||||
Invoke-CodexTool -Name view_image -Arguments @{ path = "/absolute/path" }
|
||||
```
|
||||
|
||||
Then delete the file.
|
||||
|
||||
## Output and transport
|
||||
|
||||
`ps_repl` uses a JSON-line transport over stdio.
|
||||
|
||||
- Safe output forms: pipeline output, `Write-Output`, `Write-Host`, `Write-Verbose`, `Write-Warning`
|
||||
- Avoid: direct `[Console]::Write*`, raw StdOut/StdErr writes, or other host-level output that bypasses PowerShell streams
|
||||
|
||||
Bypassing PowerShell streams can corrupt the transport between the Rust host and the persistent kernel.
|
||||
Reference in New Issue
Block a user