use serde_json::{Value, json};
use crate::bridge_protocol::{PersistedEvent, TimelineEntry};
use crate::storage::PRIMARY_RUNTIME_ID;
use super::super::timeline::{
normalize_delta_payload, timeline_entries_from_events, timeline_entries_from_thread,
timeline_entry_from_thread_item,
};
#[test]
fn timeline_entry_from_thread_item_preserves_render_metadata_for_tool_items() {
let entry = timeline_entry_from_thread_item(
PRIMARY_RUNTIME_ID,
"thread-1",
Some("turn-1"),
&json!({
"id": "item-tool-1",
"type": "mcpToolCall",
"server": "openai-docs",
"tool": "search",
"arguments": {
"q": "Responses streaming events"
},
"result": {
"hits": 3
}
}),
"thread_item",
false,
true,
)
.expect("应创建 mcpToolCall 时间线条目");
assert_eq!("mcpToolCall", entry.entry_type);
assert_eq!(Some("openai-docs / search"), entry.title.as_deref());
assert_eq!(
Some("tool"),
entry.metadata.get("renderKind").and_then(Value::as_str),
);
assert_eq!(
Some("mcpToolCall"),
entry.metadata.get("rawType").and_then(Value::as_str),
);
assert_eq!(
Some("search"),
entry
.metadata
.get("payload")
.and_then(|payload| payload.get("tool"))
.and_then(Value::as_str),
);
}
#[test]
fn timeline_entry_from_thread_item_normalizes_responses_message_roles() {
let assistant_entry = timeline_entry_from_thread_item(
PRIMARY_RUNTIME_ID,
"thread-1",
Some("turn-1"),
&json!({
"id": "item-message-1",
"type": "message",
"role": "assistant",
"phase": "final_answer",
"status": "completed",
"content": [
{
"type": "output_text",
"text": "最终回复正文"
}
]
}),
"stream_event",
false,
true,
)
.expect("应创建 assistant message 时间线条目");
assert_eq!("agentMessage", assistant_entry.entry_type);
assert_eq!(Some("最终回复"), assistant_entry.title.as_deref());
assert_eq!("最终回复正文", assistant_entry.text);
assert_eq!(
Some("message"),
assistant_entry
.metadata
.get("rawType")
.and_then(Value::as_str),
);
assert_eq!(
Some("assistant"),
assistant_entry
.metadata
.get("payload")
.and_then(|payload| payload.get("role"))
.and_then(Value::as_str),
);
let user_entry = timeline_entry_from_thread_item(
PRIMARY_RUNTIME_ID,
"thread-1",
Some("turn-1"),
&json!({
"id": "item-message-2",
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "用户输入"
}
]
}),
"thread_item",
false,
true,
)
.expect("应创建 user message 时间线条目");
assert_eq!("userMessage", user_entry.entry_type);
assert_eq!(Some("你"), user_entry.title.as_deref());
assert_eq!("用户输入", user_entry.text);
}
#[test]
fn timeline_lifecycle_metadata_tracks_waiting_streaming_and_terminal_states() {
let waiting_entry = timeline_entry_from_thread_item(
PRIMARY_RUNTIME_ID,
"thread-1",
Some("turn-1"),
&json!({
"id": "item-reason-1",
"type": "reasoning",
"status": "inProgress",
"summary": [],
"content": []
}),
"stream_event",
true,
false,
)
.expect("应创建 waiting reasoning 条目");
assert_eq!(
Some(4),
waiting_entry
.metadata
.get("schemaVersion")
.and_then(Value::as_i64),
);
assert_eq!(
Some("waiting"),
waiting_entry
.metadata
.get("lifecycle")
.and_then(|value| value.get("stage"))
.and_then(Value::as_str),
);
assert_eq!(
Some(false),
waiting_entry
.metadata
.get("lifecycle")
.and_then(|value| value.get("hasVisibleContent"))
.and_then(Value::as_bool),
);
let streaming_delta = normalize_delta_payload(
PRIMARY_RUNTIME_ID,
json!({
"threadId": "thread-1",
"turnId": "turn-1",
"itemId": "item-agent-1",
"delta": "先分析协议"
}),
"agentMessage",
Some("Codex".to_string()),
Some("inProgress".to_string()),
"item/agentMessage/delta",
json!({
"role": "assistant",
"phase": "commentary"
}),
None,
None,
);
assert_eq!(
Some("streaming"),
streaming_delta
.get("metadata")
.and_then(|value| value.get("lifecycle"))
.and_then(|value| value.get("stage"))
.and_then(Value::as_str),
);
let waiting_delta = normalize_delta_payload(
PRIMARY_RUNTIME_ID,
json!({
"threadId": "thread-1",
"turnId": "turn-1",
"itemId": "item-agent-1",
"delta": ""
}),
"reasoning",
Some("思考过程".to_string()),
Some("inProgress".to_string()),
"item/reasoning/summaryPartAdded",
json!({
"summary": [],
"content": []
}),
Some(0),
None,
);
assert_eq!(
Some("waiting"),
waiting_delta
.get("metadata")
.and_then(|value| value.get("lifecycle"))
.and_then(|value| value.get("stage"))
.and_then(Value::as_str),
);
let completed_entry = timeline_entry_from_thread_item(
PRIMARY_RUNTIME_ID,
"thread-1",
Some("turn-1"),
&json!({
"id": "item-message-3",
"type": "message",
"role": "assistant",
"phase": "final_answer",
"status": "completed",
"content": [
{
"type": "output_text",
"text": "最终内容"
}
]
}),
"thread_item",
false,
true,
)
.expect("应创建 completed message 条目");
assert_eq!(
Some("completed"),
completed_entry
.metadata
.get("lifecycle")
.and_then(|value| value.get("stage"))
.and_then(Value::as_str),
);
let failed_entry = timeline_entry_from_thread_item(
PRIMARY_RUNTIME_ID,
"thread-1",
Some("turn-1"),
&json!({
"id": "item-command-err",
"type": "commandExecution",
"status": "failed",
"command": "cargo test",
"aggregatedOutput": "boom"
}),
"thread_item",
false,
true,
)
.expect("应创建 failed command 条目");
assert_eq!(
Some("failed"),
failed_entry
.metadata
.get("lifecycle")
.and_then(|value| value.get("stage"))
.and_then(Value::as_str),
);
let declined_entry = timeline_entry_from_thread_item(
PRIMARY_RUNTIME_ID,
"thread-1",
Some("turn-1"),
&json!({
"id": "item-file-declined",
"type": "fileChange",
"status": "declined",
"changes": []
}),
"thread_item",
false,
true,
)
.expect("应创建 declined fileChange 条目");
assert_eq!(
Some("declined"),
declined_entry
.metadata
.get("lifecycle")
.and_then(|value| value.get("stage"))
.and_then(Value::as_str),
);
}
#[test]
fn timeline_entries_from_events_merge_reasoning_deltas_and_replace_plan_placeholder() {
let authoritative_plan = serde_json::to_value(TimelineEntry {
id: "turn-1:plan-1".to_string(),
runtime_id: PRIMARY_RUNTIME_ID.to_string(),
thread_id: "thread-1".to_string(),
turn_id: Some("turn-1".to_string()),
item_id: Some("plan-1".to_string()),
entry_type: "plan".to_string(),
title: Some("执行计划".to_string()),
text: "[进行中] 读取线程".to_string(),
status: Some("inProgress".to_string()),
metadata: json!({
"schemaVersion": 1,
"sourceKind": "stream_event",
"rawType": "plan",
"renderKind": "plan",
"collapseHint": {
"enabled": true,
"preferCollapsed": false,
"lineThreshold": 8,
"charThreshold": 320
},
"stream": {
"isStreaming": false,
"authoritative": true
},
"payload": {
"explanation": "先确认协议,再更新 UI。",
"plan": [
{
"step": "读取线程",
"status": "inProgress"
}
]
}
}),
})
.expect("authoritative plan 应可序列化");
let events = vec![
PersistedEvent {
seq: 1,
event_type: "plan_delta".to_string(),
runtime_id: Some(PRIMARY_RUNTIME_ID.to_string()),
thread_id: Some("thread-1".to_string()),
payload: normalize_delta_payload(
PRIMARY_RUNTIME_ID,
json!({
"threadId": "thread-1",
"turnId": "turn-1",
"itemId": "turn-plan:turn-1",
"delta": "[进行中] 读取线程"
}),
"plan",
Some("执行计划".to_string()),
Some("inProgress".to_string()),
"item/plan/delta",
json!({
"explanation": null,
"plan": [],
}),
None,
None,
),
created_at_ms: 1,
},
PersistedEvent {
seq: 2,
event_type: "turn_plan_updated".to_string(),
runtime_id: Some(PRIMARY_RUNTIME_ID.to_string()),
thread_id: Some("thread-1".to_string()),
payload: authoritative_plan,
created_at_ms: 2,
},
PersistedEvent {
seq: 3,
event_type: "reasoning_summary_part_added".to_string(),
runtime_id: Some(PRIMARY_RUNTIME_ID.to_string()),
thread_id: Some("thread-1".to_string()),
payload: normalize_delta_payload(
PRIMARY_RUNTIME_ID,
json!({
"threadId": "thread-1",
"turnId": "turn-1",
"itemId": "reason-1",
"delta": ""
}),
"reasoning",
Some("思考过程".to_string()),
Some("inProgress".to_string()),
"item/reasoning/summaryPartAdded",
json!({
"summary": [],
"content": [],
}),
Some(0),
None,
),
created_at_ms: 3,
},
PersistedEvent {
seq: 4,
event_type: "reasoning_summary_text_delta".to_string(),
runtime_id: Some(PRIMARY_RUNTIME_ID.to_string()),
thread_id: Some("thread-1".to_string()),
payload: normalize_delta_payload(
PRIMARY_RUNTIME_ID,
json!({
"threadId": "thread-1",
"turnId": "turn-1",
"itemId": "reason-1",
"delta": "先分析"
}),
"reasoning",
Some("思考过程".to_string()),
Some("inProgress".to_string()),
"item/reasoning/summaryTextDelta",
json!({
"summary": [],
"content": [],
}),
Some(0),
None,
),
created_at_ms: 4,
},
PersistedEvent {
seq: 5,
event_type: "reasoning_text_delta".to_string(),
runtime_id: Some(PRIMARY_RUNTIME_ID.to_string()),
thread_id: Some("thread-1".to_string()),
payload: normalize_delta_payload(
PRIMARY_RUNTIME_ID,
json!({
"threadId": "thread-1",
"turnId": "turn-1",
"itemId": "reason-1",
"delta": "检查流式合并"
}),
"reasoning",
Some("思考过程".to_string()),
Some("inProgress".to_string()),
"item/reasoning/textDelta",
json!({
"summary": [],
"content": [],
}),
None,
Some(0),
),
created_at_ms: 5,
},
];
let entries = timeline_entries_from_events(&events);
let plan_entries = entries
.iter()
.filter(|entry| entry.entry_type == "plan")
.collect::<Vec<_>>();
assert_eq!(1, plan_entries.len());
assert_eq!(Some("plan-1"), plan_entries[0].item_id.as_deref());
let reasoning_entry = entries
.iter()
.find(|entry| entry.item_id.as_deref() == Some("reason-1"))
.expect("应合并 reasoning 条目");
assert!(reasoning_entry.text.contains("思考摘要\n先分析"));
assert!(reasoning_entry.text.contains("思考内容\n检查流式合并"));
assert_eq!(
Some("thinking"),
reasoning_entry
.metadata
.get("renderKind")
.and_then(Value::as_str),
);
}
#[test]
fn timeline_entry_from_thread_item_classifies_command_execution_semantics_from_actions() {
let cases = vec![
(
"read",
"cat bridge/src/state/timeline.rs",
json!([{ "type": "read", "path": "bridge/src/state/timeline.rs" }]),
"explored",
Some("read"),
1_i64,
Some("bridge/src/state/timeline.rs"),
None,
),
(
"list",
"ls bridge/src/state",
json!([{ "type": "listFiles", "path": "bridge/src/state" }]),
"explored",
Some("list"),
1_i64,
Some("bridge/src/state"),
None,
),
(
"search",
"rg semantic bridge/src/state",
json!([{ "type": "search", "path": "bridge/src/state", "query": "semantic" }]),
"explored",
Some("search"),
1_i64,
Some("bridge/src/state"),
Some("semantic"),
),
(
"mixed",
"bash -lc 'cat bridge/src/state/timeline.rs && rg semantic bridge/src/state'",
json!([
{ "type": "read", "path": "bridge/src/state/timeline.rs" },
{ "type": "search", "path": "bridge/src/state", "query": "semantic" }
]),
"explored",
Some("mixed"),
2_i64,
Some("bridge/src/state/timeline.rs"),
Some("semantic"),
),
(
"unknown",
"printf 'done'",
json!([{ "type": "write", "path": "bridge/src/state/timeline.rs" }]),
"ran",
Some("run"),
0_i64,
None,
None,
),
];
for (
index,
(
_label,
command,
actions,
expected_kind,
expected_detail,
expected_target_count,
expected_primary_target,
expected_query,
),
) in cases.into_iter().enumerate()
{
let entry = timeline_entry_from_thread_item(
PRIMARY_RUNTIME_ID,
"thread-1",
Some("turn-1"),
&json!({
"id": format!("item-command-{index}"),
"type": "commandExecution",
"command": command,
"aggregatedOutput": "ok",
"commandActions": actions,
}),
"thread_item",
false,
true,
)
.expect("应创建 commandExecution 时间线条目");
assert_eq!(
Some(expected_kind),
entry
.metadata
.get("semantic")
.and_then(|value| value.get("kind"))
.and_then(Value::as_str),
);
assert_eq!(
expected_detail,
entry
.metadata
.get("semantic")
.and_then(|value| value.get("detail"))
.and_then(Value::as_str),
);
assert_eq!(
Some("high"),
entry
.metadata
.get("semantic")
.and_then(|value| value.get("confidence"))
.and_then(Value::as_str),
);
assert_eq!(
Some(command),
entry
.metadata
.get("summary")
.and_then(|value| value.get("command"))
.and_then(Value::as_str),
);
assert_eq!(
Some(expected_target_count),
entry
.metadata
.get("summary")
.and_then(|value| value.get("targets"))
.and_then(Value::as_array)
.map(|items| items.len() as i64),
);
assert_eq!(
expected_primary_target,
entry
.metadata
.get("summary")
.and_then(|value| value.get("targets"))
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(Value::as_str),
);
assert_eq!(
expected_query,
entry
.metadata
.get("summary")
.and_then(|value| value.get("query"))
.and_then(Value::as_str),
);
}
}
#[test]
fn timeline_entry_from_thread_item_summarizes_edited_and_waited_semantics() {
let file_change_entry = timeline_entry_from_thread_item(
PRIMARY_RUNTIME_ID,
"thread-1",
Some("turn-2"),
&json!({
"id": "item-file-change",
"type": "fileChange",
"changes": [
{
"path": "bridge/src/state/timeline.rs",
"kind": "modified",
"diff": "diff --git a/bridge/src/state/timeline.rs b/bridge/src/state/timeline.rs\n--- a/bridge/src/state/timeline.rs\n+++ b/bridge/src/state/timeline.rs\n@@ -1 +1,2 @@\n-old\n+new\n+extra\n"
},
{
"path": "docs/timeline.md",
"kind": "deleted",
"content": "line-1\nline-2"
}
]
}),
"thread_item",
false,
true,
)
.expect("应创建 fileChange 时间线条目");
assert_eq!(
Some("edited"),
file_change_entry
.metadata
.get("semantic")
.and_then(|value| value.get("kind"))
.and_then(Value::as_str),
);
assert_eq!(
Some("bridge/src/state/timeline.rs"),
file_change_entry
.metadata
.get("summary")
.and_then(|value| value.get("primaryPath"))
.and_then(Value::as_str),
);
assert_eq!(
Some(2),
file_change_entry
.metadata
.get("summary")
.and_then(|value| value.get("fileCount"))
.and_then(Value::as_i64),
);
assert_eq!(
Some(2),
file_change_entry
.metadata
.get("summary")
.and_then(|value| value.get("addLines"))
.and_then(Value::as_i64),
);
assert_eq!(
Some(3),
file_change_entry
.metadata
.get("summary")
.and_then(|value| value.get("removeLines"))
.and_then(Value::as_i64),
);
let waited_entry = timeline_entry_from_thread_item(
PRIMARY_RUNTIME_ID,
"thread-1",
Some("turn-2"),
&json!({
"id": "item-wait",
"type": "collabAgentToolCall",
"tool": "Wait",
"receiverThreadIds": ["thread-a", "thread-b"]
}),
"thread_item",
false,
true,
)
.expect("应创建 collabAgentToolCall 时间线条目");
assert_eq!(
Some("waited"),
waited_entry
.metadata
.get("semantic")
.and_then(|value| value.get("kind"))
.and_then(Value::as_str),
);
assert_eq!(
Some("wait_agents"),
waited_entry
.metadata
.get("semantic")
.and_then(|value| value.get("detail"))
.and_then(Value::as_str),
);
assert_eq!(
Some(2),
waited_entry
.metadata
.get("summary")
.and_then(|value| value.get("waitCount"))
.and_then(Value::as_i64),
);
}
#[test]
fn timeline_entry_from_thread_item_infers_low_confidence_semantics_for_responses_calls() {
let shell_entry = timeline_entry_from_thread_item(
PRIMARY_RUNTIME_ID,
"thread-1",
Some("turn-3"),
&json!({
"id": "item-shell",
"type": "shell_call",
"call_id": "call-shell",
"command": "bash -lc 'sed -n \"1,20p\" bridge/src/state/timeline.rs'"
}),
"thread_item",
false,
true,
)
.expect("应创建 shell_call 时间线条目");
assert_eq!(
Some("explored"),
shell_entry
.metadata
.get("semantic")
.and_then(|value| value.get("kind"))
.and_then(Value::as_str),
);
assert_eq!(
Some("read"),
shell_entry
.metadata
.get("semantic")
.and_then(|value| value.get("detail"))
.and_then(Value::as_str),
);
assert_eq!(
Some("low"),
shell_entry
.metadata
.get("semantic")
.and_then(|value| value.get("confidence"))
.and_then(Value::as_str),
);
let web_search_entry = timeline_entry_from_thread_item(
PRIMARY_RUNTIME_ID,
"thread-1",
Some("turn-3"),
&json!({
"id": "item-web-search",
"type": "web_search_call",
"call_id": "call-search",
"query": "Codex CLI TUI"
}),
"thread_item",
false,
true,
)
.expect("应创建 web_search_call 时间线条目");
assert_eq!(
Some("explored"),
web_search_entry
.metadata
.get("semantic")
.and_then(|value| value.get("kind"))
.and_then(Value::as_str),
);
assert_eq!(
Some("search"),
web_search_entry
.metadata
.get("semantic")
.and_then(|value| value.get("detail"))
.and_then(Value::as_str),
);
assert_eq!(
Some("Codex CLI TUI"),
web_search_entry
.metadata
.get("summary")
.and_then(|value| value.get("query"))
.and_then(Value::as_str),
);
let patch_entry = timeline_entry_from_thread_item(
PRIMARY_RUNTIME_ID,
"thread-1",
Some("turn-3"),
&json!({
"id": "item-patch",
"type": "apply_patch_call",
"call_id": "call-patch",
"operation": "*** Begin Patch\n*** Update File: android/app/src/main/java/com/agguy/codexmobile/app/WorkbenchTimeline.kt\n@@\n-old\n+new\n*** End Patch\n"
}),
"thread_item",
false,
true,
)
.expect("应创建 apply_patch_call 时间线条目");
assert_eq!(
Some("edited"),
patch_entry
.metadata
.get("semantic")
.and_then(|value| value.get("kind"))
.and_then(Value::as_str),
);
assert_eq!(
Some("low"),
patch_entry
.metadata
.get("semantic")
.and_then(|value| value.get("confidence"))
.and_then(Value::as_str),
);
assert_eq!(
Some("android/app/src/main/java/com/agguy/codexmobile/app/WorkbenchTimeline.kt"),
patch_entry
.metadata
.get("summary")
.and_then(|value| value.get("primaryPath"))
.and_then(Value::as_str),
);
}
#[test]
fn timeline_entries_from_thread_inherit_semantic_summary_for_output_calls() {
let thread = json!({
"id": "thread-1",
"turns": [
{
"id": "turn-4",
"items": [
{
"id": "item-shell",
"type": "shell_call",
"call_id": "call-shell",
"command": "bash -lc 'sed -n \"1,20p\" bridge/src/state/timeline.rs'",
"status": "completed"
},
{
"id": "item-shell-output",
"type": "shell_call_output",
"call_id": "call-shell",
"output": "line-1\nline-2",
"status": "completed"
},
{
"id": "item-patch",
"type": "apply_patch_call",
"call_id": "call-patch",
"operation": "*** Begin Patch\n*** Update File: docs/timeline.md\n@@\n-old\n+new\n*** End Patch\n",
"status": "completed"
},
{
"id": "item-patch-output",
"type": "apply_patch_call_output",
"call_id": "call-patch",
"output": "*** Begin Patch\n*** Update File: docs/timeline.md\n@@\n-old\n+new\n*** End Patch\n",
"status": "completed"
}
]
}
]
});
let entries = timeline_entries_from_thread(PRIMARY_RUNTIME_ID, &thread)
.expect("应从 thread 构建时间线条目");
let shell_output = entries
.iter()
.find(|entry| entry.entry_type == "shell_call_output")
.expect("应存在 shell_call_output");
assert_eq!(
Some("explored"),
shell_output
.metadata
.get("semantic")
.and_then(|value| value.get("kind"))
.and_then(Value::as_str),
);
assert_eq!(
Some("output"),
shell_output
.metadata
.get("semantic")
.and_then(|value| value.get("role"))
.and_then(Value::as_str),
);
assert_eq!(
Some("bash -lc 'sed -n \"1,20p\" bridge/src/state/timeline.rs'"),
shell_output
.metadata
.get("summary")
.and_then(|value| value.get("command"))
.and_then(Value::as_str),
);
let patch_output = entries
.iter()
.find(|entry| entry.entry_type == "apply_patch_call_output")
.expect("应存在 apply_patch_call_output");
assert_eq!(
Some("edited"),
patch_output
.metadata
.get("semantic")
.and_then(|value| value.get("kind"))
.and_then(Value::as_str),
);
assert_eq!(
Some("output"),
patch_output
.metadata
.get("semantic")
.and_then(|value| value.get("role"))
.and_then(Value::as_str),
);
assert_eq!(
Some("docs/timeline.md"),
patch_output
.metadata
.get("summary")
.and_then(|value| value.get("primaryPath"))
.and_then(Value::as_str),
);
}