保留 CC 执行过程与失败前正文
无参数工具也展示开始事件,失败终态保留已完成工具与未完成增量历史 补充 CC 失败过程回归测试与排障记录
This commit is contained in:
@@ -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`。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user