修复 cc 反馈回复的 DirectProject 条目冲突
Project CI / AI game creator shell Rust crates (push) Successful in 1m41s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 4m23s
Project CI / Backend tests (push) Successful in 4m50s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 5m9s
Project CI / Frontend tests (push) Successful in 2m25s
Project CI / AI game creator shell web tests (push) Successful in 2m29s
Project CI / Native shell tests (push) Successful in 5m59s
Project CI / Repository checks (push) Successful in 3m0s

同一 client turn 的反馈回复冲突时生成独立 assistant item ID

保持相同回复幂等并补充回归测试与排障记录
This commit is contained in:
kdletters
2026-10-03 13:45:02 +08:00
parent 25beb3ad59
commit b62e32946b
2 changed files with 65 additions and 16 deletions
@@ -984,21 +984,15 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
// 项目对话历史是这条对话的单一事实源:回复不落盘,UI 就看不到本轮结果。codex 路径由
// app-server 的 collect-history 负责写 assistant 条目,cc 没有那一步——只补终态会让
// 用户看到"回合结束但没有回复"。落盘失败按回合失败收口,不吞。
let item_id = match client_turn_id {
Some(client_turn_id) => format!("direct-codex:{client_turn_id}:assistant"),
None => format!("direct-codex:{}:assistant", uuid::Uuid::new_v4()),
let item_id = match persist_direct_claude_assistant_reply_at(root, client_turn_id, text)
{
Ok(item_id) => item_id,
Err(error) => {
eprintln!("[agc-cc-direct] persist assistant failed: {error}");
direct_turn_trace("claude-parse-error");
return Err(format!("写入本项目对话历史失败:{error}"));
}
};
let item = serde_json::json!({
"type": "message",
"role": "assistant",
"id": item_id.clone(),
"content": [{ "type": "output_text", "text": text }],
});
if let Err(error) = append_direct_project_history_item_at(root, &item) {
eprintln!("[agc-cc-direct] persist assistant failed: {error}");
direct_turn_trace("claude-parse-error");
return Err(format!("写入本项目对话历史失败:{error}"));
}
// 聊天区是按 `item.completed` 事件流投影的(codex 路径在 rawResponseItem/completed
// 时下发同款事件),只落盘历史不会让本轮回复出现在界面上——重进项目才看得到。
// 条目身份与落盘的历史条目保持同一个,重进项目按 id 去重。
@@ -1026,6 +1020,38 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
parsed
}
fn persist_direct_claude_assistant_reply_at(
root: &Path,
client_turn_id: Option<&str>,
text: &str,
) -> Result<String, String> {
let stable_item_id = match client_turn_id {
Some(client_turn_id) => format!("direct-codex:{client_turn_id}:assistant"),
None => format!("direct-codex:{}:assistant", uuid::Uuid::new_v4()),
};
let mut item = serde_json::json!({
"type": "message",
"role": "assistant",
"id": stable_item_id.clone(),
"content": [{ "type": "output_text", "text": text }],
});
match append_direct_project_history_item_at(root, &item) {
Ok(()) => Ok(stable_item_id),
Err(error) if error.starts_with("DirectProject 历史 item id 冲突:") => {
// 同一 client turn 的验收反馈会再次进入 cc 执行器。首个回复保留稳定身份,
// 后续不同回复必须另用 item id;否则历史层会把反馈收尾误判成失败。
let feedback_item_id = format!("{stable_item_id}:{}", uuid::Uuid::new_v4());
item["id"] = serde_json::Value::String(feedback_item_id.clone());
append_direct_project_history_item_at(root, &item)
.map(|()| feedback_item_id)
.map_err(|retry_error| {
format!("反馈回复历史追加失败:{retry_error}(首个条目冲突:{error})")
})
}
Err(error) => Err(error),
}
}
fn parse_direct_stream_result(
stdout: &[u8],
mut observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
@@ -1160,6 +1186,28 @@ mod tests {
assert!(error.contains("Authentication failed"));
}
#[test]
fn direct_claude_feedback_reply_does_not_fail_on_a_reused_client_turn_id() {
let root = tempfile::tempdir().expect("temp dir");
crate::init_local_game_project_at(root.path(), "cc-feedback", "cc feedback")
.expect("init project");
let turn_id = Some("client-turn-feedback-0001");
let first = persist_direct_claude_assistant_reply_at(root.path(), turn_id, "首轮回复")
.expect("persist first reply");
let identical = persist_direct_claude_assistant_reply_at(root.path(), turn_id, "首轮回复")
.expect("an identical retry remains idempotent");
let second = persist_direct_claude_assistant_reply_at(root.path(), turn_id, "反馈后回复")
.expect("a host feedback retry must persist a second reply");
assert_eq!(first, identical);
assert_ne!(first, second);
assert!(second.starts_with("direct-codex:client-turn-feedback-0001:assistant:"));
let items =
crate::agent::read_direct_project_history_items_at(root.path()).expect("read history");
assert_eq!(items.len(), 2);
}
fn test_platform_session() -> crate::platform_session::PlatformSessionSnapshot {
crate::platform_session::PlatformSessionSnapshot {
user_id: "user-1".to_string(),