Files
codex/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs
T
Brent Traut 2a1158b8e2 feat(app-server): include turns page on thread resume (#23534)
## Summary

The client currently calls `thread/resume` to establish live updates and
immediately follows it with `thread/turns/list` to hydrate recent turns.
This lets `thread/resume` return that page directly, eliminating a round
trip and the ordering/deduplication gap between the two calls.

Experimental clients opt in with `initialTurnsPage: { limit,
sortDirection, itemsView }`. The response returns `initialTurnsPage` as
a `TurnsPage`, including cursors for paging further back in history.
Keeping the controls in a nested opt-in object provides the useful
`thread/turns/list` knobs without spreading page-specific parameters
across `thread/resume`.

## Verification

- `just fmt`
- `just write-app-server-schema --experimental`
- `just write-app-server-schema`
- `cargo test -p codex-app-server-protocol`
- `cargo test -p codex-app-server
thread_resume_initial_turns_page_matches_requested_turns_list_page
--tests`
- `cargo test -p codex-app-server
thread_resume_rejoins_running_thread_even_with_override_mismatch
--tests`
- `just fix -p codex-app-server-protocol -p codex-app-server`
2026-05-28 09:18:13 -07:00

203 lines
7.2 KiB
Rust

use codex_app_server_protocol::McpToolCallResult;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::Turn;
use serde_json::Value as JsonValue;
// Temporary bandaid for remote clients: thread/resume can include large MCP and
// image-generation payloads. Keep this response-only so persisted rollout
// history, model resume history, and other APIs stay unchanged.
const REDACTED_PAYLOAD: &str = "[redacted]";
const CHATGPT_REMOTE_CLIENT_NAMES: &[&str] =
&["codex_chatgpt_android_remote", "codex_chatgpt_ios_remote"];
pub(super) fn should_redact_thread_resume_payloads(client_name: Option<&str>) -> bool {
client_name.is_some_and(|client_name| CHATGPT_REMOTE_CLIENT_NAMES.contains(&client_name))
}
pub(super) fn redact_thread_resume_payloads(turns: &mut [Turn]) {
for turn in turns {
turn.items.retain_mut(|item| match item {
ThreadItem::McpToolCall {
arguments,
result,
error,
..
} => {
*arguments = JsonValue::String(REDACTED_PAYLOAD.to_string());
if result.is_some() {
*result = Some(Box::new(redacted_mcp_tool_call_result()));
}
if let Some(error) = error {
error.message = REDACTED_PAYLOAD.to_string();
}
true
}
ThreadItem::ImageGeneration { .. } => false,
_ => true,
});
}
}
fn redacted_mcp_tool_call_result() -> McpToolCallResult {
McpToolCallResult {
content: vec![serde_json::json!({
"type": "text",
"text": REDACTED_PAYLOAD,
})],
structured_content: None,
meta: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::McpToolCallError;
use codex_app_server_protocol::McpToolCallStatus;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::TurnItemsView;
use codex_app_server_protocol::TurnStatus;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
#[test]
fn redacts_mcp_success_result_and_removes_image_generation() {
let mut thread = test_thread(vec![
ThreadItem::AgentMessage {
id: "agent-1".to_string(),
text: "kept".to_string(),
phase: None,
memory_citation: None,
},
ThreadItem::McpToolCall {
id: "mcp-1".to_string(),
server: "docs".to_string(),
tool: "lookup".to_string(),
status: McpToolCallStatus::Completed,
arguments: serde_json::json!({"secret":"argument"}),
mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()),
plugin_id: Some("sample@test".to_string()),
result: Some(Box::new(McpToolCallResult {
content: vec![serde_json::json!({
"type": "text",
"text": "secret result"
})],
structured_content: Some(serde_json::json!({"secret":"structured"})),
meta: Some(serde_json::json!({"secret":"meta"})),
})),
error: None,
duration_ms: Some(8),
},
ThreadItem::ImageGeneration {
id: "ig-1".to_string(),
status: "completed".to_string(),
revised_prompt: Some("revised".to_string()),
result: "base64-result".to_string(),
saved_path: Some(test_path_buf("/tmp/ig-1.png").abs()),
},
]);
redact_thread_resume_payloads(&mut thread.turns);
assert_eq!(thread.turns[0].items.len(), 2);
assert_eq!(
thread.turns[0].items[0],
ThreadItem::AgentMessage {
id: "agent-1".to_string(),
text: "kept".to_string(),
phase: None,
memory_citation: None,
}
);
assert_eq!(
thread.turns[0].items[1],
ThreadItem::McpToolCall {
id: "mcp-1".to_string(),
server: "docs".to_string(),
tool: "lookup".to_string(),
status: McpToolCallStatus::Completed,
arguments: JsonValue::String(REDACTED_PAYLOAD.to_string()),
mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()),
plugin_id: Some("sample@test".to_string()),
result: Some(Box::new(redacted_mcp_tool_call_result())),
error: None,
duration_ms: Some(8),
}
);
}
#[test]
fn redacts_mcp_error_message() {
let mut thread = test_thread(vec![ThreadItem::McpToolCall {
id: "mcp-1".to_string(),
server: "docs".to_string(),
tool: "lookup".to_string(),
status: McpToolCallStatus::Failed,
arguments: serde_json::json!({"secret":"argument"}),
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
error: Some(McpToolCallError {
message: "secret error".to_string(),
}),
duration_ms: Some(8),
}]);
redact_thread_resume_payloads(&mut thread.turns);
assert_eq!(
thread.turns[0].items[0],
ThreadItem::McpToolCall {
id: "mcp-1".to_string(),
server: "docs".to_string(),
tool: "lookup".to_string(),
status: McpToolCallStatus::Failed,
arguments: JsonValue::String(REDACTED_PAYLOAD.to_string()),
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
error: Some(McpToolCallError {
message: REDACTED_PAYLOAD.to_string(),
}),
duration_ms: Some(8),
}
);
}
fn test_thread(items: Vec<ThreadItem>) -> Thread {
Thread {
id: "thread-1".to_string(),
session_id: "session-1".to_string(),
forked_from_id: None,
preview: "preview".to_string(),
ephemeral: false,
model_provider: "mock_provider".to_string(),
created_at: 0,
updated_at: 0,
status: ThreadStatus::Idle,
path: None,
cwd: test_path_buf("/tmp").abs(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
name: None,
turns: vec![Turn {
id: "turn-1".to_string(),
items,
items_view: TurnItemsView::Full,
status: TurnStatus::Completed,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
}],
}
}
}