Files
codex/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs
T
Owen Lin cf0911076f store and expose parent_thread_id on Threads (#25113)
## Why

This PR
https://github.com/openai/codex/pull/24161#discussion_r3325692763
revealed a subagent data modeling issue, where we overloaded
`forked_from_id` to also mean `parent_thread_id`. That's incorrect since
guardian and review subagents can be a subagent and NOT fork the main
thread's history.

The solution here is to explicitly store a new `parent_thread_id` on
`SessionMeta`, alongside `forked_from_id` which already exists. While
we're at it, also expose it in the app-server protocol on the `Thread`
object.

A thread->subagent relationship and a fork of thread history are
orthogonal concepts.

## What Changed

- Added top-level `parent_thread_id` persistence on `SessionMeta` and
runtime/session plumbing through `SessionConfiguredEvent`,
`CodexSpawnArgs`, `SessionConfiguration`, `ThreadConfigSnapshot`,
`TurnContext`, and `ModelClient`.
- Made turn metadata, request headers, analytics, and subagent-start
events read the separate runtime/top-level parent field instead of
deriving general parent lineage from `SessionSource` or
`forked_from_thread_id`.
- Passed parent lineage separately at delegated subagent, review,
guardian, agent-job, and multi-agent spawn construction sites;
copied-history fork lineage remains derived only from `InitialHistory`.
- Persisted and exposed parent lineage through rollout/thread-store
projections and app-server v2 `Thread.parentThreadId`.
- Updated app-server README text and regenerated app-server schema
fixtures for the additive `parentThreadId` response field.
2026-06-01 04:33:20 +00:00

204 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,
parent_thread_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,
}],
}
}
}