保留 CC 执行过程与失败前正文

无参数工具也展示开始事件,失败终态保留已完成工具与未完成增量历史

补充 CC 失败过程回归测试与排障记录
This commit is contained in:
kdletters
2026-10-06 13:03:00 +08:00
parent 9e559a0188
commit 3d45c26b41
3 changed files with 140 additions and 28 deletions
@@ -1128,6 +1128,7 @@ pub(crate) async fn direct_game_creator_claude_code_home_chat(
struct ClaudeCodeToolState {
name: String,
arguments: String,
started: bool,
completed: bool,
}
@@ -1137,7 +1138,8 @@ struct ClaudeCodeStreamState {
block_item_ids: HashMap<(String, usize), String>,
block_tool_ids: HashMap<(String, usize), String>,
tool_calls: HashMap<String, ClaudeCodeToolState>,
pending_history: Vec<serde_json::Value>,
history_error: Option<String>,
partial_history: DirectProjectHistoryAccumulator,
assistant_texts: Vec<String>,
result_text: Option<String>,
result_error: Option<String>,
@@ -1194,6 +1196,20 @@ fn claude_stream_observe(
}
impl ClaudeCodeStreamState {
/// 已完成过程与 Codex 共用项目历史出口,收到时即保存;终态失败不能撤掉前面已发生的事实。
fn persist_completed_item(&mut self, root: &Path, item: &serde_json::Value) {
if let Err(error) = append_direct_project_history_item_at(root, item) {
self.history_error.get_or_insert(error);
}
}
fn persist_partial_history(&mut self, root: &Path) {
if let Err(error) = persist_direct_project_partial_items_at(root, &mut self.partial_history)
{
self.history_error.get_or_insert(error);
}
}
fn block_item_id(&mut self, message_id: &str, index: usize, kind: &str) -> String {
self.block_item_ids
.entry((message_id.to_string(), index))
@@ -1212,6 +1228,7 @@ impl ClaudeCodeStreamState {
if delta.is_empty() {
return;
}
self.partial_history.observe_delta(&item_id, &delta);
crate::agent::append_thread_event(
&crate::agent::thread_id_for_project(root),
ThreadEvent::item_delta(item_id, kind, delta.clone()),
@@ -1245,6 +1262,7 @@ impl ClaudeCodeStreamState {
.or_insert_with(|| ClaudeCodeToolState {
name: name.clone(),
arguments: String::new(),
started: false,
completed: false,
});
if !name.trim().is_empty() {
@@ -1254,9 +1272,10 @@ impl ClaudeCodeStreamState {
if !arguments.is_empty() {
entry.arguments = arguments;
}
if entry.completed || !needs_update {
if entry.completed || (entry.started && !needs_update) {
return;
}
entry.started = true;
let at = crate::agent::now_ms();
crate::agent::append_thread_event(
&crate::agent::thread_id_for_project(root),
@@ -1289,6 +1308,7 @@ impl ClaudeCodeStreamState {
.or_insert_with(|| ClaudeCodeToolState {
name: "MCP 工具".to_string(),
arguments: "{}".to_string(),
started: false,
completed: false,
});
if state.completed {
@@ -1309,14 +1329,15 @@ impl ClaudeCodeStreamState {
&crate::agent::thread_id_for_project(root),
ThreadEvent::item_completed(item, at),
);
self.pending_history.push(serde_json::json!({
let history_item = serde_json::json!({
"type": "mcpToolCall",
"id": tool_id,
"tool": state.name.clone(),
"arguments": state.arguments.clone(),
"result": output,
"status": status,
}));
});
self.persist_completed_item(root, &history_item);
claude_stream_observe(observer, TurnObservation::Activity("controlled-tool"));
}
@@ -1470,6 +1491,8 @@ impl ClaudeCodeStreamState {
continue;
}
let item_id = self.block_item_id(message_id, index, "text");
self.partial_history
.complete_item(&serde_json::json!({"id": item_id}));
let at = crate::agent::now_ms();
crate::agent::append_thread_event(
&crate::agent::thread_id_for_project(root),
@@ -1483,20 +1506,13 @@ impl ClaudeCodeStreamState {
at,
),
);
self.pending_history.push(serde_json::json!({
self.persist_completed_item(root, &serde_json::json!({
"type": "message",
"role": "assistant",
"id": self.block_item_ids.get(&(message_id.to_string(), index)).cloned().unwrap_or_default(),
"content": [{"type": "output_text", "text": text}],
}));
self.assistant_texts.push(
self.pending_history
.last()
.and_then(|item| item.pointer("/content/0/text"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
);
self.assistant_texts.push(text);
claude_stream_observe(
observer,
TurnObservation::AccumulatedText(
@@ -1510,6 +1526,8 @@ impl ClaudeCodeStreamState {
continue;
}
let item_id = self.block_item_id(message_id, index, "thinking");
self.partial_history
.complete_item(&serde_json::json!({"id": item_id}));
let at = crate::agent::now_ms();
crate::agent::append_thread_event(
&crate::agent::thread_id_for_project(root),
@@ -1522,11 +1540,14 @@ impl ClaudeCodeStreamState {
at,
),
);
self.pending_history.push(serde_json::json!({
"type": "reasoning",
"id": item_id,
"text": text,
}));
self.persist_completed_item(
root,
&serde_json::json!({
"type": "reasoning",
"id": item_id,
"text": text,
}),
);
claude_stream_observe(observer, TurnObservation::Reasoning(String::new()));
}
Some("tool_use") => {
@@ -1658,8 +1679,12 @@ impl ClaudeCodeStreamState {
{
self.result_error = claude_result_error_detail(terminal);
}
if let Some(detail) = self.result_error {
return Err(format!("Claude Code 返回失败终态:{detail}"));
if let Some(detail) = self.result_error.clone() {
self.persist_partial_history(root);
return Err(self.with_history_error(format!("Claude Code 返回失败终态:{detail}")));
}
if let Some(error) = &self.history_error {
return Err(format!("Claude Code 过程历史保存失败:{error}"));
}
let text = self
.result_text
@@ -1671,9 +1696,6 @@ impl ClaudeCodeStreamState {
})
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "Claude Code 缺少最终回复".to_string())?;
for item in self.pending_history {
append_direct_project_history_item_at(root, &item)?;
}
if self.assistant_texts.last().map(String::as_str) != Some(text.as_str()) {
let item_id = persist_direct_claude_assistant_reply_at(root, client_turn_id, &text)?;
let at = crate::agent::now_ms();
@@ -1692,6 +1714,13 @@ impl ClaudeCodeStreamState {
}
Ok(text)
}
fn with_history_error(&self, original: String) -> String {
match &self.history_error {
Some(error) => format!("{original};Claude Code 过程历史保存失败:{error}"),
None => original,
}
}
}
/// DirectProject 使用 Claude Code 自己的 MCP 调用能力;宿主只暴露 loopback
@@ -1744,7 +1773,7 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
let mut on_event = |event: &serde_json::Value| {
stream_state.observe(root, event, &mut observer);
};
let sidecar = run_sidecar_turn(
let sidecar_result = run_sidecar_turn(
root,
Some(llm),
&home,
@@ -1753,7 +1782,14 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
client_turn_id,
Some(&mut on_event),
)
.await?;
.await;
let sidecar = match sidecar_result {
Ok(sidecar) => sidecar,
Err(error) => {
stream_state.persist_partial_history(root);
return Err(stream_state.with_history_error(error));
}
};
direct_turn_trace("claude-sidecar-done");
if let Some(session_id) = sidecar
.result
@@ -2062,7 +2098,83 @@ mod tests {
assert!(observed
.iter()
.any(|event| matches!(event, TurnObservation::Activity("controlled-tool"))));
assert_eq!(state.pending_history.len(), 2);
let history = read_direct_project_history_items_at(root.path())
.expect("read process history before result");
assert_eq!(history.len(), 2);
}
#[test]
fn failed_claude_turn_keeps_completed_and_partial_process_history() {
let root = tempfile::tempdir().expect("temp root");
crate::init_local_game_project_at(root.path(), "cc-failure-history", "CC 失败历史")
.expect("init project");
let mut state = ClaudeCodeStreamState::default();
let mut observer_fn = |_event| {};
let mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)> = Some(&mut observer_fn);
state.observe(
root.path(),
&serde_json::json!({
"type": "stream_event",
"event": {"type": "message_start", "message": {"id": "partial-message"}}
}),
&mut observer,
);
state.observe(
root.path(),
&serde_json::json!({
"type": "stream_event",
"event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}}
}),
&mut observer,
);
state.observe(
root.path(),
&serde_json::json!({
"type": "stream_event",
"event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "已经读取项目"}}
}),
&mut observer,
);
state.observe(
root.path(),
&serde_json::json!({
"type": "assistant",
"message": {"id": "tool-message", "content": [{"type": "tool_use", "id": "tool-without-args", "name": "mcp__agc__agc_read_project_context"}]}
}),
&mut observer,
);
state.observe(
root.path(),
&serde_json::json!({
"type": "user",
"message": {"content": [{"type": "tool_result", "tool_use_id": "tool-without-args", "content": [{"type": "text", "text": "读取完成"}]}]}
}),
&mut observer,
);
state.observe(
root.path(),
&serde_json::json!({"type": "result", "is_error": true, "error": "上游 429"}),
&mut observer,
);
let failure = state
.finish(
root.path(),
&serde_json::json!({"is_error": true}),
Some("cc-failure-turn"),
)
.expect_err("the terminal result is a failure");
assert!(failure.contains("Claude Code 返回失败终态"));
let history = read_direct_project_history_items_at(root.path()).expect("read history");
assert_eq!(history.len(), 2);
assert!(history.iter().any(|item| {
item.get("type").and_then(serde_json::Value::as_str) == Some("message")
&& item.to_string().contains("已经读取项目")
}));
assert!(history.iter().any(|item| {
item.get("type").and_then(serde_json::Value::as_str) == Some("mcpToolCall")
&& item.to_string().contains("读取完成")
}));
}
#[test]
@@ -3,7 +3,7 @@ use serde_json::Value;
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Default)]
#[derive(Default, Debug)]
pub(crate) struct DirectProjectHistoryAccumulator {
text_by_item_id: BTreeMap<String, String>,
}
@@ -5,7 +5,7 @@
## 2026-10-06 AGC 失败终态与 CC/MCP 边界不能吞掉协议、清理和 I/O 错误
- **现象**:DirectProject 的 `turn.completed(status=failed)` 若跨 IPC 丢失 `failure` 载荷,前端会只收口回合而不显示任何说明;Claude Code sidecar 的非 UTF-8 stderr、进程树清理失败、会话表锁损坏,以及 MCP loopback 服务退出、插件能力桥超时、损坏 manifest,会分别被压成空 detail、继续成功取消、伪造 hash projectId 或静默不广告插件。
- **现行口径**:失败载荷缺失显示明确的协议错误;app-server 只给 `failed` 状态却不带 `error` 对象时,正文保留 `status=failed error=missing` 协议事实;旧历史里的已知通用失败句明确标成“历史错误事件未记录具体错误正文”,不伪装成当前仍在吞错;错误报告队列的稳定分类只用于去重,列表保留脱敏后的完整正文,先到短分类、后到详细宿主错误时用详细正文升级;CC 清理、stderr 解码、会话锁都保留原始正文;后端 `game-creator-agent-progress` 阶段事件由 DirectProject 按项目路径订阅并显示在执行过程卡,不再只显示空白忙态;MCP server / capability bridge 失败写入统一应用日志,MCP manifest / journal / 时间 / metadata 读取失败返回具体错误;完成判定在把素材缺失归因于“未引用平台图”前先验证源码、manifest 和已登记图片的 I/O/PNG 错误;资源生成队列提交异常直接收口失败并恢复可重试草稿。切片文件是可选投影,完整 spritesheet 不因无切片而误失败。
- **现行口径**:失败载荷缺失显示明确的协议错误;app-server 只给 `failed` 状态却不带 `error` 对象时,正文保留 `status=failed error=missing` 协议事实;旧历史里的已知通用失败句明确标成“历史错误事件未记录具体错误正文”,不伪装成当前仍在吞错;错误报告队列的稳定分类只用于去重,列表保留脱敏后的完整正文,先到短分类、后到详细宿主错误时用详细正文升级;CC 清理、stderr 解码、会话锁都保留原始正文,CC 已完成的工具/回复过程即时写入 DirectProject 历史,失败终态也会保留未完成的部分正文;后端 `game-creator-agent-progress` 阶段事件由 DirectProject 按项目路径订阅并显示在执行过程卡,不再只显示空白忙态;MCP server / capability bridge 失败写入统一应用日志,MCP manifest / journal / 时间 / metadata 读取失败返回具体错误;完成判定在把素材缺失归因于“未引用平台图”前先验证源码、manifest 和已登记图片的 I/O/PNG 错误;资源生成队列提交异常直接收口失败并恢复可重试草稿。切片文件是可选投影,完整 spritesheet 不因无切片而误失败。
- **验证**:`cargo test agent::claude_code_cli::tests::` 14 passed;`cargo test agent::direct_tools_mcp::tests::` 34 passed;`cargo test agent::direct_runtime::tests::` 92 passed;`npx vitest run tests/directThreadChat.test.ts tests/directTurnFailure.test.ts` 64 passed;另有损坏 manifest 回归用例 1 passed。新失败正文仍经既有脱敏边界处理。
- **关联**:`agent/claude_code_cli.rs`、`agent/direct_tools_mcp.rs`、`agent/direct_runtime/mod.rs`、`agent/thread_manager/dispatch.rs`、`chat/conversation/directThreadChat.ts`。