重构: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:
@@ -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(), ¶ms["item"], 7_777).expect("item");
|
||||
let item = thread_item_from_value(¶ms["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()
|
||||
|
||||
Reference in New Issue
Block a user