15a524e6e0
## 摘要 本 PR 将 Game Agent 资源管理页升级为真实自由画板,并打通图片资源的快速编辑、候选生成、导入、失败归档与正式图提交流程。 同时补齐图片精修的事务恢复、候选身份校验、生成中任务恢复、鉴权重试,以及“设为最终图”后游戏运行资源的稳定刷新链路。 ## 主要变更 ### 1. 资源管理页自由画板 - “按依赖”视图改为统一自由画板: - 支持指针拖拽平移 - 滚轮平移 - `Ctrl / Meta + wheel` 以指针为锚点缩放 - 支持复位和全量资源适配 - 增加隐藏导航边界: - 边界由全量资源世界范围加安全留白决定 - 搜索过滤不会缩小边界 - 用户不能把全部资源拖出可视区域 - 图片资源卡按真实宽高比展示: - 预览读取真实 `pixelWidth / pixelHeight` - 布局碰撞、世界范围、依赖连线共同消费同一资源矩形 - 非图片资源保持固定卡片尺寸 - 点击资源改为非模态详情卡: - 背景画板不卸载 - 顶部工具栏、搜索、排序、缩放状态不重置 - 图片详情不再进入全屏页 ### 2. 图片资源快速编辑与精修画布 - 点击图片资源后直接显示快速编辑卡。 - 图片精修顶栏收敛为紧凑操作: - 返回 - 导入 - 定位当前最终图 - 撤销 / 重做 - 删除、修改、设为最终图进入选中图片的上下文卡片。 - 快速编辑生成不再阻塞整张画布: - 立即创建生成占位卡 - 占位卡可拖动并持久化位置 - 右上任务列表显示排队 / 生成中 / 完成 / 失败状态 - 任务列表可折叠 - 生成失败只影响当前任务: - 保留失败占位 - 可显式归档失败任务 - `reconciliation-required` 任务不允许删除 - 原生多选图片导入: - 使用 Tauri 原生文件选择 - 后端批量安全读取 - 一次推进草稿 revision - 失败不留下半导入状态 ### 3. 候选图与正式图事务 - 生成成功只加入草稿私有候选层,不直接修改 manifest。 - “设为最终图”是唯一正式资源切换入口。 - refine 保持原 asset ID 和来源身份,不追加新的业务资产身份。 - 正式文件使用不可变路径: ```text assets/canvas/<name>--<commitId>.png --------- Co-authored-by: kdletters <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/176 Co-authored-by: suzmii <suzmii@qq.com> Co-committed-by: suzmii <suzmii@qq.com>
6261 lines
229 KiB
Rust
6261 lines
229 KiB
Rust
use super::*;
|
||
|
||
#[tokio::test]
|
||
async fn background_agent_runtime_checkpoints_full_context_across_multiple_windows() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "长任务上下文项目").expect("project init");
|
||
fs::write(
|
||
root.join("progress.txt"),
|
||
(1..=39)
|
||
.map(|index| format!("CONTEXT_MARKER_{index}"))
|
||
.collect::<Vec<_>>()
|
||
.join("\n"),
|
||
)
|
||
.expect("write progress fixture");
|
||
|
||
let (sender, receiver) = mpsc::channel();
|
||
let mut responses = (0..13)
|
||
.map(|loop_index| {
|
||
let actions = (1..=3)
|
||
.map(|offset| {
|
||
let marker = loop_index * 3 + offset;
|
||
serde_json::json!({
|
||
"tool": "project.search",
|
||
"reason": format!("读取第 {marker} 个独立进展证据"),
|
||
"input": {
|
||
"query": format!("CONTEXT_MARKER_{marker}"),
|
||
"path": "progress.txt",
|
||
"maxResults": 1,
|
||
"caseSensitive": true
|
||
}
|
||
})
|
||
})
|
||
.collect::<Vec<_>>();
|
||
serde_json::json!({
|
||
"thinkingSummary": format!("第 {} 轮继续收集不同证据", loop_index + 1),
|
||
"plan": ["读取下一组证据", "保留完整观察上下文"],
|
||
"actions": actions,
|
||
"response": ""
|
||
})
|
||
.to_string()
|
||
})
|
||
.collect::<Vec<_>>();
|
||
responses.push(
|
||
serde_json::json!({
|
||
"thinkingSummary": "证据已经足够,长任务可以收束",
|
||
"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",
|
||
"持续收集 39 个证据后再完成",
|
||
"design-context-continuation-run",
|
||
)
|
||
.expect("start long background task");
|
||
|
||
let mut requests = Vec::new();
|
||
for iteration in 1..=14 {
|
||
let request = receiver
|
||
.recv_timeout(Duration::from_secs(4))
|
||
.expect("planning request");
|
||
assert!(request.contains(&format!("第 {iteration} 轮")));
|
||
requests.push(request);
|
||
}
|
||
assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err());
|
||
assert!(!requests[6].contains("runtime.context"));
|
||
assert!(requests[6].contains("CONTEXT_MARKER_1"));
|
||
assert!(requests[6].contains("CONTEXT_MARKER_18"));
|
||
assert!(!requests[12].contains("runtime.context"));
|
||
assert!(requests[12].contains("CONTEXT_MARKER_1"));
|
||
assert!(requests[12].contains("CONTEXT_MARKER_36"));
|
||
|
||
let runtime = wait_for_agent_runtime_idle(&root, "design-director");
|
||
assert_eq!(runtime.status, "idle");
|
||
assert_eq!(runtime.phase, "completed");
|
||
assert_eq!(runtime.loop_iteration, 14);
|
||
assert_eq!(runtime.max_loop_iterations, 18);
|
||
assert_eq!(
|
||
runtime.last_response.as_deref(),
|
||
Some("已连续跨过两个六轮进度检查窗口并完成任务。")
|
||
);
|
||
|
||
let bundle_path = game_creator_agent_runtime_context_bundle_path(
|
||
&root,
|
||
"design-director",
|
||
"design-context-continuation-run",
|
||
);
|
||
let bundle: Value = serde_json::from_str(
|
||
&fs::read_to_string(&bundle_path).expect("read runtime context bundle"),
|
||
)
|
||
.expect("parse runtime context bundle");
|
||
assert_eq!(
|
||
bundle["schemaVersion"],
|
||
AGENT_RUNTIME_CONTEXT_BUNDLE_SCHEMA_VERSION
|
||
);
|
||
assert_eq!(bundle["agentId"], "design-director");
|
||
assert_eq!(bundle["sessionId"], "agent-session-design-director");
|
||
assert_eq!(bundle["runId"], "design-context-continuation-run");
|
||
assert_eq!(bundle["nextLoopIndex"], 14);
|
||
assert!(bundle["contextWindow"]
|
||
.as_u64()
|
||
.is_some_and(|value| value >= 3));
|
||
assert!(bundle["observations"]
|
||
.as_array()
|
||
.is_some_and(|items| items.len() > 12
|
||
&& items.len() <= AGENT_RUNTIME_CONTEXT_BUNDLE_OBSERVATION_LIMIT));
|
||
assert!(!bundle["observations"]
|
||
.as_array()
|
||
.is_some_and(|items| items.iter().any(|item| item["tool"] == "runtime.context")));
|
||
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 finalization_resume_recovers_real_assistant_append_checkpoint_once() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "最终回复真实中断恢复项目")
|
||
.expect("project init");
|
||
let task = "在 assistant 追加后模拟进程中断";
|
||
let run_id = "design-finalization-assistant-checkpoint-run";
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
task,
|
||
run_id,
|
||
"agent-background-task",
|
||
"准备最终回复",
|
||
vec!["保存最终回复".to_string()],
|
||
)
|
||
.expect("start finalization checkpoint runtime");
|
||
let response = "真实 finalization checkpoint 回复";
|
||
let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at(
|
||
&root,
|
||
state.clone(),
|
||
response,
|
||
0,
|
||
&[],
|
||
|checkpoint| {
|
||
if checkpoint == AgentRuntimeFinalizationCheckpoint::AssistantAppended {
|
||
Err("injected-assistant-checkpoint-crash".to_string())
|
||
} else {
|
||
Ok(())
|
||
}
|
||
},
|
||
)
|
||
.expect("checkpoint injection is a recoverable outcome");
|
||
assert!(matches!(
|
||
outcome,
|
||
AgentBackgroundFinalizationOutcome::Pending(ref error)
|
||
if error.contains("injected-assistant-checkpoint-crash")
|
||
));
|
||
let journal =
|
||
read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id)
|
||
.expect("read assistant checkpoint journal")
|
||
.expect("assistant checkpoint journal exists");
|
||
assert_eq!(journal.status, "prepared");
|
||
let interrupted = read_game_creator_agent_runtime_at(&root, "design-director")
|
||
.expect("read interrupted finalization runtime")
|
||
.state;
|
||
assert_ne!(interrupted.phase, "completed");
|
||
let conversation = read_local_conversation_for_session_at(
|
||
&root,
|
||
Some("design-director"),
|
||
Some(&state.session_id),
|
||
)
|
||
.expect("read assistant checkpoint conversation");
|
||
assert_eq!(
|
||
conversation
|
||
.messages
|
||
.iter()
|
||
.filter(|message| message.role == "assistant" && message.content == response)
|
||
.count(),
|
||
1
|
||
);
|
||
|
||
let (sender, receiver) = mpsc::channel();
|
||
let base_url = spawn_mock_llm_server_responses_with_capture(
|
||
vec![final_tool_plan_response("不应请求 LLM")],
|
||
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 resumed = resume_game_creator_agent_background_tasks_at(&root)
|
||
.expect("resume assistant checkpoint finalization");
|
||
assert_eq!(resumed.len(), 1);
|
||
assert!(receiver.recv_timeout(Duration::from_millis(250)).is_err());
|
||
let completed = wait_for_agent_runtime_idle(&root, "design-director");
|
||
assert_eq!(completed.phase, "completed");
|
||
assert_eq!(completed.run_id, run_id);
|
||
assert_eq!(completed.last_response.as_deref(), Some(response));
|
||
assert!(
|
||
read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id,)
|
||
.expect("read cleaned assistant checkpoint journal")
|
||
.is_none()
|
||
);
|
||
let conversation = read_local_conversation_for_session_at(
|
||
&root,
|
||
Some("design-director"),
|
||
Some(&state.session_id),
|
||
)
|
||
.expect("read recovered assistant checkpoint conversation");
|
||
assert_eq!(
|
||
conversation
|
||
.messages
|
||
.iter()
|
||
.filter(|message| message.role == "assistant" && message.content == response)
|
||
.count(),
|
||
1
|
||
);
|
||
let lifecycle = read_agent_db_records_for_test(&root)
|
||
.into_iter()
|
||
.filter(|record| {
|
||
record["recordType"] == "agent.runtime.finalization.lifecycle"
|
||
&& record["runId"] == run_id
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(lifecycle.len(), 4);
|
||
assert_eq!(
|
||
lifecycle
|
||
.iter()
|
||
.map(|record| record["stage"].as_str().unwrap_or_default())
|
||
.collect::<Vec<_>>(),
|
||
vec![
|
||
"prepared",
|
||
"assistant-persisted",
|
||
"runtime-completed",
|
||
"goal-completed"
|
||
]
|
||
);
|
||
assert!(lifecycle.iter().enumerate().all(|(index, record)| {
|
||
record["auditSchemaVersion"] == "game-creator-finalization-lifecycle.v1"
|
||
&& record["journalSchemaVersion"] == "game-creator-runtime-finalization.v4"
|
||
&& record["finalizationId"] == journal.finalization_id
|
||
&& record["messageId"] == journal.message_id
|
||
&& record["stageOrdinal"] == u64::try_from(index + 1).unwrap_or_default()
|
||
&& record["stageAt"].as_u64().is_some_and(|value| value > 0)
|
||
&& record.get("task").is_none()
|
||
&& record.get("response").is_none()
|
||
&& record.get("prompt").is_none()
|
||
}));
|
||
assert!(resume_game_creator_agent_background_tasks_at(&root)
|
||
.expect("repeat assistant checkpoint recovery")
|
||
.is_empty());
|
||
assert!(receiver.recv_timeout(Duration::from_millis(150)).is_err());
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn finalization_resume_cleans_completed_checkpoint_without_duplicate_projections() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "最终回复终态中断恢复项目")
|
||
.expect("project init");
|
||
let task = "在 Runtime completed 后模拟进程中断";
|
||
let run_id = "design-finalization-completed-checkpoint-run";
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
task,
|
||
run_id,
|
||
"agent-background-task",
|
||
"准备最终回复",
|
||
vec!["完成最终回复".to_string()],
|
||
)
|
||
.expect("start completed checkpoint runtime");
|
||
let response = "Runtime completed checkpoint 回复";
|
||
let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at(
|
||
&root,
|
||
state.clone(),
|
||
response,
|
||
0,
|
||
&[],
|
||
|checkpoint| {
|
||
if checkpoint == AgentRuntimeFinalizationCheckpoint::RuntimeCompleted {
|
||
Err("injected-runtime-completed-checkpoint-crash".to_string())
|
||
} else {
|
||
Ok(())
|
||
}
|
||
},
|
||
)
|
||
.expect("completed checkpoint injection is recoverable");
|
||
assert!(matches!(
|
||
outcome,
|
||
AgentBackgroundFinalizationOutcome::Pending(ref error)
|
||
if error.contains("injected-runtime-completed-checkpoint-crash")
|
||
));
|
||
let before = read_game_creator_agent_runtime_at(&root, "design-director")
|
||
.expect("read completed checkpoint runtime");
|
||
assert_eq!(before.state.phase, "completed");
|
||
assert_eq!(before.state.last_response.as_deref(), Some(response));
|
||
let before_turn_completed = before
|
||
.recent_events
|
||
.iter()
|
||
.filter(|event| event.run_id == run_id && event.event_type == "turn.completed")
|
||
.count();
|
||
let before_response = before
|
||
.recent_events
|
||
.iter()
|
||
.filter(|event| event.run_id == run_id && event.event_type == "response")
|
||
.count();
|
||
let before_records = read_agent_db_records_for_test(&root);
|
||
let before_runtime_completed = before_records
|
||
.iter()
|
||
.filter(|record| {
|
||
record["recordType"] == "agent.runtime.completed" && record["runId"] == run_id
|
||
})
|
||
.count();
|
||
let before_background_completed = before_records
|
||
.iter()
|
||
.filter(|record| {
|
||
record["recordType"] == "agent.runtime.background_task.completed"
|
||
&& record["runId"] == run_id
|
||
})
|
||
.count();
|
||
|
||
let resumed = resume_game_creator_agent_background_tasks_at(&root)
|
||
.expect("resume completed checkpoint finalization");
|
||
assert_eq!(resumed.len(), 1);
|
||
let after = read_game_creator_agent_runtime_at(&root, "design-director")
|
||
.expect("read recovered completed checkpoint runtime");
|
||
assert_eq!(after.state.phase, "completed");
|
||
assert_eq!(after.state.last_response.as_deref(), Some(response));
|
||
assert_eq!(
|
||
after
|
||
.recent_events
|
||
.iter()
|
||
.filter(|event| event.run_id == run_id && event.event_type == "turn.completed")
|
||
.count(),
|
||
before_turn_completed
|
||
);
|
||
assert_eq!(
|
||
after
|
||
.recent_events
|
||
.iter()
|
||
.filter(|event| event.run_id == run_id && event.event_type == "response")
|
||
.count(),
|
||
before_response
|
||
);
|
||
let after_records = read_agent_db_records_for_test(&root);
|
||
assert_eq!(
|
||
after_records
|
||
.iter()
|
||
.filter(|record| {
|
||
record["recordType"] == "agent.runtime.completed" && record["runId"] == run_id
|
||
})
|
||
.count(),
|
||
before_runtime_completed
|
||
);
|
||
assert_eq!(
|
||
after_records
|
||
.iter()
|
||
.filter(|record| {
|
||
record["recordType"] == "agent.runtime.background_task.completed"
|
||
&& record["runId"] == run_id
|
||
})
|
||
.count(),
|
||
before_background_completed
|
||
);
|
||
assert!(
|
||
read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id,)
|
||
.expect("read cleaned completed checkpoint journal")
|
||
.is_none()
|
||
);
|
||
let conversation = read_local_conversation_for_session_at(
|
||
&root,
|
||
Some("design-director"),
|
||
Some(&state.session_id),
|
||
)
|
||
.expect("read completed checkpoint conversation");
|
||
assert_eq!(
|
||
conversation
|
||
.messages
|
||
.iter()
|
||
.filter(|message| message.role == "assistant" && message.content == response)
|
||
.count(),
|
||
1
|
||
);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_project_snapshot_read_waits_for_project_writer() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
fs::write(root.join("game/locked-read.txt"), "before\n").expect("write initial fixture");
|
||
let project_lock = acquire_project_write_lock(&root, "test.snapshot.writer")
|
||
.expect("acquire project writer lock");
|
||
let thread_root = root.clone();
|
||
let (started_sender, started_receiver) = mpsc::channel();
|
||
let reader = std::thread::spawn(move || {
|
||
started_sender.send(()).expect("signal reader start");
|
||
tauri::async_runtime::block_on(execute_game_creator_agent_runtime_tool_action(
|
||
&thread_root,
|
||
"design-director",
|
||
"snapshot-lock-run",
|
||
"读取一致项目快照",
|
||
&AgentRuntimeToolAction {
|
||
tool: "file.read".to_string(),
|
||
reason: Some("等待项目写入完成".to_string()),
|
||
input: serde_json::json!({ "path": "game/locked-read.txt" }),
|
||
},
|
||
))
|
||
});
|
||
started_receiver
|
||
.recv_timeout(Duration::from_secs(1))
|
||
.expect("reader started");
|
||
std::thread::sleep(Duration::from_millis(40));
|
||
fs::write(root.join("game/locked-read.txt"), "after-complete\n")
|
||
.expect("write complete fixture while locked");
|
||
drop(project_lock);
|
||
let observation = reader.join().expect("join snapshot reader");
|
||
assert_eq!(observation.status, "ok");
|
||
let detail = observation.detail.expect("locked read detail");
|
||
assert!(detail.contains("after-complete"));
|
||
assert!(!detail.contains("before"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn project_diff_and_action_history_recheck_policy_after_project_lock_wait() {
|
||
for (tool, command_id) in [
|
||
("project.diff", "project.diff"),
|
||
("agent.action_history", "agent.audit"),
|
||
] {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "一致快照策略复核测试")
|
||
.expect("project init");
|
||
let input = if tool == "project.diff" {
|
||
let checkpoint = create_local_project_checkpoint_at(&root).expect("create checkpoint");
|
||
serde_json::json!({ "checkpointId": checkpoint.checkpoint_id })
|
||
} else {
|
||
serde_json::json!({ "limit": 5 })
|
||
};
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow snapshot tool before wait");
|
||
let run_id = format!("design-{}-policy-after-lock", tool.replace('.', "-"));
|
||
let mut state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"等待一致项目快照后复核权限",
|
||
&run_id,
|
||
"agent-background-task",
|
||
"等待项目锁",
|
||
vec!["锁内复核权限".to_string()],
|
||
)
|
||
.expect("start runtime state");
|
||
state.loop_iteration = 1;
|
||
let action = AgentRuntimeToolAction {
|
||
tool: tool.to_string(),
|
||
reason: Some("验证锁内二次策略复核".to_string()),
|
||
input,
|
||
};
|
||
let mut pending = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
action.clone(),
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING,
|
||
None,
|
||
);
|
||
pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string();
|
||
write_game_creator_agent_runtime_pending_tool_action(&root, &pending)
|
||
.expect("write executing snapshot pending");
|
||
state.status = "running".to_string();
|
||
state.phase = "action".to_string();
|
||
state.pending_tool_action = Some(pending.summary());
|
||
append_game_creator_agent_runtime_task(&root, &state).expect("append running task");
|
||
write_game_creator_agent_runtime_state(&root, &state).expect("write running state");
|
||
|
||
let project_lock = acquire_project_write_lock(&root, "test.snapshot.policy-writer")
|
||
.expect("acquire project writer lock");
|
||
let thread_root = root.clone();
|
||
let thread_state = state.clone();
|
||
let thread_action = action.clone();
|
||
let thread_pending = pending.clone();
|
||
let (started_sender, started_receiver) = mpsc::channel();
|
||
let (finished_sender, finished_receiver) = mpsc::channel();
|
||
let reader = std::thread::spawn(move || {
|
||
started_sender.send(()).expect("signal snapshot start");
|
||
let observation = tauri::async_runtime::block_on(
|
||
execute_game_creator_agent_runtime_tool_action_with_pending_action(
|
||
&thread_root,
|
||
"design-director",
|
||
&thread_state.run_id,
|
||
&thread_state.current_task,
|
||
&thread_action,
|
||
Some(&thread_pending.action_id),
|
||
Some(&thread_pending),
|
||
),
|
||
);
|
||
finished_sender
|
||
.send(())
|
||
.expect("signal snapshot completion");
|
||
observation
|
||
});
|
||
started_receiver
|
||
.recv_timeout(Duration::from_secs(1))
|
||
.expect("snapshot action starts");
|
||
assert!(
|
||
finished_receiver
|
||
.recv_timeout(Duration::from_millis(80))
|
||
.is_err(),
|
||
"{tool} must wait for the project consistency lock"
|
||
);
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: vec![command_id.to_string()],
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("deny snapshot tool while it waits");
|
||
drop(project_lock);
|
||
|
||
let observation = reader.join().expect("join snapshot action");
|
||
assert_eq!(observation.status, "blocked", "{tool}: {observation:?}");
|
||
assert!(
|
||
observation.summary.contains("权限策略"),
|
||
"{tool}: {observation:?}"
|
||
);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn project_snapshot_rereads_durable_pending_after_project_lock_wait() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "pending sidecar 锁内复核测试")
|
||
.expect("project init");
|
||
fs::write(root.join("game/original.txt"), "original durable action")
|
||
.expect("write original fixture");
|
||
fs::write(
|
||
root.join("game/replacement.txt"),
|
||
"replacement durable action",
|
||
)
|
||
.expect("write replacement fixture");
|
||
let mut state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"等待项目锁后复核 durable pending",
|
||
"design-pending-sidecar-after-lock",
|
||
"agent-background-task",
|
||
"等待项目锁",
|
||
vec!["锁内重读 pending sidecar".to_string()],
|
||
)
|
||
.expect("start runtime state");
|
||
state.loop_iteration = 1;
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "file.read".to_string(),
|
||
reason: Some("读取原始动作目标".to_string()),
|
||
input: serde_json::json!({ "path": "game/original.txt" }),
|
||
};
|
||
let mut pending = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
action.clone(),
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING,
|
||
None,
|
||
);
|
||
pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string();
|
||
write_game_creator_agent_runtime_pending_tool_action(&root, &pending)
|
||
.expect("write original pending sidecar");
|
||
state.status = "running".to_string();
|
||
state.phase = "action".to_string();
|
||
state.pending_tool_action = Some(pending.summary());
|
||
append_game_creator_agent_runtime_task(&root, &state).expect("append running task");
|
||
write_game_creator_agent_runtime_state(&root, &state).expect("write running state");
|
||
|
||
let project_lock = acquire_project_write_lock(&root, "test.pending-sidecar.writer")
|
||
.expect("acquire project writer lock");
|
||
let thread_root = root.clone();
|
||
let thread_state = state.clone();
|
||
let thread_action = action.clone();
|
||
let thread_pending = pending.clone();
|
||
let (started_sender, started_receiver) = mpsc::channel();
|
||
let reader = std::thread::spawn(move || {
|
||
started_sender.send(()).expect("signal snapshot start");
|
||
tauri::async_runtime::block_on(
|
||
execute_game_creator_agent_runtime_tool_action_with_pending_action(
|
||
&thread_root,
|
||
"design-director",
|
||
&thread_state.run_id,
|
||
&thread_state.current_task,
|
||
&thread_action,
|
||
Some(&thread_pending.action_id),
|
||
Some(&thread_pending),
|
||
),
|
||
)
|
||
});
|
||
started_receiver
|
||
.recv_timeout(Duration::from_secs(1))
|
||
.expect("snapshot action starts");
|
||
std::thread::sleep(Duration::from_millis(40));
|
||
|
||
let replacement_action = AgentRuntimeToolAction {
|
||
tool: "file.read".to_string(),
|
||
reason: Some("读取替换动作目标".to_string()),
|
||
input: serde_json::json!({ "path": "game/replacement.txt" }),
|
||
};
|
||
let mut replacement = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
replacement_action,
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING,
|
||
None,
|
||
);
|
||
replacement.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string();
|
||
assert_ne!(replacement.action_id, pending.action_id);
|
||
write_game_creator_agent_runtime_pending_tool_action(&root, &replacement)
|
||
.expect("replace durable pending sidecar while action waits");
|
||
drop(project_lock);
|
||
|
||
let observation = reader.join().expect("join snapshot action");
|
||
assert_eq!(
|
||
observation.status,
|
||
AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
|
||
);
|
||
assert!(observation
|
||
.detail
|
||
.as_deref()
|
||
.is_some_and(|detail| detail.contains("durable pending action")));
|
||
assert!(!observation
|
||
.detail
|
||
.as_deref()
|
||
.unwrap_or_default()
|
||
.contains("original durable action"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn agent_runtime_file_write_and_patch_replan_after_locked_revision_drift() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "并行写入 revision 围栏").expect("project init");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow direct file mutations in test");
|
||
fs::write(root.join("game/write-target.txt"), "write-original\n")
|
||
.expect("write file.write target");
|
||
fs::write(root.join("game/patch-target.txt"), "patch-original\n")
|
||
.expect("write file.patch target");
|
||
|
||
let cases = [
|
||
(
|
||
"code-prototype",
|
||
"code-write-locked-drift-run",
|
||
AgentRuntimeToolAction {
|
||
tool: "file.write".to_string(),
|
||
reason: Some("写入完整游戏文件".to_string()),
|
||
input: serde_json::json!({
|
||
"path": "game/write-target.txt",
|
||
"content": "stale-write\n"
|
||
}),
|
||
},
|
||
"game/write-target.txt",
|
||
"write-original\n",
|
||
),
|
||
(
|
||
"design-director",
|
||
"design-patch-locked-drift-run",
|
||
AgentRuntimeToolAction {
|
||
tool: "file.patch".to_string(),
|
||
reason: Some("局部修改游戏文件".to_string()),
|
||
input: serde_json::json!({
|
||
"path": "game/patch-target.txt",
|
||
"oldText": "patch-original",
|
||
"newText": "stale-patch",
|
||
"expectedReplacements": 1
|
||
}),
|
||
},
|
||
"game/patch-target.txt",
|
||
"patch-original\n",
|
||
),
|
||
];
|
||
|
||
for (index, (agent_id, run_id, action, target, expected_content)) in
|
||
cases.into_iter().enumerate()
|
||
{
|
||
let mut state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
agent_id,
|
||
"执行等待项目锁的并行文件修改",
|
||
run_id,
|
||
"agent-background-task",
|
||
"准备执行文件修改",
|
||
vec!["修改目标文件".to_string()],
|
||
)
|
||
.expect("start runtime state");
|
||
state.loop_iteration = 1;
|
||
let mut pending = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
action.clone(),
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING,
|
||
None,
|
||
);
|
||
pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string();
|
||
write_game_creator_agent_runtime_pending_tool_action(&root, &pending)
|
||
.expect("write pending file action");
|
||
state.status = "running".to_string();
|
||
state.phase = "action".to_string();
|
||
state.pending_tool_action = Some(pending.summary());
|
||
append_game_creator_agent_runtime_task(&root, &state).expect("append running task");
|
||
write_game_creator_agent_runtime_state(&root, &state).expect("write running state");
|
||
assert_eq!(
|
||
advance_project_revision_for_test(
|
||
&root,
|
||
"art-director",
|
||
&format!("art-file-drift-{index}"),
|
||
"file.write",
|
||
),
|
||
u64::try_from(index + 1).expect("expected revision"),
|
||
);
|
||
|
||
let observation = execute_game_creator_agent_runtime_tool_action_with_pending_action(
|
||
&root,
|
||
agent_id,
|
||
run_id,
|
||
&state.current_task,
|
||
&action,
|
||
Some(&pending.action_id),
|
||
Some(&pending),
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(observation.status, "blocked", "{observation:?}");
|
||
assert!(observation.summary.contains("旧动作未执行"));
|
||
assert!(observation
|
||
.detail
|
||
.as_deref()
|
||
.is_some_and(|detail| detail.contains("projectRevisionDrift=true")));
|
||
assert_eq!(
|
||
fs::read_to_string(root.join(target)).expect("read preserved target"),
|
||
expected_content,
|
||
);
|
||
}
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn agent_runtime_file_delete_revalidates_revision_after_acquiring_project_lock() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "删除锁内复核").expect("project init");
|
||
let target = root.join("game/lock-revalidated-delete-target.txt");
|
||
fs::write(&target, "revision 漂移后必须保留\n").expect("write delete target");
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"删除等待执行的文件",
|
||
"design-lock-revalidated-delete-run",
|
||
"agent-background-task",
|
||
"准备执行 file.delete",
|
||
vec!["删除目标文件".to_string()],
|
||
)
|
||
.expect("start runtime state");
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "file.delete".to_string(),
|
||
reason: Some("删除已废弃文件".to_string()),
|
||
input: serde_json::json!({
|
||
"path": "game/lock-revalidated-delete-target.txt"
|
||
}),
|
||
};
|
||
let pending = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
action.clone(),
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED,
|
||
None,
|
||
);
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow direct delete execution in test");
|
||
assert_eq!(
|
||
advance_project_revision_for_test(
|
||
&root,
|
||
"art-director",
|
||
"art-concurrent-lock-revalidation-run",
|
||
"file.write",
|
||
),
|
||
1
|
||
);
|
||
|
||
let observation = execute_game_creator_agent_runtime_tool_action_with_pending_action(
|
||
&root,
|
||
"design-director",
|
||
"design-lock-revalidated-delete-run",
|
||
&state.current_task,
|
||
&action,
|
||
Some(&pending.action_id),
|
||
Some(&pending),
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(observation.status, "verification-failed");
|
||
assert!(observation
|
||
.detail
|
||
.as_deref()
|
||
.is_some_and(|detail| detail.contains("项目 revision 已变化")));
|
||
assert_eq!(
|
||
fs::read_to_string(&target).expect("read preserved target"),
|
||
"revision 漂移后必须保留\n"
|
||
);
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn agent_runtime_file_delete_reports_reconciliation_when_audit_fails_after_delete() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "删除审计失败核对").expect("project init");
|
||
let target = root.join("game/delete-audit-failure-target.txt");
|
||
fs::write(&target, "副作用已执行\n").expect("write delete target");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow direct delete execution in test");
|
||
let agent_db_path = root.join(".agent/agent.db");
|
||
fs::remove_file(&agent_db_path).expect("remove agent db file");
|
||
fs::create_dir(&agent_db_path).expect("replace agent db with directory");
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "file.delete".to_string(),
|
||
reason: Some("删除目标并模拟审计失败".to_string()),
|
||
input: serde_json::json!({
|
||
"path": "game/delete-audit-failure-target.txt"
|
||
}),
|
||
};
|
||
|
||
let observation = execute_game_creator_agent_runtime_tool_action(
|
||
&root,
|
||
"design-director",
|
||
"design-delete-audit-failure-run",
|
||
"删除目标并验证审计失败语义",
|
||
&action,
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(observation.status, "needs-reconciliation");
|
||
assert!(observation
|
||
.summary
|
||
.contains("文件已删除但 Agent DB 审计失败"));
|
||
assert!(observation
|
||
.detail
|
||
.as_deref()
|
||
.is_some_and(|detail| detail.contains("sideEffectApplied=true")));
|
||
assert!(!target.exists());
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn background_agent_runtime_file_delete_confirmation_replans_after_stale_project_revision() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
let target = root.join("game/stale-confirm-delete-target.txt");
|
||
fs::write(&target, "revision 漂移后必须保留\n").expect("write stale delete target");
|
||
let default_policy = ProjectPermissionPolicy::default();
|
||
assert!(default_policy
|
||
.confirm_commands
|
||
.contains(&"file.delete".to_string()));
|
||
write_project_permission_policy_at(&root, default_policy).expect("write default policy");
|
||
|
||
let delete_plan = serde_json::json!({
|
||
"thinkingSummary": "删除已废弃的项目文件",
|
||
"plan": ["等待确认后删除目标文件"],
|
||
"actions": [{
|
||
"tool": "file.delete",
|
||
"reason": "清理废弃文件",
|
||
"input": { "path": "game/stale-confirm-delete-target.txt" }
|
||
}],
|
||
"response": ""
|
||
})
|
||
.to_string();
|
||
let final_response = "旧删除动作未执行,已按最新项目 revision 完成重新规划。";
|
||
let (sender, receiver) = mpsc::channel();
|
||
let base_url = spawn_mock_llm_server_responses_with_capture(
|
||
vec![
|
||
delete_plan,
|
||
final_tool_plan_response(final_response.to_string()),
|
||
],
|
||
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",
|
||
"删除等待确认期间可能已经变化的文件",
|
||
"design-stale-delete-confirm-run",
|
||
)
|
||
.expect("start stale delete confirmation task");
|
||
receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("stale delete plan request");
|
||
let waiting = wait_for_agent_runtime_confirmation(&root, "design-director");
|
||
assert_eq!(waiting.run_id, "design-stale-delete-confirm-run");
|
||
let pending_summary = waiting
|
||
.pending_tool_action
|
||
.as_ref()
|
||
.expect("pending stale delete action");
|
||
assert_eq!(pending_summary.tool, "file.delete");
|
||
let pending_path = root.join(
|
||
".agent/runtime/pending-actions/design-director/design-stale-delete-confirm-run.json",
|
||
);
|
||
let pending: AgentRuntimePendingToolAction = serde_json::from_str(
|
||
&fs::read_to_string(&pending_path).expect("read stale delete pending action"),
|
||
)
|
||
.expect("parse stale delete pending action");
|
||
assert_eq!(pending.action_id, pending_summary.action_id);
|
||
assert_eq!(pending.project_revision_before.revision, 0);
|
||
assert_eq!(
|
||
fs::read_to_string(&target).expect("read target while confirmation waits"),
|
||
"revision 漂移后必须保留\n"
|
||
);
|
||
|
||
assert_eq!(
|
||
advance_project_revision_for_test(
|
||
&root,
|
||
"art-director",
|
||
"art-concurrent-mutation-run",
|
||
"file.write",
|
||
),
|
||
1
|
||
);
|
||
assert_ne!(
|
||
read_game_creator_agent_runtime_project_revision(&root)
|
||
.expect("read advanced project revision"),
|
||
pending.project_revision_before
|
||
);
|
||
|
||
let approved = confirm_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"design-stale-delete-confirm-run",
|
||
&pending_summary.action_id,
|
||
"批准原删除动作",
|
||
)
|
||
.expect("accept approval decision before asynchronous revision reconciliation");
|
||
assert_eq!(approved.state.run_id, "design-stale-delete-confirm-run");
|
||
assert_eq!(approved.state.status, "running");
|
||
|
||
let replanning_request = receiver
|
||
.recv_timeout(Duration::from_secs(10))
|
||
.expect("same-run replanning request after stale delete approval");
|
||
assert!(replanning_request.contains("design-stale-delete-confirm-run"));
|
||
assert!(replanning_request.contains("projectRevisionDrift=true"));
|
||
assert!(replanning_request.contains("旧动作未执行"));
|
||
|
||
let completed = wait_for_agent_runtime_idle(&root, "design-director");
|
||
assert_eq!(completed.run_id, "design-stale-delete-confirm-run");
|
||
assert_eq!(completed.status, "idle");
|
||
assert_eq!(completed.phase, "completed");
|
||
assert_eq!(completed.last_response.as_deref(), Some(final_response));
|
||
assert!(completed.error.is_none());
|
||
assert!(completed.recent_tool_calls.iter().any(|call| {
|
||
call.tool == "file.delete"
|
||
&& call.status == "blocked"
|
||
&& call.summary.contains("旧动作未执行")
|
||
}));
|
||
assert_eq!(
|
||
fs::read_to_string(&target).expect("read target after stale approval"),
|
||
"revision 漂移后必须保留\n"
|
||
);
|
||
assert!(!pending_path.exists());
|
||
let records = read_agent_db_records_for_test(&root);
|
||
assert!(records.iter().any(|record| {
|
||
record["recordType"] == "agent.runtime.tool_confirmation.approved"
|
||
&& record["actionId"] == pending_summary.action_id
|
||
}));
|
||
assert!(!records.iter().any(|record| {
|
||
record["recordType"] == "agent.runtime.tool_action.executing"
|
||
&& record["actionId"] == pending_summary.action_id
|
||
}));
|
||
assert!(!records.iter().any(|record| {
|
||
record["recordType"] == "agent.runtime.file.delete"
|
||
&& record["path"] == "game/stale-confirm-delete-target.txt"
|
||
}));
|
||
assert!(!records.iter().any(|record| {
|
||
record["recordType"] == "agent.runtime.tool_action.needs_reconciliation"
|
||
&& record["actionId"] == pending_summary.action_id
|
||
}));
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn background_agent_runtime_file_list_respects_project_policy() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
fs::write(
|
||
root.join("game/blocked-notes.txt"),
|
||
"不应该被列出的文件内容",
|
||
)
|
||
.expect("write notes");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: vec!["file.list".to_string()],
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("write policy");
|
||
let (sender, receiver) = mpsc::channel();
|
||
let plan_json = serde_json::json!({
|
||
"thinkingSummary": "尝试列出项目文件",
|
||
"plan": ["列出项目文件", "回复开发者"],
|
||
"actions": [
|
||
{
|
||
"tool": "file.list",
|
||
"reason": "需要知道项目里有什么文件",
|
||
"input": {}
|
||
}
|
||
],
|
||
"response": ""
|
||
})
|
||
.to_string();
|
||
let base_url = spawn_mock_llm_server_responses_with_capture(vec![plan_json], 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",
|
||
"后台尝试列出项目文件",
|
||
"design-file-list-policy-run",
|
||
)
|
||
.expect("start background task");
|
||
|
||
let plan_request = receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("plan llm request");
|
||
assert!(plan_request.contains("confirmTools"));
|
||
assert!(plan_request.contains("file.list"));
|
||
assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err());
|
||
|
||
let runtime = wait_for_agent_runtime_confirmation(&root, "design-director");
|
||
assert_eq!(runtime.status, "waiting-for-confirmation");
|
||
assert_eq!(runtime.task_queue.waiting_for_confirmation, 1);
|
||
assert!(runtime
|
||
.tool_policy
|
||
.confirm_tools
|
||
.contains(&"file.list".to_string()));
|
||
assert!(runtime.observations.iter().any(|item| {
|
||
item.contains("file.list:waiting-for-confirmation · 项目权限策略要求用户确认:file.list")
|
||
}));
|
||
assert!(!runtime
|
||
.observations
|
||
.iter()
|
||
.any(|item| item.contains("file.list:ok")));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn agent_runtime_project_search_is_literal_scoped_and_skips_sensitive_files() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
fs::write(
|
||
root.join("game/search-notes.txt"),
|
||
"moon.*[loop]\nMOON.*[LOOP]\nMOON.*[LOOP]\n",
|
||
)
|
||
.expect("write searchable game file");
|
||
fs::write(root.join("assets/search-hints.txt"), "MOON.*[LOOP]\n")
|
||
.expect("write searchable asset file");
|
||
fs::write(root.join(".agent/private-search.txt"), "MOON.*[LOOP]\n")
|
||
.expect("write private runtime file");
|
||
fs::write(root.join(".env"), "SEARCH_SECRET=MOON.*[LOOP]\n").expect("write env file");
|
||
fs::write(
|
||
root.join(GAME_CREATOR_CONFIG_FILE_NAME),
|
||
"{\"secret\":\"MOON.*[LOOP]\"}\n",
|
||
)
|
||
.expect("write sensitive config file");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow project search");
|
||
|
||
let default_search = execute_game_creator_agent_runtime_tool_action(
|
||
&root,
|
||
"design-director",
|
||
"project-search-default-run",
|
||
"搜索字面文本",
|
||
&AgentRuntimeToolAction {
|
||
tool: "project.search".to_string(),
|
||
reason: Some("验证默认大小写不敏感的字面量搜索".to_string()),
|
||
input: serde_json::json!({ "query": "MOON.*[LOOP]" }),
|
||
},
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(default_search.status, "ok");
|
||
let default_detail = default_search.detail.as_deref().expect("search detail");
|
||
assert!(default_detail.contains("game/search-notes.txt:1: moon.*[loop]"));
|
||
assert!(default_detail.contains("assets/search-hints.txt:1: MOON.*[LOOP]"));
|
||
assert!(!default_detail.contains(".agent/private-search.txt"));
|
||
assert!(!default_detail.contains("SEARCH_SECRET"));
|
||
assert!(!default_detail.contains(GAME_CREATOR_CONFIG_FILE_NAME));
|
||
|
||
let scoped_search = execute_game_creator_agent_runtime_tool_action(
|
||
&root,
|
||
"design-director",
|
||
"project-search-scoped-run",
|
||
"限制搜索范围和结果数量",
|
||
&AgentRuntimeToolAction {
|
||
tool: "project.search".to_string(),
|
||
reason: Some("验证 path、maxResults 和 caseSensitive".to_string()),
|
||
input: serde_json::json!({
|
||
"query": "MOON.*[LOOP]",
|
||
"path": "game",
|
||
"maxResults": 1,
|
||
"caseSensitive": true
|
||
}),
|
||
},
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(scoped_search.status, "ok");
|
||
assert!(scoped_search.summary.contains("1 个匹配"));
|
||
let scoped_detail = scoped_search.detail.as_deref().expect("scoped detail");
|
||
assert!(scoped_detail.contains("game/search-notes.txt:2: MOON.*[LOOP]"));
|
||
assert!(!scoped_detail.contains("game/search-notes.txt:1:"));
|
||
assert!(!scoped_detail.contains("game/search-notes.txt:3:"));
|
||
assert!(!scoped_detail.contains("assets/search-hints.txt"));
|
||
assert!(scoped_detail.contains("结果已限制为前 1 条"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn agent_runtime_file_read_returns_numbered_slice_and_total_lines() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
fs::write(
|
||
root.join("game/read-window.txt"),
|
||
"alpha\nbeta\ngamma\ndelta\nepsilon\n",
|
||
)
|
||
.expect("write read window");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow file read");
|
||
|
||
let observation = execute_game_creator_agent_runtime_tool_action(
|
||
&root,
|
||
"design-director",
|
||
"file-read-window-run",
|
||
"读取文件中间片段",
|
||
&AgentRuntimeToolAction {
|
||
tool: "file.read".to_string(),
|
||
reason: Some("只读取第二到第三行".to_string()),
|
||
input: serde_json::json!({
|
||
"path": "game/read-window.txt",
|
||
"startLine": 2,
|
||
"maxLines": 2
|
||
}),
|
||
},
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(observation.status, "ok");
|
||
assert_eq!(
|
||
observation.summary,
|
||
"已读取 game/read-window.txt 第 2-3 行(共 5 行)"
|
||
);
|
||
let detail = observation.detail.as_deref().expect("file read detail");
|
||
let expected_sha256 = format!(
|
||
"{:x}",
|
||
Sha256::digest(b"alpha\nbeta\ngamma\ndelta\nepsilon\n")
|
||
);
|
||
assert!(detail.contains(&format!(
|
||
"game/read-window.txt · sha256={expected_sha256} · lines 2-3 of 5"
|
||
)));
|
||
assert!(detail.contains("2 | beta"));
|
||
assert!(detail.contains("3 | gamma"));
|
||
assert!(detail.contains("还有 2 行,可从 startLine=4 继续读取"));
|
||
assert!(!detail.contains("1 | alpha"));
|
||
assert!(!detail.contains("4 | delta"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn agent_runtime_project_patchset_applies_once_with_checkpoint_and_audit() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "多文件变更项目").expect("project init");
|
||
let existing_content = "const mode = 'old';\n";
|
||
fs::write(root.join("game/existing.js"), existing_content).expect("write existing file");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow patchset");
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"code-prototype",
|
||
"原子修改两个文件",
|
||
"code-patchset-success-run",
|
||
"agent-background-task",
|
||
"准备执行多文件变更",
|
||
vec!["更新现有文件并创建模块".to_string()],
|
||
)
|
||
.expect("start patchset runtime");
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "project.patchset".to_string(),
|
||
reason: Some("一次提交完整代码变更".to_string()),
|
||
input: serde_json::json!({
|
||
"changes": [
|
||
{
|
||
"operation": "update",
|
||
"path": "game/existing.js",
|
||
"expectedSha256": format!("{:x}", Sha256::digest(existing_content.as_bytes())),
|
||
"oldText": "'old'",
|
||
"newText": "'ready'",
|
||
"expectedReplacements": 1
|
||
},
|
||
{
|
||
"operation": "create",
|
||
"path": "game/new-module.js",
|
||
"content": "export const ready = true;\n"
|
||
}
|
||
]
|
||
}),
|
||
};
|
||
let mut pending = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
action.clone(),
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED,
|
||
None,
|
||
);
|
||
pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string();
|
||
|
||
let observation = execute_game_creator_agent_runtime_tool_action_with_pending_action(
|
||
&root,
|
||
"code-prototype",
|
||
&state.run_id,
|
||
&state.current_task,
|
||
&action,
|
||
Some(&pending.action_id),
|
||
Some(&pending),
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(observation.status, "ok");
|
||
assert!(observation.summary.contains("2 项变更"));
|
||
assert!(agent_runtime_observation_advances_project_revision(
|
||
&observation
|
||
));
|
||
assert!(is_agent_runtime_project_mutation_observation(&observation));
|
||
assert_eq!(
|
||
fs::read_to_string(root.join("game/existing.js")).expect("read updated file"),
|
||
"const mode = 'ready';\n"
|
||
);
|
||
assert_eq!(
|
||
fs::read_to_string(root.join("game/new-module.js")).expect("read created file"),
|
||
"export const ready = true;\n"
|
||
);
|
||
let revision =
|
||
read_game_creator_agent_runtime_project_revision(&root).expect("read patchset revision");
|
||
assert_eq!(revision.revision, 1);
|
||
let gate =
|
||
read_game_creator_agent_runtime_verification_gate(&root, "code-prototype", &state.run_id)
|
||
.expect("read patchset verification gate");
|
||
assert_eq!(gate.mutation_revision, Some(1));
|
||
assert_eq!(gate.last_mutation_tool.as_deref(), Some("project.patchset"));
|
||
assert_eq!(gate.verified_revision, None);
|
||
|
||
let records = read_agent_db_records_for_test(&root);
|
||
let prepared = records
|
||
.iter()
|
||
.filter(|record| record["recordType"] == "agent.runtime.project.patchset.prepared")
|
||
.collect::<Vec<_>>();
|
||
let completed = records
|
||
.iter()
|
||
.filter(|record| record["recordType"] == "agent.runtime.project.patchset.completed")
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(prepared.len(), 1);
|
||
assert_eq!(completed.len(), 1);
|
||
for record in [prepared[0], completed[0]] {
|
||
assert_eq!(record["actionId"], pending.action_id);
|
||
assert_eq!(record["actionFingerprint"], pending.action_fingerprint);
|
||
assert_eq!(record["changeCount"], 2);
|
||
assert_eq!(record["changes"].as_array().map(Vec::len), Some(2));
|
||
let serialized = serde_json::to_string(record).expect("serialize patchset audit");
|
||
assert!(!serialized.contains("const mode"));
|
||
assert!(!serialized.contains("export const ready"));
|
||
}
|
||
assert_eq!(completed[0]["revisionBefore"], 0);
|
||
assert_eq!(completed[0]["revisionAfter"], 1);
|
||
let checkpoint_id = completed[0]["checkpointId"]
|
||
.as_str()
|
||
.expect("patchset checkpoint id");
|
||
assert_eq!(prepared[0]["checkpointId"], checkpoint_id);
|
||
let diff = diff_local_project_checkpoint_content_at(&root, checkpoint_id, 10, 10_000)
|
||
.expect("diff patchset checkpoint");
|
||
assert_eq!(diff.file_count, 2);
|
||
assert!(diff.content.contains("game/existing.js"));
|
||
assert!(diff.content.contains("game/new-module.js"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn agent_runtime_project_patchset_requires_confirmation_by_default() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "默认确认变更项目").expect("project init");
|
||
let policy = ProjectPermissionPolicy::default();
|
||
assert!(policy
|
||
.confirm_commands
|
||
.contains(&"project.patchset".to_string()));
|
||
write_project_permission_policy_at(&root, policy).expect("write default policy");
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "project.patchset".to_string(),
|
||
reason: Some("验证多文件变更默认需确认".to_string()),
|
||
input: serde_json::json!({
|
||
"changes": [{
|
||
"operation": "create",
|
||
"path": "game/unconfirmed.js",
|
||
"content": "must wait\n"
|
||
}]
|
||
}),
|
||
};
|
||
|
||
let observation = execute_game_creator_agent_runtime_tool_action(
|
||
&root,
|
||
"code-prototype",
|
||
"code-patchset-default-confirm-run",
|
||
"尝试执行尚未确认的多文件变更",
|
||
&action,
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(observation.status, "waiting-for-confirmation");
|
||
assert!(observation.summary.contains("要求用户确认"));
|
||
assert!(!root.join("game/unconfirmed.js").exists());
|
||
assert_eq!(
|
||
read_game_creator_agent_runtime_project_revision(&root)
|
||
.expect("read unchanged revision")
|
||
.revision,
|
||
0
|
||
);
|
||
assert!(!root.join(".agent/checkpoints").exists());
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_project_patchset_rechecks_repository_fingerprint_inside_project_lock() {
|
||
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");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow patchset");
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"code-prototype",
|
||
"验证锁内仓库规范漂移",
|
||
"code-patchset-lock-fingerprint-run",
|
||
"agent-background-task",
|
||
"准备执行多文件变更",
|
||
vec!["锁内复核 repository fingerprint".to_string()],
|
||
)
|
||
.expect("start patchset runtime");
|
||
persist_game_creator_agent_runtime_context(
|
||
&root,
|
||
&state,
|
||
&state.current_task,
|
||
&AgentRuntimeToolPlan::default(),
|
||
&[],
|
||
0,
|
||
&AgentRuntimeContextWindowTracker::default(),
|
||
)
|
||
.expect("persist baseline repository context");
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "project.patchset".to_string(),
|
||
reason: Some("基于旧规范创建文件".to_string()),
|
||
input: serde_json::json!({
|
||
"changes": [{
|
||
"operation": "create",
|
||
"path": "game/stale-rules.js",
|
||
"content": "must not land\n"
|
||
}]
|
||
}),
|
||
};
|
||
let mut pending = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
action.clone(),
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED,
|
||
None,
|
||
);
|
||
pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string();
|
||
fs::write(root.join("AGENTS.md"), "drifted rules\n").expect("drift repository rules");
|
||
|
||
let observation = observe_agent_runtime_project_patchset_with_audit(
|
||
&root,
|
||
"code-prototype",
|
||
&state.run_id,
|
||
Some(&pending.action_id),
|
||
&pending.action_fingerprint,
|
||
Some(&pending),
|
||
&action.input,
|
||
append_agent_db_record,
|
||
);
|
||
|
||
assert_eq!(observation.status, "blocked");
|
||
assert!(observation.summary.contains("启动上下文已漂移"));
|
||
assert!(!root.join("game/stale-rules.js").exists());
|
||
assert!(!root.join(".agent/checkpoints").exists());
|
||
assert_eq!(
|
||
read_game_creator_agent_runtime_project_revision(&root)
|
||
.expect("read unchanged revision")
|
||
.revision,
|
||
0
|
||
);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_project_patchset_prepared_audit_failure_writes_no_source() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "变更预审失败项目").expect("project init");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow patchset");
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"code-prototype",
|
||
"验证 prepared 审计先于源码",
|
||
"code-patchset-prepared-audit-run",
|
||
"agent-background-task",
|
||
"准备执行多文件变更",
|
||
vec!["创建两个文件".to_string()],
|
||
)
|
||
.expect("start patchset runtime");
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "project.patchset".to_string(),
|
||
reason: Some("模拟 prepared 审计失败".to_string()),
|
||
input: serde_json::json!({
|
||
"changes": [{
|
||
"operation": "create",
|
||
"path": "game/prepared-must-not-exist.js",
|
||
"content": "must not be written\n"
|
||
}]
|
||
}),
|
||
};
|
||
let mut pending = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
action.clone(),
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED,
|
||
None,
|
||
);
|
||
pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string();
|
||
|
||
let observation = observe_agent_runtime_project_patchset_with_audit(
|
||
&root,
|
||
"code-prototype",
|
||
&state.run_id,
|
||
Some(&pending.action_id),
|
||
&pending.action_fingerprint,
|
||
Some(&pending),
|
||
&action.input,
|
||
|_root, _record| Err("prepared audit unavailable".to_string()),
|
||
);
|
||
|
||
assert_eq!(observation.status, "failed");
|
||
assert!(observation.summary.contains("prepared 审计失败"));
|
||
assert!(!root.join("game/prepared-must-not-exist.js").exists());
|
||
assert_eq!(
|
||
read_game_creator_agent_runtime_project_revision(&root)
|
||
.expect("read unchanged revision")
|
||
.revision,
|
||
0
|
||
);
|
||
assert!(root.join(".agent/checkpoints").is_dir());
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_project_patchset_completed_audit_failure_requires_reconciliation() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "变更完成审计失败项目").expect("project init");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow patchset");
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"code-prototype",
|
||
"验证 completed 审计失败语义",
|
||
"code-patchset-completed-audit-run",
|
||
"agent-background-task",
|
||
"准备执行多文件变更",
|
||
vec!["创建目标文件".to_string()],
|
||
)
|
||
.expect("start patchset runtime");
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "project.patchset".to_string(),
|
||
reason: Some("模拟 completed 审计失败".to_string()),
|
||
input: serde_json::json!({
|
||
"changes": [{
|
||
"operation": "create",
|
||
"path": "game/completed-applied.js",
|
||
"content": "side effect applied\n"
|
||
}]
|
||
}),
|
||
};
|
||
let mut pending = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
action.clone(),
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED,
|
||
None,
|
||
);
|
||
pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string();
|
||
let mut audit_index = 0_usize;
|
||
|
||
let observation = observe_agent_runtime_project_patchset_with_audit(
|
||
&root,
|
||
"code-prototype",
|
||
&state.run_id,
|
||
Some(&pending.action_id),
|
||
&pending.action_fingerprint,
|
||
Some(&pending),
|
||
&action.input,
|
||
|root, record| {
|
||
audit_index += 1;
|
||
if audit_index == 2 {
|
||
Err("completed audit unavailable".to_string())
|
||
} else {
|
||
append_agent_db_record(root, record)
|
||
}
|
||
},
|
||
);
|
||
|
||
assert_eq!(observation.status, "needs-reconciliation");
|
||
assert!(observation.summary.contains("completed 审计"));
|
||
assert!(observation
|
||
.detail
|
||
.as_deref()
|
||
.is_some_and(|detail| detail.starts_with("revisionAdvanced=true")));
|
||
assert_eq!(
|
||
fs::read_to_string(root.join("game/completed-applied.js")).expect("read applied file"),
|
||
"side effect applied\n"
|
||
);
|
||
assert_eq!(
|
||
read_game_creator_agent_runtime_project_revision(&root)
|
||
.expect("read advanced revision")
|
||
.revision,
|
||
1
|
||
);
|
||
let records = read_agent_db_records_for_test(&root);
|
||
assert_eq!(
|
||
records
|
||
.iter()
|
||
.filter(|record| { record["recordType"] == "agent.runtime.project.patchset.prepared" })
|
||
.count(),
|
||
1
|
||
);
|
||
assert!(!records
|
||
.iter()
|
||
.any(|record| { record["recordType"] == "agent.runtime.project.patchset.completed" }));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn agent_runtime_file_patch_replaces_exact_count_and_writes_audit() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
fs::write(
|
||
root.join("game/patch-notes.txt"),
|
||
"mode = draft\nreward = draft\n",
|
||
)
|
||
.expect("write patch target");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow file patch");
|
||
|
||
let observation = execute_game_creator_agent_runtime_tool_action(
|
||
&root,
|
||
"design-director",
|
||
"file-patch-success-run",
|
||
"精确更新草稿状态",
|
||
&AgentRuntimeToolAction {
|
||
tool: "file.patch".to_string(),
|
||
reason: Some("只替换两个完全相同的 draft 字面文本".to_string()),
|
||
input: serde_json::json!({
|
||
"path": "game/patch-notes.txt",
|
||
"oldText": "draft",
|
||
"newText": "ready",
|
||
"expectedReplacements": 2
|
||
}),
|
||
},
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(observation.status, "ok");
|
||
assert_eq!(
|
||
fs::read_to_string(root.join("game/patch-notes.txt")).expect("patched file"),
|
||
"mode = ready\nreward = ready\n"
|
||
);
|
||
let patch_records = read_agent_db_records_for_test(&root)
|
||
.into_iter()
|
||
.filter(|record| record["recordType"] == "agent.runtime.file.patch")
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(patch_records.len(), 1);
|
||
assert_eq!(patch_records[0]["agentId"], "design-director");
|
||
assert_eq!(patch_records[0]["path"], "game/patch-notes.txt");
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn agent_runtime_file_patch_preserves_file_when_match_count_differs() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
let original = "draft\ndraft\n";
|
||
fs::write(root.join("game/patch-mismatch.txt"), original).expect("write patch target");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow file patch");
|
||
|
||
let observation = execute_game_creator_agent_runtime_tool_action(
|
||
&root,
|
||
"design-director",
|
||
"file-patch-mismatch-run",
|
||
"拒绝不确定的补丁",
|
||
&AgentRuntimeToolAction {
|
||
tool: "file.patch".to_string(),
|
||
reason: Some("声明只应存在一个匹配".to_string()),
|
||
input: serde_json::json!({
|
||
"path": "game/patch-mismatch.txt",
|
||
"oldText": "draft",
|
||
"newText": "ready",
|
||
"expectedReplacements": 1
|
||
}),
|
||
},
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(observation.status, "failed");
|
||
assert!(observation.summary.contains('1'));
|
||
assert!(observation.summary.contains('2'));
|
||
assert_eq!(
|
||
fs::read_to_string(root.join("game/patch-mismatch.txt")).expect("unchanged file"),
|
||
original
|
||
);
|
||
assert!(!read_agent_db_records_for_test(&root)
|
||
.iter()
|
||
.any(|record| record["recordType"] == "agent.runtime.file.patch"));
|
||
assert_eq!(
|
||
read_game_creator_agent_runtime_project_revision(&root)
|
||
.expect("read conservative failed-patch revision")
|
||
.revision,
|
||
1
|
||
);
|
||
let gate = read_game_creator_agent_runtime_verification_gate(
|
||
&root,
|
||
"design-director",
|
||
"file-patch-mismatch-run",
|
||
)
|
||
.expect("read conservative failed-patch gate");
|
||
assert!(gate.requires_verification);
|
||
assert_eq!(gate.mutation_revision, Some(1));
|
||
assert_eq!(gate.verified_revision, None);
|
||
assert!(project_verification_completion_blocker_at(
|
||
&root,
|
||
"design-director",
|
||
"file-patch-mismatch-run",
|
||
&[observation],
|
||
)
|
||
.is_some());
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn agent_runtime_file_delete_is_idempotent_when_target_is_already_missing() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow idempotent file delete");
|
||
assert!(!root.join("game/already-missing.txt").exists());
|
||
|
||
let observation = execute_agent_runtime_file_delete_for_test(
|
||
&root,
|
||
"file-delete-already-missing-run",
|
||
Some("game/already-missing.txt"),
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(observation.tool, "file.delete");
|
||
assert_eq!(observation.status, "ok");
|
||
assert_eq!(
|
||
observation.summary,
|
||
"目标文件已不存在:game/already-missing.txt"
|
||
);
|
||
assert_eq!(observation.detail.as_deref(), Some("deleted=false"));
|
||
let delete_records = read_agent_db_records_for_test(&root)
|
||
.into_iter()
|
||
.filter(|record| record["recordType"] == "agent.runtime.file.delete")
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(delete_records.len(), 1);
|
||
assert_eq!(delete_records[0]["agentId"], "design-director");
|
||
assert_eq!(delete_records[0]["path"], "game/already-missing.txt");
|
||
assert_eq!(delete_records[0]["deleted"], false);
|
||
assert_eq!(
|
||
read_game_creator_agent_runtime_project_revision(&root)
|
||
.expect("read idempotent delete revision")
|
||
.revision,
|
||
1
|
||
);
|
||
let gate = read_game_creator_agent_runtime_verification_gate(
|
||
&root,
|
||
"design-director",
|
||
"file-delete-already-missing-run",
|
||
)
|
||
.expect("read idempotent delete verification gate");
|
||
assert!(gate.requires_verification);
|
||
assert_eq!(gate.mutation_revision, Some(1));
|
||
assert_eq!(gate.verified_revision, None);
|
||
assert_eq!(gate.last_mutation_tool.as_deref(), Some("file.delete"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn agent_runtime_file_delete_uses_independent_permission_policy() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
let target = root.join("game/policy-delete-target.txt");
|
||
fs::write(&target, "权限测试文件\n").expect("write policy target");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: vec!["file.write".to_string()],
|
||
confirm_commands: vec!["file.delete".to_string()],
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("require file delete confirmation");
|
||
|
||
let waiting = execute_agent_runtime_file_delete_for_test(
|
||
&root,
|
||
"file-delete-confirm-policy-run",
|
||
Some("game/policy-delete-target.txt"),
|
||
)
|
||
.await;
|
||
assert_eq!(waiting.status, "waiting-for-confirmation");
|
||
assert!(waiting.summary.contains("file.delete"));
|
||
assert!(!waiting.summary.contains("file.write"));
|
||
assert!(target.exists());
|
||
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: vec!["file.delete".to_string()],
|
||
confirm_commands: vec!["file.write".to_string()],
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("deny file delete");
|
||
let blocked = execute_agent_runtime_file_delete_for_test(
|
||
&root,
|
||
"file-delete-deny-policy-run",
|
||
Some("game/policy-delete-target.txt"),
|
||
)
|
||
.await;
|
||
assert_eq!(blocked.status, "blocked");
|
||
assert!(blocked.summary.contains("file.delete"));
|
||
assert!(!blocked.summary.contains("file.write"));
|
||
assert!(target.exists());
|
||
assert_eq!(
|
||
read_game_creator_agent_runtime_project_revision(&root)
|
||
.expect("read policy-blocked revision")
|
||
.revision,
|
||
0
|
||
);
|
||
assert!(!read_agent_db_records_for_test(&root)
|
||
.iter()
|
||
.any(|record| record["recordType"] == "agent.runtime.file.delete"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_file_delete_rechecks_permission_policy_after_project_lock() {
|
||
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",
|
||
"删除废弃说明",
|
||
"file-delete-policy-recheck-run",
|
||
"agent-background-task",
|
||
"准备删除文件",
|
||
vec!["删除废弃说明".to_string()],
|
||
)
|
||
.expect("runtime state");
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "file.delete".to_string(),
|
||
reason: Some("删除废弃说明".to_string()),
|
||
input: serde_json::json!({ "path": "game/obsolete.txt" }),
|
||
};
|
||
let mut pending = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
action,
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING,
|
||
None,
|
||
);
|
||
pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string();
|
||
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: vec!["file.delete".to_string()],
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("deny file delete while action waits for project lock");
|
||
assert!(matches!(
|
||
game_creator_agent_runtime_tool_policy_block_after_lock(
|
||
&root,
|
||
"design-director",
|
||
"file.delete",
|
||
Some(&pending),
|
||
),
|
||
Some(AgentRuntimeToolPolicyBlock::Denied(_))
|
||
));
|
||
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: vec!["file.delete".to_string()],
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("require confirmation while automatic action waits for project lock");
|
||
assert!(matches!(
|
||
game_creator_agent_runtime_tool_policy_block_after_lock(
|
||
&root,
|
||
"design-director",
|
||
"file.delete",
|
||
Some(&pending),
|
||
),
|
||
Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_))
|
||
));
|
||
|
||
pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION.to_string();
|
||
assert!(game_creator_agent_runtime_tool_policy_block_after_lock(
|
||
&root,
|
||
"design-director",
|
||
"file.delete",
|
||
Some(&pending),
|
||
)
|
||
.is_none());
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn agent_runtime_file_delete_rejects_unsafe_and_agent_control_paths() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
let directory = root.join("game/delete-protected-dir");
|
||
fs::create_dir_all(&directory).expect("create directory target");
|
||
fs::write(directory.join("keep.txt"), "目录内容必须保留\n").expect("write directory file");
|
||
let backslash_target = root.join("game/backslash-target.txt");
|
||
fs::write(&backslash_target, "反斜杠路径不能删除这个文件\n").expect("write backslash target");
|
||
let outside_target = unique_project_path().with_extension("txt");
|
||
fs::write(&outside_target, "项目外文件必须保留\n").expect("write outside target");
|
||
let parent_traversal_path = format!(
|
||
"../{}",
|
||
outside_target
|
||
.file_name()
|
||
.expect("outside target file name")
|
||
.to_string_lossy()
|
||
);
|
||
let absolute_path = outside_target.to_string_lossy().into_owned();
|
||
let private_target = root.join(".agent/runtime/delete-protected.json");
|
||
fs::create_dir_all(private_target.parent().expect("runtime private parent"))
|
||
.expect("create runtime private dir");
|
||
fs::write(&private_target, r#"{"state":"must-survive"}"#)
|
||
.expect("write runtime private target");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow file delete validation");
|
||
let policy_target = root.join(".agent/policy.json");
|
||
let policy_before = fs::read(&policy_target).expect("read protected policy");
|
||
let manifest_target = root.join(".agent/manifest.json");
|
||
let manifest_before = fs::read(&manifest_target).expect("read protected manifest");
|
||
let agent_db_target = root.join(".agent/agent.db");
|
||
let agent_db_marker = r#"{"recordType":"test.file.delete.control-marker"}"#;
|
||
let mut agent_db = fs::OpenOptions::new()
|
||
.create(true)
|
||
.append(true)
|
||
.open(&agent_db_target)
|
||
.expect("open protected agent db");
|
||
writeln!(agent_db, "{agent_db_marker}").expect("append agent db marker");
|
||
drop(agent_db);
|
||
|
||
let missing =
|
||
execute_agent_runtime_file_delete_for_test(&root, "file-delete-missing-path-run", None)
|
||
.await;
|
||
assert_eq!(missing.status, "failed");
|
||
assert_eq!(missing.summary, "缺少 path");
|
||
assert_eq!(
|
||
read_game_creator_agent_runtime_project_revision(&root)
|
||
.expect("read missing-path revision")
|
||
.revision,
|
||
0
|
||
);
|
||
assert!(!game_creator_agent_runtime_verification_gate_path(
|
||
&root,
|
||
"design-director",
|
||
"file-delete-missing-path-run"
|
||
)
|
||
.exists());
|
||
|
||
for (run_id, path, expected_error) in [
|
||
(
|
||
"file-delete-absolute-path-run",
|
||
absolute_path.as_str(),
|
||
"不能是绝对路径",
|
||
),
|
||
(
|
||
"file-delete-parent-traversal-run",
|
||
parent_traversal_path.as_str(),
|
||
"项目文件路径非法",
|
||
),
|
||
(
|
||
"file-delete-backslash-run",
|
||
"game\\backslash-target.txt",
|
||
"不能包含反斜杠",
|
||
),
|
||
] {
|
||
let observation =
|
||
execute_agent_runtime_file_delete_for_test(&root, run_id, Some(path)).await;
|
||
assert_eq!(observation.status, "failed", "{path}");
|
||
assert!(observation.summary.contains(expected_error), "{path}");
|
||
assert_eq!(
|
||
fs::read_to_string(&outside_target).expect("read preserved outside target"),
|
||
"项目外文件必须保留\n"
|
||
);
|
||
assert_eq!(
|
||
fs::read_to_string(&backslash_target).expect("read preserved backslash target"),
|
||
"反斜杠路径不能删除这个文件\n"
|
||
);
|
||
}
|
||
|
||
let directory_observation = execute_agent_runtime_file_delete_for_test(
|
||
&root,
|
||
"file-delete-directory-run",
|
||
Some("game/delete-protected-dir"),
|
||
)
|
||
.await;
|
||
assert_eq!(directory_observation.status, "failed");
|
||
assert!(directory_observation.summary.contains("只能删除文件"));
|
||
assert!(directory.join("keep.txt").exists());
|
||
|
||
for (run_id, path, expected_error) in [
|
||
(
|
||
"file-delete-runtime-private-run",
|
||
".agent/runtime/delete-protected.json",
|
||
"Runtime 私有控制面",
|
||
),
|
||
(
|
||
"file-delete-agent-db-run",
|
||
".agent/agent.db",
|
||
"Agent 控制面",
|
||
),
|
||
(
|
||
"file-delete-policy-run",
|
||
".agent/policy.json",
|
||
"Agent 控制面",
|
||
),
|
||
(
|
||
"file-delete-manifest-run",
|
||
".agent/manifest.json",
|
||
"Agent 控制面",
|
||
),
|
||
] {
|
||
let observation =
|
||
execute_agent_runtime_file_delete_for_test(&root, run_id, Some(path)).await;
|
||
assert_eq!(observation.status, "failed", "{path}");
|
||
assert!(observation.summary.contains(expected_error), "{path}");
|
||
}
|
||
assert!(private_target.exists());
|
||
assert_eq!(
|
||
fs::read_to_string(&private_target).expect("read protected runtime target"),
|
||
r#"{"state":"must-survive"}"#
|
||
);
|
||
assert!(fs::read_to_string(&agent_db_target)
|
||
.expect("read protected agent db")
|
||
.contains(agent_db_marker));
|
||
assert_eq!(
|
||
fs::read(&policy_target).expect("read protected policy after delete attempts"),
|
||
policy_before
|
||
);
|
||
assert_eq!(
|
||
fs::read(&manifest_target).expect("read protected manifest after delete attempts"),
|
||
manifest_before
|
||
);
|
||
|
||
let project_lock_target = root.join(".agent/project.lock");
|
||
fs::write(&project_lock_target, "project lock marker").expect("write protected project lock");
|
||
let project_lock_error = delete_local_project_file_at(&root, ".agent/project.lock")
|
||
.expect_err("file.delete must reject the project lock itself");
|
||
assert!(project_lock_error.contains("Agent 控制面"));
|
||
assert_eq!(
|
||
fs::read_to_string(&project_lock_target).expect("read protected project lock"),
|
||
"project lock marker"
|
||
);
|
||
fs::remove_file(&project_lock_target).expect("remove project lock fixture");
|
||
|
||
assert_eq!(
|
||
read_game_creator_agent_runtime_project_revision(&root)
|
||
.expect("read conservative delete revision")
|
||
.revision,
|
||
4
|
||
);
|
||
let directory_gate = read_game_creator_agent_runtime_verification_gate(
|
||
&root,
|
||
"design-director",
|
||
"file-delete-directory-run",
|
||
)
|
||
.expect("read directory delete gate");
|
||
let private_gate = read_game_creator_agent_runtime_verification_gate(
|
||
&root,
|
||
"design-director",
|
||
"file-delete-runtime-private-run",
|
||
)
|
||
.expect("read runtime private delete gate");
|
||
assert!(directory_gate.requires_verification);
|
||
assert_eq!(directory_gate.mutation_revision, Some(1));
|
||
assert_eq!(
|
||
directory_gate.last_mutation_tool.as_deref(),
|
||
Some("file.delete")
|
||
);
|
||
assert!(!private_gate.requires_verification);
|
||
assert_eq!(private_gate.mutation_revision, None);
|
||
assert_eq!(private_gate.last_mutation_tool, None);
|
||
assert!(!read_agent_db_records_for_test(&root)
|
||
.iter()
|
||
.any(|record| record["recordType"] == "agent.runtime.file.delete"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
fs::remove_file(outside_target).ok();
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[tokio::test]
|
||
async fn agent_runtime_file_delete_rejects_live_and_dangling_symlinks() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow file delete symlink validation");
|
||
|
||
let outside_target = unique_project_path().with_extension("txt");
|
||
fs::write(&outside_target, "符号链接外目标必须保留\n").expect("write symlink outside target");
|
||
let live_link = root.join("game/live-delete-link.txt");
|
||
symlink(&outside_target, &live_link).expect("create live symlink");
|
||
|
||
let dangling_target = unique_project_path().with_extension("missing");
|
||
assert!(!dangling_target.exists());
|
||
let dangling_link = root.join("game/dangling-delete-link.txt");
|
||
symlink(&dangling_target, &dangling_link).expect("create dangling symlink");
|
||
|
||
for (run_id, path) in [
|
||
("file-delete-live-symlink-run", "game/live-delete-link.txt"),
|
||
(
|
||
"file-delete-dangling-symlink-run",
|
||
"game/dangling-delete-link.txt",
|
||
),
|
||
] {
|
||
let observation =
|
||
execute_agent_runtime_file_delete_for_test(&root, run_id, Some(path)).await;
|
||
assert_eq!(observation.status, "failed", "{path}");
|
||
assert!(observation.summary.contains("符号链接"), "{path}");
|
||
}
|
||
|
||
assert!(fs::symlink_metadata(&live_link)
|
||
.expect("live link metadata")
|
||
.file_type()
|
||
.is_symlink());
|
||
assert_eq!(
|
||
fs::read_to_string(&outside_target).expect("read preserved symlink target"),
|
||
"符号链接外目标必须保留\n"
|
||
);
|
||
assert!(fs::symlink_metadata(&dangling_link)
|
||
.expect("dangling link metadata")
|
||
.file_type()
|
||
.is_symlink());
|
||
assert!(!dangling_target.exists());
|
||
assert!(!read_agent_db_records_for_test(&root)
|
||
.iter()
|
||
.any(|record| record["recordType"] == "agent.runtime.file.delete"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
fs::remove_file(outside_target).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn agent_runtime_search_and_patch_inherit_file_read_write_policy() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
fs::write(root.join("game/policy-notes.txt"), "mode = draft\n").expect("write policy target");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: vec!["file.write".to_string()],
|
||
confirm_commands: vec!["file.read".to_string()],
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("write inherited policy");
|
||
|
||
let runtime = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"检查搜索和补丁策略",
|
||
"search-patch-policy-run",
|
||
"agent-chat",
|
||
"读取工具策略",
|
||
vec!["核对继承关系".to_string()],
|
||
)
|
||
.expect("start runtime");
|
||
assert!(runtime
|
||
.tool_policy
|
||
.confirm_tools
|
||
.contains(&"file.read".to_string()));
|
||
assert!(runtime
|
||
.tool_policy
|
||
.confirm_tools
|
||
.contains(&"project.search".to_string()));
|
||
assert!(runtime
|
||
.tool_policy
|
||
.denied_tools
|
||
.contains(&"file.write".to_string()));
|
||
assert!(runtime
|
||
.tool_policy
|
||
.denied_tools
|
||
.contains(&"file.patch".to_string()));
|
||
|
||
let search = execute_game_creator_agent_runtime_tool_action(
|
||
&root,
|
||
"design-director",
|
||
"search-policy-direct-run",
|
||
"搜索项目文件",
|
||
&AgentRuntimeToolAction {
|
||
tool: "project.search".to_string(),
|
||
reason: Some("验证继承 file.read 确认策略".to_string()),
|
||
input: serde_json::json!({ "query": "mode = draft" }),
|
||
},
|
||
)
|
||
.await;
|
||
assert_eq!(search.status, "waiting-for-confirmation");
|
||
assert!(search.summary.contains("file.read"));
|
||
|
||
let patch = execute_game_creator_agent_runtime_tool_action(
|
||
&root,
|
||
"design-director",
|
||
"patch-policy-direct-run",
|
||
"修改项目文件",
|
||
&AgentRuntimeToolAction {
|
||
tool: "file.patch".to_string(),
|
||
reason: Some("验证继承 file.write 拒绝策略".to_string()),
|
||
input: serde_json::json!({
|
||
"path": "game/policy-notes.txt",
|
||
"oldText": "draft",
|
||
"newText": "ready",
|
||
"expectedReplacements": 1
|
||
}),
|
||
},
|
||
)
|
||
.await;
|
||
assert_eq!(patch.status, "blocked");
|
||
assert!(patch.summary.contains("file.write"));
|
||
assert_eq!(
|
||
fs::read_to_string(root.join("game/policy-notes.txt")).expect("unchanged policy target"),
|
||
"mode = draft\n"
|
||
);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn tool_plan_handoff_real_e2e_checkpoint_ack_publish_is_complete_and_noreplace() {
|
||
let fixture =
|
||
RealE2eToolPlanCheckpointFixture::new("tool-plan-checkpoint-atomic-ack-run", 5_000);
|
||
let ack_bytes = br#"{"schemaVersion":"atomic-ack-test","complete":true}"#;
|
||
let mut before_publish = false;
|
||
let mut after_publish = false;
|
||
let persisted = publish_game_creator_agent_runtime_tool_plan_checkpoint_ack_for_test(
|
||
&fixture.config_dir,
|
||
ack_bytes,
|
||
|phase, temporary_path, final_path| match phase {
|
||
AgentRuntimeRealE2eAckPublishPhase::BeforePublish => {
|
||
before_publish = true;
|
||
assert!(!final_path.exists());
|
||
assert_eq!(
|
||
fs::read(temporary_path).expect("read complete ACK temp"),
|
||
ack_bytes
|
||
);
|
||
assert_eq!(
|
||
real_e2e_tool_plan_checkpoint_temp_paths(&fixture.config_dir),
|
||
vec![temporary_path.to_path_buf()]
|
||
);
|
||
}
|
||
AgentRuntimeRealE2eAckPublishPhase::AfterPublish => {
|
||
after_publish = true;
|
||
assert!(!temporary_path.exists());
|
||
assert_eq!(fs::read(final_path).expect("read published ACK"), ack_bytes);
|
||
assert!(real_e2e_tool_plan_checkpoint_temp_paths(&fixture.config_dir).is_empty());
|
||
}
|
||
},
|
||
)
|
||
.expect("atomically publish complete ACK");
|
||
assert!(before_publish && after_publish);
|
||
assert_eq!(persisted, ack_bytes);
|
||
fixture.cleanup();
|
||
|
||
let competing =
|
||
RealE2eToolPlanCheckpointFixture::new("tool-plan-checkpoint-existing-ack-run", 5_000);
|
||
let existing_ack = serde_json::json!({"existing": "ACK must win"});
|
||
let existing_bytes = serde_json::to_vec(&existing_ack).expect("serialize existing ACK");
|
||
let mut injected_existing = false;
|
||
let error = publish_game_creator_agent_runtime_tool_plan_checkpoint_ack_for_test(
|
||
&competing.config_dir,
|
||
br#"{"candidate":"must not replace"}"#,
|
||
|phase, temporary_path, final_path| {
|
||
if phase == AgentRuntimeRealE2eAckPublishPhase::BeforePublish {
|
||
injected_existing = true;
|
||
assert_eq!(
|
||
fs::read(temporary_path).expect("read competing ACK temp"),
|
||
br#"{"candidate":"must not replace"}"#
|
||
);
|
||
write_real_e2e_tool_plan_checkpoint_json(final_path, &existing_ack, 0o600);
|
||
}
|
||
},
|
||
)
|
||
.expect_err("atomic ACK publish must not replace a competing final");
|
||
assert!(injected_existing);
|
||
assert!(error.contains("checkpoint-needs-reconciliation"));
|
||
assert_eq!(
|
||
fs::read(competing.ack_path()).expect("read preserved existing ACK"),
|
||
existing_bytes
|
||
);
|
||
assert!(real_e2e_tool_plan_checkpoint_temp_paths(&competing.config_dir).is_empty());
|
||
competing.cleanup();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn tool_plan_handoff_real_e2e_checkpoint_is_disabled_without_control() {
|
||
let fixture = RealE2eToolPlanCheckpointFixture::new("tool-plan-checkpoint-disabled-run", 5_000);
|
||
let response = await_game_creator_agent_runtime_tool_plan_checkpoint_for_test(
|
||
&fixture.root,
|
||
fixture.snapshot.clone(),
|
||
real_e2e_tool_plan_checkpoint_response(),
|
||
)
|
||
.await
|
||
.expect("missing control must not alter Provider completion")
|
||
.expect("Provider response must remain available");
|
||
assert_eq!(
|
||
response.text,
|
||
REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE
|
||
);
|
||
assert!(!fixture.ack_path().exists());
|
||
let lifecycle = read_agent_db_records_for_test(&fixture.root)
|
||
.into_iter()
|
||
.filter(|record| {
|
||
record["recordType"] == "agent.runtime.provider_request.lifecycle"
|
||
&& record["runId"] == fixture.state.run_id
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(lifecycle.len(), 2);
|
||
assert_eq!(lifecycle[0]["status"], "started");
|
||
assert_eq!(lifecycle[1]["status"], "completed");
|
||
fixture.cleanup();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn tool_plan_handoff_real_e2e_checkpoint_writes_private_ack_then_fails_on_delete() {
|
||
let fixture = RealE2eToolPlanCheckpointFixture::new("tool-plan-checkpoint-delete-run", 5_000);
|
||
fixture.write_control(0o600);
|
||
let root = fixture.root.clone();
|
||
let snapshot = fixture.snapshot.clone();
|
||
let mut checkpoint = tokio::spawn(async move {
|
||
await_game_creator_agent_runtime_tool_plan_checkpoint_for_test(
|
||
&root,
|
||
snapshot,
|
||
real_e2e_tool_plan_checkpoint_response(),
|
||
)
|
||
.await
|
||
});
|
||
|
||
let ack_content = wait_for_real_e2e_tool_plan_checkpoint_ack(&fixture.ack_path()).await;
|
||
let ack = serde_json::from_str::<Value>(&ack_content).expect("parse checkpoint ack");
|
||
let ack_keys = ack
|
||
.as_object()
|
||
.expect("checkpoint ack object")
|
||
.keys()
|
||
.cloned()
|
||
.collect::<BTreeSet<_>>();
|
||
assert_eq!(
|
||
ack_keys,
|
||
[
|
||
"schemaVersion",
|
||
"capabilitySha256",
|
||
"projectRootSha256",
|
||
"agentId",
|
||
"runId",
|
||
"requestSlot",
|
||
"providerRequestIdSha256",
|
||
"responseFingerprint",
|
||
"reachedAtMs",
|
||
]
|
||
.into_iter()
|
||
.map(str::to_string)
|
||
.collect()
|
||
);
|
||
assert_eq!(
|
||
ack["schemaVersion"],
|
||
REAL_E2E_TOOL_PLAN_CHECKPOINT_ACK_SCHEMA
|
||
);
|
||
assert_eq!(
|
||
ack["capabilitySha256"],
|
||
format!("{:x}", Sha256::digest(fixture.capability.as_bytes()))
|
||
);
|
||
assert_eq!(
|
||
ack["projectRootSha256"],
|
||
real_e2e_tool_plan_checkpoint_project_root_sha256(&fixture.root)
|
||
);
|
||
assert_eq!(ack["agentId"], fixture.state.agent_id);
|
||
assert_eq!(ack["runId"], fixture.state.run_id);
|
||
assert_eq!(ack["requestSlot"], "loop-0-repair-0");
|
||
assert!(ack["reachedAtMs"].as_u64().is_some_and(|value| value > 0));
|
||
let handoff = crate::tool_plan_handoff::read_for_run_at(
|
||
&fixture.root,
|
||
&fixture.state.agent_id,
|
||
&fixture.state.run_id,
|
||
)
|
||
.expect("read checkpoint handoff")
|
||
.expect("checkpoint handoff exists");
|
||
assert_eq!(handoff.entries.len(), 1);
|
||
let entry = &handoff.entries[0];
|
||
assert_eq!(ack["responseFingerprint"], entry.response_fingerprint);
|
||
assert_eq!(
|
||
ack["providerRequestIdSha256"],
|
||
format!("{:x}", Sha256::digest(entry.provider_request_id.as_bytes()))
|
||
);
|
||
assert!(!ack_content.contains(&fixture.capability));
|
||
assert!(!ack_content.contains(&entry.provider_request_id));
|
||
assert!(!ack_content.contains(REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE));
|
||
assert!(!ack_content.contains(REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_ARGUMENT));
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
|
||
let metadata = fs::symlink_metadata(fixture.ack_path()).expect("checkpoint ack metadata");
|
||
assert!(metadata.is_file());
|
||
assert!(!metadata.file_type().is_symlink());
|
||
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
|
||
}
|
||
assert!(
|
||
tokio::time::timeout(Duration::from_millis(120), &mut checkpoint)
|
||
.await
|
||
.is_err()
|
||
);
|
||
fs::remove_file(fixture.control_path()).expect("delete checkpoint control");
|
||
let error = tokio::time::timeout(Duration::from_secs(2), checkpoint)
|
||
.await
|
||
.expect("checkpoint must fail promptly after control deletion")
|
||
.expect("join checkpoint task")
|
||
.expect_err("deleted checkpoint control must fail closed");
|
||
assert_real_e2e_tool_plan_checkpoint_error_is_private(&fixture, &error);
|
||
assert_real_e2e_tool_plan_checkpoint_reconciliation(&fixture);
|
||
fixture.cleanup();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn tool_plan_handoff_real_e2e_checkpoint_fails_closed_at_expiry() {
|
||
let fixture = RealE2eToolPlanCheckpointFixture::new("tool-plan-checkpoint-expiry-run", 800);
|
||
fixture.write_control(0o600);
|
||
let root = fixture.root.clone();
|
||
let snapshot = fixture.snapshot.clone();
|
||
let mut checkpoint = tokio::spawn(async move {
|
||
await_game_creator_agent_runtime_tool_plan_checkpoint_for_test(
|
||
&root,
|
||
snapshot,
|
||
real_e2e_tool_plan_checkpoint_response(),
|
||
)
|
||
.await
|
||
});
|
||
wait_for_real_e2e_tool_plan_checkpoint_ack(&fixture.ack_path()).await;
|
||
assert!(
|
||
tokio::time::timeout(Duration::from_millis(120), &mut checkpoint)
|
||
.await
|
||
.is_err()
|
||
);
|
||
let error = tokio::time::timeout(Duration::from_secs(2), checkpoint)
|
||
.await
|
||
.expect("checkpoint must stop at expiresAtMs")
|
||
.expect("join expiring checkpoint task")
|
||
.expect_err("expired checkpoint must fail closed");
|
||
assert_real_e2e_tool_plan_checkpoint_error_is_private(&fixture, &error);
|
||
assert_real_e2e_tool_plan_checkpoint_reconciliation(&fixture);
|
||
assert!(fixture.control_path().exists());
|
||
fixture.cleanup();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn tool_plan_handoff_real_e2e_checkpoint_rejects_ttl_above_ten_minutes() {
|
||
let fixture = RealE2eToolPlanCheckpointFixture::new(
|
||
"tool-plan-checkpoint-overlong-ttl-run",
|
||
10 * 60 * 1_000 + 1,
|
||
);
|
||
fixture.write_control(0o600);
|
||
let error = await_game_creator_agent_runtime_tool_plan_checkpoint_for_test(
|
||
&fixture.root,
|
||
fixture.snapshot.clone(),
|
||
real_e2e_tool_plan_checkpoint_response(),
|
||
)
|
||
.await
|
||
.expect_err("checkpoint TTL above ten minutes must fail closed");
|
||
assert!(!fixture.ack_path().exists());
|
||
assert_real_e2e_tool_plan_checkpoint_error_is_private(&fixture, &error);
|
||
assert_real_e2e_tool_plan_checkpoint_reconciliation(&fixture);
|
||
fixture.cleanup();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn tool_plan_handoff_real_e2e_checkpoint_rechecks_cancel_and_project_revision_after_ack() {
|
||
for drift in ["cancel", "project-revision"] {
|
||
let fixture = RealE2eToolPlanCheckpointFixture::new(
|
||
&format!("tool-plan-checkpoint-{drift}-drift-run"),
|
||
5_000,
|
||
);
|
||
fixture.write_control(0o600);
|
||
let root = fixture.root.clone();
|
||
let snapshot = fixture.snapshot.clone();
|
||
let checkpoint = tokio::spawn(async move {
|
||
await_game_creator_agent_runtime_tool_plan_checkpoint_for_test(
|
||
&root,
|
||
snapshot,
|
||
real_e2e_tool_plan_checkpoint_response(),
|
||
)
|
||
.await
|
||
});
|
||
wait_for_real_e2e_tool_plan_checkpoint_ack(&fixture.ack_path()).await;
|
||
|
||
match drift {
|
||
"cancel" => write_game_creator_agent_runtime_cancel_request(
|
||
&fixture.root,
|
||
&fixture.state.agent_id,
|
||
&fixture.state.run_id,
|
||
"checkpoint drift test",
|
||
)
|
||
.expect("write durable cancel drift"),
|
||
"project-revision" => {
|
||
let mut revision = read_game_creator_agent_runtime_project_revision(&fixture.root)
|
||
.expect("read checkpoint project revision");
|
||
revision.revision = revision.revision.saturating_add(1);
|
||
revision.updated_at = unix_timestamp();
|
||
write_game_creator_agent_runtime_project_revision(&fixture.root, &revision)
|
||
.expect("write checkpoint project revision drift");
|
||
}
|
||
_ => unreachable!(),
|
||
}
|
||
|
||
let error = tokio::time::timeout(Duration::from_secs(2), checkpoint)
|
||
.await
|
||
.expect("checkpoint must detect post-ACK drift promptly")
|
||
.expect("join drifting checkpoint task")
|
||
.expect_err("post-ACK drift must fail closed");
|
||
assert_real_e2e_tool_plan_checkpoint_error_is_private(&fixture, &error);
|
||
assert_real_e2e_tool_plan_checkpoint_phase(
|
||
&fixture,
|
||
if drift == "cancel" {
|
||
"cancelling"
|
||
} else {
|
||
"needs-reconciliation"
|
||
},
|
||
);
|
||
fixture.cleanup();
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn tool_plan_handoff_real_e2e_checkpoint_rejects_identity_sentinel_and_capability() {
|
||
for case in ["identity", "sentinel", "capability"] {
|
||
let mut fixture = RealE2eToolPlanCheckpointFixture::new(
|
||
&format!("tool-plan-checkpoint-invalid-{case}-run"),
|
||
5_000,
|
||
);
|
||
match case {
|
||
"identity" => {
|
||
fixture.control["agentId"] = Value::String("different-agent".to_string());
|
||
}
|
||
"sentinel" => {
|
||
fixture.control["sentinelToken"] =
|
||
Value::String("different-sentinel-token".to_string());
|
||
}
|
||
"capability" => {
|
||
fixture.control["capability"] = Value::String("A".repeat(64));
|
||
}
|
||
_ => unreachable!(),
|
||
}
|
||
fixture.write_control(0o600);
|
||
let error = await_game_creator_agent_runtime_tool_plan_checkpoint_for_test(
|
||
&fixture.root,
|
||
fixture.snapshot.clone(),
|
||
real_e2e_tool_plan_checkpoint_response(),
|
||
)
|
||
.await
|
||
.expect_err("invalid checkpoint control must fail closed");
|
||
assert!(!fixture.ack_path().exists());
|
||
assert_real_e2e_tool_plan_checkpoint_error_is_private(&fixture, &error);
|
||
assert_real_e2e_tool_plan_checkpoint_reconciliation(&fixture);
|
||
fixture.cleanup();
|
||
}
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[tokio::test]
|
||
async fn tool_plan_handoff_real_e2e_checkpoint_rejects_group_readable_control() {
|
||
let fixture =
|
||
RealE2eToolPlanCheckpointFixture::new("tool-plan-checkpoint-public-control-run", 5_000);
|
||
fixture.write_control(0o640);
|
||
let error = await_game_creator_agent_runtime_tool_plan_checkpoint_for_test(
|
||
&fixture.root,
|
||
fixture.snapshot.clone(),
|
||
real_e2e_tool_plan_checkpoint_response(),
|
||
)
|
||
.await
|
||
.expect_err("group-readable checkpoint control must fail closed");
|
||
assert!(!fixture.ack_path().exists());
|
||
assert_real_e2e_tool_plan_checkpoint_error_is_private(&fixture, &error);
|
||
assert_real_e2e_tool_plan_checkpoint_reconciliation(&fixture);
|
||
fixture.cleanup();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn background_agent_runtime_can_create_checkpoint_before_file_write() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
let check_command = write_agent_runtime_verification_fixture(&root);
|
||
write_local_project_file_at(&root, "game/notes.txt", "v1").expect("write notes");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow project checkpoint");
|
||
|
||
let (sender, receiver) = mpsc::channel();
|
||
let plan_json = serde_json::json!({
|
||
"thinkingSummary": "写文件前先创建 checkpoint",
|
||
"plan": ["创建 checkpoint", "修改项目说明文件"],
|
||
"actions": [
|
||
{
|
||
"tool": "project.checkpoint",
|
||
"reason": "保存写入前的项目状态",
|
||
"input": {}
|
||
},
|
||
{
|
||
"tool": "file.write",
|
||
"reason": "更新设计说明",
|
||
"input": { "path": "game/notes.txt", "content": "v2" }
|
||
}
|
||
],
|
||
"response": ""
|
||
})
|
||
.to_string();
|
||
let base_url = spawn_mock_llm_server_responses_with_capture(
|
||
vec![
|
||
plan_json,
|
||
agent_runtime_verification_plan(check_command),
|
||
final_tool_plan_response("checkpoint 已创建,文件也已更新。"),
|
||
],
|
||
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",
|
||
"后台修改说明前保存 checkpoint",
|
||
"design-checkpoint-write-run",
|
||
)
|
||
.expect("start background task");
|
||
|
||
let plan_request = receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("plan llm request");
|
||
assert!(plan_request.contains("project.checkpoint"));
|
||
assert!(plan_request.contains("file.write"));
|
||
let final_request = receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("final reply llm request");
|
||
assert!(final_request.contains("project.checkpoint"));
|
||
assert!(final_request.contains("已创建 checkpoint checkpoint-"));
|
||
assert!(final_request.contains("file.write"));
|
||
let verified_final_request = receiver
|
||
.recv_timeout(Duration::from_secs(10))
|
||
.expect("final request after checkpoint write verification");
|
||
assert!(verified_final_request.contains("project.verify"));
|
||
assert!(verified_final_request.contains("AGENT_RUNTIME_CURRENT_REVISION_OK"));
|
||
|
||
let runtime = wait_for_agent_runtime_idle(&root, "design-director");
|
||
assert_eq!(runtime.status, "idle");
|
||
assert!(runtime
|
||
.tool_policy
|
||
.auto_tools
|
||
.contains(&"project.checkpoint".to_string()));
|
||
assert!(runtime
|
||
.observations
|
||
.iter()
|
||
.any(|item| item.contains("project.checkpoint:ok · 已创建 checkpoint")));
|
||
assert!(runtime
|
||
.observations
|
||
.iter()
|
||
.any(|item| item.contains("file.write:ok · 已写入 game/notes.txt")));
|
||
assert!(runtime
|
||
.observations
|
||
.iter()
|
||
.any(|item| item.contains("project.verify:ok · check:agent 已通过")));
|
||
|
||
let checkpoints_root = root.join(".agent/checkpoints");
|
||
let mut checkpoint_dirs = fs::read_dir(&checkpoints_root)
|
||
.expect("checkpoint dir")
|
||
.filter_map(Result::ok)
|
||
.map(|entry| entry.path())
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(checkpoint_dirs.len(), 1);
|
||
let checkpoint_path = checkpoint_dirs.pop().expect("checkpoint path");
|
||
assert_eq!(
|
||
fs::read_to_string(checkpoint_path.join("files/game/notes.txt")).expect("checkpoint notes"),
|
||
"v1"
|
||
);
|
||
assert_eq!(
|
||
fs::read_to_string(root.join("game/notes.txt")).expect("updated notes"),
|
||
"v2"
|
||
);
|
||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
|
||
assert!(agent_db.contains("\"recordType\":\"project.checkpoint\""));
|
||
assert!(agent_db.contains("\"tool\":\"project.checkpoint\""));
|
||
assert!(agent_db.contains("\"tool\":\"file.write\""));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn background_agent_runtime_checkpoint_respects_project_policy() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
write_local_project_file_at(&root, "game/blocked-notes.txt", "v1").expect("write notes");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: vec!["project.checkpoint".to_string()],
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("write policy");
|
||
|
||
let (sender, receiver) = mpsc::channel();
|
||
let plan_json = serde_json::json!({
|
||
"thinkingSummary": "尝试在写文件前创建 checkpoint",
|
||
"plan": ["创建 checkpoint", "等待权限结果"],
|
||
"actions": [
|
||
{
|
||
"tool": "project.checkpoint",
|
||
"reason": "保存写入前的项目状态",
|
||
"input": {}
|
||
}
|
||
],
|
||
"response": ""
|
||
})
|
||
.to_string();
|
||
let base_url = spawn_mock_llm_server_responses_with_capture(vec![plan_json], 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",
|
||
"后台尝试创建 checkpoint",
|
||
"design-checkpoint-policy-run",
|
||
)
|
||
.expect("start background task");
|
||
|
||
let plan_request = receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("plan llm request");
|
||
assert!(plan_request.contains("project.checkpoint"));
|
||
assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err());
|
||
|
||
let runtime = wait_for_agent_runtime_confirmation(&root, "design-director");
|
||
assert_eq!(runtime.status, "waiting-for-confirmation");
|
||
assert_eq!(runtime.task_queue.waiting_for_confirmation, 1);
|
||
assert!(runtime
|
||
.tool_policy
|
||
.confirm_tools
|
||
.contains(&"project.checkpoint".to_string()));
|
||
assert!(runtime.observations.iter().any(|item| {
|
||
item.contains(
|
||
"project.checkpoint:waiting-for-confirmation · 项目权限策略要求用户确认:project.checkpoint",
|
||
)
|
||
}));
|
||
assert!(runtime.recent_tool_calls.iter().any(|call| {
|
||
call.tool == "project.checkpoint" && call.status == "waiting-for-confirmation"
|
||
}));
|
||
assert!(!root.join(".agent/checkpoints").exists());
|
||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
|
||
assert!(!agent_db.contains("\"recordType\":\"project.checkpoint\""));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn background_agent_runtime_can_diff_checkpoint() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
write_local_project_file_at(&root, "game/notes.txt", "v1").expect("write notes");
|
||
write_local_project_file_at(&root, "exports/README.md", "publish")
|
||
.expect("write export readme");
|
||
let checkpoint = create_local_project_checkpoint_at(&root).expect("create checkpoint");
|
||
write_local_project_file_at(&root, "game/notes.txt", "v2").expect("change notes");
|
||
write_local_project_file_at(&root, "game/new.txt", "new").expect("add file");
|
||
delete_local_project_file_at(&root, "exports/README.md").expect("delete export readme");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow project diff");
|
||
|
||
let (sender, receiver) = mpsc::channel();
|
||
let plan_json = serde_json::json!({
|
||
"thinkingSummary": "需要查看 checkpoint 后的本地差异",
|
||
"plan": ["对比 checkpoint", "根据新增修改删除回复开发者"],
|
||
"actions": [
|
||
{
|
||
"tool": "project.diff",
|
||
"reason": "确认本轮项目文件变化",
|
||
"input": { "checkpointId": checkpoint.checkpoint_id }
|
||
}
|
||
],
|
||
"response": ""
|
||
})
|
||
.to_string();
|
||
let base_url = spawn_mock_llm_server_responses_with_capture(
|
||
vec![
|
||
plan_json,
|
||
final_tool_plan_response("checkpoint 差异已经确认。"),
|
||
],
|
||
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",
|
||
"后台检查本地项目差异",
|
||
"design-project-diff-run",
|
||
)
|
||
.expect("start background task");
|
||
|
||
let plan_request = receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("plan llm request");
|
||
assert!(plan_request.contains("project.diff"));
|
||
assert!(plan_request.contains("checkpointId"));
|
||
let final_request = receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("final reply llm request");
|
||
assert!(final_request.contains("project.diff"));
|
||
assert!(final_request.contains("已对比 checkpoint"));
|
||
assert!(final_request.contains("added: 1"));
|
||
assert!(final_request.contains("changed: 1"));
|
||
assert!(final_request.contains("deleted: 1"));
|
||
assert!(final_request.contains("game/new.txt"));
|
||
assert!(final_request.contains("game/notes.txt"));
|
||
assert!(final_request.contains("exports/README.md"));
|
||
|
||
let runtime = wait_for_agent_runtime_idle(&root, "design-director");
|
||
assert_eq!(runtime.status, "idle");
|
||
assert!(runtime
|
||
.tool_policy
|
||
.auto_tools
|
||
.contains(&"project.diff".to_string()));
|
||
assert!(runtime
|
||
.observations
|
||
.iter()
|
||
.any(|item| item.contains("project.diff:ok · 已对比 checkpoint")));
|
||
assert!(runtime.recent_tool_calls.iter().any(|call| {
|
||
call.tool == "project.diff"
|
||
&& call.status == "ok"
|
||
&& call
|
||
.detail
|
||
.as_deref()
|
||
.is_some_and(|detail| detail.contains("game/new.txt"))
|
||
}));
|
||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
|
||
assert!(agent_db.contains("\"tool\":\"project.diff\""));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn agent_runtime_project_diff_can_return_bounded_content_hunks() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "内容差异项目").expect("project init");
|
||
write_local_project_file_at(&root, "game/notes.txt", "before\n").expect("write original notes");
|
||
let checkpoint = create_local_project_checkpoint_at(&root).expect("create checkpoint");
|
||
write_local_project_file_at(&root, "game/notes.txt", "after\n").expect("update notes");
|
||
write_local_project_file_at(&root, "game/new.txt", "created\n").expect("create new file");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow content diff");
|
||
|
||
let observation = execute_game_creator_agent_runtime_tool_action(
|
||
&root,
|
||
"design-director",
|
||
"project-content-diff-run",
|
||
"检查 checkpoint 的内容差异",
|
||
&AgentRuntimeToolAction {
|
||
tool: "project.diff".to_string(),
|
||
reason: Some("查看统一 diff hunks".to_string()),
|
||
input: serde_json::json!({
|
||
"checkpointId": checkpoint.checkpoint_id,
|
||
"includeContent": true,
|
||
"maxFiles": 2,
|
||
"maxChars": 4_000
|
||
}),
|
||
},
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(observation.status, "ok");
|
||
assert!(observation.summary.contains("的内容"));
|
||
let detail = observation.detail.expect("content diff detail");
|
||
assert!(detail.contains("contentFileCount: 2"));
|
||
assert!(detail.contains("contentTruncated: false"));
|
||
assert!(detail.contains("diff --git a/game/new.txt b/game/new.txt"));
|
||
assert!(detail.contains("diff --git a/game/notes.txt b/game/notes.txt"));
|
||
assert!(detail.contains("-before"));
|
||
assert!(detail.contains("+after"));
|
||
|
||
let invalid = execute_game_creator_agent_runtime_tool_action(
|
||
&root,
|
||
"design-director",
|
||
"project-content-diff-invalid-run",
|
||
"拒绝超出内容差异上限的参数",
|
||
&AgentRuntimeToolAction {
|
||
tool: "project.diff".to_string(),
|
||
reason: Some("验证内容差异参数上限".to_string()),
|
||
input: serde_json::json!({
|
||
"checkpointId": checkpoint.checkpoint_id,
|
||
"includeContent": true,
|
||
"maxFiles": 51
|
||
}),
|
||
},
|
||
)
|
||
.await;
|
||
assert_eq!(invalid.status, "failed");
|
||
assert!(invalid.summary.contains("maxFiles 必须在"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn agent_runtime_git_inspect_returns_safe_diff_without_advancing_revision() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "Git 审阅项目").expect("project init");
|
||
fs::write(root.join("game/notes.txt"), "before\n").expect("write tracked fixture");
|
||
let git = |arguments: &[&str]| {
|
||
let status = std::process::Command::new("git")
|
||
.current_dir(&root)
|
||
.args(arguments)
|
||
.status()
|
||
.expect("run git fixture command");
|
||
assert!(
|
||
status.success(),
|
||
"git fixture command failed: {arguments:?}"
|
||
);
|
||
};
|
||
git(&["init", "--quiet"]);
|
||
git(&["add", "--", "game/notes.txt"]);
|
||
git(&[
|
||
"-c",
|
||
"user.name=Runtime Test",
|
||
"-c",
|
||
"user.email=runtime-test@example.invalid",
|
||
"commit",
|
||
"--quiet",
|
||
"-m",
|
||
"seed",
|
||
]);
|
||
fs::write(root.join("game/notes.txt"), "after\n").expect("change tracked fixture");
|
||
fs::write(root.join("game/new.txt"), "safe untracked\n").expect("write untracked fixture");
|
||
fs::write(root.join(".env"), "GIT_INSPECT_SECRET=hidden\n").expect("write secret fixture");
|
||
fs::create_dir_all(root.join("data")).expect("create data fixture");
|
||
fs::write(root.join("data/local.sqlite"), "database secret\n").expect("write database fixture");
|
||
let revision_before = read_game_creator_agent_runtime_project_revision(&root)
|
||
.expect("read revision before git inspect")
|
||
.revision;
|
||
|
||
let observation = execute_game_creator_agent_runtime_tool_action(
|
||
&root,
|
||
"design-director",
|
||
"git-inspect-run",
|
||
"审阅当前 Git 工作树",
|
||
&AgentRuntimeToolAction {
|
||
tool: "git.inspect".to_string(),
|
||
reason: Some("查看安全工作树差异".to_string()),
|
||
input: serde_json::json!({
|
||
"includeDiff": true,
|
||
"maxFiles": 20,
|
||
"maxChars": 24_000
|
||
}),
|
||
},
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(observation.status, "ok");
|
||
let detail = observation.detail.expect("git inspect detail");
|
||
assert!(detail.contains("gitContentTruncated: false"));
|
||
assert!(detail.contains("commitSnapshotFingerprint: "));
|
||
assert!(!detail.contains("commitSnapshotFingerprint: (unavailable)"));
|
||
assert!(detail.contains("- game/notes.txt"));
|
||
assert!(detail.contains("- game/new.txt"));
|
||
assert!(detail.contains("diff --git a/game/notes.txt b/game/notes.txt"));
|
||
assert!(detail.contains("-before"));
|
||
assert!(detail.contains("+after"));
|
||
assert!(!detail.contains(".env"));
|
||
assert!(!detail.contains("local.sqlite"));
|
||
assert!(!detail.contains("GIT_INSPECT_SECRET"));
|
||
assert_eq!(
|
||
read_game_creator_agent_runtime_project_revision(&root)
|
||
.expect("read revision after git inspect")
|
||
.revision,
|
||
revision_before
|
||
);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_git_commit_prompt_and_input_summary_keep_the_safe_contract() {
|
||
let prompt = game_creator_agent_runtime_tool_plan_system_prompt();
|
||
for expected in [
|
||
"project.git_commit",
|
||
"commitSnapshotFingerprint",
|
||
"expectedSnapshotFingerprint",
|
||
"expectedHead",
|
||
] {
|
||
assert!(
|
||
prompt.contains(expected),
|
||
"missing prompt contract: {expected}"
|
||
);
|
||
}
|
||
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "Git 提交摘要项目").expect("project init");
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "project.git_commit".to_string(),
|
||
reason: Some("提交已验证修改".to_string()),
|
||
input: serde_json::json!({
|
||
"message": "提交已验证修改\nPRIVATE-COMMIT-BODY-MUST-NOT-PERSIST",
|
||
"paths": ["game/main.js", "game/style.css"],
|
||
"expectedHead": "0123456789abcdef0123456789abcdef01234567",
|
||
"expectedSnapshotFingerprint": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
||
}),
|
||
};
|
||
let summary =
|
||
agent_runtime_tool_action_input_summary(&root, &action).expect("git commit input summary");
|
||
assert!(summary.contains("title=提交已验证修改"));
|
||
assert!(summary.contains("pathCount=2"));
|
||
assert!(summary.contains("game/main.js,game/style.css"));
|
||
assert!(summary.contains("expectedHead=0123456789ab"));
|
||
assert!(summary.contains("snapshot=abcdef012345"));
|
||
assert!(summary.contains("messageSha256="));
|
||
assert!(!summary.contains("PRIVATE-COMMIT-BODY-MUST-NOT-PERSIST"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_git_commit_rejects_unverified_revision_without_moving_head() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "未验证 Git 提交项目").expect("project init");
|
||
fs::write(root.join("game/notes.txt"), "before\n").expect("write tracked fixture");
|
||
let original_head = seed_agent_runtime_git_fixture(&root);
|
||
fs::write(root.join("game/notes.txt"), "after\n").expect("modify tracked fixture");
|
||
advance_project_revision_for_test(
|
||
&root,
|
||
"code-prototype",
|
||
"git-commit-unverified-run",
|
||
"file.write",
|
||
);
|
||
let _lock = acquire_project_write_lock(&root, "test.git_commit.unverified")
|
||
.expect("acquire project lock");
|
||
let inspect = inspect_local_git_worktree_at(&root, true, 20, 24_000)
|
||
.expect("inspect unverified git fixture");
|
||
let fingerprint = inspect
|
||
.commit_snapshot_fingerprint
|
||
.expect("commit snapshot fingerprint");
|
||
let observation = observe_agent_runtime_project_git_commit_locked_with_audit(
|
||
&root,
|
||
"code-prototype",
|
||
"git-commit-unverified-run",
|
||
Some("action-unverified"),
|
||
"fingerprint-unverified",
|
||
&serde_json::json!({
|
||
"message": "不应创建的提交",
|
||
"paths": ["game/notes.txt"],
|
||
"expectedHead": original_head,
|
||
"expectedSnapshotFingerprint": fingerprint,
|
||
}),
|
||
append_agent_db_record,
|
||
);
|
||
|
||
assert_eq!(observation.status, "verification-failed");
|
||
assert_eq!(
|
||
run_agent_runtime_git_fixture(&root, &["rev-parse", "HEAD"]),
|
||
original_head
|
||
);
|
||
assert_eq!(
|
||
run_agent_runtime_git_fixture(&root, &["show", "HEAD:game/notes.txt"]),
|
||
"before"
|
||
);
|
||
|
||
drop(_lock);
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_git_commit_commits_only_selected_paths_and_persists_safe_audit() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "受控 Git 提交项目").expect("project init");
|
||
fs::write(root.join("game/selected.txt"), "before selected\n").expect("write selected fixture");
|
||
fs::write(root.join("game/unselected.txt"), "before unselected\n")
|
||
.expect("write unselected fixture");
|
||
let original_head = seed_agent_runtime_git_fixture(&root);
|
||
fs::write(root.join("game/selected.txt"), "after selected\n").expect("modify selected fixture");
|
||
fs::write(root.join("game/unselected.txt"), "after unselected\n")
|
||
.expect("modify unselected fixture");
|
||
fs::write(root.join("game/new-selected.txt"), "new selected\n")
|
||
.expect("write selected untracked fixture");
|
||
fs::write(root.join("game/new-unselected.txt"), "new unselected\n")
|
||
.expect("write unselected untracked fixture");
|
||
advance_project_revision_for_test(
|
||
&root,
|
||
"code-prototype",
|
||
"git-commit-passed-run",
|
||
"project.patchset",
|
||
);
|
||
persist_project_verification_for_test(
|
||
&root,
|
||
"code-prototype",
|
||
"git-commit-passed-run",
|
||
"project.verify",
|
||
true,
|
||
);
|
||
let _lock =
|
||
acquire_project_write_lock(&root, "test.git_commit.passed").expect("acquire project lock");
|
||
let inspect = inspect_local_git_worktree_at(&root, true, 20, 24_000)
|
||
.expect("inspect verified git fixture");
|
||
let fingerprint = inspect
|
||
.commit_snapshot_fingerprint
|
||
.expect("commit snapshot fingerprint");
|
||
let private_body = "PRIVATE-COMMIT-BODY-MUST-STAY-IN-GIT";
|
||
let observation = observe_agent_runtime_project_git_commit_locked_with_audit(
|
||
&root,
|
||
"code-prototype",
|
||
"git-commit-passed-run",
|
||
Some("action-passed"),
|
||
"fingerprint-passed",
|
||
&serde_json::json!({
|
||
"message": format!("提交选定文件\n{private_body}"),
|
||
"paths": ["game/selected.txt", "game/new-selected.txt"],
|
||
"expectedHead": original_head,
|
||
"expectedSnapshotFingerprint": fingerprint,
|
||
}),
|
||
append_agent_db_record,
|
||
);
|
||
|
||
assert_eq!(observation.status, "ok", "{observation:?}");
|
||
let new_head = run_agent_runtime_git_fixture(&root, &["rev-parse", "HEAD"]);
|
||
assert_ne!(new_head, original_head);
|
||
assert_eq!(
|
||
run_agent_runtime_git_fixture(&root, &["show", "HEAD:game/selected.txt"]),
|
||
"after selected"
|
||
);
|
||
assert_eq!(
|
||
run_agent_runtime_git_fixture(&root, &["show", "HEAD:game/new-selected.txt"]),
|
||
"new selected"
|
||
);
|
||
assert_eq!(
|
||
run_agent_runtime_git_fixture(&root, &["show", "HEAD:game/unselected.txt"]),
|
||
"before unselected"
|
||
);
|
||
let status = run_agent_runtime_git_fixture(
|
||
&root,
|
||
&["status", "--porcelain=v1", "--untracked-files=all"],
|
||
);
|
||
assert!(status.contains("game/unselected.txt"));
|
||
assert!(status.contains("game/new-unselected.txt"));
|
||
assert!(!status.contains("game/selected.txt"));
|
||
assert!(!status.contains("game/new-selected.txt"));
|
||
|
||
let detail = observation.detail.as_deref().expect("safe commit detail");
|
||
let receipt = agent_runtime_git_commit_safe_detail_value(&root, detail)
|
||
.expect("receipt-safe commit detail");
|
||
assert_eq!(receipt["parentHead"], original_head);
|
||
assert_eq!(receipt["commitHead"], new_head);
|
||
assert_eq!(receipt["pathCount"], 2);
|
||
assert_eq!(receipt["remainingChangedCount"], 2);
|
||
assert!(!detail.contains(private_body));
|
||
let audit_records = read_agent_db_records_for_test(&root);
|
||
let commit_audit = audit_records
|
||
.iter()
|
||
.find(|record| record["recordType"] == "agent.runtime.project.git_commit")
|
||
.expect("commit audit record");
|
||
let audit_json = serde_json::to_string(commit_audit).expect("serialize commit audit");
|
||
assert_eq!(commit_audit["commitHead"], new_head);
|
||
assert_eq!(commit_audit["pathCount"], 2);
|
||
assert!(!audit_json.contains(private_body));
|
||
assert_eq!(
|
||
read_game_creator_agent_runtime_project_revision(&root)
|
||
.expect("read unchanged project revision")
|
||
.revision,
|
||
1
|
||
);
|
||
|
||
drop(_lock);
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_git_commit_audit_failure_requires_reconciliation_after_ref_moves() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "Git 提交审计失败项目").expect("project init");
|
||
fs::write(root.join("game/notes.txt"), "before\n").expect("write tracked fixture");
|
||
let original_head = seed_agent_runtime_git_fixture(&root);
|
||
fs::write(root.join("game/notes.txt"), "after\n").expect("modify tracked fixture");
|
||
let runtime = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"code-prototype",
|
||
"验证提交审计失败后的回执",
|
||
"git-commit-audit-failure-run",
|
||
"agent-background-task",
|
||
"准备创建受控本地提交",
|
||
vec!["保留可核对 commit SHA".to_string()],
|
||
)
|
||
.expect("start runtime state");
|
||
advance_project_revision_for_test(&root, "code-prototype", &runtime.run_id, "file.write");
|
||
persist_project_verification_for_test(
|
||
&root,
|
||
"code-prototype",
|
||
&runtime.run_id,
|
||
"project.verify",
|
||
true,
|
||
);
|
||
let _lock = acquire_project_write_lock(&root, "test.git_commit.audit_failure")
|
||
.expect("acquire project lock");
|
||
let inspect = inspect_local_git_worktree_at(&root, true, 20, 24_000)
|
||
.expect("inspect audit failure fixture");
|
||
let fingerprint = inspect
|
||
.commit_snapshot_fingerprint
|
||
.expect("commit snapshot fingerprint");
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "project.git_commit".to_string(),
|
||
reason: Some("提交后模拟审计失败".to_string()),
|
||
input: serde_json::json!({
|
||
"message": "提交后模拟审计失败",
|
||
"paths": ["game/notes.txt"],
|
||
"expectedHead": original_head,
|
||
"expectedSnapshotFingerprint": fingerprint,
|
||
}),
|
||
};
|
||
let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &runtime.current_task);
|
||
let action_id = agent_runtime_tool_action_id(&runtime.run_id, 0, 0, 1, &action_fingerprint);
|
||
let observation = observe_agent_runtime_project_git_commit_locked_with_audit(
|
||
&root,
|
||
"code-prototype",
|
||
&runtime.run_id,
|
||
Some(&action_id),
|
||
&action_fingerprint,
|
||
&action.input,
|
||
|_root, _record| Err("audit unavailable".to_string()),
|
||
);
|
||
|
||
assert_eq!(observation.status, "needs-reconciliation");
|
||
assert!(observation.summary.contains("审计"));
|
||
let commit_head = run_agent_runtime_git_fixture(&root, &["rev-parse", "HEAD"]);
|
||
assert_ne!(commit_head, original_head);
|
||
assert_eq!(
|
||
run_agent_runtime_git_fixture(&root, &["show", "HEAD:game/notes.txt"]),
|
||
"after"
|
||
);
|
||
let safe_detail = agent_runtime_git_commit_safe_detail_value(
|
||
&root,
|
||
observation
|
||
.detail
|
||
.as_deref()
|
||
.expect("reconciliation detail"),
|
||
)
|
||
.expect("reconciliation detail remains receipt-safe");
|
||
assert_eq!(safe_detail["commitHead"], commit_head);
|
||
append_agent_runtime_action_receipt(
|
||
&root,
|
||
&runtime,
|
||
&action_id,
|
||
&action_fingerprint,
|
||
"project.git_commit",
|
||
AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION,
|
||
None,
|
||
&observation,
|
||
)
|
||
.expect("append fallback terminal receipt");
|
||
let records = read_agent_db_records_for_test(&root);
|
||
let receipt = records
|
||
.iter()
|
||
.find(|record| {
|
||
record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE
|
||
&& record["actionId"] == action_id
|
||
})
|
||
.expect("fallback terminal receipt");
|
||
let receipt_detail: Value = serde_json::from_str(
|
||
receipt["safeDetail"]
|
||
.as_str()
|
||
.expect("receipt safe detail string"),
|
||
)
|
||
.expect("parse receipt safe detail");
|
||
assert_eq!(receipt_detail["commitHead"], commit_head);
|
||
assert_eq!(receipt["detailUnavailable"], false);
|
||
|
||
drop(_lock);
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn supervisor_collaboration_project_git_commit_rechecks_after_project_lock() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(
|
||
&root,
|
||
"project-supervisor-collaboration-git-commit",
|
||
"Supervisor Git 锁内协作门禁项目",
|
||
)
|
||
.expect("project init");
|
||
fs::write(root.join("game/notes.txt"), "before\n").expect("write tracked fixture");
|
||
let original_head = seed_agent_runtime_git_fixture(&root);
|
||
fs::write(root.join("game/notes.txt"), "after\n").expect("modify tracked fixture");
|
||
let run_id = "supervisor-collaboration-git-commit-run";
|
||
let runtime = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
"委派后不得由总控创建 Git 提交",
|
||
run_id,
|
||
"agent-chat",
|
||
"验证 Git 提交锁内协作门禁",
|
||
vec!["Git HEAD 保持不变".to_string()],
|
||
)
|
||
.expect("start supervisor runtime");
|
||
advance_project_revision_for_test(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
run_id,
|
||
"project.patchset",
|
||
);
|
||
persist_project_verification_for_test(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
run_id,
|
||
"project.verify",
|
||
true,
|
||
);
|
||
assert!(
|
||
supervisor_orchestrator_mutation_block_after_dispatch_for_test(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
run_id,
|
||
"project.git_commit",
|
||
)
|
||
.is_none()
|
||
);
|
||
let inspect = inspect_local_git_worktree_at(&root, true, 20, 24_000)
|
||
.expect("inspect verified git fixture");
|
||
let fingerprint = inspect
|
||
.commit_snapshot_fingerprint
|
||
.expect("commit snapshot fingerprint");
|
||
let delivery = new_static_delegate_delivery(
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
&runtime.session_id,
|
||
run_id,
|
||
"supervisor-collaboration-git-commit-delegate-action",
|
||
"supervisor-collaboration-git-commit-delivery",
|
||
"code-prototype",
|
||
"supervisor-collaboration-git-commit-child-session",
|
||
"supervisor-collaboration-git-commit-child-run",
|
||
);
|
||
create_or_read_static_delegate_delivery_at(&root, &delivery)
|
||
.expect("create durable supervisor delivery");
|
||
let revision_before = read_game_creator_agent_runtime_project_revision(&root)
|
||
.expect("read revision before blocked commit");
|
||
let _lock = acquire_project_write_lock(&root, "test.supervisor_collaboration.git_commit")
|
||
.expect("acquire project lock for locked commit core");
|
||
|
||
let observation = observe_agent_runtime_project_git_commit_locked_with_audit(
|
||
&root,
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
run_id,
|
||
Some("supervisor-collaboration-git-commit-action"),
|
||
"supervisor-collaboration-git-commit-fingerprint",
|
||
&serde_json::json!({
|
||
"message": "总控不应创建的提交",
|
||
"paths": ["game/notes.txt"],
|
||
"expectedHead": original_head,
|
||
"expectedSnapshotFingerprint": fingerprint,
|
||
}),
|
||
append_agent_db_record,
|
||
);
|
||
|
||
assert_eq!(observation.status, "blocked", "{observation:?}");
|
||
assert!(observation.summary.contains("协作编排"));
|
||
assert_eq!(
|
||
run_agent_runtime_git_fixture(&root, &["rev-parse", "HEAD"]),
|
||
original_head
|
||
);
|
||
assert_eq!(
|
||
run_agent_runtime_git_fixture(&root, &["show", "HEAD:game/notes.txt"]),
|
||
"before"
|
||
);
|
||
assert_eq!(
|
||
read_game_creator_agent_runtime_project_revision(&root)
|
||
.expect("read revision after blocked commit"),
|
||
revision_before,
|
||
);
|
||
assert!(!read_agent_db_records_for_test(&root)
|
||
.iter()
|
||
.any(|record| record["recordType"] == "agent.runtime.project.git_commit"));
|
||
drop(_lock);
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn agent_runtime_git_commit_requires_confirmation_by_default() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "Git 提交默认确认项目").expect("project init");
|
||
fs::write(root.join("game/notes.txt"), "before\n").expect("write tracked fixture");
|
||
let original_head = seed_agent_runtime_git_fixture(&root);
|
||
fs::write(root.join("game/notes.txt"), "after\n").expect("modify tracked fixture");
|
||
let policy = ProjectPermissionPolicy::default();
|
||
assert!(policy
|
||
.confirm_commands
|
||
.contains(&"project.git_commit".to_string()));
|
||
write_project_permission_policy_at(&root, policy).expect("write default policy");
|
||
let _lock = acquire_project_write_lock(&root, "test.git_commit.default_confirm.inspect")
|
||
.expect("acquire project lock");
|
||
let inspect = inspect_local_git_worktree_at(&root, true, 20, 24_000)
|
||
.expect("inspect default confirmation fixture");
|
||
let fingerprint = inspect
|
||
.commit_snapshot_fingerprint
|
||
.expect("commit snapshot fingerprint");
|
||
drop(_lock);
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "project.git_commit".to_string(),
|
||
reason: Some("验证本地提交默认需确认".to_string()),
|
||
input: serde_json::json!({
|
||
"message": "等待确认的提交",
|
||
"paths": ["game/notes.txt"],
|
||
"expectedHead": original_head,
|
||
"expectedSnapshotFingerprint": fingerprint,
|
||
}),
|
||
};
|
||
|
||
let observation = execute_game_creator_agent_runtime_tool_action(
|
||
&root,
|
||
"code-prototype",
|
||
"git-commit-default-confirm-run",
|
||
"尝试创建尚未确认的本地提交",
|
||
&action,
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(observation.status, "waiting-for-confirmation");
|
||
assert!(observation.summary.contains("要求用户确认"));
|
||
assert_eq!(
|
||
run_agent_runtime_git_fixture(&root, &["rev-parse", "HEAD"]),
|
||
original_head
|
||
);
|
||
assert!(run_agent_runtime_git_fixture(&root, &["diff", "--cached", "--name-only"]).is_empty());
|
||
assert!(!read_agent_db_records_for_test(&root)
|
||
.iter()
|
||
.any(|record| record["recordType"] == "agent.runtime.project.git_commit"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn agent_runtime_git_commit_rejects_stale_pending_gate_before_moving_ref() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "Git 提交待确认漂移项目").expect("project init");
|
||
fs::write(root.join("game/notes.txt"), "before\n").expect("write tracked fixture");
|
||
let original_head = seed_agent_runtime_git_fixture(&root);
|
||
fs::write(root.join("game/notes.txt"), "after\n").expect("modify tracked fixture");
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"code-prototype",
|
||
"提交已验证的本地修改",
|
||
"git-commit-stale-pending-run",
|
||
"agent-background-task",
|
||
"准备创建受控本地提交",
|
||
vec!["复核待确认动作门禁".to_string()],
|
||
)
|
||
.expect("start runtime state");
|
||
persist_game_creator_agent_runtime_context(
|
||
&root,
|
||
&state,
|
||
&state.current_task,
|
||
&AgentRuntimeToolPlan::default(),
|
||
&[],
|
||
0,
|
||
&AgentRuntimeContextWindowTracker::default(),
|
||
)
|
||
.expect("persist repository context");
|
||
advance_project_revision_for_test(&root, "code-prototype", &state.run_id, "file.write");
|
||
persist_project_verification_for_test(
|
||
&root,
|
||
"code-prototype",
|
||
&state.run_id,
|
||
"project.verify",
|
||
true,
|
||
);
|
||
let inspect_lock = acquire_project_write_lock(&root, "test.git_commit.pending.inspect")
|
||
.expect("acquire project lock");
|
||
let inspect = inspect_local_git_worktree_at(&root, true, 20, 24_000)
|
||
.expect("inspect pending drift fixture");
|
||
let fingerprint = inspect
|
||
.commit_snapshot_fingerprint
|
||
.expect("commit snapshot fingerprint");
|
||
drop(inspect_lock);
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "project.git_commit".to_string(),
|
||
reason: Some("提交已审阅修改".to_string()),
|
||
input: serde_json::json!({
|
||
"message": "不应在门禁漂移后创建",
|
||
"paths": ["game/notes.txt"],
|
||
"expectedHead": original_head,
|
||
"expectedSnapshotFingerprint": fingerprint,
|
||
}),
|
||
};
|
||
let pending = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
action.clone(),
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED,
|
||
None,
|
||
);
|
||
write_game_creator_agent_runtime_pending_tool_action(&root, &pending)
|
||
.expect("write approved pending action");
|
||
write_game_creator_agent_runtime_tool_confirmation(
|
||
&root,
|
||
"code-prototype",
|
||
&state.run_id,
|
||
"project.git_commit",
|
||
&pending.action_fingerprint,
|
||
"确认已审阅的本地提交",
|
||
)
|
||
.expect("write git commit confirmation ticket");
|
||
advance_project_revision_for_test(&root, "code-prototype", &state.run_id, "file.patch");
|
||
|
||
let observation = execute_game_creator_agent_runtime_tool_action_with_pending_action(
|
||
&root,
|
||
"code-prototype",
|
||
&state.run_id,
|
||
&state.current_task,
|
||
&action,
|
||
Some(&pending.action_id),
|
||
Some(&pending),
|
||
)
|
||
.await;
|
||
|
||
assert_eq!(observation.status, "needs-reconciliation");
|
||
assert!(observation.summary.contains("持久门禁"));
|
||
assert_eq!(
|
||
run_agent_runtime_git_fixture(&root, &["rev-parse", "HEAD"]),
|
||
original_head
|
||
);
|
||
assert!(run_agent_runtime_git_fixture(&root, &["diff", "--cached", "--name-only"]).is_empty());
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_git_commit_executing_recovery_never_replays_commit() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "Git 提交执行中恢复项目").expect("project init");
|
||
fs::write(root.join("game/notes.txt"), "before\n").expect("write tracked fixture");
|
||
let original_head = seed_agent_runtime_git_fixture(&root);
|
||
fs::write(root.join("game/notes.txt"), "after\n").expect("modify tracked fixture");
|
||
let mut state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"code-prototype",
|
||
"恢复执行中断的受控 Git 提交",
|
||
"git-commit-executing-recovery-run",
|
||
"agent-background-task",
|
||
"模拟 ref 更新前后 Runner 退出",
|
||
vec!["禁止自动重放提交".to_string()],
|
||
)
|
||
.expect("start runtime state");
|
||
state.loop_iteration = 1;
|
||
advance_project_revision_for_test(&root, "code-prototype", &state.run_id, "file.write");
|
||
persist_project_verification_for_test(
|
||
&root,
|
||
"code-prototype",
|
||
&state.run_id,
|
||
"project.verify",
|
||
true,
|
||
);
|
||
let inspect_lock = acquire_project_write_lock(&root, "test.git_commit.recovery.inspect")
|
||
.expect("acquire project lock");
|
||
let inspect = inspect_local_git_worktree_at(&root, true, 20, 24_000)
|
||
.expect("inspect executing recovery fixture");
|
||
let fingerprint = inspect
|
||
.commit_snapshot_fingerprint
|
||
.expect("commit snapshot fingerprint");
|
||
drop(inspect_lock);
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "project.git_commit".to_string(),
|
||
reason: Some("提交已审阅修改".to_string()),
|
||
input: serde_json::json!({
|
||
"message": "不允许恢复时重放",
|
||
"paths": ["game/notes.txt"],
|
||
"expectedHead": original_head,
|
||
"expectedSnapshotFingerprint": fingerprint,
|
||
}),
|
||
};
|
||
let pending = pending_tool_action_for_test(
|
||
&root,
|
||
&state,
|
||
action,
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING,
|
||
None,
|
||
);
|
||
write_game_creator_agent_runtime_pending_tool_action(&root, &pending)
|
||
.expect("write executing git commit pending action");
|
||
state.status = "running".to_string();
|
||
state.phase = "action".to_string();
|
||
state.pending_tool_action = Some(pending.summary());
|
||
append_game_creator_agent_runtime_task(&root, &state).expect("append running task");
|
||
write_game_creator_agent_runtime_state(&root, &state).expect("write running state");
|
||
|
||
let resumed = resume_game_creator_agent_background_tasks_at(&root)
|
||
.expect("resume interrupted git commit");
|
||
let reconciled = resumed
|
||
.iter()
|
||
.find(|runtime| runtime.state.agent_id == "code-prototype")
|
||
.expect("reconciled runtime");
|
||
assert_eq!(reconciled.state.status, "failed");
|
||
assert_eq!(reconciled.state.phase, "needs-reconciliation");
|
||
assert!(reconciled
|
||
.state
|
||
.error
|
||
.as_deref()
|
||
.is_some_and(|error| error.contains("不会自动重放")));
|
||
assert_eq!(
|
||
run_agent_runtime_git_fixture(&root, &["rev-parse", "HEAD"]),
|
||
original_head
|
||
);
|
||
assert_eq!(
|
||
fs::read_to_string(root.join("game/notes.txt")).expect("read preserved worktree"),
|
||
"after\n"
|
||
);
|
||
let records = read_agent_db_records_for_test(&root);
|
||
assert!(!records
|
||
.iter()
|
||
.any(|record| record["recordType"] == "agent.runtime.project.git_commit"));
|
||
assert!(records.iter().any(|record| {
|
||
record["recordType"] == "agent.runtime.tool_confirmation.needs_reconciliation"
|
||
&& record["actionId"] == pending.action_id
|
||
}));
|
||
let persisted =
|
||
read_game_creator_agent_runtime_pending_tool_action(&root, "code-prototype", &state.run_id)
|
||
.expect("executing pending action remains for reconciliation");
|
||
assert_eq!(
|
||
persisted.status,
|
||
AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING
|
||
);
|
||
cancel_game_creator_agent_runtime_task_at(&root, "code-prototype", &state.run_id)
|
||
.expect("cancel reconciled task");
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn background_agent_runtime_project_diff_respects_project_policy() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
write_local_project_file_at(&root, "game/blocked-notes.txt", "v1").expect("write notes");
|
||
let checkpoint = create_local_project_checkpoint_at(&root).expect("create checkpoint");
|
||
write_local_project_file_at(&root, "game/blocked-notes.txt", "v2").expect("change notes");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: vec!["project.diff".to_string()],
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("write policy");
|
||
|
||
let (sender, receiver) = mpsc::channel();
|
||
let plan_json = serde_json::json!({
|
||
"thinkingSummary": "尝试对比 checkpoint",
|
||
"plan": ["对比 checkpoint", "回复开发者"],
|
||
"actions": [
|
||
{
|
||
"tool": "project.diff",
|
||
"reason": "需要知道项目变化",
|
||
"input": { "checkpointId": checkpoint.checkpoint_id }
|
||
}
|
||
],
|
||
"response": ""
|
||
})
|
||
.to_string();
|
||
let base_url = spawn_mock_llm_server_responses_with_capture(vec![plan_json], 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",
|
||
"后台尝试检查本地项目差异",
|
||
"design-project-diff-policy-run",
|
||
)
|
||
.expect("start background task");
|
||
|
||
let plan_request = receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("plan llm request");
|
||
assert!(plan_request.contains("confirmTools"));
|
||
assert!(plan_request.contains("project.diff"));
|
||
assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err());
|
||
|
||
let runtime = wait_for_agent_runtime_confirmation(&root, "design-director");
|
||
assert_eq!(runtime.status, "waiting-for-confirmation");
|
||
assert_eq!(runtime.task_queue.waiting_for_confirmation, 1);
|
||
assert!(runtime
|
||
.tool_policy
|
||
.confirm_tools
|
||
.contains(&"project.diff".to_string()));
|
||
assert!(runtime.observations.iter().any(|item| item.contains(
|
||
"project.diff:waiting-for-confirmation · 项目权限策略要求用户确认:project.diff"
|
||
)));
|
||
assert!(!runtime
|
||
.observations
|
||
.iter()
|
||
.any(|item| item.contains("project.diff:ok")));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn background_agent_runtime_can_restore_checkpoint() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
let check_command = write_agent_runtime_verification_fixture(&root);
|
||
write_local_project_file_at(&root, "game/notes.txt", "v1").expect("write notes");
|
||
let checkpoint = create_local_project_checkpoint_at(&root).expect("create checkpoint");
|
||
write_local_project_file_at(&root, "game/notes.txt", "v2").expect("change notes");
|
||
write_local_project_file_at(&root, "game/temp.txt", "temp").expect("add temp file");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow project restore");
|
||
|
||
let (sender, receiver) = mpsc::channel();
|
||
let plan_json = serde_json::json!({
|
||
"thinkingSummary": "本轮修改需要回滚到 checkpoint",
|
||
"plan": ["恢复 checkpoint", "确认恢复结果"],
|
||
"actions": [
|
||
{
|
||
"tool": "project.restore",
|
||
"reason": "撤销本轮偏离目标的文件修改",
|
||
"input": { "checkpointId": checkpoint.checkpoint_id }
|
||
}
|
||
],
|
||
"response": ""
|
||
})
|
||
.to_string();
|
||
let base_url = spawn_mock_llm_server_responses_with_capture(
|
||
vec![
|
||
plan_json,
|
||
agent_runtime_verification_plan(check_command),
|
||
final_tool_plan_response("checkpoint 已恢复。"),
|
||
],
|
||
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",
|
||
"后台恢复到安全 checkpoint",
|
||
"design-project-restore-run",
|
||
)
|
||
.expect("start background task");
|
||
|
||
let plan_request = receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("plan llm request");
|
||
assert!(plan_request.contains("project.restore"));
|
||
assert!(plan_request.contains("checkpointId"));
|
||
let final_request = receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("final reply llm request");
|
||
assert!(final_request.contains("project.restore"));
|
||
assert!(final_request.contains("已恢复 checkpoint"));
|
||
assert!(final_request.contains("restoredCount="));
|
||
assert!(final_request.contains("deletedCount=1"));
|
||
let verified_final_request = receiver
|
||
.recv_timeout(Duration::from_secs(10))
|
||
.expect("final request after restore verification");
|
||
assert!(verified_final_request.contains("project.verify"));
|
||
assert!(verified_final_request.contains("AGENT_RUNTIME_CURRENT_REVISION_OK"));
|
||
|
||
let runtime = wait_for_agent_runtime_idle(&root, "design-director");
|
||
assert_eq!(runtime.status, "idle");
|
||
assert!(runtime
|
||
.tool_policy
|
||
.auto_tools
|
||
.contains(&"project.restore".to_string()));
|
||
assert!(runtime
|
||
.observations
|
||
.iter()
|
||
.any(|item| item.contains("project.restore:ok · 已恢复 checkpoint")));
|
||
assert!(runtime
|
||
.observations
|
||
.iter()
|
||
.any(|item| item.contains("project.verify:ok · check:agent 已通过")));
|
||
assert!(runtime.recent_tool_calls.iter().any(|call| {
|
||
call.tool == "project.restore"
|
||
&& call.status == "ok"
|
||
&& call
|
||
.detail
|
||
.as_deref()
|
||
.is_some_and(|detail| detail.contains("deletedCount=1"))
|
||
}));
|
||
assert_eq!(
|
||
fs::read_to_string(root.join("game/notes.txt")).expect("restored notes"),
|
||
"v1"
|
||
);
|
||
assert!(!root.join("game/temp.txt").exists());
|
||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
|
||
assert!(agent_db.contains("\"recordType\":\"project.restore\""));
|
||
assert!(agent_db.contains("\"tool\":\"project.restore\""));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn background_agent_runtime_project_restore_respects_project_policy() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
write_local_project_file_at(&root, "game/blocked-notes.txt", "v1").expect("write notes");
|
||
let checkpoint = create_local_project_checkpoint_at(&root).expect("create checkpoint");
|
||
write_local_project_file_at(&root, "game/blocked-notes.txt", "v2").expect("change notes");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: vec!["project.restore".to_string()],
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("write policy");
|
||
|
||
let (sender, receiver) = mpsc::channel();
|
||
let plan_json = serde_json::json!({
|
||
"thinkingSummary": "尝试恢复 checkpoint",
|
||
"plan": ["恢复 checkpoint", "等待权限结果"],
|
||
"actions": [
|
||
{
|
||
"tool": "project.restore",
|
||
"reason": "恢复项目到安全状态",
|
||
"input": { "checkpointId": checkpoint.checkpoint_id }
|
||
}
|
||
],
|
||
"response": ""
|
||
})
|
||
.to_string();
|
||
let base_url = spawn_mock_llm_server_responses_with_capture(vec![plan_json], 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",
|
||
"后台尝试恢复 checkpoint",
|
||
"design-project-restore-policy-run",
|
||
)
|
||
.expect("start background task");
|
||
|
||
let plan_request = receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("plan llm request");
|
||
assert!(plan_request.contains("confirmTools"));
|
||
assert!(plan_request.contains("project.restore"));
|
||
assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err());
|
||
|
||
let runtime = wait_for_agent_runtime_confirmation(&root, "design-director");
|
||
assert_eq!(runtime.status, "waiting-for-confirmation");
|
||
assert_eq!(runtime.task_queue.waiting_for_confirmation, 1);
|
||
assert!(runtime
|
||
.tool_policy
|
||
.confirm_tools
|
||
.contains(&"project.restore".to_string()));
|
||
assert!(runtime.observations.iter().any(|item| item.contains(
|
||
"project.restore:waiting-for-confirmation · 项目权限策略要求用户确认:project.restore"
|
||
)));
|
||
assert!(!runtime
|
||
.observations
|
||
.iter()
|
||
.any(|item| item.contains("project.restore:ok")));
|
||
assert_eq!(
|
||
fs::read_to_string(root.join("game/blocked-notes.txt")).expect("blocked notes"),
|
||
"v2"
|
||
);
|
||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
|
||
assert!(!agent_db.contains("\"recordType\":\"project.restore\""));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_action_receipt_is_idempotent_and_redacts_sensitive_detail() {
|
||
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",
|
||
"核对持久动作回执",
|
||
"design-action-receipt-run",
|
||
"agent-background-task",
|
||
"读取动作结果",
|
||
vec!["检查动作回执".to_string()],
|
||
)
|
||
.expect("start runtime");
|
||
runtime.loop_iteration = 1;
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "file.read".to_string(),
|
||
reason: Some("读取源码".to_string()),
|
||
input: serde_json::json!({ "path": "game/index.html" }),
|
||
};
|
||
let fingerprint = agent_runtime_tool_action_fingerprint(&action, &runtime.current_task);
|
||
let action_id = agent_runtime_tool_action_id(&runtime.run_id, 1, 0, 7, &fingerprint);
|
||
let secret = "sk-action-secret";
|
||
let observation = AgentRuntimeToolObservation {
|
||
tool: "file.read".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "已读取 game/index.html".to_string(),
|
||
detail: Some(format!(
|
||
"{}\nconst token = '{secret}';",
|
||
root.join("game/index.html").display()
|
||
)),
|
||
};
|
||
|
||
for _ in 0..2 {
|
||
append_agent_runtime_action_receipt(
|
||
&root,
|
||
&runtime,
|
||
&action_id,
|
||
&fingerprint,
|
||
"file.read",
|
||
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||
Some("path=game/index.html · startLine=1 · maxLines=120"),
|
||
&observation,
|
||
)
|
||
.expect("append idempotent receipt");
|
||
}
|
||
let conflict = append_agent_runtime_action_receipt(
|
||
&root,
|
||
&runtime,
|
||
&action_id,
|
||
&fingerprint,
|
||
"file.read",
|
||
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||
Some("path=game/index.html · startLine=1 · maxLines=120"),
|
||
&AgentRuntimeToolObservation {
|
||
summary: "冲突摘要不能覆盖既有回执".to_string(),
|
||
..observation.clone()
|
||
},
|
||
)
|
||
.expect_err("conflicting receipt identity must fail closed");
|
||
assert!(conflict.contains("身份冲突"));
|
||
let message_action = AgentRuntimeToolAction {
|
||
tool: "agent.message".to_string(),
|
||
reason: Some("发送私密上下文".to_string()),
|
||
input: serde_json::json!({ "agentId": "art-director", "content": "omitted" }),
|
||
};
|
||
let message_fingerprint =
|
||
agent_runtime_tool_action_fingerprint(&message_action, &runtime.current_task);
|
||
let message_action_id =
|
||
agent_runtime_tool_action_id(&runtime.run_id, 1, 1, 9, &message_fingerprint);
|
||
let private_detail = "internal-design-phrase-must-not-enter-receipt";
|
||
append_agent_runtime_action_receipt(
|
||
&root,
|
||
&runtime,
|
||
&message_action_id,
|
||
&message_fingerprint,
|
||
"agent.message",
|
||
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||
Some("agentId=art-director · contentChars=7"),
|
||
&AgentRuntimeToolObservation {
|
||
tool: "agent.message".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "已发送定向消息".to_string(),
|
||
detail: Some(private_detail.to_string()),
|
||
},
|
||
)
|
||
.expect("append message receipt without private detail");
|
||
let non_terminal_error = append_agent_runtime_action_receipt(
|
||
&root,
|
||
&runtime,
|
||
"action-aaaaaaaaaaaaaaaaaaaaaaaa",
|
||
&"a".repeat(64),
|
||
"file.list",
|
||
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||
None,
|
||
&AgentRuntimeToolObservation {
|
||
tool: "file.list".to_string(),
|
||
status: "running".to_string(),
|
||
summary: "非终态不得写入 receipt".to_string(),
|
||
detail: None,
|
||
},
|
||
)
|
||
.expect_err("non-terminal receipt must be rejected");
|
||
assert!(non_terminal_error.contains("终态"));
|
||
|
||
let records = read_agent_db_records_for_test(&root);
|
||
let receipts = records
|
||
.iter()
|
||
.filter(|record| {
|
||
record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE
|
||
&& record["actionId"] == action_id
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(receipts.len(), 1);
|
||
assert_eq!(receipts[0]["detailUnavailable"], true);
|
||
assert!(receipts[0]["safeDetail"].is_null());
|
||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
|
||
assert!(!agent_db.contains(secret));
|
||
assert!(!agent_db.contains(private_detail));
|
||
assert!(!agent_db.contains(root.to_string_lossy().as_ref()));
|
||
|
||
let history = observe_agent_runtime_action_history(
|
||
&root,
|
||
"design-director",
|
||
&runtime.run_id,
|
||
&serde_json::json!({ "actionId": action_id }),
|
||
);
|
||
assert_eq!(history.status, "ok");
|
||
let detail = history.detail.expect("history detail");
|
||
assert!(detail.contains("file.read"));
|
||
assert!(detail.contains("detailUnavailable"));
|
||
assert!(!detail.contains(secret));
|
||
assert!(!detail.contains(root.to_string_lossy().as_ref()));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_action_receipt_repairs_truncated_agent_db_tail() {
|
||
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",
|
||
"恢复半写入动作回执",
|
||
"design-action-tail-recovery-run",
|
||
"agent-background-task",
|
||
"补齐持久动作回执",
|
||
vec!["修复 JSONL 尾部".to_string()],
|
||
)
|
||
.expect("start runtime");
|
||
runtime.loop_iteration = 1;
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "project.patchset".to_string(),
|
||
reason: Some("恢复回执".to_string()),
|
||
input: serde_json::json!({ "changes": [] }),
|
||
};
|
||
let fingerprint = agent_runtime_tool_action_fingerprint(&action, &runtime.current_task);
|
||
let action_id = agent_runtime_tool_action_id(&runtime.run_id, 1, 0, 19, &fingerprint);
|
||
for index in 0..12 {
|
||
append_agent_db_record(
|
||
&root,
|
||
serde_json::json!({
|
||
"recordType": "test.action_history.padding",
|
||
"index": index,
|
||
"content": "用于验证有界尾窗不会从多字节字符中间解析".repeat(8),
|
||
}),
|
||
)
|
||
.expect("append bounded history padding");
|
||
}
|
||
let agent_db_path = root.join(".agent/agent.db");
|
||
fs::OpenOptions::new()
|
||
.append(true)
|
||
.open(&agent_db_path)
|
||
.expect("open agent db for crash tail")
|
||
.write_all(br#"{"recordType":"agent.runtime.action_receipt"#)
|
||
.expect("write truncated crash tail");
|
||
|
||
append_agent_runtime_action_receipt(
|
||
&root,
|
||
&runtime,
|
||
&action_id,
|
||
&fingerprint,
|
||
"project.patchset",
|
||
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||
Some("changes=1"),
|
||
&AgentRuntimeToolObservation {
|
||
tool: "project.patchset".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "project.patchset 已原子应用 1 项变更".to_string(),
|
||
detail: Some("checkpointId=checkpoint-tail · revision=2 · changeCount=1".to_string()),
|
||
},
|
||
)
|
||
.expect("repair tail and append receipt");
|
||
|
||
let records = read_agent_db_records_for_test(&root);
|
||
assert_eq!(
|
||
records
|
||
.iter()
|
||
.filter(|record| {
|
||
record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE
|
||
&& record["actionId"] == action_id
|
||
})
|
||
.count(),
|
||
1
|
||
);
|
||
let (bounded_records, bounded_truncated) =
|
||
read_agent_db_records_bounded(&root, 2_048).expect("read bounded agent db tail");
|
||
assert!(bounded_truncated);
|
||
assert!(bounded_records.iter().any(|record| {
|
||
record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE
|
||
&& record["actionId"] == action_id
|
||
}));
|
||
let history = observe_agent_runtime_action_history(
|
||
&root,
|
||
"design-director",
|
||
&runtime.run_id,
|
||
&serde_json::json!({ "actionId": action_id }),
|
||
);
|
||
assert_eq!(history.status, "ok");
|
||
assert!(history
|
||
.detail
|
||
.expect("history detail")
|
||
.contains("checkpoint-tail"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_action_receipt_rejects_corrupt_agent_db_middle() {
|
||
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",
|
||
"拒绝损坏的动作回执账本",
|
||
"design-action-middle-corruption-run",
|
||
"agent-background-task",
|
||
"验证 JSONL 中间损坏失败关闭",
|
||
vec!["保持既有审计字节不变".to_string()],
|
||
)
|
||
.expect("start runtime");
|
||
runtime.loop_iteration = 1;
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "file.read".to_string(),
|
||
reason: Some("验证账本完整性".to_string()),
|
||
input: serde_json::json!({ "path": "game/index.html" }),
|
||
};
|
||
let fingerprint = agent_runtime_tool_action_fingerprint(&action, &runtime.current_task);
|
||
let action_id = agent_runtime_tool_action_id(&runtime.run_id, 1, 0, 23, &fingerprint);
|
||
let agent_db_path = root.join(".agent/agent.db");
|
||
fs::OpenOptions::new()
|
||
.append(true)
|
||
.open(&agent_db_path)
|
||
.expect("open agent db for middle corruption")
|
||
.write_all(
|
||
b"{\"recordType\":\"broken-middle\"\n{\"recordType\":\"test.after-corruption\"}\n",
|
||
)
|
||
.expect("write corrupt middle and valid tail");
|
||
let corrupt_bytes = fs::read(&agent_db_path).expect("read corrupt agent db");
|
||
|
||
let error = append_agent_runtime_action_receipt(
|
||
&root,
|
||
&runtime,
|
||
&action_id,
|
||
&fingerprint,
|
||
"file.read",
|
||
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||
Some("path=game/index.html"),
|
||
&AgentRuntimeToolObservation {
|
||
tool: "file.read".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "已读取 game/index.html".to_string(),
|
||
detail: None,
|
||
},
|
||
)
|
||
.expect_err("middle corruption must fail closed");
|
||
assert!(error.contains("解析 Agent 本地索引"));
|
||
assert_eq!(
|
||
fs::read(&agent_db_path).expect("read preserved corrupt agent db"),
|
||
corrupt_bytes
|
||
);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_preview_receipt_binds_relative_evidence_to_owner_and_revision() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-preview-history", "预览证据回查测试")
|
||
.expect("project init");
|
||
let mut runtime = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"code-prototype",
|
||
"回查双视口证据",
|
||
"preview-action-history-run",
|
||
"agent-background-task",
|
||
"读取安全预览证据",
|
||
vec!["只返回项目内相对路径".to_string()],
|
||
)
|
||
.expect("start runtime");
|
||
runtime.loop_iteration = 1;
|
||
let action = AgentRuntimeToolAction {
|
||
tool: "preview.validate".to_string(),
|
||
reason: Some("验证当前 revision".to_string()),
|
||
input: serde_json::json!({}),
|
||
};
|
||
let fingerprint = agent_runtime_tool_action_fingerprint(&action, &runtime.current_task);
|
||
let append_preview_receipt = |action_index: u32,
|
||
occurrence_nonce: u64,
|
||
evidence_agent_id: &str,
|
||
evidence_run_id: &str,
|
||
evidence_revision: u64,
|
||
observation_revision: u64| {
|
||
let action_id = agent_runtime_tool_action_id(
|
||
&runtime.run_id,
|
||
1,
|
||
action_index,
|
||
occurrence_nonce,
|
||
&fingerprint,
|
||
);
|
||
let evidence_root = format!(
|
||
".agent/runtime/browser-validations/{evidence_agent_id}/{evidence_run_id}/{evidence_revision}"
|
||
);
|
||
append_agent_runtime_action_receipt(
|
||
&root,
|
||
&runtime,
|
||
&action_id,
|
||
&fingerprint,
|
||
"preview.validate",
|
||
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||
Some("viewports=desktop,mobile"),
|
||
&AgentRuntimeToolObservation {
|
||
tool: "preview.validate".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "浏览器验证已通过,已生成桌面与移动证据".to_string(),
|
||
detail: Some(
|
||
serde_json::json!({
|
||
"passed": true,
|
||
"revision": observation_revision,
|
||
"reportPath": format!("{evidence_root}/validation.json"),
|
||
"screenshots": [
|
||
format!("{evidence_root}/desktop.png"),
|
||
format!("{evidence_root}/mobile.png"),
|
||
],
|
||
"diagnostics": [],
|
||
"playtest": {"passed": true, "scenario": "lane-defense-v1"},
|
||
"privateAbsolutePath": "/home/example/private/screenshot.png",
|
||
})
|
||
.to_string(),
|
||
),
|
||
},
|
||
)
|
||
.expect("append preview receipt");
|
||
action_id
|
||
};
|
||
let action_id = append_preview_receipt(0, 29, &runtime.agent_id, &runtime.run_id, 7, 7);
|
||
let cross_agent_action_id =
|
||
append_preview_receipt(1, 31, "quality-review", &runtime.run_id, 7, 7);
|
||
let cross_run_action_id = append_preview_receipt(
|
||
2,
|
||
37,
|
||
&runtime.agent_id,
|
||
"preview-action-history-other-run",
|
||
7,
|
||
7,
|
||
);
|
||
let historical_revision_action_id =
|
||
append_preview_receipt(3, 41, &runtime.agent_id, &runtime.run_id, 6, 7);
|
||
|
||
let records = read_agent_db_records_for_test(&root);
|
||
let valid_receipt = records
|
||
.iter()
|
||
.find(|record| {
|
||
record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE
|
||
&& record["actionId"] == action_id
|
||
})
|
||
.expect("bound preview action receipt");
|
||
let safe_detail = serde_json::from_str::<Value>(
|
||
valid_receipt["safeDetail"]
|
||
.as_str()
|
||
.expect("bound preview safe detail"),
|
||
)
|
||
.expect("parse bound preview safe detail");
|
||
let evidence_root = format!(
|
||
".agent/runtime/browser-validations/{}/{}/7",
|
||
runtime.agent_id, runtime.run_id
|
||
);
|
||
assert_eq!(safe_detail["revision"], 7);
|
||
assert_eq!(
|
||
safe_detail["reportPath"],
|
||
format!("{evidence_root}/validation.json")
|
||
);
|
||
assert_eq!(
|
||
safe_detail["screenshots"],
|
||
serde_json::json!([
|
||
format!("{evidence_root}/desktop.png"),
|
||
format!("{evidence_root}/mobile.png"),
|
||
])
|
||
);
|
||
assert_eq!(valid_receipt["detailUnavailable"], false);
|
||
for rejected_action_id in [
|
||
&cross_agent_action_id,
|
||
&cross_run_action_id,
|
||
&historical_revision_action_id,
|
||
] {
|
||
let receipt = records
|
||
.iter()
|
||
.find(|record| {
|
||
record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE
|
||
&& record["actionId"] == rejected_action_id.as_str()
|
||
})
|
||
.expect("rejected preview action receipt");
|
||
assert!(receipt["safeDetail"].is_null());
|
||
assert_eq!(receipt["detailUnavailable"], true);
|
||
}
|
||
|
||
let history = observe_agent_runtime_action_history(
|
||
&root,
|
||
&runtime.agent_id,
|
||
&runtime.run_id,
|
||
&serde_json::json!({ "actionId": action_id }),
|
||
);
|
||
assert_eq!(history.status, "ok");
|
||
let detail = history.detail.expect("preview history detail");
|
||
assert!(detail.contains("desktop.png"));
|
||
assert!(detail.contains("mobile.png"));
|
||
assert!(detail.contains("validation.json"));
|
||
assert!(detail.contains("\"detailUnavailable\":false"));
|
||
assert!(!detail.contains("/home/example/private"));
|
||
assert!(!detail.contains("privateAbsolutePath"));
|
||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
|
||
assert!(!agent_db.contains("/home/example/private"));
|
||
assert!(!agent_db.contains("preview-action-history-other-run"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_preview_receipt_history_rejects_forged_owner_and_revision_paths() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(
|
||
&root,
|
||
"project-preview-forged-history",
|
||
"伪造预览证据回查测试",
|
||
)
|
||
.expect("project init");
|
||
let runtime = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"code-prototype",
|
||
"拒绝伪造双视口证据",
|
||
"preview-forged-history-run",
|
||
"agent-background-task",
|
||
"读取安全预览证据",
|
||
vec!["只返回当前 Agent、run 和 revision 的证据".to_string()],
|
||
)
|
||
.expect("start runtime");
|
||
let forged_cases = [
|
||
(
|
||
"action-111111111111111111111111",
|
||
"1".repeat(64),
|
||
format!(
|
||
".agent/runtime/browser-validations/quality-review/{}/7",
|
||
runtime.run_id
|
||
),
|
||
7,
|
||
),
|
||
(
|
||
"action-222222222222222222222222",
|
||
"2".repeat(64),
|
||
format!(
|
||
".agent/runtime/browser-validations/{}/preview-forged-other-run/7",
|
||
runtime.agent_id
|
||
),
|
||
7,
|
||
),
|
||
(
|
||
"action-333333333333333333333333",
|
||
"3".repeat(64),
|
||
format!(
|
||
".agent/runtime/browser-validations/{}/{}/6",
|
||
runtime.agent_id, runtime.run_id
|
||
),
|
||
7,
|
||
),
|
||
];
|
||
for (action_id, action_fingerprint, evidence_root, revision) in &forged_cases {
|
||
append_agent_db_record_fixture(
|
||
&root,
|
||
serde_json::json!({
|
||
"recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE,
|
||
"agentId": runtime.agent_id,
|
||
"taskId": runtime.task_id,
|
||
"sessionId": runtime.session_id,
|
||
"runId": runtime.run_id,
|
||
"actionId": action_id,
|
||
"actionFingerprint": action_fingerprint,
|
||
"tool": "preview.validate",
|
||
"executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||
"status": "ok",
|
||
"summary": "伪造预览证据不得进入动作历史",
|
||
"safeDetail": serde_json::json!({
|
||
"passed": true,
|
||
"revision": revision,
|
||
"reportPath": format!("{evidence_root}/validation.json"),
|
||
"screenshots": [
|
||
format!("{evidence_root}/desktop.png"),
|
||
format!("{evidence_root}/mobile.png"),
|
||
],
|
||
"diagnosticsCount": 0,
|
||
"playtestPassed": true,
|
||
"playtestScenario": "lane-defense-v1",
|
||
})
|
||
.to_string(),
|
||
"detailUnavailable": false,
|
||
}),
|
||
)
|
||
.expect("append forged preview receipt");
|
||
|
||
let history = observe_agent_runtime_action_history(
|
||
&root,
|
||
&runtime.agent_id,
|
||
&runtime.run_id,
|
||
&serde_json::json!({ "actionId": action_id }),
|
||
);
|
||
assert_eq!(history.status, "ok");
|
||
let detail = history.detail.expect("forged preview history detail");
|
||
assert!(detail.contains("\"detailUnavailable\":true"));
|
||
assert!(!detail.contains(evidence_root));
|
||
assert!(!detail.contains("desktop.png"));
|
||
assert!(!detail.contains("mobile.png"));
|
||
assert!(!detail.contains("validation.json"));
|
||
}
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_action_history_folds_receipts_and_legacy_observations() {
|
||
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",
|
||
"回查当前 run 动作",
|
||
"design-action-history-run",
|
||
"agent-background-task",
|
||
"读取动作历史",
|
||
vec!["按终态筛选".to_string()],
|
||
)
|
||
.expect("start runtime");
|
||
runtime.loop_iteration = 1;
|
||
|
||
let patch_action = AgentRuntimeToolAction {
|
||
tool: "project.patchset".to_string(),
|
||
reason: Some("批量修改".to_string()),
|
||
input: serde_json::json!({ "changes": [] }),
|
||
};
|
||
let patch_fingerprint =
|
||
agent_runtime_tool_action_fingerprint(&patch_action, &runtime.current_task);
|
||
let patch_action_id =
|
||
agent_runtime_tool_action_id(&runtime.run_id, 1, 0, 11, &patch_fingerprint);
|
||
append_agent_runtime_action_receipt(
|
||
&root,
|
||
&runtime,
|
||
&patch_action_id,
|
||
&patch_fingerprint,
|
||
"project.patchset",
|
||
AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION,
|
||
Some("changes=2"),
|
||
&AgentRuntimeToolObservation {
|
||
tool: "project.patchset".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "project.patchset 已原子应用 2 项变更".to_string(),
|
||
detail: Some(
|
||
"checkpointId=checkpoint-history · revision=2 · changeCount=2 · source=/home/example/private/key.txt"
|
||
.to_string(),
|
||
),
|
||
},
|
||
)
|
||
.expect("append patchset receipt");
|
||
|
||
let legacy_action = AgentRuntimeToolAction {
|
||
tool: "command.exec".to_string(),
|
||
reason: Some("执行失败测试".to_string()),
|
||
input: serde_json::json!({}),
|
||
};
|
||
let legacy_fingerprint =
|
||
agent_runtime_tool_action_fingerprint(&legacy_action, &runtime.current_task);
|
||
let legacy_action_id =
|
||
agent_runtime_tool_action_id(&runtime.run_id, 1, 1, 13, &legacy_fingerprint);
|
||
append_agent_db_record(
|
||
&root,
|
||
serde_json::json!({
|
||
"recordType": "agent.runtime.tool_action.executing",
|
||
"agentId": runtime.agent_id,
|
||
"runId": runtime.run_id,
|
||
"actionId": legacy_action_id,
|
||
"actionFingerprint": legacy_fingerprint,
|
||
"tool": "command.exec",
|
||
"executionMode": "confirmation",
|
||
"inputSummary": "program=node · argsCount=2"
|
||
}),
|
||
)
|
||
.expect("append legacy executing");
|
||
append_agent_db_record(
|
||
&root,
|
||
serde_json::json!({
|
||
"recordType": "agent.runtime.tool_observation",
|
||
"agentId": runtime.agent_id,
|
||
"runId": runtime.run_id,
|
||
"actionId": legacy_action_id,
|
||
"actionFingerprint": legacy_fingerprint,
|
||
"tool": "command.exec",
|
||
"status": "failed",
|
||
"summary": "命令退出码为 1"
|
||
}),
|
||
)
|
||
.expect("append legacy observation");
|
||
|
||
let waiting_action_id =
|
||
agent_runtime_tool_action_id(&runtime.run_id, 1, 2, 15, &legacy_fingerprint);
|
||
append_agent_db_record(
|
||
&root,
|
||
serde_json::json!({
|
||
"recordType": "agent.runtime.tool_action.observed",
|
||
"agentId": runtime.agent_id,
|
||
"runId": runtime.run_id,
|
||
"actionId": waiting_action_id,
|
||
"actionFingerprint": legacy_fingerprint,
|
||
"tool": "command.exec",
|
||
"executionMode": "auto",
|
||
"status": "pending",
|
||
"observationStatus": "waiting-for-confirmation"
|
||
}),
|
||
)
|
||
.expect("append legacy waiting observation");
|
||
|
||
let history_action = AgentRuntimeToolAction {
|
||
tool: "agent.action_history".to_string(),
|
||
reason: Some("查询动作历史".to_string()),
|
||
input: serde_json::json!({}),
|
||
};
|
||
let history_fingerprint =
|
||
agent_runtime_tool_action_fingerprint(&history_action, &runtime.current_task);
|
||
let history_action_id =
|
||
agent_runtime_tool_action_id(&runtime.run_id, 1, 2, 17, &history_fingerprint);
|
||
append_agent_runtime_action_receipt(
|
||
&root,
|
||
&runtime,
|
||
&history_action_id,
|
||
&history_fingerprint,
|
||
"agent.action_history",
|
||
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||
Some("limit=5"),
|
||
&AgentRuntimeToolObservation {
|
||
tool: "agent.action_history".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "已读取动作历史".to_string(),
|
||
detail: Some("不应递归进入默认结果".to_string()),
|
||
},
|
||
)
|
||
.expect("append history receipt");
|
||
|
||
let history = observe_agent_runtime_action_history(
|
||
&root,
|
||
"design-director",
|
||
&runtime.run_id,
|
||
&serde_json::json!({ "limit": 10 }),
|
||
);
|
||
assert_eq!(history.status, "ok");
|
||
assert!(history.summary.contains("2 条终态动作"));
|
||
let detail = history.detail.expect("history detail");
|
||
assert!(detail.contains("\"agentId\":\"design-director\""));
|
||
assert!(detail.contains(&format!("\"sessionId\":\"{}\"", runtime.session_id)));
|
||
assert!(detail.contains(&format!("\"runId\":\"{}\"", runtime.run_id)));
|
||
assert!(detail.contains(&patch_action_id));
|
||
assert!(detail.contains("checkpoint-history"));
|
||
assert!(!detail.contains("/home/example/private/key.txt"));
|
||
assert!(!detail.contains("source="));
|
||
assert!(detail.contains(&legacy_action_id));
|
||
assert!(!detail.contains(&waiting_action_id));
|
||
assert!(detail.contains("detailUnavailable\":true"));
|
||
assert!(!detail.contains(&history_action_id));
|
||
|
||
let filtered = observe_agent_runtime_action_history(
|
||
&root,
|
||
"design-director",
|
||
&runtime.run_id,
|
||
&serde_json::json!({ "tool": "agent.action_history", "status": "ok" }),
|
||
);
|
||
assert_eq!(filtered.status, "ok");
|
||
assert!(filtered
|
||
.detail
|
||
.expect("filtered detail")
|
||
.contains(&history_action_id));
|
||
|
||
let other_agent_action_id = "action-ffffffffffffffffffffffff";
|
||
append_agent_db_record_fixture(
|
||
&root,
|
||
serde_json::json!({
|
||
"recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE,
|
||
"agentId": "art-director",
|
||
"taskId": "art-director",
|
||
"sessionId": "art-session",
|
||
"runId": "art-action-history-run",
|
||
"actionId": other_agent_action_id,
|
||
"actionFingerprint": "f".repeat(64),
|
||
"tool": "file.list",
|
||
"executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||
"inputSummary": "path=assets",
|
||
"status": "ok",
|
||
"summary": "已读取美术目录",
|
||
"safeDetail": serde_json::Value::Null,
|
||
"detailUnavailable": true,
|
||
}),
|
||
)
|
||
.expect("append other agent receipt");
|
||
let cross_agent = observe_agent_runtime_action_history(
|
||
&root,
|
||
"design-director",
|
||
&runtime.run_id,
|
||
&serde_json::json!({ "runId": "art-action-history-run" }),
|
||
);
|
||
assert_eq!(cross_agent.status, "ok");
|
||
let cross_agent_detail = cross_agent.detail.expect("cross agent history detail");
|
||
assert!(cross_agent_detail.contains("\"count\":0"));
|
||
assert!(!cross_agent_detail.contains(other_agent_action_id));
|
||
assert!(!cross_agent_detail.contains("art-director"));
|
||
|
||
let mut default_limit_action_ids = Vec::new();
|
||
for index in 0..6_u64 {
|
||
let action_id = format!("action-{:024x}", 0xabc000_u64 + index);
|
||
append_agent_db_record_fixture(
|
||
&root,
|
||
serde_json::json!({
|
||
"recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE,
|
||
"agentId": runtime.agent_id,
|
||
"taskId": runtime.task_id,
|
||
"sessionId": runtime.session_id,
|
||
"runId": runtime.run_id,
|
||
"actionId": action_id,
|
||
"actionFingerprint": format!("{:064x}", 0xdef000_u64 + index),
|
||
"tool": "file.list",
|
||
"executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||
"inputSummary": format!("index={index}"),
|
||
"status": "ok",
|
||
"summary": format!("默认边界记录 {index}"),
|
||
"safeDetail": serde_json::Value::Null,
|
||
"detailUnavailable": true,
|
||
}),
|
||
)
|
||
.expect("append default limit receipt");
|
||
default_limit_action_ids.push(action_id);
|
||
}
|
||
let default_limited = observe_agent_runtime_action_history(
|
||
&root,
|
||
"design-director",
|
||
&runtime.run_id,
|
||
&serde_json::json!({}),
|
||
);
|
||
assert_eq!(default_limited.status, "ok");
|
||
let default_payload = serde_json::from_str::<Value>(
|
||
default_limited
|
||
.detail
|
||
.as_deref()
|
||
.expect("default limited history detail"),
|
||
)
|
||
.expect("parse default limited history");
|
||
assert_eq!(default_payload["count"], 5);
|
||
assert_eq!(default_payload["truncated"], true);
|
||
let default_encoded = default_payload.to_string();
|
||
assert!(!default_encoded.contains(&default_limit_action_ids[0]));
|
||
assert!(default_encoded.contains(&default_limit_action_ids[5]));
|
||
|
||
let invalid = observe_agent_runtime_action_history(
|
||
&root,
|
||
"design-director",
|
||
&runtime.run_id,
|
||
&serde_json::json!({ "limit": 11 }),
|
||
);
|
||
assert_eq!(invalid.status, "failed");
|
||
|
||
let forged_detail_action_id = "action-eeeeeeeeeeeeeeeeeeeeeeee";
|
||
let forged_detail_marker = "FORGED_FILE_READ_SOURCE_MUST_NOT_RETURN";
|
||
append_agent_db_record_fixture(
|
||
&root,
|
||
serde_json::json!({
|
||
"recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE,
|
||
"agentId": runtime.agent_id,
|
||
"taskId": runtime.task_id,
|
||
"sessionId": runtime.session_id,
|
||
"runId": runtime.run_id,
|
||
"actionId": forged_detail_action_id,
|
||
"actionFingerprint": "e".repeat(64),
|
||
"tool": "file.read",
|
||
"executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||
"status": "ok",
|
||
"summary": "伪造旧版 file.read receipt",
|
||
"safeDetail": forged_detail_marker,
|
||
"detailUnavailable": false,
|
||
}),
|
||
)
|
||
.expect("append forged safe detail receipt");
|
||
let sanitized_forged = observe_agent_runtime_action_history(
|
||
&root,
|
||
"design-director",
|
||
&runtime.run_id,
|
||
&serde_json::json!({ "actionId": forged_detail_action_id }),
|
||
);
|
||
assert_eq!(sanitized_forged.status, "ok");
|
||
let sanitized_forged_detail = sanitized_forged.detail.expect("sanitized forged history");
|
||
assert!(sanitized_forged_detail.contains(forged_detail_action_id));
|
||
assert!(sanitized_forged_detail.contains("detailUnavailable\":true"));
|
||
assert!(!sanitized_forged_detail.contains(forged_detail_marker));
|
||
|
||
let non_terminal_action_id = "action-dddddddddddddddddddddddd";
|
||
append_agent_db_record_fixture(
|
||
&root,
|
||
serde_json::json!({
|
||
"recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE,
|
||
"agentId": runtime.agent_id,
|
||
"taskId": runtime.task_id,
|
||
"sessionId": runtime.session_id,
|
||
"runId": runtime.run_id,
|
||
"actionId": non_terminal_action_id,
|
||
"actionFingerprint": "d".repeat(64),
|
||
"tool": "file.list",
|
||
"executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||
"status": "running",
|
||
"summary": "伪造非终态 receipt",
|
||
}),
|
||
)
|
||
.expect("append forged non-terminal receipt");
|
||
let non_terminal = observe_agent_runtime_action_history(
|
||
&root,
|
||
"design-director",
|
||
&runtime.run_id,
|
||
&serde_json::json!({ "actionId": non_terminal_action_id }),
|
||
);
|
||
assert_eq!(non_terminal.status, "failed");
|
||
assert!(non_terminal
|
||
.detail
|
||
.expect("non-terminal receipt failure detail")
|
||
.contains("不是终态"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_action_history_fails_closed_on_action_identity_conflict() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "动作身份冲突测试").expect("project init");
|
||
let runtime = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"读取冲突动作历史",
|
||
"design-action-history-conflict-run",
|
||
"agent-background-task",
|
||
"验证冲突失败关闭",
|
||
Vec::new(),
|
||
)
|
||
.expect("start runtime");
|
||
let action_id = "action-cccccccccccccccccccccccc";
|
||
append_agent_db_record(
|
||
&root,
|
||
serde_json::json!({
|
||
"recordType": "agent.runtime.tool_action.executing",
|
||
"agentId": runtime.agent_id,
|
||
"taskId": runtime.task_id,
|
||
"sessionId": runtime.session_id,
|
||
"runId": runtime.run_id,
|
||
"actionId": action_id,
|
||
"actionFingerprint": "c".repeat(64),
|
||
"tool": "file.list",
|
||
"executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||
"inputSummary": "path=."
|
||
}),
|
||
)
|
||
.expect("append action metadata");
|
||
append_agent_db_record_fixture(
|
||
&root,
|
||
serde_json::json!({
|
||
"recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE,
|
||
"agentId": runtime.agent_id,
|
||
"taskId": runtime.task_id,
|
||
"sessionId": runtime.session_id,
|
||
"runId": runtime.run_id,
|
||
"actionId": action_id,
|
||
"actionFingerprint": "b".repeat(64),
|
||
"tool": "file.list",
|
||
"executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||
"status": "ok",
|
||
"inputSummary": "path=.",
|
||
"summary": "伪造冲突 receipt",
|
||
"safeDetail": serde_json::Value::Null,
|
||
"detailUnavailable": true,
|
||
}),
|
||
)
|
||
.expect("append conflicting receipt fixture");
|
||
|
||
let observation = observe_agent_runtime_action_history(
|
||
&root,
|
||
"design-director",
|
||
&runtime.run_id,
|
||
&serde_json::json!({ "actionId": action_id }),
|
||
);
|
||
assert_eq!(observation.status, "failed");
|
||
assert!(observation
|
||
.detail
|
||
.expect("identity conflict detail")
|
||
.contains("动作账本身份冲突"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_action_history_fails_closed_on_task_ledger_conflict() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "任务身份冲突测试").expect("project init");
|
||
let runtime = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"读取冲突任务历史",
|
||
"design-action-history-task-conflict-run",
|
||
"agent-background-task",
|
||
"验证任务身份失败关闭",
|
||
Vec::new(),
|
||
)
|
||
.expect("start runtime");
|
||
let action_id = "action-bbbbbbbbbbbbbbbbbbbbbbbb";
|
||
append_agent_db_record_fixture(
|
||
&root,
|
||
serde_json::json!({
|
||
"recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE,
|
||
"agentId": runtime.agent_id,
|
||
"taskId": "forged-task",
|
||
"sessionId": runtime.session_id,
|
||
"runId": runtime.run_id,
|
||
"actionId": action_id,
|
||
"actionFingerprint": "b".repeat(64),
|
||
"tool": "file.list",
|
||
"executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||
"status": "ok",
|
||
"inputSummary": serde_json::Value::Null,
|
||
"summary": "伪造任务身份 receipt",
|
||
"safeDetail": serde_json::Value::Null,
|
||
"detailUnavailable": true,
|
||
}),
|
||
)
|
||
.expect("append task-conflicting receipt fixture");
|
||
|
||
let observation = observe_agent_runtime_action_history(
|
||
&root,
|
||
"design-director",
|
||
&runtime.run_id,
|
||
&serde_json::json!({ "actionId": action_id }),
|
||
);
|
||
assert_eq!(observation.status, "failed");
|
||
assert!(observation
|
||
.detail
|
||
.expect("task conflict detail")
|
||
.contains("任务账本身份冲突"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_action_history_drops_optional_detail_without_damaging_identity() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
let runtime = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"读取超长动作历史",
|
||
&format!("design-action-history-output-{}", "r".repeat(120)),
|
||
"agent-background-task",
|
||
"验证结构化输出预算",
|
||
vec!["保持完整身份字段".to_string()],
|
||
)
|
||
.expect("start runtime");
|
||
let mut expected_fingerprints = BTreeMap::new();
|
||
for index in 0..10_u64 {
|
||
let action_id = format!("action-{:024x}", 0xfeed00_u64 + index);
|
||
let fingerprint = format!("{:064x}", 0xbeef00_u64 + index);
|
||
expected_fingerprints.insert(action_id.clone(), fingerprint.clone());
|
||
append_agent_db_record_fixture(
|
||
&root,
|
||
serde_json::json!({
|
||
"recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE,
|
||
"agentId": runtime.agent_id,
|
||
"taskId": runtime.task_id,
|
||
"sessionId": runtime.session_id,
|
||
"runId": runtime.run_id,
|
||
"actionId": action_id,
|
||
"actionFingerprint": fingerprint,
|
||
"tool": "project.patchset",
|
||
"executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||
"inputSummary": "input".repeat(80),
|
||
"status": "ok",
|
||
"summary": "summary".repeat(80),
|
||
"safeDetail": format!(
|
||
"checkpointId=checkpoint-{index}-{} · revision={} · changeCount={} · revisionAdvanced=true",
|
||
"c".repeat(120),
|
||
"2".repeat(120),
|
||
"3".repeat(120)
|
||
),
|
||
"detailUnavailable": false,
|
||
}),
|
||
)
|
||
.expect("append oversized history receipt");
|
||
}
|
||
let observation = observe_agent_runtime_action_history(
|
||
&root,
|
||
"design-director",
|
||
&runtime.run_id,
|
||
&serde_json::json!({ "tool": "project.patchset", "limit": 10 }),
|
||
);
|
||
assert_eq!(observation.status, "ok");
|
||
let detail = observation.detail.expect("bounded history detail");
|
||
assert!(detail.chars().count() <= AGENT_RUNTIME_ACTION_HISTORY_MAX_OUTPUT_CHARS);
|
||
let payload = serde_json::from_str::<Value>(&detail).expect("history remains valid json");
|
||
assert_eq!(payload["outputTruncated"], true);
|
||
assert_eq!(payload["truncated"], false);
|
||
let actions = payload["actions"].as_array().expect("history actions");
|
||
assert_eq!(actions.len(), 10);
|
||
assert_eq!(payload["count"], actions.len());
|
||
for action in actions {
|
||
let action_id = action["actionId"].as_str().expect("action id");
|
||
assert_eq!(action["runId"], runtime.run_id);
|
||
assert_eq!(
|
||
action["actionFingerprint"].as_str(),
|
||
expected_fingerprints.get(action_id).map(String::as_str)
|
||
);
|
||
}
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn action_history_and_final_reply_are_blocked_until_isolated_join_is_claimed() {
|
||
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", "all-join 完成门禁测试").expect("project init");
|
||
let state = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"code-prototype",
|
||
"等待隔离子 Agent all-join",
|
||
"code-isolated-join-gate-run",
|
||
"agent-background-task",
|
||
"验证 all-join 门禁",
|
||
vec!["认领 join 后才能收束".to_string()],
|
||
)
|
||
.expect("start runtime");
|
||
let parent_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "code-prototype")
|
||
.expect("acquire active parent lane")
|
||
.expect("parent lane available");
|
||
let group = create_or_read_isolated_group_at(
|
||
&root,
|
||
"code-prototype",
|
||
&state.run_id,
|
||
&state.session_id,
|
||
"action-555555555555555555555555",
|
||
&GameCreationIsolatedAgentSpawnRequest {
|
||
children: vec![GameCreationIsolatedAgentChildSpec {
|
||
template_agent_id: "quality-review".to_string(),
|
||
task: "读取项目证据并给出独立结论".to_string(),
|
||
acceptance_criteria: vec!["已读取 repository context".to_string()],
|
||
expected_artifacts: vec!["e2e/review/evidence.txt".to_string()],
|
||
write_scopes: vec!["e2e/review/**".to_string()],
|
||
}],
|
||
join_mode: GameCreationIsolatedAgentJoinMode::All,
|
||
},
|
||
)
|
||
.expect("create isolated group");
|
||
|
||
let blocker = isolated_join_completion_blocker_at(&root, "code-prototype", &state.run_id)
|
||
.expect("incomplete all-join blocks final reply");
|
||
assert_eq!(blocker.tool, "runtime.isolated_join");
|
||
assert_eq!(blocker.status, "blocked");
|
||
assert!(blocker
|
||
.detail
|
||
.as_deref()
|
||
.is_some_and(|detail| detail.contains("waitingGroups=1")));
|
||
let waiting_finalization = finish_game_creator_agent_background_runtime_turn_at(
|
||
&root,
|
||
state.clone(),
|
||
"等待状态不得持久化的回复",
|
||
0,
|
||
&[],
|
||
)
|
||
.expect("waiting join is a recoverable finalization blocker");
|
||
assert!(matches!(
|
||
waiting_finalization,
|
||
AgentBackgroundFinalizationOutcome::Stale(AgentRuntimeToolObservation {
|
||
ref tool,
|
||
..
|
||
}) if tool == "runtime.isolated_join"
|
||
));
|
||
|
||
let instance = resolve_isolated_agent_instance_at(&root, &group.instance_ids[0])
|
||
.expect("resolve isolated instance");
|
||
let join = record_isolated_child_result_at(
|
||
&root,
|
||
&GameCreationIsolatedAgentChildResult {
|
||
delegation_id: instance.delegation_id.clone(),
|
||
instance_id: instance.instance_id.clone(),
|
||
template_agent_id: instance.template_agent_id.clone(),
|
||
run_id: instance.run_id.clone(),
|
||
status: GameCreationIsolatedAgentResultStatus::Completed,
|
||
summary: "独立审查已完成".to_string(),
|
||
artifacts: vec![GameCreationIsolatedAgentArtifact {
|
||
path: "e2e/review/evidence.txt".to_string(),
|
||
sha256: "a".repeat(64),
|
||
}],
|
||
evidence: vec![GameCreationIsolatedAgentEvidence {
|
||
kind: "repository.context".to_string(),
|
||
summary: "已读取 repository context".to_string(),
|
||
path: None,
|
||
sha256: None,
|
||
}],
|
||
verified_revision: None,
|
||
error: None,
|
||
},
|
||
)
|
||
.expect("record isolated child result")
|
||
.expect("all join ready");
|
||
dispatch_isolated_agent_join_at(&root, join.clone()).expect("queue isolated join");
|
||
|
||
let ready_blocker = isolated_join_completion_blocker_at(&root, "code-prototype", &state.run_id)
|
||
.expect("ready but unclaimed all-join still blocks final reply");
|
||
assert!(ready_blocker
|
||
.detail
|
||
.as_deref()
|
||
.is_some_and(|detail| detail.contains("readyUnclaimedGroups=1")));
|
||
let ready_finalization = finish_game_creator_agent_background_runtime_turn_at(
|
||
&root,
|
||
state.clone(),
|
||
"未认领状态不得持久化的回复",
|
||
0,
|
||
&[],
|
||
)
|
||
.expect("ready unclaimed join is a recoverable finalization blocker");
|
||
assert!(matches!(
|
||
ready_finalization,
|
||
AgentBackgroundFinalizationOutcome::Stale(AgentRuntimeToolObservation {
|
||
ref tool,
|
||
..
|
||
}) if tool == "runtime.isolated_join"
|
||
));
|
||
|
||
let observation = execute_game_creator_agent_runtime_tool_action(
|
||
&root,
|
||
"code-prototype",
|
||
&state.run_id,
|
||
&state.current_task,
|
||
&AgentRuntimeToolAction {
|
||
tool: "agent.action_history".to_string(),
|
||
reason: Some("尝试提前验收动作历史".to_string()),
|
||
input: serde_json::json!({ "limit": 5 }),
|
||
},
|
||
)
|
||
.await;
|
||
assert_eq!(observation.tool, "agent.action_history");
|
||
assert_eq!(observation.status, "blocked");
|
||
assert!(observation.summary.contains("all-join"));
|
||
assert!(observation
|
||
.detail
|
||
.as_deref()
|
||
.is_some_and(|detail| detail.contains("agent.run_status")));
|
||
|
||
let run_status = execute_game_creator_agent_runtime_tool_action_with_action_id(
|
||
&root,
|
||
"code-prototype",
|
||
&state.run_id,
|
||
&state.current_task,
|
||
&AgentRuntimeToolAction {
|
||
tool: "agent.run_status".to_string(),
|
||
reason: Some("认领 ready all-join".to_string()),
|
||
input: serde_json::json!({ "scope": "all" }),
|
||
},
|
||
Some("action-666666666666666666666666"),
|
||
)
|
||
.await;
|
||
assert_eq!(run_status.status, "ok");
|
||
assert!(run_status.summary.contains("ready all-join"));
|
||
let delivery = read_isolated_join_delivery_at(&root, &join)
|
||
.expect("read join delivery")
|
||
.expect("join delivery exists");
|
||
assert_eq!(
|
||
delivery.status,
|
||
IsolatedAgentJoinDeliveryStatus::ClaimedByParent
|
||
);
|
||
assert_eq!(
|
||
delivery.claimed_by_action_id.as_deref(),
|
||
Some("action-666666666666666666666666")
|
||
);
|
||
assert!(mark_isolated_join_claim_observed_at(
|
||
&root,
|
||
"code-prototype",
|
||
&state.run_id,
|
||
"action-666666666666666666666666",
|
||
)
|
||
.expect("persist direct run_status observation"));
|
||
assert!(isolated_join_completion_blocker_at(&root, "code-prototype", &state.run_id).is_none());
|
||
|
||
let history = execute_game_creator_agent_runtime_tool_action_with_action_id(
|
||
&root,
|
||
"code-prototype",
|
||
&state.run_id,
|
||
&state.current_task,
|
||
&AgentRuntimeToolAction {
|
||
tool: "agent.action_history".to_string(),
|
||
reason: Some("join 认领后读取动作历史".to_string()),
|
||
input: serde_json::json!({ "limit": 5 }),
|
||
},
|
||
Some("action-777777777777777777777777"),
|
||
)
|
||
.await;
|
||
assert_eq!(history.status, "ok");
|
||
|
||
let completed = finish_game_creator_agent_background_runtime_turn_at(
|
||
&root,
|
||
state.clone(),
|
||
"认领 all-join 后允许持久化的回复",
|
||
0,
|
||
&[],
|
||
)
|
||
.expect("finalize after join claim");
|
||
assert!(matches!(
|
||
completed,
|
||
AgentBackgroundFinalizationOutcome::Completed(_)
|
||
));
|
||
let conversation = read_local_conversation_for_session_at(
|
||
&root,
|
||
Some("code-prototype"),
|
||
Some(&state.session_id),
|
||
)
|
||
.expect("read finalization conversation");
|
||
assert!(conversation.messages.iter().any(|message| {
|
||
message.role == "assistant" && message.content == "认领 all-join 后允许持久化的回复"
|
||
}));
|
||
assert!(!conversation.messages.iter().any(|message| {
|
||
message.role == "assistant"
|
||
&& matches!(
|
||
message.content.as_str(),
|
||
"等待状态不得持久化的回复" | "未认领状态不得持久化的回复"
|
||
)
|
||
}));
|
||
|
||
drop(parent_lock);
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_runtime_action_receipt_never_persists_file_uri_identity_fields() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "动作回执身份清洗测试").expect("project init");
|
||
let runtime = start_game_creator_agent_runtime_task_at(
|
||
&root,
|
||
"design-director",
|
||
"拒绝路径型动作身份",
|
||
"design-safe-receipt-identity-run",
|
||
"agent-background-task",
|
||
"验证回执身份边界",
|
||
vec!["不持久化 file URI".to_string()],
|
||
)
|
||
.expect("start runtime");
|
||
let action_id = "action-333333333333333333333333";
|
||
let fingerprint = "3".repeat(64);
|
||
let private_uri = "file:///home/alice/private-project";
|
||
|
||
let mut unsafe_run = runtime.clone();
|
||
unsafe_run.run_id = private_uri.to_string();
|
||
let _ = append_agent_runtime_action_receipt(
|
||
&root,
|
||
&unsafe_run,
|
||
action_id,
|
||
&fingerprint,
|
||
"file.list",
|
||
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||
None,
|
||
&AgentRuntimeToolObservation {
|
||
tool: "file.list".to_string(),
|
||
status: "ok".to_string(),
|
||
summary: "读取完成".to_string(),
|
||
detail: None,
|
||
},
|
||
);
|
||
let _ = append_agent_runtime_action_receipt(
|
||
&root,
|
||
&runtime,
|
||
"action-444444444444444444444444",
|
||
&"4".repeat(64),
|
||
private_uri,
|
||
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||
None,
|
||
&AgentRuntimeToolObservation {
|
||
tool: private_uri.to_string(),
|
||
status: "rejected".to_string(),
|
||
summary: "未知工具已拒绝".to_string(),
|
||
detail: None,
|
||
},
|
||
);
|
||
|
||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("read agent db");
|
||
assert!(!agent_db.contains(private_uri));
|
||
assert!(!agent_db.contains("/home/alice/private-project"));
|
||
let invalid_query = observe_agent_runtime_action_history(
|
||
&root,
|
||
"design-director",
|
||
&runtime.run_id,
|
||
&serde_json::json!({ "runId": private_uri }),
|
||
);
|
||
assert_eq!(invalid_query.status, "failed");
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn background_agent_runtime_can_query_its_persisted_action_history() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||
let (sender, receiver) = mpsc::channel();
|
||
let first_plan = serde_json::json!({
|
||
"thinkingSummary": "先读取项目摘要,再通过持久回执核对动作",
|
||
"plan": ["读取项目摘要", "回查已完成动作"],
|
||
"actions": [{
|
||
"tool": "file.list",
|
||
"reason": "取得项目目录摘要",
|
||
"input": {}
|
||
}],
|
||
"response": ""
|
||
})
|
||
.to_string();
|
||
let history_plan = serde_json::json!({
|
||
"thinkingSummary": "项目摘要已返回,需要按工具名核对终态回执",
|
||
"plan": ["回查 file.list 终态"],
|
||
"actions": [{
|
||
"tool": "agent.action_history",
|
||
"reason": "确认早先只读动作已经持久化且没有重放",
|
||
"input": { "tool": "file.list", "limit": 5 }
|
||
}],
|
||
"response": ""
|
||
})
|
||
.to_string();
|
||
let base_url = spawn_mock_llm_server_responses_with_capture(
|
||
vec![
|
||
first_plan,
|
||
history_plan,
|
||
final_tool_plan_response("项目摘要动作已有持久回执,可以继续下一步。"),
|
||
],
|
||
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",
|
||
"读取项目摘要并核对持久动作回执",
|
||
"design-action-history-loop-run",
|
||
)
|
||
.expect("start background task");
|
||
|
||
let first_request = receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("first plan request");
|
||
assert!(first_request.contains("agent.action_history"));
|
||
let history_request = receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("history plan request");
|
||
assert!(history_request.contains("file.list"));
|
||
let final_request = receiver
|
||
.recv_timeout(Duration::from_secs(2))
|
||
.expect("final plan request");
|
||
assert!(final_request.contains("agent.action_history"));
|
||
assert!(final_request.contains("actionId"));
|
||
assert!(final_request.contains("file.list"));
|
||
|
||
let runtime = wait_for_agent_runtime_idle(&root, "design-director");
|
||
assert_eq!(runtime.status, "idle");
|
||
assert!(runtime
|
||
.tool_policy
|
||
.auto_tools
|
||
.contains(&"agent.action_history".to_string()));
|
||
assert!(runtime.recent_tool_calls.iter().any(|call| {
|
||
call.tool == "file.list"
|
||
&& call
|
||
.action_id
|
||
.as_deref()
|
||
.is_some_and(is_valid_agent_runtime_action_id)
|
||
}));
|
||
assert!(runtime.recent_tool_calls.iter().any(|call| {
|
||
call.tool == "agent.action_history"
|
||
&& call
|
||
.action_id
|
||
.as_deref()
|
||
.is_some_and(is_valid_agent_runtime_action_id)
|
||
}));
|
||
let runtime_snapshot = read_game_creator_agent_runtime_at(&root, "design-director")
|
||
.expect("read runtime snapshot with history event");
|
||
let history_event = runtime_snapshot
|
||
.recent_events
|
||
.iter()
|
||
.find(|event| {
|
||
event.event_type == "observation"
|
||
&& event.summary.starts_with("agent.action_history:ok")
|
||
})
|
||
.expect("action history event");
|
||
let event_payload = serde_json::from_str::<Value>(
|
||
history_event
|
||
.detail
|
||
.as_deref()
|
||
.expect("history event detail"),
|
||
)
|
||
.expect("history event detail remains valid json");
|
||
assert_eq!(event_payload["actions"][0]["tool"], "file.list");
|
||
let receipts = read_agent_db_records_for_test(&root)
|
||
.into_iter()
|
||
.filter(|record| record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE)
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(receipts.len(), 2);
|
||
assert!(receipts.iter().any(|record| record["tool"] == "file.list"));
|
||
assert!(receipts
|
||
.iter()
|
||
.any(|record| record["tool"] == "agent.action_history"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn agent_native_tool_parser_accepts_plan_only_checkpoint() {
|
||
let plan = platform_llm::LlmToolCall {
|
||
id: "call-plan-checkpoint".to_string(),
|
||
name: AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME.to_string(),
|
||
arguments: serde_json::json!({
|
||
"explanation": "先持久化计划,再读取匹配工作流",
|
||
"steps": [
|
||
{"step": "读取匹配工作流", "status": "in_progress"},
|
||
{"step": "完成项目修改与验证", "status": "pending"}
|
||
]
|
||
})
|
||
.to_string(),
|
||
};
|
||
let parsed = parse_game_creator_agent_tool_plan_llm_response(&agent_tool_plan_llm_response(
|
||
"",
|
||
vec![plan],
|
||
))
|
||
.expect("plan-only checkpoint must be accepted");
|
||
|
||
assert_eq!(parsed.protocol, "native_runtime_tools");
|
||
assert!(parsed.plan.actions.is_empty());
|
||
assert!(parsed.plan.response.is_empty());
|
||
assert_eq!(parsed.plan.plan_update.as_ref().unwrap().steps.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn local_project_checkpoint_diff_restore_and_index_are_recorded() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
|
||
write_local_project_file_at(&root, "game/notes.txt", "v1").expect("write notes");
|
||
write_local_project_file_at(&root, "exports/README.md", "publish")
|
||
.expect("write export readme");
|
||
let checkpoint = create_local_project_checkpoint_at(&root).expect("create project checkpoint");
|
||
|
||
write_local_project_file_at(&root, "game/notes.txt", "v2").expect("change notes");
|
||
write_local_project_file_at(&root, "game/extra.txt", "new").expect("add file");
|
||
fs::create_dir_all(root.join(".agent/runtime/agents")).expect("runtime agent dir");
|
||
fs::write(
|
||
root.join(".agent/runtime/agents/design-director.json"),
|
||
r#"{"agentId":"design-director","status":"running"}"#,
|
||
)
|
||
.expect("runtime state");
|
||
delete_local_project_file_at(&root, "exports/README.md").expect("delete tracked file");
|
||
let diff = diff_local_project_checkpoint_at(&root, &checkpoint.checkpoint_id).expect("diff");
|
||
|
||
assert!(diff
|
||
.changed
|
||
.iter()
|
||
.any(|entry| entry.path == "game/notes.txt"));
|
||
assert!(diff
|
||
.added
|
||
.iter()
|
||
.any(|entry| entry.path == "game/extra.txt"));
|
||
assert!(diff
|
||
.deleted
|
||
.iter()
|
||
.any(|entry| entry.path == "exports/README.md"));
|
||
assert!(!diff
|
||
.added
|
||
.iter()
|
||
.any(|entry| entry.path.starts_with(".agent/runtime/")));
|
||
|
||
let index = build_local_project_index_at(&root).expect("project index");
|
||
assert!(index.file_count > 0);
|
||
assert!(!index
|
||
.files
|
||
.iter()
|
||
.any(|file| file.path.starts_with(".agent/runtime/")));
|
||
assert!(root.join(PROJECT_INDEX_PATH).exists());
|
||
|
||
let restored =
|
||
restore_local_project_checkpoint_at(&root, &checkpoint.checkpoint_id).expect("restore");
|
||
assert!(restored.restored_count > 0);
|
||
assert_eq!(
|
||
fs::read_to_string(root.join("game/notes.txt")).expect("restored notes"),
|
||
"v1"
|
||
);
|
||
assert!(root.join("exports/README.md").exists());
|
||
assert!(!root.join("game/extra.txt").exists());
|
||
assert!(root
|
||
.join(".agent/runtime/agents/design-director.json")
|
||
.exists());
|
||
assert_eq!(restored.deleted_count, 1);
|
||
|
||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
|
||
assert!(agent_db.contains("\"recordType\":\"project.checkpoint\""));
|
||
assert!(agent_db.contains("\"recordType\":\"project.index\""));
|
||
assert!(agent_db.contains("\"recordType\":\"project.restore\""));
|
||
assert!(agent_db.contains("\"deletedCount\":1"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn project_write_lock_rejects_parallel_writer_and_releases_on_drop() {
|
||
let root = unique_project_path();
|
||
let first = acquire_project_write_lock(&root, "file.write").expect("first lock");
|
||
|
||
let error = acquire_project_write_lock(&root, "file.delete").expect_err("second lock fails");
|
||
assert!(error.contains("项目正在被其他写操作占用"));
|
||
|
||
drop(first);
|
||
acquire_project_write_lock(&root, "file.delete").expect("lock released");
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn steer_consumption_waits_across_a_legitimate_longer_project_write() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "steer lock wait").expect("project init");
|
||
let lock = acquire_project_write_lock(&root, "test.steer.concurrent-writer")
|
||
.expect("acquire competing project lock");
|
||
let release = std::thread::spawn(move || {
|
||
std::thread::sleep(Duration::from_millis(1_250));
|
||
drop(lock);
|
||
});
|
||
|
||
let acquired = acquire_game_creator_agent_runtime_steer_project_write_lock_with_wait(&root)
|
||
.expect("steer consumption should outwait a legitimate project projection");
|
||
drop(acquired);
|
||
release.join().expect("release competing project lock");
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn provider_plan_waits_across_an_autonomous_manifest_wave_project_write() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "provider plan manifest lock wait")
|
||
.expect("project init");
|
||
let lock = acquire_project_write_lock(&root, "runtime.autonomous.schedule_ready")
|
||
.expect("acquire manifest-wave project lock");
|
||
let release = std::thread::spawn(move || {
|
||
std::thread::sleep(Duration::from_millis(1_250));
|
||
drop(lock);
|
||
});
|
||
|
||
let acquired = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait(
|
||
&root,
|
||
"runtime.provider_request.build.tool_plan",
|
||
)
|
||
.expect("provider plan should outwait the manifest wave reservation");
|
||
drop(acquired);
|
||
release.join().expect("release manifest-wave project lock");
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_parallel_read_projection_waits_across_a_manifest_wave_project_write() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "parallel read projection lock wait")
|
||
.expect("project init");
|
||
let lock = acquire_project_write_lock(&root, "runtime.autonomous.schedule_ready")
|
||
.expect("acquire manifest-wave project lock");
|
||
let release = std::thread::spawn(move || {
|
||
std::thread::sleep(Duration::from_millis(1_250));
|
||
drop(lock);
|
||
});
|
||
|
||
let acquired = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||
&root,
|
||
"runtime.parallel_read.project",
|
||
)
|
||
.expect("parallel read projection should outwait the manifest wave reservation");
|
||
drop(acquired);
|
||
release.join().expect("release manifest-wave project lock");
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn runtime_project_write_lock_waits_for_delete_pending_target() {
|
||
use std::os::windows::fs::OpenOptionsExt;
|
||
use std::os::windows::io::AsRawHandle;
|
||
use windows_sys::Win32::Storage::FileSystem::{
|
||
FileDispositionInfo, SetFileInformationByHandle, FILE_DISPOSITION_INFO,
|
||
};
|
||
|
||
const DELETE_ACCESS: u32 = 0x0001_0000;
|
||
const GENERIC_READ: u32 = 0x8000_0000;
|
||
const FILE_SHARE_READ: u32 = 0x0000_0001;
|
||
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
|
||
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "delete pending project lock wait")
|
||
.expect("project init");
|
||
let lock_path = root.join(PROJECT_WRITE_LOCK_PATH);
|
||
fs::write(&lock_path, "delete-pending-project-lock").expect("write lock fixture");
|
||
|
||
let delete_pending = fs::OpenOptions::new()
|
||
.access_mode(GENERIC_READ | DELETE_ACCESS)
|
||
.share_mode(FILE_SHARE_READ)
|
||
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
|
||
.open(&lock_path)
|
||
.expect("open delete-pending lock fixture");
|
||
let disposition = FILE_DISPOSITION_INFO { DeleteFile: true };
|
||
// SAFETY: delete_pending is live and disposition has the required layout.
|
||
assert_ne!(
|
||
unsafe {
|
||
SetFileInformationByHandle(
|
||
delete_pending.as_raw_handle().cast(),
|
||
FileDispositionInfo,
|
||
(&raw const disposition).cast(),
|
||
std::mem::size_of::<FILE_DISPOSITION_INFO>() as u32,
|
||
)
|
||
},
|
||
0,
|
||
"mark project lock delete-pending: {}",
|
||
std::io::Error::last_os_error()
|
||
);
|
||
let create_error = fs::OpenOptions::new()
|
||
.create_new(true)
|
||
.write(true)
|
||
.open(&lock_path)
|
||
.expect_err("delete-pending target must block the create_new contention path");
|
||
assert!(
|
||
create_error.kind() == std::io::ErrorKind::PermissionDenied
|
||
|| create_error.raw_os_error() == Some(5),
|
||
"delete-pending create_new should reproduce ACCESS_DENIED: {create_error}"
|
||
);
|
||
let release = std::thread::spawn(move || {
|
||
std::thread::sleep(Duration::from_millis(50));
|
||
drop(delete_pending);
|
||
});
|
||
|
||
let acquired = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||
&root,
|
||
"runtime.parallel_read.project",
|
||
)
|
||
.expect("runtime projection should outwait a delete-pending project lock");
|
||
drop(acquired);
|
||
release.join().expect("release delete-pending lock fixture");
|
||
assert!(!lock_path.exists());
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn agent_runtime_file_write_waits_for_short_parallel_project_writer() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "并行文件写入等待").expect("project init");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow direct file write");
|
||
let barrier = Arc::new(Barrier::new(2));
|
||
let holder_barrier = Arc::clone(&barrier);
|
||
let holder_root = root.clone();
|
||
let holder = std::thread::spawn(move || {
|
||
let lock = acquire_project_write_lock(&holder_root, "concurrent-writer")
|
||
.expect("acquire short-lived project writer");
|
||
holder_barrier.wait();
|
||
std::thread::sleep(Duration::from_millis(50));
|
||
drop(lock);
|
||
});
|
||
barrier.wait();
|
||
|
||
let observation = execute_game_creator_agent_runtime_tool_action(
|
||
&root,
|
||
"design-director",
|
||
"design-parallel-file-write-run",
|
||
"等待并行写锁后写入文件",
|
||
&AgentRuntimeToolAction {
|
||
tool: "file.write".to_string(),
|
||
reason: Some("验证 Runtime 文件写入会等待短暂锁竞争".to_string()),
|
||
input: serde_json::json!({
|
||
"path": "game/parallel-write.txt",
|
||
"content": "parallel write completed\n"
|
||
}),
|
||
},
|
||
)
|
||
.await;
|
||
|
||
holder.join().expect("join short-lived project writer");
|
||
assert_eq!(observation.status, "ok");
|
||
assert_eq!(
|
||
fs::read_to_string(root.join("game/parallel-write.txt")).expect("read written file"),
|
||
"parallel write completed\n"
|
||
);
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn agent_runtime_file_write_lock_failure_redacts_project_path() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "并行文件写入脱敏").expect("project init");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("allow direct file write");
|
||
let lock = acquire_project_write_lock(&root, "persistent-writer")
|
||
.expect("acquire persistent project writer");
|
||
|
||
let observation = execute_game_creator_agent_runtime_tool_action(
|
||
&root,
|
||
"design-director",
|
||
"design-file-write-lock-failure-run",
|
||
"验证写锁失败观察可以安全持久化",
|
||
&AgentRuntimeToolAction {
|
||
tool: "file.write".to_string(),
|
||
reason: Some("验证项目绝对路径不会进入失败观察".to_string()),
|
||
input: serde_json::json!({
|
||
"path": "game/blocked-write.txt",
|
||
"content": "must not be written\n"
|
||
}),
|
||
},
|
||
)
|
||
.await;
|
||
|
||
drop(lock);
|
||
assert_eq!(observation.status, "failed");
|
||
assert!(!observation
|
||
.summary
|
||
.contains(root.to_string_lossy().as_ref()));
|
||
assert!(!root.join("game/blocked-write.txt").exists());
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn project_write_lock_reclaims_dead_process_owner() {
|
||
let root = unique_project_path();
|
||
fs::create_dir_all(root.join(".agent")).expect("create agent dir");
|
||
fs::write(
|
||
root.join(PROJECT_WRITE_LOCK_PATH),
|
||
serde_json::json!({
|
||
"commandId": "project.verify",
|
||
"pid": i32::MAX as u64 - 1,
|
||
"createdAt": unix_timestamp(),
|
||
"nonce": 1,
|
||
})
|
||
.to_string(),
|
||
)
|
||
.expect("write dead owner lock");
|
||
|
||
let lock = acquire_project_write_lock(&root, "file.patch").expect("reclaim dead owner lock");
|
||
let lock_content =
|
||
fs::read_to_string(root.join(PROJECT_WRITE_LOCK_PATH)).expect("read replacement lock");
|
||
assert!(lock_content.contains(&std::process::id().to_string()));
|
||
drop(lock);
|
||
assert!(!root.join(PROJECT_WRITE_LOCK_PATH).exists());
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn project_write_lock_rejects_symlinked_agent_directory() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let root = unique_project_path();
|
||
let outside = unique_project_path();
|
||
fs::create_dir_all(&root).expect("create project root");
|
||
fs::create_dir_all(&outside).expect("create outside dir");
|
||
symlink(&outside, root.join(".agent")).expect("symlink agent dir");
|
||
|
||
let error = acquire_project_write_lock(&root, "project.verify")
|
||
.expect_err("symlinked control directory should be rejected");
|
||
assert!(error.contains("符号链接"));
|
||
assert!(!outside.join("project.lock").exists());
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
fs::remove_dir_all(outside).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn local_project_file_read_and_list_respect_project_policy() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
|
||
fs::write(root.join("game/notes.txt"), "hello").expect("notes");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: vec!["file.list".to_string(), "file.read".to_string()],
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("write policy");
|
||
let project_path = root.to_string_lossy().into_owned();
|
||
|
||
let list_error = list_local_project_files(project_path.clone()).expect_err("file.list denied");
|
||
let read_error = read_local_project_file(project_path, "game/notes.txt".to_string(), None)
|
||
.expect_err("file.read denied");
|
||
|
||
assert!(list_error.contains("项目权限策略拒绝执行:file.list"));
|
||
assert!(read_error.contains("项目权限策略拒绝执行:file.read"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn local_project_file_read_can_enforce_agent_trace_read_policy() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
|
||
fs::write(root.join(".agent/run.latest.json"), "{}").expect("trace");
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: vec!["agent.trace_read".to_string()],
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("write policy");
|
||
let project_path = root.to_string_lossy().into_owned();
|
||
|
||
let trace_error = read_local_project_file(
|
||
project_path.clone(),
|
||
".agent/run.latest.json".to_string(),
|
||
Some("agent.trace_read".to_string()),
|
||
)
|
||
.expect_err("agent.trace_read denied");
|
||
let scope_error = read_local_project_file(
|
||
project_path.clone(),
|
||
"game/notes.txt".to_string(),
|
||
Some("agent.trace_read".to_string()),
|
||
)
|
||
.expect_err("trace command cannot read arbitrary files");
|
||
let unsupported_error = read_local_project_file(
|
||
project_path,
|
||
".agent/run.latest.json".to_string(),
|
||
Some("project.status".to_string()),
|
||
)
|
||
.expect_err("unsupported file read command denied");
|
||
|
||
assert!(trace_error.contains("项目权限策略拒绝执行:agent.trace_read"));
|
||
assert!(scope_error.contains("agent.trace_read 只能读取 Agent run trace"));
|
||
assert!(unsupported_error.contains("不支持通过文件读取执行命令:project.status"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn local_project_file_read_rejects_sensitive_config_files() {
|
||
let root = unique_project_path();
|
||
fs::create_dir_all(root.join("nested")).expect("nested dir");
|
||
fs::write(root.join(".env"), "OPENAI_API_KEY=secret").expect("env file");
|
||
fs::write(root.join("nested/.env.local"), "TOKEN=secret").expect("local env file");
|
||
fs::write(
|
||
root.join(GAME_CREATOR_CONFIG_FILE_NAME),
|
||
"{\"apiKey\":\"secret\"}",
|
||
)
|
||
.expect("config file");
|
||
fs::write(
|
||
root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME),
|
||
"{\"apiKey\":\"secret\"}",
|
||
)
|
||
.expect("local config file");
|
||
|
||
for path in [
|
||
".env",
|
||
"nested/.env.local",
|
||
GAME_CREATOR_CONFIG_FILE_NAME,
|
||
GAME_CREATOR_LOCAL_CONFIG_FILE_NAME,
|
||
] {
|
||
let error = read_local_project_file_at(&root, path)
|
||
.expect_err("sensitive project file should not be readable");
|
||
assert!(error.contains("敏感配置文件"));
|
||
}
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn project_checkpoint_excludes_and_preserves_sensitive_local_files() {
|
||
let root = unique_project_path();
|
||
init_local_game_project_at(&root, "project-1", "敏感文件 checkpoint 测试")
|
||
.expect("project init");
|
||
fs::write(root.join("game/state.txt"), "before\n").expect("write project file");
|
||
fs::write(root.join(".env"), "CHECKPOINT_ENV_SECRET\n").expect("write env lure");
|
||
fs::write(
|
||
root.join(GAME_CREATOR_CONFIG_FILE_NAME),
|
||
"CHECKPOINT_CONFIG_SECRET\n",
|
||
)
|
||
.expect("write config lure");
|
||
fs::write(
|
||
root.join(".agent/private-secret.txt"),
|
||
"CHECKPOINT_AGENT_SECRET\n",
|
||
)
|
||
.expect("write agent lure");
|
||
|
||
let index = build_local_project_index_at(&root).expect("build safe project index");
|
||
assert!(index.files.iter().any(|file| file.path == "game/state.txt"));
|
||
assert!(!index.files.iter().any(|file| {
|
||
file.path == ".env"
|
||
|| file.path == GAME_CREATOR_CONFIG_FILE_NAME
|
||
|| file.path.starts_with(".agent/")
|
||
}));
|
||
let checkpoint = create_local_project_checkpoint_at(&root).expect("create safe checkpoint");
|
||
let checkpoint_root = PathBuf::from(&checkpoint.checkpoint_path);
|
||
assert_eq!(
|
||
fs::read_to_string(checkpoint_root.join("files/game/state.txt"))
|
||
.expect("read checkpoint project file"),
|
||
"before\n"
|
||
);
|
||
assert!(!checkpoint_root.join("files/.env").exists());
|
||
assert!(!checkpoint_root
|
||
.join("files")
|
||
.join(GAME_CREATOR_CONFIG_FILE_NAME)
|
||
.exists());
|
||
assert!(!checkpoint_root.join("files/.agent").exists());
|
||
let checkpoint_manifest = fs::read_to_string(checkpoint_root.join("manifest.json"))
|
||
.expect("read checkpoint manifest");
|
||
assert!(!checkpoint_manifest.contains("CHECKPOINT_ENV_SECRET"));
|
||
assert!(!checkpoint_manifest.contains("CHECKPOINT_CONFIG_SECRET"));
|
||
assert!(!checkpoint_manifest.contains("CHECKPOINT_AGENT_SECRET"));
|
||
|
||
fs::write(root.join("game/state.txt"), "after\n").expect("mutate project file");
|
||
fs::write(root.join(".env"), "ENV_CHANGED_LOCALLY\n").expect("mutate env lure");
|
||
fs::write(
|
||
root.join(GAME_CREATOR_CONFIG_FILE_NAME),
|
||
"CONFIG_CHANGED_LOCALLY\n",
|
||
)
|
||
.expect("mutate config lure");
|
||
fs::write(
|
||
root.join(".agent/private-secret.txt"),
|
||
"AGENT_CHANGED_LOCALLY\n",
|
||
)
|
||
.expect("mutate agent lure");
|
||
restore_local_project_checkpoint_at(&root, &checkpoint.checkpoint_id)
|
||
.expect("restore safe checkpoint");
|
||
assert_eq!(
|
||
fs::read_to_string(root.join("game/state.txt")).unwrap(),
|
||
"before\n"
|
||
);
|
||
assert_eq!(
|
||
fs::read_to_string(root.join(".env")).unwrap(),
|
||
"ENV_CHANGED_LOCALLY\n"
|
||
);
|
||
assert_eq!(
|
||
fs::read_to_string(root.join(GAME_CREATOR_CONFIG_FILE_NAME)).unwrap(),
|
||
"CONFIG_CHANGED_LOCALLY\n"
|
||
);
|
||
assert_eq!(
|
||
fs::read_to_string(root.join(".agent/private-secret.txt")).unwrap(),
|
||
"AGENT_CHANGED_LOCALLY\n"
|
||
);
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn local_project_image_preview_obeys_auto_file_read_policy() {
|
||
let root = unique_project_path();
|
||
let project_path = root.to_string_lossy().into_owned();
|
||
let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled();
|
||
init_local_game_project_at(&root, "image-preview-policy", "图片预览策略项目")
|
||
.expect("project init");
|
||
fs::create_dir_all(root.join("assets")).expect("asset dir");
|
||
let preview_bytes = base64::engine::general_purpose::STANDARD
|
||
.decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
|
||
.expect("valid preview image");
|
||
fs::write(root.join("assets/preview.png"), &preview_bytes).expect("preview image");
|
||
register_local_asset_at(
|
||
&root,
|
||
"assets/preview.png",
|
||
"ui-prototype",
|
||
"image/png",
|
||
"canvas",
|
||
GameCreationAppAssetSource {
|
||
kind: GameCreationAppAssetSourceKind::Canvas,
|
||
canvas_project_id: Some("canvas-project-preview".to_string()),
|
||
resource_id: Some("resource-preview".to_string()),
|
||
asset_object_id: Some("asset-preview".to_string()),
|
||
task_id: None,
|
||
prompt: None,
|
||
model: None,
|
||
generation_route: None,
|
||
generation_kind: None,
|
||
reference_resource_ids: Vec::new(),
|
||
},
|
||
)
|
||
.expect("register preview asset");
|
||
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: vec!["file.read".to_string()],
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("confirm policy");
|
||
let confirm_error =
|
||
read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation)
|
||
.expect_err("confirm policy blocks automatic preview");
|
||
assert!(confirm_error.contains("要求用户确认:file.read"));
|
||
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: vec!["file.read".to_string()],
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("deny policy");
|
||
let deny_error =
|
||
read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation)
|
||
.expect_err("deny policy blocks preview");
|
||
assert!(deny_error.contains("拒绝执行:file.read"));
|
||
|
||
write_project_permission_policy_at(
|
||
&root,
|
||
ProjectPermissionPolicy {
|
||
denied_commands: Vec::new(),
|
||
confirm_commands: Vec::new(),
|
||
agent_policies: BTreeMap::new(),
|
||
},
|
||
)
|
||
.expect("auto policy");
|
||
let preview =
|
||
read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation)
|
||
.expect("auto preview");
|
||
assert_eq!(preview.media_type, "image/png");
|
||
assert_eq!(preview.pixel_width, 1);
|
||
assert_eq!(preview.pixel_height, 1);
|
||
let serialized_preview = serde_json::to_value(&preview).expect("serialize image preview");
|
||
assert_eq!(serialized_preview["pixelWidth"], 1);
|
||
assert_eq!(serialized_preview["pixelHeight"], 1);
|
||
|
||
fs::write(root.join("assets/unregistered.png"), &preview_bytes).expect("unregistered image");
|
||
let unregistered_error = read_local_project_image_preview_at(
|
||
&project_path,
|
||
"assets/unregistered.png",
|
||
&cancellation,
|
||
)
|
||
.expect_err("unregistered image rejected");
|
||
assert!(unregistered_error.contains("只能预览已登记资源"));
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn local_project_resource_previews_require_registered_safe_resources() {
|
||
let root = unique_project_path();
|
||
let project_path = root.to_string_lossy().into_owned();
|
||
let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled();
|
||
init_local_game_project_at(&root, "resource-preview-policy", "资源预览策略项目")
|
||
.expect("project init");
|
||
fs::create_dir_all(root.join("assets")).expect("asset dir");
|
||
fs::create_dir_all(root.join("game")).expect("game dir");
|
||
fs::write(root.join("game/design.md"), "# 玩法设计\n\n安全正文").expect("project document");
|
||
fs::write(
|
||
root.join("assets/icon.svg"),
|
||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><circle cx=\"2\" cy=\"2\" r=\"2\"/></svg>",
|
||
)
|
||
.expect("svg resource");
|
||
fs::write(
|
||
root.join("assets/bgm.mp3"),
|
||
[b'I', b'D', b'3', 4, 0, 0, 0, 0, 0, 0],
|
||
)
|
||
.expect("audio resource");
|
||
fs::write(root.join("game/unregistered.md"), "不应读取").expect("unregistered document");
|
||
|
||
let source = || GameCreationAppAssetSource {
|
||
kind: GameCreationAppAssetSourceKind::Generated,
|
||
canvas_project_id: None,
|
||
resource_id: None,
|
||
asset_object_id: None,
|
||
task_id: None,
|
||
prompt: None,
|
||
model: None,
|
||
generation_route: None,
|
||
generation_kind: None,
|
||
reference_resource_ids: Vec::new(),
|
||
};
|
||
register_local_asset_at(
|
||
&root,
|
||
"game/design.md",
|
||
"design-document",
|
||
"text/markdown",
|
||
"generated",
|
||
source(),
|
||
)
|
||
.expect("register document");
|
||
register_local_asset_at(
|
||
&root,
|
||
"assets/icon.svg",
|
||
"icon",
|
||
"image/svg+xml",
|
||
"generated",
|
||
source(),
|
||
)
|
||
.expect("register svg");
|
||
register_local_asset_at(
|
||
&root,
|
||
"assets/bgm.mp3",
|
||
"bgm",
|
||
"audio/mpeg",
|
||
"generated",
|
||
source(),
|
||
)
|
||
.expect("register audio");
|
||
|
||
let document =
|
||
read_local_project_text_preview_at(&project_path, "game/design.md", &cancellation)
|
||
.expect("read registered document");
|
||
assert_eq!(document.media_type, "text/markdown");
|
||
assert!(document.content.contains("安全正文"));
|
||
|
||
let svg =
|
||
read_local_project_media_preview_at(&project_path, "assets/icon.svg", "art", &cancellation)
|
||
.expect("read registered svg");
|
||
assert_eq!(svg.media_type, "image/svg+xml");
|
||
let audio = read_local_project_media_preview_at(
|
||
&project_path,
|
||
"assets/bgm.mp3",
|
||
"audio",
|
||
&cancellation,
|
||
)
|
||
.expect("read registered audio");
|
||
assert_eq!(audio.media_type, "audio/mpeg");
|
||
|
||
let unregistered_error =
|
||
read_local_project_text_preview_at(&project_path, "game/unregistered.md", &cancellation)
|
||
.expect_err("unregistered document rejected");
|
||
assert!(unregistered_error.contains("已登记的文档资源"));
|
||
assert!(
|
||
read_local_project_text_preview_at(&project_path, "../outside.md", &cancellation,).is_err()
|
||
);
|
||
assert!(read_local_project_media_preview_at(
|
||
&project_path,
|
||
"assets/bgm.mp3",
|
||
"art",
|
||
&cancellation,
|
||
)
|
||
.is_err());
|
||
|
||
fs::remove_dir_all(root).ok();
|
||
}
|