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.
This commit is contained in:
2026-09-30 17:40:02 +08:00
parent 8a4e3cbcdd
commit 41c3dea3fd
@@ -365,16 +365,24 @@ impl DirectToolBridgeState {
Ok(())
}
/// 取当前回合身份,媒体资源请求按回合身份与请求指纹确定性派生 operation/idempotency id。
fn active_resource_turn_id(&self) -> Result<String, String> {
/// 读当前回合的客户端身份;桥没有被回合授权时给出 `None`。
///
/// 回合身份既用于派生确定性 operation/idempotency id,也用于给诊断事件标回合归属。
/// 这里只如实读授权状态,缺回合不是错误:外部客户端工具桥本来就不绑定直连回合。
fn active_client_turn_id(&self) -> Result<Option<String>, 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<String, String> {
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<Value> {
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::<Vec<_>>();
turn_ids.sort_by_key(Value::to_string);
turn_ids
}
#[test]
fn bridge_art_resource_exposes_only_the_safe_identity_projection() {
let asset = GameCreationAppAssetManifestEntry {