过程卡工具文案改为用户语义

- MCP 工具按写入、浏览、读取素材、导入、生成图片等用户动作显示文案
- 写入工具与文件变更统一显示正在写入文件和项目相对路径
- 命令显示具体命令,验证类命令显示正在验证游戏
- 未知工具只显示正在调用工具,不暴露内部工具名和未审核参数
- 工具详情限制路径长度并拒绝绝对路径与上跳路径
- stream=false 时继续保留新的具体工具执行文案
- 补充工具映射、安全边界、命令分类与过程卡心跳回归
- 决策记录同步工具语义文案规则
This commit is contained in:
2026-09-02 19:44:57 +08:00
parent 73f5c94de4
commit beae1dfe5f
5 changed files with 308 additions and 41 deletions
@@ -544,15 +544,10 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if item_type == "commandExecution" {
let command = item
if item
.get("command")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase();
if command.contains("game.static_smoke")
|| command.contains("preview.validate")
|| command.contains("verify")
|| command.contains("test")
.is_some_and(direct_codex_command_is_game_verification)
{
return "game-verify";
}
@@ -580,6 +575,108 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat
direct_codex_safe_activity_for_item(item_type)
}
fn direct_codex_command_is_game_verification(command: &str) -> bool {
let command = command.to_ascii_lowercase();
command.contains("game.static_smoke")
|| command.contains("preview.validate")
|| command.contains("verify")
|| command.contains("test")
}
fn direct_codex_bounded_detail(value: &str, max_chars: usize) -> Option<String> {
let value = value.split_whitespace().collect::<Vec<_>>().join(" ");
if value.is_empty() {
return None;
}
Some(value.chars().take(max_chars).collect())
}
fn direct_codex_project_path_detail(item: &serde_json::Value, pointer: &str) -> Option<String> {
let value = item.pointer(pointer)?.as_str()?;
if value.is_empty() {
return None;
}
let path = std::path::Path::new(value);
if path.is_absolute()
|| path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return None;
}
direct_codex_bounded_detail(value, 120)
}
fn direct_codex_mcp_tool_intermediate_text(item: &serde_json::Value) -> String {
let arguments = item
.get("arguments")
.cloned()
.unwrap_or(serde_json::Value::Null);
let optional_path = |pointer: &str| {
direct_codex_project_path_detail(
&serde_json::json!({ "arguments": arguments.clone(), "tool": "" }),
pointer,
)
};
let optional_text = |field: &str, max_chars: usize| {
arguments
.get(field)
.and_then(serde_json::Value::as_str)
.and_then(|value| direct_codex_bounded_detail(value, max_chars))
};
match item.get("tool").and_then(serde_json::Value::as_str) {
Some("taonier_prepare_game_art") => "正在准备美术素材".to_string(),
Some("agc_generate_image") => match direct_codex_project_path_detail(
&serde_json::json!({ "arguments": arguments.clone() }),
"/arguments/outputPath",
) {
Some(path) => format!("正在生成图片:{path}"),
None => "正在生成图片".to_string(),
},
Some("agc_edit_image") => match optional_text("assetName", 80) {
Some(name) => format!("正在编辑图片:{name}"),
None => "正在编辑图片".to_string(),
},
Some("agc_list_registered_assets") => match optional_text("query", 80) {
Some(query) => format!("正在读取素材库:{query}"),
None => "正在读取素材库".to_string(),
},
Some("agc_list_project_files") => {
match optional_path("/arguments/path").or_else(|| optional_text("query", 80)) {
Some(detail) => format!("正在浏览项目文件:{detail}"),
None => "正在浏览项目文件".to_string(),
}
}
Some("agc_write_file") => match optional_path("/arguments/path") {
Some(path) => format!("正在写入文件:{path}"),
None => "正在写入文件".to_string(),
},
Some("agc_list_account_assets") => match optional_text("query", 80) {
Some(query) => format!("正在读取账户素材:{query}"),
None => "正在读取账户素材".to_string(),
},
Some("agc_import_account_assets") => {
let local_path_count = arguments
.get("localPaths")
.and_then(serde_json::Value::as_array)
.map(Vec::len)
.filter(|count| *count > 0);
match local_path_count {
Some(count) => format!("正在导入素材:{count}"),
None => "正在导入素材".to_string(),
}
}
Some("agc_create_or_derive_resource") => "正在创建素材资源".to_string(),
Some("agc_remove_background") => "正在去除图片背景".to_string(),
Some("agc_browser_playtest") => "正在试玩游戏".to_string(),
Some("agc_web_search") => match optional_text("query", 80) {
Some(query) => format!("正在搜索资料:{query}"),
None => "正在搜索资料".to_string(),
},
_ => "正在调用工具".to_string(),
}
}
/// Project a started Codex item into a short user-visible progress line.
/// Codex app-server 0.147/0.149 only pushes structural item/started events
/// (with the concrete command/tool/path) while tools run; it does not push
@@ -592,28 +689,22 @@ fn direct_codex_item_intermediate_text(item: &serde_json::Value) -> Option<Strin
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let text = match item_type {
"mcpToolCall" => {
let tool = item
.get("tool")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or("工具");
match item
.pointer("/arguments/path")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
{
Some(path) => format!("正在调用 {tool}{path}"),
None => format!("正在调用 {tool}"),
}
}
"mcpToolCall" => direct_codex_mcp_tool_intermediate_text(item),
"commandExecution" => {
let command = item
.get("command")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty());
match command {
Some(command) => format!("正在执行:{command}"),
Some(command) => {
let command = direct_codex_bounded_detail(command, 120)
.unwrap_or_else(|| "命令".to_string());
if direct_codex_command_is_game_verification(&command) {
format!("正在验证游戏:{command}")
} else {
format!("正在执行命令:{command}")
}
}
None => "正在执行命令".to_string(),
}
}
@@ -624,11 +715,11 @@ fn direct_codex_item_intermediate_text(item: &serde_json::Value) -> Option<Strin
.or_else(|| item.get("path").and_then(serde_json::Value::as_str))
.filter(|value| !value.trim().is_empty());
match path {
Some(path) => format!("正在修改{path}"),
None => "正在修改项目文件".to_string(),
Some(path) => format!("正在写入文件{path}"),
None => "正在写入文件".to_string(),
}
}
"webSearch" => "正在联网搜索".to_string(),
"webSearch" => "正在搜索资料".to_string(),
"contextCompaction" => "正在整理上下文".to_string(),
_ => return None,
};
@@ -3584,7 +3675,7 @@ mod tests {
});
assert_eq!(
direct_codex_item_intermediate_text(&mcp).as_deref(),
Some("正在调用 agc_write_filegame/index.html")
Some("正在写入文件game/index.html")
);
let command = serde_json::json!({
@@ -3593,7 +3684,7 @@ mod tests {
});
assert_eq!(
direct_codex_item_intermediate_text(&command).as_deref(),
Some("正在执行:npm run build")
Some("正在执行命令npm run build")
);
assert_eq!(
direct_codex_safe_activity_for_item_value(&serde_json::json!({
@@ -3602,6 +3693,14 @@ mod tests {
})),
"game-verify"
);
assert_eq!(
direct_codex_item_intermediate_text(&serde_json::json!({
"type": "commandExecution",
"command": "preview.validate"
}))
.as_deref(),
Some("正在验证游戏:preview.validate")
);
let file_change = serde_json::json!({
"type": "fileChange",
@@ -3609,13 +3708,121 @@ mod tests {
});
assert_eq!(
direct_codex_item_intermediate_text(&file_change).as_deref(),
Some("正在修改game/player.gd")
Some("正在写入文件game/player.gd")
);
let reasoning = serde_json::json!({ "type": "reasoning" });
assert_eq!(direct_codex_item_intermediate_text(&reasoning), None);
}
#[test]
fn direct_mcp_tool_details_use_user_facing_work_labels() {
let detail = |tool: &str, arguments: serde_json::Value| {
direct_codex_item_intermediate_text(&serde_json::json!({
"type": "mcpToolCall",
"tool": tool,
"arguments": arguments
}))
.expect("mcp tool detail")
};
assert_eq!(
detail("taonier_prepare_game_art", serde_json::json!({})),
"正在准备美术素材"
);
assert_eq!(
detail(
"agc_generate_image",
serde_json::json!({ "outputPath": "assets/hero.png" })
),
"正在生成图片:assets/hero.png"
);
assert_eq!(
detail(
"agc_edit_image",
serde_json::json!({ "assetName": "主角头像" })
),
"正在编辑图片:主角头像"
);
assert_eq!(
detail(
"agc_list_registered_assets",
serde_json::json!({ "query": "hero" })
),
"正在读取素材库:hero"
);
assert_eq!(
detail(
"agc_list_project_files",
serde_json::json!({ "path": "assets" })
),
"正在浏览项目文件:assets"
);
assert_eq!(
detail("agc_list_account_assets", serde_json::json!({})),
"正在读取账户素材"
);
assert_eq!(
detail(
"agc_import_account_assets",
serde_json::json!({ "localPaths": ["assets/a.png", "assets/b.png"] })
),
"正在导入素材:2 项"
);
assert_eq!(
detail("agc_create_or_derive_resource", serde_json::json!({})),
"正在创建素材资源"
);
assert_eq!(
detail("agc_remove_background", serde_json::json!({})),
"正在去除图片背景"
);
assert_eq!(
detail("agc_browser_playtest", serde_json::json!({})),
"正在试玩游戏"
);
assert_eq!(
detail(
"agc_web_search",
serde_json::json!({ "query": "tauri webview" })
),
"正在搜索资料:tauri webview"
);
}
#[test]
fn direct_mcp_tool_details_do_not_expose_unreviewed_names_or_unsafe_paths() {
let unknown = direct_codex_item_intermediate_text(&serde_json::json!({
"type": "mcpToolCall",
"tool": "SECRET_TOOL_/private/project",
"arguments": { "prompt": "Bearer secret-token /private/project" }
}))
.expect("safe unknown-tool detail");
assert_eq!(unknown, "正在调用工具");
assert!(!unknown.contains("SECRET_TOOL"));
assert!(!unknown.contains("/private/project"));
assert!(!unknown.contains("secret-token"));
assert_eq!(
direct_codex_item_intermediate_text(&serde_json::json!({
"type": "mcpToolCall",
"tool": "agc_write_file",
"arguments": { "path": "C:/Users/private/secret.txt" }
}))
.as_deref(),
Some("正在写入文件")
);
assert_eq!(
direct_codex_item_intermediate_text(&serde_json::json!({
"type": "mcpToolCall",
"tool": "agc_write_file",
"arguments": { "path": "../outside.txt" }
}))
.as_deref(),
Some("正在写入文件")
);
}
#[test]
fn direct_preparing_notifications_emit_thinking_activity_without_raw_text() {
let reasoning = serde_json::json!({ "delta": "hidden reasoning must not leak" });
@@ -3697,12 +3697,23 @@ fn project_direct_codex_accumulated_text(
}
fn is_direct_codex_item_started_work_detail(value: &str) -> bool {
const PREFIXES: [&str; 5] = [
"正在执行",
"正在调用",
"正在修改:",
"正在联网搜索",
const PREFIXES: [&str; 16] = [
"正在写入文件",
"正在浏览项目文件",
"正在读取素材库",
"正在读取账户素材",
"正在导入素材",
"正在生成图片",
"正在编辑图片",
"正在准备美术素材",
"正在创建素材资源",
"正在去除图片背景",
"正在试玩游戏",
"正在搜索资料:",
"正在执行命令:",
"正在验证游戏:",
"正在整理上下文",
"正在调用工具",
];
PREFIXES.iter().any(|prefix| value.starts_with(prefix))
}
@@ -4696,14 +4707,15 @@ mod tests {
#[test]
fn direct_item_started_work_detail_survives_stream_disabled() {
assert!(is_direct_codex_item_started_work_detail(
"正在执行:npm run build"
"正在执行命令npm run build"
));
assert!(is_direct_codex_item_started_work_detail(
"正在调用 agc_read_filegame/index.html"
"正在浏览项目文件game/index.html"
));
assert!(is_direct_codex_item_started_work_detail(
"正在修改game/player.gd"
"正在写入文件game/player.gd"
));
assert!(is_direct_codex_item_started_work_detail("正在调用工具"));
assert!(!is_direct_codex_item_started_work_detail("阶段性回复"));
assert!(!is_direct_codex_item_started_work_detail(
"hidden reasoning must not leak"
+20 -1
View File
@@ -319,6 +319,23 @@ function directCodexActivityDetail(
}
}
const DIRECT_CODEX_SPECIFIC_WORK_DETAIL_PREFIXES = [
'正在写入文件:',
'正在浏览项目文件',
'正在读取素材库',
'正在读取账户素材',
'正在导入素材',
'正在生成图片',
'正在编辑图片',
'正在准备美术素材',
'正在创建素材资源',
'正在去除图片背景',
'正在试玩游戏',
'正在搜索资料:',
'正在执行命令:',
'正在验证游戏:',
] as const;
function directCodexProcessDetail({
accumulatedText,
activity,
@@ -348,7 +365,9 @@ function directCodexProcessDetail({
}
function isDirectCodexSpecificWorkDetail(text: string) {
return /^(?:||)/u.test(text);
return DIRECT_CODEX_SPECIFIC_WORK_DETAIL_PREFIXES.some((prefix) =>
text.startsWith(prefix),
);
}
function directCodexTransientReplyText({
@@ -5755,7 +5755,7 @@ export function registerProjectSupervisorSurfaceTests() {
sequence: 5,
status: 'running',
activity: 'command-exec',
accumulatedText: `正在执行:npm run smoke${'heartbeat 不得覆盖具体命令。'.repeat(12)}`,
accumulatedText: `正在执行命令npm run smoke${'heartbeat 不得覆盖具体命令。'.repeat(12)}`,
updatedAt: 5000,
},
});
@@ -5774,6 +5774,35 @@ export function registerProjectSupervisorSurfaceTests() {
within(streamingProcessCard).getByText(/heartbeat /u),
).not.toBeNull();
expect(within(streamingProcessCard).queryByText('正在执行命令')).toBeNull();
await act(async () => {
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 7,
status: 'running',
activity: 'controlled-tool',
accumulatedText: `正在写入文件:game/index.html${'写入详情需要保持展开状态。'.repeat(12)}`,
updatedAt: 5200,
},
});
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 8,
status: 'running',
activity: 'controlled-tool',
updatedAt: 5300,
},
});
});
expect(
within(streamingProcessCard).getByText(
/game\/index\.html/u,
),
).not.toBeNull();
expect(within(streamingProcessCard).queryByText('正在调用工具')).toBeNull();
expect(
within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent,
).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解');
@@ -18,9 +18,9 @@
## 2026-09-02 Direct 过程卡按回合阶段状态驱动
- 背景:DirectProject 结果卡把工具活动词、中间文本和真实回复增量都当成“实时回复”,标题随最近一次事件跳动;上游常整包返回正文时还叠加合成打字机,用户看到的是行为名而非当前阶段。
- 决策:Direct 过程卡顶部标题只由 `GameCreatorDirectTurnUpdateStatus` 决定(accepted=需求已接收 / running=任务执行中 / streaming=回复生成中 / finalizing=结果整理中 / completed=回复已生成 / failed=处理失败),小字只展示当前正在执行的具体内容并统一加“正在”前缀;真实回复增量(AccumulatedText)才标记 streaming,计划、推理、工具输出与 Activity 一律 running。生成中的累计回复直接作为 assistant 消息气泡在会话列表中原位更新,不再拼进过程卡;进入 finalizing / completed 时保留完整累计回复直到正式消息接管,失败时清除未完成正文。移除合成打字机回放;工具说明/中间文本不再触发 streaming。计划/推理通知收敛为 `preparing` 活动并在界面显示“正在思考中”,原始推理/计划正文不进入 UI,思考期的心跳按 1.2s 限流。命令/文件/工具执行细节(例如“正在执行:<命令>”)与回复流解耦,`stream=false` 时仍展示在过程卡;同一 command-exec 后续无正文的活动心跳不得用通用“正在执行命令”覆盖已展示的具体命令。展开/收起是同一 `project + clientTurnId` 内的持久状态,内容更新不重置,切换新回合才收起;展开详情的滚动条轨道和角落保持透明。
- 决策:Direct 过程卡顶部标题只由 `GameCreatorDirectTurnUpdateStatus` 决定(accepted=需求已接收 / running=任务执行中 / streaming=回复生成中 / finalizing=结果整理中 / completed=回复已生成 / failed=处理失败),小字只展示当前正在执行的具体内容并统一加“正在”前缀;真实回复增量(AccumulatedText)才标记 streaming,计划、推理、工具输出与 Activity 一律 running。生成中的累计回复直接作为 assistant 消息气泡在会话列表中原位更新,不再拼进过程卡;进入 finalizing / completed 时保留完整累计回复直到正式消息接管,失败时清除未完成正文。移除合成打字机回放;工具说明/中间文本不再触发 streaming。计划/推理通知收敛为 `preparing` 活动并在界面显示“正在思考中”,原始推理/计划正文不进入 UI,思考期的心跳按 1.2s 限流。命令/文件/工具执行细节与回复流解耦,`stream=false` 时仍展示在过程卡;MCP 工具按用户语义显示(例如 `agc_write_file` 为“正在写入文件:<项目相对路径>”、图片/素材/搜索/试玩分别显示生成、导入、搜索、试玩等动作),未知工具只显示“正在调用工具”不暴露内部工具名;命令显示“正在执行命令:<命令>”,验证类命令显示“正在验证游戏:<命令>”。同一活动后续无正文的心跳不得用通用文案覆盖已展示的具体工作。展开/收起是同一 `project + clientTurnId` 内的持久状态,内容更新不重置,切换新回合才收起;展开详情的滚动条轨道和角落保持透明。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs` 的 DirectProject observer、`apps/ai-game-creator-shell/src/App.tsx` 的事件投影、`ProjectSupervisorView` 过程卡渲染与对应 AppSurface 回归。
- 验证方式:Rust 单测证明只有开启流式时的 AccumulatedText 是 streaming、preparing 通知只产生 thinking 活动词且不携带原始推理文本、执行细节在 `stream=false` 时仍保留;AppSurface 覆盖接受态、preparing 显示“正在思考中”、running 长文本展开、command-exec 心跳不覆盖具体命令、streaming 正文进入 assistant 气泡且过程卡只显示阶段、同一回合后续 running 不覆盖正文也不收起、失败后清除未完成正文、正式消息接管不重复;样式核对确认展开详情的滚动条轨道与角落透明;AGC typecheck、全量 appSurface、rustfmt、`npm run check:encoding``git diff --check` 通过。
- 验证方式:Rust 单测证明只有开启流式时的 AccumulatedText 是 streaming、preparing 通知只产生 thinking 活动词且不携带原始推理文本、执行细节在 `stream=false` 时仍保留,并覆盖全部 AGC MCP 工具语义、未知工具不泄漏、绝对路径 / 上跳路径不展示AppSurface 覆盖接受态、preparing 显示“正在思考中”、running 长文本展开、command-exec 与写文件心跳不覆盖具体工作、streaming 正文进入 assistant 气泡且过程卡只显示阶段、同一回合后续 running 不覆盖正文也不收起、失败后清除未完成正文、正式消息接管不重复;样式核对确认展开详情的滚动条轨道与角落透明;AGC typecheck、全量 appSurface、rustfmt、`npm run check:encoding``git diff --check` 通过。
- 关联文档:`docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`、分支 `feat/agc-llm-router-official-chain`
---