运行态事件与历史切片共用聊天条目投影

- 新增 direct_chat_entry 深模块,把原始 response item 与 app-server item 投影成同一聊天条目
- item.started/item.completed 载荷改为完整条目,前端不再需要按 itemId 取快照
- 原始 function_call 与 function_call_output 按 callId 并成一张卡片,文本与明细统一脱敏截断
- 历史切片新增 entries 与 first_item_id,分页锚点不再依赖是否出现可显示条目
This commit is contained in:
2026-09-16 18:01:50 +08:00
parent 721e45f01b
commit 29d0cbb4df
5 changed files with 585 additions and 12 deletions
@@ -14,6 +14,7 @@ mod codex_cli;
mod codex_provider_proxy;
mod design_runtime;
mod design_tools;
mod direct_chat_entry;
mod direct_codex_attachments;
mod direct_codex_audit;
mod direct_codex_user_item;
@@ -48,6 +49,7 @@ pub(crate) use codex_cli::{
};
pub(crate) use codex_provider_proxy::*;
pub(crate) use design_runtime::*;
pub(crate) use direct_chat_entry::*;
pub(crate) use direct_codex_attachments::*;
pub(crate) use direct_codex_audit::*;
pub(crate) use direct_codex_user_item::*;
@@ -744,13 +744,17 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat
/// Project an app-server item into the small public payload carried by the
/// DirectProject event queue. Full item contents are persisted in JSONL and
/// must not be forwarded through the runtime event stream.
fn direct_thread_item_started_payload(item: &serde_json::Value) -> serde_json::Value {
serde_json::json!({
"itemType": item
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown"),
})
/// 运行态事件载荷:与历史切片同形的聊天条目;拿不到条目时给空对象。
fn direct_chat_entry_payload(
root: &std::path::Path,
item: &serde_json::Value,
turn_id: Option<&str>,
completed: bool,
now_ms: u64,
) -> serde_json::Value {
direct_chat_entry_from_item(root, item, turn_id, completed, now_ms)
.and_then(|entry| serde_json::to_value(entry).ok())
.unwrap_or_else(|| serde_json::json!({}))
}
fn direct_thread_item_id(item: &serde_json::Value) -> Option<String> {
@@ -3053,6 +3057,13 @@ impl CodexAppServerConnection {
"rawResponseItem/completed 缺少 item".to_string(),
));
}
let entry_payload = direct_chat_entry_payload(
history_root,
&item,
Some(turn_id.as_str()),
true,
direct_tool_call_now_ms(),
);
let history_root = history_root.to_path_buf();
let history_item = item.clone();
tokio::task::spawn_blocking(move || {
@@ -3074,7 +3085,7 @@ impl CodexAppServerConnection {
turn_id: turn_id.clone(),
item_id,
call_id: direct_thread_item_call_id(&item),
payload: serde_json::json!({}),
payload: entry_payload,
},
);
}
@@ -3216,7 +3227,13 @@ impl CodexAppServerConnection {
turn_id: turn_id.clone(),
item_id,
call_id: None,
payload: direct_thread_item_started_payload(item),
payload: direct_chat_entry_payload(
history_root,
item,
Some(turn_id.as_str()),
completed,
direct_tool_call_now_ms(),
),
},
);
}
@@ -4527,10 +4544,27 @@ mod tests {
"result": { "content": "large output" }
});
assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1"));
assert_eq!(
direct_thread_item_started_payload(&item),
serde_json::json!({ "itemType": "mcpToolCall" })
// 运行态事件必须自足:载荷是投影后的聊天条目,前端不需要再按 itemId 取快照。
let payload = direct_chat_entry_payload(
std::path::Path::new("."),
&item,
Some("turn-1"),
false,
1000,
);
assert_eq!(
payload.get("kind").and_then(serde_json::Value::as_str),
Some("tool")
);
assert_eq!(
payload.get("itemId").and_then(serde_json::Value::as_str),
Some("item-1")
);
assert_eq!(
payload.get("turnId").and_then(serde_json::Value::as_str),
Some("turn-1")
);
assert!(payload.get("toolCall").is_some(), "{payload}");
}
#[test]
File diff suppressed because it is too large Load Diff
@@ -10,6 +10,8 @@ use std::collections::{HashMap, HashSet};
use std::sync::{Mutex, OnceLock};
use uuid::Uuid;
use crate::agent::DirectChatEntry;
const DEFAULT_MAX_EVENTS: usize = 8_192;
const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024;
@@ -71,6 +73,10 @@ pub(crate) struct DirectThreadHistorySlice {
pub(crate) items: Vec<Value>,
pub(crate) has_more: bool,
pub(crate) item_timestamps: std::collections::BTreeMap<String, u64>,
/// 与运行态事件同形的聊天条目(顺序即文件顺序)。
pub(crate) entries: Vec<DirectChatEntry>,
/// 本次切片的原始条目锚点:无论切片里有没有可显示条目,分页都要靠它继续向前。
pub(crate) first_item_id: Option<String>,
}
#[derive(Clone, Debug)]
@@ -5383,10 +5383,18 @@ pub(crate) async fn read_direct_project_history_slice(
before_item_id.as_deref(),
limit.unwrap_or(20),
)?;
let entries = direct_chat_entries_from_history(root, &items);
let first_item_id = items
.first()
.and_then(|item| item.get("id"))
.and_then(serde_json::Value::as_str)
.map(str::to_string);
Ok(DirectThreadHistorySlice {
items,
has_more,
item_timestamps,
entries,
first_item_id,
})
})
.await