合并主分支

合入主分支的智能体根任务可观测性、队列恢复与前端状态更新。
保留角色动作正式序列、后台预览换签和画布导出修复。
完成合并树验证并保持现有契约测试。
This commit is contained in:
2026-08-06 15:17:22 +08:00
22 changed files with 2138 additions and 204 deletions
@@ -156,6 +156,13 @@ pub(crate) fn render_local_conversation_prompt_context_for_session(
agent_label: &str,
) {
for message in conversation.messages {
if message.message_id.as_deref().is_some_and(|message_id| {
message_id
.trim()
.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
}) {
continue;
}
let content = sanitize_prompt_context(&message.content)
.split_whitespace()
.collect::<Vec<_>>()
@@ -240,7 +240,7 @@ pub(in crate::agent) use pending_execution::*;
pub(in crate::agent) use pending_recovery::*;
pub(in crate::agent) use provider_recovery::*;
pub(in crate::agent) use recovery_scan::*;
pub(in crate::agent) use task_queue::*;
pub(crate) use task_queue::*;
pub(in crate::agent) use task_start::*;
#[cfg(test)]
@@ -353,10 +353,139 @@ fn latest_game_chat_deadline_runtime_at(
.unwrap_or(fallback)
}
struct GameChatAbsoluteDeadlinePublicStates {
root: AgentRuntimeState,
child: Option<AgentRuntimeState>,
}
fn resolve_game_chat_absolute_deadline_public_states_at(
root: &Path,
runtime: &AgentRuntimeState,
phase: &str,
) -> Result<GameChatAbsoluteDeadlinePublicStates, String> {
let binding = read_game_creator_agent_runtime_run_profile_binding(
root,
&runtime.agent_id,
&runtime.run_id,
)?
.ok_or_else(|| "game-chat 绝对硬截止缺少当前 Run Profile 绑定".to_string())?;
if binding.agent_id != runtime.agent_id
|| binding.run_id != runtime.run_id
|| binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|| binding.root_run_id.trim().is_empty()
|| binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
{
return Err("game-chat 绝对硬截止当前 Run Profile 绑定身份不一致".to_string());
}
let root_binding = read_game_creator_agent_runtime_run_profile_binding(
root,
&binding.root_agent_id,
&binding.root_run_id,
)?
.ok_or_else(|| "game-chat 绝对硬截止缺少根 Run Profile 绑定".to_string())?;
if root_binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|| root_binding.run_id != binding.root_run_id
|| root_binding.root_agent_id != root_binding.agent_id
|| root_binding.root_run_id != root_binding.run_id
|| root_binding.parent_agent_id.is_some()
|| root_binding.parent_run_id.is_some()
|| root_binding.source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
|| root_binding.profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
{
return Err("game-chat 绝对硬截止根 Run Profile 绑定身份不一致".to_string());
}
let root_task = read_latest_game_creator_agent_runtime_task_by_run_id(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&root_binding.run_id,
)?
.ok_or_else(|| "game-chat 绝对硬截止缺少根 Supervisor 任务".to_string())?;
if root_task.parent_agent_id.is_some()
|| root_task.parent_run_id.is_some()
|| root_task.session_id.trim().is_empty()
|| root_task.run_profile_binding_fingerprint != root_binding.binding_fingerprint
{
return Err("game-chat 绝对硬截止根 Supervisor 任务身份不一致".to_string());
}
let mut root_state = agent_runtime_state_from_task_record(&root_task);
root_state.status = "failed".to_string();
root_state.phase = phase.to_string();
if runtime.agent_id == root_state.agent_id && runtime.run_id == root_state.run_id {
if runtime.session_id != root_state.session_id
|| runtime.parent_agent_id.is_some()
|| runtime.parent_run_id.is_some()
|| binding.parent_agent_id.is_some()
|| binding.parent_run_id.is_some()
|| runtime.run_profile_binding_fingerprint != root_binding.binding_fingerprint
{
return Err("game-chat 绝对硬截止当前根 Runtime 身份不一致".to_string());
}
return Ok(GameChatAbsoluteDeadlinePublicStates {
root: root_state,
child: None,
});
}
if runtime.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|| runtime.session_id.trim().is_empty()
|| runtime.parent_agent_id.as_deref() != Some(root_state.agent_id.as_str())
|| runtime.parent_run_id.as_deref() != Some(root_state.run_id.as_str())
|| binding.parent_agent_id.as_deref() != Some(root_state.agent_id.as_str())
|| binding.parent_run_id.as_deref() != Some(root_state.run_id.as_str())
|| runtime.run_profile_binding_fingerprint != binding.binding_fingerprint
{
return Err("game-chat 绝对硬截止当前专业 Agent Runtime 身份不一致".to_string());
}
let child_task = read_latest_game_creator_agent_runtime_task_by_run_id(
root,
&runtime.agent_id,
&runtime.run_id,
)?
.ok_or_else(|| "game-chat 绝对硬截止缺少当前专业 Agent 任务".to_string())?;
if child_task.session_id != runtime.session_id
|| child_task.parent_agent_id != runtime.parent_agent_id
|| child_task.parent_run_id != runtime.parent_run_id
|| child_task.run_profile_binding_fingerprint != binding.binding_fingerprint
{
return Err("game-chat 绝对硬截止当前专业 Agent 任务身份不一致".to_string());
}
let mut child_state = agent_runtime_state_from_task_record(&child_task);
child_state.status = "failed".to_string();
child_state.phase = phase.to_string();
Ok(GameChatAbsoluteDeadlinePublicStates {
root: root_state,
child: Some(child_state),
})
}
fn append_resolved_game_chat_absolute_deadline_public_messages_at(
root: &Path,
states: &GameChatAbsoluteDeadlinePublicStates,
error: &str,
) -> Result<(), String> {
append_game_creator_agent_runtime_terminal_public_message_at(root, &states.root, error)?;
if let Some(child) = states.child.as_ref() {
append_game_creator_agent_runtime_terminal_public_message_at(root, child, error)?;
}
Ok(())
}
pub(super) fn append_game_chat_absolute_deadline_public_messages_at(
root: &Path,
runtime: &AgentRuntimeState,
phase: &str,
error: &str,
) -> Result<(), String> {
let states = resolve_game_chat_absolute_deadline_public_states_at(root, runtime, phase)?;
append_resolved_game_chat_absolute_deadline_public_messages_at(root, &states, error)
}
pub(super) fn finish_game_chat_absolute_deadline_timeout_at(
root: &Path,
agent_id: &str,
session_id: &str,
_agent_id: &str,
_session_id: &str,
fallback: AgentRuntimeState,
) -> AgentBackgroundTaskOutcome {
let mut runtime = latest_game_chat_deadline_runtime_at(root, fallback);
@@ -387,6 +516,31 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at(
GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS
)
};
// The absolute deadline may itself be handling a broken Runtime state
// projection. Commit the stable user-facing terminal outcome before any
// reconciliation/failure projection, then retry the same idempotent
// message after cleanup below in case the first conversation write was
// transient.
let public_terminal_phase = if preserves_external_reconciliation {
"needs-reconciliation"
} else {
"failed"
};
let public_states = match resolve_game_chat_absolute_deadline_public_states_at(
root,
&runtime,
public_terminal_phase,
) {
Ok(states) => states,
Err(_) => return AgentBackgroundTaskOutcome::Finished,
};
let initial_public_status_error =
append_resolved_game_chat_absolute_deadline_public_messages_at(
root,
&public_states,
&error,
)
.err();
let terminal_failure_error = if let Some(pending) = pending_action
.as_ref()
.filter(|_| preserves_external_reconciliation)
@@ -448,6 +602,9 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at(
if let Some(error) = terminal_failure_error {
cleanup_errors.push(sanitize_agent_runtime_text(&error, 160));
}
if let Some(error) = initial_public_status_error {
cleanup_errors.push(sanitize_agent_runtime_text(&error, 160));
}
let _ = append_agent_db_record(
root,
serde_json::json!({
@@ -465,15 +622,10 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at(
"cleanupErrorCount": cleanup_errors.len(),
}),
);
let _ = append_local_conversation_message_for_session_at(
let _ = append_resolved_game_chat_absolute_deadline_public_messages_at(
root,
Some(agent_id),
Some(session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: game_creator_agent_runtime_failure_conversation_message(agent_id, &error),
agent_id: None,
},
&public_states,
&error,
);
AgentBackgroundTaskOutcome::Finished
}
@@ -1168,19 +1320,24 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
}
if error.starts_with(GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX) {
let error = redact_agent_runtime_error(&root, &error, 500);
let public_runtime = runtime.clone();
let public_states =
match resolve_game_chat_absolute_deadline_public_states_at(
&root,
&public_runtime,
"budget-exhausted",
) {
Ok(states) => states,
Err(_) => return AgentBackgroundTaskOutcome::Finished,
};
let initial_public_status =
append_resolved_game_chat_absolute_deadline_public_messages_at(
&root,
&public_states,
&error,
);
let failed_runtime =
fail_game_creator_agent_runtime_budget_at(&root, runtime, &error);
let _ = append_local_conversation_message_for_session_at(
&root,
Some(&agent_id),
Some(&session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: "首版素材生成已达到七十五分钟硬上限,本轮已停止,不会继续在后台运行。"
.to_string(),
agent_id: None,
},
);
if let Ok(runtime) = failed_runtime {
let _ = append_game_creator_agent_background_task_failed_audit(
&root,
@@ -1188,23 +1345,19 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_BUDGET,
);
}
if initial_public_status.is_err() {
let _ =
append_resolved_game_chat_absolute_deadline_public_messages_at(
&root,
&public_states,
&error,
);
}
return AgentBackgroundTaskOutcome::Finished;
}
let error = redact_agent_runtime_error(&root, &error, 500);
let failed_runtime =
fail_game_creator_agent_runtime_turn_at(&root, runtime, &error);
let _ = append_local_conversation_message_for_session_at(
&root,
Some(&agent_id),
Some(&session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: game_creator_agent_runtime_failure_conversation_message(
&agent_id, &error,
),
agent_id: None,
},
);
if let Ok(runtime) = failed_runtime {
let _ = append_game_creator_agent_background_task_failed_audit(
&root,
@@ -2388,18 +2541,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
Err(error) => {
let error = redact_agent_runtime_error(&root, &error, 500);
let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error);
let _ = append_local_conversation_message_for_session_at(
&root,
Some(&agent_id),
Some(&session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: game_creator_agent_runtime_failure_conversation_message(
&agent_id, &error,
),
agent_id: None,
},
);
return AgentBackgroundTaskOutcome::Finished;
}
}
@@ -2514,18 +2655,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
{
let error = redact_agent_runtime_error(&root, &error, 500);
let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error);
let _ = append_local_conversation_message_for_session_at(
&root,
Some(&agent_id),
Some(&session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: game_creator_agent_runtime_failure_conversation_message(
&agent_id, &error,
),
agent_id: None,
},
);
return AgentBackgroundTaskOutcome::Finished;
}
let pre_execution_drift =
@@ -2779,18 +2908,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
{
let error = redact_agent_runtime_error(&root, &error, 500);
let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error);
let _ = append_local_conversation_message_for_session_at(
&root,
Some(&agent_id),
Some(&session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: game_creator_agent_runtime_failure_conversation_message(
&agent_id, &error,
),
agent_id: None,
},
);
return AgentBackgroundTaskOutcome::Finished;
}
if let Err(error) =
@@ -3298,16 +3415,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
};
let error = redact_agent_runtime_error(&root, &error, 500);
let failed_runtime = fail_game_creator_agent_runtime_budget_at(&root, runtime, &error);
let _ = append_local_conversation_message_for_session_at(
&root,
Some(&agent_id),
Some(&session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: format!("后台任务未完成:{error}"),
agent_id: None,
},
);
if let Ok(runtime) = failed_runtime {
let _ = append_game_creator_agent_background_task_failed_audit(
&root,
@@ -3598,18 +3705,6 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
let error = redact_agent_runtime_error(&root, &error, 500);
let failed_runtime =
fail_game_creator_agent_runtime_turn_at(&root, runtime, &error);
let _ = append_local_conversation_message_for_session_at(
&root,
Some(&agent_id),
Some(&session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: game_creator_agent_runtime_failure_conversation_message(
&agent_id, &error,
),
agent_id: None,
},
);
if let Ok(runtime) = failed_runtime {
let _ = append_game_creator_agent_background_task_failed_audit(
&root,
@@ -1,6 +1,6 @@
use super::main_loop::{
await_game_chat_absolute_deadline_at, finish_game_chat_absolute_deadline_timeout_at,
game_chat_absolute_deadline_from_bound_at,
append_game_chat_absolute_deadline_public_messages_at, await_game_chat_absolute_deadline_at,
finish_game_chat_absolute_deadline_timeout_at, game_chat_absolute_deadline_from_bound_at,
};
use super::*;
@@ -62,21 +62,248 @@ async fn game_chat_absolute_deadline_returns_an_in_flight_result_before_expiry()
assert_eq!(result.expect("in-flight action completes"), "completed");
}
#[test]
fn game_chat_absolute_deadline_rejects_forged_root_runtime_identity() {
let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-forged-root-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "deadline-forged-root", "硬截止伪根身份测试")
.expect("project init");
let root_session = resolve_agent_conversation_session_id_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
None,
true,
)
.expect("resolve game-chat root session");
let root_record = append_unique_game_creator_agent_runtime_pending_task(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&root_session,
"生成首版可玩游戏",
"game-chat-deadline-forged-root-run",
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("queue autonomous game-chat root");
let mut forged_session = agent_runtime_state_from_task_record(&root_record);
forged_session.session_id = "forged-root-session".to_string();
let error = append_game_chat_absolute_deadline_public_messages_at(
&root,
&forged_session,
"failed",
GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX,
)
.expect_err("forged root session must fail closed");
assert!(error.contains("当前根 Runtime 身份不一致"));
let outcome = finish_game_chat_absolute_deadline_timeout_at(
&root,
&forged_session.agent_id,
&forged_session.session_id,
forged_session.clone(),
);
assert!(matches!(outcome, AgentBackgroundTaskOutcome::Finished));
let root_after_forged_finish = read_latest_game_creator_agent_runtime_task_by_run_id(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&root_record.run_id,
)
.expect("read authoritative root after forged finish")
.expect("authoritative root task remains present");
assert_eq!(root_after_forged_finish.session_id, root_session);
assert_eq!(root_after_forged_finish.status, "pending");
let mut forged_parent = agent_runtime_state_from_task_record(&root_record);
forged_parent.parent_run_id = Some("forged-parent-run".to_string());
let error = append_game_chat_absolute_deadline_public_messages_at(
&root,
&forged_parent,
"failed",
GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX,
)
.expect_err("root runtime carrying a parent identity must fail closed");
assert!(error.contains("当前根 Runtime 身份不一致"));
let child_session = resolve_agent_conversation_session_id_at(&root, "art-director", None, true)
.expect("resolve child session");
let child_record = append_unique_game_creator_agent_runtime_pending_task(
&root,
"art-director",
&child_session,
"执行美术审计",
"game-chat-deadline-forged-child-run",
"agent-ready-task-scheduler",
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
Some(&AgentRuntimeTaskLink {
parent_agent_id: Some(root_record.agent_id.clone()),
parent_run_id: Some(root_record.run_id.clone()),
delegation_id: None,
}),
)
.expect("queue autonomous game-chat child");
let mut forged_child_session = agent_runtime_state_from_task_record(&child_record);
forged_child_session.session_id = "forged-child-session".to_string();
let error = append_game_chat_absolute_deadline_public_messages_at(
&root,
&forged_child_session,
"failed",
GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX,
)
.expect_err("child runtime with the wrong session must fail closed");
assert!(error.contains("当前专业 Agent 任务身份不一致"));
let project_conversation =
read_local_conversation_at(&root, None).expect("read forged root conversation");
assert_eq!(
project_conversation
.messages
.iter()
.filter(|message| {
message.message_id.as_deref().is_some_and(|message_id| {
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
})
})
.count(),
0,
"identity conflicts must not create a second project terminal message"
);
fs::remove_dir_all(root).ok();
}
#[test]
fn game_chat_absolute_deadline_deduplicates_root_terminal_across_children() {
let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-multi-child-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "deadline-multi-child", "硬截止多 child 幂等测试")
.expect("project init");
let root_session = resolve_agent_conversation_session_id_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
None,
true,
)
.expect("resolve game-chat root session");
let root_record = append_unique_game_creator_agent_runtime_pending_task(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&root_session,
"生成首版可玩游戏",
"game-chat-deadline-multi-child-root-run",
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("queue autonomous game-chat root");
let task_link = AgentRuntimeTaskLink {
parent_agent_id: Some(root_record.agent_id.clone()),
parent_run_id: Some(root_record.run_id.clone()),
delegation_id: None,
};
let mut children = Vec::new();
for (agent_id, run_id) in [
("art-director", "game-chat-deadline-multi-child-art"),
("code-prototype", "game-chat-deadline-multi-child-code"),
] {
let session_id = resolve_agent_conversation_session_id_at(&root, agent_id, None, true)
.expect("resolve child session");
let record = append_unique_game_creator_agent_runtime_pending_task(
&root,
agent_id,
&session_id,
"执行专业任务",
run_id,
"agent-ready-task-scheduler",
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
Some(&task_link),
)
.expect("queue autonomous game-chat child");
children.push(agent_runtime_state_from_task_record(&record));
}
let private_error = format!(
"{GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX}: operationId=secret-operation-id pendingAction=private-action"
);
for child in &children {
append_game_chat_absolute_deadline_public_messages_at(
&root,
child,
"needs-reconciliation",
&private_error,
)
.expect("append idempotent root and child deadline terminals");
}
let project_conversation =
read_local_conversation_at(&root, None).expect("read multi-child root conversation");
let root_public_statuses = project_conversation
.messages
.iter()
.filter(|message| {
message.message_id.as_deref().is_some_and(|message_id| {
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
})
})
.collect::<Vec<_>>();
assert_eq!(root_public_statuses.len(), 1);
assert_eq!(
root_public_statuses[0].content,
"项目总控 Agent 执行失败,请稍后重试"
);
assert!(!root_public_statuses[0]
.content
.contains("secret-operation-id"));
assert!(!root_public_statuses[0].content.contains("pendingAction"));
for child in &children {
assert!(!root_public_statuses[0].content.contains(&child.session_id));
assert!(!root_public_statuses[0].content.contains(&child.run_id));
let child_conversation = read_local_conversation_for_session_at(
&root,
Some(&child.agent_id),
Some(&child.session_id),
)
.expect("read child deadline conversation");
assert_eq!(
child_conversation
.messages
.iter()
.filter(|message| {
message.message_id.as_deref().is_some_and(|message_id| {
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
})
})
.count(),
1
);
}
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn game_chat_absolute_deadline_preserves_external_generation_for_same_action_resume() {
let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-reconciliation-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "deadline-reconciliation", "硬截止收尾测试")
.expect("project init");
bind_game_creator_agent_runtime_run_profile_at(
let root_session = resolve_agent_conversation_session_id_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
None,
true,
)
.expect("resolve game-chat root session");
let root_record = append_unique_game_creator_agent_runtime_pending_task(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&root_session,
"生成首版可玩游戏",
"game-chat-deadline-reconciliation-root-run",
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("bind autonomous game-chat root profile");
.expect("queue autonomous game-chat root");
bind_game_creator_agent_runtime_run_profile_at(
&root,
"art-director",
@@ -84,8 +311,8 @@ async fn game_chat_absolute_deadline_preserves_external_generation_for_same_acti
"agent-ready-task-scheduler",
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
Some(&AgentRuntimeTaskLink {
parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()),
parent_run_id: Some("game-chat-deadline-reconciliation-root-run".to_string()),
parent_agent_id: Some(root_record.agent_id.clone()),
parent_run_id: Some(root_record.run_id.clone()),
delegation_id: None,
}),
)
@@ -100,6 +327,8 @@ async fn game_chat_absolute_deadline_preserves_external_generation_for_same_acti
vec!["执行外部图片生成".to_string()],
)
.expect("start runtime");
runtime.parent_agent_id = Some(root_record.agent_id.clone());
runtime.parent_run_id = Some(root_record.run_id.clone());
runtime.loop_iteration = 1;
let action = AgentRuntimeToolAction {
tool: "canvas.asset_generate".to_string(),
@@ -227,6 +456,42 @@ async fn game_chat_absolute_deadline_preserves_external_generation_for_same_acti
assert!(agent_db.contains("\"externalGenerationRecordPreserved\":true"));
assert!(agent_db.contains("agent.runtime.tool_action.needs_reconciliation"));
assert!(!agent_db.contains("test-operation-id"));
let conversation = read_local_conversation_for_session_at(
&root,
Some(&runtime.agent_id),
Some(&runtime.session_id),
)
.expect("read reconciliation deadline conversation");
let public_statuses = conversation
.messages
.iter()
.filter(|message| {
message.message_id.as_deref().is_some_and(|message_id| {
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
})
})
.collect::<Vec<_>>();
assert_eq!(public_statuses.len(), 1);
assert_eq!(
public_statuses[0].content,
"专业 Agent 执行失败,请稍后重试"
);
let project_conversation =
read_local_conversation_at(&root, None).expect("read root deadline public conversation");
let root_public_statuses = project_conversation
.messages
.iter()
.filter(|message| {
message.message_id.as_deref().is_some_and(|message_id| {
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
})
})
.collect::<Vec<_>>();
assert_eq!(root_public_statuses.len(), 1);
assert_eq!(
root_public_statuses[0].content,
"项目总控 Agent 执行失败,请稍后重试"
);
fs::remove_dir_all(root).ok();
}
@@ -237,6 +502,37 @@ fn game_chat_absolute_deadline_still_cleans_local_action_recovery() {
let root = temporary.path().join("project");
init_local_game_project_at(&root, "deadline-local-cleanup", "硬截止本地清理测试")
.expect("project init");
let root_session = resolve_agent_conversation_session_id_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
None,
true,
)
.expect("resolve local deadline root session");
let root_record = append_unique_game_creator_agent_runtime_pending_task(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&root_session,
"生成首版可玩游戏",
"game-chat-deadline-local-root-run",
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("queue local deadline game-chat root");
bind_game_creator_agent_runtime_run_profile_at(
&root,
"code-prototype",
"game-chat-deadline-local-cleanup-run",
"agent-ready-task-scheduler",
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
Some(&AgentRuntimeTaskLink {
parent_agent_id: Some(root_record.agent_id.clone()),
parent_run_id: Some(root_record.run_id.clone()),
delegation_id: None,
}),
)
.expect("bind local deadline child profile");
let mut runtime = start_game_creator_agent_runtime_task_at(
&root,
"code-prototype",
@@ -247,6 +543,8 @@ fn game_chat_absolute_deadline_still_cleans_local_action_recovery() {
vec!["执行首版写入".to_string()],
)
.expect("start runtime");
runtime.parent_agent_id = Some(root_record.agent_id.clone());
runtime.parent_run_id = Some(root_record.run_id.clone());
runtime.loop_iteration = 1;
let action = AgentRuntimeToolAction {
tool: "file.write".to_string(),
@@ -311,6 +609,38 @@ fn game_chat_absolute_deadline_still_cleans_local_action_recovery() {
));
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.contains("\"reconciliationPreserved\":false"));
let conversation = read_local_conversation_for_session_at(
&root,
Some(&runtime.agent_id),
Some(&runtime.session_id),
)
.expect("read local deadline conversation");
assert_eq!(
conversation
.messages
.iter()
.filter(|message| {
message.message_id.as_deref().is_some_and(|message_id| {
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
})
})
.count(),
1
);
let project_conversation =
read_local_conversation_at(&root, None).expect("read local root deadline conversation");
assert_eq!(
project_conversation
.messages
.iter()
.filter(|message| {
message.message_id.as_deref().is_some_and(|message_id| {
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
})
})
.count(),
1
);
fs::remove_dir_all(root).ok();
}
@@ -9,17 +9,15 @@ pub(in crate::agent) fn game_creator_agent_background_task_default_plan() -> Vec
]
}
pub(in crate::agent) fn agent_runtime_background_task_message_id(
pub(crate) fn agent_runtime_background_task_message_id(
agent_id: &str,
session_id: &str,
run_id: &str,
source: &str,
_source: &str,
) -> String {
let identity = format!("{agent_id}\n{session_id}\n{run_id}\n{source}");
let fingerprint = format!("{:x}", Sha256::digest(identity.as_bytes()));
format!(
"runtime-task-{}",
fingerprint.chars().take(32).collect::<String>()
game_creator_agent_runtime_message_correlation_id(agent_id, session_id, run_id)
)
}
@@ -279,7 +277,7 @@ pub(crate) fn spawn_started_game_creator_agent_background_task_drain_with_lock(
pub(in crate::agent) fn fail_game_creator_agent_background_context_at(
root: &Path,
agent_id: &str,
session_id: &str,
_session_id: &str,
runtime: AgentRuntimeState,
error: &str,
) -> AgentBackgroundTaskOutcome {
@@ -295,16 +293,6 @@ pub(in crate::agent) fn fail_game_creator_agent_background_context_at(
}
let error = redact_agent_runtime_error(root, error, 500);
let failed_runtime = fail_game_creator_agent_runtime_turn_at(root, runtime, &error);
let _ = append_local_conversation_message_for_session_at(
root,
Some(agent_id),
Some(session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: game_creator_agent_runtime_failure_conversation_message(agent_id, &error),
agent_id: None,
},
);
if let Ok(runtime) = failed_runtime {
let _ = append_agent_db_record(
root,
@@ -278,7 +278,18 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_in_se
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
});
let (pending_task, pending_task_created) = if is_static_supervisor_delegate {
let requires_public_start_status = agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
&& task_link
.and_then(|link| link.parent_agent_id.as_deref())
.is_none()
&& task_link
.and_then(|link| link.parent_run_id.as_deref())
.is_none()
&& !matches!(
source,
AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE | AGENT_RUNTIME_ISOLATED_JOIN_SOURCE
);
let (mut pending_task, pending_task_created) = if is_static_supervisor_delegate {
append_or_read_exact_game_creator_agent_runtime_pending_task(
root,
&agent_id,
@@ -291,16 +302,29 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_in_se
)?
} else {
(
append_unique_game_creator_agent_runtime_pending_task(
root,
&agent_id,
&session_id,
task,
run_id,
source,
run_profile,
task_link,
)?,
if requires_public_start_status {
append_unique_game_creator_agent_runtime_public_status_preparing_task(
root,
&agent_id,
&session_id,
task,
run_id,
source,
run_profile,
task_link,
)?
} else {
append_unique_game_creator_agent_runtime_pending_task(
root,
&agent_id,
&session_id,
task,
run_id,
source,
run_profile,
task_link,
)?
},
true,
)
};
@@ -332,7 +356,17 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_in_se
)
.map(|_| ())
};
if let Err(error) = conversation_result {
let conversation_error = match conversation_result {
Ok(()) => None,
Err(error) => {
match game_creator_agent_runtime_task_has_user_message_at(root, &pending_task) {
Ok(true) => None,
Ok(false) => Some(error),
Err(identity_error) => Some(identity_error),
}
}
};
if let Some(error) = conversation_error {
let error = redact_agent_runtime_project_paths(root, &error, 500);
let failed_task = AgentRuntimeTaskRecord {
status: "failed".to_string(),
@@ -359,6 +393,37 @@ pub(in crate::agent) fn start_game_creator_agent_background_task_with_link_in_se
return Err(format!("后台任务用户消息落盘失败,任务未执行:{error}"));
}
}
if requires_public_start_status {
if let Err(error) =
ensure_game_creator_agent_runtime_accepted_public_status_at(root, &pending_task)
{
let error = redact_agent_runtime_project_paths(root, &error, 500);
let terminal_result =
fail_game_creator_agent_runtime_public_start_status_at(root, &pending_task, &error);
let _ = append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.background_task.queue_warning",
"agentId": pending_task.agent_id,
"sessionId": pending_task.session_id,
"runId": pending_task.run_id,
"warningKind": "public-status-write-failed",
"error": sanitize_agent_runtime_text(&error, 240),
}),
);
terminal_result?;
return Err(format!("后台任务启动确认落盘失败,任务未执行:{error}"));
}
let queued_task = AgentRuntimeTaskRecord {
status: "pending".to_string(),
phase: "queued".to_string(),
current_action: "等待当前后台任务完成".to_string(),
updated_at: unix_timestamp(),
..pending_task.clone()
};
append_game_creator_agent_runtime_task_record(root, &queued_task)?;
pending_task = queued_task;
}
if !pending_task_created && game_creator_agent_runtime_terminal_status(&pending_task).is_some()
{
return read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(&session_id))
File diff suppressed because it is too large Load Diff
@@ -44,7 +44,11 @@ pub(crate) fn game_creator_agent_runtime_terminal_status(
"completed" => Some("completed"),
"budget-exhausted" => Some("budget-exhausted"),
"cancelled" => Some("cancelled"),
"failed" | "conversation-write-failed" if task.status == "failed" => Some("failed"),
"failed" | "conversation-write-failed" | "public-status-write-failed"
if task.status == "failed" =>
{
Some("failed")
}
_ => None,
}
}
@@ -378,6 +378,13 @@ fn render_conversation_messages(
messages
.iter()
.filter_map(|message| {
if message.message_id.as_deref().is_some_and(|message_id| {
message_id
.trim()
.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
}) {
return None;
}
let content = normalize_conversation_content(&message.content);
(!content.is_empty()).then(|| format!("- [{label} / {}] {content}", message.role))
})
@@ -987,6 +987,29 @@ fn take_agent_db_record_failure_injection(
}
}
#[cfg(test)]
fn take_conversation_audit_failure_injection(root: &Path, message_id: &str) -> Result<(), String> {
let failure_path = root.join(".agent/runtime/test-fail-next-agent-db-record");
match fs::read_to_string(&failure_path) {
Ok(expected) => {
let expected = expected.trim();
let Some(message_id_prefix) = expected.strip_prefix("conversation.message:") else {
return Ok(());
};
if !message_id.starts_with(message_id_prefix) {
return Ok(());
}
fs::remove_file(&failure_path)
.map_err(|error| format!("清理 Agent DB 测试失败注入标记失败:{error}"))?;
Err(format!(
"测试注入 conversation.message 审计失败:{message_id_prefix}"
))
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(format!("读取 Agent DB 测试失败注入标记失败:{error}")),
}
}
fn append_agent_db_record_internal(root: &Path, record: serde_json::Value) -> Result<(), String> {
#[cfg(test)]
take_agent_db_record_failure_injection(
@@ -4153,6 +4176,15 @@ pub(super) fn ensure_conversation_message_audit_at(
)? {
return Ok(());
}
#[cfg(test)]
take_conversation_audit_failure_injection(root, message_id)?;
#[cfg(test)]
take_agent_db_record_failure_injection(
root,
audit_record
.get("recordType")
.and_then(serde_json::Value::as_str),
)?;
let append_class = agent_db_record_append_class(&audit_record);
let line = serialize_agent_db_record(audit_record)?;
validate_agent_db_append_class_record_size(append_class, &line)?;
@@ -1055,6 +1055,29 @@ pub(crate) fn read_local_conversation_message_by_id_for_session_at(
agent_id: Option<&str>,
session_id: Option<&str>,
message_id: &str,
) -> Result<Option<LocalConversationMessageRecord>, String> {
read_local_conversation_message_by_id_for_session_internal_at(
root, agent_id, session_id, message_id, true,
)
}
pub(crate) fn read_local_conversation_message_by_id_for_session_without_touch_at(
root: &Path,
agent_id: Option<&str>,
session_id: Option<&str>,
message_id: &str,
) -> Result<Option<LocalConversationMessageRecord>, String> {
read_local_conversation_message_by_id_for_session_internal_at(
root, agent_id, session_id, message_id, false,
)
}
fn read_local_conversation_message_by_id_for_session_internal_at(
root: &Path,
agent_id: Option<&str>,
session_id: Option<&str>,
message_id: &str,
touch_session: bool,
) -> Result<Option<LocalConversationMessageRecord>, String> {
let message_id = normalize_local_conversation_message_id(message_id)?;
let (path, normalized_agent_id, normalized_session_id) =
@@ -1068,17 +1091,19 @@ pub(crate) fn read_local_conversation_message_by_id_for_session_at(
normalized_session_id.as_deref(),
&message_id,
)?;
if let (Some(agent_id), Some(session_id)) = (
normalized_agent_id.as_deref(),
normalized_session_id.as_deref(),
) {
touch_agent_conversation_session_at(
root,
agent_id,
session_id,
records.len() as u64,
false,
)?;
if touch_session {
if let (Some(agent_id), Some(session_id)) = (
normalized_agent_id.as_deref(),
normalized_session_id.as_deref(),
) {
touch_agent_conversation_session_at(
root,
agent_id,
session_id,
records.len() as u64,
false,
)?;
}
}
Ok(matched_index.map(|index| records[index].to_public_record()))
}
@@ -5578,9 +5578,10 @@ async fn background_agent_runtime_marks_unconverged_loop_budget_exhausted() {
.expect("read budget conversation");
assert!(conversation.messages.iter().any(|message| {
message.role == "assistant"
&& message
.content
.contains("后台任务未完成:loop-budget-exhausted")
&& message.content == "专业 Agent 执行失败,请稍后重试"
&& message.message_id.as_deref().is_some_and(|message_id| {
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
})
}));
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.contains("\"failureKind\":\"loop-budget-exhausted\""));
File diff suppressed because it is too large Load Diff
@@ -2422,6 +2422,30 @@ fn local_conversation_prompt_context_scopes_agent_messages() {
},
)
.expect("append other agent conversation");
append_local_conversation_message_for_session_idempotent_at(
&root,
None,
None,
LocalConversationMessage {
role: "assistant".to_string(),
content: "任务已接收,项目总控 Agent 正在启动处理。".to_string(),
agent_id: None,
},
"runtime-public-status-project-accepted",
)
.expect("append Runtime-owned project status");
append_local_conversation_message_for_session_idempotent_at(
&root,
Some("art-asset-plan"),
None,
LocalConversationMessage {
role: "assistant".to_string(),
content: "专业 Agent 执行失败,请稍后重试".to_string(),
agent_id: None,
},
"runtime-public-status-agent-failed",
)
.expect("append Runtime-owned Agent status");
let project_context =
render_local_conversation_prompt_context(&root, None).expect("project context");
@@ -2430,6 +2454,7 @@ fn local_conversation_prompt_context_scopes_agent_messages() {
assert!(project_context.contains("[project / user] 希望主角用月光厨房做弹幕躲避"));
assert!(!project_context.contains("[art-asset-plan / assistant]"));
assert!(!project_context.contains("美术建议:霓虹锅铲和月亮灶台"));
assert!(!project_context.contains("任务已接收"));
let art_context = render_local_conversation_prompt_context(&root, Some("art-asset-plan"))
.expect("art agent context");
@@ -2439,6 +2464,8 @@ fn local_conversation_prompt_context_scopes_agent_messages() {
assert!(art_context.contains("[redacted sensitive context]"));
assert!(!art_context.contains("secret-token"));
assert!(!art_context.contains("策划建议:只保留三种输入"));
assert!(!art_context.contains("任务已接收"));
assert!(!art_context.contains("专业 Agent 执行失败"));
fs::remove_dir_all(root).ok();
}
@@ -21,6 +21,28 @@ import type {
TauriInvoke,
} from '../../app/types';
const AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX = 'runtime-public-status-';
const AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX = 'runtime-task-';
const AGENT_RUNTIME_MESSAGE_CORRELATION_PATTERN = /^[0-9a-f]{32}$/;
function agentRuntimeMessageCorrelationId(messageId: string | null | undefined) {
const normalized = messageId?.trim() ?? '';
const prefix = normalized.startsWith(
AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX,
)
? AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX
: normalized.startsWith(AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX)
? AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX
: null;
if (!prefix) {
return null;
}
const correlationId = normalized.slice(prefix.length).split('-', 1)[0] ?? '';
return AGENT_RUNTIME_MESSAGE_CORRELATION_PATTERN.test(correlationId)
? correlationId
: null;
}
export function createLocalConversationDraftMessage(
content: string,
updatedAt = Date.now(),
@@ -1221,17 +1243,56 @@ export function mergeProjectSupervisorConversation(
projectRecords: LocalConversationMessageRecord[],
supervisorRecords: LocalConversationMessageRecord[],
): ChatMessage[] {
const supervisorRecordIndexByCorrelation = new Map<string, number>();
supervisorRecords.forEach((record, index) => {
if (record.role !== 'user') {
return;
}
const correlationId = agentRuntimeMessageCorrelationId(record.messageId);
if (correlationId && !supervisorRecordIndexByCorrelation.has(correlationId)) {
supervisorRecordIndexByCorrelation.set(correlationId, index);
}
});
const records = [
...projectRecords.map((record, index) => ({
record,
runtimeOwned: false,
stableIndex: index,
})),
...supervisorRecords.map((record, index) => ({
record,
runtimeOwned: true,
stableIndex: projectRecords.length + index,
})),
...projectRecords.map((record, index) => {
const runtimeOwned = Boolean(
record.messageId
?.trim()
.startsWith(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX),
);
const accepted =
runtimeOwned &&
record.content === '任务已接收,项目总控 Agent 正在启动处理。';
const correlationId = agentRuntimeMessageCorrelationId(record.messageId);
const supervisorRecordIndex = correlationId
? supervisorRecordIndexByCorrelation.get(correlationId)
: undefined;
const runtimeRecordIndex =
supervisorRecordIndex ?? supervisorRecords.length + index;
return {
record,
runtimeOwned,
sameSecondLane: runtimeOwned ? 1 : 0,
sameSecondOrder: runtimeOwned
? runtimeRecordIndex * 4 + (accepted ? 1 : 2)
: index,
stableIndex: index,
};
}),
...supervisorRecords.map((record, index) => {
const userMessage = record.role === 'user';
return {
record,
runtimeOwned: true,
sameSecondLane: 1,
// Conversation timestamps have second precision. Runtime task and
// public status IDs carry the same opaque run correlation digest, so
// status ordering follows its actual task record instead of guessing
// from independent user/accepted/terminal category counters.
sameSecondOrder: index * 4 + (userMessage ? 0 : 3),
stableIndex: projectRecords.length + index,
};
}),
]
.filter(
({ record }) => record.role === 'user' || record.role === 'assistant',
@@ -1239,6 +1300,8 @@ export function mergeProjectSupervisorConversation(
.sort(
(left, right) =>
left.record.updatedAt - right.record.updatedAt ||
left.sameSecondLane - right.sameSecondLane ||
left.sameSecondOrder - right.sameSecondOrder ||
left.stableIndex - right.stableIndex,
);
const seen = new Set<string>();
@@ -12,6 +12,7 @@ import type {
import { useEffect, useMemo, useState } from 'react';
import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { PROJECT_SUPERVISOR_AGENT_ID } from '../../app/constants';
import type {
AgentRuntimeEventRecord,
AgentRuntimeResponseStream,
@@ -54,6 +55,7 @@ type RuntimeControlProps = ComponentProps<
export type GameChatRuntimeEvent = {
key: string;
agentLabel: string;
rootSupervisorRuntime: boolean;
event: AgentRuntimeEventRecord;
};
@@ -76,6 +78,14 @@ const GAME_CHAT_INTERNAL_RUNTIME_EVENT_TYPES = new Set([
'agent.runtime.tool.response',
'agent.runtime.tool.result',
]);
const GAME_CHAT_ROOT_RUNTIME_OWNED_EVENT_TYPES = new Set([
// Runtime-owned accepted status is persisted once by the backend. Keeping
// turn.started in the event transcript would show a second synthetic start
// message and make a single accepted task look like two user-visible turns.
'turn.started',
'turn.failed',
'turn.budget_exhausted',
]);
const GAME_CHAT_RUNTIME_CLOCK_INTERVAL_MS = 10_000;
const GAME_CHAT_RUNTIME_STALL_THRESHOLD_MS = 5 * 60 * 1000;
const GAME_CHAT_EARLIEST_RUNTIME_TIMESTAMP_MS = Date.UTC(2020, 0, 1);
@@ -674,6 +684,10 @@ function collectGameChatRuntimeEventsInternal(
deduplicated.set(key, {
key,
agentLabel: source.label,
rootSupervisorRuntime:
source.runtime.agentId === PROJECT_SUPERVISOR_AGENT_ID &&
source.runtime.parentAgentId == null &&
source.runtime.parentRunId == null,
event,
});
}
@@ -705,7 +719,9 @@ function gameChatRuntimeEventMessageText(item: GameChatRuntimeEvent) {
if (
!eventId ||
!publicText ||
GAME_CHAT_INTERNAL_RUNTIME_EVENT_TYPES.has(eventType)
GAME_CHAT_INTERNAL_RUNTIME_EVENT_TYPES.has(eventType) ||
(item.rootSupervisorRuntime &&
GAME_CHAT_ROOT_RUNTIME_OWNED_EVENT_TYPES.has(eventType))
) {
return null;
}
@@ -5,10 +5,12 @@ import type {
AgentRuntimeResult,
AgentRuntimeState,
ChatMessage,
LocalConversationMessageRecord,
} from '../src/app/types';
import {
formatAgentRuntimeEvent,
mergeGameChatRuntimeResponseMessagesIntoHistory,
mergeProjectSupervisorConversation,
MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE,
projectRuntimeVisibleCurrentWork,
projectRuntimeVisibleError,
@@ -18,6 +20,184 @@ import {
submitProjectSupervisorRuntimeTask,
} from '../src/features/agent-runtime/model';
describe('Runtime-owned public statuses', () => {
test('keeps backend status messages visible without treating them as client-authored conversation', () => {
const projectRecords: LocalConversationMessageRecord[] = [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '任务已接收,项目总控 Agent 正在启动处理。',
agentId: null,
messageId:
'runtime-public-status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-accepted',
updatedAt: 2,
},
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '普通项目消息',
agentId: null,
messageId: 'project-message-1',
updatedAt: 3,
},
];
const supervisorRecords: LocalConversationMessageRecord[] = [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: '请继续完成当前游戏',
agentId: 'project-supervisor',
messageId: 'runtime-task-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
updatedAt: 2,
},
];
const messages = mergeProjectSupervisorConversation(
projectRecords,
supervisorRecords,
);
expect(messages).toEqual([
expect.objectContaining({
text: '请继续完成当前游戏',
runtimeOwned: true,
}),
expect.objectContaining({
text: '任务已接收,项目总控 Agent 正在启动处理。',
runtimeOwned: true,
}),
expect.objectContaining({
text: '普通项目消息',
runtimeOwned: false,
}),
]);
});
test('interleaves rapid same-second tasks with their accepted and terminal statuses', () => {
const projectRecords: LocalConversationMessageRecord[] = [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '任务已接收,项目总控 Agent 正在启动处理。',
agentId: null,
messageId:
'runtime-public-status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-accepted',
updatedAt: 9,
},
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '项目总控 Agent 执行失败,请稍后重试',
agentId: null,
messageId:
'runtime-public-status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-failed',
updatedAt: 9,
},
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '任务已接收,项目总控 Agent 正在启动处理。',
agentId: null,
messageId:
'runtime-public-status-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-accepted',
updatedAt: 9,
},
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '项目总控 Agent 已达到执行预算,请缩小任务范围后重试',
agentId: null,
messageId:
'runtime-public-status-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-budget',
updatedAt: 9,
},
];
const supervisorRecords: LocalConversationMessageRecord[] = [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: '第一条任务',
agentId: 'project-supervisor',
messageId: 'runtime-task-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
updatedAt: 9,
},
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: '第二条任务',
agentId: 'project-supervisor',
messageId: 'runtime-task-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
updatedAt: 9,
},
];
expect(
mergeProjectSupervisorConversation(projectRecords, supervisorRecords).map(
(message) => message.text,
),
).toEqual([
'第一条任务',
'任务已接收,项目总控 Agent 正在启动处理。',
'项目总控 Agent 执行失败,请稍后重试',
'第二条任务',
'任务已接收,项目总控 Agent 正在启动处理。',
'项目总控 Agent 已达到执行预算,请缩小任务范围后重试',
]);
});
test('keeps an older sparse terminal before a new same-second task', () => {
const projectRecords: LocalConversationMessageRecord[] = [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '项目总控 Agent 执行失败,请稍后重试',
agentId: null,
messageId:
'runtime-public-status-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-failed',
updatedAt: 9,
},
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: '任务已接收,项目总控 Agent 正在启动处理。',
agentId: null,
messageId:
'runtime-public-status-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-accepted',
updatedAt: 9,
},
];
const supervisorRecords: LocalConversationMessageRecord[] = [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: '旧任务',
agentId: 'project-supervisor',
messageId: 'runtime-task-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
updatedAt: 8,
},
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: '新任务',
agentId: 'project-supervisor',
messageId: 'runtime-task-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
updatedAt: 9,
},
];
expect(
mergeProjectSupervisorConversation(projectRecords, supervisorRecords).map(
(message) => message.text,
),
).toEqual([
'旧任务',
'项目总控 Agent 执行失败,请稍后重试',
'新任务',
'任务已接收,项目总控 Agent 正在启动处理。',
]);
});
});
describe('Game Chat stream identity and source', () => {
test('response stream identity binds run, slot, and revision', () => {
const base: AgentRuntimeResponseStream = {
@@ -3689,6 +3689,20 @@ export function registerProjectSupervisorSurfaceTests() {
const runtime = gameChatRuntimeState({
runId,
recentEvents: [
gameChatRuntimeEvent({
runId,
eventType: 'turn.started',
summary: 'Synthetic start event must stay out of chat',
updatedAt: 30,
}),
gameChatRuntimeEvent({
runId,
eventType: 'turn.failed',
status: 'failed',
phase: 'failed',
summary: 'Root terminal event is covered by durable public status',
updatedAt: 35,
}),
gameChatRuntimeEvent({
runId,
eventType: 'turn.progress',
@@ -3737,11 +3751,35 @@ export function registerProjectSupervisorSurfaceTests() {
}),
],
});
const designRuntime = gameChatRuntimeState({
agentId: 'design-director',
taskId: 'design-director',
sessionId: 'design-director-session',
runId: 'design-director-child-run',
source: 'agent-delegate',
parentAgentId: 'project-supervisor',
parentRunId: runId,
recentEvents: [
gameChatRuntimeEvent({
agentId: 'design-director',
runId: 'design-director-child-run',
eventType: 'turn.started',
summary: 'Design Agent started and must stay visible',
updatedAt: 32,
}),
],
updatedAt: 32,
});
const messages = gameChatRuntimeEventMessages(runtime, {});
const messages = gameChatRuntimeEventMessages(runtime, {
'design-director': designRuntime,
});
expect(messages.map((message) => message.updatedAt)).toEqual([40, 60, 80]);
expect(messages.map((message) => message.updatedAt)).toEqual([
32, 40, 60, 80,
]);
expect(messages.map((message) => message.text)).toEqual([
expect.stringContaining('Design Agent started and must stay visible'),
expect.stringContaining('Generated prototype progress'),
expect.stringContaining('code agent repair collision'),
expect.stringContaining('preview.validate:ok'),
@@ -3760,6 +3798,47 @@ export function registerProjectSupervisorSurfaceTests() {
expect(messages.map((message) => message.text).join('\n')).not.toContain(
'agent.runtime.tool.request',
);
for (const continuation of [
{ kind: 'receipt', source: 'agent-delegate-receipt' },
{ kind: 'isolated join', source: 'agent-isolated-join' },
]) {
const continuationRunId = `supervisor-${continuation.kind.replaceAll(' ', '-')}-continuation`;
const supervisorContinuation = gameChatRuntimeState({
runId: continuationRunId,
source: continuation.source,
parentAgentId: null,
parentRunId: runId,
recentEvents: [
gameChatRuntimeEvent({
runId: continuationRunId,
eventType: 'turn.started',
summary: `Supervisor ${continuation.kind} continuation started`,
updatedAt: 100,
}),
gameChatRuntimeEvent({
runId: continuationRunId,
eventType: 'turn.failed',
status: 'failed',
phase: 'failed',
summary: `Supervisor ${continuation.kind} continuation failed`,
updatedAt: 110,
}),
],
});
expect(
gameChatRuntimeEventMessages(supervisorContinuation, {}).map(
(message) => message.text,
),
).toEqual([
expect.stringContaining(
`Supervisor ${continuation.kind} continuation started`,
),
expect.stringContaining(
`Supervisor ${continuation.kind} continuation failed`,
),
]);
}
});
it('turns only professional final-reply streams into labeled game-chat messages', () => {