From 41c3dea3fdab4e3c677cbe98d7f8eaa508e33675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 30 Sep 2026 17:40:02 +0800 Subject: [PATCH] Refactor: Add client turn attribution to tool diagnostics and enhance test coverage - Introduced `active_client_turn_id` for client turn attribution in diagnostics. - Updated error tracking to include `clientTurnId` in tool failure events. - Added test cases to validate behavior with and without bound client turns. --- .../src-tauri/src/agent/direct_tool_bridge.rs | 92 ++++++++++++++++++- 1 file changed, 87 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 1300be1b5..46d14f083 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -365,16 +365,24 @@ impl DirectToolBridgeState { Ok(()) } - /// 取当前回合身份,媒体资源请求按回合身份与请求指纹确定性派生 operation/idempotency id。 - fn active_resource_turn_id(&self) -> Result { + /// 读当前回合的客户端身份;桥没有被回合授权时给出 `None`。 + /// + /// 回合身份既用于派生确定性 operation/idempotency id,也用于给诊断事件标回合归属。 + /// 这里只如实读授权状态,缺回合不是错误:外部客户端工具桥本来就不绑定直连回合。 + fn active_client_turn_id(&self) -> Result, String> { let authorization = self .turn_authorization .lock() .map_err(|_| "AGC 工具桥回合授权状态不可用".to_string())?; - authorization + Ok(authorization .active .as_ref() - .map(|active| active.turn_id.clone()) + .map(|active| active.turn_id.clone())) + } + + /// 取当前回合身份,媒体资源请求按回合身份与请求指纹确定性派生 operation/idempotency id。 + fn active_resource_turn_id(&self) -> Result { + self.active_client_turn_id()? .ok_or_else(|| "当前没有客户端签发的资源生成回合身份".to_string()) } } @@ -3114,9 +3122,12 @@ async fn handle_direct_tool_bridge( } else { "tool-error" }; + // 诊断事件按回合归属:并发排障要能从错误记录认出是哪一轮调的工具。 + // 桥没被回合授权(外部客户端工具桥)或授权状态不可用时如实记为无回合。 + let client_turn_id = state.active_client_turn_id().ok().flatten(); let _ = persist_agent_runtime_error( &state.root, - None, + client_turn_id.as_deref(), "agc-tools", "tool-execution", code, @@ -4885,6 +4896,77 @@ mod tests { assert!(state.authorize_regeneration_call("陶泥风格").is_err()); } + /// 失败工具写的诊断事件按回合归属:并发排障要能从错误记录认出是哪一轮调的工具。 + #[tokio::test] + async fn failed_tool_call_records_the_bound_client_turn() { + let temporary = tempfile::tempdir().expect("turn attribution root"); + let root = temporary.path(); + init_local_game_project_at(root, "turn-attribution", "回合归属").unwrap(); + let client_turn_id = "client-turn-attribution-0001"; + let _reservation = + TurnReservation::accept_for_test(&thread_id_for_project(root), client_turn_id); + let state = direct_tool_bridge_state(root.to_path_buf()); + let _active_turn = state.begin_user_turn().expect("bind the client turn"); + + let response = handle_direct_tool_bridge( + State(state), + Json(DirectToolBridgeRequest { + tool: "agc_unreviewed_tool".to_string(), + arguments: json!({}), + }), + ) + .await + .0; + assert_eq!(response["isError"], true); + + assert_eq!( + recorded_tool_error_client_turn_ids(root), + vec![json!(client_turn_id)] + ); + } + + /// 桥没有被回合授权时如实记 null:外部客户端工具桥不绑定直连回合。 + #[tokio::test] + async fn failed_tool_call_without_a_bound_turn_records_no_client_turn() { + let temporary = tempfile::tempdir().expect("unbound turn root"); + let root = temporary.path(); + init_local_game_project_at(root, "turn-unbound", "无回合归属").unwrap(); + + let response = handle_direct_tool_bridge( + State(direct_tool_bridge_state(root.to_path_buf())), + Json(DirectToolBridgeRequest { + tool: "agc_unreviewed_tool".to_string(), + arguments: json!({}), + }), + ) + .await + .0; + assert_eq!(response["isError"], true); + + assert_eq!(recorded_tool_error_client_turn_ids(root), vec![Value::Null]); + } + + /// 读工具失败诊断里的 `clientTurnId`:字段必须存在,值可以是 `null`。 + fn recorded_tool_error_client_turn_ids(root: &Path) -> Vec { + let mut turn_ids = std::fs::read_dir(root.join(".agent/runtime/errors")) + .expect("runtime error directory") + .filter_map(Result::ok) + .map(|entry| { + let raw = std::fs::read_to_string(entry.path()).expect("runtime error sidecar"); + let event: Value = serde_json::from_str(&raw).expect("parse runtime error sidecar"); + assert_eq!(event["source"], "agc-tools", "{raw}"); + assert_eq!(event["stage"], "tool-execution", "{raw}"); + assert_eq!(event["code"], "tool-error", "{raw}"); + event + .get("clientTurnId") + .cloned() + .unwrap_or_else(|| panic!("tool error event must carry clientTurnId: {raw}")) + }) + .collect::>(); + turn_ids.sort_by_key(Value::to_string); + turn_ids + } + #[test] fn bridge_art_resource_exposes_only_the_safe_identity_projection() { let asset = GameCreationAppAssetManifestEntry {