宿主与前端:用户可见文案不再带诊断引用,失败也进错误上报池

- `record_direct_codex_failure` 的收口文案去掉 `;详情:<path>`,诊断 sidecar 与应用日志照写
- 同一出口把最终文案送进错误上报池:命令接单化后前端 catch 只剩"接单被拒",池不能只靠前端填
- 删除 `persist_direct_codex_failure_context`:失败说明本轮不写进项目历史,留 TODO 记录以后"进历史但不喂模型"的通道
- 删除只服务详情展开的 IPC `read_agent_runtime_error_detail` 及其注册
- 前端去掉 `详情:` 正则与第二次读取,横幅只显示一句话
- 同步命令边界与相关测试注释
This commit is contained in:
2026-09-23 17:57:36 +08:00
parent e776aa1084
commit 5398a53e6e
5 changed files with 27 additions and 85 deletions
@@ -2162,7 +2162,8 @@ fn direct_codex_error_feedback_prompt(error: &str, attempt: usize) -> String {
/// ([`DirectTurnError::is_reportable`],宿主 / 环境事实)。
///
/// 返回给用户看的那行 `direct-codex-failure:v2 ...` 文本:它是这一轮(或这次拒绝)的收口说明,
/// 事件载荷、横幅与项目历史共用同一份,命令边界也只序列化它一次。
/// 事件载荷与横幅共用同一份,命令边界也只序列化它一次。诊断文件的引用**不进**这份文案:
/// 界面不再展开详情,线索只留在宿主侧(`.agent/runtime/errors`、应用日志与错误上报池)。
pub(crate) fn record_direct_codex_failure(
root: &Path,
failure: &DirectTurnError,
@@ -2211,7 +2212,8 @@ pub(crate) fn record_direct_codex_failure(
};
// 诊断 code 仍走共享的 runtime_error 分类(它是跨链路的持久化标签,不是流程判据)。
let error_code = classify_direct_codex_error(&detail);
let unified_detail_ref = persist_agent_runtime_error(
// sidecar 照写,但引用不进用户可见文案。
let _ = persist_agent_runtime_error(
root,
client_turn_id,
"direct-codex",
@@ -2226,10 +2228,9 @@ pub(crate) fn record_direct_codex_failure(
"legacyDiagnosticWritten": diagnostic_written,
}),
)
.ok()
.map(|event| event.detail_ref);
format!(
"direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{}{}",
.ok();
let public_text = format!(
"direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{}",
stage.id(),
error_code,
retryable,
@@ -2238,18 +2239,19 @@ pub(crate) fn record_direct_codex_failure(
.unwrap_or("未提供可安全展示的详细原因"),
recovery_hint,
diagnostics_suffix,
unified_detail_ref
.map(|path| format!(";详情:{path}"))
.unwrap_or_default(),
)
);
// 失败也进错误上报池:命令接单化之后前端 catch 只剩"接单被拒",池不能只靠前端填。
// 两条通道同时上报也不会变成两条——池按 fingerprint 合并同一份文案。
let _ = crate::error_report::report_agent_runtime_error("direct-codex", &public_text);
public_text
}
/// 命令边界的错误文本:可留痕的调用级拒绝在这里补一份运行错误诊断(返回串因此带 `详情:` 引用),
/// 命令边界的错误文本:可留痕的调用级拒绝在这里补一份运行错误诊断(文案里不带诊断引用),
/// 其余只输出 [`DirectTurnError`] 的 `Display`。
///
/// 分层改成 typed 之前,这几条"宿主 / 环境事实"是在回合失败通道里被写进诊断的;分层之后它们不再
/// 进那条通道,留痕与界面的 `详情:` 展开都在这里补回来。GUI 命令与 CLI 边界共用这一份,禁止在各自
/// 边界再写一套判据;回合级失败已在上游写过诊断,这里直接放行。
/// 进那条通道,留痕在这里补回来。GUI 命令与 CLI 边界共用这一份,禁止在各自边界再写一套判据;
/// 回合级失败已在上游写过诊断,这里直接放行。
pub(crate) fn direct_turn_error_boundary_text(
root: &Path,
client_turn_id: Option<&str>,
@@ -2261,19 +2263,6 @@ pub(crate) fn direct_turn_error_boundary_text(
record_direct_codex_failure(root, &failure, client_turn_id)
}
fn persist_direct_codex_failure_context(
root: &Path,
client_turn_id: &str,
error: &str,
) -> Result<(), String> {
let item = direct_project_local_message_item(
"assistant",
error,
Some(&format!("direct-codex:{client_turn_id}:failure")),
)?;
append_direct_project_history_item_at(root, &item)
}
fn direct_taonier_art_generation_runtime_context(
root: &Path,
output_path: &str,
@@ -4649,12 +4638,9 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
&failure,
turn_emitter.map(|emitter| emitter.turn_id()),
);
if let Some(emitter) = turn_emitter {
// Persist the safe terminal projection so the next DirectProject
// turn can answer a diagnostic question from evidence instead of
// guessing or starting another playtest.
let _ = persist_direct_codex_failure_context(root, emitter.turn_id(), &error);
}
// TODO(Direct 命令接单化):失败说明本轮不写进项目历史——重进项目只会看到那条没有回复的
// 用户消息,原因只在当轮界面与宿主诊断里。以后要给"进历史但不喂模型"的条目留一条通道
// (见 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md` 的备选方案第 3 条)。
if let Some(emitter) = turn_emitter {
// 失败说明也是这一回合的内容:按出现顺序追加到回合流末尾,
// 这样"流里已经是完整内容"这一点对失败回合同样成立。
@@ -7936,8 +7922,8 @@ mod tests {
);
}
/// 可留痕的调用级拒绝(宿主 / 环境事实)在命令边界补一份运行错误诊断,返回串带 `详情:` 引用:
/// 这是分层改 typed 之前的行为,前端横幅的展开逻辑按这个引用工作。
/// 可留痕的调用级拒绝(宿主 / 环境事实)在命令边界补一份运行错误诊断,但返回串不再带引用:
/// 界面不展开详情,线索只留在 `.agent/runtime/errors`、应用日志与错误上报池。
#[test]
fn reportable_call_rejection_writes_a_diagnostic_at_the_boundary() {
let parent = tempfile::tempdir().expect("temp dir");
@@ -7952,7 +7938,8 @@ mod tests {
},
);
assert!(text.contains("详情:.agent/runtime/errors/"), "{text}");
assert!(text.contains("direct-codex-failure:v2"), "{text}");
assert!(!text.contains("详情:"), "{text}");
let entries = std::fs::read_dir(root.join(".agent/runtime/errors"))
.expect("runtime error directory")
.filter_map(Result::ok)
@@ -31,8 +31,8 @@ pub(crate) fn normalize_direct_client_turn_id(
/// DirectProject 聊天命令:对外仍然是 `Result<String, String>`。
///
/// 字符串只在这里生成一次;前端拿到的仍是"一句给用户看的话",而 Rust 侧从命令入口到宿主出口全程
/// 只传 typed 错误。可留痕的调用级拒绝(宿主 / 环境事实)在这里补一份运行错误诊断,返回串因此带上
/// `详情:` 引用——界面横幅的展开逻辑按这个引用工作。
/// 只传 typed 错误。可留痕的调用级拒绝(宿主 / 环境事实)在这里补一份运行错误诊断,但返回串不再
/// 带诊断引用——界面不展开详情,线索只在宿主侧。
///
// TODO(Direct 命令接单化,未实施):现在这个命令 await 整轮,于是"命令边界"承担了不属于它的角色——
// 回合失败的文案要靠这条 Err 回到界面,认证失败重试也只能挂在它上面。目标形状(草案见
@@ -5873,38 +5873,6 @@ pub(crate) async fn read_direct_project_conversation(
.map_err(|error| format!("读取 DirectProject 历史后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) async fn read_agent_runtime_error_detail(
project_path: String,
detail_ref: String,
) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
let relative = detail_ref.trim();
let Some(file_name) = relative.strip_prefix(".agent/runtime/errors/") else {
return Err("错误诊断引用不在项目错误目录内".to_string());
};
if file_name.is_empty()
|| file_name.contains(['/', '\\'])
|| file_name.contains("..")
|| !file_name.ends_with(".json")
{
return Err("错误诊断引用格式无效".to_string());
}
let path = root.join(relative);
prepare_game_creator_private_path_for_read(&path, false, "统一错误诊断")?;
let bytes = std::fs::read(&path).map_err(|error| format!("读取错误诊断失败:{error}"))?;
if bytes.len() > 16 * 1024 {
return Err("错误诊断超过读取上限".to_string());
}
let text = String::from_utf8(bytes).map_err(|_| "错误诊断不是 UTF-8 文本".to_string())?;
Ok(redact_agent_runtime_error(root, &text, 16 * 1024))
})
.await
.map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) fn list_game_creator_direct_active_turns(
) -> Result<Vec<DirectActiveTurnSnapshot>, String> {
@@ -2761,7 +2761,6 @@ fn main() {
archive_game_creator_agent_session,
read_local_conversation,
read_direct_project_conversation,
read_agent_runtime_error_detail,
list_game_creator_direct_active_turns,
subscribe_direct_project_thread,
consume_direct_project_thread,
@@ -610,22 +610,10 @@ export function useDirectProjectChatController({
}
void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID);
const message = error instanceof Error ? error.message : String(error);
let persistedDetail = '';
const detailRef = message.match(
/详情:(\.agent\/runtime\/errors\/[^\s;]+)/,
)?.[1];
if (detailRef) {
try {
persistedDetail = await invoke<string>(
'read_agent_runtime_error_detail',
{ projectPath: nextProjectPath, detailRef },
);
} catch {
persistedDetail = '';
}
}
// 不再展开诊断详情:文案里没有引用,前端也不去读那份文件。线索留在
// `.agent/runtime/errors`、应用日志与错误上报池里,界面只显示这一句话。
const visibleMessage = projectRuntimeVisibleError(
persistedDetail ? `${message}\n\n${persistedDetail}` : message,
message,
'陶泥儿智能创作',
true,
);