Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs
T
suzmii 15a524e6e0
Project CI / Repository checks (push) Successful in 3m31s
Project CI / Frontend tests (push) Successful in 4m16s
Project CI / Backend tests (push) Successful in 5m7s
Project CI / Native shell tests (push) Successful in 13m19s
Feat: 完善 Game Agent 资源画板与图片精修链路 (#176)
## 摘要

本 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>
2026-08-24 16:27:33 +08:00

6261 lines
229 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}