5381 lines
202 KiB
Rust
5381 lines
202 KiB
Rust
use super::*;
|
||
|
||
fn runtime_task_json_line(run_id: &str, status: &str, phase: &str) -> String {
|
||
serde_json::json!({
|
||
"schemaVersion": AGENT_RUNTIME_SCHEMA_VERSION,
|
||
"agentId": "design-director",
|
||
"taskId": "design-director",
|
||
"sessionId": "agent-session-design-director",
|
||
"runId": run_id,
|
||
"source": "test",
|
||
"task": "验证 Runtime task JSONL 读取",
|
||
"status": status,
|
||
"phase": phase,
|
||
"currentAction": "等待测试",
|
||
"updatedAt": 1,
|
||
})
|
||
.to_string()
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_task_reader_ignores_only_unterminated_truncated_tail() {
|
||
let root = unique_project_path();
|
||
fs::create_dir_all(&root).expect("create runtime task fixture directory");
|
||
let path = root.join("tasks.jsonl");
|
||
let complete = runtime_task_json_line("complete-run", "pending", "queued");
|
||
fs::write(
|
||
&path,
|
||
format!("{complete}\n{{\"schemaVersion\":\"{AGENT_RUNTIME_SCHEMA_VERSION}\""),
|
||
)
|
||
.expect("write truncated runtime task tail");
|
||
|
||
let records = read_all_game_creator_agent_runtime_tasks(&path)
|
||
.expect("unterminated truncated tail should be ignored");
|
||
|
||
assert_eq!(records.len(), 1);
|
||
assert_eq!(records[0].run_id, "complete-run");
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_task_reader_rejects_malformed_middle_line() {
|
||
let root = unique_project_path();
|
||
fs::create_dir_all(&root).expect("create runtime task fixture directory");
|
||
let path = root.join("tasks.jsonl");
|
||
let first = runtime_task_json_line("first-run", "pending", "queued");
|
||
let last = runtime_task_json_line("last-run", "completed", "completed");
|
||
fs::write(&path, format!("{first}\n{{\"runId\":\n{last}"))
|
||
.expect("write malformed middle runtime task line");
|
||
|
||
let error = read_all_game_creator_agent_runtime_tasks(&path)
|
||
.expect_err("malformed middle line must fail closed");
|
||
|
||
assert!(error.contains("第 2 行 JSON 无效"), "{error}");
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_task_reader_rejects_newline_terminated_malformed_tail() {
|
||
let root = unique_project_path();
|
||
fs::create_dir_all(&root).expect("create runtime task fixture directory");
|
||
let path = root.join("tasks.jsonl");
|
||
let complete = runtime_task_json_line("complete-run", "pending", "queued");
|
||
fs::write(&path, format!("{complete}\n{{\"runId\":\n"))
|
||
.expect("write terminated malformed runtime task tail");
|
||
|
||
let error = read_all_game_creator_agent_runtime_tasks(&path)
|
||
.expect_err("newline-terminated malformed tail must fail closed");
|
||
|
||
assert!(error.contains("第 2 行 JSON 无效"), "{error}");
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_task_reader_rejects_unterminated_non_truncated_syntax_error() {
|
||
let root = unique_project_path();
|
||
fs::create_dir_all(&root).expect("create runtime task fixture directory");
|
||
let path = root.join("tasks.jsonl");
|
||
let complete = runtime_task_json_line("complete-run", "pending", "queued");
|
||
fs::write(&path, format!("{complete}\nnot-json"))
|
||
.expect("write unterminated invalid runtime task tail");
|
||
|
||
let error = read_all_game_creator_agent_runtime_tasks(&path)
|
||
.expect_err("unterminated syntax error must not be treated as a truncated record");
|
||
|
||
assert!(error.contains("第 2 行 JSON 无效"), "{error}");
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_events_expose_stable_ids_and_backend_owned_public_text_only() {
|
||
let root = unique_project_path();
|
||
fs::create_dir_all(&root).expect("create runtime event fixture directory");
|
||
let state = default_game_creator_agent_runtime_state("code-prototype", "public-event-run");
|
||
|
||
append_game_creator_agent_runtime_action_event(
|
||
&root,
|
||
&state,
|
||
"turn.progress",
|
||
"running",
|
||
"planning",
|
||
"正在生成首个可玩版本",
|
||
Some("taskSha256=private-hash"),
|
||
"public-event-progress-1",
|
||
)
|
||
.expect("append public progress");
|
||
append_game_creator_agent_runtime_action_event(
|
||
&root,
|
||
&state,
|
||
"observation",
|
||
"running",
|
||
"observation",
|
||
"runtime.plan_update:blocked · 内部计划门禁",
|
||
Some("fingerprint=private"),
|
||
"public-event-internal-1",
|
||
)
|
||
.expect("append internal observation");
|
||
append_game_creator_agent_runtime_action_event(
|
||
&root,
|
||
&state,
|
||
"turn.progress",
|
||
"running",
|
||
"planning",
|
||
"Bearer secret-token",
|
||
None,
|
||
"public-event-sensitive-1",
|
||
)
|
||
.expect("append sensitive progress");
|
||
append_game_creator_agent_runtime_action_event(
|
||
&root,
|
||
&state,
|
||
"action",
|
||
"running",
|
||
"action",
|
||
"调用工具 file.write",
|
||
Some("raw tool input must stay private"),
|
||
"public-event-action-1",
|
||
)
|
||
.expect("append public action");
|
||
append_game_creator_agent_runtime_action_event(
|
||
&root,
|
||
&state,
|
||
"action",
|
||
"running",
|
||
"action",
|
||
"调用工具 file.write",
|
||
Some("raw tool input must stay private"),
|
||
"public-event-action-1",
|
||
)
|
||
.expect("repeat public action idempotently");
|
||
append_game_creator_agent_runtime_action_event(
|
||
&root,
|
||
&state,
|
||
"turn.failed",
|
||
"failed",
|
||
"failed",
|
||
"Agent Runtime 本轮处理失败。",
|
||
Some("kind=codex-app-server-context-window-exceeded fingerprint=private"),
|
||
"public-event-failure-1",
|
||
)
|
||
.expect("append classified public failure");
|
||
|
||
let events = read_recent_game_creator_agent_runtime_events(
|
||
&game_creator_agent_runtime_event_path(&root, "code-prototype"),
|
||
)
|
||
.expect("read public runtime events");
|
||
assert_eq!(events.len(), 5);
|
||
assert!(events.iter().all(|event| !event.event_id.trim().is_empty()));
|
||
let mut event_ids = events
|
||
.iter()
|
||
.map(|event| event.event_id.as_str())
|
||
.collect::<Vec<_>>();
|
||
event_ids.sort_unstable();
|
||
event_ids.dedup();
|
||
assert_eq!(event_ids.len(), events.len());
|
||
assert_eq!(
|
||
events[0].public_text.as_deref(),
|
||
Some("正在生成首个可玩版本")
|
||
);
|
||
assert_eq!(events[1].public_text, None);
|
||
assert_eq!(events[2].public_text, None);
|
||
assert_eq!(
|
||
events[3].public_text.as_deref(),
|
||
Some("调用工具 file.write")
|
||
);
|
||
assert!(!events[3]
|
||
.public_text
|
||
.as_deref()
|
||
.unwrap_or_default()
|
||
.contains("raw tool input must stay private"));
|
||
assert_eq!(
|
||
events[4].public_text.as_deref(),
|
||
Some("专业 Agent 模型上下文已超限,请缩小任务范围后重试")
|
||
);
|
||
assert_eq!(events[4].detail, events[4].public_text);
|
||
assert!(!events[4]
|
||
.public_text
|
||
.as_deref()
|
||
.unwrap_or_default()
|
||
.contains("fingerprint"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_task_reader_rejects_unknown_status_and_phase() {
|
||
let root = unique_project_path();
|
||
fs::create_dir_all(&root).expect("create runtime task fixture directory");
|
||
let path = root.join("tasks.jsonl");
|
||
for (status, phase, expected) in [
|
||
("unknown-status", "queued", "status 无效"),
|
||
("pending", "unknown-phase", "phase 无效"),
|
||
] {
|
||
fs::write(
|
||
&path,
|
||
runtime_task_json_line("invalid-schema-run", status, phase),
|
||
)
|
||
.expect("write invalid runtime task schema");
|
||
let error = read_all_game_creator_agent_runtime_tasks(&path)
|
||
.expect_err("unknown status or phase must fail closed");
|
||
assert!(error.contains(expected), "{error}");
|
||
}
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_task_reader_accepts_rare_persisted_phases() {
|
||
let root = unique_project_path();
|
||
fs::create_dir_all(&root).expect("create runtime task fixture directory");
|
||
let path = root.join("tasks.jsonl");
|
||
let brief = runtime_task_json_line("brief-run", "running", "brief");
|
||
let parent_link_missing =
|
||
runtime_task_json_line("parent-link-missing-run", "failed", "parent-link-missing");
|
||
let public_status_write_failed = runtime_task_json_line(
|
||
"public-status-write-failed-run",
|
||
"failed",
|
||
"public-status-write-failed",
|
||
);
|
||
// 写侧不校验 phase,只有读侧校验:白名单漏登记一个 phase,落盘之后整份 journal
|
||
// 从那一行起再也读不出来,`agent.run_status` 对该 Agent 永久失败。这里用写方
|
||
// 引用的同一个常量,漏登记会在这条用例上先红。
|
||
fs::write(
|
||
&path,
|
||
format!("{brief}\n{parent_link_missing}\n{public_status_write_failed}\n"),
|
||
)
|
||
.expect("write rare persisted task phases");
|
||
|
||
let records = read_all_game_creator_agent_runtime_tasks(&path)
|
||
.expect("known persisted task phases must remain readable");
|
||
|
||
assert_eq!(records.len(), 3);
|
||
assert_eq!(records[0].phase, "brief");
|
||
assert_eq!(records[1].phase, "parent-link-missing");
|
||
assert_eq!(records[2].phase, "public-status-write-failed");
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn parallel_jsonl_appends_keep_records_line_delimited() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "并行追加测试").expect("project init");
|
||
let conversation_path = root.join(".agent/conversations/agents/design-director.jsonl");
|
||
let worker_count = 8;
|
||
let records_per_worker = 20;
|
||
let gate = Arc::new((StdMutex::new(false), Condvar::new()));
|
||
let mut handles = Vec::new();
|
||
|
||
for worker_index in 0..worker_count {
|
||
let root = root.clone();
|
||
let conversation_path = conversation_path.clone();
|
||
let gate = Arc::clone(&gate);
|
||
handles.push(std::thread::spawn(move || {
|
||
let (lock, cvar) = &*gate;
|
||
let mut started = lock.lock().expect("start gate lock");
|
||
while !*started {
|
||
started = cvar.wait(started).expect("wait start gate");
|
||
}
|
||
drop(started);
|
||
|
||
for record_index in 0..records_per_worker {
|
||
let payload = "x".repeat(4096);
|
||
let line = serde_json::json!({
|
||
"schemaVersion": LOCAL_CONVERSATION_SCHEMA_VERSION,
|
||
"role": "assistant",
|
||
"content": format!("worker-{worker_index}-record-{record_index}-{payload}"),
|
||
"agentId": "design-director",
|
||
"updatedAt": unix_timestamp(),
|
||
})
|
||
.to_string();
|
||
append_jsonl_line(&conversation_path, &line, "测试对话记录")
|
||
.expect("append conversation line");
|
||
append_agent_db_record(
|
||
&root,
|
||
serde_json::json!({
|
||
"recordType": "test.parallel.append",
|
||
"workerIndex": worker_index,
|
||
"recordIndex": record_index,
|
||
}),
|
||
)
|
||
.expect("append agent db record");
|
||
}
|
||
}));
|
||
}
|
||
|
||
{
|
||
let (lock, cvar) = &*gate;
|
||
let mut started = lock.lock().expect("start gate lock");
|
||
*started = true;
|
||
cvar.notify_all();
|
||
}
|
||
for handle in handles {
|
||
handle.join().expect("append worker");
|
||
}
|
||
|
||
let expected_records = worker_count * records_per_worker;
|
||
let conversation = fs::read_to_string(&conversation_path).expect("conversation file");
|
||
let conversation_records = conversation.lines().collect::<Vec<_>>();
|
||
assert_eq!(conversation_records.len(), expected_records);
|
||
for line in conversation_records {
|
||
serde_json::from_str::<Value>(line).expect("conversation line should be valid json");
|
||
}
|
||
|
||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
|
||
let mut parallel_records = 0;
|
||
for line in agent_db.lines() {
|
||
let value =
|
||
serde_json::from_str::<Value>(line).expect("agent db line should be valid json");
|
||
if value.get("recordType").and_then(Value::as_str) == Some("test.parallel.append") {
|
||
parallel_records += 1;
|
||
}
|
||
}
|
||
assert_eq!(parallel_records, expected_records);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_lock_status_keeps_fresh_same_process_locks_busy() {
|
||
let root = unique_project_path();
|
||
let lock_path = root.join(".agent/runtime/locks/design-director.lock");
|
||
fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("lock dir");
|
||
fs::write(
|
||
&lock_path,
|
||
serde_json::json!({
|
||
"agentId": "design-director",
|
||
"pid": std::process::id(),
|
||
"createdAt": unix_timestamp(),
|
||
})
|
||
.to_string(),
|
||
)
|
||
.expect("write lock");
|
||
|
||
let status = read_game_creator_agent_runtime_lock_status(&lock_path);
|
||
|
||
assert!(!status.is_stale);
|
||
assert!(!status.belongs_to_previous_process);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_lock_status_marks_old_previous_process_locks_stale() {
|
||
let root = unique_project_path();
|
||
let lock_path = root.join(".agent/runtime/locks/design-director.lock");
|
||
fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("lock dir");
|
||
fs::write(
|
||
&lock_path,
|
||
serde_json::json!({
|
||
"agentId": "design-director",
|
||
"pid": u64::from(std::process::id()) + 1000,
|
||
"createdAt": unix_timestamp()
|
||
.saturating_sub(AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS + 1),
|
||
})
|
||
.to_string(),
|
||
)
|
||
.expect("write lock");
|
||
|
||
let status = read_game_creator_agent_runtime_lock_status(&lock_path);
|
||
|
||
assert!(status.is_stale);
|
||
assert!(status.belongs_to_previous_process);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_system_lock_allows_only_one_owner() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "锁互斥测试").expect("project init");
|
||
let first = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director")
|
||
.expect("first lock")
|
||
.expect("first owner acquires lock");
|
||
assert!(
|
||
try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director")
|
||
.expect("second lock attempt")
|
||
.is_none()
|
||
);
|
||
drop(first);
|
||
let replacement = wait_to_acquire_agent_runtime_lock(&root, "design-director");
|
||
|
||
assert!(root
|
||
.join(".agent/runtime/locks/design-director.lock")
|
||
.exists());
|
||
drop(replacement);
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn agent_runtime_system_lock_rejects_symlink_without_touching_target() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "锁链接测试").expect("project init");
|
||
let target = root.join("outside-lock-target.txt");
|
||
fs::write(&target, "keep-me").expect("write lock target");
|
||
let lock_path = root.join(".agent/runtime/locks/design-director.lock");
|
||
fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("lock dir");
|
||
symlink(&target, &lock_path).expect("symlink lock path");
|
||
|
||
assert!(try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director").is_err());
|
||
assert_eq!(fs::read_to_string(&target).expect("read target"), "keep-me");
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn agent_runtime_system_lock_rejects_symlinked_parent_without_touching_target() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "锁父目录链接测试").expect("project init");
|
||
let outside_locks = root.join("outside-locks");
|
||
fs::create_dir(&outside_locks).expect("outside lock dir");
|
||
let target = outside_locks.join("design-director.lock");
|
||
fs::write(&target, "keep-me").expect("write lock target");
|
||
let locks_dir = root.join(".agent/runtime/locks");
|
||
if locks_dir.exists() {
|
||
fs::remove_dir_all(&locks_dir).expect("remove existing locks directory");
|
||
}
|
||
symlink(&outside_locks, &locks_dir).expect("symlink locks directory");
|
||
|
||
assert!(try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director").is_err());
|
||
assert_eq!(fs::read_to_string(&target).expect("read target"), "keep-me");
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn agent_runtime_system_lock_rejects_hard_link_without_touching_target() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "锁硬链接测试").expect("project init");
|
||
let target = root.join("outside-hard-link-target.txt");
|
||
fs::write(&target, "keep-me").expect("write lock target");
|
||
let lock_path = root.join(".agent/runtime/locks/design-director.lock");
|
||
fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("lock dir");
|
||
fs::hard_link(&target, &lock_path).expect("hard-link lock path");
|
||
|
||
assert!(try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director").is_err());
|
||
assert_eq!(fs::read_to_string(&target).expect("read target"), "keep-me");
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn missing_runtime_state_stays_idle_without_a_run_profile_binding() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-empty-runtime", "空 Runtime 项目")
|
||
.expect("project init");
|
||
|
||
let runtime =
|
||
read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
|
||
.expect("read default Supervisor runtime");
|
||
|
||
assert!(runtime.state.run_id.is_empty());
|
||
assert_eq!(runtime.state.status, "idle");
|
||
assert_eq!(runtime.state.phase, "idle");
|
||
assert!(runtime.state.error.is_none());
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn structured_plan_update_validates_and_advances_monotonically() {
|
||
let parsed = parse_game_creator_agent_tool_plan_response(
|
||
&serde_json::json!({
|
||
"thinkingSummary": "先建立可恢复计划",
|
||
"planUpdate": {
|
||
"explanation": "首次拆解",
|
||
"steps": [
|
||
{ "step": "读取项目", "status": "in_progress" },
|
||
{ "step": "验证结果", "status": "pending" }
|
||
]
|
||
},
|
||
"plan": ["旧计划应被结构化更新覆盖"],
|
||
"actions": [],
|
||
"response": ""
|
||
})
|
||
.to_string(),
|
||
)
|
||
.expect("parse structured plan");
|
||
assert!(parsed.plan_update.is_some());
|
||
|
||
let legacy = parse_game_creator_agent_tool_plan_response(
|
||
&serde_json::json!({
|
||
"thinkingSummary": "旧 Provider fallback",
|
||
"plan": ["旧步骤"],
|
||
"actions": [],
|
||
"response": ""
|
||
})
|
||
.to_string(),
|
||
)
|
||
.expect("parse legacy plan");
|
||
assert!(legacy.plan_update.is_none());
|
||
|
||
let mut runtime = default_game_creator_agent_runtime_state("design-director", "plan-run");
|
||
assert!(apply_agent_runtime_plan_update(
|
||
&mut runtime,
|
||
parsed.plan_update.as_ref().expect("plan update"),
|
||
)
|
||
.expect("apply first plan")
|
||
.advanced_steps());
|
||
assert_eq!(runtime.plan_revision, 1);
|
||
assert_eq!(runtime.active_plan_step_index, Some(0));
|
||
assert_eq!(runtime.plan_steps[0].status, "in_progress");
|
||
assert_eq!(runtime.plan, vec!["读取项目", "验证结果"]);
|
||
|
||
assert_eq!(
|
||
apply_agent_runtime_plan_update(
|
||
&mut runtime,
|
||
parsed.plan_update.as_ref().expect("same plan update"),
|
||
)
|
||
.expect("idempotent plan update"),
|
||
AgentRuntimePlanUpdateOutcome::Unchanged
|
||
);
|
||
assert_eq!(runtime.plan_revision, 1);
|
||
|
||
let progressed = AgentRuntimePlanUpdate {
|
||
explanation: "读取完成,开始验证".to_string(),
|
||
steps: vec![
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "读取项目".to_string(),
|
||
status: "completed".to_string(),
|
||
},
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "验证结果".to_string(),
|
||
status: "in_progress".to_string(),
|
||
},
|
||
],
|
||
};
|
||
assert!(apply_agent_runtime_plan_update(&mut runtime, &progressed)
|
||
.expect("advance plan")
|
||
.advanced_steps());
|
||
assert_eq!(runtime.plan_revision, 2);
|
||
assert_eq!(runtime.plan_steps[0].status, "completed");
|
||
assert_eq!(runtime.active_plan_step_index, Some(1));
|
||
|
||
let regression = AgentRuntimePlanUpdate {
|
||
explanation: "错误回退".to_string(),
|
||
steps: vec![
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "读取项目".to_string(),
|
||
status: "pending".to_string(),
|
||
},
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "验证结果".to_string(),
|
||
status: "in_progress".to_string(),
|
||
},
|
||
],
|
||
};
|
||
assert!(apply_agent_runtime_plan_update(&mut runtime, ®ression)
|
||
.expect_err("completed step cannot regress")
|
||
.contains("不能让已完成步骤回退"));
|
||
assert_eq!(runtime.plan_revision, 2);
|
||
|
||
let completed = AgentRuntimePlanUpdate {
|
||
explanation: "全部完成".to_string(),
|
||
steps: vec![AgentRuntimePlanUpdateStep {
|
||
step: "验证结果".to_string(),
|
||
status: "completed".to_string(),
|
||
}],
|
||
};
|
||
assert!(apply_agent_runtime_plan_update(&mut runtime, &completed)
|
||
.expect("terminal step is retained")
|
||
.advanced_steps());
|
||
assert_eq!(runtime.plan_revision, 3);
|
||
assert_eq!(runtime.plan, vec!["读取项目", "验证结果"]);
|
||
assert!(runtime
|
||
.plan_steps
|
||
.iter()
|
||
.all(|step| step.status == "completed"));
|
||
assert!(structured_plan_completion_blocker(&runtime).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn first_structured_plan_replaces_terminal_legacy_scaffolding_steps() {
|
||
let mut runtime = default_game_creator_agent_runtime_state("code-prototype", "legacy-plan-run");
|
||
runtime.plan = vec![
|
||
"记录开发者投递的后台任务".to_string(),
|
||
"结构化修复步骤 2".to_string(),
|
||
];
|
||
runtime.plan_steps = vec![
|
||
AgentRuntimePlanStep {
|
||
index: 0,
|
||
title: "记录开发者投递的后台任务".to_string(),
|
||
status: "active".to_string(),
|
||
detail: None,
|
||
updated_at: unix_timestamp(),
|
||
},
|
||
AgentRuntimePlanStep {
|
||
index: 1,
|
||
title: "结构化修复步骤 2".to_string(),
|
||
status: "pending".to_string(),
|
||
detail: Some("legacy-detail-canary".to_string()),
|
||
updated_at: 1,
|
||
},
|
||
];
|
||
runtime.active_plan_step_index = Some(0);
|
||
complete_agent_runtime_active_plan_step(
|
||
&mut runtime,
|
||
"failed",
|
||
"command.run_limited:failed · game.static_smoke 执行失败",
|
||
);
|
||
assert_eq!(runtime.plan_revision, 0);
|
||
assert_eq!(runtime.plan_steps.len(), 2);
|
||
assert_eq!(runtime.plan_steps[0].status, "failed");
|
||
|
||
let first_structured_update = AgentRuntimePlanUpdate {
|
||
explanation: "根据 smoke 诊断进入同 Run 修复".to_string(),
|
||
steps: (1..=AGENT_RUNTIME_PLAN_STEP_LIMIT)
|
||
.map(|index| AgentRuntimePlanUpdateStep {
|
||
step: format!("结构化修复步骤 {index}"),
|
||
status: if index == 1 { "in_progress" } else { "pending" }.to_string(),
|
||
})
|
||
.collect(),
|
||
};
|
||
|
||
assert!(
|
||
apply_agent_runtime_plan_update(&mut runtime, &first_structured_update)
|
||
.expect("first structured plan replaces legacy scaffolding")
|
||
.advanced_steps()
|
||
);
|
||
assert_eq!(runtime.plan_revision, 1);
|
||
assert_eq!(runtime.plan_steps.len(), AGENT_RUNTIME_PLAN_STEP_LIMIT);
|
||
assert_eq!(runtime.active_plan_step_index, Some(0));
|
||
assert!(runtime
|
||
.plan_steps
|
||
.iter()
|
||
.all(|step| step.title != "记录开发者投递的后台任务"));
|
||
assert!(runtime
|
||
.plan_steps
|
||
.iter()
|
||
.all(|step| step.status != "failed"));
|
||
let same_title_step = runtime
|
||
.plan_steps
|
||
.iter()
|
||
.find(|step| step.title == "结构化修复步骤 2")
|
||
.expect("same-title structured step exists");
|
||
assert_eq!(same_title_step.detail, None);
|
||
assert_ne!(same_title_step.updated_at, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn explanation_only_plan_update_is_not_plan_progress() {
|
||
let mut runtime = default_game_creator_agent_runtime_state("design-director", "plan-idle-run");
|
||
let initial = AgentRuntimePlanUpdate {
|
||
explanation: "先读项目再验证".to_string(),
|
||
steps: vec![
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "读取项目".to_string(),
|
||
status: "in_progress".to_string(),
|
||
},
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "验证结果".to_string(),
|
||
status: "pending".to_string(),
|
||
},
|
||
],
|
||
};
|
||
assert!(apply_agent_runtime_plan_update(&mut runtime, &initial)
|
||
.expect("apply first plan")
|
||
.advanced_steps());
|
||
assert_eq!(runtime.plan_revision, 1);
|
||
|
||
// 步骤集合与状态一字未动,只换了解释:这是活锁的形状,不能算计划进展,
|
||
// 也不能发一个新 planRevision 替它背书。
|
||
let explanation_only = AgentRuntimePlanUpdate {
|
||
explanation: "换个说法解释同一个计划".to_string(),
|
||
steps: initial.steps.clone(),
|
||
};
|
||
let outcome = apply_agent_runtime_plan_update(&mut runtime, &explanation_only)
|
||
.expect("apply explanation-only plan");
|
||
assert_eq!(outcome, AgentRuntimePlanUpdateOutcome::ExplanationOnly);
|
||
assert!(!outcome.advanced_steps());
|
||
assert_eq!(runtime.plan_revision, 1);
|
||
assert_eq!(runtime.plan_explanation, "换个说法解释同一个计划");
|
||
assert_eq!(runtime.plan_steps[0].status, "in_progress");
|
||
assert_eq!(runtime.plan_steps[1].status, "pending");
|
||
|
||
// 真把步骤推到下一格才重新计数。
|
||
let progressed = AgentRuntimePlanUpdate {
|
||
explanation: "换个说法解释同一个计划".to_string(),
|
||
steps: vec![
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "读取项目".to_string(),
|
||
status: "completed".to_string(),
|
||
},
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "验证结果".to_string(),
|
||
status: "in_progress".to_string(),
|
||
},
|
||
],
|
||
};
|
||
assert!(apply_agent_runtime_plan_update(&mut runtime, &progressed)
|
||
.expect("advance plan")
|
||
.advanced_steps());
|
||
assert_eq!(runtime.plan_revision, 2);
|
||
}
|
||
|
||
#[test]
|
||
fn plan_update_idle_repair_threshold_tolerates_exactly_one_split_round() {
|
||
// 第一轮只更新计划、下一轮才动手是正常的两步走,不该被当成活锁。
|
||
assert!(!plan_update_idle_rounds_require_repair(0));
|
||
assert!(!plan_update_idle_rounds_require_repair(1));
|
||
assert!(plan_update_idle_rounds_require_repair(2));
|
||
assert!(plan_update_idle_rounds_require_repair(3));
|
||
}
|
||
|
||
#[test]
|
||
fn structured_plan_failed_step_remains_immutable_after_migration() {
|
||
let mut runtime = default_game_creator_agent_runtime_state("code-prototype", "failed-plan-run");
|
||
runtime.plan_revision = 1;
|
||
runtime.plan_explanation = "已进入结构化计划".to_string();
|
||
runtime.plan = vec!["不可改写的失败步骤".to_string()];
|
||
runtime.plan_steps = vec![AgentRuntimePlanStep {
|
||
index: 0,
|
||
title: "不可改写的失败步骤".to_string(),
|
||
status: "failed".to_string(),
|
||
detail: Some("非验证类动作失败".to_string()),
|
||
updated_at: unix_timestamp(),
|
||
}];
|
||
|
||
let error = apply_agent_runtime_plan_update(
|
||
&mut runtime,
|
||
&AgentRuntimePlanUpdate {
|
||
explanation: "尝试错误重开".to_string(),
|
||
steps: vec![AgentRuntimePlanUpdateStep {
|
||
step: "不可改写的失败步骤".to_string(),
|
||
status: "in_progress".to_string(),
|
||
}],
|
||
},
|
||
)
|
||
.expect_err("structured failed step must remain immutable");
|
||
|
||
assert!(error.contains("不能改写已失败步骤"));
|
||
assert_eq!(runtime.plan_revision, 1);
|
||
assert_eq!(runtime.plan_steps[0].status, "failed");
|
||
}
|
||
|
||
#[test]
|
||
fn structured_plan_update_rejects_invalid_shapes_and_ignores_action_indexes() {
|
||
let invalid_updates = [
|
||
AgentRuntimePlanUpdate {
|
||
explanation: " ".to_string(),
|
||
steps: vec![AgentRuntimePlanUpdateStep {
|
||
step: "步骤".to_string(),
|
||
status: "pending".to_string(),
|
||
}],
|
||
},
|
||
AgentRuntimePlanUpdate {
|
||
explanation: "重复".to_string(),
|
||
steps: vec![
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "步骤".to_string(),
|
||
status: "pending".to_string(),
|
||
},
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "步骤".to_string(),
|
||
status: "completed".to_string(),
|
||
},
|
||
],
|
||
},
|
||
AgentRuntimePlanUpdate {
|
||
explanation: "多活动步骤".to_string(),
|
||
steps: vec![
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "步骤一".to_string(),
|
||
status: "in_progress".to_string(),
|
||
},
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "步骤二".to_string(),
|
||
status: "in_progress".to_string(),
|
||
},
|
||
],
|
||
},
|
||
AgentRuntimePlanUpdate {
|
||
explanation: "未知状态".to_string(),
|
||
steps: vec![AgentRuntimePlanUpdateStep {
|
||
step: "步骤".to_string(),
|
||
status: "blocked".to_string(),
|
||
}],
|
||
},
|
||
];
|
||
for update in invalid_updates {
|
||
assert!(sanitize_agent_runtime_plan_update(&update).is_err());
|
||
}
|
||
|
||
let mut runtime = default_game_creator_agent_runtime_state("design-director", "guard-run");
|
||
let update = AgentRuntimePlanUpdate {
|
||
explanation: "等待工具真实结果".to_string(),
|
||
steps: vec![
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "读取项目".to_string(),
|
||
status: "in_progress".to_string(),
|
||
},
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "验证结果".to_string(),
|
||
status: "pending".to_string(),
|
||
},
|
||
],
|
||
};
|
||
apply_agent_runtime_plan_update(&mut runtime, &update).expect("apply guarded plan");
|
||
let snapshot = runtime.plan_steps.clone();
|
||
activate_agent_runtime_plan_step(&mut runtime, 1, "工具数组第二项");
|
||
complete_agent_runtime_active_plan_step(&mut runtime, "completed", "工具成功");
|
||
retry_agent_runtime_active_plan_step(&mut runtime, "等待重试");
|
||
activate_agent_runtime_response_plan_step(&mut runtime, "准备回复");
|
||
complete_agent_runtime_remaining_plan_steps(&mut runtime, "自动收束");
|
||
assert_eq!(runtime.plan_steps, snapshot);
|
||
let blocker = structured_plan_completion_blocker(&runtime).expect("incomplete plan blocks");
|
||
assert_eq!(blocker.tool, "runtime.plan_update");
|
||
assert!(blocker.summary.contains("0/2"));
|
||
let blocker_detail = blocker.detail.expect("structured blocker detail");
|
||
assert!(blocker_detail.contains("planRevision=1"));
|
||
assert!(blocker_detail.contains("completed=0"));
|
||
assert!(blocker_detail.contains("pending=1"));
|
||
assert!(blocker_detail.contains("inProgress=1"));
|
||
assert!(blocker_detail.contains("failed=0"));
|
||
assert!(blocker_detail.contains("必须在同一响应调用具体动作函数"));
|
||
assert!(blocker_detail.contains("不能只改计划解释"));
|
||
assert!(blocker_detail.contains(&format!(
|
||
"in_progress:{:x}",
|
||
Sha256::digest("读取项目".as_bytes())
|
||
)));
|
||
assert!(blocker_detail.contains(&format!(
|
||
"pending:{:x}",
|
||
Sha256::digest("验证结果".as_bytes())
|
||
)));
|
||
assert!(!blocker.summary.contains("读取项目"));
|
||
assert!(!blocker.summary.contains("验证结果"));
|
||
assert!(!blocker_detail.contains("读取项目"));
|
||
assert!(!blocker_detail.contains("验证结果"));
|
||
}
|
||
|
||
#[test]
|
||
fn structured_plan_state_normalization_fails_closed_on_corruption() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "损坏计划状态项目").expect("project init");
|
||
let mut runtime = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"验证损坏计划失败关闭",
|
||
"structured-plan-corrupt-run",
|
||
"agent-background-task",
|
||
"损坏计划测试",
|
||
vec!["旧计划".to_string()],
|
||
)
|
||
.expect("start corrupt plan state");
|
||
apply_agent_runtime_plan_update(
|
||
&mut runtime,
|
||
&AgentRuntimePlanUpdate {
|
||
explanation: "当前步骤执行中".to_string(),
|
||
steps: vec![AgentRuntimePlanUpdateStep {
|
||
step: "执行验证".to_string(),
|
||
status: "in_progress".to_string(),
|
||
}],
|
||
},
|
||
)
|
||
.expect("apply plan before corruption");
|
||
runtime.active_plan_step_index = None;
|
||
write_game_creator_agent_runtime_state(&root, &runtime).expect("persist corrupt plan state");
|
||
|
||
let loaded = read_game_creator_agent_runtime_at(&root, "design-director")
|
||
.expect("read corrupt plan state")
|
||
.state;
|
||
assert_eq!(loaded.status, "needs-reconciliation");
|
||
assert_eq!(loaded.phase, "needs-reconciliation");
|
||
assert!(loaded.error.as_deref().is_some_and(|error| {
|
||
error.contains("activePlanStepIndex") && error.contains("in_progress")
|
||
}));
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn structured_plan_failure_after_all_steps_completed_preserves_terminal_snapshot() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "结构化计划失败收束项目").expect("project init");
|
||
let mut state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"计划完成后模拟 Runtime 失败",
|
||
"structured-plan-completed-before-failure-run",
|
||
"agent-background-task",
|
||
"准备失败收束回归",
|
||
vec!["旧计划占位".to_string()],
|
||
)
|
||
.expect("start completed structured plan fixture");
|
||
apply_agent_runtime_plan_update(
|
||
&mut state,
|
||
&AgentRuntimePlanUpdate {
|
||
explanation: "所有必要步骤已完成".to_string(),
|
||
steps: vec![
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "完成实现".to_string(),
|
||
status: "completed".to_string(),
|
||
},
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "完成验证".to_string(),
|
||
status: "completed".to_string(),
|
||
},
|
||
],
|
||
},
|
||
)
|
||
.expect("apply fully completed structured plan");
|
||
write_game_creator_agent_runtime_state(&root, &state)
|
||
.expect("persist completed structured plan before failure");
|
||
let expected_revision = state.plan_revision;
|
||
let expected_explanation = state.plan_explanation.clone();
|
||
let expected_plan = state.plan.clone();
|
||
let expected_steps = state.plan_steps.clone();
|
||
let expected_active_index = state.active_plan_step_index;
|
||
assert_eq!(expected_revision, 1);
|
||
assert!(expected_steps.iter().all(|step| step.status == "completed"));
|
||
assert_eq!(expected_active_index, None);
|
||
|
||
let failed = fail_game_creator_agent_runtime_turn_at(
|
||
&root,
|
||
state,
|
||
"结构化计划完成后的模拟 Runtime 失败",
|
||
)
|
||
.expect("fail completed structured plan through real terminal path");
|
||
assert_eq!(failed.status, "failed");
|
||
assert_eq!(failed.phase, "failed");
|
||
assert_eq!(failed.plan_revision, expected_revision);
|
||
assert_eq!(failed.plan_explanation, expected_explanation);
|
||
assert_eq!(failed.plan, expected_plan);
|
||
assert_eq!(failed.plan_steps, expected_steps);
|
||
assert_eq!(failed.active_plan_step_index, expected_active_index);
|
||
assert!(failed
|
||
.plan_steps
|
||
.iter()
|
||
.all(|step| step.status == "completed"));
|
||
assert!(structured_plan_completion_blocker(&failed).is_none());
|
||
|
||
let persisted = read_game_creator_agent_runtime_at(&root, "design-director")
|
||
.expect("read failed structured plan state")
|
||
.state;
|
||
assert_eq!(persisted.status, "failed");
|
||
assert_eq!(persisted.phase, "failed");
|
||
assert_eq!(persisted.plan_revision, expected_revision);
|
||
assert_eq!(persisted.plan_explanation, expected_explanation);
|
||
assert_eq!(persisted.plan, expected_plan);
|
||
assert_eq!(persisted.plan_steps, expected_steps);
|
||
assert_eq!(persisted.active_plan_step_index, expected_active_index);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_failure_public_audits_hash_private_delivery_diagnostics() {
|
||
for (
|
||
agent_id,
|
||
child_run_id,
|
||
parent_run_id,
|
||
parent_action_id,
|
||
private_error,
|
||
budget_exhausted,
|
||
terminal_event_type,
|
||
failure_kind,
|
||
) in [
|
||
(
|
||
"code-prototype",
|
||
"public-failure-audit-code-run",
|
||
"public-failure-audit-code-parent-run",
|
||
"action-111111111111111111111111",
|
||
"private deliveryStructuredResult:程序 Agent 失败诊断正文仅供私有返工使用",
|
||
false,
|
||
"turn.failed",
|
||
"tool-plan-failed",
|
||
),
|
||
(
|
||
"quality-review",
|
||
"public-failure-audit-quality-run",
|
||
"public-failure-audit-quality-parent-run",
|
||
"action-222222222222222222222222",
|
||
"private deliveryResult:质量 Agent 预算耗尽诊断正文仅供私有返工使用",
|
||
true,
|
||
"turn.budget_exhausted",
|
||
"loop-budget-exhausted",
|
||
),
|
||
] {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "公共失败审计正文隔离测试")
|
||
.expect("project init");
|
||
let parent = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
"等待专业 Agent 私有失败回执",
|
||
parent_run_id,
|
||
"agent-chat",
|
||
"等待专业 Agent",
|
||
vec!["处理专业 Agent 回执".to_string()],
|
||
)
|
||
.expect("start parent runtime");
|
||
let mut child = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
agent_id,
|
||
"执行专业 Agent 任务并保留私有失败诊断",
|
||
child_run_id,
|
||
"agent-delegate",
|
||
"执行专业任务",
|
||
vec!["返回私有诊断".to_string()],
|
||
)
|
||
.expect("start child runtime");
|
||
let delegation_id = agent_runtime_delegation_id(
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
parent_run_id,
|
||
agent_id,
|
||
parent_action_id,
|
||
);
|
||
child.parent_agent_id = Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string());
|
||
child.parent_run_id = Some(parent_run_id.to_string());
|
||
child.delegation_id = Some(delegation_id.clone());
|
||
let delivery = new_static_delegate_delivery(
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
&parent.session_id,
|
||
parent_run_id,
|
||
parent_action_id,
|
||
&delegation_id,
|
||
agent_id,
|
||
&child.session_id,
|
||
child_run_id,
|
||
);
|
||
create_or_read_static_delegate_delivery_at(&root, &delivery)
|
||
.expect("create static delivery");
|
||
|
||
let failed = if budget_exhausted {
|
||
fail_game_creator_agent_runtime_budget_at(&root, child, private_error)
|
||
} else {
|
||
fail_game_creator_agent_runtime_turn_at(&root, child, private_error)
|
||
}
|
||
.expect("persist private failure state");
|
||
append_game_creator_agent_background_task_failed_audit(&root, &failed, failure_kind)
|
||
.expect("append public failure audit");
|
||
|
||
assert_eq!(failed.error.as_deref(), Some(private_error));
|
||
let private_delivery = read_static_delegate_delivery_at(&root, &delegation_id)
|
||
.expect("read private delivery")
|
||
.expect("private delivery exists");
|
||
assert_eq!(private_delivery.status, StaticDelegateDeliveryStatus::Ready);
|
||
assert_eq!(
|
||
private_delivery.result_summary.as_deref(),
|
||
Some(private_error)
|
||
);
|
||
assert_eq!(
|
||
private_delivery
|
||
.structured_result
|
||
.as_ref()
|
||
.and_then(|result| result.error.as_deref()),
|
||
Some(private_error)
|
||
);
|
||
|
||
let error_sha256 = format!("{:x}", Sha256::digest(private_error.as_bytes()));
|
||
let error_chars = private_error.chars().count();
|
||
let expected_public_detail = "专业 Agent 执行失败,请稍后重试";
|
||
let result = read_game_creator_agent_runtime_at(&root, agent_id)
|
||
.expect("read failed runtime projection");
|
||
let public_failure_events = result
|
||
.recent_events
|
||
.iter()
|
||
.filter(|event| {
|
||
event.run_id == child_run_id
|
||
&& matches!(
|
||
event.event_type.as_str(),
|
||
"error" | "turn.failed" | "turn.budget_exhausted"
|
||
)
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(public_failure_events.len(), 2);
|
||
assert!(public_failure_events
|
||
.iter()
|
||
.any(|event| event.event_type == "error"));
|
||
assert!(public_failure_events
|
||
.iter()
|
||
.any(|event| event.event_type == terminal_event_type));
|
||
assert!(public_failure_events.iter().all(|event| {
|
||
event.detail.as_deref() == Some(expected_public_detail)
|
||
&& !event.summary.contains(private_error)
|
||
}));
|
||
let event_log = fs::read_to_string(game_creator_agent_runtime_event_path(&root, agent_id))
|
||
.expect("read public event log");
|
||
assert!(!event_log.contains(private_error));
|
||
|
||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("read agent db");
|
||
assert!(!agent_db.contains(private_error));
|
||
let failure_records = agent_db
|
||
.lines()
|
||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||
.filter(|record| {
|
||
record.get("recordType").and_then(Value::as_str)
|
||
== Some("agent.runtime.background_task.failed")
|
||
&& record.get("runId").and_then(Value::as_str) == Some(child_run_id)
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(failure_records.len(), 1);
|
||
let failure_record = &failure_records[0];
|
||
assert_eq!(failure_record["agentId"], agent_id);
|
||
assert_eq!(failure_record["taskId"], agent_id);
|
||
assert_eq!(failure_record["sessionId"], failed.session_id);
|
||
assert_eq!(failure_record["runId"], child_run_id);
|
||
assert_eq!(failure_record["source"], "agent-delegate");
|
||
assert_eq!(failure_record["failureKind"], failure_kind);
|
||
assert_eq!(failure_record["errorSha256"], error_sha256);
|
||
assert_eq!(
|
||
failure_record["errorChars"].as_u64(),
|
||
Some(error_chars as u64)
|
||
);
|
||
assert!(failure_record.get("error").is_none());
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn background_final_reply_failure_keeps_private_conversation_and_hashes_public_audits() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "最终回复公共失败审计测试")
|
||
.expect("project init");
|
||
let config_dir = unique_project_path();
|
||
fs::create_dir_all(&config_dir).expect("create public failure audit config dir");
|
||
let config_guard = use_test_runtime_config_dir(config_dir.clone());
|
||
let config_path = config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME);
|
||
let planning_response = serde_json::json!({
|
||
"thinkingSummary": "已有上下文足够,准备生成最终回复",
|
||
"plan": ["回复开发者"],
|
||
"actions": [],
|
||
"response": ""
|
||
})
|
||
.to_string();
|
||
let base_url = spawn_mock_llm_tool_plan_then_invalid_final_reply(planning_response);
|
||
replace_test_local_config(
|
||
&config_path,
|
||
format!(
|
||
r#"{{
|
||
"agentMode": "provider",
|
||
"agentLlm": {{
|
||
"design-director": {{
|
||
"apiKey": "design-key",
|
||
"baseUrl": {base_url:?},
|
||
"model": "design-runtime-model",
|
||
"apiKind": "openai_responses",
|
||
"maxRetries": 0
|
||
}}
|
||
}}
|
||
}}"#
|
||
),
|
||
);
|
||
let run_id = "background-final-reply-public-failure-audit-run";
|
||
start_game_creator_agent_background_task_at(
|
||
&root,
|
||
"design-director",
|
||
"验证最终回复失败的私有诊断和公共审计边界",
|
||
run_id,
|
||
)
|
||
.expect("start background task");
|
||
|
||
let runtime = wait_for_agent_runtime_terminal_and_lane_release_async(
|
||
&root,
|
||
"design-director",
|
||
run_id,
|
||
"failed",
|
||
"failed",
|
||
)
|
||
.await
|
||
.state;
|
||
assert_eq!(runtime.status, "failed");
|
||
assert_eq!(runtime.phase, "failed");
|
||
let private_error = runtime.error.clone().expect("private runtime error");
|
||
assert!(!private_error.trim().is_empty());
|
||
|
||
let error_sha256 = format!("{:x}", Sha256::digest(private_error.as_bytes()));
|
||
let error_chars = private_error.chars().count();
|
||
let expected_public_detail = "专业 Agent 服务请求失败,请稍后重试";
|
||
let result = read_game_creator_agent_runtime_at(&root, "design-director")
|
||
.expect("read public failure projections");
|
||
for event_type in ["error", "turn.failed"] {
|
||
let event = result
|
||
.recent_events
|
||
.iter()
|
||
.find(|event| event.run_id == run_id && event.event_type == event_type)
|
||
.expect("public failure event");
|
||
assert_eq!(event.detail.as_deref(), Some(expected_public_detail));
|
||
}
|
||
let event_log = fs::read_to_string(game_creator_agent_runtime_event_path(
|
||
&root,
|
||
"design-director",
|
||
))
|
||
.expect("read public event log");
|
||
assert!(!event_log.contains(&private_error));
|
||
|
||
let failure_records = read_agent_db_records_for_test(&root)
|
||
.into_iter()
|
||
.filter(|record| {
|
||
record.get("recordType").and_then(Value::as_str)
|
||
== Some("agent.runtime.background_task.failed")
|
||
&& record.get("runId").and_then(Value::as_str) == Some(run_id)
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(failure_records.len(), 1);
|
||
let failure_record = &failure_records[0];
|
||
assert_eq!(failure_record["failureKind"], "final-reply-failed");
|
||
assert_eq!(failure_record["errorSha256"], error_sha256);
|
||
assert_eq!(
|
||
failure_record["errorChars"].as_u64(),
|
||
Some(error_chars as u64)
|
||
);
|
||
assert!(failure_record.get("error").is_none());
|
||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("read agent db");
|
||
assert!(!agent_db.contains(&private_error));
|
||
|
||
let conversation = read_local_conversation_for_session_at(
|
||
&root,
|
||
Some("design-director"),
|
||
Some(&runtime.session_id),
|
||
)
|
||
.expect("read Runtime-owned public failure conversation");
|
||
assert!(conversation.messages.iter().any(|message| {
|
||
message.role == "assistant"
|
||
&& message.content == "专业 Agent 服务请求失败,请稍后重试"
|
||
&& message.message_id.as_deref().is_some_and(|message_id| {
|
||
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
|
||
})
|
||
}));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
drop(config_guard);
|
||
fs::remove_dir_all(config_dir).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn structured_plan_failure_and_budget_preserve_last_trusted_progress() {
|
||
for budget_exhausted in [false, true] {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "结构化计划失败进度项目")
|
||
.expect("project init");
|
||
let run_id = if budget_exhausted {
|
||
"structured-plan-budget-preserves-progress-run"
|
||
} else {
|
||
"structured-plan-failure-preserves-progress-run"
|
||
};
|
||
let mut state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"保留失败前最后一次可信计划进度",
|
||
run_id,
|
||
"agent-background-task",
|
||
"准备失败进度回归",
|
||
vec!["旧计划占位".to_string()],
|
||
)
|
||
.expect("start structured failure fixture");
|
||
apply_agent_runtime_plan_update(
|
||
&mut state,
|
||
&AgentRuntimePlanUpdate {
|
||
explanation: "实现已完成,验证仍在执行".to_string(),
|
||
steps: vec![
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "完成实现".to_string(),
|
||
status: "completed".to_string(),
|
||
},
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "运行验证".to_string(),
|
||
status: "in_progress".to_string(),
|
||
},
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "整理结果".to_string(),
|
||
status: "pending".to_string(),
|
||
},
|
||
],
|
||
},
|
||
)
|
||
.expect("apply structured failure plan");
|
||
write_game_creator_agent_runtime_state(&root, &state)
|
||
.expect("persist structured state before failure");
|
||
persist_game_creator_agent_runtime_context(
|
||
&root,
|
||
&state,
|
||
&state.current_task,
|
||
&AgentRuntimeToolPlan::default(),
|
||
&[],
|
||
0,
|
||
&AgentRuntimeContextWindowTracker::default(),
|
||
)
|
||
.expect("persist structured context before failure");
|
||
let expected_revision = state.plan_revision;
|
||
let expected_explanation = state.plan_explanation.clone();
|
||
let expected_plan = state.plan.clone();
|
||
let expected_steps = state.plan_steps.clone();
|
||
let expected_active_index = state.active_plan_step_index;
|
||
|
||
let failed = if budget_exhausted {
|
||
fail_game_creator_agent_runtime_budget_at(&root, state, "loop-budget-exhausted:test")
|
||
} else {
|
||
fail_game_creator_agent_runtime_turn_at(&root, state, "structured failure test")
|
||
}
|
||
.expect("persist structured terminal runtime");
|
||
assert_eq!(failed.plan_revision, expected_revision);
|
||
assert_eq!(failed.plan_explanation, expected_explanation);
|
||
assert_eq!(failed.plan, expected_plan);
|
||
assert_eq!(failed.plan_steps, expected_steps);
|
||
assert_eq!(failed.active_plan_step_index, expected_active_index);
|
||
assert_eq!(
|
||
failed.phase,
|
||
if budget_exhausted {
|
||
"budget-exhausted"
|
||
} else {
|
||
"failed"
|
||
}
|
||
);
|
||
let context = read_game_creator_agent_runtime_context_bundle(&root, &failed)
|
||
.expect("read terminal structured context")
|
||
.expect("terminal structured context exists");
|
||
assert_eq!(context.plan_revision, expected_revision);
|
||
assert_eq!(context.plan_steps, expected_steps);
|
||
assert_eq!(context.active_plan_step_index, expected_active_index);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn structured_plan_state_write_failure_stops_before_context_and_audit() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "结构化计划状态写失败项目")
|
||
.expect("project init");
|
||
let (request_sender, request_receiver) = mpsc::channel();
|
||
let (release_sender, release_receiver) = mpsc::channel();
|
||
let response = serde_json::json!({
|
||
"thinkingSummary": "准备提交结构化计划",
|
||
"planUpdate": {
|
||
"explanation": "状态写入失败时不得推进其它投影",
|
||
"steps": [
|
||
{ "step": "完成检查", "status": "completed" }
|
||
]
|
||
},
|
||
"plan": [],
|
||
"actions": [],
|
||
"response": "该回复不得落盘"
|
||
})
|
||
.to_string();
|
||
let base_url = spawn_releasable_mock_llm_server_responses_with_capture(
|
||
vec![response],
|
||
request_sender,
|
||
release_receiver,
|
||
);
|
||
let _config_guard = write_test_local_config(format!(
|
||
r#"{{
|
||
"agentMode": "provider",
|
||
"agentLlm": {{
|
||
"design-director": {{
|
||
"apiKey": "design-key",
|
||
"baseUrl": {base_url:?},
|
||
"model": "design-runtime-model",
|
||
"apiKind": "openai_responses"
|
||
}}
|
||
}}
|
||
}}"#
|
||
));
|
||
let run_id = "structured-plan-state-write-failure-run";
|
||
start_game_creator_agent_background_task_at(
|
||
&root,
|
||
"design-director",
|
||
"验证结构化计划状态写失败边界",
|
||
run_id,
|
||
)
|
||
.expect("start structured state write failure task");
|
||
request_receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("planning request reached provider");
|
||
|
||
let state_path = root.join(".agent/runtime/agents/design-director.json");
|
||
fs::remove_file(&state_path).expect("remove runtime state before response");
|
||
fs::create_dir(&state_path).expect("replace runtime state with directory");
|
||
release_sender
|
||
.send(())
|
||
.expect("release structured plan response");
|
||
drop(wait_to_acquire_agent_runtime_lock(&root, "design-director"));
|
||
|
||
let context_path =
|
||
game_creator_agent_runtime_context_bundle_path(&root, "design-director", run_id);
|
||
let context: Value = serde_json::from_str(
|
||
&fs::read_to_string(&context_path).expect("read unchanged context bundle"),
|
||
)
|
||
.expect("parse unchanged context bundle");
|
||
assert_eq!(context["planRevision"], 0);
|
||
let records = read_agent_db_records_for_test(&root);
|
||
assert!(!records.iter().any(|record| {
|
||
record["recordType"] == "agent.runtime.plan_update" && record["runId"] == run_id
|
||
}));
|
||
let agent_conversation = read_local_conversation_for_session_at(
|
||
&root,
|
||
Some("design-director"),
|
||
Some("agent-session-design-director"),
|
||
)
|
||
.expect("read state write failure Agent conversation");
|
||
assert!(!agent_conversation.messages.iter().any(|message| {
|
||
message.role == "assistant" && message.content.contains("该回复不得落盘")
|
||
}));
|
||
assert!(agent_conversation.messages.iter().any(|message| {
|
||
message.role == "assistant"
|
||
&& 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 conversation_text = serde_json::to_string(&agent_conversation)
|
||
.expect("serialize state write failure conversation");
|
||
assert!(!conversation_text.contains("absolute-path"));
|
||
assert!(!conversation_text.contains("redacted sensitive context"));
|
||
|
||
fs::remove_dir(&state_path).expect("remove sabotaged runtime state directory");
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn structured_plan_background_runtime_requires_explicit_completion() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "持久计划项目").expect("project init");
|
||
fs::write(root.join("game/notes.txt"), "结构化计划证据").expect("write notes");
|
||
let (sender, receiver) = mpsc::channel();
|
||
let responses = vec![
|
||
serde_json::json!({
|
||
"thinkingSummary": "先读取项目证据",
|
||
"planUpdate": {
|
||
"explanation": "首次拆解",
|
||
"steps": [
|
||
{ "step": "读取项目证据", "status": "in_progress" },
|
||
{ "step": "确认并回复", "status": "pending" }
|
||
]
|
||
},
|
||
"plan": [],
|
||
"actions": [{
|
||
"tool": "file.read",
|
||
"reason": "读取真实证据",
|
||
"input": { "path": "game/notes.txt" }
|
||
}],
|
||
"response": ""
|
||
})
|
||
.to_string(),
|
||
serde_json::json!({
|
||
"thinkingSummary": "证据已读,仍需完成确认",
|
||
"planUpdate": {
|
||
"explanation": "读取已完成",
|
||
"steps": [
|
||
{ "step": "读取项目证据", "status": "completed" },
|
||
{ "step": "确认并回复", "status": "in_progress" }
|
||
]
|
||
},
|
||
"plan": [],
|
||
"actions": [],
|
||
"response": "这条回复必须被未完成计划阻断"
|
||
})
|
||
.to_string(),
|
||
serde_json::json!({
|
||
"thinkingSummary": "全部必要步骤已经完成",
|
||
"planUpdate": {
|
||
"explanation": "确认完成并收束",
|
||
"steps": [
|
||
{ "step": "读取项目证据", "status": "completed" },
|
||
{ "step": "确认并回复", "status": "completed" }
|
||
]
|
||
},
|
||
"plan": [],
|
||
"actions": [],
|
||
"response": "结构化计划完成后才允许返回。"
|
||
})
|
||
.to_string(),
|
||
];
|
||
let base_url = spawn_mock_llm_server_responses_with_capture(responses, Some(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_responses"
|
||
}}
|
||
}}
|
||
}}"#
|
||
));
|
||
|
||
start_game_creator_agent_background_task_at(
|
||
&root,
|
||
"design-director",
|
||
"读取证据并按持久计划收束",
|
||
"structured-plan-loop-run",
|
||
)
|
||
.expect("start structured plan task");
|
||
let requests = (0..3)
|
||
.map(|_| {
|
||
receiver
|
||
.recv_timeout(Duration::from_secs(3))
|
||
.expect("structured plan request")
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert!(requests[1].contains("结构化计划证据"));
|
||
assert!(requests[1].contains("结构化计划:revision=1"));
|
||
assert!(requests[1].contains("[in_progress] 读取项目证据"));
|
||
assert!(requests[2].contains("runtime.plan_update"));
|
||
assert!(requests[2].contains("结构化计划:revision=2"));
|
||
assert!(!requests[2].contains("这条回复必须被未完成计划阻断"));
|
||
|
||
let runtime = wait_for_agent_runtime_idle(&root, "design-director");
|
||
assert_eq!(runtime.status, "idle");
|
||
assert_eq!(runtime.phase, "completed");
|
||
assert_eq!(runtime.plan_revision, 3);
|
||
assert_eq!(runtime.plan_explanation, "确认完成并收束");
|
||
assert_eq!(runtime.active_plan_step_index, None);
|
||
assert!(runtime
|
||
.plan_steps
|
||
.iter()
|
||
.all(|step| step.status == "completed"));
|
||
assert_eq!(
|
||
runtime.last_response.as_deref(),
|
||
Some("结构化计划完成后才允许返回。")
|
||
);
|
||
assert_eq!(
|
||
read_game_creator_agent_runtime_project_revision(&root)
|
||
.expect("read project revision after plan updates")
|
||
.revision,
|
||
0,
|
||
"plan metadata must not advance the project revision"
|
||
);
|
||
let plan_audits = read_agent_db_records_for_test(&root)
|
||
.into_iter()
|
||
.filter(|record| {
|
||
record["recordType"] == "agent.runtime.plan_update"
|
||
&& record["runId"] == "structured-plan-loop-run"
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(plan_audits.len(), 3);
|
||
assert_eq!(
|
||
plan_audits
|
||
.iter()
|
||
.filter_map(|record| record["planRevision"].as_u64())
|
||
.collect::<Vec<_>>(),
|
||
vec![1, 2, 3]
|
||
);
|
||
assert!(plan_audits.iter().all(|record| {
|
||
record["steps"].as_array().is_some_and(|steps| {
|
||
steps
|
||
.iter()
|
||
.filter(|step| step["status"] == "in_progress")
|
||
.count()
|
||
<= 1
|
||
})
|
||
}));
|
||
let plan_audit_json = serde_json::to_string(&plan_audits).expect("serialize plan audits");
|
||
assert!(!plan_audit_json.contains("读取项目证据"));
|
||
assert!(!plan_audit_json.contains("确认并回复"));
|
||
assert!(plan_audits.iter().all(|record| {
|
||
record["steps"].as_array().is_some_and(|steps| {
|
||
steps.iter().all(|step| {
|
||
step["stepSha256"]
|
||
.as_str()
|
||
.is_some_and(|value| value.len() == 64)
|
||
&& step.get("step").is_none()
|
||
})
|
||
})
|
||
}));
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn structured_plan_same_run_stale_running_resume_preserves_v3_snapshot() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "结构化计划 Runner 恢复项目")
|
||
.expect("project init");
|
||
let task = "恢复同一 run 的结构化计划";
|
||
let run_id = "structured-plan-stale-running-resume-run";
|
||
let mut state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
task,
|
||
run_id,
|
||
"agent-background-task",
|
||
"Runner 中断前正在规划",
|
||
vec!["旧计划占位".to_string()],
|
||
)
|
||
.expect("start stale structured runtime");
|
||
apply_agent_runtime_plan_update(
|
||
&mut state,
|
||
&AgentRuntimePlanUpdate {
|
||
explanation: "恢复时必须保留完整计划快照".to_string(),
|
||
steps: vec![
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "读取持久上下文".to_string(),
|
||
status: "completed".to_string(),
|
||
},
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "继续 Runner 恢复".to_string(),
|
||
status: "in_progress".to_string(),
|
||
},
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "完成最终核对".to_string(),
|
||
status: "pending".to_string(),
|
||
},
|
||
],
|
||
},
|
||
)
|
||
.expect("apply pre-restart structured plan");
|
||
state.updated_at = unix_timestamp().saturating_sub(600);
|
||
append_game_creator_agent_runtime_task(&root, &state).expect("append stale running task");
|
||
write_game_creator_agent_runtime_state(&root, &state)
|
||
.expect("persist stale structured runtime");
|
||
persist_game_creator_agent_runtime_context(
|
||
&root,
|
||
&state,
|
||
task,
|
||
&AgentRuntimeToolPlan::default(),
|
||
&[],
|
||
0,
|
||
&AgentRuntimeContextWindowTracker::default(),
|
||
)
|
||
.expect("persist pre-restart v3 context");
|
||
let persisted_before = read_game_creator_agent_runtime_context_bundle(&root, &state)
|
||
.expect("read pre-restart context")
|
||
.expect("pre-restart context exists");
|
||
assert_eq!(persisted_before.plan_revision, 1);
|
||
assert_eq!(persisted_before.plan_steps, state.plan_steps);
|
||
assert_eq!(persisted_before.active_plan_step_index, Some(1));
|
||
|
||
let lock_path = root.join(".agent/runtime/locks/design-director.lock");
|
||
fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("create lock parent");
|
||
fs::write(
|
||
&lock_path,
|
||
serde_json::json!({
|
||
"agentId": "design-director",
|
||
"pid": 0,
|
||
"createdAt": 1,
|
||
})
|
||
.to_string(),
|
||
)
|
||
.expect("write stale Runner lock projection");
|
||
|
||
let (request_sender, request_receiver) = mpsc::channel();
|
||
let (response_sender, response_receiver) = mpsc::channel();
|
||
let base_url =
|
||
spawn_interactive_mock_llm_server_with_capture(1, request_sender, response_receiver);
|
||
let _config_guard = write_test_local_config(format!(
|
||
r#"{{
|
||
"agentLlm": {{
|
||
"design-director": {{
|
||
"apiKey": "design-key",
|
||
"baseUrl": {base_url:?},
|
||
"model": "design-runtime-model",
|
||
"apiKind": "openai_responses"
|
||
}}
|
||
}}
|
||
}}"#
|
||
));
|
||
|
||
let resumed = resume_game_creator_agent_background_tasks_at(&root)
|
||
.expect("resume stale structured running task");
|
||
assert_eq!(resumed.len(), 1);
|
||
assert_eq!(resumed[0].state.task_id, state.task_id);
|
||
assert_eq!(resumed[0].state.session_id, state.session_id);
|
||
assert_eq!(resumed[0].state.run_id, run_id);
|
||
assert_eq!(resumed[0].state.plan_revision, 1);
|
||
assert_eq!(resumed[0].state.plan_explanation, state.plan_explanation);
|
||
assert_eq!(resumed[0].state.plan_steps, state.plan_steps);
|
||
assert_eq!(resumed[0].state.active_plan_step_index, Some(1));
|
||
|
||
let replanning_request = request_receiver
|
||
.recv_timeout(Duration::from_secs(3))
|
||
.expect("same-run resumed planning request");
|
||
assert!(replanning_request.contains("结构化计划:revision=1"));
|
||
assert!(replanning_request.contains("恢复时必须保留完整计划快照"));
|
||
assert!(replanning_request.contains("[completed] 读取持久上下文"));
|
||
assert!(replanning_request.contains("[in_progress] 继续 Runner 恢复"));
|
||
assert!(replanning_request.contains("[pending] 完成最终核对"));
|
||
|
||
let running = read_game_creator_agent_runtime_at(&root, "design-director")
|
||
.expect("read resumed running state")
|
||
.state;
|
||
assert_eq!(running.task_id, state.task_id);
|
||
assert_eq!(running.session_id, state.session_id);
|
||
assert_eq!(running.run_id, run_id);
|
||
assert_eq!(running.plan_revision, 1);
|
||
assert_eq!(running.plan_explanation, state.plan_explanation);
|
||
assert_eq!(running.plan_steps, state.plan_steps);
|
||
assert_eq!(running.active_plan_step_index, Some(1));
|
||
let resumed_context = read_game_creator_agent_runtime_context_bundle(&root, &running)
|
||
.expect("read resumed v3 context")
|
||
.expect("resumed v3 context exists");
|
||
assert_eq!(resumed_context.plan_revision, 1);
|
||
assert_eq!(resumed_context.plan_explanation, running.plan_explanation);
|
||
assert_eq!(resumed_context.plan, running.plan);
|
||
assert_eq!(resumed_context.plan_steps, running.plan_steps);
|
||
assert_eq!(resumed_context.active_plan_step_index, Some(1));
|
||
|
||
response_sender
|
||
.send(
|
||
serde_json::json!({
|
||
"thinkingSummary": "恢复快照可信,可以完成剩余核对",
|
||
"planUpdate": {
|
||
"explanation": "Runner 恢复后完成剩余步骤",
|
||
"steps": [
|
||
{ "step": "读取持久上下文", "status": "completed" },
|
||
{ "step": "继续 Runner 恢复", "status": "completed" },
|
||
{ "step": "完成最终核对", "status": "completed" }
|
||
]
|
||
},
|
||
"plan": [],
|
||
"actions": [],
|
||
"response": "同一 run 已保留结构化计划并完成恢复。"
|
||
})
|
||
.to_string(),
|
||
)
|
||
.expect("release resumed planning response");
|
||
let completed = wait_for_agent_runtime_idle(&root, "design-director");
|
||
assert_eq!(completed.task_id, state.task_id);
|
||
assert_eq!(completed.session_id, state.session_id);
|
||
assert_eq!(completed.run_id, run_id);
|
||
assert_eq!(completed.phase, "completed");
|
||
assert_eq!(completed.plan_revision, 2);
|
||
assert_eq!(completed.active_plan_step_index, None);
|
||
assert!(completed
|
||
.plan_steps
|
||
.iter()
|
||
.all(|step| step.status == "completed"));
|
||
assert_eq!(
|
||
completed.last_response.as_deref(),
|
||
Some("同一 run 已保留结构化计划并完成恢复。")
|
||
);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn structured_plan_context_v3_revision_zero_rejects_each_legacy_snapshot_mismatch() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "revision zero context 项目")
|
||
.expect("project init");
|
||
let task = "验证 revision zero 的 v3 完整快照";
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
task,
|
||
"structured-plan-context-revision-zero-run",
|
||
"agent-background-task",
|
||
"准备 legacy context 快照",
|
||
vec![
|
||
"读取 legacy 项目".to_string(),
|
||
"完成 legacy 核对".to_string(),
|
||
],
|
||
)
|
||
.expect("start revision zero context state");
|
||
assert_eq!(state.plan_revision, 0);
|
||
assert_eq!(state.active_plan_step_index, Some(0));
|
||
let bundle = build_game_creator_agent_runtime_context_bundle(
|
||
&root,
|
||
&state,
|
||
task,
|
||
&AgentRuntimeToolPlan::default(),
|
||
&[],
|
||
0,
|
||
&AgentRuntimeContextWindowTracker::default(),
|
||
)
|
||
.expect("build revision zero v3 context");
|
||
assert_eq!(
|
||
bundle.schema_version,
|
||
AGENT_RUNTIME_CONTEXT_BUNDLE_SCHEMA_VERSION
|
||
);
|
||
assert_eq!(bundle.plan_revision, 0);
|
||
|
||
let mut plan_mismatch = bundle.clone();
|
||
plan_mismatch.plan[0] = "被篡改的 legacy plan".to_string();
|
||
write_game_creator_agent_runtime_context_bundle(&root, &plan_mismatch)
|
||
.expect("write revision zero plan mismatch");
|
||
assert!(
|
||
read_game_creator_agent_runtime_context_bundle(&root, &state)
|
||
.expect_err("revision zero v3 legacy plan mismatch must fail")
|
||
.contains("计划快照与当前状态不匹配")
|
||
);
|
||
|
||
let mut steps_mismatch = bundle.clone();
|
||
steps_mismatch.plan_steps[0].title = "被篡改的 legacy planSteps".to_string();
|
||
write_game_creator_agent_runtime_context_bundle(&root, &steps_mismatch)
|
||
.expect("write revision zero planSteps mismatch");
|
||
assert!(
|
||
read_game_creator_agent_runtime_context_bundle(&root, &state)
|
||
.expect_err("revision zero v3 legacy planSteps mismatch must fail")
|
||
.contains("计划快照与当前状态不匹配")
|
||
);
|
||
|
||
let mut active_index_mismatch = bundle.clone();
|
||
active_index_mismatch.active_plan_step_index = None;
|
||
write_game_creator_agent_runtime_context_bundle(&root, &active_index_mismatch)
|
||
.expect("write revision zero active index mismatch");
|
||
assert!(
|
||
read_game_creator_agent_runtime_context_bundle(&root, &state)
|
||
.expect_err("revision zero v3 active index mismatch must fail")
|
||
.contains("计划快照与当前状态不匹配")
|
||
);
|
||
|
||
let bundle_path = game_creator_agent_runtime_context_bundle_path(
|
||
&root,
|
||
"design-director",
|
||
"structured-plan-context-revision-zero-run",
|
||
);
|
||
let mut legacy_v2 = serde_json::to_value(&bundle).expect("serialize revision zero v3 bundle");
|
||
let legacy_object = legacy_v2.as_object_mut().expect("legacy v2 bundle object");
|
||
legacy_object.insert(
|
||
"schemaVersion".to_string(),
|
||
Value::String("game-creator-runtime-context-bundle.v2".to_string()),
|
||
);
|
||
legacy_object.remove("planRevision");
|
||
legacy_object.remove("planExplanation");
|
||
legacy_object.remove("planSteps");
|
||
legacy_object.remove("activePlanStepIndex");
|
||
fs::write(
|
||
&bundle_path,
|
||
serde_json::to_string_pretty(&legacy_v2).expect("serialize revision zero v2 bundle"),
|
||
)
|
||
.expect("write compatible revision zero v2 bundle");
|
||
let migrated = read_game_creator_agent_runtime_context_bundle(&root, &state)
|
||
.expect("read compatible revision zero v2 bundle")
|
||
.expect("revision zero v2 bundle exists");
|
||
assert_eq!(
|
||
migrated.schema_version,
|
||
AGENT_RUNTIME_CONTEXT_BUNDLE_SCHEMA_VERSION
|
||
);
|
||
assert_eq!(migrated.plan_revision, 0);
|
||
assert_eq!(migrated.plan, state.plan);
|
||
assert_eq!(migrated.plan_steps, state.plan_steps);
|
||
assert_eq!(migrated.active_plan_step_index, Some(0));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_context_and_pending_records_fail_closed() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "旧版恢复失败关闭项目").expect("project init");
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"拒绝恢复缺少 verification gate 的旧记录",
|
||
"legacy-v1-run",
|
||
"agent-background-task",
|
||
"旧版恢复测试",
|
||
vec!["失败关闭".to_string()],
|
||
)
|
||
.expect("start legacy recovery runtime");
|
||
|
||
let context_path =
|
||
game_creator_agent_runtime_context_bundle_path(&root, "design-director", "legacy-v1-run");
|
||
fs::create_dir_all(context_path.parent().expect("context parent"))
|
||
.expect("create context parent");
|
||
fs::write(
|
||
&context_path,
|
||
serde_json::json!({
|
||
"schemaVersion": "game-creator-runtime-context-bundle.v1",
|
||
"agentId": "design-director",
|
||
"runId": "legacy-v1-run"
|
||
})
|
||
.to_string(),
|
||
)
|
||
.expect("write legacy context bundle");
|
||
let context_error = read_game_creator_agent_runtime_context_bundle(&root, &state)
|
||
.expect_err("legacy context must not restore without a gate snapshot");
|
||
assert!(context_error.contains("不支持的 Agent Runtime context bundle 版本"));
|
||
|
||
let pending_path = game_creator_agent_runtime_pending_tool_action_path(
|
||
&root,
|
||
"design-director",
|
||
"legacy-v1-run",
|
||
);
|
||
fs::create_dir_all(pending_path.parent().expect("pending parent"))
|
||
.expect("create pending parent");
|
||
for version in [
|
||
"game-creator-pending-action.v1",
|
||
"game-creator-pending-action.v2",
|
||
"game-creator-pending-action.v3",
|
||
"game-creator-pending-action.v4",
|
||
] {
|
||
fs::write(
|
||
&pending_path,
|
||
serde_json::json!({
|
||
"schemaVersion": version,
|
||
"agentId": "design-director",
|
||
"runId": "legacy-v1-run"
|
||
})
|
||
.to_string(),
|
||
)
|
||
.expect("write legacy pending action");
|
||
let pending_error = read_game_creator_agent_runtime_pending_tool_action(
|
||
&root,
|
||
"design-director",
|
||
"legacy-v1-run",
|
||
)
|
||
.expect_err("legacy pending action must not execute without revision snapshots");
|
||
assert!(pending_error.contains("不支持的 Agent Runtime 待确认动作版本"));
|
||
assert!(pending_error.contains(version));
|
||
}
|
||
let resumed = resume_game_creator_agent_background_tasks_at(&root)
|
||
.expect("legacy pending action should fail only its own run");
|
||
assert!(resumed.iter().any(|runtime| {
|
||
runtime.state.run_id == "legacy-v1-run"
|
||
&& runtime.state.status == "failed"
|
||
&& runtime.state.error.as_deref().is_some_and(|error| {
|
||
error.contains("待确认动作恢复失败") && error.contains("pending-action.v4")
|
||
})
|
||
}));
|
||
|
||
let art_state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"art-director",
|
||
"拒绝恢复旧 context bundle",
|
||
"legacy-v1-context-run",
|
||
"agent-background-task",
|
||
"旧 context 恢复测试",
|
||
vec!["失败关闭".to_string()],
|
||
)
|
||
.expect("start legacy context runtime");
|
||
let art_context_path = game_creator_agent_runtime_context_bundle_path(
|
||
&root,
|
||
"art-director",
|
||
"legacy-v1-context-run",
|
||
);
|
||
fs::create_dir_all(art_context_path.parent().expect("art context parent"))
|
||
.expect("create art context parent");
|
||
fs::write(
|
||
&art_context_path,
|
||
serde_json::json!({
|
||
"schemaVersion": "game-creator-runtime-context-bundle.v1",
|
||
"agentId": "art-director",
|
||
"sessionId": art_state.session_id,
|
||
"runId": "legacy-v1-context-run"
|
||
})
|
||
.to_string(),
|
||
)
|
||
.expect("write art legacy context");
|
||
resume_game_creator_agent_background_tasks_at(&root)
|
||
.expect("legacy context should fail only its own run");
|
||
let mut art_runtime = read_game_creator_agent_runtime_at(&root, "art-director")
|
||
.expect("read art runtime")
|
||
.state;
|
||
for _ in 0..100 {
|
||
if art_runtime.status == "failed" {
|
||
break;
|
||
}
|
||
std::thread::sleep(Duration::from_millis(20));
|
||
art_runtime = read_game_creator_agent_runtime_at(&root, "art-director")
|
||
.expect("poll art runtime")
|
||
.state;
|
||
}
|
||
assert_eq!(art_runtime.status, "failed");
|
||
assert!(art_runtime.error.as_deref().is_some_and(|error| {
|
||
error.contains("context bundle") && error.contains("context-bundle.v1")
|
||
}));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
/// 澄清转述那一轮写出的 bundle 必须能被父 run 唤醒时读回来。
|
||
///
|
||
/// 用户答完澄清后整条 run 改跑在转述任务上,那一轮的 bundle 因此带着转述文本,而 run 的
|
||
/// 持久 task 始终是用户原始请求。validate_agent_runtime_pending_context 早就为 pending 入口
|
||
/// 豁免过同一条文本相等断言,bundle 入口漏了——D11 走过一轮澄清后,策划子 Agent 完成、父
|
||
/// run 认领委派回执时必定在这里失败。豁免只放开转述这一种 task,别的文本照旧 fail closed。
|
||
#[test]
|
||
fn agent_runtime_context_bundle_accepts_only_the_clarification_relay_task_divergence() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "澄清转述上下文项目").expect("project init");
|
||
let task = "贪吃蛇";
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
task,
|
||
"plan-clarification-relay-run",
|
||
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
|
||
"等待澄清回答",
|
||
vec!["回答澄清后续跑".to_string()],
|
||
)
|
||
.expect("start clarification runtime state");
|
||
let context_tracker = AgentRuntimeContextWindowTracker::default();
|
||
let relay_task = format!(
|
||
"{AGENT_RUNTIME_DELEGATE_CLARIFICATION_TASK_PREFIX}delegation-96db734b07f794a576ac5c99;questionCount=1 · questionsSha256=ef5b1201。请回答以下问题。"
|
||
);
|
||
let relay_bundle = build_game_creator_agent_runtime_context_bundle(
|
||
&root,
|
||
&state,
|
||
&relay_task,
|
||
&AgentRuntimeToolPlan::default(),
|
||
&[],
|
||
0,
|
||
&context_tracker,
|
||
)
|
||
.expect("build clarification relay context bundle");
|
||
write_game_creator_agent_runtime_context_bundle(&root, &relay_bundle)
|
||
.expect("write clarification relay bundle");
|
||
let restored = read_game_creator_agent_runtime_context_bundle(&root, &state)
|
||
.expect("clarification relay bundle must restore")
|
||
.expect("clarification relay bundle present");
|
||
assert_eq!(restored.task, relay_task);
|
||
|
||
let mut unrelated_task = relay_bundle;
|
||
unrelated_task.task = "另一个不相干的任务".to_string();
|
||
write_game_creator_agent_runtime_context_bundle(&root, &unrelated_task)
|
||
.expect("write unrelated-task bundle");
|
||
let error = read_game_creator_agent_runtime_context_bundle(&root, &state)
|
||
.expect_err("非转述任务的文本分叉必须照旧 fail closed");
|
||
assert!(error.contains("身份与当前任务不匹配"), "{error}");
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_context_bundle_rejects_task_source_and_loop_identity_mismatches() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "上下文身份项目").expect("project init");
|
||
let task = "验证 context bundle 完整身份绑定";
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
task,
|
||
"design-context-identity-run",
|
||
"agent-background-task",
|
||
"身份绑定测试",
|
||
vec!["验证身份字段".to_string()],
|
||
)
|
||
.expect("start identity runtime state");
|
||
let context_tracker = AgentRuntimeContextWindowTracker::default();
|
||
let valid_bundle = build_game_creator_agent_runtime_context_bundle(
|
||
&root,
|
||
&state,
|
||
task,
|
||
&AgentRuntimeToolPlan::default(),
|
||
&[],
|
||
0,
|
||
&context_tracker,
|
||
)
|
||
.expect("build valid identity context bundle");
|
||
|
||
let mut mismatched_task = valid_bundle.clone();
|
||
mismatched_task.task_id = "code-prototype".to_string();
|
||
write_game_creator_agent_runtime_context_bundle(&root, &mismatched_task)
|
||
.expect("write task-mismatched bundle");
|
||
let error = read_game_creator_agent_runtime_context_bundle(&root, &state)
|
||
.expect_err("taskId mismatch must fail closed");
|
||
assert!(error.contains("身份与当前任务不匹配"));
|
||
|
||
let mut mismatched_source = valid_bundle.clone();
|
||
mismatched_source.source = "agent-ready-task-scheduler".to_string();
|
||
write_game_creator_agent_runtime_context_bundle(&root, &mismatched_source)
|
||
.expect("write source-mismatched bundle");
|
||
let error = read_game_creator_agent_runtime_context_bundle(&root, &state)
|
||
.expect_err("source mismatch must fail closed");
|
||
assert!(error.contains("身份与当前任务不匹配"));
|
||
|
||
write_game_creator_agent_runtime_context_bundle(&root, &valid_bundle)
|
||
.expect("write project-bound bundle");
|
||
let manifest_path = root.join(".agent/manifest.json");
|
||
let mut manifest: Value =
|
||
serde_json::from_str(&fs::read_to_string(&manifest_path).expect("read identity manifest"))
|
||
.expect("parse identity manifest");
|
||
manifest["projectId"] = Value::String("project-2".to_string());
|
||
fs::write(
|
||
&manifest_path,
|
||
serde_json::to_string_pretty(&manifest).expect("serialize changed identity manifest"),
|
||
)
|
||
.expect("write changed identity manifest");
|
||
let error = read_game_creator_agent_runtime_context_bundle(&root, &state)
|
||
.expect_err("projectId mismatch must fail closed");
|
||
assert!(error.contains("身份与当前任务不匹配"));
|
||
manifest["projectId"] = Value::String("project-1".to_string());
|
||
fs::write(
|
||
&manifest_path,
|
||
serde_json::to_string_pretty(&manifest).expect("serialize restored identity manifest"),
|
||
)
|
||
.expect("restore identity manifest");
|
||
|
||
let mut mismatched_loop = valid_bundle;
|
||
mismatched_loop.next_loop_index = 2;
|
||
mismatched_loop.context_window = 1;
|
||
write_game_creator_agent_runtime_context_bundle(&root, &mismatched_loop)
|
||
.expect("write loop-mismatched bundle");
|
||
let error = read_game_creator_agent_runtime_context_bundle(&root, &state)
|
||
.expect_err("loop mismatch must fail closed");
|
||
assert!(error.contains("轮次与当前状态不匹配"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_context_bundle_redacts_paths_and_secrets_and_limits_plan() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "上下文安全项目").expect("project init");
|
||
let mut state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"验证上下文安全持久化",
|
||
"design-context-safety-run",
|
||
"agent-background-task",
|
||
"安全持久化测试",
|
||
vec!["生成安全 context bundle".to_string()],
|
||
)
|
||
.expect("start runtime state");
|
||
let root_display = root.to_string_lossy();
|
||
let secret = ["s", "k-1234567890abcdef"].concat();
|
||
let plan = AgentRuntimeToolPlan {
|
||
thinking_summary: format!("检查 {root_display},凭据为 {secret}"),
|
||
plan_update: None,
|
||
plan: (0..AGENT_RUNTIME_PLAN_STEP_LIMIT + 3)
|
||
.map(|index| format!("步骤 {index} 读取 {root_display}"))
|
||
.collect(),
|
||
actions: Vec::new(),
|
||
response: format!(r#"结果位于 {root_display},{{"credential":"{secret}"}}"#),
|
||
};
|
||
state.plan = plan.plan.clone();
|
||
let observations = vec![AgentRuntimeToolObservation {
|
||
tool: "project.search".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: format!("在 {root_display} 找到结果"),
|
||
detail: Some(format!(r#"{{"credential":"{secret}"}}"#)),
|
||
}];
|
||
let context_tracker = AgentRuntimeContextWindowTracker::default();
|
||
|
||
persist_game_creator_agent_runtime_context(
|
||
&root,
|
||
&state,
|
||
&format!("分析 {root_display}"),
|
||
&plan,
|
||
&observations,
|
||
0,
|
||
&context_tracker,
|
||
)
|
||
.expect("persist sanitized context bundle");
|
||
|
||
let bundle_path = game_creator_agent_runtime_context_bundle_path(
|
||
&root,
|
||
"design-director",
|
||
"design-context-safety-run",
|
||
);
|
||
let content = fs::read_to_string(&bundle_path).expect("read sanitized context bundle");
|
||
let bundle: AgentRuntimeContextBundle =
|
||
serde_json::from_str(&content).expect("parse sanitized context bundle");
|
||
assert!(!content.contains(root_display.as_ref()));
|
||
assert!(!content.contains(&secret));
|
||
assert!(content.contains("$PROJECT_ROOT"));
|
||
assert!(content.contains("[redacted-secret]"));
|
||
assert_eq!(bundle.plan.len(), AGENT_RUNTIME_PLAN_STEP_LIMIT);
|
||
assert!(
|
||
fs::metadata(&bundle_path)
|
||
.expect("context bundle metadata")
|
||
.len()
|
||
<= AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES as u64
|
||
);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_context_bundle_preserves_latest_bounded_content_diff() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "内容差异上下文项目").expect("project init");
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"code-prototype",
|
||
"审查较大的多文件内容差异",
|
||
"code-content-diff-context-run",
|
||
"agent-background-task",
|
||
"持久化内容差异",
|
||
vec!["保留最新内容 diff".to_string()],
|
||
)
|
||
.expect("start runtime state");
|
||
let large_hunk = format!(
|
||
"checkpointId: checkpoint-large\ncontentFileCount: 2\ncontentTruncated: false\ndiff --git a/game/a.js b/game/a.js\n{}",
|
||
"x".repeat(10_000)
|
||
);
|
||
let mut observations = vec![
|
||
AgentRuntimeToolObservation {
|
||
tool: "project.diff".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "已对比 checkpoint checkpoint-large 的内容".to_string(),
|
||
detail: Some(large_hunk),
|
||
},
|
||
AgentRuntimeToolObservation {
|
||
tool: "git.inspect".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "已审阅 Git 工作树".to_string(),
|
||
detail: Some(format!(
|
||
"gitContentFileCount: 2\ngitContentTruncated: false\ndiff --git a/game/a.js b/game/a.js\n{}",
|
||
"g".repeat(5_000)
|
||
)),
|
||
},
|
||
];
|
||
observations.extend((0..20).map(|index| AgentRuntimeToolObservation {
|
||
tool: "file.read".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: format!("读取后续文件 {index}"),
|
||
detail: Some(format!("game/file-{index}.txt")),
|
||
}));
|
||
|
||
let retained_observations =
|
||
sanitize_game_creator_agent_runtime_context_observations_for_storage(&root, &observations);
|
||
let retained = retained_observations
|
||
.iter()
|
||
.find(|observation| observation.tool == "project.diff")
|
||
.and_then(|observation| observation.detail.as_deref())
|
||
.expect("latest content diff retained during compaction");
|
||
assert!(retained.chars().count() > 10_000);
|
||
assert!(retained_observations.iter().any(|observation| {
|
||
observation.tool == "git.inspect"
|
||
&& observation
|
||
.detail
|
||
.as_deref()
|
||
.is_some_and(|detail| detail.contains("gitContentFileCount: 2"))
|
||
}));
|
||
|
||
persist_game_creator_agent_runtime_context(
|
||
&root,
|
||
&state,
|
||
&state.current_task,
|
||
&AgentRuntimeToolPlan::default(),
|
||
&observations,
|
||
0,
|
||
&AgentRuntimeContextWindowTracker::default(),
|
||
)
|
||
.expect("persist content diff context bundle");
|
||
let bundle = read_game_creator_agent_runtime_context_bundle(&root, &state)
|
||
.expect("read content diff context bundle")
|
||
.expect("content diff context bundle exists");
|
||
let persisted = bundle
|
||
.observations
|
||
.iter()
|
||
.find(|observation| observation.tool == "project.diff")
|
||
.and_then(|observation| observation.detail.as_deref())
|
||
.expect("content diff survives context persistence");
|
||
assert!(persisted.chars().count() > 10_000);
|
||
assert!(persisted.contains("contentFileCount: 2"));
|
||
assert!(bundle.observations.iter().any(|observation| {
|
||
observation.tool == "git.inspect"
|
||
&& observation
|
||
.detail
|
||
.as_deref()
|
||
.is_some_and(|detail| detail.contains("gitContentFileCount: 2"))
|
||
}));
|
||
assert!(
|
||
fs::metadata(game_creator_agent_runtime_context_bundle_path(
|
||
&root,
|
||
"code-prototype",
|
||
"code-content-diff-context-run",
|
||
))
|
||
.expect("content diff bundle metadata")
|
||
.len()
|
||
<= AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES as u64
|
||
);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_context_bundle_keeps_completed_milestones_until_sidecar_compaction() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "里程碑上下文项目").expect("project init");
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"code-prototype",
|
||
"保留完整里程碑直到 token-aware 压缩",
|
||
"milestone-context-run",
|
||
"agent-background-task",
|
||
"持久化完整观察",
|
||
vec!["保留规范动作事实".to_string()],
|
||
)
|
||
.expect("start milestone context runtime");
|
||
let mut observations = vec![
|
||
AgentRuntimeToolObservation {
|
||
tool: "agent.spawn_isolated".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "已创建 3 个动态隔离 Agent,等待 all join".to_string(),
|
||
detail: Some("instanceCount=3 · joinMode=all".to_string()),
|
||
},
|
||
AgentRuntimeToolObservation {
|
||
tool: "project.patchset".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "project.patchset 已原子应用 2 项变更".to_string(),
|
||
detail: Some("checkpointId=checkpoint-milestone".to_string()),
|
||
},
|
||
AgentRuntimeToolObservation {
|
||
tool: "agent.run_status".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "已读取 16 个 Agent 状态,并取得 1 个 ready all-join".to_string(),
|
||
detail: Some("readyIsolatedJoins: reviewer 已完成独立审查".to_string()),
|
||
},
|
||
AgentRuntimeToolObservation {
|
||
tool: "agent.action_history".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "已读取当前 Agent 的 1 条终态动作".to_string(),
|
||
detail: Some(
|
||
"{\"actionId\":\"action-111111111111111111111111\",\"tool\":\"project.patchset\",\"status\":\"ok\"}"
|
||
.to_string(),
|
||
),
|
||
},
|
||
];
|
||
observations.extend((0..20).map(|index| AgentRuntimeToolObservation {
|
||
tool: "file.read".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: format!("读取后续文件 {index}"),
|
||
detail: Some(format!("game/file-{index}.txt")),
|
||
}));
|
||
|
||
let bundle = build_game_creator_agent_runtime_context_bundle(
|
||
&root,
|
||
&state,
|
||
&state.current_task,
|
||
&AgentRuntimeToolPlan::default(),
|
||
&observations,
|
||
0,
|
||
&AgentRuntimeContextWindowTracker::default(),
|
||
)
|
||
.expect("build full milestone context bundle");
|
||
assert_eq!(bundle.observations.len(), observations.len());
|
||
assert!(!bundle.observations.iter().any(|observation| {
|
||
matches!(
|
||
observation.tool.as_str(),
|
||
"runtime.context" | "runtime.milestones"
|
||
)
|
||
}));
|
||
assert!(bundle.observations.iter().any(|observation| {
|
||
observation.tool == "agent.action_history"
|
||
&& observation
|
||
.detail
|
||
.as_deref()
|
||
.is_some_and(|detail| detail.contains("action-111111111111111111111111"))
|
||
}));
|
||
assert!(bundle.observations.iter().any(|observation| {
|
||
observation.tool == "agent.run_status"
|
||
&& observation
|
||
.detail
|
||
.as_deref()
|
||
.is_some_and(|detail| detail.contains("reviewer 已完成独立审查"))
|
||
}));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_context_bundle_size_limit_includes_trailing_newline() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "上下文大小项目").expect("project init");
|
||
let mut state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"验证上下文大小边界",
|
||
"design-context-size-run",
|
||
"agent-background-task",
|
||
"大小边界测试",
|
||
vec!["生成边界 context bundle".to_string()],
|
||
)
|
||
.expect("start runtime state");
|
||
let plan = AgentRuntimeToolPlan {
|
||
thinking_summary: "上下文大小边界".to_string(),
|
||
plan_update: None,
|
||
plan: (0..AGENT_RUNTIME_PLAN_STEP_LIMIT)
|
||
.map(|_| "p".repeat(180))
|
||
.collect(),
|
||
actions: Vec::new(),
|
||
response: String::new(),
|
||
};
|
||
state.plan = plan.plan.clone();
|
||
let context_tracker = AgentRuntimeContextWindowTracker::default();
|
||
let build_candidate = |content_diff_chars: usize| {
|
||
let mut observations = (0..186)
|
||
.map(|_| AgentRuntimeToolObservation {
|
||
tool: "project.search".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "s".repeat(320),
|
||
detail: Some("界".repeat(1_600)),
|
||
})
|
||
.collect::<Vec<_>>();
|
||
observations.push(AgentRuntimeToolObservation {
|
||
tool: "project.diff".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "已对比 checkpoint boundary 的内容".to_string(),
|
||
detail: Some(format!(
|
||
"checkpointId: boundary\ncontentFileCount: 2\ncontentTruncated: false\n{}",
|
||
"界".repeat(content_diff_chars)
|
||
)),
|
||
});
|
||
build_game_creator_agent_runtime_context_bundle(
|
||
&root,
|
||
&state,
|
||
&"t".repeat(AGENT_RUNTIME_TASK_MAX_CHARS),
|
||
&plan,
|
||
&observations,
|
||
0,
|
||
&context_tracker,
|
||
)
|
||
.expect("build candidate context bundle")
|
||
};
|
||
let mut boundary_bundle = None;
|
||
let mut low = 0_usize;
|
||
let mut high = 24_256_usize;
|
||
while low <= high {
|
||
let midpoint = low + (high - low) / 2;
|
||
let bundle = build_candidate(midpoint);
|
||
let serialized_len = serde_json::to_string_pretty(&bundle)
|
||
.expect("serialize candidate context bundle")
|
||
.len();
|
||
if serialized_len <= AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES {
|
||
boundary_bundle = Some((bundle, serialized_len));
|
||
low = midpoint.saturating_add(1);
|
||
} else if midpoint == 0 {
|
||
break;
|
||
} else {
|
||
high = midpoint - 1;
|
||
}
|
||
}
|
||
let (mut bundle, serialized_len) =
|
||
boundary_bundle.expect("find context bundle just below byte limit");
|
||
let remaining = AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES - serialized_len;
|
||
assert!(remaining <= 1_200, "remaining context bytes: {remaining}");
|
||
bundle.fallback_response = "x".repeat(remaining);
|
||
assert_eq!(
|
||
serde_json::to_string_pretty(&bundle)
|
||
.expect("serialize boundary context bundle")
|
||
.len(),
|
||
AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES
|
||
);
|
||
|
||
let error = write_game_creator_agent_runtime_context_bundle(&root, &bundle)
|
||
.expect_err("trailing newline must count toward context bundle size limit");
|
||
assert!(error.contains("超过") && error.contains("字节上限"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn agent_runtime_context_bundle_rejects_symlinked_parent_directory() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let root = unique_project_path();
|
||
let outside = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "上下文符号链接项目").expect("project init");
|
||
fs::create_dir_all(&outside).expect("create outside context directory");
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"验证上下文符号链接边界",
|
||
"design-context-symlink-run",
|
||
"agent-background-task",
|
||
"符号链接边界测试",
|
||
vec!["拒绝符号链接父目录".to_string()],
|
||
)
|
||
.expect("start symlink runtime state");
|
||
let tracker = AgentRuntimeContextWindowTracker::default();
|
||
let bundle = build_game_creator_agent_runtime_context_bundle(
|
||
&root,
|
||
&state,
|
||
&state.current_task,
|
||
&AgentRuntimeToolPlan::default(),
|
||
&[],
|
||
0,
|
||
&tracker,
|
||
)
|
||
.expect("build symlink context bundle");
|
||
let context_root = root.join(".agent/runtime/context-bundles");
|
||
fs::create_dir_all(&context_root).expect("create context root");
|
||
symlink(&outside, context_root.join("design-director"))
|
||
.expect("symlink context agent directory");
|
||
|
||
let error = write_game_creator_agent_runtime_context_bundle(&root, &bundle)
|
||
.expect_err("symlinked context parent must fail closed");
|
||
assert!(error.contains("符号链接"));
|
||
assert!(fs::read_dir(&outside)
|
||
.expect("read outside context directory")
|
||
.next()
|
||
.is_none());
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
fs::remove_dir_all(outside).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_context_bundle_read_is_bounded_by_actual_file_bytes() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "上下文读取上限项目").expect("project init");
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"验证上下文读取上限",
|
||
"design-context-read-limit-run",
|
||
"agent-background-task",
|
||
"读取上限测试",
|
||
vec!["有界读取 context bundle".to_string()],
|
||
)
|
||
.expect("start bounded-read runtime state");
|
||
let bundle_path = game_creator_agent_runtime_context_bundle_path(
|
||
&root,
|
||
"design-director",
|
||
"design-context-read-limit-run",
|
||
);
|
||
fs::create_dir_all(bundle_path.parent().expect("bundle parent"))
|
||
.expect("create bounded-read bundle parent");
|
||
fs::write(
|
||
&bundle_path,
|
||
vec![b'x'; AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES + 1],
|
||
)
|
||
.expect("write oversized context bundle");
|
||
|
||
let error = read_game_creator_agent_runtime_context_bundle(&root, &state)
|
||
.expect_err("oversized context bundle must fail before parsing");
|
||
assert!(error.contains("超过") && error.contains("字节上限"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn repository_context_v1_pending_fingerprint_blocks_project_mutation() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "旧仓库指令快照迁移").expect("project init");
|
||
fs::write(root.join("AGENTS.md"), "root rules\n").expect("write root rules");
|
||
fs::write(root.join("game/AGENTS.md"), "game rules\n").expect("write scoped rules");
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"code-prototype",
|
||
"验证旧 repository context 不会直接写入",
|
||
"repository-context-v1-pending-run",
|
||
"agent-background-task",
|
||
"准备旧指令快照动作",
|
||
vec!["重新确认 scoped AGENTS 指令".to_string()],
|
||
)
|
||
.expect("start runtime");
|
||
let mut pending = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
AgentRuntimeToolAction {
|
||
tool: "file.write".to_string(),
|
||
reason: Some("基于旧指令快照写文件".to_string()),
|
||
input: serde_json::json!({
|
||
"path": "game/legacy-context-write.txt",
|
||
"content": "must not land\n"
|
||
}),
|
||
},
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED,
|
||
None,
|
||
);
|
||
let mut legacy_context = build_repository_startup_context_at(&root).expect("current context");
|
||
legacy_context.schema_version = "repository-startup-context-v1".to_string();
|
||
for document in &mut legacy_context.documents {
|
||
document.scope.clear();
|
||
}
|
||
pending.planned_repository_context_fingerprint =
|
||
repository_startup_context_fingerprint(&legacy_context);
|
||
|
||
let observation = pending_repository_context_drift_observation(&root, &pending)
|
||
.expect("evaluate repository drift")
|
||
.expect("legacy fingerprint must drift");
|
||
assert_eq!(observation.status, "blocked");
|
||
assert!(observation.summary.contains("旧动作未执行"));
|
||
assert!(observation
|
||
.detail
|
||
.as_deref()
|
||
.is_some_and(|detail| detail.contains("repositoryContextDrift=true")));
|
||
assert!(!root.join("game/legacy-context-write.txt").exists());
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn repository_context_v2_pending_fingerprint_blocks_project_mutation() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "旧 Skill catalog 快照迁移")
|
||
.expect("project init");
|
||
fs::create_dir_all(root.join(".codex/skills/release-capsule")).expect("create skill dir");
|
||
fs::write(
|
||
root.join(".codex/skills/release-capsule/SKILL.md"),
|
||
"---\nname: release-capsule\ndescription: Use for release capsule tasks.\n---\nSKILL_V3_BODY\n",
|
||
)
|
||
.expect("write skill");
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"code-prototype",
|
||
"验证 v2 repository context 不会绕过 Skill 加载直接写入",
|
||
"repository-context-v2-pending-run",
|
||
"agent-background-task",
|
||
"准备旧 Skill catalog 快照动作",
|
||
vec!["重新确认项目 Skill".to_string()],
|
||
)
|
||
.expect("start runtime");
|
||
let mut pending = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
AgentRuntimeToolAction {
|
||
tool: "file.write".to_string(),
|
||
reason: Some("基于 v2 快照写文件".to_string()),
|
||
input: serde_json::json!({
|
||
"path": "game/legacy-skill-context-write.txt",
|
||
"content": "must not land\n"
|
||
}),
|
||
},
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED,
|
||
None,
|
||
);
|
||
let mut legacy_context = build_repository_startup_context_at(&root).expect("current context");
|
||
legacy_context.schema_version = "repository-startup-context-v2".to_string();
|
||
legacy_context.skills.clear();
|
||
legacy_context
|
||
.source_paths
|
||
.retain(|path| !path.ends_with("/SKILL.md"));
|
||
pending.planned_repository_context_fingerprint =
|
||
repository_startup_context_fingerprint(&legacy_context);
|
||
|
||
let observation = pending_repository_context_drift_observation(&root, &pending)
|
||
.expect("evaluate repository drift")
|
||
.expect("v2 fingerprint must drift");
|
||
assert_eq!(observation.status, "blocked");
|
||
assert!(observation.summary.contains("旧动作未执行"));
|
||
assert!(observation
|
||
.detail
|
||
.as_deref()
|
||
.is_some_and(|detail| detail.contains("repositoryContextDrift=true")));
|
||
assert!(!root.join("game/legacy-skill-context-write.txt").exists());
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn runtime_v11_closure_repository_context_drift_replans_before_auto_mutations() {
|
||
const DRIFT_COMMAND: &str = r#"node -e "require('fs').writeFileSync('AGENTS.md','drifted rules\n');process.stdout.write('DRIFTED')""#;
|
||
|
||
for tool in [
|
||
"file.write",
|
||
"file.patch",
|
||
"file.delete",
|
||
"project.patchset",
|
||
"project.restore",
|
||
] {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "启动上下文漂移门禁").expect("project init");
|
||
fs::write(root.join("AGENTS.md"), "baseline rules\n").expect("write baseline rules");
|
||
fs::write(
|
||
root.join("package.json"),
|
||
serde_json::to_string_pretty(&serde_json::json!({
|
||
"name": "repository-context-drift-fixture",
|
||
"private": true,
|
||
"scripts": { "check:drift": DRIFT_COMMAND }
|
||
}))
|
||
.expect("serialize drift package json"),
|
||
)
|
||
.expect("write drift package json");
|
||
|
||
let target = root.join(format!("game/{}-target.txt", tool.replace('.', "-")));
|
||
let action_input = match tool {
|
||
"file.write" => serde_json::json!({
|
||
"path": "game/file-write-target.txt",
|
||
"content": "stale write must not land\n"
|
||
}),
|
||
"file.patch" => {
|
||
fs::write(&target, "mode = draft\n").expect("write patch target");
|
||
serde_json::json!({
|
||
"path": "game/file-patch-target.txt",
|
||
"oldText": "draft",
|
||
"newText": "ready",
|
||
"expectedReplacements": 1
|
||
})
|
||
}
|
||
"file.delete" => {
|
||
fs::write(&target, "delete target must survive\n").expect("write delete target");
|
||
serde_json::json!({ "path": "game/file-delete-target.txt" })
|
||
}
|
||
"project.patchset" => {
|
||
let content = "mode = draft\n";
|
||
fs::write(&target, content).expect("write patchset target");
|
||
serde_json::json!({
|
||
"changes": [{
|
||
"operation": "update",
|
||
"path": "game/project-patchset-target.txt",
|
||
"expectedSha256": format!("{:x}", Sha256::digest(content.as_bytes())),
|
||
"oldText": "draft",
|
||
"newText": "ready",
|
||
"expectedReplacements": 1
|
||
}]
|
||
})
|
||
}
|
||
"project.restore" => {
|
||
fs::write(&target, "checkpoint version\n").expect("write checkpoint target");
|
||
let checkpoint =
|
||
create_local_project_checkpoint_at(&root).expect("create restore checkpoint");
|
||
fs::write(&target, "current version\n").expect("write current target");
|
||
serde_json::json!({ "checkpointId": checkpoint.checkpoint_id })
|
||
}
|
||
_ => unreachable!(),
|
||
};
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow automatic runtime actions");
|
||
|
||
let action_plan = serde_json::json!({
|
||
"thinkingSummary": "先读取验证脚本,再执行会改变规范文件的验证,最后尝试旧修改动作",
|
||
"plan": ["读取 package.json", "运行验证脚本", "执行项目修改"],
|
||
"actions": [
|
||
{
|
||
"tool": "file.read",
|
||
"reason": "读取原始 script 定义",
|
||
"input": { "path": "package.json", "startLine": 1, "maxLines": 40 }
|
||
},
|
||
{
|
||
"tool": "project.verify",
|
||
"reason": "执行固定验证脚本",
|
||
"input": {
|
||
"script": "check:drift",
|
||
"expectedCommand": DRIFT_COMMAND,
|
||
"timeoutSeconds": 15
|
||
}
|
||
},
|
||
{
|
||
"tool": tool,
|
||
"reason": "这条动作基于 planning 时的旧仓库规范",
|
||
"input": action_input
|
||
}
|
||
],
|
||
"response": ""
|
||
})
|
||
.to_string();
|
||
let final_response = format!("{tool} 已因启动上下文漂移而重新规划。");
|
||
let (sender, receiver) = mpsc::channel();
|
||
let base_url = spawn_mock_llm_server_responses_with_capture(
|
||
vec![
|
||
action_plan,
|
||
final_tool_plan_response(final_response.clone()),
|
||
],
|
||
Some(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_responses"
|
||
}}
|
||
}}
|
||
}}"#
|
||
));
|
||
let run_id = format!("runtime-v11-context-drift-{}", tool.replace('.', "-"));
|
||
start_game_creator_agent_background_task_at(
|
||
&root,
|
||
"design-director",
|
||
"验证 planning 后仓库上下文漂移门禁",
|
||
&run_id,
|
||
)
|
||
.expect("start drift runtime task");
|
||
|
||
receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("initial planning request");
|
||
let replanning_request = receiver
|
||
.recv_timeout(Duration::from_secs(10))
|
||
.expect("same-run replanning request after drift");
|
||
assert!(replanning_request.contains(&run_id));
|
||
assert!(replanning_request.contains("repositoryContextDrift=true"));
|
||
assert!(replanning_request.contains("旧动作未执行"));
|
||
|
||
let runtime = wait_for_agent_runtime_idle(&root, "design-director");
|
||
assert_eq!(runtime.run_id, run_id);
|
||
assert_eq!(
|
||
runtime.last_response.as_deref(),
|
||
Some(final_response.as_str())
|
||
);
|
||
assert!(runtime.recent_tool_calls.iter().any(|call| {
|
||
call.tool == tool && call.status == "blocked" && call.summary.contains("旧动作未执行")
|
||
}));
|
||
assert_eq!(
|
||
read_game_creator_agent_runtime_project_revision(&root)
|
||
.expect("read unchanged revision")
|
||
.revision,
|
||
0
|
||
);
|
||
match tool {
|
||
"file.write" => assert!(!target.exists()),
|
||
"file.patch" => assert_eq!(
|
||
fs::read_to_string(&target).expect("read unpatched target"),
|
||
"mode = draft\n"
|
||
),
|
||
"file.delete" => assert_eq!(
|
||
fs::read_to_string(&target).expect("read undeleted target"),
|
||
"delete target must survive\n"
|
||
),
|
||
"project.patchset" => assert_eq!(
|
||
fs::read_to_string(&target).expect("read unapplied patchset target"),
|
||
"mode = draft\n"
|
||
),
|
||
"project.restore" => assert_eq!(
|
||
fs::read_to_string(&target).expect("read unrestored target"),
|
||
"current version\n"
|
||
),
|
||
_ => unreachable!(),
|
||
}
|
||
assert_eq!(
|
||
fs::read_to_string(root.join("AGENTS.md")).expect("read drifted rules"),
|
||
"drifted rules\n"
|
||
);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_v11_closure_parent_terminals_cancel_children_and_suppress_join_once() {
|
||
use platform_agent::game_creation::{
|
||
GameCreationIsolatedAgentChildResult, GameCreationIsolatedAgentChildSpec,
|
||
GameCreationIsolatedAgentJoinMode, GameCreationIsolatedAgentResultStatus,
|
||
GameCreationIsolatedAgentSpawnRequest,
|
||
};
|
||
|
||
for terminal in ["failed", "budget-exhausted", "cancelled"] {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "父终态取消隔离子任务")
|
||
.expect("project init");
|
||
let run_id = format!("runtime-v11-parent-{terminal}");
|
||
let mut state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"父任务等待动态隔离子任务",
|
||
&run_id,
|
||
"agent-background-task",
|
||
"等待动态隔离子任务",
|
||
vec!["等待 all-join".to_string()],
|
||
)
|
||
.expect("start parent state");
|
||
let request = GameCreationIsolatedAgentSpawnRequest {
|
||
children: vec![
|
||
GameCreationIsolatedAgentChildSpec {
|
||
template_agent_id: "code-prototype".to_string(),
|
||
task: "实现 feature-a".to_string(),
|
||
acceptance_criteria: vec!["产物存在".to_string()],
|
||
expected_artifacts: vec!["game/feature-a/output.txt".to_string()],
|
||
write_scopes: vec!["game/feature-a/**".to_string()],
|
||
},
|
||
GameCreationIsolatedAgentChildSpec {
|
||
template_agent_id: "code-prototype".to_string(),
|
||
task: "实现 feature-b".to_string(),
|
||
acceptance_criteria: vec!["产物存在".to_string()],
|
||
expected_artifacts: vec!["game/feature-b/output.txt".to_string()],
|
||
write_scopes: vec!["game/feature-b/**".to_string()],
|
||
},
|
||
],
|
||
join_mode: GameCreationIsolatedAgentJoinMode::All,
|
||
};
|
||
let group = create_or_read_isolated_group_at(
|
||
&root,
|
||
"design-director",
|
||
&run_id,
|
||
&state.session_id,
|
||
&format!("runtime-v11-terminal-action-{terminal}"),
|
||
&request,
|
||
)
|
||
.expect("create isolated group");
|
||
|
||
state = match terminal {
|
||
"failed" => fail_game_creator_agent_runtime_turn_at(&root, state, "父任务普通失败")
|
||
.expect("fail parent"),
|
||
"budget-exhausted" => {
|
||
fail_game_creator_agent_runtime_budget_at(&root, state, "父任务预算耗尽")
|
||
.expect("exhaust parent budget")
|
||
}
|
||
"cancelled" => {
|
||
mark_game_creator_agent_runtime_cancelled_at(
|
||
&root,
|
||
&mut state,
|
||
"父任务已取消",
|
||
None,
|
||
)
|
||
.expect("cancel parent");
|
||
state
|
||
}
|
||
_ => unreachable!(),
|
||
};
|
||
assert_eq!(state.phase, terminal);
|
||
for instance_id in &group.instance_ids {
|
||
let instance = resolve_isolated_agent_instance_at(&root, instance_id)
|
||
.expect("resolve child instance");
|
||
let tombstone = root
|
||
.join(".agent/runtime/cancel")
|
||
.join(&instance.instance_id)
|
||
.join(format!("{}.json", instance.run_id));
|
||
let payload: Value = serde_json::from_str(
|
||
&fs::read_to_string(&tombstone).expect("read child cancellation tombstone"),
|
||
)
|
||
.expect("parse child cancellation tombstone");
|
||
assert_eq!(payload["agentId"], instance.instance_id);
|
||
assert_eq!(payload["runId"], instance.run_id);
|
||
}
|
||
|
||
if terminal == "failed" {
|
||
let mut join = None;
|
||
for instance_id in &group.instance_ids {
|
||
let instance = resolve_isolated_agent_instance_at(&root, instance_id)
|
||
.expect("resolve cancelled child");
|
||
join = record_isolated_child_result_at(
|
||
&root,
|
||
&GameCreationIsolatedAgentChildResult {
|
||
delegation_id: instance.delegation_id,
|
||
instance_id: instance.instance_id,
|
||
template_agent_id: instance.template_agent_id,
|
||
run_id: instance.run_id,
|
||
status: GameCreationIsolatedAgentResultStatus::Cancelled,
|
||
summary: "父任务终止后子任务取消".to_string(),
|
||
artifacts: Vec::new(),
|
||
evidence: Vec::new(),
|
||
verified_revision: None,
|
||
error: None,
|
||
},
|
||
)
|
||
.expect("record cancelled child result")
|
||
.or(join);
|
||
}
|
||
let join = join.expect("all cancelled children produce a join");
|
||
dispatch_isolated_agent_join_at(&root, join.clone()).expect("suppress terminal join");
|
||
dispatch_isolated_agent_join_at(&root, join.clone())
|
||
.expect("repeat terminal join suppression");
|
||
for recovered in reconcile_all_isolated_groups_at(&root).expect("reconcile joins") {
|
||
dispatch_isolated_agent_join_at(&root, recovered)
|
||
.expect("recovered terminal join stays suppressed");
|
||
}
|
||
assert_eq!(
|
||
read_isolated_join_delivery_at(&root, &join)
|
||
.expect("read suppressed delivery")
|
||
.expect("suppressed delivery exists")
|
||
.status,
|
||
IsolatedAgentJoinDeliveryStatus::Suppressed
|
||
);
|
||
assert!(read_game_creator_agent_runtime_at(&root, "design-director")
|
||
.expect("read failed parent runtime")
|
||
.recent_tasks
|
||
.iter()
|
||
.all(|task| task.source != AGENT_RUNTIME_ISOLATED_JOIN_SOURCE));
|
||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
|
||
assert_eq!(
|
||
agent_db
|
||
.matches("agent.runtime.agent.isolated_join.suppressed")
|
||
.count(),
|
||
1
|
||
);
|
||
}
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_v11_closure_started_join_cannot_be_reclaimed_by_parent() {
|
||
use platform_agent::game_creation::{
|
||
GameCreationIsolatedAgentArtifact, GameCreationIsolatedAgentChildResult,
|
||
GameCreationIsolatedAgentChildSpec, GameCreationIsolatedAgentEvidence,
|
||
GameCreationIsolatedAgentJoinMode, GameCreationIsolatedAgentResultStatus,
|
||
GameCreationIsolatedAgentSpawnRequest,
|
||
};
|
||
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "启动后 join 不可重认领").expect("project init");
|
||
let parent_state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"等待隔离结果",
|
||
"runtime-v11-started-join-parent",
|
||
"agent-background-task",
|
||
"等待隔离结果",
|
||
vec!["等待 all-join".to_string()],
|
||
)
|
||
.expect("start parent state");
|
||
let parent_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director")
|
||
.expect("acquire parent lane")
|
||
.expect("parent lane available");
|
||
let request = GameCreationIsolatedAgentSpawnRequest {
|
||
children: vec![GameCreationIsolatedAgentChildSpec {
|
||
template_agent_id: "code-prototype".to_string(),
|
||
task: "完成独立功能".to_string(),
|
||
acceptance_criteria: vec!["产物存在".to_string()],
|
||
expected_artifacts: vec!["game/feature/output.txt".to_string()],
|
||
write_scopes: vec!["game/feature/**".to_string()],
|
||
}],
|
||
join_mode: GameCreationIsolatedAgentJoinMode::All,
|
||
};
|
||
let group = create_or_read_isolated_group_at(
|
||
&root,
|
||
"design-director",
|
||
&parent_state.run_id,
|
||
&parent_state.session_id,
|
||
"runtime-v11-started-join-action",
|
||
&request,
|
||
)
|
||
.expect("create isolated group");
|
||
let instance =
|
||
resolve_isolated_agent_instance_at(&root, &group.instance_ids[0]).expect("resolve child");
|
||
let join = record_isolated_child_result_at(
|
||
&root,
|
||
&GameCreationIsolatedAgentChildResult {
|
||
delegation_id: instance.delegation_id,
|
||
instance_id: instance.instance_id,
|
||
template_agent_id: instance.template_agent_id,
|
||
run_id: instance.run_id,
|
||
status: GameCreationIsolatedAgentResultStatus::Completed,
|
||
summary: "独立功能已完成".to_string(),
|
||
artifacts: vec![GameCreationIsolatedAgentArtifact {
|
||
path: "game/feature/output.txt".to_string(),
|
||
sha256: "a".repeat(64),
|
||
}],
|
||
evidence: vec![GameCreationIsolatedAgentEvidence {
|
||
kind: "project.verify".to_string(),
|
||
summary: "验证通过".to_string(),
|
||
path: None,
|
||
sha256: None,
|
||
}],
|
||
verified_revision: Some(1),
|
||
error: None,
|
||
},
|
||
)
|
||
.expect("record child result")
|
||
.expect("join ready");
|
||
write_agent_runtime_task_record_for_test(
|
||
&root,
|
||
&AgentRuntimeTaskRecord {
|
||
goal_id: None,
|
||
goal_revision: 0,
|
||
goal_status: None,
|
||
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
|
||
agent_id: "design-director".to_string(),
|
||
task_id: parent_state.task_id.clone(),
|
||
session_id: parent_state.session_id.clone(),
|
||
run_id: group.join_run_id.clone(),
|
||
source: AGENT_RUNTIME_ISOLATED_JOIN_SOURCE.to_string(),
|
||
run_profile: default_agent_runtime_run_profile(),
|
||
run_profile_binding_fingerprint: String::new(),
|
||
parent_agent_id: None,
|
||
parent_run_id: Some(parent_state.run_id.clone()),
|
||
delegation_id: Some(group.delegation_group_id.clone()),
|
||
task: join.prompt.clone(),
|
||
status: "pending".to_string(),
|
||
phase: "queued".to_string(),
|
||
current_action: "等待执行旧版 join continuation".to_string(),
|
||
terminal_detail: None,
|
||
error: None,
|
||
updated_at: unix_timestamp(),
|
||
},
|
||
);
|
||
let legacy_delivery = write_isolated_join_delivery_at(
|
||
&root,
|
||
&join,
|
||
IsolatedAgentJoinDeliveryStatus::Dispatched,
|
||
Some(&group.join_run_id),
|
||
None,
|
||
)
|
||
.expect("write legacy continuation delivery");
|
||
assert_eq!(
|
||
legacy_delivery.delivery_target,
|
||
IsolatedAgentJoinDeliveryTarget::Continuation
|
||
);
|
||
let join_task = read_game_creator_agent_runtime_at(&root, "design-director")
|
||
.expect("read parent tasks")
|
||
.recent_tasks
|
||
.into_iter()
|
||
.find(|task| task.run_id == group.join_run_id)
|
||
.expect("queued join task");
|
||
append_game_creator_agent_runtime_task_record(
|
||
&root,
|
||
&AgentRuntimeTaskRecord {
|
||
goal_id: None,
|
||
goal_revision: 0,
|
||
goal_status: None,
|
||
status: "completed".to_string(),
|
||
phase: "completed".to_string(),
|
||
current_action: "join continuation 已经调用父 Agent 并完成".to_string(),
|
||
terminal_detail: Some("join continuation 已完成".to_string()),
|
||
updated_at: unix_timestamp(),
|
||
..join_task
|
||
},
|
||
)
|
||
.expect("mark join continuation completed");
|
||
|
||
let claim = observe_agent_runtime_run_status(
|
||
&root,
|
||
"design-director",
|
||
&parent_state.run_id,
|
||
Some("runtime-v11-late-claim-action"),
|
||
&serde_json::json!({ "scope": "all" }),
|
||
);
|
||
assert_eq!(claim.status, "failed");
|
||
assert!(claim.summary.contains("join continuation 已开始"));
|
||
dispatch_isolated_agent_join_at(&root, join.clone()).expect("repeat join reconciliation");
|
||
let delivery = read_isolated_join_delivery_at(&root, &join)
|
||
.expect("read join delivery")
|
||
.expect("join delivery exists");
|
||
assert_eq!(delivery.status, IsolatedAgentJoinDeliveryStatus::Dispatched);
|
||
assert_eq!(
|
||
delivery.delivery_target,
|
||
IsolatedAgentJoinDeliveryTarget::Continuation
|
||
);
|
||
assert_eq!(
|
||
delivery.queued_run_id.as_deref(),
|
||
Some(group.join_run_id.as_str())
|
||
);
|
||
assert!(delivery.claimed_by_action_id.is_none());
|
||
let join_tasks = read_game_creator_agent_runtime_at(&root, "design-director")
|
||
.expect("read stable parent tasks")
|
||
.recent_tasks
|
||
.into_iter()
|
||
.filter(|task| task.source == AGENT_RUNTIME_ISOLATED_JOIN_SOURCE)
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(join_tasks.len(), 1);
|
||
assert_eq!(join_tasks[0].run_id, group.join_run_id);
|
||
assert_eq!(join_tasks[0].status, "completed");
|
||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
|
||
assert!(!agent_db.contains("agent.runtime.agent.isolated_join.claimed_by_parent"));
|
||
|
||
drop(parent_lock);
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn background_task_does_not_execute_when_user_message_cannot_persist() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "后台任务对话一致性测试").expect("project init");
|
||
let session_id = resolve_agent_conversation_session_id_at(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
None,
|
||
true,
|
||
)
|
||
.expect("resolve Supervisor session");
|
||
fs::write(
|
||
root.join(".agent/runtime/test-fail-next-background-conversation"),
|
||
b"fail once\n",
|
||
)
|
||
.expect("arm one-shot background conversation failure");
|
||
let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
)
|
||
.expect("acquire runtime lock")
|
||
.expect("runtime lock available");
|
||
|
||
let result = start_game_creator_agent_background_task_for_session_at(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
Some(&session_id),
|
||
"这条任务必须先持久化对话",
|
||
"conversation-write-failure-run",
|
||
);
|
||
|
||
assert!(result.is_err());
|
||
let task_log = fs::read_to_string(root.join(".agent/runtime/tasks/project-supervisor.jsonl"))
|
||
.expect("read failed queued task log");
|
||
let task = task_log
|
||
.lines()
|
||
.filter_map(|line| serde_json::from_str::<AgentRuntimeTaskRecord>(line).ok())
|
||
.filter(|task| task.run_id == "conversation-write-failure-run")
|
||
.last()
|
||
.expect("failed queued task exists");
|
||
assert_eq!(task.status, "failed");
|
||
assert_eq!(task.phase, "conversation-write-failed");
|
||
assert!(!root
|
||
.join(".agent/runtime/test-fail-next-background-conversation")
|
||
.exists());
|
||
let conversation = read_local_conversation_for_session_at(
|
||
&root,
|
||
Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID),
|
||
Some(&session_id),
|
||
)
|
||
.expect("read conversation after injected user write failure");
|
||
assert!(conversation
|
||
.messages
|
||
.iter()
|
||
.all(|message| message.content != "这条任务必须先持久化对话"));
|
||
|
||
drop(runtime_lock);
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn background_task_closes_when_user_message_identity_conflicts() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "后台任务用户消息身份冲突测试")
|
||
.expect("project init");
|
||
let session_id = resolve_agent_conversation_session_id_at(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
None,
|
||
true,
|
||
)
|
||
.expect("resolve Supervisor session");
|
||
let run_id = "conversation-identity-conflict-run";
|
||
let message_id = agent_runtime_background_task_message_id(
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
&session_id,
|
||
run_id,
|
||
"agent-background-task",
|
||
);
|
||
append_local_conversation_message_for_session_idempotent_at(
|
||
&root,
|
||
Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID),
|
||
Some(&session_id),
|
||
LocalConversationMessage {
|
||
role: "user".to_string(),
|
||
content: "占用同一消息身份的冲突内容".to_string(),
|
||
agent_id: None,
|
||
},
|
||
&message_id,
|
||
)
|
||
.expect("append conflicting user message");
|
||
let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
)
|
||
.expect("acquire runtime lock")
|
||
.expect("runtime lock available");
|
||
|
||
let result = start_game_creator_agent_background_task_for_session_at(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
Some(&session_id),
|
||
"不能借用冲突消息执行",
|
||
run_id,
|
||
);
|
||
|
||
assert!(result.is_err());
|
||
let latest = read_latest_game_creator_agent_runtime_task_by_run_id(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
run_id,
|
||
)
|
||
.expect("read identity-conflicted task")
|
||
.expect("identity-conflicted task exists");
|
||
assert_eq!(latest.status, "failed");
|
||
assert_eq!(latest.phase, "conversation-write-failed");
|
||
assert!(read_next_runnable_game_creator_agent_runtime_task(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
)
|
||
.expect("read runnable task after identity conflict")
|
||
.is_none());
|
||
|
||
drop(runtime_lock);
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn background_task_does_not_execute_when_public_start_status_cannot_persist() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "后台任务启动确认一致性测试")
|
||
.expect("project init");
|
||
let project_conversation_path = root.join(".agent/conversations/project.jsonl");
|
||
if project_conversation_path.is_file() {
|
||
fs::remove_file(&project_conversation_path).expect("remove project conversation fixture");
|
||
}
|
||
fs::create_dir_all(&project_conversation_path)
|
||
.expect("replace project conversation file with directory");
|
||
let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
)
|
||
.expect("acquire runtime lock")
|
||
.expect("runtime lock available");
|
||
|
||
let result = start_game_creator_agent_background_task_at(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
"这条任务必须先公开确认启动",
|
||
"public-start-status-write-failure-run",
|
||
);
|
||
|
||
assert!(result.is_err());
|
||
let task_log = fs::read_to_string(root.join(".agent/runtime/tasks/project-supervisor.jsonl"))
|
||
.expect("read failed queued task log");
|
||
let tasks = task_log
|
||
.lines()
|
||
.filter_map(|line| serde_json::from_str::<AgentRuntimeTaskRecord>(line).ok())
|
||
.filter(|task| task.run_id == "public-start-status-write-failure-run")
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(
|
||
tasks
|
||
.iter()
|
||
.map(|task| (task.status.as_str(), task.phase.as_str()))
|
||
.collect::<Vec<_>>(),
|
||
vec![
|
||
("preparing", "public-status-pending"),
|
||
("failed", "public-status-write-failed"),
|
||
]
|
||
);
|
||
let task = tasks.last().expect("failed queued task exists");
|
||
assert_eq!(task.status, "failed");
|
||
assert_eq!(task.phase, "public-status-write-failed");
|
||
assert!(read_next_runnable_game_creator_agent_runtime_task(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
)
|
||
.expect("read runnable task after public status failure")
|
||
.is_none());
|
||
let conversation =
|
||
read_local_conversation_at(&root, Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID))
|
||
.expect("read Agent conversation after public status failure");
|
||
assert!(conversation.messages.iter().any(|message| {
|
||
message.role == "user" && message.content == "这条任务必须先公开确认启动"
|
||
}));
|
||
assert!(conversation
|
||
.messages
|
||
.iter()
|
||
.all(|message| message.role != "assistant"));
|
||
|
||
drop(runtime_lock);
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn supervisor_start_continues_when_accepted_is_durable_but_audit_write_fails() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "启动确认审计失败恢复测试")
|
||
.expect("project init");
|
||
let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
)
|
||
.expect("acquire runtime lock")
|
||
.expect("runtime lock available");
|
||
let failure_path = root.join(".agent/runtime/test-fail-next-agent-db-record");
|
||
fs::write(
|
||
&failure_path,
|
||
b"conversation.message:runtime-public-status-\n",
|
||
)
|
||
.expect("inject accepted conversation audit failure");
|
||
let run_id = "public-start-status-audit-failure-run";
|
||
|
||
start_game_creator_agent_background_task_at(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
"accepted 已落盘时必须继续入队",
|
||
run_id,
|
||
)
|
||
.expect("durable accepted message remains authoritative");
|
||
|
||
assert!(!failure_path.exists());
|
||
let latest = read_latest_game_creator_agent_runtime_task_by_run_id(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
run_id,
|
||
)
|
||
.expect("read queued task")
|
||
.expect("queued task exists");
|
||
assert_eq!(latest.status, "pending");
|
||
assert_eq!(latest.phase, "queued");
|
||
let project_conversation =
|
||
read_local_conversation_at(&root, None).expect("read accepted project 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
|
||
);
|
||
|
||
drop(runtime_lock);
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn supervisor_start_continues_when_user_message_is_durable_but_audit_write_fails() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "启动用户消息审计失败恢复测试")
|
||
.expect("project init");
|
||
let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
)
|
||
.expect("acquire runtime lock")
|
||
.expect("runtime lock available");
|
||
let failure_path = root.join(".agent/runtime/test-fail-next-agent-db-record");
|
||
fs::write(&failure_path, b"conversation.message:runtime-task-\n")
|
||
.expect("inject user conversation audit failure");
|
||
let run_id = "user-message-audit-failure-run";
|
||
|
||
start_game_creator_agent_background_task_at(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
"用户消息已落盘时必须继续入队",
|
||
run_id,
|
||
)
|
||
.expect("durable user message remains authoritative");
|
||
|
||
assert!(!failure_path.exists());
|
||
let latest = read_latest_game_creator_agent_runtime_task_by_run_id(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
run_id,
|
||
)
|
||
.expect("read queued task")
|
||
.expect("queued task exists");
|
||
assert_eq!(latest.status, "pending");
|
||
assert_eq!(latest.phase, "queued");
|
||
let supervisor_conversation = read_local_conversation_for_session_at(
|
||
&root,
|
||
Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID),
|
||
Some(&latest.session_id),
|
||
)
|
||
.expect("read durable user conversation");
|
||
assert_eq!(
|
||
supervisor_conversation
|
||
.messages
|
||
.iter()
|
||
.filter(|message| message.role == "user"
|
||
&& message.content == "用户消息已落盘时必须继续入队")
|
||
.count(),
|
||
1
|
||
);
|
||
let project_conversation =
|
||
read_local_conversation_at(&root, None).expect("read accepted project conversation");
|
||
assert_eq!(
|
||
project_conversation
|
||
.messages
|
||
.iter()
|
||
.filter(|message| message.content
|
||
== game_creator_agent_runtime_accepted_public_message(
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
))
|
||
.count(),
|
||
1
|
||
);
|
||
|
||
drop(runtime_lock);
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn supervisor_background_task_persists_one_public_start_status_before_execution() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "Supervisor 启动确认测试")
|
||
.expect("project init");
|
||
let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
)
|
||
.expect("acquire runtime lock")
|
||
.expect("runtime lock available");
|
||
let run_id = "supervisor-public-start-status-run";
|
||
|
||
let queued = start_game_creator_agent_background_task_at(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
"先确认启动,再执行长任务",
|
||
run_id,
|
||
)
|
||
.expect("queue Supervisor task");
|
||
let task_log = fs::read_to_string(root.join(".agent/runtime/tasks/project-supervisor.jsonl"))
|
||
.expect("read Supervisor start gate task log");
|
||
let task_states = task_log
|
||
.lines()
|
||
.filter_map(|line| serde_json::from_str::<AgentRuntimeTaskRecord>(line).ok())
|
||
.filter(|task| task.run_id == run_id)
|
||
.map(|task| (task.status, task.phase))
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(
|
||
task_states,
|
||
vec![
|
||
("preparing".to_string(), "public-status-pending".to_string(),),
|
||
("pending".to_string(), "queued".to_string()),
|
||
]
|
||
);
|
||
append_game_creator_agent_runtime_public_status_message_at(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
&queued.state.session_id,
|
||
run_id,
|
||
"accepted",
|
||
&game_creator_agent_runtime_accepted_public_message(
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
),
|
||
)
|
||
.expect("repeat accepted public status idempotently");
|
||
|
||
let project_conversation =
|
||
read_local_conversation_at(&root, None).expect("read public start status");
|
||
let 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!(public_statuses.len(), 1);
|
||
assert_eq!(
|
||
public_statuses[0].content,
|
||
"任务已接收,项目总控 Agent 正在启动处理。"
|
||
);
|
||
let supervisor_conversation = read_local_conversation_for_session_at(
|
||
&root,
|
||
Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID),
|
||
Some(&queued.state.session_id),
|
||
)
|
||
.expect("read Supervisor prompt conversation");
|
||
assert_eq!(supervisor_conversation.messages.len(), 1);
|
||
assert_eq!(supervisor_conversation.messages[0].role, "user");
|
||
assert_eq!(
|
||
supervisor_conversation.messages[0].content,
|
||
"先确认启动,再执行长任务"
|
||
);
|
||
let prompt_context = render_local_conversation_prompt_context_for_session(
|
||
&root,
|
||
Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID),
|
||
Some(&queued.state.session_id),
|
||
)
|
||
.expect("render Supervisor prompt conversation");
|
||
assert!(prompt_context.contains("先确认启动,再执行长任务"));
|
||
assert!(!prompt_context.contains("任务已接收"));
|
||
let runtime_prompt_history = prepare_game_creator_agent_runtime_prompt_history(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
&queued.state.session_id,
|
||
run_id,
|
||
&[],
|
||
1_000,
|
||
)
|
||
.expect("render Runtime prompt history");
|
||
assert!(runtime_prompt_history
|
||
.context
|
||
.contains("先确认启动,再执行长任务"));
|
||
assert!(!runtime_prompt_history.context.contains("任务已接收"));
|
||
|
||
drop(runtime_lock);
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn accepted_supervisor_preparing_task_recovers_to_runnable_queue() {
|
||
let _config_guard = write_test_local_config("{}".to_string());
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "Supervisor 启动确认恢复测试")
|
||
.expect("project init");
|
||
let session_id = resolve_agent_conversation_session_id_at(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
None,
|
||
true,
|
||
)
|
||
.expect("resolve Supervisor session");
|
||
let run_id = "supervisor-public-start-status-recovery-run";
|
||
let preparing = append_unique_game_creator_agent_runtime_public_status_preparing_task(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
&session_id,
|
||
"验证 accepted 后可恢复入队",
|
||
run_id,
|
||
"agent-background-task",
|
||
None,
|
||
None,
|
||
)
|
||
.expect("append preparing task");
|
||
assert_eq!(preparing.status, "preparing");
|
||
let task_path = root.join(".agent/runtime/tasks/project-supervisor.jsonl");
|
||
let task_log_before_preflight = fs::read(&task_path).expect("read preparing task journal");
|
||
assert!(
|
||
!has_recoverable_game_creator_agent_background_tasks_at(&root)
|
||
.expect("preflight task before accepted status")
|
||
);
|
||
assert_eq!(
|
||
fs::read(&task_path).expect("reread preparing task journal"),
|
||
task_log_before_preflight
|
||
);
|
||
assert!(resume_game_creator_agent_background_tasks_at(&root)
|
||
.expect("scan task before accepted status")
|
||
.is_empty());
|
||
|
||
let user_message_id = agent_runtime_background_task_message_id(
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
&session_id,
|
||
run_id,
|
||
"agent-background-task",
|
||
);
|
||
append_local_conversation_message_for_session_idempotent_at(
|
||
&root,
|
||
Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID),
|
||
Some(&session_id),
|
||
LocalConversationMessage {
|
||
role: "user".to_string(),
|
||
content: "验证 accepted 后可恢复入队".to_string(),
|
||
agent_id: None,
|
||
},
|
||
&user_message_id,
|
||
)
|
||
.expect("append persisted user message before accepted crash window");
|
||
let task_log_after_user_message =
|
||
fs::read(&task_path).expect("read user-persisted preparing journal");
|
||
assert!(
|
||
has_recoverable_game_creator_agent_background_tasks_at(&root)
|
||
.expect("preflight user-persisted preparing task")
|
||
);
|
||
assert_eq!(
|
||
fs::read(&task_path).expect("reread user-persisted preparing journal"),
|
||
task_log_after_user_message
|
||
);
|
||
let resumed = resume_game_creator_agent_background_tasks_at(&root)
|
||
.expect("repair accepted status and recover preparing task through Runner scan");
|
||
assert_eq!(resumed.len(), 1);
|
||
assert_eq!(resumed[0].state.run_id, run_id);
|
||
let task_log = fs::read_to_string(root.join(".agent/runtime/tasks/project-supervisor.jsonl"))
|
||
.expect("read recovered task journal");
|
||
let recovered_states = task_log
|
||
.lines()
|
||
.filter_map(|line| serde_json::from_str::<AgentRuntimeTaskRecord>(line).ok())
|
||
.filter(|task| task.run_id == run_id)
|
||
.map(|task| (task.status, task.phase))
|
||
.collect::<Vec<_>>();
|
||
assert!(recovered_states.contains(&("pending".to_string(), "queued".to_string())));
|
||
let project_conversation =
|
||
read_local_conversation_at(&root, None).expect("read repaired accepted public status");
|
||
assert!(project_conversation.messages.iter().any(|message| {
|
||
message.content
|
||
== game_creator_agent_runtime_accepted_public_message(
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
)
|
||
&& message.message_id.as_deref().is_some_and(|message_id| {
|
||
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
|
||
})
|
||
}));
|
||
drop(wait_to_acquire_agent_runtime_lock(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn supervisor_terminal_failure_persists_one_public_status() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "Supervisor 终态失败公开消息测试")
|
||
.expect("project init");
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
"验证终态失败公开消息",
|
||
"supervisor-terminal-public-status-run",
|
||
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
|
||
"准备验证失败收束",
|
||
vec!["验证终态失败公开消息".to_string()],
|
||
)
|
||
.expect("start Supervisor runtime");
|
||
let failed = fail_game_creator_agent_runtime_turn_at(&root, state, "private provider failure")
|
||
.expect("fail Supervisor runtime");
|
||
append_game_creator_agent_runtime_terminal_public_message_at(
|
||
&root,
|
||
&failed,
|
||
"private provider failure",
|
||
)
|
||
.expect("repeat terminal public status idempotently");
|
||
|
||
let project_conversation =
|
||
read_local_conversation_at(&root, None).expect("read public terminal status");
|
||
let 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!(public_statuses.len(), 1);
|
||
assert_eq!(
|
||
public_statuses[0].content,
|
||
"项目总控 Agent 执行失败,请稍后重试"
|
||
);
|
||
assert!(!serde_json::to_string(&project_conversation)
|
||
.expect("serialize public terminal conversation")
|
||
.contains("private provider failure"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn supervisor_terminal_failure_retries_one_shot_public_status_write() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "Supervisor 终态公开消息重试测试")
|
||
.expect("project init");
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
"验证终态公开消息瞬时失败重试",
|
||
"supervisor-terminal-public-status-retry-run",
|
||
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
|
||
"准备验证失败收束",
|
||
vec!["验证终态公开消息瞬时失败重试".to_string()],
|
||
)
|
||
.expect("start Supervisor runtime");
|
||
let failure_path = root.join(".agent/runtime/test-fail-next-conversation-append");
|
||
fs::write(&failure_path, b"assistant\n").expect("arm one-shot public status failure");
|
||
|
||
let failed = fail_game_creator_agent_runtime_turn_at(&root, state, "private provider failure")
|
||
.expect("retry and fail Supervisor runtime");
|
||
|
||
assert_eq!(failed.status, "failed");
|
||
assert!(!failure_path.exists());
|
||
let project_conversation =
|
||
read_local_conversation_at(&root, None).expect("read retried public terminal status");
|
||
assert_eq!(
|
||
project_conversation
|
||
.messages
|
||
.iter()
|
||
.filter(|message| message.content == "项目总控 Agent 执行失败,请稍后重试")
|
||
.count(),
|
||
1
|
||
);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn supervisor_continuation_failure_stays_out_of_project_conversation() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "Supervisor continuation 终态边界测试")
|
||
.expect("project init");
|
||
let session_id = resolve_agent_conversation_session_id_at(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
None,
|
||
true,
|
||
)
|
||
.expect("resolve Supervisor session");
|
||
let parent = append_unique_game_creator_agent_runtime_pending_task(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
&session_id,
|
||
"Supervisor parent task",
|
||
"supervisor-continuation-parent-run",
|
||
"agent-background-task",
|
||
None,
|
||
None,
|
||
)
|
||
.expect("append Supervisor parent task");
|
||
let continuation = append_unique_game_creator_agent_runtime_pending_task(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
&session_id,
|
||
"Supervisor receipt continuation",
|
||
"supervisor-continuation-child-run",
|
||
"agent-delegate-receipt",
|
||
None,
|
||
Some(&AgentRuntimeTaskLink {
|
||
parent_agent_id: None,
|
||
parent_run_id: Some(parent.run_id.clone()),
|
||
delegation_id: Some("supervisor-continuation-delegation".to_string()),
|
||
}),
|
||
)
|
||
.expect("append Supervisor continuation task");
|
||
let state = agent_runtime_state_from_task_record(&continuation);
|
||
|
||
fail_game_creator_agent_runtime_turn_at(&root, state, "private continuation failure")
|
||
.expect("fail Supervisor continuation");
|
||
|
||
let project_conversation =
|
||
read_local_conversation_at(&root, None).expect("read project conversation");
|
||
assert!(project_conversation.messages.iter().all(|message| {
|
||
!message.message_id.as_deref().is_some_and(|message_id| {
|
||
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
|
||
})
|
||
}));
|
||
let supervisor_conversation = read_local_conversation_for_session_at(
|
||
&root,
|
||
Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID),
|
||
Some(&session_id),
|
||
)
|
||
.expect("read Supervisor continuation conversation");
|
||
assert!(supervisor_conversation.messages.iter().all(|message| {
|
||
!message.message_id.as_deref().is_some_and(|message_id| {
|
||
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
|
||
})
|
||
}));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn background_task_recovers_when_assistant_message_cannot_persist() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "后台回复对话一致性测试").expect("project init");
|
||
let (request_sender, request_receiver) = mpsc::channel();
|
||
let (release_sender, release_receiver) = mpsc::channel();
|
||
let base_url = spawn_releasable_mock_llm_server_responses_with_capture(
|
||
vec![final_tool_plan_response("这条后台回复必须先持久化。")],
|
||
request_sender,
|
||
release_receiver,
|
||
);
|
||
let _config_guard = write_test_local_config(format!(
|
||
r#"{{
|
||
"agentLlm": {{
|
||
"design-director": {{
|
||
"apiKey": "design-key",
|
||
"baseUrl": {base_url:?},
|
||
"model": "design-runtime-model",
|
||
"apiKind": "openai_responses"
|
||
}}
|
||
}}
|
||
}}"#
|
||
));
|
||
|
||
start_game_creator_agent_background_task_at(
|
||
&root,
|
||
"design-director",
|
||
"验证后台 assistant 回复落盘失败",
|
||
"assistant-conversation-write-failure-run",
|
||
)
|
||
.expect("start background task");
|
||
request_receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("background planning request in flight");
|
||
|
||
let session_id =
|
||
resolve_agent_conversation_session_id_at(&root, "design-director", None, false)
|
||
.expect("resolve agent session");
|
||
let conversation_failure_path = root.join(".agent/runtime/test-fail-next-conversation-append");
|
||
fs::write(&conversation_failure_path, b"assistant\n")
|
||
.expect("arm one-shot assistant conversation failure");
|
||
release_sender
|
||
.send(())
|
||
.expect("release background planning response");
|
||
|
||
let mut result = read_game_creator_agent_runtime_at(&root, "design-director")
|
||
.expect("read assistant persistence runtime");
|
||
for _ in 0..100 {
|
||
if result.state.phase == "finalizing" {
|
||
break;
|
||
}
|
||
std::thread::sleep(Duration::from_millis(20));
|
||
result = read_game_creator_agent_runtime_at(&root, "design-director")
|
||
.expect("read assistant persistence runtime");
|
||
}
|
||
|
||
assert_eq!(result.state.status, "running");
|
||
assert_eq!(result.state.phase, "finalizing");
|
||
assert!(result.state.error.is_some());
|
||
assert!(!conversation_failure_path.exists());
|
||
assert!(result.recent_tasks.iter().any(|task| {
|
||
task.run_id == "assistant-conversation-write-failure-run"
|
||
&& task.status == "running"
|
||
&& task.phase == "finalizing"
|
||
}));
|
||
assert!(!result.recent_tasks.iter().any(|task| {
|
||
task.run_id == "assistant-conversation-write-failure-run" && task.status == "completed"
|
||
}));
|
||
assert!(result
|
||
.recent_events
|
||
.iter()
|
||
.any(|event| event.event_type == "finalization.pending"));
|
||
assert!(!result
|
||
.recent_events
|
||
.iter()
|
||
.any(|event| event.event_type == "turn.failed"));
|
||
assert!(!result
|
||
.recent_events
|
||
.iter()
|
||
.any(|event| event.event_type == "turn.completed"));
|
||
let conversation =
|
||
read_local_conversation_for_session_at(&root, Some("design-director"), Some(&session_id))
|
||
.expect("read failed assistant conversation");
|
||
assert!(conversation
|
||
.messages
|
||
.iter()
|
||
.all(|message| message.role != "assistant"));
|
||
let mut audit_records = Vec::new();
|
||
for _ in 0..100 {
|
||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
|
||
audit_records = agent_db
|
||
.lines()
|
||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||
.collect::<Vec<_>>();
|
||
if audit_records.iter().any(|record| {
|
||
record.get("recordType").and_then(Value::as_str)
|
||
== Some("agent.runtime.background_task.finalization_pending")
|
||
&& record.get("runId").and_then(Value::as_str)
|
||
== Some("assistant-conversation-write-failure-run")
|
||
}) {
|
||
break;
|
||
}
|
||
std::thread::sleep(Duration::from_millis(20));
|
||
}
|
||
assert!(audit_records.iter().any(|record| {
|
||
record.get("recordType").and_then(Value::as_str)
|
||
== Some("agent.runtime.background_task.finalization_pending")
|
||
&& record.get("runId").and_then(Value::as_str)
|
||
== Some("assistant-conversation-write-failure-run")
|
||
}));
|
||
assert!(!audit_records.iter().any(|record| {
|
||
record.get("recordType").and_then(Value::as_str)
|
||
== Some("agent.runtime.background_task.failed")
|
||
&& record.get("runId").and_then(Value::as_str)
|
||
== Some("assistant-conversation-write-failure-run")
|
||
}));
|
||
assert!(!audit_records.iter().any(|record| {
|
||
record.get("recordType").and_then(Value::as_str)
|
||
== Some("agent.runtime.background_task.completed")
|
||
&& record.get("runId").and_then(Value::as_str)
|
||
== Some("assistant-conversation-write-failure-run")
|
||
}));
|
||
assert!(!audit_records.iter().any(|record| {
|
||
record.get("recordType").and_then(Value::as_str) == Some("agent.runtime.completed")
|
||
&& record.get("runId").and_then(Value::as_str)
|
||
== Some("assistant-conversation-write-failure-run")
|
||
}));
|
||
|
||
let resumed = resume_game_creator_agent_background_tasks_at(&root)
|
||
.expect("resume assistant conversation persistence");
|
||
assert_eq!(resumed.len(), 1);
|
||
assert!(request_receiver
|
||
.recv_timeout(Duration::from_millis(250))
|
||
.is_err());
|
||
let completed = wait_for_agent_runtime_terminal_and_lane_release(
|
||
&root,
|
||
"design-director",
|
||
"assistant-conversation-write-failure-run",
|
||
"idle",
|
||
"completed",
|
||
)
|
||
.state;
|
||
assert_eq!(completed.phase, "completed");
|
||
assert_eq!(completed.run_id, "assistant-conversation-write-failure-run");
|
||
assert_eq!(
|
||
completed.last_response.as_deref(),
|
||
Some("这条后台回复必须先持久化。")
|
||
);
|
||
let conversation =
|
||
read_local_conversation_for_session_at(&root, Some("design-director"), Some(&session_id))
|
||
.expect("read recovered assistant conversation");
|
||
assert_eq!(
|
||
conversation
|
||
.messages
|
||
.iter()
|
||
.filter(|message| {
|
||
message.role == "assistant" && message.content == "这条后台回复必须先持久化。"
|
||
})
|
||
.count(),
|
||
1
|
||
);
|
||
assert!(read_game_creator_agent_runtime_finalization_journal(
|
||
&root,
|
||
"design-director",
|
||
"assistant-conversation-write-failure-run",
|
||
)
|
||
.expect("read recovered assistant finalization journal")
|
||
.is_none());
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn schedule_ready_manifest_tasks_command_requires_auto_policy() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
|
||
let error = schedule_game_creator_agent_ready_tasks(root.to_string_lossy().into_owned(), 0)
|
||
.expect_err("default policy should require confirmation");
|
||
assert!(error.contains("项目权限策略要求用户确认:agent.schedule_ready"));
|
||
let manifest: Value =
|
||
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
|
||
.expect("manifest json");
|
||
assert_task_status(&manifest, "design-director", "pending");
|
||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
|
||
assert!(!agent_db.contains("\"recordType\":\"agent.runtime.ready_task.scheduled\""));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn agent_db_rejects_symlinked_project_root() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let root = unique_project_path();
|
||
let link = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "Agent DB 根目录链接测试")
|
||
.expect("project init");
|
||
let agent_db_path = root.join(".agent/agent.db");
|
||
let before = fs::read(&agent_db_path).expect("read agent db before symlink attempt");
|
||
symlink(&root, &link).expect("create project root symlink");
|
||
|
||
let error = append_agent_db_record(
|
||
&link,
|
||
serde_json::json!({
|
||
"recordType": "test.symlinked-project-root",
|
||
"mustNotPersist": true,
|
||
}),
|
||
)
|
||
.expect_err("symlinked project root must be rejected");
|
||
assert!(!error.trim().is_empty());
|
||
assert_eq!(
|
||
fs::read(&agent_db_path).expect("read unchanged agent db"),
|
||
before
|
||
);
|
||
|
||
fs::remove_file(link).ok();
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_db_idempotent_append_reopens_storage_after_directory_replacement() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "Agent DB 身份替换测试").expect("project init");
|
||
let record_type = "agent.runtime.action_receipt";
|
||
let agent_id = "design-director";
|
||
let run_id = "design-agent-db-replacement-run";
|
||
let action_id = "action-111111111111111111111111";
|
||
let record = serde_json::json!({
|
||
"recordType": record_type,
|
||
"agentId": agent_id,
|
||
"taskId": agent_id,
|
||
"sessionId": "session-agent-db-replacement",
|
||
"runId": run_id,
|
||
"actionId": action_id,
|
||
"actionFingerprint": "1".repeat(64),
|
||
"tool": "file.list",
|
||
"executionMode": "auto",
|
||
"status": "ok",
|
||
"inputSummary": serde_json::Value::Null,
|
||
"summary": "旧目录中的同 key 记录",
|
||
"safeDetail": serde_json::Value::Null,
|
||
"detailUnavailable": true,
|
||
});
|
||
assert!(append_agent_db_record_if_missing_for_action(
|
||
&root,
|
||
record_type,
|
||
agent_id,
|
||
run_id,
|
||
action_id,
|
||
record.clone(),
|
||
)
|
||
.expect("append original identity"));
|
||
|
||
let swap_root = root.clone();
|
||
let appended = append_agent_db_record_if_missing_for_action_with_before_lock(
|
||
&root,
|
||
record_type,
|
||
agent_id,
|
||
run_id,
|
||
action_id,
|
||
record,
|
||
move || {
|
||
fs::rename(swap_root.join(".agent"), swap_root.join(".agent-replaced"))
|
||
.expect("move old agent directory");
|
||
fs::create_dir(swap_root.join(".agent")).expect("create replacement agent directory");
|
||
},
|
||
)
|
||
.expect("append must rescan replacement database");
|
||
assert!(
|
||
appended,
|
||
"the pre-lock directory identity must not suppress append to the new database"
|
||
);
|
||
let records = fs::read_to_string(root.join(".agent/agent.db"))
|
||
.expect("read replacement agent db")
|
||
.lines()
|
||
.map(|line| serde_json::from_str::<Value>(line).expect("replacement record json"))
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(
|
||
records
|
||
.iter()
|
||
.filter(|record| {
|
||
record["recordType"] == record_type && record["actionId"] == action_id
|
||
})
|
||
.count(),
|
||
1
|
||
);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn bounded_agent_db_read_keeps_complete_records_before_partial_utf8_tail() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "Agent DB UTF-8 尾部测试")
|
||
.expect("project init");
|
||
append_agent_db_record(
|
||
&root,
|
||
serde_json::json!({
|
||
"recordType": "test.before-partial-utf8",
|
||
"marker": "完整中文记录",
|
||
}),
|
||
)
|
||
.expect("append complete record");
|
||
let mut file = fs::OpenOptions::new()
|
||
.append(true)
|
||
.open(root.join(".agent/agent.db"))
|
||
.expect("open agent db for partial utf8 tail");
|
||
file.write_all(b"{\"recordType\":\"test.partial\",\"text\":\"")
|
||
.expect("write partial json prefix");
|
||
file.write_all(&[0xe4, 0xb8])
|
||
.expect("write incomplete utf8 codepoint");
|
||
drop(file);
|
||
|
||
let (records, truncated) =
|
||
read_agent_db_records_bounded(&root, 32 * 1024 * 1024).expect("read bounded records");
|
||
assert!(truncated);
|
||
assert!(records.iter().any(|record| {
|
||
record["recordType"] == "test.before-partial-utf8" && record["marker"] == "完整中文记录"
|
||
}));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_db_tail_repair_accepts_exact_one_mib_record_boundary() {
|
||
const ONE_MIB: usize = 1024 * 1024;
|
||
|
||
for valid_tail in [true, false] {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "Agent DB 精确尾窗测试")
|
||
.expect("project init");
|
||
let path = root.join(".agent/agent.db");
|
||
let prefix = b"{\"recordType\":\"test.boundary-prefix\"}\n";
|
||
let mut content = prefix.to_vec();
|
||
if valid_tail {
|
||
content.extend_from_slice(&exact_agent_db_json_record(
|
||
"test.exact-one-mib-tail",
|
||
ONE_MIB,
|
||
));
|
||
} else {
|
||
content.extend_from_slice(b"{\"recordType\":\"test.incomplete-one-mib-tail\",");
|
||
content.resize(prefix.len() + ONE_MIB, b'x');
|
||
}
|
||
fs::write(&path, content).expect("write exact one MiB tail fixture");
|
||
|
||
append_agent_db_record(
|
||
&root,
|
||
serde_json::json!({
|
||
"recordType": "test.after-exact-one-mib-tail",
|
||
"validTail": valid_tail,
|
||
}),
|
||
)
|
||
.expect("repair exact boundary and append record");
|
||
let records = fs::read_to_string(&path)
|
||
.expect("read repaired exact boundary database")
|
||
.lines()
|
||
.map(|line| serde_json::from_str::<Value>(line).expect("all repaired lines are json"))
|
||
.collect::<Vec<_>>();
|
||
assert!(records
|
||
.iter()
|
||
.any(|record| record["recordType"] == "test.boundary-prefix"));
|
||
assert!(records.iter().any(|record| {
|
||
record["recordType"] == "test.after-exact-one-mib-tail"
|
||
&& record["validTail"] == valid_tail
|
||
}));
|
||
assert_eq!(
|
||
records
|
||
.iter()
|
||
.any(|record| record["recordType"] == "test.exact-one-mib-tail"),
|
||
valid_tail
|
||
);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn agent_db_action_scan_rejects_oversized_complete_line() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "Agent DB 超长单行测试").expect("project init");
|
||
let path = root.join(".agent/agent.db");
|
||
let mut file = fs::OpenOptions::new()
|
||
.append(true)
|
||
.open(&path)
|
||
.expect("open agent db for oversized line");
|
||
file.write_all(&exact_agent_db_json_record(
|
||
"test.oversized-complete-line",
|
||
2 * 1024 * 1024,
|
||
))
|
||
.expect("write oversized complete record");
|
||
file.write_all(b"\n").expect("finish oversized record");
|
||
drop(file);
|
||
|
||
let error = append_agent_db_record_if_missing_for_action(
|
||
&root,
|
||
AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE,
|
||
"design-director",
|
||
"design-oversized-line-run",
|
||
"action-222222222222222222222222",
|
||
serde_json::json!({
|
||
"recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE,
|
||
"agentId": "design-director",
|
||
"taskId": "design-director",
|
||
"sessionId": "session-oversized-line",
|
||
"runId": "design-oversized-line-run",
|
||
"actionId": "action-222222222222222222222222",
|
||
"actionFingerprint": "2".repeat(64),
|
||
"tool": "file.list",
|
||
"executionMode": "auto",
|
||
"status": "ok",
|
||
"inputSummary": serde_json::Value::Null,
|
||
"summary": "不得越过超长审计行",
|
||
"safeDetail": serde_json::Value::Null,
|
||
"detailUnavailable": true,
|
||
}),
|
||
)
|
||
.expect_err("oversized complete line must fail closed");
|
||
assert!(error.contains("单条记录"), "unexpected error: {error}");
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn pending_terminal_observation_persists_receipt_before_cancellation() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "终态观察取消顺序测试").expect("project init");
|
||
let mut state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"在取消前持久化已完成观察",
|
||
"design-terminal-observation-cancel-run",
|
||
"agent-background-task",
|
||
"先写回执再取消",
|
||
vec!["保持动作审计完整".to_string()],
|
||
)
|
||
.expect("start runtime");
|
||
state.loop_iteration = 1;
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "file.list".to_string(),
|
||
reason: Some("读取项目目录".to_string()),
|
||
input: serde_json::json!({}),
|
||
};
|
||
let observation = AgentRuntimeToolObservation {
|
||
tool: "file.list".to_string(),
|
||
status: "blocked".to_string(),
|
||
summary: "开发者拒绝待确认工具动作".to_string(),
|
||
detail: Some("取消请求到达前 observation 已持久化".to_string()),
|
||
};
|
||
let pending = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
action,
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED,
|
||
Some(observation),
|
||
);
|
||
write_game_creator_agent_runtime_pending_tool_action(&root, &pending)
|
||
.expect("write terminal observed pending");
|
||
state.status = "running".to_string();
|
||
state.phase = "observation".to_string();
|
||
state.pending_tool_action = Some(pending.summary());
|
||
append_game_creator_agent_runtime_task(&root, &state).expect("append observation task");
|
||
write_game_creator_agent_runtime_state(&root, &state).expect("write observation state");
|
||
write_game_creator_agent_runtime_cancel_request(
|
||
&root,
|
||
"design-director",
|
||
&state.run_id,
|
||
"测试终态 observation 与取消竞态",
|
||
)
|
||
.expect("write cancellation tombstone");
|
||
|
||
continue_game_creator_agent_pending_tool_action(
|
||
root.clone(),
|
||
"design-director".to_string(),
|
||
pending.clone(),
|
||
state.clone(),
|
||
)
|
||
.await;
|
||
|
||
let receipts = read_agent_db_records_for_test(&root)
|
||
.into_iter()
|
||
.filter(|record| {
|
||
record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE
|
||
&& record["actionId"] == pending.action_id
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(receipts.len(), 1);
|
||
assert_eq!(receipts[0]["status"], "blocked");
|
||
let result = read_game_creator_agent_runtime_at(&root, "design-director")
|
||
.expect("read cancelled runtime");
|
||
assert_eq!(result.state.status, "cancelled");
|
||
assert_eq!(result.state.phase, "cancelled");
|
||
assert!(!game_creator_agent_runtime_pending_tool_action_exists(
|
||
&root,
|
||
"design-director",
|
||
&state.run_id
|
||
));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn first_pass_reconciliation_observation_applies_cancellation_after_receipt() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "首轮核对态取消顺序测试").expect("project init");
|
||
let mut state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"首轮核对态先写回执再取消",
|
||
"design-first-pass-reconciliation-cancel-run",
|
||
"agent-background-task",
|
||
"保持回执完整后结束任务",
|
||
vec!["验证取消顺序".to_string()],
|
||
)
|
||
.expect("start runtime");
|
||
state.loop_iteration = 1;
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "file.write".to_string(),
|
||
reason: Some("模拟已返回但需核对的写入".to_string()),
|
||
input: serde_json::json!({
|
||
"path": "game/reconciliation-result.txt",
|
||
"content": "动作不会在测试中重放"
|
||
}),
|
||
};
|
||
let observation = AgentRuntimeToolObservation {
|
||
tool: "file.write".to_string(),
|
||
status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(),
|
||
summary: "首轮工具结果需要人工核对".to_string(),
|
||
detail: Some("工具已返回终态,取消只能在 receipt 后生效".to_string()),
|
||
};
|
||
let mut pending = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
action,
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED,
|
||
Some(observation.clone()),
|
||
);
|
||
pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string();
|
||
write_game_creator_agent_runtime_pending_tool_action(&root, &pending)
|
||
.expect("write reconciliation pending");
|
||
state.pending_tool_action = Some(pending.summary());
|
||
write_game_creator_agent_runtime_state(&root, &state).expect("write running state");
|
||
write_game_creator_agent_runtime_cancel_request(
|
||
&root,
|
||
"design-director",
|
||
&state.run_id,
|
||
"测试首轮 reconciliation 与取消竞态",
|
||
)
|
||
.expect("write cancellation tombstone");
|
||
|
||
assert!(
|
||
persist_agent_runtime_reconciliation_observation_before_cancellation(
|
||
&root,
|
||
&mut state,
|
||
&pending,
|
||
&observation,
|
||
)
|
||
);
|
||
assert_eq!(state.status, "cancelled");
|
||
assert_eq!(state.phase, "cancelled");
|
||
assert!(!game_creator_agent_runtime_pending_tool_action_exists(
|
||
&root,
|
||
"design-director",
|
||
&state.run_id
|
||
));
|
||
let receipts = read_agent_db_records_for_test(&root)
|
||
.into_iter()
|
||
.filter(|record| {
|
||
record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE
|
||
&& record["actionId"] == pending.action_id
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(receipts.len(), 1);
|
||
assert_eq!(
|
||
receipts[0]["status"],
|
||
AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
|
||
);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn receipt_failure_keeps_terminal_pending_when_cancellation_is_already_requested() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "回执失败取消竞态测试").expect("project init");
|
||
let mut state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"回执失败时保留终态动作",
|
||
"design-receipt-failure-cancel-run",
|
||
"agent-background-task",
|
||
"失败关闭且不清理 pending",
|
||
vec!["保留恢复入口".to_string()],
|
||
)
|
||
.expect("start runtime");
|
||
state.loop_iteration = 1;
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "file.list".to_string(),
|
||
reason: Some("读取项目目录".to_string()),
|
||
input: serde_json::json!({}),
|
||
};
|
||
let observation = AgentRuntimeToolObservation {
|
||
tool: "file.list".to_string(),
|
||
status: "blocked".to_string(),
|
||
summary: "开发者拒绝待确认工具动作".to_string(),
|
||
detail: None,
|
||
};
|
||
let pending = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
action,
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED,
|
||
Some(observation),
|
||
);
|
||
write_game_creator_agent_runtime_pending_tool_action(&root, &pending)
|
||
.expect("write terminal observed pending");
|
||
state.status = "running".to_string();
|
||
state.phase = "observation".to_string();
|
||
state.pending_tool_action = Some(pending.summary());
|
||
append_game_creator_agent_runtime_task(&root, &state).expect("append observation task");
|
||
write_game_creator_agent_runtime_state(&root, &state).expect("write observation state");
|
||
append_agent_db_record_fixture(
|
||
&root,
|
||
serde_json::json!({
|
||
"recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE,
|
||
"agentId": state.agent_id,
|
||
"taskId": state.task_id,
|
||
"sessionId": state.session_id,
|
||
"runId": state.run_id,
|
||
"actionId": pending.action_id,
|
||
"actionFingerprint": pending.action_fingerprint,
|
||
"tool": pending.action.tool,
|
||
"executionMode": pending.execution_mode,
|
||
"status": "blocked",
|
||
"inputSummary": pending.input_summary,
|
||
"summary": "故意制造身份冲突的既有回执",
|
||
"safeDetail": serde_json::Value::Null,
|
||
"detailUnavailable": false,
|
||
}),
|
||
)
|
||
.expect("append conflicting receipt fixture");
|
||
write_game_creator_agent_runtime_cancel_request(
|
||
&root,
|
||
"design-director",
|
||
&state.run_id,
|
||
"测试 receipt 失败与取消同时发生",
|
||
)
|
||
.expect("write cancellation tombstone");
|
||
|
||
continue_game_creator_agent_pending_tool_action(
|
||
root.clone(),
|
||
"design-director".to_string(),
|
||
pending.clone(),
|
||
state.clone(),
|
||
)
|
||
.await;
|
||
|
||
let result = read_game_creator_agent_runtime_at(&root, "design-director")
|
||
.expect("read reconciliation runtime");
|
||
assert_eq!(result.state.status, "failed");
|
||
assert_eq!(result.state.phase, "needs-reconciliation");
|
||
assert!(result
|
||
.state
|
||
.error
|
||
.as_deref()
|
||
.is_some_and(|error| error.contains("持久动作回执")));
|
||
assert!(game_creator_agent_runtime_pending_tool_action_exists(
|
||
&root,
|
||
"design-director",
|
||
&state.run_id
|
||
));
|
||
assert!(root
|
||
.join(".agent/runtime/cancel/design-director")
|
||
.join(format!("{}.json", state.run_id))
|
||
.is_file());
|
||
let receipts = read_agent_db_records_for_test(&root)
|
||
.into_iter()
|
||
.filter(|record| {
|
||
record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE
|
||
&& record["actionId"] == pending.action_id
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(receipts.len(), 1);
|
||
assert_eq!(receipts[0]["summary"], "故意制造身份冲突的既有回执");
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn foreground_agent_chat_rejects_same_agent_background_lane_overlap() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
let (request_sender, request_receiver) = mpsc::channel();
|
||
let (release_sender, release_receiver) = mpsc::channel();
|
||
let base_url = spawn_releasable_mock_llm_server_responses_with_capture(
|
||
vec![serde_json::json!({
|
||
"thinkingSummary": "后台任务占用同 Agent lane",
|
||
"plan": [],
|
||
"actions": [],
|
||
"response": "后台任务完成。"
|
||
})
|
||
.to_string()],
|
||
request_sender,
|
||
release_receiver,
|
||
);
|
||
let _config_guard = write_test_local_config(format!(
|
||
r#"{{
|
||
"agentLlm": {{
|
||
"design-director": {{
|
||
"apiKey": "design-key",
|
||
"baseUrl": {base_url:?},
|
||
"model": "design-runtime-model",
|
||
"apiKind": "openai_responses"
|
||
}}
|
||
}}
|
||
}}"#
|
||
));
|
||
|
||
start_game_creator_agent_background_task_at(
|
||
&root,
|
||
"design-director",
|
||
"正在执行的后台任务",
|
||
"design-background-lane-run",
|
||
)
|
||
.expect("start background task");
|
||
request_receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("background request in flight");
|
||
|
||
let error = chat_with_game_creator_role_agent(
|
||
root.to_string_lossy().into_owned(),
|
||
"design-director".to_string(),
|
||
None,
|
||
"这条前台消息不能并发覆盖状态".to_string(),
|
||
)
|
||
.await
|
||
.expect_err("foreground chat must respect the background lane");
|
||
assert!(error.contains("Agent 正在执行其他前台或后台任务:design-director"));
|
||
let running = read_game_creator_agent_runtime_at(&root, "design-director")
|
||
.expect("read background runtime");
|
||
assert_eq!(running.state.run_id, "design-background-lane-run");
|
||
assert_eq!(running.state.status, "running");
|
||
|
||
release_sender
|
||
.send(())
|
||
.expect("release background response");
|
||
let completed = wait_for_agent_runtime_idle(&root, "design-director");
|
||
assert_eq!(completed.run_id, "design-background-lane-run");
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn foreground_agent_chat_releases_lane_then_drains_queued_background_task() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
let (request_sender, request_receiver) = mpsc::channel();
|
||
let (release_sender, release_receiver) = mpsc::channel();
|
||
let base_url = spawn_releasable_mock_llm_server_responses_with_capture(
|
||
vec![
|
||
"前台 Agent 已完成回复。".to_string(),
|
||
serde_json::json!({
|
||
"thinkingSummary": "前台结束后处理排队任务",
|
||
"plan": [],
|
||
"actions": [],
|
||
"response": "排队后台任务完成。"
|
||
})
|
||
.to_string(),
|
||
],
|
||
request_sender,
|
||
release_receiver,
|
||
);
|
||
let _config_guard = write_test_local_config(format!(
|
||
r#"{{
|
||
"agentLlm": {{
|
||
"design-director": {{
|
||
"apiKey": "design-key",
|
||
"baseUrl": {base_url:?},
|
||
"model": "design-runtime-model",
|
||
"apiKind": "openai_responses"
|
||
}}
|
||
}}
|
||
}}"#
|
||
));
|
||
|
||
let project_path = root.to_string_lossy().into_owned();
|
||
let foreground = tokio::spawn(async move {
|
||
chat_with_game_creator_role_agent(
|
||
project_path,
|
||
"design-director".to_string(),
|
||
None,
|
||
"先完成前台对话".to_string(),
|
||
)
|
||
.await
|
||
});
|
||
tokio::task::yield_now().await;
|
||
let foreground_request = request_receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("foreground request in flight");
|
||
assert!(foreground_request.contains("先完成前台对话"));
|
||
let project_lock = acquire_project_write_lock(&root, "test.concurrent-project-write")
|
||
.expect("foreground LLM wait must not hold the project write lock");
|
||
drop(project_lock);
|
||
|
||
let queued = start_game_creator_agent_background_task_at(
|
||
&root,
|
||
"design-director",
|
||
"前台结束后执行的后台任务",
|
||
"design-after-foreground-run",
|
||
)
|
||
.expect("queue background task while foreground runs");
|
||
assert!(queued
|
||
.recent_tasks
|
||
.iter()
|
||
.any(|task| { task.run_id == "design-after-foreground-run" && task.status == "pending" }));
|
||
assert!(request_receiver
|
||
.recv_timeout(Duration::from_millis(200))
|
||
.is_err());
|
||
|
||
release_sender
|
||
.send(())
|
||
.expect("release foreground response");
|
||
let foreground_reply = foreground
|
||
.await
|
||
.expect("foreground join")
|
||
.expect("foreground reply");
|
||
assert_eq!(foreground_reply.reply_text, "前台 Agent 已完成回复。");
|
||
let background_request = request_receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("queued background request after foreground release");
|
||
assert!(background_request.contains("前台结束后执行的后台任务"));
|
||
let completed = wait_for_agent_runtime_idle(&root, "design-director");
|
||
assert_eq!(completed.run_id, "design-after-foreground-run");
|
||
assert_eq!(
|
||
completed.last_response.as_deref(),
|
||
Some("排队后台任务完成。")
|
||
);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_run_control_updates_lifecycle_and_jsonl() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
|
||
write_agent_run_trace(
|
||
&root,
|
||
"run-control-1",
|
||
"做一个可控 run",
|
||
"running",
|
||
1,
|
||
&[agent_trace_step(
|
||
1,
|
||
"Planner",
|
||
"completed",
|
||
&[".agent/manifest.json"],
|
||
&[".agent/spec.md"],
|
||
"已拆解目标",
|
||
"llm.chat.planner",
|
||
)],
|
||
None,
|
||
)
|
||
.expect("run trace");
|
||
|
||
let killed = update_agent_run_lifecycle(&root, "kill", None).expect("kill should update trace");
|
||
assert_eq!(killed.status, "killed");
|
||
assert_eq!(killed.lifecycle_status, "killed");
|
||
assert_eq!(killed.next_step, "resume-or-retry");
|
||
let trace: Value =
|
||
serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap())
|
||
.expect("run trace json");
|
||
assert_eq!(trace["status"], "killed");
|
||
assert_eq!(trace["lifecycleStatus"], "killed");
|
||
assert_eq!(trace["stopReason"], "killed");
|
||
|
||
let retried =
|
||
update_agent_run_lifecycle(&root, "retry", None).expect("retry should mark pending");
|
||
assert_eq!(retried.status, "pending");
|
||
assert_eq!(retried.lifecycle_status, "pending");
|
||
assert_eq!(retried.next_step, "rerun-now");
|
||
let resumed = update_agent_run_lifecycle(&root, "resume", Some("继续修复输入监听"))
|
||
.expect("resume should mark pending");
|
||
assert_eq!(resumed.status, "pending");
|
||
assert_eq!(resumed.lifecycle_status, "pending");
|
||
assert_eq!(resumed.next_step, "rerun-now");
|
||
let trace: Value =
|
||
serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap())
|
||
.expect("run trace json after resume");
|
||
assert_eq!(trace["stopReason"], "human-resume");
|
||
assert_eq!(trace["error"], Value::Null);
|
||
let status =
|
||
update_agent_run_lifecycle(&root, "status", None).expect("status should read trace");
|
||
assert_eq!(status.status, "pending");
|
||
assert_eq!(status.lifecycle_status, "pending");
|
||
let activity = fs::read_to_string(root.join(".agent/activity.jsonl")).expect("activity");
|
||
assert!(activity.contains("agent.kill"));
|
||
assert!(activity.contains("agent.retry"));
|
||
assert!(activity.contains("agent.resume"));
|
||
assert!(activity.contains("agent.run_status"));
|
||
let output = fs::read_to_string(root.join(".agent/output.jsonl")).expect("output");
|
||
assert!(output.contains("agent.kill"));
|
||
assert!(output.contains("agent.resume"));
|
||
assert!(root.join(".agent/context.bundle.json").exists());
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_run_status_derives_missing_lifecycle_from_status() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
|
||
write_agent_run_trace(
|
||
&root,
|
||
"run-control-legacy",
|
||
"做一个旧 trace",
|
||
"passed",
|
||
1,
|
||
&[agent_trace_step(
|
||
1,
|
||
"Playtest",
|
||
"completed",
|
||
&["game/index.html"],
|
||
&[".agent/logs/command.log"],
|
||
"静态入口自检通过",
|
||
"game.static_smoke",
|
||
)],
|
||
None,
|
||
)
|
||
.expect("run trace");
|
||
let trace_path = root.join(".agent/run.latest.json");
|
||
let mut trace: Value =
|
||
serde_json::from_str(&fs::read_to_string(&trace_path).unwrap()).expect("trace json");
|
||
trace
|
||
.as_object_mut()
|
||
.expect("trace object")
|
||
.remove("lifecycleStatus");
|
||
fs::write(
|
||
&trace_path,
|
||
serde_json::to_string_pretty(&trace).expect("trace json"),
|
||
)
|
||
.expect("write legacy trace");
|
||
|
||
let status =
|
||
update_agent_run_lifecycle(&root, "status", None).expect("status should read trace");
|
||
|
||
assert_eq!(status.status, "passed");
|
||
assert_eq!(status.lifecycle_status, "done");
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn structured_plan_same_run_steer_preserves_monotonic_runtime_and_v3_context() {
|
||
let root = unique_project_path();
|
||
let mut state = start_agent_runtime_steer_fixture(&root, "structured-plan-steer-run");
|
||
let task = state.current_task.clone();
|
||
let session_id = state.session_id.clone();
|
||
let task_id = state.task_id.clone();
|
||
let plan = AgentRuntimeToolPlan::default();
|
||
let tracker = AgentRuntimeContextWindowTracker::default();
|
||
let mut observations = Vec::new();
|
||
|
||
let initial_update = AgentRuntimePlanUpdate {
|
||
explanation: "已读取项目,开始实现键盘控制".to_string(),
|
||
steps: vec![
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "读取项目".to_string(),
|
||
status: "completed".to_string(),
|
||
},
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "实现键盘控制".to_string(),
|
||
status: "in_progress".to_string(),
|
||
},
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "运行定向验证".to_string(),
|
||
status: "pending".to_string(),
|
||
},
|
||
],
|
||
};
|
||
assert!(apply_agent_runtime_plan_update(&mut state, &initial_update)
|
||
.expect("apply pre-steer structured plan")
|
||
.advanced_steps());
|
||
let pre_steer_revision = state.plan_revision;
|
||
assert_eq!(pre_steer_revision, 1);
|
||
write_game_creator_agent_runtime_state(&root, &state)
|
||
.expect("persist pre-steer structured runtime");
|
||
persist_game_creator_agent_runtime_context(
|
||
&root,
|
||
&state,
|
||
&task,
|
||
&plan,
|
||
&observations,
|
||
0,
|
||
&tracker,
|
||
)
|
||
.expect("persist pre-steer v3 context");
|
||
let pre_steer_context = read_game_creator_agent_runtime_context_bundle(&root, &state)
|
||
.expect("read pre-steer context")
|
||
.expect("pre-steer context exists");
|
||
assert_eq!(
|
||
pre_steer_context.schema_version,
|
||
AGENT_RUNTIME_CONTEXT_BUNDLE_SCHEMA_VERSION
|
||
);
|
||
assert_eq!(pre_steer_context.plan_revision, pre_steer_revision);
|
||
assert_eq!(pre_steer_context.plan_steps, state.plan_steps);
|
||
|
||
steer_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
&state.agent_id,
|
||
&state.session_id,
|
||
&state.run_id,
|
||
"structured-plan-steer-1",
|
||
"保留已完成的读取步骤,先运行定向验证,再继续实现键盘控制。",
|
||
"test",
|
||
)
|
||
.expect("queue same-run steer");
|
||
assert!(consume_game_creator_agent_runtime_steers(
|
||
&root,
|
||
&mut state,
|
||
&task,
|
||
&plan,
|
||
&mut observations,
|
||
0,
|
||
&tracker,
|
||
)
|
||
.expect("consume same-run steer"));
|
||
assert_eq!(state.plan_revision, pre_steer_revision);
|
||
assert!(state
|
||
.plan_steps
|
||
.iter()
|
||
.any(|step| { step.title == "读取项目" && step.status == "completed" }));
|
||
|
||
let consumed_state = read_game_creator_agent_runtime_at(&root, &state.agent_id)
|
||
.expect("read consumed runtime")
|
||
.state;
|
||
assert_eq!(consumed_state.task_id, task_id);
|
||
assert_eq!(consumed_state.session_id, session_id);
|
||
assert_eq!(consumed_state.run_id, state.run_id);
|
||
assert_eq!(consumed_state.plan_revision, pre_steer_revision);
|
||
assert_eq!(consumed_state.plan_steps, state.plan_steps);
|
||
let consumed_context = read_game_creator_agent_runtime_context_bundle(&root, &consumed_state)
|
||
.expect("read consumed context")
|
||
.expect("consumed context exists");
|
||
assert_eq!(
|
||
consumed_context.schema_version,
|
||
AGENT_RUNTIME_CONTEXT_BUNDLE_SCHEMA_VERSION
|
||
);
|
||
assert_eq!(consumed_context.plan_revision, consumed_state.plan_revision);
|
||
assert_eq!(
|
||
consumed_context.plan_explanation,
|
||
consumed_state.plan_explanation
|
||
);
|
||
assert_eq!(consumed_context.plan, consumed_state.plan);
|
||
assert_eq!(consumed_context.plan_steps, consumed_state.plan_steps);
|
||
assert_eq!(
|
||
consumed_context.active_plan_step_index,
|
||
consumed_state.active_plan_step_index
|
||
);
|
||
assert!(consumed_context.plan_revision >= pre_steer_context.plan_revision);
|
||
|
||
state = consumed_state;
|
||
let rejected_revision = state.plan_revision;
|
||
let completed_regression = AgentRuntimePlanUpdate {
|
||
explanation: "错误地回退已完成步骤".to_string(),
|
||
steps: vec![
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "读取项目".to_string(),
|
||
status: "pending".to_string(),
|
||
},
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "运行定向验证".to_string(),
|
||
status: "in_progress".to_string(),
|
||
},
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "实现键盘控制".to_string(),
|
||
status: "pending".to_string(),
|
||
},
|
||
],
|
||
};
|
||
assert!(
|
||
apply_agent_runtime_plan_update(&mut state, &completed_regression)
|
||
.expect_err("same-run steer replan cannot regress completed steps")
|
||
.contains("不能让已完成步骤回退")
|
||
);
|
||
assert_eq!(state.plan_revision, rejected_revision);
|
||
|
||
let steered_update = AgentRuntimePlanUpdate {
|
||
explanation: "按追加要求重排未完成步骤".to_string(),
|
||
steps: vec![
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "运行定向验证".to_string(),
|
||
status: "in_progress".to_string(),
|
||
},
|
||
AgentRuntimePlanUpdateStep {
|
||
step: "实现键盘控制".to_string(),
|
||
status: "pending".to_string(),
|
||
},
|
||
],
|
||
};
|
||
assert!(apply_agent_runtime_plan_update(&mut state, &steered_update)
|
||
.expect("apply post-steer structured plan")
|
||
.advanced_steps());
|
||
assert!(state.plan_revision > rejected_revision);
|
||
assert!(state
|
||
.plan_steps
|
||
.iter()
|
||
.any(|step| { step.title == "读取项目" && step.status == "completed" }));
|
||
write_game_creator_agent_runtime_state(&root, &state)
|
||
.expect("persist post-steer structured runtime");
|
||
persist_game_creator_agent_runtime_context(
|
||
&root,
|
||
&state,
|
||
&task,
|
||
&plan,
|
||
&observations,
|
||
0,
|
||
&tracker,
|
||
)
|
||
.expect("persist post-steer v3 context");
|
||
|
||
let persisted_state = read_game_creator_agent_runtime_at(&root, &state.agent_id)
|
||
.expect("read post-steer runtime")
|
||
.state;
|
||
let persisted_context = read_game_creator_agent_runtime_context_bundle(&root, &persisted_state)
|
||
.expect("read post-steer context")
|
||
.expect("post-steer context exists");
|
||
assert_eq!(
|
||
persisted_context.schema_version,
|
||
AGENT_RUNTIME_CONTEXT_BUNDLE_SCHEMA_VERSION
|
||
);
|
||
assert_eq!(
|
||
persisted_context.plan_revision,
|
||
persisted_state.plan_revision
|
||
);
|
||
assert_eq!(
|
||
persisted_context.plan_explanation,
|
||
persisted_state.plan_explanation
|
||
);
|
||
assert_eq!(persisted_context.plan, persisted_state.plan);
|
||
assert_eq!(persisted_context.plan_steps, persisted_state.plan_steps);
|
||
assert_eq!(
|
||
persisted_context.active_plan_step_index,
|
||
persisted_state.active_plan_step_index
|
||
);
|
||
assert!(persisted_context.plan_revision > consumed_context.plan_revision);
|
||
assert!(persisted_context
|
||
.plan_steps
|
||
.iter()
|
||
.any(|step| { step.title == "读取项目" && step.status == "completed" }));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_steer_is_idempotent_rejects_conflicts_and_enforces_capacity() {
|
||
let root = unique_project_path();
|
||
let state = start_agent_runtime_steer_fixture(&root, "steer-contract-run");
|
||
let first = steer_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
&state.agent_id,
|
||
&state.session_id,
|
||
&state.run_id,
|
||
"steer-1",
|
||
"先保留现有结构,但把移动改成键盘控制。",
|
||
"test",
|
||
)
|
||
.expect("accept first steer");
|
||
let duplicate = steer_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
&state.agent_id,
|
||
&state.session_id,
|
||
&state.run_id,
|
||
"steer-1",
|
||
"先保留现有结构,但把移动改成键盘控制。",
|
||
"test",
|
||
)
|
||
.expect("retry same steer");
|
||
assert_eq!(first.sequence, 1);
|
||
assert_eq!(duplicate.sequence, 1);
|
||
assert_eq!(duplicate.status, "queued");
|
||
assert_eq!(
|
||
read_agent_runtime_steer_jsonl(&root, &state.agent_id, &state.run_id).len(),
|
||
3,
|
||
"prepared/conversation-persisted/queued must remain exactly once"
|
||
);
|
||
let conversation = read_local_conversation_for_session_at(
|
||
&root,
|
||
Some(&state.agent_id),
|
||
Some(&state.session_id),
|
||
)
|
||
.expect("read steer conversation");
|
||
assert_eq!(
|
||
conversation
|
||
.messages
|
||
.iter()
|
||
.filter(|message| message.role == "user")
|
||
.count(),
|
||
1
|
||
);
|
||
let conflict = steer_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
&state.agent_id,
|
||
&state.session_id,
|
||
&state.run_id,
|
||
"steer-1",
|
||
"换成完全不同的指令。",
|
||
"test",
|
||
)
|
||
.expect_err("same steer id with different body must fail");
|
||
assert!(conflict.contains("steer-id-conflict"));
|
||
|
||
for index in 2..=AGENT_RUNTIME_STEER_MAX_RUN_COUNT {
|
||
steer_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
&state.agent_id,
|
||
&state.session_id,
|
||
&state.run_id,
|
||
&format!("steer-{index}"),
|
||
&format!("第 {index} 条追加要求。"),
|
||
"test",
|
||
)
|
||
.expect("accept steer within count limit");
|
||
}
|
||
let overflow = steer_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
&state.agent_id,
|
||
&state.session_id,
|
||
&state.run_id,
|
||
"steer-overflow",
|
||
"超过数量上限的追加要求。",
|
||
"test",
|
||
)
|
||
.expect_err("seventeenth steer must fail");
|
||
assert!(overflow.contains("容量上限"));
|
||
assert!(steer_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
&state.agent_id,
|
||
"wrong-session",
|
||
&state.run_id,
|
||
"wrong-session-steer",
|
||
"不应接受。",
|
||
"test",
|
||
)
|
||
.is_err());
|
||
assert!(steer_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
&state.agent_id,
|
||
&state.session_id,
|
||
"wrong-run",
|
||
"wrong-run-steer",
|
||
"不应接受。",
|
||
"test",
|
||
)
|
||
.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn root_supervisor_steer_persists_one_correlated_public_acknowledgement() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-steer-public-ack", "总控追加消息确认测试")
|
||
.expect("initialize Supervisor steer fixture");
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
"制作一个可玩的游戏",
|
||
"supervisor-steer-public-ack-run",
|
||
"project-supervisor-gui",
|
||
"正在制作游戏",
|
||
vec!["完成可玩版本".to_string()],
|
||
)
|
||
.expect("start root Supervisor runtime");
|
||
|
||
for _ in 0..2 {
|
||
steer_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
&state.agent_id,
|
||
&state.session_id,
|
||
&state.run_id,
|
||
"supervisor-steer-public-ack-1",
|
||
"你在做什么?",
|
||
"test",
|
||
)
|
||
.expect("queue idempotent Supervisor steer");
|
||
}
|
||
for _ in 0..2 {
|
||
persist_game_creator_agent_runtime_steer_decision_and_reply_at(
|
||
&root,
|
||
&state,
|
||
"supervisor-steer-public-ack-1",
|
||
1,
|
||
AgentRuntimeSteerDecision {
|
||
reply: "我正在完成玩法实现;这是状态询问,当前任务会继续。".to_string(),
|
||
interrupt_current_provider: false,
|
||
reason: "状态询问不需要中断当前 Provider。".to_string(),
|
||
},
|
||
)
|
||
.expect("persist idempotent Supervisor steer reply");
|
||
}
|
||
|
||
let supervisor_conversation = read_local_conversation_for_session_at(
|
||
&root,
|
||
Some(&state.agent_id),
|
||
Some(&state.session_id),
|
||
)
|
||
.expect("read Supervisor steer conversation");
|
||
let steer_message = supervisor_conversation
|
||
.messages
|
||
.iter()
|
||
.find(|message| message.role == "user" && message.content == "你在做什么?")
|
||
.expect("find persisted steer user message");
|
||
let correlation_id = steer_message
|
||
.message_id
|
||
.as_deref()
|
||
.and_then(|message_id| message_id.strip_prefix("agent-steer-"))
|
||
.expect("read steer correlation id");
|
||
|
||
let project_conversation =
|
||
read_local_conversation_at(&root, None).expect("read public steer acknowledgement");
|
||
let acknowledgements = project_conversation
|
||
.messages
|
||
.iter()
|
||
.filter(|message| {
|
||
message.content == "收到。我正在判断这条消息是否需要调整当前任务;现有任务会继续运行。"
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(acknowledgements.len(), 1);
|
||
assert!(acknowledgements[0]
|
||
.message_id
|
||
.as_deref()
|
||
.is_some_and(|message_id| {
|
||
message_id.starts_with(&format!(
|
||
"{AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX}{correlation_id}-"
|
||
))
|
||
}));
|
||
assert_eq!(
|
||
project_conversation
|
||
.messages
|
||
.iter()
|
||
.filter(|message| {
|
||
message.content == "我正在完成玩法实现;这是状态询问,当前任务会继续。"
|
||
})
|
||
.count(),
|
||
1
|
||
);
|
||
let decision = read_game_creator_agent_runtime_steer_decision_at(
|
||
&root,
|
||
&state.agent_id,
|
||
&state.run_id,
|
||
"supervisor-steer-public-ack-1",
|
||
)
|
||
.expect("read persisted Supervisor steer decision")
|
||
.expect("Supervisor steer decision exists");
|
||
assert!(!decision.interrupt_current_provider);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_steer_sequences_are_unique_under_concurrent_acceptance() {
|
||
let root = unique_project_path();
|
||
let state = start_agent_runtime_steer_fixture(&root, "steer-concurrent-run");
|
||
let root = Arc::new(root);
|
||
let state = Arc::new(state);
|
||
let mut workers = Vec::new();
|
||
for index in 0..8 {
|
||
let root = root.clone();
|
||
let state = state.clone();
|
||
workers.push(std::thread::spawn(move || {
|
||
steer_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
&state.agent_id,
|
||
&state.session_id,
|
||
&state.run_id,
|
||
&format!("steer-concurrent-{index}"),
|
||
&format!("并发追加指令 {index}"),
|
||
"test",
|
||
)
|
||
.expect("accept concurrent steer")
|
||
.sequence
|
||
}));
|
||
}
|
||
let mut sequences = workers
|
||
.into_iter()
|
||
.map(|worker| worker.join().expect("join steer worker"))
|
||
.collect::<Vec<_>>();
|
||
sequences.sort_unstable();
|
||
assert_eq!(sequences, (1..=8).collect::<Vec<_>>());
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_steer_rejects_invalid_body_and_reconciliation_state() {
|
||
let root = unique_project_path();
|
||
let mut state = start_agent_runtime_steer_fixture(&root, "steer-invalid-run");
|
||
assert!(steer_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
&state.agent_id,
|
||
&state.session_id,
|
||
&state.run_id,
|
||
"steer-too-large",
|
||
&"x".repeat(AGENT_RUNTIME_STEER_MAX_INSTRUCTION_BYTES + 1),
|
||
"test",
|
||
)
|
||
.expect_err("oversized steer must fail")
|
||
.contains("字节上限"));
|
||
assert!(steer_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
&state.agent_id,
|
||
&state.session_id,
|
||
&state.run_id,
|
||
"steer-control",
|
||
"非法\u{0007}控制字符",
|
||
"test",
|
||
)
|
||
.expect_err("control character steer must fail")
|
||
.contains("控制字符"));
|
||
state.phase = "needs-reconciliation".to_string();
|
||
state.current_action = "等待人工核对".to_string();
|
||
write_game_creator_agent_runtime_state(&root, &state).expect("write reconciliation state");
|
||
assert!(steer_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
&state.agent_id,
|
||
&state.session_id,
|
||
&state.run_id,
|
||
"steer-reconciliation",
|
||
"不应越过待核对边界。",
|
||
"test",
|
||
)
|
||
.expect_err("reconciliation run must reject steer")
|
||
.contains("不接受追加指令"));
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_steer_blocks_stale_auto_action_and_defers_pending_confirmation() {
|
||
let root = unique_project_path();
|
||
let mut state = start_agent_runtime_steer_fixture(&root, "steer-action-gate-run");
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "file.write".to_string(),
|
||
reason: Some("执行旧计划的第二步".to_string()),
|
||
input: serde_json::json!({
|
||
"path": "game/stale-action.txt",
|
||
"content": "must-not-run"
|
||
}),
|
||
};
|
||
let mut stale_auto = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
action.clone(),
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED,
|
||
None,
|
||
);
|
||
stale_auto.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string();
|
||
steer_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
&state.agent_id,
|
||
&state.session_id,
|
||
&state.run_id,
|
||
"steer-before-action-2",
|
||
"停止旧计划,不要执行第二个写入动作。",
|
||
"test",
|
||
)
|
||
.expect("queue steer before second auto action");
|
||
assert!(
|
||
!mark_game_creator_agent_runtime_auto_action_executing_if_current(&root, &mut stale_auto,)
|
||
.expect("check stale auto action gate")
|
||
);
|
||
assert!(!root.join("game/stale-action.txt").exists());
|
||
|
||
let pending = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
action,
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING,
|
||
None,
|
||
);
|
||
write_game_creator_agent_runtime_pending_tool_action(&root, &pending)
|
||
.expect("persist confirmation pending before steer");
|
||
state.status = "waiting-for-confirmation".to_string();
|
||
state.phase = "waiting-for-confirmation".to_string();
|
||
state.pending_tool_action = Some(pending.summary());
|
||
write_game_creator_agent_runtime_state(&root, &state)
|
||
.expect("persist waiting confirmation state");
|
||
steer_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
&state.agent_id,
|
||
&state.session_id,
|
||
&state.run_id,
|
||
"steer-during-confirmation",
|
||
"待确认动作保持原样,之后再应用这条要求。",
|
||
"test",
|
||
)
|
||
.expect("queue steer during confirmation");
|
||
let restored =
|
||
read_game_creator_agent_runtime_pending_tool_action(&root, &state.agent_id, &state.run_id)
|
||
.expect("read pending confirmation after steer");
|
||
assert_eq!(restored.status, AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING);
|
||
assert_eq!(restored.action_id, pending.action_id);
|
||
assert_eq!(restored.action_fingerprint, pending.action_fingerprint);
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_steer_context_cursor_repairs_missing_applied_transition() {
|
||
let root = unique_project_path();
|
||
let mut state = start_agent_runtime_steer_fixture(&root, "steer-recovery-run");
|
||
steer_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
&state.agent_id,
|
||
&state.session_id,
|
||
&state.run_id,
|
||
"steer-recovery-1",
|
||
"改为先修复失败测试,再继续实现。",
|
||
"test",
|
||
)
|
||
.expect("queue recovery steer");
|
||
let plan = AgentRuntimeToolPlan::default();
|
||
let tracker = AgentRuntimeContextWindowTracker::default();
|
||
let mut observations = Vec::new();
|
||
assert!(consume_game_creator_agent_runtime_steers(
|
||
&root,
|
||
&mut state,
|
||
"实现一个可验证的键盘操作原型",
|
||
&plan,
|
||
&mut observations,
|
||
0,
|
||
&tracker,
|
||
)
|
||
.expect("consume steer"));
|
||
assert_eq!(state.applied_steer_cursor, 1);
|
||
assert_eq!(state.applied_steer_refs.len(), 1);
|
||
assert!(render_game_creator_agent_runtime_steers_for_prompt(
|
||
&root,
|
||
&state.agent_id,
|
||
&state.session_id,
|
||
&state.run_id,
|
||
)
|
||
.expect("render applied steers")
|
||
.contains("先修复失败测试"));
|
||
let context = read_game_creator_agent_runtime_context_bundle(&root, &state)
|
||
.expect("read context bundle")
|
||
.expect("context exists");
|
||
assert_eq!(context.applied_steer_cursor, 1);
|
||
assert_eq!(context.applied_steer_refs, state.applied_steer_refs);
|
||
|
||
let ledger_path =
|
||
game_creator_agent_runtime_steer_ledger_path(&root, &state.agent_id, &state.run_id);
|
||
let retained = fs::read_to_string(&ledger_path)
|
||
.expect("read ledger before simulated crash")
|
||
.lines()
|
||
.filter(|line| {
|
||
serde_json::from_str::<serde_json::Value>(line)
|
||
.ok()
|
||
.and_then(|record| record["status"].as_str().map(str::to_string))
|
||
.as_deref()
|
||
!= Some("applied")
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
fs::write(&ledger_path, format!("{retained}\n")).expect("remove applied transition");
|
||
assert!(!consume_game_creator_agent_runtime_steers(
|
||
&root,
|
||
&mut state,
|
||
"实现一个可验证的键盘操作原型",
|
||
&plan,
|
||
&mut observations,
|
||
0,
|
||
&tracker,
|
||
)
|
||
.expect("repair missing applied transition"));
|
||
let applied_count = read_agent_runtime_steer_jsonl(&root, &state.agent_id, &state.run_id)
|
||
.iter()
|
||
.filter(|record| record["status"] == "applied")
|
||
.count();
|
||
assert_eq!(applied_count, 1);
|
||
}
|