修复AGC总控恢复与工具计划交接
Project CI / Repository checks (push) Successful in 1m3s
Project CI / Frontend tests (push) Successful in 2m50s
Project CI / Backend tests (push) Successful in 3m5s
Project CI / Native shell tests (push) Successful in 9m33s

修复Windows下tool-plan账本按句柄原子安装失败。

补齐项目总控空态、持久Runtime水合与核对后重试交互。

修正Responses多角色内容映射与steer审计幂等身份。

修复首批协作repair累积、缺失Agent约束和isolated单槽替换。

收紧API Key安全持久化检测并覆盖装饰赋值与自然语言边界。

修复Provider retry恢复扫描与无画布密钥时的视觉产物降级。

补充定向回归测试和项目技术文档。
This commit is contained in:
2026-07-28 19:56:33 +08:00
parent d29240b954
commit b1db88c114
28 changed files with 1571 additions and 114 deletions
@@ -37,4 +37,4 @@ tauri-plugin-clipboard-manager = "2.3.2"
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_JobObjects"] }
windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_JobObjects"] }
@@ -28,18 +28,18 @@ pub(in crate::agent) fn validate_agent_runtime_pending_serialized_content(
) -> Result<(), String> {
let lower = content.to_ascii_lowercase();
let sensitive_rule = [
".env",
"game-creator.config",
"authorization:",
"cookie:",
"api_key",
"apikey",
"api key",
"token=",
"bearer ",
(0, ".env"),
(1, "game-creator.config"),
(2, "authorization:"),
(3, "cookie:"),
(4, "api_key"),
(5, "apikey"),
(7, "token="),
(8, "bearer "),
]
.into_iter()
.position(|marker| lower.contains(marker))
.find_map(|(rule, marker)| lower.contains(marker).then_some(rule))
.or_else(|| agent_runtime_contains_api_key_assignment(&lower).then_some(6))
.or_else(|| (redact_secret_tokens(content) != content).then_some(9));
if let Some(rule) = sensitive_rule {
return Err(format!(
@@ -59,6 +59,154 @@ pub(in crate::agent) fn validate_agent_runtime_pending_serialized_content(
Ok(())
}
fn agent_runtime_contains_api_key_assignment(lower: &str) -> bool {
lower.match_indices("api key").any(|(index, marker)| {
let Some(value) =
agent_runtime_api_key_assignment_value(lower[index + marker.len()..].trim_start())
else {
return false;
};
let value = value
.trim_start()
.trim_start_matches(|character| matches!(character, '"' | '\\'))
.trim_start();
!agent_runtime_api_key_assignment_is_safe_status(value)
})
}
fn agent_runtime_api_key_assignment_value(remainder: &str) -> Option<&str> {
let mut cursor = 0usize;
loop {
if remainder[..cursor].chars().count() > 64 {
return None;
}
let current = &remainder[cursor..];
let trimmed = current.trim_start();
cursor += current.len().saturating_sub(trimmed.len());
let current = &remainder[cursor..];
let character = current.chars().next()?;
if matches!(character, ':' | '=' | '') {
return Some(&current[character.len_utf8()..]);
}
if matches!(character, '*' | '`' | '_' | '~' | '\\' | '\'' | '"' | ']') {
cursor += character.len_utf8();
continue;
}
let closing = match character {
'(' => Some(')'),
'' => Some(''),
'[' => Some(']'),
'【' => Some('】'),
_ => None,
};
if let Some(closing) = closing {
let after_open = &current[character.len_utf8()..];
let closing_index = after_open.find(closing)?;
let qualifier = &after_open[..closing_index];
if qualifier.chars().count() > 32
|| qualifier
.chars()
.any(|value| matches!(value, ':' | '=' | '' | '"' | '\n' | '\r'))
{
return None;
}
cursor += character.len_utf8() + closing_index + closing.len_utf8();
continue;
}
let qualifier = [
"value",
"production",
"development",
"prod",
"test",
"dev",
"",
"生产",
"测试",
"开发",
]
.into_iter()
.find(|qualifier| {
current.strip_prefix(qualifier).is_some_and(|suffix| {
suffix.chars().next().is_none_or(|next| {
next.is_whitespace()
|| matches!(
next,
':' | '=' | '' | '*' | '`' | '_' | '~' | '' | '(' | '[' | '【'
)
})
})
})?;
cursor += qualifier.len();
}
}
fn agent_runtime_api_key_assignment_is_safe_status(value: &str) -> bool {
let value = agent_runtime_serialized_string_value(value)
.trim()
.trim_end_matches(['。', '.']);
if [
"当前未配置",
"未配置",
"没有配置",
"未提供",
"缺失",
"不存在",
"不可用",
"为空",
"禁止",
"不要",
"不得",
"无需",
"not configured",
"unconfigured",
"not available",
"unavailable",
"missing",
"absent",
"none",
"empty",
"not provided",
"do not",
"never",
"disabled",
]
.into_iter()
.any(|safe_status| value == safe_status)
{
return true;
}
matches!(
value,
"当前未配置,请按无密钥路径降级"
| "未配置,请按无密钥路径降级"
| "not configured; use the text-only fallback"
)
}
fn agent_runtime_serialized_string_value(value: &str) -> &str {
for (index, character) in value.char_indices() {
if character != '"' {
continue;
}
let escaped = value[..index]
.as_bytes()
.iter()
.rev()
.take_while(|byte| **byte == b'\\')
.count()
% 2
== 1;
if !escaped {
return &value[..index];
}
}
value
}
pub(crate) fn agent_runtime_contains_secret_key_prefix(content: &str, prefix: &str) -> bool {
content
.match_indices(prefix)
@@ -531,3 +679,57 @@ pub(in crate::agent) fn consume_game_creator_agent_runtime_tool_confirmation(
})?;
Ok(!action_fingerprint.trim().is_empty() && confirmed_fingerprint == action_fingerprint)
}
#[cfg(test)]
mod tests {
use super::validate_agent_runtime_pending_serialized_content;
use std::path::Path;
#[test]
fn pending_content_allows_api_key_security_guidance_without_secret_material() {
for task in [
"继续修复失败的视觉任务;不要读取或暴露 External Editor API Key。",
"External Editor API Key:当前未配置,请按无密钥路径降级。",
"External Editor API Key: not configured; use the text-only fallback.",
"不要读取或暴露 External Editor API Key;失败时:改走文本降级。",
"不要暴露 API Key,说明见 https://example.test/docs",
] {
let content = serde_json::json!({
"action": {
"tool": "agent.delegate",
"input": { "task": task }
}
})
.to_string();
validate_agent_runtime_pending_serialized_content(Path::new("C:\\workspace"), &content)
.expect("natural-language API Key guidance is not secret material");
}
}
#[test]
fn pending_content_still_rejects_api_key_fields_and_secret_tokens() {
let root = Path::new("C:\\workspace");
for (content, rule) in [
(r#"{"apiKey":"plain-secret-material"}"#, 5),
(r#"{"api_key":"plain-secret-material"}"#, 4),
(r#"{"task":"API Key: plain-secret-material"}"#, 6),
(r#"{"task":"API Key = plain-secret-material"}"#, 6),
(r#"{"task":"API Key: none-but-real-secret-material"}"#, 6),
(
r#"{"task":"API Key: not configured; actual value plain-secret-material"}"#,
6,
),
(r#"{"task":"API Key: disabled plain-secret-material"}"#, 6),
(r#"{"task":"**API Key**: plain-secret-material"}"#, 6),
(r#"{"task":"`API Key`: plain-secret-material"}"#, 6),
(r#"{"task":"API Key(生产): plain-secret-material"}"#, 6),
(r#"{"token":"token=plain-secret-material"}"#, 7),
(r#"{"note":"sk-prohibitedsecret"}"#, 9),
] {
let error = validate_agent_runtime_pending_serialized_content(root, content)
.expect_err("sensitive content must remain rejected");
assert!(error.contains(&format!("#{rule}")), "{content}: {error}");
}
}
}
@@ -1,5 +1,91 @@
use super::*;
fn supervisor_collaboration_repair_action_key(action: &AgentRuntimeToolAction) -> Option<String> {
match action.tool.trim() {
"agent.delegate" => action
.input
.get("agentId")
.or_else(|| action.input.get("agent_id"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|agent_id| format!("delegate:{agent_id}")),
"agent.spawn_isolated" => Some("isolated".to_string()),
_ => None,
}
}
fn merge_supervisor_collaboration_repair_actions(
accumulated: &[AgentRuntimeToolAction],
current: &[AgentRuntimeToolAction],
) -> Vec<AgentRuntimeToolAction> {
let mut merged = accumulated.to_vec();
for action in current {
let Some(key) = supervisor_collaboration_repair_action_key(action) else {
continue;
};
if let Some(existing) = merged.iter_mut().find(|candidate| {
supervisor_collaboration_repair_action_key(candidate).as_deref() == Some(key.as_str())
}) {
*existing = action.clone();
} else {
merged.push(action.clone());
}
}
merged
}
fn supervisor_collaboration_missing_agent_ids(error: &str) -> Vec<String> {
let mut missing = error
.split_once("missingStaticAgents=")
.map(|(_, suffix)| {
suffix
.split(['·', '', ';', '\n'])
.next()
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|value| {
!value.is_empty() && *value != "-" && !value.eq_ignore_ascii_case("none")
})
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
for agent_id in ["code-prototype", "quality-review", "art-asset-plan"] {
if error.contains(&format!("缺少 {agent_id} 委派"))
&& !missing.iter().any(|value| value == agent_id)
{
missing.push(agent_id.to_string());
}
}
missing
}
fn restrict_supervisor_collaboration_repair_to_missing_agents(
request: &mut LlmRunRequest,
error: &str,
) -> Result<(), String> {
let missing = supervisor_collaboration_missing_agent_ids(error);
if missing.is_empty() {
return Ok(());
}
let delegate_function = native_runtime_function_name("agent.delegate")
.ok_or_else(|| "无法生成 Supervisor 首批委派修复工具名".to_string())?;
let delegate = request
.function_tools
.iter_mut()
.find(|tool| tool.name == delegate_function)
.ok_or_else(|| "Supervisor 首批委派修复工具目录缺少 agent.delegate".to_string())?;
let agent_id = delegate
.parameters
.pointer_mut("/properties/input/properties/agentId")
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| "Supervisor 首批委派修复 agent.delegate schema 缺少 agentId".to_string())?;
agent_id.insert("enum".to_string(), serde_json::json!(missing));
Ok(())
}
pub(in crate::agent) fn append_game_creator_agent_tool_plan_audit_idempotent(
root: &Path,
record: serde_json::Value,
@@ -27,6 +113,10 @@ pub(in crate::agent) fn append_game_creator_agent_tool_plan_audit_idempotent(
.get("repairAttempt")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| "tool-plan 审计缺少 repairAttempt".to_string())?;
record
.get("appliedSteerCursor")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| "tool-plan 审计缺少 appliedSteerCursor".to_string())?;
if request_slot != format!("loop-{loop_iteration}-repair-{repair_attempt}") {
return Err("tool-plan 审计 requestSlot 与 loop/repair 身份不匹配".to_string());
}
@@ -182,6 +272,8 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
!= AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
|| agent_runtime_autonomous_project_verify_available(root);
let mut autonomous_scaffold_repair_active = false;
let mut supervisor_collaboration_repair_active = false;
let mut supervisor_collaboration_repair_actions = Vec::new();
for repair_attempt in 0..=format_repair_attempts {
if game_creator_agent_runtime_cancel_requested_for(root, agent_id, run_id) {
return Err("Agent 后台任务已收到取消请求".to_string());
@@ -275,10 +367,29 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
"{:x}",
Sha256::digest(response_handoff.provider_request_id.as_bytes())
);
let mut supervisor_collaboration_candidate_actions = None;
let parsed = parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified(
&response,
&mcp_catalog,
)
.map(|mut parsed| {
let merged = merge_supervisor_collaboration_repair_actions(
if supervisor_collaboration_repair_active {
&supervisor_collaboration_repair_actions
} else {
&[]
},
&parsed.plan.actions,
);
supervisor_collaboration_candidate_actions = Some(merged.clone());
if supervisor_collaboration_repair_active {
parsed.plan.plan_update = None;
parsed.plan.plan.clear();
parsed.plan.response.clear();
parsed.plan.actions = merged;
}
parsed
})
.and_then(|parsed| {
let source_payload = if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
let verification_gate = read_game_creator_agent_runtime_verification_gate(
@@ -522,6 +633,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
"loopIteration": loop_index,
"repairAttempt": repair_attempt,
"requestSlot": request_slot,
"appliedSteerCursor": request_snapshot.applied_steer_cursor,
"responseFingerprint": response_fingerprint,
"providerRequestIdSha256": provider_request_id_sha256,
"protocol": protocol,
@@ -586,6 +698,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
"loopIteration": loop_index,
"repairAttempt": repair_attempt,
"requestSlot": request_slot,
"appliedSteerCursor": request_snapshot.applied_steer_cursor,
"responseFingerprint": response_fingerprint,
"providerRequestIdSha256": provider_request_id_sha256,
"attempt": next_attempt,
@@ -728,7 +841,15 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
}
}
if force_supervisor_initial_collaboration {
supervisor_collaboration_repair_active = true;
if let Some(actions) = supervisor_collaboration_candidate_actions.take() {
supervisor_collaboration_repair_actions = actions;
}
restrict_agent_runtime_supervisor_collaboration_repair_tools(&mut request)?;
restrict_supervisor_collaboration_repair_to_missing_agents(
&mut request,
&protocol_error,
)?;
let instruction = if run_profile
== AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
{
@@ -939,3 +1060,55 @@ pub(crate) async fn request_game_creator_agent_background_tool_plan_for_test(
}
}
}
#[cfg(test)]
mod supervisor_collaboration_repair_tests {
use super::*;
fn collaboration_action(tool: &str, input: serde_json::Value) -> AgentRuntimeToolAction {
AgentRuntimeToolAction {
tool: tool.to_string(),
reason: None,
input,
}
}
#[test]
fn missing_static_agents_ignores_none_sentinel() {
assert!(supervisor_collaboration_missing_agent_ids(
"Project Supervisor 首批协作不满足项目合同:static=1/2 · missingStaticAgents=none"
)
.is_empty());
assert_eq!(
supervisor_collaboration_missing_agent_ids(
"Project Supervisor 首批协作不满足项目合同:missingStaticAgents=code-prototype,quality-review · isolatedChildrenTotal=0"
),
vec!["code-prototype".to_string(), "quality-review".to_string()]
);
}
#[test]
fn isolated_repair_replaces_the_single_accumulated_slot() {
let accumulated = vec![collaboration_action(
"agent.spawn_isolated",
serde_json::json!({
"children": [{"templateAgentId": "quality-review", "task": "旧检查任务"}],
"joinMode": "all"
}),
)];
let replacement = collaboration_action(
"agent.spawn_isolated",
serde_json::json!({
"children": [{"templateAgentId": "quality-review", "task": "修正后的检查任务"}],
"joinMode": "all"
}),
);
let merged = merge_supervisor_collaboration_repair_actions(
&accumulated,
std::slice::from_ref(&replacement),
);
assert_eq!(merged, vec![replacement]);
}
}
@@ -238,6 +238,8 @@ pub(crate) use interaction::{
answer_game_creator_agent_runtime_user_input_at, confirm_game_creator_agent_runtime_task_at,
pending_repository_context_drift_observation, reject_game_creator_agent_runtime_task_at,
};
#[cfg(test)]
pub(crate) use lifecycle_control::resolve_game_creator_agent_runtime_retry_configuration_at;
pub(crate) use lifecycle_control::{
append_game_creator_agent_runtime_queued_cancellation,
cancel_game_creator_agent_runtime_task_at,
@@ -738,6 +738,44 @@ pub(crate) fn append_game_creator_agent_runtime_queued_cancellation(
Ok(())
}
pub(crate) fn resolve_game_creator_agent_runtime_retry_configuration_at(
root: &Path,
task: &AgentRuntimeTaskRecord,
delegated: bool,
) -> Result<(String, String), String> {
let (run_profile, _) = agent_runtime_run_profile_identity_at(
root,
&task.agent_id,
&task.run_id,
Some(&task.run_profile),
Some(&task.run_profile_binding_fingerprint),
)?;
let source = if delegated {
"agent-delegate-retry".to_string()
} else if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
let binding = read_game_creator_agent_runtime_run_profile_binding(
root,
&task.agent_id,
&task.run_id,
)?
.ok_or_else(|| "自主构建 Agent Runtime 重试缺少 Run Profile 绑定".to_string())?;
if binding.parent_run_id.is_some()
|| binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|| binding.root_run_id != task.run_id
|| !matches!(
binding.source.as_str(),
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
)
{
return Err("自主构建 Agent Runtime 重试绑定不是可信 Supervisor 根 Run".to_string());
}
binding.source
} else {
"agent-background-task".to_string()
};
Ok((run_profile, source))
}
pub(crate) fn retry_game_creator_agent_runtime_task_at(
root: &Path,
agent_id: &str,
@@ -848,11 +886,12 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at(
}),
_ => None,
};
let retry_source = if retry_link.is_some() {
"agent-delegate-retry"
} else {
"agent-background-task"
};
let (retry_run_profile, retry_source) =
resolve_game_creator_agent_runtime_retry_configuration_at(
root,
&task,
retry_link.is_some(),
)?;
let (mut result, actual_retry_run_id) = with_agent_conversation_session_lane_at(
root,
&agent_id,
@@ -864,8 +903,8 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at(
Some(&task.session_id),
&task.task,
&retry_run_id,
retry_source,
Some(&task.run_profile),
&retry_source,
Some(&retry_run_profile),
retry_link.as_ref(),
)
},
@@ -205,6 +205,31 @@ fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState
revision
}
#[test]
fn autonomous_visual_ready_tasks_only_require_images_when_editor_api_key_is_configured() {
{
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
assert!(!autonomous_manifest_ready_task_requires_visual_asset(
"design-foundation"
));
assert!(!autonomous_manifest_ready_task_requires_visual_asset(
"art-asset-plan"
));
}
let _config_guard = crate::tests::write_test_local_config(
r#"{"editorApi":{"apiKey":"visual-ready-task-test-key"}}"#.to_string(),
);
assert!(autonomous_manifest_ready_task_requires_visual_asset(
"design-foundation"
));
assert!(autonomous_manifest_ready_task_requires_visual_asset(
"art-asset-plan"
));
assert!(!autonomous_manifest_ready_task_requires_visual_asset(
"code-prototype"
));
}
#[test]
fn autonomous_supervisor_empty_plan_uses_deterministic_final_reply_fallback() {
assert_eq!(
@@ -1117,10 +1117,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke
&task_text,
)?;
if status == GameCreationAppTaskStatus::Completed
&& matches!(
manifest_task.id.as_str(),
"design-foundation" | "art-asset-plan"
)
&& autonomous_manifest_ready_task_requires_visual_asset(&manifest_task.id)
&& !manifest_has_required_visual_asset(root, &manifest, &manifest_task.id)
{
status = GameCreationAppTaskStatus::Failed;
@@ -1199,6 +1196,11 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke
Ok(true)
}
pub(super) fn autonomous_manifest_ready_task_requires_visual_asset(task_id: &str) -> bool {
editor_api_key_is_configured()
&& matches!(task_id, "design-foundation" | "art-asset-plan")
}
pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt(
task: &GameCreationAppTaskState,
) -> String {
@@ -318,10 +318,14 @@ pub(crate) fn observe_agent_runtime_agent_delegate(
agent_runtime_tool_input_text(input, &["repairOfDelegationId", "repair_of_delegation_id"]);
let repair_of_delegation_id =
(!repair_of_delegation_id.is_empty()).then_some(repair_of_delegation_id);
let required_visual_artifact = match target_agent_id.as_str() {
"design-foundation" => Some("assets/ui-prototype.png"),
"art-asset-plan" => Some("assets/art-spritesheet.png"),
_ => None,
let required_visual_artifact = if editor_api_key_is_configured() {
match target_agent_id.as_str() {
"design-foundation" => Some("assets/ui-prototype.png"),
"art-asset-plan" => Some("assets/art-spritesheet.png"),
_ => None,
}
} else {
None
};
if repair_of_delegation_id.is_none()
&& required_visual_artifact.is_some_and(|required| {
@@ -1610,6 +1610,7 @@ pub(crate) fn append_agent_db_tool_plan_audit_idempotent(
"loopIteration",
"repairAttempt",
"requestSlot",
"appliedSteerCursor",
"responseFingerprint",
"providerRequestIdSha256",
"protocol",
@@ -1637,6 +1638,7 @@ pub(crate) fn append_agent_db_tool_plan_audit_idempotent(
"loopIteration",
"repairAttempt",
"requestSlot",
"appliedSteerCursor",
"responseFingerprint",
"providerRequestIdSha256",
"protocol",
@@ -1706,6 +1708,13 @@ pub(crate) fn append_agent_db_tool_plan_audit_idempotent(
return Err(format!("Agent DB tool-plan 幂等审计字段无效:{field}"));
}
}
if record
.get("appliedSteerCursor")
.and_then(serde_json::Value::as_u64)
.is_none()
{
return Err("Agent DB tool-plan 幂等审计字段无效:appliedSteerCursor".to_string());
}
let is_null_or_sha256 = |field: &str| match record.get(field) {
Some(serde_json::Value::Null) => true,
Some(serde_json::Value::String(value)) => is_valid_agent_db_sha256(value),
@@ -2398,6 +2407,10 @@ fn validate_agent_db_tool_plan_audit_records_unlocked(
.and_then(serde_json::Value::as_str)
.expect("validated tool-plan audit identity")
});
let applied_steer_cursor = expected
.get("appliedSteerCursor")
.and_then(serde_json::Value::as_u64)
.expect("validated tool-plan audit applied steer cursor");
file.seek(SeekFrom::Start(0))
.map_err(|error| format!("定位 Agent 本地索引失败:{}: {error}", path.display()))?;
let mut reader = BufReader::new(file);
@@ -2423,7 +2436,7 @@ fn validate_agent_db_tool_plan_audit_records_unlocked(
}
let record = serde_json::from_slice::<serde_json::Value>(&line.content)
.map_err(|error| format!("解析 Agent 本地索引失败:{}: {error}", path.display()))?;
let matches_key = [
let matches_legacy_key = [
"recordType",
"agentId",
"taskId",
@@ -2437,20 +2450,32 @@ fn validate_agent_db_tool_plan_audit_records_unlocked(
.all(|(field, value)| {
record.get(field).and_then(serde_json::Value::as_str) == Some(*value)
});
if !matches_key {
if !matches_legacy_key {
continue;
}
if !agent_db_stored_record_matches_expected_payload(&record, expected) {
let stored_applied_steer_cursor = match record.get("appliedSteerCursor") {
None => 0,
Some(value) => value.as_u64().ok_or_else(|| {
format!(
"Agent 本地索引 tool-plan 审计 appliedSteerCursor 无效:{}",
path.display()
)
})?,
};
if stored_applied_steer_cursor != applied_steer_cursor {
continue;
}
if !agent_db_tool_plan_stored_record_matches_expected_payload(&record, expected) {
return Err(format!(
"Agent 本地索引 tool-plan 幂等审计内容冲突:{}/{}/{}/{}",
identity[0], identity[1], identity[4], identity[6]
"Agent 本地索引 tool-plan 幂等审计内容冲突:{}/{}/{}/{}/steer-{}",
identity[0], identity[1], identity[4], identity[6], applied_steer_cursor
));
}
exact_matches = exact_matches.saturating_add(1);
if exact_matches > 1 {
return Err(format!(
"Agent 本地索引 tool-plan 幂等审计重复:{}/{}/{}/{}",
identity[0], identity[1], identity[4], identity[6]
"Agent 本地索引 tool-plan 幂等审计重复:{}/{}/{}/{}/steer-{}",
identity[0], identity[1], identity[4], identity[6], applied_steer_cursor
));
}
}
@@ -2464,6 +2489,32 @@ fn validate_agent_db_tool_plan_audit_records_unlocked(
Ok(exact_matches == 1)
}
fn agent_db_tool_plan_stored_record_matches_expected_payload(
stored: &serde_json::Value,
expected: &serde_json::Value,
) -> bool {
if stored.get("appliedSteerCursor").is_some() {
return agent_db_stored_record_matches_expected_payload(stored, expected);
}
if expected
.get("appliedSteerCursor")
.and_then(serde_json::Value::as_u64)
!= Some(0)
{
return false;
}
let mut normalized = stored.clone();
let Some(object) = normalized.as_object_mut() else {
return false;
};
object.insert(
"appliedSteerCursor".to_string(),
serde_json::Value::Number(serde_json::Number::from(0)),
);
agent_db_stored_record_matches_expected_payload(&normalized, expected)
}
fn validate_agent_db_action_records_unlocked(
file: &mut File,
path: &Path,
@@ -48,6 +48,7 @@ fn tool_plan_protocol_audit_record(
"loopIteration": 0,
"repairAttempt": 0,
"requestSlot": request_slot,
"appliedSteerCursor": 0,
"responseFingerprint": "1".repeat(64),
"providerRequestIdSha256": "2".repeat(64),
"protocol": "native_runtime_tools",
@@ -82,6 +83,7 @@ fn tool_plan_repair_audit_record(
"loopIteration": 0,
"repairAttempt": 0,
"requestSlot": request_slot,
"appliedSteerCursor": 0,
"responseFingerprint": "1".repeat(64),
"providerRequestIdSha256": "2".repeat(64),
"protocol": "native_runtime_tools",
@@ -760,6 +762,16 @@ fn tool_plan_audit_append_is_atomic_conflict_checked_and_agent_scoped() {
.expect_err("same tool-plan audit identity with new payload must conflict");
assert!(error.contains("内容冲突"), "{error}");
let mut steered = record.clone();
steered["appliedSteerCursor"] = serde_json::json!(1);
steered["responseFingerprint"] = serde_json::json!("3".repeat(64));
assert!(
append_agent_db_tool_plan_audit_idempotent(&root, steered.clone())
.expect("a new steer cursor must own a distinct tool-plan audit slot")
);
assert!(!append_agent_db_tool_plan_audit_idempotent(&root, steered)
.expect("same steered tool-plan audit remains idempotent"));
let same_run_other_agent =
tool_plan_protocol_audit_record("art-director", "shared-run-id", "loop-0-repair-0");
assert!(
@@ -808,6 +820,26 @@ fn tool_plan_audit_append_is_atomic_conflict_checked_and_agent_scoped() {
fs::remove_dir_all(&root).ok();
}
#[test]
fn legacy_tool_plan_audit_without_steer_cursor_matches_cursor_zero() {
let root = unique_agent_db_test_root("legacy-tool-plan-audit-cursor-zero");
fs::create_dir_all(root.join(".agent")).expect("create legacy Agent DB directory");
let current = tool_plan_repair_audit_record("design-director", "legacy-run", "loop-0-repair-0");
let mut legacy = current.clone();
legacy
.as_object_mut()
.expect("legacy audit object")
.remove("appliedSteerCursor");
let line = serialize_agent_db_record(legacy).expect("serialize legacy tool-plan audit");
fs::write(root.join(".agent/agent.db"), format!("{line}\n"))
.expect("write legacy tool-plan audit");
assert!(!append_agent_db_tool_plan_audit_idempotent(&root, current)
.expect("cursor zero must reuse the matching legacy audit"));
fs::remove_dir_all(&root).ok();
}
#[test]
fn tool_plan_protocol_audit_is_idempotent_across_processes() {
let record_type = "agent.runtime.tool_plan.protocol";
@@ -197,11 +197,17 @@ pub(crate) fn list_at(root: &Path) -> Result<Vec<AgentRuntimeProviderRetryRecord
)
})?;
let relative_path_text = relative_path
.to_str()
.ok_or_else(|| "Provider 重试相对路径必须是 UTF-8".to_string())?;
.iter()
.map(|component| {
component
.to_str()
.ok_or_else(|| "Provider 重试相对路径必须是 UTF-8".to_string())
})
.collect::<Result<Vec<_>, _>>()?
.join("/");
let record = read_agent_runtime_json_sidecar_with_max_bytes(
root,
relative_path_text,
&relative_path_text,
PROVIDER_RETRY_LABEL,
PROVIDER_RETRY_SIDECAR_MAX_BYTES,
)?
@@ -2039,6 +2039,9 @@ fn agent_native_delegate_contract_flows_through_parser_executor_and_delivery() {
#[test]
fn visual_specialist_delegations_require_image_artifacts_but_read_only_work_allows_none() {
let _config_guard = crate::tests::write_test_local_config(
r#"{"editorApi":{"apiKey":"visual-delegation-contract-key"}}"#.to_string(),
);
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "视觉委派合同测试").expect("project init");
let parent_run_id = "visual-delegate-contract-parent-run";
@@ -2120,3 +2123,57 @@ fn visual_specialist_delegations_require_image_artifacts_but_read_only_work_allo
fs::remove_dir_all(root).ok();
}
#[test]
fn visual_specialist_delegation_degrades_to_text_artifacts_without_editor_api_key() {
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "无图片密钥委派合同测试")
.expect("project init");
let parent_run_id = "text-only-design-delegate-parent-run";
start_game_creator_agent_runtime_task_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"委派文本玩法规格",
parent_run_id,
"agent-chat",
"准备委派",
vec!["允许无图片密钥降级".to_string()],
)
.expect("start supervisor parent runtime");
let target_agent_id = "design-foundation";
let action_id = "allow-text-only-design-artifacts";
let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id)
.expect("acquire design target lane")
.expect("design target lane available");
let observation = observe_agent_runtime_agent_delegate(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
Some(action_id),
&serde_json::json!({
"agentId": target_agent_id,
"task": "完成玩法规格与双视口界面说明",
"acceptanceCriteria": ["玩法规格可直接指导程序实现"],
"expectedArtifacts": ["memory/project.md", "game/game_design.md"],
"repairOfDelegationId": null,
"runId": null
}),
);
assert_eq!(observation.status, "ok", "{observation:?}");
let delegation_id = agent_runtime_delegation_id(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
target_agent_id,
action_id,
);
let delivery = read_static_delegate_delivery_at(&root, &delegation_id)
.expect("read text-only design delivery")
.expect("text-only design delivery exists");
assert_eq!(
delivery.expected_artifacts,
vec!["memory/project.md", "game/game_design.md"]
);
drop(target_lock);
fs::remove_dir_all(root).ok();
}
@@ -1421,7 +1421,7 @@ async fn supervisor_collaboration_empty_initial_plan_repairs_into_required_stati
}
#[tokio::test]
async fn supervisor_collaboration_read_only_first_window_repairs_with_collaboration_tools_only() {
async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboration_tools_only() {
let root = unique_project_path();
init_local_game_project_at(
&root,
@@ -1430,15 +1430,6 @@ async fn supervisor_collaboration_read_only_first_window_repairs_with_collaborat
)
.expect("project init");
let (sender, receiver) = mpsc::channel();
let update_arguments = serde_json::json!({
"explanation": "继续读取项目后再决定委派",
"steps": [
{"step": "读取项目入口", "status": "in_progress"},
{"step": "建立专业协作", "status": "pending"}
]
})
.to_string();
let read_arguments = serde_json::json!({"reason": "继续读取项目索引", "input": {}}).to_string();
let delegate_function =
native_runtime_function_name("agent.delegate").expect("delegate function");
let isolated_function =
@@ -1469,32 +1460,16 @@ async fn supervisor_collaboration_read_only_first_window_repairs_with_collaborat
.to_string();
let base_url = spawn_mock_llm_raw_responses_with_capture(
vec![
native_agent_tool_plan_chat_response_with_calls(vec![
(
"call-supervisor-read-only-plan",
AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME,
update_arguments,
),
(
"call-supervisor-read-only-index",
native_runtime_function_name("project.index")
.expect("index function")
.as_str(),
read_arguments,
),
]),
native_agent_tool_plan_chat_response_with_calls(vec![
(
"call-supervisor-read-only-code-delegate",
delegate_function.as_str(),
code_arguments,
),
(
"call-supervisor-read-only-quality-delegate",
delegate_function.as_str(),
quality_arguments,
),
]),
native_agent_tool_plan_chat_response(
"call-supervisor-initial-code-delegate",
delegate_function.as_str(),
code_arguments,
),
native_agent_tool_plan_chat_response(
"call-supervisor-read-only-quality-delegate",
delegate_function.as_str(),
quality_arguments,
),
],
Some(sender),
);
@@ -1563,8 +1538,7 @@ async fn supervisor_collaboration_read_only_first_window_repairs_with_collaborat
let repair_request = receiver
.recv_timeout(Duration::from_secs(2))
.expect("supervisor read-only collaboration repair request");
assert!(repair_request.contains("当前已到第 7 轮"));
assert!(repair_request.contains("不得继续只更新计划"));
assert!(repair_request.contains("missingStaticAgents=quality-review"));
let repair_request_json = mock_http_request_json(&repair_request);
let repair_function_names = repair_request_json["tools"]
.as_array()
@@ -1584,6 +1558,28 @@ async fn supervisor_collaboration_read_only_first_window_repairs_with_collaborat
repair_function_names,
BTreeSet::from([delegate_function.as_str(), isolated_function.as_str()])
);
let delegate_schema = repair_request_json["tools"]
.as_array()
.and_then(|tools| {
tools.iter().find(|tool| {
tool.get("name").and_then(serde_json::Value::as_str)
== Some(delegate_function.as_str())
|| tool
.get("function")
.and_then(|function| function.get("name"))
.and_then(serde_json::Value::as_str)
== Some(delegate_function.as_str())
})
})
.expect("missing-quality repair keeps agent.delegate");
let parameters = delegate_schema
.get("parameters")
.or_else(|| delegate_schema.pointer("/function/parameters"))
.expect("delegate parameters");
assert_eq!(
parameters["properties"]["input"]["properties"]["agentId"]["enum"],
serde_json::json!(["quality-review"])
);
assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err());
let records = read_agent_db_records_for_test(&root);
@@ -1594,7 +1590,9 @@ async fn supervisor_collaboration_read_only_first_window_repairs_with_collaborat
})
.collect::<Vec<_>>();
assert_eq!(repairs.len(), 1);
assert_eq!(repairs[0]["protocolErrorKind"], "plan-semantics");
assert!(repairs
.iter()
.all(|repair| repair["protocolErrorKind"] == "plan-semantics"));
assert_eq!(repairs[0]["repairAttempt"], 0);
let protocol = records
.iter()
@@ -1603,7 +1601,7 @@ async fn supervisor_collaboration_read_only_first_window_repairs_with_collaborat
})
.expect("repaired collaboration protocol audit");
assert_eq!(protocol["repairAttempt"], 1);
assert_eq!(protocol["functionCallCount"], 2);
assert_eq!(protocol["functionCallCount"], 1);
let collaboration_state = read_supervisor_collaboration_state_at(
&root,
@@ -1385,6 +1385,54 @@ fn spawn_mock_llm_server_responses_with_capture(
base_url
}
fn spawn_mock_llm_scripted_responses_with_capture(
response_contents: Vec<Option<String>>,
request_sender: mpsc::Sender<String>,
) -> String {
let listener = bind_test_tcp_listener("mock scripted llm bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
std::thread::spawn(move || {
for response_content in response_contents {
let (mut stream, _) = listener.accept().expect("mock scripted llm accept");
let request_text = read_mock_http_request(&mut stream);
let _ = request_sender.send(request_text.clone());
let Some(response_content) = response_content else {
drop(stream);
continue;
};
let body = if request_text.contains("POST /responses HTTP/1.1") {
serde_json::json!({
"id": "resp_game_creator_scripted_mock",
"model": "mock-game-model",
"output_text": response_content,
"status": "completed",
"usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 }
})
} else {
serde_json::json!({
"id": "chatcmpl_game_creator_scripted_mock",
"model": "mock-game-model",
"choices": [{
"message": { "content": response_content },
"finish_reason": "stop"
}],
"usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 }
})
}
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("mock scripted llm response");
}
});
base_url
}
fn spawn_interactive_mock_llm_server_with_capture(
response_count: usize,
request_sender: mpsc::Sender<String>,
@@ -4411,6 +4411,103 @@ async fn provider_retry_waiting_steer_supersedes_old_attempt_and_wakes_same_run(
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn provider_repair_retry_waiting_steer_uses_distinct_audit_cursor() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "Provider repair 等待 steer 测试")
.expect("project init");
let (request_sender, request_receiver) = mpsc::channel();
let base_url = spawn_mock_llm_scripted_responses_with_capture(
vec![
Some("first-invalid-tool-plan".to_string()),
None,
Some("steered-invalid-tool-plan".to_string()),
Some(final_tool_plan_response("steer 后 repair 已完成")),
],
request_sender,
);
let _config_guard = write_test_local_config(format!(
r#"{{
"agentLlm": {{
"design-director": {{
"apiKey": "design-key",
"baseUrl": {base_url:?},
"model": "design-runtime-model",
"apiKind": "openai_chat",
"stream": false,
"maxRetries": 1,
"retryBackoffMs": 30000
}}
}}
}}"#
));
let run_id = "design-provider-repair-wait-steer-run";
let started = start_game_creator_agent_background_task_at(
&root,
"design-director",
"先写 repair 审计,再进入 Provider retry 等待",
run_id,
)
.expect("start repair retry steer task");
request_receiver
.recv_timeout(Duration::from_secs(5))
.expect("initial invalid tool-plan request");
request_receiver
.recv_timeout(Duration::from_secs(5))
.expect("repair transport failure request");
wait_for_agent_runtime_phase(&root, "design-director", "waiting-for-provider-retry");
steer_game_creator_agent_runtime_task_at(
&root,
"design-director",
&started.state.session_id,
run_id,
"provider-repair-retry-steer-1",
"用新指令重新规划,不要复用旧 repair 审计槽",
"test",
)
.expect("steer waiting repair Provider retry");
request_receiver
.recv_timeout(Duration::from_secs(5))
.expect("fresh invalid tool-plan request after steer");
request_receiver
.recv_timeout(Duration::from_secs(5))
.expect("fresh repair request after steer");
let completed = wait_for_agent_runtime_idle(&root, "design-director");
assert_eq!(completed.phase, "completed");
assert_eq!(completed.run_id, run_id);
assert_eq!(completed.applied_steer_cursor, 1);
assert_eq!(
completed.last_response.as_deref(),
Some("steer 后 repair 已完成")
);
let records = read_agent_db_records_for_test(&root);
let repair_audits = records
.iter()
.filter(|record| {
record["recordType"] == "agent.runtime.tool_plan.repair"
&& record["runId"] == run_id
&& record["requestSlot"] == "loop-1-repair-0"
})
.collect::<Vec<_>>();
assert_eq!(repair_audits.len(), 2);
assert_eq!(
repair_audits
.iter()
.filter_map(|record| record["appliedSteerCursor"].as_u64())
.collect::<BTreeSet<_>>(),
BTreeSet::from([0, 1])
);
assert!(records.iter().all(|record| {
record["recordType"] != "agent.runtime.background_task.failed" || record["runId"] != run_id
}));
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn provider_retry_waiting_exhaustion_fails_and_removes_sidecar() {
let root = unique_project_path();
@@ -75,6 +75,7 @@ pub(super) use crate::{
read_recent_game_creator_agent_runtime_events, redact_secret_tokens,
reject_game_creator_agent_runtime_task, reject_game_creator_agent_runtime_task_at,
render_evaluator_findings, request_game_creator_agent_background_tool_plan_for_test,
resolve_game_creator_agent_runtime_retry_configuration_at,
resume_game_creator_agent_background_tasks_at, resume_game_creator_agent_runtime_tasks,
retry_game_creator_agent_runtime_task_at, schedule_game_creator_agent_ready_tasks_at,
start_game_creator_agent_background_task_at,
@@ -272,6 +272,68 @@ async fn background_agent_runtime_can_cancel_active_task_and_retry_it() {
fs::remove_dir_all(root).ok();
}
#[test]
fn autonomous_supervisor_retry_restores_trusted_source_from_run_profile_binding() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-autonomous-retry-source", "自主重试来源测试")
.expect("project init");
let original_run_id = "supervisor-autonomous-retry-source";
let binding = bind_game_creator_agent_runtime_run_profile_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
original_run_id,
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("bind autonomous Supervisor run");
let task = AgentRuntimeTaskRecord {
goal_id: None,
goal_revision: 0,
goal_status: None,
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(),
task_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(),
session_id: "agent-session-project-supervisor".to_string(),
run_id: original_run_id.to_string(),
source: "agent-background-task".to_string(),
run_profile: binding.profile,
run_profile_binding_fingerprint: binding.binding_fingerprint,
parent_agent_id: None,
parent_run_id: None,
delegation_id: None,
task: "构建并验证一个完整的自主游戏".to_string(),
status: "failed".to_string(),
phase: "failed".to_string(),
current_action: "测试失败".to_string(),
terminal_detail: Some("测试失败".to_string()),
error: Some("测试失败".to_string()),
updated_at: unix_timestamp(),
};
let (profile, source) =
resolve_game_creator_agent_runtime_retry_configuration_at(&root, &task, false)
.expect("resolve autonomous Supervisor retry configuration");
assert_eq!(profile, AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD);
assert_eq!(source, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE);
let standard_task = AgentRuntimeTaskRecord {
run_id: "standard-retry-source".to_string(),
run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(),
run_profile_binding_fingerprint: String::new(),
..task
};
let (_, standard_source) =
resolve_game_creator_agent_runtime_retry_configuration_at(&root, &standard_task, false)
.expect("resolve standard retry configuration");
assert_eq!(standard_source, "agent-background-task");
let (_, delegated_source) =
resolve_game_creator_agent_runtime_retry_configuration_at(&root, &standard_task, true)
.expect("resolve delegated retry configuration");
assert_eq!(delegated_source, "agent-delegate-retry");
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_can_cancel_pending_task_before_drain() {
let root = unique_project_path();
@@ -46,6 +46,7 @@ pub(crate) fn failure_kind(error: &str) -> &'static str {
} else if error.contains("写入")
|| error.contains("读取")
|| error.contains("创建")
|| error.contains("安装")
|| error.contains("目录")
|| error.contains("文件")
|| error.contains("权限")
@@ -751,9 +751,10 @@ fn rename_windows_tool_plan_file_at(
) -> Result<(), String> {
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::{
FileRenameInfo, SetFileInformationByHandle, FILE_RENAME_INFO,
use windows_sys::Wdk::Storage::FileSystem::{
FileRenameInformation, NtSetInformationFile, FILE_RENAME_INFORMATION,
};
use windows_sys::Win32::System::IO::IO_STATUS_BLOCK;
let wide_name = std::ffi::OsStr::new(new_name)
.encode_wide()
@@ -763,13 +764,13 @@ fn rename_windows_tool_plan_file_at(
.checked_mul(2)
.and_then(|value| u32::try_from(value).ok())
.ok_or_else(|| format!("{label} 目标名称过长"))?;
let header_bytes = std::mem::offset_of!(FILE_RENAME_INFO, FileName);
let header_bytes = std::mem::offset_of!(FILE_RENAME_INFORMATION, FileName);
let total_bytes = header_bytes
.checked_add(name_bytes as usize)
.ok_or_else(|| format!("{label} 重命名缓冲区过大"))?;
let word_bytes = std::mem::size_of::<usize>();
let mut buffer = vec![0usize; total_bytes.div_ceil(word_bytes)];
let information = buffer.as_mut_ptr().cast::<FILE_RENAME_INFO>();
let information = buffer.as_mut_ptr().cast::<FILE_RENAME_INFORMATION>();
// SAFETY: buffer is aligned and sized for the fixed header plus the complete UTF-16 name.
unsafe {
(*information).Anonymous.ReplaceIfExists = replace;
@@ -781,19 +782,29 @@ fn rename_windows_tool_plan_file_at(
wide_name.len(),
);
}
// SAFETY: file owns a DELETE-capable handle and information spans total_bytes bytes.
if unsafe {
SetFileInformationByHandle(
let mut io_status = IO_STATUS_BLOCK::default();
// SAFETY: file owns a DELETE-capable handle, parent is a verified directory handle,
// and information spans the fixed header plus the complete relative UTF-16 name.
// NtSetInformationFile is required here because SetFileInformationByHandle rejects
// a non-null RootDirectory with ERROR_INVALID_PARAMETER on Windows.
let status = unsafe {
NtSetInformationFile(
file.as_raw_handle().cast(),
FileRenameInfo,
&mut io_status,
information.cast(),
total_bytes as u32,
FileRenameInformation,
)
} == 0
{
};
if status < 0 {
unsafe extern "system" {
fn RtlNtStatusToDosError(status: i32) -> u32;
}
// SAFETY: conversion accepts any NTSTATUS and returns a Win32 error code.
let code = unsafe { RtlNtStatusToDosError(status) };
return Err(format!(
"按句柄安装 {label} 失败:{}",
std::io::Error::last_os_error()
std::io::Error::from_raw_os_error(code as i32)
));
}
Ok(())
@@ -45,6 +45,14 @@ use crate::agent::{
};
use crate::provider_retry::{self, AgentRuntimeProviderRetryIdentity};
#[test]
fn tool_plan_handoff_classifies_windows_handle_install_failure_as_storage() {
assert_eq!(
failure_kind("按句柄安装 tool-plan 成功响应交接账本 失败:参数错误。 (os error 87)"),
"tool-plan-storage"
);
}
fn identity(slot: &str) -> AgentRuntimeProviderRetryIdentity {
identity_for(slot, "project-supervisor", "run-tool-plan-handoff")
}
+98 -1
View File
@@ -4884,11 +4884,82 @@ export function App({
if (!invoke || !nextProjectPath || !currentRuntime || chatAgentBusy) {
throw new Error('项目总控状态已变化,请等待刷新后重试');
}
const needsReconciliation =
currentRuntime.status === 'needs-reconciliation' ||
currentRuntime.phase === 'needs-reconciliation';
if (needsReconciliation) {
if (
currentRuntime.runId !== runtime.runId ||
currentRuntime.agentId !== PROJECT_SUPERVISOR_AGENT_ID
) {
throw new Error('项目总控待核对任务已变化,请等待刷新');
}
const previousRunId = currentRuntime.runId;
setChatAgentBusy(true);
setProjectSupervisorRuntimeError('');
try {
const result = await invoke<AgentRuntimeResult>(
'cancel_game_creator_agent_runtime_task',
{
projectPath: nextProjectPath,
agentId: PROJECT_SUPERVISOR_AGENT_ID,
runId: previousRunId,
},
);
if (
localProjectPathRef.current !== nextProjectPath ||
projectSupervisorRuntimeRef.current?.runId !== previousRunId
) {
return '项目已切换,未把旧项目的取消状态合并到当前界面';
}
const nextRuntime = agentRuntimeStateFromResult(result, currentRuntime);
if (
nextRuntime.agentId !== PROJECT_SUPERVISOR_AGENT_ID ||
nextRuntime.runId !== previousRunId ||
nextRuntime.sessionId !== currentRuntime.sessionId
) {
throw new Error('项目总控取消后的 Runtime 身份不匹配');
}
updateProjectSupervisorRuntime(nextRuntime);
updateProjectSupervisorResponseStream(
result.responseStream,
nextRuntime,
);
setCommandLog((current) => [
...current,
'agent.runtime.cancel project-supervisor reconciliation',
]);
const queuePending = nextRuntime.taskQueue?.pending ?? 0;
if (
nextRuntime.status === 'cancelled' ||
nextRuntime.phase === 'cancelled'
) {
return queuePending > 0
? `旧任务已结束,队列中还有 ${queuePending} 个待处理任务,队列将继续处理`
: '旧任务已结束,当前队列为空,可重新启动项目总控';
}
return '已提交结束旧任务请求,正在同步取消状态';
} catch (error) {
throw new Error(
`项目总控旧任务结束失败:${
error instanceof Error ? error.message : String(error)
}`,
);
} finally {
setChatAgentBusy(false);
}
}
const cancelledWithEmptyQueue =
(currentRuntime.status === 'cancelled' ||
currentRuntime.phase === 'cancelled') &&
(currentRuntime.taskQueue?.pending ?? 0) === 0;
if (
currentRuntime.runId !== runtime.runId ||
currentRuntime.agentId !== PROJECT_SUPERVISOR_AGENT_ID ||
!(
currentRuntime.status === 'failed' || currentRuntime.phase === 'failed'
currentRuntime.status === 'failed' ||
currentRuntime.phase === 'failed' ||
cancelledWithEmptyQueue
) ||
currentRuntime.pendingToolAction
) {
@@ -9025,6 +9096,32 @@ export function App({
for (const runtimeResult of runtimes) {
nextRuntimes.push(agentRuntimeStateFromResult(runtimeResult));
}
const supervisorRuntimeIndex = nextRuntimes.findIndex(
(runtime) => runtime.agentId === PROJECT_SUPERVISOR_AGENT_ID,
);
const persistedSupervisorRuntime =
supervisorRuntimeIndex >= 0
? nextRuntimes[supervisorRuntimeIndex]!
: null;
if (projectSupervisorOnly && persistedSupervisorRuntime) {
const currentRuntime = projectSupervisorRuntimeRef.current;
if (
!currentRuntime ||
persistedSupervisorRuntime.updatedAt >= currentRuntime.updatedAt
) {
if (persistedSupervisorRuntime.sessionId) {
projectSupervisorSessionIdRef.current =
persistedSupervisorRuntime.sessionId;
setProjectSupervisorSessionId(persistedSupervisorRuntime.sessionId);
}
updateProjectSupervisorRuntime(persistedSupervisorRuntime);
updateProjectSupervisorResponseStream(
runtimes[supervisorRuntimeIndex]?.responseStream,
persistedSupervisorRuntime,
);
setProjectSupervisorRuntimeError('');
}
}
setAgentRuntimeById((current) =>
nextRuntimes.reduce(
(next, runtime) => mergeAgentRuntimeStateIntoMap(next, runtime, true),
@@ -1268,6 +1268,9 @@ export function isAgentFinalizationMessageId(
}
export function projectRuntimeStatusPresentation(runtime: AgentRuntimeState) {
if (runtime.phase === 'needs-reconciliation') {
return { label: '待核对', tone: 'failed' };
}
if (
runtime.userInputRequest ||
runtime.status === 'waiting-for-user-input' ||
@@ -699,7 +699,8 @@ export function ProjectSupervisorRuntimePanel({
setSupervisorRetryFeedback('');
}
}, [runtime?.phase, runtime?.runId, runtime?.status]);
const status = projectSupervisorRuntimeStatusLabel(runtime, error);
const status =
projectSupervisorRuntimeStatusLabel(runtime, error) ?? '尚未开始';
const collaboratingRuntimes = projectSupervisorCollaboratingAgentRuntimes(
runtime,
runtimeByAgentId,
@@ -727,9 +728,6 @@ export function ProjectSupervisorRuntimePanel({
panelRef.current.scrollTop = 0;
}
}, [visibleSnapshotKey]);
if (!status) {
return null;
}
const rawStatusDetail = error || runtime?.error || '';
const statusDetail = rawStatusDetail
? projectRuntimeVisibleError(rawStatusDetail, '项目总控 Agent', true)
@@ -752,10 +750,22 @@ export function ProjectSupervisorRuntimePanel({
: null;
const userInputRequest = runtime?.userInputRequest ?? null;
const needsUserInput = agentRuntimeNeedsUserInput(runtime);
const needsSupervisorReconciliation = Boolean(
runtime &&
(runtime.status === 'needs-reconciliation' ||
runtime.phase === 'needs-reconciliation'),
);
const cancelledSupervisorQueuePending =
runtime && (runtime.status === 'cancelled' || runtime.phase === 'cancelled')
? (runtime.taskQueue?.pending ?? 0)
: 0;
const canRetrySupervisor = Boolean(
runtime &&
!pendingToolAction &&
(runtime.status === 'failed' || runtime.phase === 'failed') &&
(runtime.status === 'failed' ||
runtime.phase === 'failed' ||
((runtime.status === 'cancelled' || runtime.phase === 'cancelled') &&
cancelledSupervisorQueuePending === 0)) &&
agentRuntimeCanRetry(runtime.status),
);
const activeCollaboratingAgents = collaboratingRuntimes.filter(
@@ -788,6 +798,15 @@ export function ProjectSupervisorRuntimePanel({
) : null}
</header>
{statusDetail ? <small>{statusDetail}</small> : null}
{!runtime && !statusDetail ? (
<div
className="project-runtime-empty-state"
aria-label="项目总控 Agent 尚未开始"
>
<Target size={22} aria-hidden="true" />
<strong></strong>
</div>
) : null}
{runtime ? (
<div className="project-runtime-overview" aria-label="当前工作状态">
<strong>{projectRuntimeVisibleCurrentWork(runtime)}</strong>
@@ -808,22 +827,35 @@ export function ProjectSupervisorRuntimePanel({
{compactProgress ? (
<small aria-label="项目总控 Agent 进度">{compactProgress}</small>
) : null}
{canRetrySupervisor && runtime ? (
{(needsSupervisorReconciliation || canRetrySupervisor) && runtime ? (
<div
className="project-runtime-recovery"
aria-label="项目总控 Agent 失败恢复"
aria-label={
needsSupervisorReconciliation
? '项目总控 Agent 待核对恢复'
: '项目总控 Agent 失败恢复'
}
>
<span>
{activeCollaboratingAgents.length > 0
? `${activeCollaboratingAgents
.map((professionalRuntime) =>
projectProfessionalAgentLabel(
professionalRuntime.agentId,
),
)
.join('、')}仍在运行。`
: '本轮项目总控已停止。'}
<small></small>
{needsSupervisorReconciliation ? (
<>
<small></small>
</>
) : (
<>
{activeCollaboratingAgents.length > 0
? `${activeCollaboratingAgents
.map((professionalRuntime) =>
projectProfessionalAgentLabel(
professionalRuntime.agentId,
),
)
.join('、')}仍在运行。`
: '本轮项目总控已停止。'}
<small></small>
</>
)}
</span>
<button
type="button"
@@ -833,7 +865,11 @@ export function ProjectSupervisorRuntimePanel({
supervisorRetryAccepted
}
onClick={() => {
setSupervisorRetryFeedback('正在重试项目总控…');
setSupervisorRetryFeedback(
needsSupervisorReconciliation
? '正在结束待核对的旧任务…'
: '正在重试项目总控…',
);
setSupervisorRetrySubmitting(true);
void onSupervisorRetry(runtime)
.then((message) => {
@@ -856,19 +892,32 @@ export function ProjectSupervisorRuntimePanel({
}}
>
{supervisorRetrySubmitting
? '正在重试项目总控…'
? needsSupervisorReconciliation
? '正在结束旧任务…'
: '正在重试项目总控…'
: supervisorRetryAccepted
? '重试已受理'
: '在当前项目重试总控'}
? needsSupervisorReconciliation
? '旧任务结束请求已受理'
: '重试已受理'
: needsSupervisorReconciliation
? '已核对,结束旧任务'
: '在当前项目重试总控'}
</button>
</div>
) : null}
{cancelledSupervisorQueuePending > 0 && !supervisorRetryFeedback ? (
<small className="project-runtime-retry-feedback" role="status">
{`旧任务已结束,队列中还有 ${cancelledSupervisorQueuePending} 个待处理任务,队列将继续处理`}
</small>
) : null}
{supervisorRetryFeedback ? (
<small className="project-runtime-retry-feedback" role="status">
{supervisorRetryFeedback}
</small>
) : null}
{pendingToolAction && pendingActionPresentation ? (
{pendingToolAction &&
pendingActionPresentation &&
!needsSupervisorReconciliation ? (
<div
className="pending-command project-runtime-pending-command"
aria-label="项目总控 Agent 待确认动作"
+15
View File
@@ -4047,6 +4047,21 @@ iframe.preview-frame {
background: #f8fafd;
}
.project-runtime-empty-state {
display: grid;
min-height: 112px;
place-content: center;
justify-items: center;
gap: 10px;
color: #d9794c;
text-align: center;
}
.project-runtime-empty-state strong {
color: #55443c;
font-size: 13px;
}
.game-workbench-chat
.project-runtime-summary
> small[aria-label='项目总控 Agent 进度'] {
@@ -702,6 +702,347 @@ export function registerUserSurfaceBoundaryTests() {
}
export function registerProjectSupervisorSurfaceTests() {
it('keeps the Project Supervisor welcome and empty runtime surface before the first message', async () => {
const projectPath = '/tmp/launcher-empty-supervisor-game';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-empty-supervisor-game',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
initialSessionExists: false,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-empty-supervisor-game',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
fireEvent.change(screen.getByLabelText('项目目录'), {
target: { value: projectPath },
});
fireEvent.click(screen.getByRole('button', { name: '打开' }));
const supervisorSurface = await screen.findByLabelText('项目总控对话');
const messageList =
within(supervisorSurface).getByLabelText('项目总控消息');
expect(
await within(messageList).findByText('想做什么游戏?'),
).not.toBeNull();
expect(
within(supervisorSurface).getByLabelText('项目总控 Agent 状态'),
).not.toBeNull();
expect(
within(supervisorSurface).getByText('项目总控 Agent · 尚未开始'),
).not.toBeNull();
expect(
within(supervisorSurface).getByText('告诉陶泥儿你想做什么游戏'),
).not.toBeNull();
expect(
within(supervisorSurface).getByRole('button', { name: '发送' }),
).toHaveProperty('disabled', false);
});
it('hydrates a persisted needs-reconciliation Supervisor runtime without an active Session index', async () => {
const projectPath = '/tmp/launcher-reconciliation-supervisor-game';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-reconciliation-supervisor-game',
);
const reconciliationRuntime = {
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'project-supervisor',
taskId: 'project-supervisor',
sessionId: 'persisted-supervisor-session',
runId: 'persisted-reconciliation-run',
source: 'project-supervisor',
status: 'needs-reconciliation',
phase: 'needs-reconciliation',
currentTask: '帮我生成一个贪吃蛇',
currentGoal: '完成贪吃蛇原型',
currentAction: '等待核对 Provider 回复交接',
waitingOn: '人工核对',
nextStep: '核对后继续或取消',
plan: [],
observations: [],
allowedTools: [],
pendingToolAction: null,
lastResponse: null,
error: 'tool-plan-unknown',
updatedAt: 7000,
};
const cancelledQueue = {
total: 2,
pending: 1,
running: 0,
waitingForConfirmation: 0,
waitingForUserInput: 0,
cancelled: 1,
completed: 0,
failed: 0,
latestRunId: 'persisted-reconciliation-run',
updatedAt: 8000,
};
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
initialSessionExists: false,
initialRuntime: reconciliationRuntime,
runtimeMapLoader: async () => [reconciliationRuntime],
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-reconciliation-supervisor-game',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'cancel_game_creator_agent_runtime_task') {
const state = supervisorHarness.runtimeState({
...reconciliationRuntime,
status: 'cancelled',
phase: 'cancelled',
currentAction: '待核对的旧任务已结束',
waitingOn: '队列中的下一个任务',
error: null,
taskQueue: cancelledQueue,
updatedAt: 8000,
});
return {
...supervisorHarness.runtimeResult(state),
taskQueue: cancelledQueue,
};
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
fireEvent.change(screen.getByLabelText('项目目录'), {
target: { value: projectPath },
});
fireEvent.click(screen.getByRole('button', { name: '打开' }));
const supervisorSurface = await screen.findByLabelText('项目总控对话');
expect(
await within(supervisorSurface).findByText('项目总控 Agent · 失败'),
).not.toBeNull();
expect(
within(supervisorSurface).getByText('当前阶段:待核对'),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('read_game_creator_agent_runtimes', {
projectPath,
});
const reconcileButton = within(supervisorSurface).getByRole('button', {
name: '已核对,结束旧任务',
});
fireEvent.click(reconcileButton);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'cancel_game_creator_agent_runtime_task',
{
projectPath,
agentId: 'project-supervisor',
runId: 'persisted-reconciliation-run',
},
);
});
expect(invoke).not.toHaveBeenCalledWith(
'confirm_retry_game_creator_agent_runtime_task',
expect.anything(),
);
expect(
await within(supervisorSurface).findByText(
'旧任务已结束,队列中还有 1 个待处理任务,队列将继续处理',
),
).not.toBeNull();
expect(
within(supervisorSurface).queryByRole('button', {
name: '在当前项目重试总控',
}),
).toBeNull();
});
it('allows retrying a cancelled reconciled Supervisor only after its queue is empty', async () => {
const projectPath = '/tmp/launcher-reconciliation-empty-queue';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-reconciliation-empty-queue',
);
const runId = 'reconciliation-empty-queue-run';
const reconciliationRuntime = {
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'project-supervisor',
taskId: 'project-supervisor',
sessionId: 'reconciliation-empty-queue-session',
runId,
source: 'project-supervisor',
status: 'needs-reconciliation',
phase: 'needs-reconciliation',
currentTask: '生成贪吃蛇原型',
currentGoal: '完成可玩原型',
currentAction: '等待核对 Provider 回复交接',
waitingOn: '人工核对',
nextStep: '核对后结束旧任务',
plan: [],
observations: [],
allowedTools: [],
pendingToolAction: null,
lastResponse: null,
error: 'tool-plan-unknown',
updatedAt: 7000,
};
const emptyQueue = {
total: 1,
pending: 0,
running: 0,
waitingForConfirmation: 0,
waitingForUserInput: 0,
cancelled: 1,
completed: 0,
failed: 0,
latestRunId: runId,
updatedAt: 8000,
};
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
sessionId: 'reconciliation-empty-queue-session',
initialSessionExists: false,
initialRuntime: reconciliationRuntime,
runtimeMapLoader: async () => [reconciliationRuntime],
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-reconciliation-empty-queue',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'cancel_game_creator_agent_runtime_task') {
const state = supervisorHarness.runtimeState({
...reconciliationRuntime,
status: 'cancelled',
phase: 'cancelled',
currentAction: '待核对的旧任务已结束',
waitingOn: '',
error: null,
taskQueue: emptyQueue,
updatedAt: 8000,
});
return {
...supervisorHarness.runtimeResult(state),
taskQueue: emptyQueue,
};
}
if (command === 'confirm_retry_game_creator_agent_runtime_task') {
const nextRunId = String(args?.nextRunId ?? '');
const state = supervisorHarness.runtimeState({
...reconciliationRuntime,
runId: nextRunId,
status: 'running',
phase: 'planning',
currentAction: '重新生成项目总控计划',
waitingOn: 'Agent 输出计划或回复',
error: null,
updatedAt: 9000,
});
return {
...supervisorHarness.runtimeResult(state),
acceptedRunId: nextRunId,
};
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
fireEvent.change(screen.getByLabelText('项目目录'), {
target: { value: projectPath },
});
fireEvent.click(screen.getByRole('button', { name: '打开' }));
const supervisorSurface = await screen.findByLabelText('项目总控对话');
fireEvent.click(
await within(supervisorSurface).findByRole('button', {
name: '已核对,结束旧任务',
}),
);
expect(
await within(supervisorSurface).findByText(
'旧任务已结束,当前队列为空,可重新启动项目总控',
),
).not.toBeNull();
const retryButton = await within(supervisorSurface).findByRole('button', {
name: '在当前项目重试总控',
});
fireEvent.click(retryButton);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'confirm_retry_game_creator_agent_runtime_task',
{
projectPath,
agentId: 'project-supervisor',
runId,
nextRunId: expect.stringMatching(/^project-supervisor-retry-/),
},
);
});
const cancelCallIndex = invoke.mock.calls.findIndex(
([command]) => command === 'cancel_game_creator_agent_runtime_task',
);
const retryCallIndex = invoke.mock.calls.findIndex(
([command]) =>
command === 'confirm_retry_game_creator_agent_runtime_task',
);
expect(cancelCallIndex).toBeGreaterThanOrEqual(0);
expect(retryCallIndex).toBeGreaterThan(cancelCallIndex);
});
it('loads and continues the active Project Supervisor Session in the standalone chat surface', async () => {
const projectPath = '/tmp/supervisor-chat-only-game';
const historyMessage = '已持久化的项目总控历史';

Some files were not shown because too many files have changed in this diff Show More