重构:ThreadManager 停止对线程条目脱敏与限长,改为原样透传

- 删除 wire/items.rs 的 bounded / detail_text / sanitize_detail_text / relativize_project_root_paths / thread_delta_text 与三个字符上限常量
- thread_item_from_value / thread_items_from_history / 运行态条目 / 流式增量签名去掉 root,字段原样透传,脱敏交给事件进前端状态时统一做
- dispatch.rs 写历史失败不再预脱敏,原文交给 classify 投影成失败载荷时统一脱敏 + 截断
- 用例改为"原样透传"口径,删除已无对象的流式脱敏用例
- 失败载荷(错误文案)的脱敏保留
This commit is contained in:
2026-10-03 11:53:01 +08:00
parent 11e98a3cae
commit 68cb41e332
6 changed files with 67 additions and 291 deletions
@@ -794,14 +794,11 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat
direct_codex_safe_activity_for_item(item_type)
}
/// 运行态事件载荷:与历史切片同形的脱敏原始条目;拿不到身份或类型就整条跳过。
/// 运行态事件载荷:与历史切片同形的原始条目;拿不到身份或类型就整条跳过。
///
/// 这里不生成工具卡片形状:标题、折叠摘要和可见性都是前端投影的职责。
fn direct_thread_event_item(
root: &std::path::Path,
item: &serde_json::Value,
) -> Option<ThreadItem> {
thread_item_from_value(root, item, now_ms())
/// 这里不生成工具卡片形状:标题、折叠摘要和可见性都是前端投影的职责;脱敏也在前端做。
fn direct_thread_event_item(item: &serde_json::Value) -> Option<ThreadItem> {
thread_item_from_value(item, now_ms())
}
/// 运行态条目投影:Codex 回显的用户消息整条跳过。
@@ -813,14 +810,11 @@ fn direct_thread_event_item(
/// `append_direct_project_history_item_at` 过滤,运行态必须用同一口径过滤,否则前端会多
/// 渲染出两条没有历史对应的孤儿用户气泡,各自开出一个耗时 0 秒的假回合,直到重进页面才
/// 恢复(那时读的是同一份已过滤的 `project.jsonl`)。
fn direct_thread_visible_item(
root: &std::path::Path,
item: &serde_json::Value,
) -> Option<ThreadItem> {
fn direct_thread_visible_item(item: &serde_json::Value) -> Option<ThreadItem> {
if is_direct_project_codex_user_item(item) {
return None;
}
direct_thread_event_item(root, item)
direct_thread_event_item(item)
}
/// 下发本轮的开口用户条目:放行之后、起 codex 之前的第一条运行态条目。
@@ -837,7 +831,7 @@ pub(crate) fn emit_direct_thread_user_item(
root: &std::path::Path,
item: &serde_json::Value,
) -> Option<ThreadItem> {
let entry_item = direct_thread_event_item(root, item)?;
let entry_item = direct_thread_event_item(item)?;
// 条目时间是落盘 / 观测时间。前端乐观用户气泡已删(ADR「DirectProject命令入队化」),
// 所以这就是界面显示这条用户消息的唯一时间口径:它晚于用户按下发送,但不再有第二份更早的时间。
let at = entry_item.at();
@@ -1185,14 +1179,14 @@ fn direct_codex_reasoning_delta_event(
})
}
/// DirectProject 的流式正文在进入 Thread Manager 前就完成脱敏;前端不得接触原始增量。
/// DirectProject 的流式正文原样进事件流;脱敏在事件进前端状态时统一做(前端
/// `directThreadSanitize`)。
fn direct_codex_thread_delta_event(
root: &std::path::Path,
item_id: String,
kind: ThreadDeltaKind,
delta: &str,
) -> ThreadEvent {
ThreadEvent::item_delta(item_id, kind, thread_delta_text(root, delta))
ThreadEvent::item_delta(item_id, kind, delta.to_string())
}
/// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。
///
@@ -3712,7 +3706,6 @@ impl CodexAppServerConnection {
// 事件自足:增量自带 item 身份与正文类别(正文 / 思考),
// 前端 reducer 不允许靠猜 itemId 的来源决定 kind。
direct_codex_thread_delta_event(
history_root,
item_id.clone(),
ThreadDeltaKind::Message,
&delta,
@@ -3745,7 +3738,6 @@ impl CodexAppServerConnection {
append_thread_event(
&direct_thread_id,
direct_codex_thread_delta_event(
history_root,
item_id,
ThreadDeltaKind::Reasoning,
&delta,
@@ -3765,7 +3757,7 @@ impl CodexAppServerConnection {
"rawResponseItem/completed 缺少 item".to_string(),
)));
}
let entry_item = direct_thread_visible_item(history_root, &item);
let entry_item = direct_thread_visible_item(&item);
let history_root = history_root.to_path_buf();
let history_item = item.clone();
tokio::task::spawn_blocking(move || {
@@ -3882,9 +3874,7 @@ impl CodexAppServerConnection {
&& self.inner.workspace_mode
== CodexAppServerWorkspaceMode::DirectProject
{
if let Some(entry_item) =
direct_thread_visible_item(history_root, item)
{
if let Some(entry_item) = direct_thread_visible_item(item) {
// `item/started` 的通知层带 `startedAtMs`:这是工具真正
// 开始的阶段时间,优先于条目展示时间与宿主钟。
append_thread_event(
@@ -5752,8 +5742,8 @@ mod tests {
"arguments": { "path": "game/index.html", "token": "secret" },
"result": { "content": "large output" }
});
// 运行态事件必须自足:载荷是脱敏原始条目,前端不需要再按 itemId 取快照。
let projected = direct_thread_event_item(std::path::Path::new("."), &item).expect("item");
// 运行态事件必须自足:载荷是原始条目,前端不需要再按 itemId 取快照。
let projected = direct_thread_event_item(&item).expect("item");
assert_eq!(projected.item_id(), "item-1");
let payload = serde_json::to_value(&projected).expect("payload");
assert_eq!(
@@ -5769,12 +5759,11 @@ mod tests {
assert!(payload.get("title").is_none(), "{payload}");
assert!(payload.get("summary").is_none(), "{payload}");
assert!(payload.get("kind").is_none(), "{payload}");
// 参数里的密钥不得随载荷下发(脱敏占位符可以保留,明文不行)。
let arguments = payload
.get("arguments")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
assert!(!arguments.contains("\"secret\""), "{payload}");
// 脱敏不在这一层做:原始参数原样下发,密钥由前端"事件进状态"时统一抹掉。
assert_eq!(
payload.get("arguments").and_then(serde_json::Value::as_str),
Some("{\n \"path\": \"game/index.html\",\n \"token\": \"secret\"\n}")
);
}
#[test]
@@ -6037,56 +6026,18 @@ mod tests {
}
#[test]
fn direct_thread_message_and_reasoning_deltas_are_sanitized_before_enqueue() {
let root = std::path::Path::new("/workspace/direct-project");
fn direct_thread_message_and_reasoning_deltas_are_passed_through_verbatim() {
let raw = "key=sk-abcdefghijklmnop project=/workspace/direct-project/assets/a.png private=/root/secret.txt";
for kind in [ThreadDeltaKind::Message, ThreadDeltaKind::Reasoning] {
let event = direct_codex_thread_delta_event(root, "item-1".to_string(), kind, raw);
let event = direct_codex_thread_delta_event("item-1".to_string(), kind, raw);
let wire = serde_json::to_string(&event).expect("serialize direct thread delta");
assert!(
!wire.contains("sk-abcdefghijklmnop"),
"不得泄漏密钥:{wire}"
);
assert!(
!wire.contains("/root/secret.txt"),
"不得泄漏绝对路径:{wire}"
);
assert!(
wire.contains("assets/a.png"),
"项目内绝对路径应归一成相对路径:{wire}"
wire.contains(raw),
"增量原样进事件流,脱敏由前端统一做:{wire}"
);
}
}
/// 流式脱敏必须保留增量自带的换行:前端把每个 `item.delta` 的脱敏结果**直接拼接**成一条
/// 消息再交给 Markdown 渲染,所以「逐段脱敏」必须与「整段脱敏」同形,不能吃掉段尾换行。
///
/// 回归背景:`sanitize_error_context` 曾用 `lines()` + `join("\n")` 往返,把以换行结尾的段
/// 的末尾换行吃掉。真实会话里段落、列表项和表格行会因此并进同一行(表头、`|---|---|` 与
/// 数据行挤成一段正文),整条汇报的 Markdown 结构——尤其表格——直接失效。
#[test]
fn direct_thread_delta_sanitization_preserves_line_breaks() {
let root = std::path::Path::new("/workspace/direct-project");
let chunks = [
"共 4 项验收要求。\n",
"\n",
"| 素材 | 用途 |\n",
"|---|---|\n",
"| 飞鸟角色 | 玩家角色 |\n",
];
let streamed: String = chunks
.iter()
.map(|chunk| thread_delta_text(root, chunk))
.collect();
let whole = chunks.concat();
assert_eq!(streamed, whole, "逐段脱敏不得吃掉段尾换行");
assert_eq!(
thread_delta_text(root, &whole),
streamed,
"逐段脱敏与整段脱敏必须同形"
);
}
/// 判据:读取器与单测共用同一个分类函数,这些分支的行为被钉在这里。
///
/// 参数:method / params / 正文候选 / 安全活动类别 / turnId。
@@ -625,7 +625,7 @@ const ERROR_REDACTED_KEY: &str = "[redacted-sensitive-field]";
/// information whenever a safe error line mentions a credential field.
///
/// 逐行脱敏,但**保留每个 chunk 末尾的换行**:本函数会被流式增量逐段调用
/// (`thread_delta_text` → 前端把各段拼成一条消息再交给 Markdown 渲染)。用
/// (脱敏后的各段由前端拼成一条消息再交给 Markdown 渲染)。用
/// `lines()` + `join("\n")` 会把「以换行结尾的段」的末尾换行吃掉,拼接后段落、列表项和表格行
/// 会并进同一行,整条消息的 Markdown 结构(尤其表格)就作废了。
pub(crate) fn sanitize_error_context(value: &str) -> String {
@@ -20,9 +20,9 @@ use crate::agent::PendingTurn;
use crate::agent::{
append_direct_project_user_message_at, claim_pending_turn, complete_turn_if_reserved,
direct_codex_user_item_to_prompt, now_ms, record_direct_codex_failure,
redact_agent_runtime_error, run_direct_game_creator_turn_at_with_creation_type_and_emitter,
thread_id_for_project, DirectGameCreatorTurnUpdateEmitter, DispatchedTurn, FailureStage,
TurnCompletion, TurnError, TurnErrorClassified,
run_direct_game_creator_turn_at_with_creation_type_and_emitter, thread_id_for_project,
DirectGameCreatorTurnUpdateEmitter, DispatchedTurn, FailureStage, TurnCompletion, TurnError,
TurnErrorClassified,
};
/// 一次放行的占用。持有它就代表这一轮还没收口。
@@ -233,11 +233,8 @@ async fn run_dispatched_direct_turn(
if let Err(error) = append_direct_project_user_message_at(&root, &canonical_user_item) {
// 不继续起整轮:历史是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口用户
// 消息的助手回复,而且失败会被静默掉。
let failure = TurnError::environment_not_ready(redact_agent_runtime_error(
&root,
&format!("写入本项目对话历史失败:{error}"),
600,
));
// 原文不在这里脱敏:`classify` 在投影成失败载荷时统一脱敏 + 截断(错误文案的脱敏保留)。
let failure = TurnError::environment_not_ready(format!("写入本项目对话历史失败:{error}"));
finish_turn_failure(&reservation, &failure, &root);
return;
}
@@ -1,18 +1,7 @@
use crate::agent::redact_secret_tokens;
use crate::agent::sanitize_error_context;
use crate::redact_absolute_path_tokens;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::Path;
use ts_rs::TS;
/// 正文(消息 / 思考)上限。
const THREAD_TEXT_MAX_CHARS: usize = 8_000;
/// 工具明细(命令 / 参数 / 输出)上限。
const THREAD_DETAIL_MAX_CHARS: usize = 4_000;
/// 单条变更路径上限。
const THREAD_PATH_MAX_CHARS: usize = 300;
/// 一条文件变更。
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -23,7 +12,7 @@ pub(crate) struct ThreadFileChange {
pub(crate) kind: String,
}
/// 聊天视图的输入条目:一条 Codex 原始条目的脱敏投影。
/// 聊天视图的输入条目:一条 Codex 原始条目的原样投影(脱敏与限长在事件进前端状态时统一做)。
///
/// `itemType` 就是 Codex 的原始类型,逐字透传;前端按它决定投影成消息、思考还是工具卡片。
/// 未识别的类型走 [`ThreadItem::Other`],Rust 不替前端决定它是否可见。
@@ -198,20 +187,7 @@ impl ThreadRequestKind {
}
}
fn bounded(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
return value.to_string();
}
let mut truncated = value.chars().take(max_chars).collect::<String>();
truncated.push('…');
truncated
}
fn detail_text(root: &Path, value: &str) -> String {
bounded(&sanitize_detail_text(root, value), THREAD_DETAIL_MAX_CHARS)
}
/// 原始值转文本:字符串原样,其它 JSON 值序列化(调用方随后脱敏)。
/// 原始值转文本:字符串原样,其它 JSON 值序列化。脱敏不在这一层做。
fn value_text(value: &Value) -> Option<String> {
match value {
Value::Null => None,
@@ -220,9 +196,8 @@ fn value_text(value: &Value) -> Option<String> {
}
}
fn item_text(root: &Path, item: &Value) -> Option<String> {
let raw = item
.get("text")
fn item_text(item: &Value) -> Option<String> {
item.get("text")
.and_then(Value::as_str)
// 空 / 全空白的 `text` 要在**回落之前**判掉:否则 `Some("")` 会短路 `content` / `summary`
// 兜底,末尾那条非空过滤再把整条条目丢掉(message / reasoning 的正文就此消失)。
@@ -243,16 +218,7 @@ fn item_text(root: &Path, item: &Value) -> Option<String> {
}
}
None
})?;
Some(bounded(
&sanitize_detail_text(root, &raw),
THREAD_TEXT_MAX_CHARS,
))
}
/// 流式正文与完成态条目使用同一套脱敏和字符预算,避免增量文本绕过历史投影的安全边界。
pub(crate) fn thread_delta_text(root: &Path, value: &str) -> String {
bounded(&sanitize_detail_text(root, value), THREAD_TEXT_MAX_CHARS)
})
}
fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 {
@@ -349,7 +315,7 @@ pub(crate) fn thread_item_identity(item: &Value) -> Option<String> {
call_id.or(id)
}
fn item_changes(root: &Path, item: &Value) -> Vec<ThreadFileChange> {
fn item_changes(item: &Value) -> Vec<ThreadFileChange> {
item.get("changes")
.and_then(Value::as_array)
.map(|changes| {
@@ -362,11 +328,11 @@ fn item_changes(root: &Path, item: &Value) -> Vec<ThreadFileChange> {
.map(str::trim)
.filter(|path| !path.is_empty())?;
Some(ThreadFileChange {
path: bounded(&sanitize_detail_text(root, path), THREAD_PATH_MAX_CHARS),
path: path.to_string(),
kind: change
.get("kind")
.and_then(Value::as_str)
.map(|kind| bounded(kind, THREAD_DETAIL_MAX_CHARS))
.map(str::to_string)
.unwrap_or_else(|| "update".to_string()),
})
})
@@ -375,20 +341,17 @@ fn item_changes(root: &Path, item: &Value) -> Vec<ThreadFileChange> {
.unwrap_or_default()
}
fn field_text(root: &Path, item: &Value, key: &str) -> Option<String> {
item.get(key)
.and_then(value_text)
.map(|value| detail_text(root, &value))
fn field_text(item: &Value, key: &str) -> Option<String> {
item.get(key).and_then(value_text)
}
/// 把一条 Codex 原始条目投影成线上条目;拿不到身份或类型时返回 `None`。
///
/// `observed_at_ms` 只在条目自带时间缺失时兜底(运行态用当前时间,历史用文件记录时间)。
pub(crate) fn thread_item_from_value(
root: &Path,
item: &Value,
observed_at_ms: u64,
) -> Option<ThreadItem> {
///
/// 这一层**原样透传**字段,不做脱敏、也不限长:脱敏在事件进前端状态时统一做(前端
/// `directThreadSanitize`)。Thread Manager 只归一身形与身份,不替前端决定什么能看。
pub(crate) fn thread_item_from_value(item: &Value, observed_at_ms: u64) -> Option<ThreadItem> {
if !item.is_object() {
return None;
}
@@ -399,7 +362,7 @@ pub(crate) fn thread_item_from_value(
.map(str::trim)
.filter(|value| !value.is_empty())?;
let at = item_at_ms(item, observed_at_ms);
let text = item_text(root, item);
let text = item_text(item);
let role = item
.get("role")
.and_then(Value::as_str)
@@ -430,14 +393,14 @@ pub(crate) fn thread_item_from_value(
name: item
.get("name")
.and_then(Value::as_str)
.map(|name| detail_text(root, name))
.map(str::to_string)
.unwrap_or_default(),
arguments: field_text(root, item, "arguments").unwrap_or_default(),
arguments: field_text(item, "arguments").unwrap_or_default(),
at,
},
"function_call_output" => ThreadItem::FunctionCallOutput {
item_id,
output: field_text(root, item, "output").unwrap_or_default(),
output: field_text(item, "output").unwrap_or_default(),
at,
},
"commandExecution" => ThreadItem::CommandExecution {
@@ -447,21 +410,21 @@ pub(crate) fn thread_item_from_value(
.and_then(Value::as_str)
.map(str::trim)
.filter(|command| !command.is_empty())
.map(|command| detail_text(root, command))
.map(str::to_string)
.unwrap_or_default(),
output: ["aggregatedOutput", "output", "error"]
.iter()
.find_map(|key| field_text(root, item, key)),
.find_map(|key| field_text(item, key)),
status: item
.get("status")
.and_then(Value::as_str)
.map(|status| bounded(status, THREAD_DETAIL_MAX_CHARS)),
.map(str::to_string),
exit_code: item.get("exitCode").and_then(Value::as_i64),
at,
},
"fileChange" => ThreadItem::FileChange {
item_id,
changes: item_changes(root, item),
changes: item_changes(item),
at,
},
"mcpToolCall" => ThreadItem::McpToolCall {
@@ -471,33 +434,32 @@ pub(crate) fn thread_item_from_value(
.and_then(Value::as_str)
.map(str::trim)
.filter(|tool| !tool.is_empty())
.map(|tool| detail_text(root, tool))
.map(str::to_string)
.unwrap_or_default(),
arguments: field_text(root, item, "arguments").unwrap_or_default(),
arguments: field_text(item, "arguments").unwrap_or_default(),
output: ["result", "error"]
.iter()
.find_map(|key| field_text(root, item, key)),
.find_map(|key| field_text(item, key)),
status: item
.get("status")
.and_then(Value::as_str)
.map(|status| bounded(status, THREAD_DETAIL_MAX_CHARS)),
.map(str::to_string),
at,
},
"webSearch" => ThreadItem::WebSearch {
item_id,
query: field_text(root, item, "query").or_else(|| {
query: field_text(item, "query").or_else(|| {
item.get("action")
.and_then(|action| action.get("query"))
.and_then(value_text)
.map(|query| detail_text(root, &query))
}),
output: field_text(root, item, "output"),
output: field_text(item, "output"),
at,
},
"contextCompaction" => ThreadItem::ContextCompaction { item_id, at },
other => ThreadItem::Other {
item_id,
raw_type: bounded(other, THREAD_DETAIL_MAX_CHARS),
raw_type: other.to_string(),
at,
},
})
@@ -507,131 +469,11 @@ pub(crate) fn thread_item_from_value(
///
/// `timestamp_of` 是文件记录时间,仅在条目自带时间缺失时兜底。
pub(crate) fn thread_items_from_history(
root: &Path,
items: &[Value],
timestamp_of: impl Fn(&Value) -> u64,
) -> Vec<ThreadItem> {
items
.iter()
.filter_map(|item| thread_item_from_value(root, item, timestamp_of(item)))
.filter_map(|item| thread_item_from_value(item, timestamp_of(item)))
.collect()
}
/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。
fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) {
let mut index = start;
let mut relative = String::new();
while index < value.len() {
let character = value[index..].chars().next().unwrap_or_default();
if matches!(character, '/' | '\\') {
if !relative.is_empty() {
relative.push('/');
}
index += character.len_utf8();
continue;
}
if character.is_whitespace()
|| matches!(
character,
'\'' | '"'
| '`'
| ','
| ';'
| '|'
| '&'
| '('
| ')'
| '['
| ']'
| '{'
| '}'
| '<'
| '>'
| ':'
)
{
break;
}
relative.push(character);
index += character.len_utf8();
}
while relative.ends_with('/') {
relative.pop();
}
(index, relative)
}
/// 把项目根目录前缀换成**项目相对路径**(`<root>/game/src/x.ts` → `game/src/x.ts`)。
///
/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成
/// `<absolute-path>`,之后就再也认不出哪些路径在项目内了。
/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。
fn relativize_project_root_paths(root: &Path, value: &str) -> String {
let root_text = root.to_string_lossy();
let root_text = root_text.trim_end_matches(['/', '\\']);
if root_text.is_empty() {
return value.to_string();
}
let mut needles = [
root_text.to_string(),
root_text.replace('\\', "/"),
root_text.replace('/', "\\"),
]
.into_iter()
.map(|needle| needle.to_ascii_lowercase())
.filter(|needle| !needle.is_empty())
.collect::<Vec<_>>();
needles.sort();
needles.dedup();
let lower = value.to_ascii_lowercase();
let mut output = String::with_capacity(value.len());
let mut cursor = 0usize;
while cursor < value.len() {
let mut hit: Option<(usize, usize)> = None;
for needle in &needles {
let mut search = cursor;
while let Some(relative) = lower[search..].find(needle.as_str()) {
let start = search + relative;
let end = start + needle.len();
let left_is_boundary = start == 0
|| lower[..start].chars().next_back().is_some_and(|character| {
!character.is_alphanumeric() && character != '_' && character != '-'
});
if left_is_boundary && value[end..].starts_with(['/', '\\']) {
if hit.is_none_or(|(best_start, _)| start < best_start) {
hit = Some((start, end));
}
break;
}
search = end;
}
}
let Some((start, end)) = hit else {
break;
};
output.push_str(&value[cursor..start]);
let (consumed, relative) = project_relative_path_segment(value, end);
if relative.is_empty() {
// 只写了项目根目录本身(没有后续路径段):按占位形状处理。
output.push_str("<absolute-path>");
} else {
output.push_str(&relative);
}
cursor = consumed;
}
output.push_str(&value[cursor..]);
output
}
/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与
/// 错误上下文脱敏。
///
/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录
/// 仍然会留下;这里先归一化路径 token,再处理密钥。
pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String {
let without_project_root = relativize_project_root_paths(root, value);
let without_absolute = redact_absolute_path_tokens(&without_project_root);
let without_secret = redact_secret_tokens(&without_absolute);
sanitize_error_context(&without_secret)
}
@@ -1,16 +1,10 @@
use super::clock::*;
use super::{failure::*, items::*, turn::*};
use serde_json::json;
use std::path::Path;
fn root() -> &'static Path {
Path::new(".")
}
#[test]
fn message_item_carries_role_text_and_turn() {
let item = thread_item_from_value(
root(),
&json!({
"id": "direct-codex:turn-1:user",
"type": "message",
@@ -34,7 +28,6 @@ fn message_item_carries_role_text_and_turn() {
#[test]
fn app_server_agent_message_defaults_to_assistant_role() {
let item = thread_item_from_value(
root(),
&json!({"id": "msg-1", "type": "agentMessage", "text": "已执行"}),
1000,
)
@@ -49,7 +42,6 @@ fn app_server_agent_message_defaults_to_assistant_role() {
fn blank_message_text_falls_back_to_content_parts() {
// 空 / 全空白的 `text` 不能短路 `content` 兜底:否则整条条目会被判成"没有正文"直接丢掉。
let item = thread_item_from_value(
root(),
&json!({
"id": "msg-blank-text",
"type": "message",
@@ -69,7 +61,6 @@ fn blank_message_text_falls_back_to_content_parts() {
#[test]
fn tool_item_identity_is_normalized_to_one_id() {
let item = thread_item_from_value(
root(),
&json!({
"id": "05dc0af1-8023-47fd-ad22-d54df2837b1b",
"call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473",
@@ -93,7 +84,6 @@ fn tool_item_identity_is_normalized_to_one_id() {
#[test]
fn command_execution_keeps_raw_status_and_exit_code() {
let item = thread_item_from_value(
root(),
&json!({
"id": "call-1",
"type": "commandExecution",
@@ -118,14 +108,15 @@ fn command_execution_keeps_raw_status_and_exit_code() {
}
#[test]
fn secrets_and_absolute_paths_are_not_leaked() {
fn item_text_is_passed_through_verbatim() {
// Thread Manager 不再脱敏 / 限长:原始正文原样下发,脱敏在事件进前端状态时统一做。
let raw = "key=sk-abcdefghijklmnop at /root/secret/x";
let item = thread_item_from_value(
root(),
&json!({
"id": "msg-1",
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "key=sk-abcdefghijklmnop at /root/secret/x"}],
"content": [{"type": "output_text", "text": raw}],
}),
0,
)
@@ -133,11 +124,7 @@ fn secrets_and_absolute_paths_are_not_leaked() {
let ThreadItem::Message { text, .. } = item else {
panic!("message item");
};
assert!(
!text.contains("sk-abcdefghijklmnop"),
"不得泄漏明文密钥:{text}"
);
assert!(!text.contains("/root/secret"), "不得泄漏绝对路径:{text}");
assert_eq!(text, raw);
}
#[test]
@@ -157,7 +144,7 @@ fn history_keeps_call_and_output_as_two_items_with_one_identity() {
"output": "assets\ngame\n",
}),
];
let projected = thread_items_from_history(root(), &items, |_| 0);
let projected = thread_items_from_history(&items, |_| 0);
assert_eq!(projected.len(), 2, "搬运层不得替前端做合并");
assert!(matches!(projected[0], ThreadItem::FunctionCall { .. }));
assert!(matches!(
@@ -172,7 +159,6 @@ fn history_keeps_call_and_output_as_two_items_with_one_identity() {
#[test]
fn unknown_item_types_are_passed_through_without_body() {
let item = thread_item_from_value(
root(),
&json!({"id": "plan-1", "type": "plan", "text": "内部计划"}),
0,
)
@@ -198,7 +184,7 @@ fn event_stage_time_is_independent_from_item_display_time() {
"startedAtMs": 1_000u64,
},
});
let item = thread_item_from_value(root(), &params["item"], 7_777).expect("item");
let item = thread_item_from_value(&params["item"], 7_777).expect("item");
// 条目展示时间不受事件级时间影响,仍按条目自己的字段推导。
assert_eq!(item.at(), 1_000);
assert_eq!(
@@ -1400,7 +1400,7 @@ pub(crate) async fn read_direct_project_history_slice(
};
let (raw_items, has_more, recorded_at_ms, first_item_id) =
read_direct_project_history_items_slice_at(root, anchor, limit.unwrap_or(20))?;
let items = thread_items_from_history(root, &raw_items, |item| {
let items = thread_items_from_history(&raw_items, |item| {
thread_item_identity(item)
.and_then(|identity| recorded_at_ms.get(&identity).copied())
.unwrap_or_default()