Remove js_repl feature (#19410)

This commit is contained in:
Curtis 'Fjord' Hawthorne
2026-04-24 17:49:29 -07:00
committed by GitHub
parent cf02e9c052
commit 8a559e7938
63 changed files with 77 additions and 9261 deletions

View File

@@ -1,795 +0,0 @@
#![allow(clippy::expect_used, clippy::unwrap_used)]
use anyhow::Result;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerTransportConfig;
use codex_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::stdio_server_bin;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event_match;
use std::collections::HashMap;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::time::Duration;
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 assert_js_repl_ok(req: &ResponsesRequest, call_id: &str, expected_output: &str) {
let (output, success) = custom_tool_output_text_and_success(req, call_id);
assert_ne!(
success,
Some(false),
"js_repl call failed unexpectedly: {output}"
);
assert!(output.contains(expected_output), "output was: {output}");
}
fn assert_js_repl_err(req: &ResponsesRequest, call_id: &str, expected_output: &str) {
let (output, success) = custom_tool_output_text_and_success(req, call_id);
assert_ne!(success, Some(true), "js_repl call should fail: {output}");
assert!(output.contains(expected_output), "output was: {output}");
}
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_node_script(dir: &Path) -> Result<std::path::PathBuf> {
#[cfg(windows)]
{
let path = dir.join("old-node.cmd");
fs::write(&path, "@echo off\r\necho v0.0.1\r\n")?;
Ok(path)
}
#[cfg(unix)]
{
let path = dir.join("old-node.sh");
fs::write(&path, "#!/bin/sh\necho v0.0.1\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 js_repl test fixture");
}
}
async fn run_js_repl_turn(
server: &MockServer,
prompt: &str,
calls: &[(&str, &str)],
) -> Result<ResponseMock> {
let mut mocks = run_js_repl_sequence(server, prompt, calls).await?;
Ok(mocks
.pop()
.expect("js_repl test should return a request mock"))
}
async fn run_js_repl_sequence(
server: &MockServer,
prompt: &str,
calls: &[(&str, &str)],
) -> Result<Vec<ResponseMock>> {
anyhow::ensure!(
!calls.is_empty(),
"js_repl test must include at least one call"
);
let mut builder = test_codex().with_config(|config| {
config
.features
.enable(Feature::JsRepl)
.expect("test config should allow feature update");
});
let test = builder.build(server).await?;
responses::mount_sse_once(
server,
sse(vec![
ev_response_created("resp-1"),
ev_custom_tool_call(calls[0].0, "js_repl", calls[0].1),
ev_completed("resp-1"),
]),
)
.await;
let mut mocks = Vec::with_capacity(calls.len());
for (response_index, (call_id, js_input)) in calls.iter().enumerate().skip(1) {
let response_id = format!("resp-{}", response_index + 1);
let mock = responses::mount_sse_once(
server,
sse(vec![
ev_response_created(&response_id),
ev_custom_tool_call(call_id, "js_repl", js_input),
ev_completed(&response_id),
]),
)
.await;
mocks.push(mock);
}
let final_response_id = format!("resp-{}", calls.len() + 1);
let final_mock = responses::mount_sse_once(
server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed(&final_response_id),
]),
)
.await;
mocks.push(final_mock);
test.submit_turn(prompt).await?;
Ok(mocks)
}
async fn assert_failed_cell_followup(
server: &MockServer,
prompt: &str,
failing_cell: &str,
followup_cell: &str,
expected_followup_output: &str,
) -> Result<()> {
let mocks = run_js_repl_sequence(
server,
prompt,
&[("call-1", failing_cell), ("call-2", followup_cell)],
)
.await?;
assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom");
assert_js_repl_ok(
&mocks[1].single_request(),
"call-2",
expected_followup_output,
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_is_not_advertised_when_startup_node_is_incompatible() -> Result<()> {
skip_if_no_network!(Ok(()));
if std::env::var_os("CODEX_JS_REPL_NODE_PATH").is_some() {
return Ok(());
}
let server = responses::start_mock_server().await;
let temp = tempdir()?;
let old_node = write_too_old_node_script(temp.path())?;
let mut builder = test_codex().with_config(move |config| {
config
.features
.enable(Feature::JsRepl)
.expect("test config should allow feature update");
config.js_repl_node_path = Some(old_node);
});
let test = builder.build(&server).await?;
let warning = wait_for_event_match(&test.codex, |event| match event {
EventMsg::Warning(ev) if ev.message.contains("Disabled `js_repl` for this session") => {
Some(ev.message.clone())
}
_ => None,
})
.await;
assert!(
warning.contains("Node runtime"),
"warning should explain the Node 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 == "js_repl"),
"js_repl should be omitted when startup validation fails: {tools:?}"
);
assert!(
!tools.iter().any(|tool| tool == "js_repl_reset"),
"js_repl_reset should be omitted when startup validation fails: {tools:?}"
);
let instructions = body["instructions"].as_str().unwrap_or_default();
assert!(
!instructions.contains("## JavaScript REPL (Node)"),
"startup instructions should not mention js_repl when it is disabled: {instructions}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_persists_top_level_destructured_bindings_and_supports_tla() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mocks = run_js_repl_sequence(
&server,
"run js_repl twice",
&[
(
"call-1",
"const { context: liveContext, session } = await Promise.resolve({ context: 41, session: 1 }); console.log(liveContext + session);",
),
("call-2", "console.log(liveContext + session);"),
],
)
.await?;
assert_js_repl_ok(&mocks[0].single_request(), "call-1", "42");
assert_js_repl_ok(&mocks[1].single_request(), "call-2", "42");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_failed_cells_commit_initialized_bindings_only() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mocks = run_js_repl_sequence(
&server,
"run js_repl across a failed cell",
&[
("call-1", "const base = 40; console.log(base);"),
(
"call-2",
"const { session } = await Promise.resolve({ session: 2 }); throw new Error(\"boom\"); const late = 99;",
),
("call-3", "console.log(base + session, typeof late);"),
],
)
.await?;
assert_js_repl_ok(&mocks[0].single_request(), "call-1", "40");
assert_js_repl_err(&mocks[1].single_request(), "call-2", "boom");
assert_js_repl_ok(&mocks[2].single_request(), "call-3", "42 undefined");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_failed_cells_preserve_initialized_lexical_destructuring_bindings() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mocks = run_js_repl_sequence(
&server,
"run js_repl through partial destructuring failure",
&[
(
"call-1",
"const { a, b } = { a: 1, get b() { throw new Error(\"boom\"); } };",
),
(
"call-2",
"let aValue; try { aValue = a; } catch (error) { aValue = error.name; } let bValue; try { bValue = b; } catch (error) { bValue = error.name; } console.log(aValue, bValue);",
),
],
)
.await?;
assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom");
assert_js_repl_ok(&mocks[1].single_request(), "call-2", "1 ReferenceError");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_link_failures_keep_prior_module_state() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mocks = run_js_repl_sequence(
&server,
"run js_repl across a link failure",
&[
("call-1", "const answer = 41; console.log(answer);"),
("call-2", "import value from \"./foo\";"),
("call-3", "console.log(answer + 1);"),
],
)
.await?;
assert_js_repl_ok(&mocks[0].single_request(), "call-1", "41");
assert_js_repl_err(
&mocks[1].single_request(),
"call-2",
"Top-level static import \"./foo\" is not supported in js_repl",
);
assert_js_repl_ok(&mocks[2].single_request(), "call-3", "42");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_failed_cells_do_not_commit_unreached_hoisted_bindings() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mocks = run_js_repl_sequence(
&server,
"run js_repl through hoisted binding failure",
&[
(
"call-1",
"var early = 1; throw new Error(\"boom\"); var late = 2; function fn() { return 1; }",
),
(
"call-2",
"const late = 40; const fn = 1; console.log(early + late + fn);",
),
],
)
.await?;
assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom");
assert_js_repl_ok(&mocks[1].single_request(), "call-2", "42");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_failed_cells_do_not_preserve_hoisted_function_reads_before_declaration()
-> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mocks = run_js_repl_sequence(
&server,
"run js_repl through unsupported hoisted function reads",
&[
(
"call-1",
"foo(); throw new Error(\"boom\"); function foo() {}",
),
(
"call-2",
"let value; try { foo; value = \"present\"; } catch (error) { value = error.name; } console.log(value);",
),
],
)
.await?;
assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom");
assert_js_repl_ok(&mocks[1].single_request(), "call-2", "ReferenceError");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_failed_cells_preserve_functions_when_declaration_sites_are_reached() -> Result<()>
{
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mocks = run_js_repl_sequence(
&server,
"run js_repl through supported function declaration persistence",
&[
("call-1", "function foo() {} throw new Error(\"boom\");"),
("call-2", "console.log(typeof foo);"),
],
)
.await?;
assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom");
assert_js_repl_ok(&mocks[1].single_request(), "call-2", "function");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_failed_cells_preserve_prior_binding_writes_without_new_bindings() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mocks = run_js_repl_sequence(
&server,
"run js_repl through failed prior-binding writes",
&[
("call-1", "let x = 1; console.log(x);"),
("call-2", "x = 2; throw new Error(\"boom\");"),
("call-3", "console.log(x);"),
],
)
.await?;
assert_js_repl_ok(&mocks[0].single_request(), "call-1", "1");
assert_js_repl_err(&mocks[1].single_request(), "call-2", "boom");
assert_js_repl_ok(&mocks[2].single_request(), "call-3", "2");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_failed_cells_var_persistence_boundaries() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let cases = [
(
"run js_repl through supported pre-declaration var writes",
"x = 5; y = 1; y += 2; z = 1; z++; throw new Error(\"boom\"); var x, y, z;",
"console.log(x, y, z);",
"5 3 2",
),
(
"run js_repl through short-circuited logical var assignments",
"x &&= 1; y ||= 2; z ??= 3; throw new Error(\"boom\"); var x, y, z;",
"let xValue; try { xValue = x; } catch (error) { xValue = error.name; } console.log(xValue, y, z);",
"ReferenceError 2 3",
),
(
"run js_repl through unsupported shadowed nested var writes",
"{ let x = 1; x = 2; } throw new Error(\"boom\"); var x;",
"let value; try { value = x; } catch (error) { value = error.name; } console.log(value);",
"ReferenceError",
),
(
"run js_repl through unsupported nested assignment writes",
"x = (y = 1); throw new Error(\"boom\"); var x, y;",
"let yValue; try { yValue = y; } catch (error) { yValue = error.name; } console.log(x, yValue);",
"1 ReferenceError",
),
(
"run js_repl through unsupported var destructuring recovery",
"var { a, b } = { a: 1, get b() { throw new Error(\"boom\"); } };",
"let aValue; try { aValue = a; } catch (error) { aValue = error.name; } let bValue; try { bValue = b; } catch (error) { bValue = error.name; } console.log(aValue, bValue);",
"ReferenceError ReferenceError",
),
];
for (prompt, failing_cell, followup_cell, expected_followup_output) in cases {
assert_failed_cell_followup(
&server,
prompt,
failing_cell,
followup_cell,
expected_followup_output,
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_failed_cells_commit_non_empty_loop_vars_but_skip_empty_loops() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mocks = run_js_repl_sequence(
&server,
"run js_repl through failed loop bindings",
&[
(
"call-1",
"for (var item of [2]) {} for (var emptyItem of []) {} throw new Error(\"boom\");",
),
(
"call-2",
"let itemValue; try { itemValue = item; } catch (error) { itemValue = error.name; } let emptyValue; try { emptyValue = emptyItem; } catch (error) { emptyValue = error.name; } console.log(itemValue, emptyValue);",
),
],
)
.await?;
assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom");
assert_js_repl_ok(&mocks[1].single_request(), "call-2", "2 ReferenceError");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_keeps_function_to_string_stable() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mock = run_js_repl_turn(
&server,
"run js_repl through function toString",
&[(
"call-1",
"function foo() { return 1; } console.log(foo.toString());",
)],
)
.await?;
let req = mock.single_request();
assert_js_repl_ok(&req, "call-1", "function foo() { return 1; }");
let (output, _) = custom_tool_output_text_and_success(&req, "call-1");
assert!(!output.contains("__codexInternalMarkCommittedBindings"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_allows_globalthis_shadowing_with_instrumented_bindings() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mock = run_js_repl_turn(
&server,
"run js_repl with shadowed globalThis",
&[(
"call-1",
"const globalThis = {}; const value = 1; console.log(typeof globalThis, value);",
)],
)
.await?;
let req = mock.single_request();
assert_js_repl_ok(&req, "call-1", "object 1");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_can_invoke_builtin_tools() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mock = run_js_repl_turn(
&server,
"use js_repl to call a tool",
&[(
"call-1",
"const toolOut = await codex.tool(\"list_mcp_resources\", {}); console.log(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),
"js_repl call failed unexpectedly: {output}"
);
assert!(output.contains("function_call_output"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_can_invoke_mcp_tools_by_display_name() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let rmcp_test_server_bin = stdio_server_bin()?;
let mut builder = test_codex().with_config(move |config| {
config
.features
.enable(Feature::JsRepl)
.expect("test config should allow feature update");
let mut servers = config.mcp_servers.get().clone();
servers.insert(
"rmcp".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
env: None,
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
},
);
config
.mcp_servers
.set(servers)
.expect("test mcp servers should accept any configuration");
});
let test = builder.build(&server).await?;
responses::mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_custom_tool_call(
"call-1",
"js_repl",
r#"
const result = await codex.tool("mcp__rmcp__echo", { message: "ping" });
console.log(result.output);
"#,
),
ev_completed("resp-1"),
]),
)
.await;
let final_mock = responses::mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
)
.await;
test.submit_turn("use js_repl to call an MCP tool").await?;
let req = final_mock.single_request();
assert_js_repl_ok(&req, "call-1", "ECHOING: ping");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_tool_call_rejects_recursive_js_repl_invocation() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mock = run_js_repl_turn(
&server,
"use js_repl recursively",
&[(
"call-1",
r#"
try {
await codex.tool("js_repl", "console.log('recursive')");
console.log("unexpected-success");
} catch (err) {
console.log(String(err));
}
"#,
)],
)
.await?;
let req = mock.single_request();
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
assert_ne!(
success,
Some(false),
"js_repl call failed unexpectedly: {output}"
);
assert!(
output.contains("js_repl cannot invoke itself"),
"expected recursion guard message, got output: {output}"
);
assert!(
!output.contains("unexpected-success"),
"recursive js_repl call unexpectedly succeeded: {output}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_does_not_expose_process_global() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mock = run_js_repl_turn(
&server,
"check process visibility",
&[("call-1", "console.log(typeof process);")],
)
.await?;
let req = mock.single_request();
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
assert_ne!(
success,
Some(false),
"js_repl call failed unexpectedly: {output}"
);
assert!(output.contains("undefined"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_exposes_codex_path_helpers() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mock = run_js_repl_turn(
&server,
"check codex path helpers",
&[(
"call-1",
"console.log(`cwd:${typeof codex.cwd}:${codex.cwd.length > 0}`); console.log(`home:${codex.homeDir === null || typeof codex.homeDir === \"string\"}`);",
)],
)
.await?;
let req = mock.single_request();
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
assert_ne!(
success,
Some(false),
"js_repl call failed unexpectedly: {output}"
);
assert!(output.contains("cwd:string:true"));
assert!(output.contains("home:true"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_blocks_sensitive_builtin_imports() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mock = run_js_repl_turn(
&server,
"import a blocked module",
&[("call-1", "await import(\"node:process\");")],
)
.await?;
let req = mock.single_request();
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
assert_ne!(
success,
Some(true),
"blocked import unexpectedly succeeded: {output}"
);
assert!(output.contains("Importing module \"node:process\" is not allowed in js_repl"));
Ok(())
}

View File

@@ -51,7 +51,6 @@ mod hooks;
mod hooks_mcp;
mod image_rollout;
mod items;
mod js_repl;
mod json_result;
mod live_cli;
mod live_reload;

View File

@@ -24,7 +24,6 @@ use codex_core::config::Config;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::Environment;
use codex_exec_server::HttpRequestParams;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY;
use codex_models_manager::manager::RefreshStrategy;
@@ -48,7 +47,6 @@ use codex_utils_cargo_bin::cargo_bin;
use core_test_support::assert_regex_match;
use core_test_support::remote_env_env_var;
use core_test_support::responses;
use core_test_support::responses::ev_custom_tool_call;
use core_test_support::responses::mount_models_once;
use core_test_support::responses::mount_sse_once;
use core_test_support::skip_if_no_network;
@@ -1328,90 +1326,6 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_test_value)]
async fn js_repl_emit_image_preserves_original_detail_for_mcp_images() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let call_id = "js-repl-rmcp-image";
let rmcp_test_server_bin = stdio_server_bin()?;
let fixture = test_codex()
.with_model("gpt-5.3-codex")
.with_config(move |config| {
config
.features
.enable(Feature::JsRepl)
.expect("test config should allow feature update");
insert_mcp_server(
config,
"rmcp",
stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()),
TestMcpServerOptions::default(),
);
})
.build(&server)
.await?;
wait_for_mcp_tool(&fixture, "mcp__rmcp__image_scenario").await?;
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
ev_custom_tool_call(
call_id,
"js_repl",
r#"
const out = await codex.tool("mcp__rmcp__image_scenario", {
scenario: "image_only_original_detail",
});
const imageItem = out.output.find((item) => item.type === "input_image");
await codex.emitImage(imageItem);
"#,
),
responses::ev_completed("resp-1"),
]),
)
.await;
let final_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "done"),
responses::ev_completed("resp-2"),
]),
)
.await;
fixture
.submit_turn("use js_repl to emit the rmcp image scenario output")
.await?;
let output = final_mock.single_request().custom_tool_call_output(call_id);
let output_items = output["output"]
.as_array()
.expect("js_repl output should be content items");
let image_item = output_items
.iter()
.find(|item| item.get("type").and_then(Value::as_str) == Some("input_image"))
.expect("js_repl should emit an input_image item");
assert_eq!(
image_item.get("detail").and_then(Value::as_str),
Some("original")
);
assert!(
image_item
.get("image_url")
.and_then(Value::as_str)
.is_some_and(|image_url| image_url.starts_with("data:image/png;base64,")),
"js_repl should emit a png data URL"
);
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_test_value)]
async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Result<()> {

View File

@@ -96,10 +96,6 @@ async fn empty_turn_environments_omits_environment_backed_tools() -> Result<()>
.features
.enable(Feature::UnifiedExec)
.expect("unified exec should enable for test");
config
.features
.enable(Feature::JsRepl)
.expect("js repl should enable for test");
config.include_apply_patch_tool = true;
});
let test = builder.build(&server).await?;
@@ -112,14 +108,7 @@ async fn empty_turn_environments_omits_environment_backed_tools() -> Result<()>
tools.contains(&"update_plan".to_string()),
"non-environment tool should remain available; got {tools:?}"
);
for environment_tool in [
"exec_command",
"write_stdin",
"js_repl",
"js_repl_reset",
"apply_patch",
"view_image",
] {
for environment_tool in ["exec_command", "write_stdin", "apply_patch", "view_image"] {
assert!(
!tools.contains(&environment_tool.to_string()),
"{environment_tool} should be omitted for explicit empty turn environments; got {tools:?}"

View File

@@ -22,7 +22,6 @@ use codex_protocol::user_input::UserInput;
use core_test_support::responses;
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_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_models_once;
@@ -871,233 +870,6 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_only
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_emit_image_attaches_local_image() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config
.features
.enable(Feature::JsRepl)
.expect("test config should allow feature update");
});
let TestCodex {
codex,
cwd,
session_configured,
..
} = builder.build(&server).await?;
let call_id = "js-repl-view-image";
let js_input = r#"
const fs = await import("node:fs/promises");
const path = await import("node:path");
const imagePath = path.join(codex.tmpDir, "js-repl-view-image.png");
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
"base64"
);
await fs.writeFile(imagePath, png);
const out = await codex.tool("view_image", { path: imagePath });
await codex.emitImage(out);
"#;
let first_response = sse(vec![
ev_response_created("resp-1"),
ev_custom_tool_call(call_id, "js_repl", js_input),
ev_completed("resp-1"),
]);
responses::mount_sse_once(&server, first_response).await;
let second_response = sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]);
let mock = responses::mount_sse_once(&server, second_response).await;
let session_model = session_configured.model.clone();
codex
.submit(Op::UserTurn {
environments: None,
items: vec![UserInput::Text {
text: "use js_repl to write an image and attach it".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
approvals_reviewer: None,
sandbox_policy: SandboxPolicy::DangerFullAccess,
permission_profile: None,
model: session_model,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
})
.await?;
let mut tool_event = None;
wait_for_event_with_timeout(
&codex,
|event| match event {
EventMsg::ViewImageToolCall(_) => {
tool_event = Some(event.clone());
false
}
EventMsg::TurnComplete(_) => true,
_ => false,
},
VIEW_IMAGE_TURN_COMPLETE_TIMEOUT,
)
.await;
let tool_event = match tool_event {
Some(EventMsg::ViewImageToolCall(event)) => event,
other => panic!("expected ViewImageToolCall event, got {other:?}"),
};
assert!(
tool_event.path.ends_with("js-repl-view-image.png"),
"unexpected image path: {}",
tool_event.path.display()
);
let req = mock.single_request();
let body = req.body_json();
assert_eq!(
image_messages(&body).len(),
0,
"js_repl view_image should not inject a pending input image message"
);
let custom_output = req.custom_tool_call_output(call_id);
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 js_repl custom tool output");
assert!(
image_url.starts_with("data:image/png;base64,"),
"expected png data URL, got {image_url}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_view_image_requires_explicit_emit() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
#[allow(clippy::expect_used)]
let mut builder = test_codex().with_config(|config| {
config
.features
.enable(Feature::JsRepl)
.expect("test config should allow feature update");
});
let TestCodex {
codex,
cwd,
session_configured,
..
} = builder.build(&server).await?;
let call_id = "js-repl-view-image-no-emit";
let js_input = r#"
const fs = await import("node:fs/promises");
const path = await import("node:path");
const imagePath = path.join(codex.tmpDir, "js-repl-view-image-no-emit.png");
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
"base64"
);
await fs.writeFile(imagePath, png);
const out = await codex.tool("view_image", { path: imagePath });
console.log(out.type);
"#;
let first_response = sse(vec![
ev_response_created("resp-1"),
ev_custom_tool_call(call_id, "js_repl", js_input),
ev_completed("resp-1"),
]);
responses::mount_sse_once(&server, first_response).await;
let second_response = sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]);
let mock = responses::mount_sse_once(&server, second_response).await;
let session_model = session_configured.model.clone();
codex
.submit(Op::UserTurn {
environments: None,
items: vec![UserInput::Text {
text: "use js_repl to write an image but do not emit it".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
approvals_reviewer: None,
sandbox_policy: SandboxPolicy::DangerFullAccess,
permission_profile: None,
model: session_model,
effort: None,
service_tier: None,
summary: None,
collaboration_mode: None,
personality: None,
})
.await?;
let mut tool_event = None;
wait_for_event_with_timeout(
&codex,
|event| match event {
EventMsg::ViewImageToolCall(_) => {
tool_event = Some(event.clone());
false
}
EventMsg::TurnComplete(_) => true,
_ => false,
},
VIEW_IMAGE_TURN_COMPLETE_TIMEOUT,
)
.await;
let tool_event = match tool_event {
Some(EventMsg::ViewImageToolCall(event)) => event,
other => panic!("expected ViewImageToolCall event, got {other:?}"),
};
assert!(
tool_event.path.ends_with("js-repl-view-image-no-emit.png"),
"unexpected image path: {}",
tool_event.path.display()
);
let req = mock.single_request();
let custom_output = req.custom_tool_call_output(call_id);
let output_items = custom_output.get("output").and_then(Value::as_array);
assert!(
output_items.is_none_or(|items| items
.iter()
.all(|item| item.get("type").and_then(Value::as_str) != Some("input_image"))),
"nested view_image should not auto-populate js_repl output"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
@@ -1572,4 +1344,3 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()>
Ok(())
}
use codex_features::Feature;