Files
codex/codex-rs/app-server/tests/suite/v2/turn_steer.rs
T
Alex Kira 6d8f311b00 Render untrusted additional context as named tool outputs (#57279)
## What changed

When `features.untrusted_as_tool_output` is enabled, emit untrusted additional context as `function_call_output` items in the `agent` namespace before user input. Use the explicit context name when supplied; otherwise derive a bounded name from the source key, falling back to `untrusted_content`.

Deduplicate these outputs by name and truncated value so name changes are emitted while changes confined to truncated content are ignored. Application context remains in developer messages, and disabling the feature preserves the existing message format.

## Testing

Extend core and app-server tests and add request-history snapshots covering both formats, turn start and steering, derived names, truncation, and deduplication.

GitOrigin-RevId: cd5e2f4b3121714acb7f4baf5607f3c27d408cae
2026-10-26 21:55:00 +00:00

568 lines
19 KiB
Rust

#![cfg(unix)]
use anyhow::Context;
use anyhow::Result;
use app_test_support::TestAppServer;
use app_test_support::create_command_execution_sse_response;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE;
use codex_app_server::INVALID_PARAMS_ERROR_CODE;
use codex_app_server_protocol::AdditionalContextEntry;
use codex_app_server_protocol::AdditionalContextKind;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnSteerParams;
use codex_app_server_protocol::TurnSteerResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
use core_test_support::skip_if_remote;
use pretty_assertions::assert_eq;
use serde_json::Value;
use std::collections::HashMap;
use tempfile::TempDir;
use tokio::time::timeout;
use super::analytics::mount_analytics_capture;
use super::analytics::wait_for_analytics_event;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
async fn turn_steer_requires_active_turn() -> Result<()> {
let tmp = TempDir::new()?;
let codex_home = tmp.path().join("codex_home");
std::fs::create_dir(&codex_home)?;
let server = create_mock_responses_server_sequence(vec![]).await;
write_mock_responses_config_toml_with_chatgpt_base_url(
&codex_home,
&server.uri(),
&server.uri(),
)?;
mount_analytics_capture(&server, &codex_home).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(&codex_home)
.without_managed_config()
.build_initialized()
.await?;
let ThreadStartResponse { thread, .. } = mcp
.start_thread(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let steer_req = mcp
.send_turn_steer_request(TurnSteerParams {
thread_id: thread.id.clone(),
client_user_message_id: Some("client-steer-message-1".to_string()),
input: vec![V2UserInput::Text {
text: "steer".to_string(),
text_elements: Vec::new(),
}],
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
additional_context: None,
expected_turn_id: "turn-does-not-exist".to_string(),
})
.await?;
let steer_err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(steer_req)),
)
.await??;
assert_eq!(steer_err.error.code, -32600);
let event =
wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?;
assert_eq!(event["event_params"]["thread_id"], thread.id);
assert_eq!(event["event_params"]["result"], "rejected");
assert_eq!(event["event_params"]["num_input_images"], 0);
assert_eq!(
event["event_params"]["expected_turn_id"],
"turn-does-not-exist"
);
assert_eq!(
event["event_params"]["accepted_turn_id"],
serde_json::Value::Null
);
assert_eq!(event["event_params"]["rejection_reason"], "no_active_turn");
let malformed_request = mcp
.send_turn_steer_request(TurnSteerParams {
thread_id: thread.id,
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "rejected input".to_owned(),
text_elements: Vec::new(),
}],
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
additional_context: Some(HashMap::from([(
"browser_info".to_owned(),
AdditionalContextEntry {
value: "rejected context".to_owned(),
kind: AdditionalContextKind::Untrusted,
name: Some("agent.browser".to_owned()),
},
)])),
expected_turn_id: "turn-does-not-exist".to_owned(),
})
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(malformed_request)),
)
.await??;
assert_eq!(
error.error,
codex_app_server_protocol::JSONRPCErrorError {
code: -32600,
message: "additional context name must match [a-z][a-z0-9_]{0,63}".to_owned(),
data: None,
}
);
Ok(())
}
#[tokio::test]
async fn turn_steer_rejects_oversized_text_input() -> Result<()> {
// TODO(anp): Remove after the active-turn fixture can run in the selected remote environment.
skip_if_remote!(
Ok(()),
"uses a host-local command and cwd fixture unavailable to remote executors"
);
#[cfg(target_os = "windows")]
let shell_command = vec![
"powershell".to_string(),
"-Command".to_string(),
"Start-Sleep -Seconds 10".to_string(),
];
#[cfg(not(target_os = "windows"))]
let shell_command = vec!["sleep".to_string(), "10".to_string()];
let tmp = TempDir::new()?;
let codex_home = tmp.path().join("codex_home");
std::fs::create_dir(&codex_home)?;
let working_directory = tmp.path().join("workdir");
std::fs::create_dir(&working_directory)?;
let server = create_mock_responses_server_sequence_unchecked(vec![
create_command_execution_sse_response(
shell_command.clone(),
Some(&working_directory),
Some(10_000),
"call_sleep",
)?,
])
.await;
write_mock_responses_config_toml_with_chatgpt_base_url(
&codex_home,
&server.uri(),
&server.uri(),
)?;
mount_analytics_capture(&server, &codex_home).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(&codex_home)
.without_managed_config()
.build_initialized()
.await?;
let ThreadStartResponse { thread, .. } = mcp
.start_thread(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let TurnStartResponse { turn } = mcp
.request(|request_id| ClientRequest::TurnStart {
request_id,
params: TurnStartParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "run sleep".to_string(),
text_elements: Vec::new(),
}],
cwd: Some(working_directory.clone()),
..Default::default()
},
})
.await?;
let _task_started: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/started"),
)
.await??;
let oversized_input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1);
let steer_req = mcp
.send_turn_steer_request(TurnSteerParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: oversized_input.clone(),
text_elements: Vec::new(),
}],
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
additional_context: None,
expected_turn_id: turn.id.clone(),
})
.await?;
let steer_err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(steer_req)),
)
.await??;
assert_eq!(steer_err.error.code, INVALID_PARAMS_ERROR_CODE);
assert_eq!(
steer_err.error.message,
format!("Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters.")
);
let data = steer_err
.error
.data
.expect("expected structured error data");
assert_eq!(data["input_error_code"], INPUT_TOO_LARGE_ERROR_CODE);
assert_eq!(data["max_chars"], MAX_USER_INPUT_TEXT_CHARS);
assert_eq!(data["actual_chars"], oversized_input.chars().count());
mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT)
.await?;
Ok(())
}
#[test_case::test_case(false; "legacy_context")]
#[test_case::test_case(true; "named_context")]
#[tokio::test]
async fn turn_steer_returns_active_turn_id(tool_outputs: bool) -> Result<()> {
// TODO(anp): Remove after the active-turn fixture can run in the selected remote environment.
skip_if_remote!(
Ok(()),
"uses a host-local command and cwd fixture unavailable to remote executors"
);
#[cfg(target_os = "windows")]
let shell_command = vec![
"powershell".to_string(),
"-Command".to_string(),
"Start-Sleep -Seconds 2".to_string(),
];
#[cfg(not(target_os = "windows"))]
let shell_command = vec!["sleep".to_string(), "2".to_string()];
let tmp = TempDir::new()?;
let codex_home = tmp.path().join("codex_home");
std::fs::create_dir(&codex_home)?;
let working_directory = tmp.path().join("workdir");
std::fs::create_dir(&working_directory)?;
let server = create_mock_responses_server_sequence_unchecked(vec![
create_command_execution_sse_response(
shell_command.clone(),
Some(&working_directory),
Some(10_000),
"call_sleep",
)?,
app_test_support::create_final_assistant_message_sse_response("Done")?,
])
.await;
write_mock_responses_config_toml_with_chatgpt_base_url(
&codex_home,
&server.uri(),
&server.uri(),
)?;
mount_analytics_capture(&server, &codex_home).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(&codex_home)
.without_managed_config()
.build_initialized()
.await?;
let ThreadStartResponse { thread, .. } = mcp
.start_thread(ThreadStartParams {
model: Some("mock-model".to_string()),
config: Some(HashMap::from([(
"features.untrusted_as_tool_output".to_owned(),
serde_json::json!(tool_outputs),
)])),
..Default::default()
})
.await?;
let TurnStartResponse { turn } = mcp
.request(|request_id| ClientRequest::TurnStart {
request_id,
params: TurnStartParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "run sleep".to_string(),
text_elements: Vec::new(),
}],
cwd: Some(working_directory.clone()),
..Default::default()
},
})
.await?;
let _task_started: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/started"),
)
.await??;
let steer: TurnSteerResponse = mcp
.request(|request_id| ClientRequest::TurnSteer {
request_id,
params: TurnSteerParams {
thread_id: thread.id.clone(),
client_user_message_id: Some("client-steer-message-1".to_string()),
input: vec![V2UserInput::Text {
text: "steer".to_string(),
text_elements: Vec::new(),
}],
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
additional_context: Some(HashMap::from([(
"browser_info".to_owned(),
AdditionalContextEntry {
value: "tab one".to_owned(),
kind: AdditionalContextKind::Untrusted,
name: Some("browser".to_owned()),
},
)])),
expected_turn_id: turn.id.clone(),
},
})
.await?;
assert_eq!(steer.turn_id, turn.id);
timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let notification = mcp
.read_stream_until_notification_message("item/started")
.await?;
let params = notification.params.expect("item/started params");
let item_started: ItemStartedNotification =
serde_json::from_value(params).expect("deserialize item/started notification");
let ThreadItemPayload::UserMessage {
client_id, content, ..
} = item_started.item.payload
else {
continue;
};
if client_id == Some("client-steer-message-1".to_string()) {
assert_eq!(
content,
vec![V2UserInput::Text {
text: "steer".to_string(),
text_elements: Vec::new(),
}]
);
return Ok::<(), anyhow::Error>(());
}
}
})
.await??;
let event =
wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?;
assert_eq!(event["event_params"]["thread_id"], thread.id);
assert_eq!(event["event_params"]["session_id"], thread.session_id);
assert_eq!(event["event_params"]["result"], "accepted");
assert_eq!(event["event_params"]["num_input_images"], 0);
assert_eq!(event["event_params"]["expected_turn_id"], turn.id);
assert_eq!(event["event_params"]["accepted_turn_id"], turn.id);
assert_eq!(
event["event_params"]["rejection_reason"],
serde_json::Value::Null
);
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let requests = server
.received_requests()
.await
.context("recorded requests")?;
let body = requests
.iter()
.rfind(|request| request.url.path().ends_with("/responses"))
.context("model request after steering")?
.body_json::<Value>()?;
let input = body["input"].as_array().context("input array")?;
let context_index = if tool_outputs {
input.iter().position(|item| {
item["type"] == "function_call_output"
&& item["namespace"] == "agent"
&& item["name"] == "browser"
&& item["output"] == "tab one"
})
} else {
input.iter().position(|item| {
item["content"].as_array().is_some_and(|content| {
content.iter().any(|part| {
part["text"] == "<external_browser_info>tab one</external_browser_info>"
})
})
})
}
.context("steered context")?;
let user_index = input
.iter()
.position(|item| {
item["role"] == "user"
&& item["content"]
.as_array()
.is_some_and(|content| content.iter().any(|part| part["text"] == "steer"))
})
.context("steered user input")?;
assert!(context_index < user_index);
Ok(())
}
#[tokio::test]
async fn turn_steer_rejects_context_only_input_without_merging_context() -> Result<()> {
// TODO(anp): Remove after the active-turn fixture can run in the selected remote environment.
skip_if_remote!(
Ok(()),
"uses a host-local command and cwd fixture unavailable to remote executors"
);
let tmp = TempDir::new()?;
let codex_home = tmp.path().join("codex_home");
std::fs::create_dir(&codex_home)?;
let working_directory = tmp.path().join("workdir");
std::fs::create_dir(&working_directory)?;
let server = create_mock_responses_server_sequence_unchecked(vec![
create_command_execution_sse_response(
vec!["sleep".to_string(), "1".to_string()],
Some(&working_directory),
Some(10_000),
"call_sleep",
)?,
app_test_support::create_final_assistant_message_sse_response("Done")?,
])
.await;
write_mock_responses_config_toml_with_chatgpt_base_url(
&codex_home,
&server.uri(),
&server.uri(),
)?;
mount_analytics_capture(&server, &codex_home).await?;
let mut mcp = TestAppServer::builder()
.with_codex_home(&codex_home)
.without_managed_config()
.build_initialized()
.await?;
let ThreadStartResponse { thread, .. } = mcp
.start_thread(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let TurnStartResponse { turn } = mcp
.request(|request_id| ClientRequest::TurnStart {
request_id,
params: TurnStartParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "run sleep".to_string(),
text_elements: Vec::new(),
}],
cwd: Some(working_directory),
..Default::default()
},
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/started"),
)
.await??;
let additional_context = Some(HashMap::from([(
"browser_info".to_string(),
AdditionalContextEntry {
value: "tab one".to_string(),
kind: AdditionalContextKind::Untrusted,
name: None,
},
)]));
let steer_req = mcp
.send_turn_steer_request(TurnSteerParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: Vec::new(),
responsesapi_client_metadata: None,
responsesapi_client_metadata_patch: None,
additional_context,
expected_turn_id: turn.id,
})
.await?;
let steer_error: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(steer_req)),
)
.await??;
assert_eq!(steer_error.error.code, -32600);
assert_eq!(steer_error.error.message, "input must not be empty");
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let requests = server
.received_requests()
.await
.context("failed to fetch received requests")?;
let response_requests = requests
.iter()
.filter(|request| request.url.path().ends_with("/responses"))
.collect::<Vec<_>>();
assert_eq!(response_requests.len(), 2);
let body = response_requests[1]
.body_json::<Value>()
.context("request body should be JSON")?;
assert!(
!body
.to_string()
.contains("<external_browser_info>tab one</external_browser_info>")
);
Ok(())
}