修复AGC总控恢复与工具计划交接
修复Windows下tool-plan账本按句柄原子安装失败。 补齐项目总控空态、持久Runtime水合与核对后重试交互。 修正Responses多角色内容映射与steer审计幂等身份。 修复首批协作repair累积、缺失Agent约束和isolated单槽替换。 收紧API Key安全持久化检测并覆盖装饰赋值与自然语言边界。 修复Provider retry恢复扫描与无画布密钥时的视觉产物降级。 补充定向回归测试和项目技术文档。
This commit is contained in:
@@ -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"] }
|
||||
|
||||
+212
-10
@@ -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(¤t[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 = ¤t[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();
|
||||
}
|
||||
|
||||
+38
-40
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 待确认动作"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user