use std::fs;
use std::path::Path;
use serde_json::json;
use tempfile::tempdir;
use super::history::enrich_thread_history_from_rollout;
use crate::bridge_protocol::ThreadRenderNode;
use crate::state::render::build_thread_render_snapshot;
#[test]
fn enrich_thread_history_backfills_missing_aggregated_output() {
let temp_dir = tempdir().expect("创建临时目录失败");
let rollout_path = temp_dir.path().join("rollout-thread-a.jsonl");
write_rollout(
&rollout_path,
&[json!({
"type": "event_msg",
"payload": {
"type": "exec_command_end",
"call_id": "call-a",
"turn_id": "turn-a",
"command": ["/bin/bash", "-lc", "pwd"],
"cwd": "/tmp/work",
"parsed_cmd": [{"type": "unknown", "cmd": "pwd"}],
"aggregated_output": "/tmp/work\n",
"exit_code": 0,
"status": "completed"
}
})],
);
let mut thread = json!({
"id": "thread-a",
"path": rollout_path.display().to_string(),
"turns": [{
"id": "turn-a",
"items": [{
"id": "call-a",
"type": "commandExecution",
"status": "completed",
"command": "pwd",
"commandActions": [{"type": "run", "command": "pwd"}],
"aggregatedOutput": null
}]
}]
});
enrich_thread_history_from_rollout(&mut thread, None);
let item = &thread["turns"][0]["items"][0];
assert_eq!(item["aggregatedOutput"], json!("/tmp/work\n"));
assert_eq!(item["exitCode"], json!(0));
}
#[test]
fn enrich_thread_history_keeps_existing_aggregated_output() {
let temp_dir = tempdir().expect("创建临时目录失败");
let rollout_path = temp_dir.path().join("rollout-thread-keep.jsonl");
write_rollout(
&rollout_path,
&[json!({
"type": "event_msg",
"payload": {
"type": "exec_command_end",
"call_id": "call-keep",
"turn_id": "turn-keep",
"command": ["/bin/bash", "-lc", "pwd"],
"cwd": "/tmp/work",
"parsed_cmd": [{"type": "unknown", "cmd": "pwd"}],
"aggregated_output": "fallback output\n",
"exit_code": 0,
"status": "completed"
}
})],
);
let mut thread = json!({
"id": "thread-keep",
"path": rollout_path.display().to_string(),
"turns": [{
"id": "turn-keep",
"items": [{
"id": "call-keep",
"type": "commandExecution",
"status": "completed",
"command": "pwd",
"commandActions": [{"type": "run", "command": "pwd"}],
"aggregatedOutput": "official output\n"
}]
}]
});
enrich_thread_history_from_rollout(&mut thread, None);
assert_eq!(
thread["turns"][0]["items"][0]["aggregatedOutput"],
json!("official output\n")
);
}
#[test]
fn enrich_thread_history_inserts_missing_command_before_final_answer() {
let temp_dir = tempdir().expect("创建临时目录失败");
let rollout_path = temp_dir.path().join("rollout-thread-b.jsonl");
write_rollout(
&rollout_path,
&[
json!({
"type": "turn_context",
"payload": {
"turn_id": "turn-b"
}
}),
json!({
"type": "response_item",
"payload": {
"type": "function_call",
"name": "exec_command",
"call_id": "call-b",
"arguments": "{\"cmd\":\"pwd\",\"workdir\":\"/tmp/work\"}"
}
}),
json!({
"type": "response_item",
"payload": {
"type": "function_call_output",
"call_id": "call-b",
"output": "Command: /bin/bash -lc pwd\nProcess exited with code 0\nOutput:\n/tmp/work\n"
}
}),
],
);
let mut thread = json!({
"id": "thread-b",
"path": rollout_path.display().to_string(),
"turns": [{
"id": "turn-b",
"items": [
{
"id": "item-user",
"type": "userMessage",
"content": [{"type": "text", "text": "继续"}]
},
{
"id": "item-commentary",
"type": "agentMessage",
"text": "先查一下",
"phase": "commentary"
},
{
"id": "item-final",
"type": "agentMessage",
"text": "结论如下",
"phase": "final_answer"
}
]
}]
});
enrich_thread_history_from_rollout(&mut thread, None);
let items = thread["turns"][0]["items"]
.as_array()
.expect("应存在 items");
assert_eq!(items[2]["type"], json!("commandExecution"));
assert_eq!(items[2]["id"], json!("call-b"));
assert_eq!(items[2]["aggregatedOutput"], json!("/tmp/work\n"));
assert_eq!(items[3]["id"], json!("item-final"));
let snapshot =
build_thread_render_snapshot("runtime-test", &thread).expect("构建 render snapshot 失败");
let exec_node = snapshot
.nodes
.iter()
.find_map(|node| match node {
ThreadRenderNode::ExecGroup {
title,
output_text,
commands,
..
} => Some((title.clone(), output_text.clone(), commands.clone())),
_ => None,
})
.expect("应存在补建后的 exec group");
assert_eq!(exec_node.0, "Explored");
assert_eq!(exec_node.1, Some("/tmp/work\n".to_string()));
assert_eq!(exec_node.2[0].label, "List");
}
#[test]
fn enrich_thread_history_falls_back_to_codex_home_when_thread_path_missing() {
let temp_dir = tempdir().expect("创建临时目录失败");
let rollout_path = temp_dir
.path()
.join("sessions/2026/04/15/rollout-2026-04-15T10-00-00-thread-c.jsonl");
if let Some(parent) = rollout_path.parent() {
fs::create_dir_all(parent).expect("创建 sessions 目录失败");
}
write_rollout(
&rollout_path,
&[
json!({
"type": "turn_context",
"payload": {
"turn_id": "turn-c"
}
}),
json!({
"type": "response_item",
"payload": {
"type": "function_call",
"name": "exec_command",
"call_id": "call-c",
"arguments": "{\"cmd\":\"rg --files -g 'AGENTS.md' .\",\"workdir\":\"/tmp/work\"}"
}
}),
json!({
"type": "response_item",
"payload": {
"type": "function_call_output",
"call_id": "call-c",
"output": "Command: /bin/bash -lc \"rg --files -g 'AGENTS.md' .\"\nProcess exited with code 0\nOutput:\n./AGENTS.md\n"
}
}),
],
);
let mut thread = json!({
"id": "thread-c",
"turns": [{
"id": "turn-c",
"items": [{
"id": "item-final",
"type": "agentMessage",
"text": "done",
"phase": "final_answer"
}]
}]
});
enrich_thread_history_from_rollout(
&mut thread,
Some(temp_dir.path().to_string_lossy().as_ref()),
);
let items = thread["turns"][0]["items"]
.as_array()
.expect("应存在 items");
assert_eq!(items[0]["id"], json!("call-c"));
assert_eq!(items[0]["aggregatedOutput"], json!("./AGENTS.md\n"));
}
#[test]
fn enrich_thread_history_extracts_text_from_function_call_output_body_arrays() {
let temp_dir = tempdir().expect("创建临时目录失败");
let rollout_path = temp_dir.path().join("rollout-thread-d.jsonl");
write_rollout(
&rollout_path,
&[
json!({
"type": "turn_context",
"payload": {
"turn_id": "turn-d"
}
}),
json!({
"type": "response_item",
"payload": {
"type": "function_call",
"name": "exec_command",
"call_id": "call-d",
"arguments": "{\"cmd\":\"printf hello\",\"workdir\":\"/tmp/work\"}"
}
}),
json!({
"type": "response_item",
"payload": {
"type": "function_call_output",
"call_id": "call-d",
"output": {
"body": [
{
"type": "input_text",
"text": "Command: /bin/bash -lc 'printf hello'\nProcess exited with code 0\nOutput:\nhello\n"
},
{
"type": "input_image",
"image_url": "data:image/png;base64,abc"
}
]
}
}
}),
],
);
let mut thread = json!({
"id": "thread-d",
"path": rollout_path.display().to_string(),
"turns": [{
"id": "turn-d",
"items": [{
"id": "item-final",
"type": "agentMessage",
"text": "done",
"phase": "final_answer"
}]
}]
});
enrich_thread_history_from_rollout(&mut thread, None);
let items = thread["turns"][0]["items"]
.as_array()
.expect("应存在 items");
assert_eq!(items[0]["id"], json!("call-d"));
assert_eq!(items[0]["aggregatedOutput"], json!("hello\n"));
assert_eq!(items[0]["exitCode"], json!(0));
}
#[test]
fn enrich_thread_history_does_not_fake_output_for_image_only_function_call_output() {
let temp_dir = tempdir().expect("创建临时目录失败");
let rollout_path = temp_dir.path().join("rollout-thread-e.jsonl");
write_rollout(
&rollout_path,
&[
json!({
"type": "turn_context",
"payload": {
"turn_id": "turn-e"
}
}),
json!({
"type": "response_item",
"payload": {
"type": "function_call",
"name": "exec_command",
"call_id": "call-e",
"arguments": "{\"cmd\":\"git status --short\",\"workdir\":\"/tmp/work\"}"
}
}),
json!({
"type": "response_item",
"payload": {
"type": "function_call_output",
"call_id": "call-e",
"output": [
{
"type": "input_image",
"image_url": "data:image/png;base64,abc"
}
]
}
}),
],
);
let mut thread = json!({
"id": "thread-e",
"path": rollout_path.display().to_string(),
"turns": [{
"id": "turn-e",
"items": [{
"id": "call-e",
"type": "commandExecution",
"status": "completed",
"command": "git status --short",
"commandActions": [{"type": "run", "command": "git status --short"}],
"aggregatedOutput": null
}]
}]
});
enrich_thread_history_from_rollout(&mut thread, None);
let item = &thread["turns"][0]["items"][0];
assert_eq!(item["id"], json!("call-e"));
assert!(item.get("aggregatedOutput").is_none() || item["aggregatedOutput"].is_null());
assert!(item.get("exitCode").is_none() || item["exitCode"].is_null());
}
fn write_rollout(path: &Path, lines: &[serde_json::Value]) {
let content = lines
.iter()
.map(|line| serde_json::to_string(line).expect("序列化 rollout 行失败"))
.collect::<Vec<_>>()
.join("\n");
fs::write(path, format!("{content}\n")).expect("写入 rollout 文件失败");
}