补齐单Agent删除与聊天恢复闭环

修复事件监听失败后的真实非流式降级与空 choices SSE 兼容
接入受策略保护的 file.delete 并加固确认、revision 和核对门禁
让客户端项目写入统一推进 revision,补齐锁内策略复核
为 manifest 增加跨平台替换与恢复副本
补齐 Runtime、契约和恢复回归测试并同步文档
This commit is contained in:
AIGameCreator App
2026-07-12 14:59:34 +08:00
parent 82fec317a3
commit b022bb456a
8 changed files with 2335 additions and 363 deletions
File diff suppressed because one or more lines are too long
@@ -70,7 +70,7 @@ pub(crate) fn is_game_creator_project_directory(root: &Path) -> bool {
return false;
}
let manifest_path = root.join(".agent/manifest.json");
manifest_path.is_file() && read_manifest(&manifest_path).is_ok()
read_manifest(&manifest_path).is_ok()
}
pub(crate) fn game_creator_project_name(root: &Path) -> Option<String> {
@@ -89,7 +89,7 @@ pub(crate) fn game_creator_project_manifest_error(root: &Path) -> Option<String>
return None;
}
let manifest_path = root.join(".agent/manifest.json");
if !manifest_path.is_file() {
if !manifest_storage_exists(&manifest_path).unwrap_or(true) {
return None;
}
read_manifest(&manifest_path).err()
@@ -219,6 +219,7 @@ pub(crate) async fn generate_local_game_draft(
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "game.generate_draft")?;
let _lock = acquire_project_write_lock(root, "game.generate_draft")?;
advance_agent_runtime_project_revision_locked(root)?;
generate_local_game_draft_at(
root,
prompt.trim(),
@@ -608,6 +609,7 @@ pub(crate) fn upload_local_asset(
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "asset.upload")?;
let _lock = acquire_project_write_lock(root, "asset.upload")?;
advance_agent_runtime_project_revision_locked(root)?;
upload_local_asset_at(root, file_name.trim(), media_type.trim(), &bytes)
}
@@ -628,6 +630,7 @@ pub(crate) fn register_local_asset(
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
let _lock = acquire_project_write_lock(root, "asset.register")?;
advance_agent_runtime_project_revision_locked(root)?;
register_local_asset_at(
root,
local_path.trim(),
@@ -662,6 +665,7 @@ pub(crate) fn import_canvas_asset(
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "canvas.asset_import")?;
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
advance_agent_runtime_project_revision_locked(root)?;
import_canvas_asset_at(
root,
local_path.trim(),
@@ -685,6 +689,7 @@ pub(crate) fn import_canvas_export(
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "canvas.export_import")?;
let _lock = acquire_project_write_lock(root, "canvas.export_import")?;
advance_agent_runtime_project_revision_locked(root)?;
import_canvas_export_at(
root,
Path::new(export_path.trim()),
@@ -702,6 +707,7 @@ pub(crate) async fn sync_canvas_project_assets(
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "canvas.project_sync")?;
let _lock = acquire_project_write_lock(root, "canvas.project_sync")?;
advance_agent_runtime_project_revision_locked(root)?;
sync_canvas_project_assets_at(root, canvas_project_id.trim(), api_base_url, api_key).await
}
@@ -713,6 +719,7 @@ pub(crate) async fn generate_platform_art_asset(
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "canvas.asset_generate")?;
let _lock = acquire_project_write_lock(root, "canvas.asset_generate")?;
advance_agent_runtime_project_revision_locked(root)?;
let generated = generate_platform_art_asset_at(root, prompt.trim(), &[]).await?;
Ok(generated.asset)
}
@@ -811,6 +818,7 @@ pub(crate) fn write_local_project_file(
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "file.write")?;
let _lock = acquire_project_write_lock(root, "file.write")?;
advance_agent_runtime_project_revision_locked(root)?;
write_local_project_file_at(root, relative_path.trim(), &content)
}
@@ -822,6 +830,7 @@ pub(crate) fn delete_local_project_file(
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "file.delete")?;
let _lock = acquire_project_write_lock(root, "file.delete")?;
advance_agent_runtime_project_revision_locked(root)?;
delete_local_project_file_at(root, relative_path.trim())
}
@@ -856,6 +865,7 @@ pub(crate) fn write_local_agent_memory(
let task_id = normalize_game_creator_runtime_agent_id(task_id.trim())?;
enforce_project_permission_policy(root, "memory.write")?;
let _lock = acquire_project_write_lock(root, "memory.write")?;
advance_agent_runtime_project_revision_locked(root)?;
write_local_agent_memory_at(root, &task_id, &content)
}
@@ -868,6 +878,7 @@ pub(crate) fn write_local_game_memory(
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "memory.write")?;
let _lock = acquire_project_write_lock(root, "memory.write")?;
advance_agent_runtime_project_revision_locked(root)?;
write_local_game_memory_at(root, scope.trim(), &content)
}
@@ -879,6 +890,7 @@ pub(crate) fn delete_local_game_memory(
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "memory.delete")?;
let _lock = acquire_project_write_lock(root, "memory.delete")?;
advance_agent_runtime_project_revision_locked(root)?;
delete_local_game_memory_at(root, scope.trim())
}
@@ -987,6 +999,7 @@ pub(crate) fn export_local_project_package(
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.export_package")?;
let _lock = acquire_project_write_lock(root, "project.export_package")?;
advance_agent_runtime_project_revision_locked(root)?;
export_local_project_package_at(root)
}
@@ -1017,6 +1030,7 @@ pub(crate) fn restore_local_project_checkpoint(
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.restore")?;
let _lock = acquire_project_write_lock(root, "project.restore")?;
advance_agent_runtime_project_revision_locked(root)?;
restore_local_project_checkpoint_at(root, checkpoint_id.trim())
}
@@ -56,7 +56,7 @@ pub(crate) fn init_local_game_project_at(
}
let manifest_path = root.join(".agent/manifest.json");
if !manifest_path.exists() {
if !manifest_storage_exists(&manifest_path)? {
let manifest = new_game_creation_app_manifest(project_id, name);
write_manifest(&manifest_path, &manifest)?;
}
@@ -2438,6 +2438,16 @@ fn reject_agent_runtime_private_control_path(normalized_path: &str) -> Result<()
Ok(())
}
fn reject_agent_control_path_delete(normalized_path: &str) -> Result<(), String> {
if matches!(
normalized_path.split('/').next(),
Some(part) if part.eq_ignore_ascii_case(".agent")
) {
return Err("Agent 控制面不可通过 file.delete 删除".to_string());
}
Ok(())
}
pub(crate) fn reject_sensitive_project_file_read(normalized_path: &str) -> Result<(), String> {
for part in normalized_path.split('/') {
let lower = part.to_ascii_lowercase();
@@ -2488,6 +2498,7 @@ pub(crate) fn delete_local_project_file_at(
) -> Result<LocalProjectFileMutationResult, String> {
let normalized_path = normalize_relative_path(relative_path)?;
reject_agent_runtime_private_control_path(&normalized_path)?;
reject_agent_control_path_delete(&normalized_path)?;
let path = resolve_local_project_path(root, &normalized_path)?;
if !path.exists() {
return Ok(LocalProjectFileMutationResult {
@@ -3139,14 +3150,23 @@ pub(crate) fn resolve_local_project_path(
validate_project_root(root)?;
let normalized = normalize_relative_path(relative_path)?;
let mut path = root.to_path_buf();
let mut should_check_metadata = true;
for part in normalized.split('/') {
path.push(part);
if path.exists() {
let metadata = fs::symlink_metadata(&path)
.map_err(|error| format!("读取路径失败:{}: {error}", path.display()))?;
if metadata.file_type().is_symlink() {
if !should_check_metadata {
continue;
}
match fs::symlink_metadata(&path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err("项目文件路径不能包含符号链接".to_string());
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
should_check_metadata = false;
}
Err(error) => {
return Err(format!("读取路径失败:{}: {error}", path.display()));
}
}
}
Ok(path)
@@ -3162,11 +3182,15 @@ pub(crate) fn validate_project_root(root: &Path) -> Result<(), String> {
if project_path_has_control_chars(root) {
return Err("项目目录不能包含控制字符".to_string());
}
if root.exists() {
let metadata = fs::symlink_metadata(root)
.map_err(|error| format!("读取项目目录失败:{}: {error}", root.display()))?;
if metadata.file_type().is_symlink() {
return Err("项目目录不能是符号链接".to_string());
match fs::symlink_metadata(root) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return Err("项目目录不能是符号链接".to_string());
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!("读取项目目录失败:{}: {error}", root.display()));
}
}
Ok(())
@@ -3531,7 +3555,7 @@ pub(crate) fn read_or_create_manifest(
}
let manifest_path = root.join(".agent/manifest.json");
let manifest = if manifest_path.exists() {
let manifest = if manifest_storage_exists(&manifest_path)? {
read_manifest(&manifest_path)?
} else {
new_game_creation_app_manifest("local-project-draft", "未命名游戏原型")
@@ -3560,11 +3584,173 @@ pub(crate) fn memory_file_path<'a>(
}
}
fn manifest_backup_path(path: &Path) -> PathBuf {
path.with_file_name(format!(
".{}.previous",
path.file_name()
.and_then(|value| value.to_str())
.unwrap_or("manifest.json")
))
}
pub(crate) fn manifest_storage_exists(path: &Path) -> Result<bool, String> {
match fs::symlink_metadata(path) {
Ok(_) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let backup_path = manifest_backup_path(path);
match fs::symlink_metadata(&backup_path) {
Ok(_) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(format!(
"读取 manifest 恢复副本元数据失败:{}: {error}",
backup_path.display()
)),
}
}
Err(error) => Err(format!(
"读取 manifest 元数据失败:{}: {error}",
path.display()
)),
}
}
fn remove_manifest_backup(path: &Path) -> Result<(), String> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
Err("manifest 恢复副本必须是普通文件".to_string())
}
Ok(_) => fs::remove_file(path)
.map_err(|error| format!("删除 manifest 恢复副本失败:{}: {error}", path.display())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(format!(
"读取 manifest 恢复副本元数据失败:{}: {error}",
path.display()
)),
}
}
pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, String> {
let payload = fs::read_to_string(path)
.map_err(|error| format!("读取 manifest 失败:{}: {error}", path.display()))?;
let backup_path = manifest_backup_path(path);
let (source_path, metadata, is_backup) = match fs::symlink_metadata(path) {
Ok(metadata) => (path, metadata, false),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
match fs::symlink_metadata(&backup_path) {
Ok(metadata) => (backup_path.as_path(), metadata, true),
Err(backup_error) if backup_error.kind() == std::io::ErrorKind::NotFound => {
return Err(format!("读取 manifest 失败:{}: {error}", path.display()));
}
Err(backup_error) => {
return Err(format!(
"读取 manifest 恢复副本元数据失败:{}: {backup_error}",
backup_path.display()
));
}
}
}
Err(error) => {
return Err(format!(
"读取 manifest 元数据失败:{}: {error}",
path.display()
));
}
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(if is_backup {
"manifest 恢复副本必须是普通文件".to_string()
} else {
"manifest 必须是普通文件".to_string()
});
}
let label = if is_backup {
"manifest 恢复副本"
} else {
"manifest"
};
let mut options = fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let mut payload = String::new();
options
.open(source_path)
.and_then(|mut file| file.read_to_string(&mut payload))
.map_err(|error| format!("读取 {label} 失败:{}: {error}", source_path.display()))?;
serde_json::from_str(&payload)
.map_err(|error| format!("解析 manifest 失败:{}: {error}", path.display()))
.map_err(|error| format!("解析 {label} 失败:{}: {error}", source_path.display()))
}
fn install_manifest_temp_with<F>(
path: &Path,
temp_path: &Path,
mut rename_file: F,
) -> Result<(), String>
where
F: FnMut(&Path, &Path) -> std::io::Result<()>,
{
let backup_path = manifest_backup_path(path);
match rename_file(temp_path, path) {
Ok(()) => remove_manifest_backup(&backup_path),
Err(replace_error) => {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return match rename_file(temp_path, path) {
Ok(()) => remove_manifest_backup(&backup_path),
Err(retry_error) => {
let _ = fs::remove_file(temp_path);
Err(format!(
"替换 manifest 失败:{} -> {}: {replace_error};重试失败:{retry_error}",
temp_path.display(),
path.display()
))
}
};
}
Err(error) => {
let _ = fs::remove_file(temp_path);
return Err(format!(
"读取 manifest 元数据失败:{}: {error}",
path.display()
));
}
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
let _ = fs::remove_file(temp_path);
return Err("manifest 必须是普通文件".to_string());
}
if let Err(error) = remove_manifest_backup(&backup_path) {
let _ = fs::remove_file(temp_path);
return Err(error);
}
rename_file(path, &backup_path).map_err(|error| {
let _ = fs::remove_file(temp_path);
format!(
"准备替换 manifest 失败:{} -> {}: {error}",
path.display(),
backup_path.display()
)
})?;
match rename_file(temp_path, path) {
Ok(()) => remove_manifest_backup(&backup_path),
Err(error) => {
let restore_error = rename_file(&backup_path, path).err();
let _ = fs::remove_file(temp_path);
let restore_detail = restore_error
.map(|error| format!(";恢复旧文件失败:{error}"))
.unwrap_or_default();
Err(format!(
"替换 manifest 失败:{} -> {}: {error}{restore_detail}",
temp_path.display(),
path.display()
))
}
}
}
}
}
pub(crate) fn write_manifest(
@@ -3577,8 +3763,37 @@ pub(crate) fn write_manifest(
fs::create_dir_all(parent)
.map_err(|error| format!("创建 manifest 目录失败:{}: {error}", parent.display()))?;
}
fs::write(path, format!("{payload}\n"))
.map_err(|error| format!("写入 manifest 失败:{}: {error}", path.display()))
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
return Err("manifest 必须是普通文件".to_string());
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!(
"读取 manifest 元数据失败:{}: {error}",
path.display()
));
}
}
let temp_path = path.with_file_name(format!(
".{}.tmp.{}.{}",
path.file_name()
.and_then(|value| value.to_str())
.unwrap_or("manifest.json"),
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
fs::write(&temp_path, format!("{payload}\n")).map_err(|error| {
format!(
"写入 manifest 临时文件失败:{}: {error}",
temp_path.display()
)
})?;
install_manifest_temp_with(path, &temp_path, |from, to| fs::rename(from, to))
}
pub(crate) fn sanitize_file_name(file_name: &str) -> String {
@@ -3632,6 +3847,180 @@ pub(crate) fn unix_millis() -> u128 {
.unwrap_or(0)
}
#[cfg(test)]
mod manifest_recovery_tests {
use super::*;
fn unique_manifest_test_root(test_name: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"genarrative-manifest-{test_name}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
))
}
fn write_manifest_fixture(path: &Path, manifest: &GameCreationAppManifest) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create manifest fixture directory");
}
let payload = serde_json::to_string_pretty(manifest).expect("serialize manifest fixture");
fs::write(path, format!("{payload}\n")).expect("write manifest fixture");
}
#[test]
fn manifest_read_and_project_write_recover_previous_file() {
let root = unique_manifest_test_root("recover-previous");
let manifest_path = root.join(".agent/manifest.json");
let backup_path = root.join(".agent/.manifest.json.previous");
let mut manifest = new_game_creation_app_manifest("project-recovered", "恢复项目");
manifest.goal = Some("保留恢复副本内容".to_string());
write_manifest_fixture(&backup_path, &manifest);
let recovered = read_manifest(&manifest_path).expect("read manifest recovery file");
assert_eq!(recovered.project_id, "project-recovered");
assert_eq!(recovered.goal.as_deref(), Some("保留恢复副本内容"));
let recovered = read_manifest_for_project(&root).expect("rewrite recovered manifest");
assert_eq!(recovered.project_id, "project-recovered");
assert!(manifest_path.is_file());
assert!(!backup_path.exists());
fs::remove_dir_all(root).ok();
}
#[test]
fn manifest_install_uses_previous_when_direct_replace_fails() {
let root = unique_manifest_test_root("replace-fallback");
let manifest_path = root.join(".agent/manifest.json");
let temp_path = root.join(".agent/.manifest.json.tmp.test");
let original = new_game_creation_app_manifest("project-original", "原项目");
let replacement = new_game_creation_app_manifest("project-replacement", "替换项目");
write_manifest_fixture(&manifest_path, &original);
write_manifest_fixture(&temp_path, &replacement);
let mut direct_install_attempts = 0;
install_manifest_temp_with(&manifest_path, &temp_path, |from, to| {
if from == temp_path && to == manifest_path {
direct_install_attempts += 1;
if direct_install_attempts == 1 {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"simulate Windows rename without replacement",
));
}
}
fs::rename(from, to)
})
.expect("replace manifest through recovery file");
assert_eq!(direct_install_attempts, 2);
assert_eq!(
read_manifest(&manifest_path)
.expect("read replacement manifest")
.project_id,
"project-replacement"
);
assert!(!manifest_backup_path(&manifest_path).exists());
assert!(!temp_path.exists());
fs::remove_dir_all(root).ok();
}
#[test]
fn manifest_install_restores_previous_when_new_file_install_fails() {
let root = unique_manifest_test_root("replace-restore");
let manifest_path = root.join(".agent/manifest.json");
let temp_path = root.join(".agent/.manifest.json.tmp.test");
let original = new_game_creation_app_manifest("project-original", "原项目");
let replacement = new_game_creation_app_manifest("project-replacement", "替换项目");
write_manifest_fixture(&manifest_path, &original);
write_manifest_fixture(&temp_path, &replacement);
let error = install_manifest_temp_with(&manifest_path, &temp_path, |from, to| {
if from == temp_path && to == manifest_path {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"simulate manifest install failure",
));
}
fs::rename(from, to)
})
.expect_err("manifest install must fail after restoring previous file");
assert!(error.contains("替换 manifest 失败"));
assert_eq!(
read_manifest(&manifest_path)
.expect("read restored manifest")
.project_id,
"project-original"
);
assert!(!manifest_backup_path(&manifest_path).exists());
assert!(!temp_path.exists());
fs::remove_dir_all(root).ok();
}
#[test]
fn manifest_read_rejects_directories() {
let root = unique_manifest_test_root("reject-directories");
let manifest_path = root.join(".agent/manifest.json");
fs::create_dir_all(&manifest_path).expect("create manifest directory");
let error = read_manifest(&manifest_path).expect_err("reject manifest directory");
assert!(error.contains("manifest 必须是普通文件"));
fs::remove_dir_all(&manifest_path).expect("remove manifest directory");
let backup_path = root.join(".agent/.manifest.json.previous");
fs::create_dir_all(&backup_path).expect("create manifest recovery directory");
let error = read_manifest(&manifest_path).expect_err("reject manifest recovery directory");
assert!(error.contains("manifest 恢复副本必须是普通文件"), "{error}");
fs::remove_dir_all(root).ok();
}
#[cfg(unix)]
#[test]
fn manifest_read_and_write_reject_symbolic_links() {
use std::os::unix::fs::symlink;
let root = unique_manifest_test_root("reject-symbolic-links");
let manifest_path = root.join(".agent/manifest.json");
let target_path = root.join("manifest-target.json");
let original = new_game_creation_app_manifest("project-original", "原项目");
write_manifest_fixture(&target_path, &original);
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
.expect("create manifest directory");
symlink(&target_path, &manifest_path).expect("create manifest symbolic link");
let error = read_manifest(&manifest_path).expect_err("reject manifest symbolic link");
assert!(error.contains("manifest 必须是普通文件"));
let replacement = new_game_creation_app_manifest("project-replacement", "替换项目");
let error = write_manifest(&manifest_path, &replacement)
.expect_err("reject replacing manifest symbolic link");
assert!(error.contains("manifest 必须是普通文件"));
assert!(fs::symlink_metadata(&manifest_path)
.expect("read manifest symbolic link metadata")
.file_type()
.is_symlink());
assert_eq!(
read_manifest(&target_path)
.expect("read untouched manifest target")
.project_id,
"project-original"
);
fs::remove_file(&manifest_path).expect("remove manifest symbolic link");
let backup_path = root.join(".agent/.manifest.json.previous");
symlink(&target_path, &backup_path).expect("create recovery symbolic link");
let error = read_manifest(&manifest_path).expect_err("reject recovery symbolic link");
assert!(error.contains("manifest 恢复副本必须是普通文件"), "{error}");
fs::remove_dir_all(root).ok();
}
}
#[cfg(test)]
mod idempotent_conversation_tests {
use super::*;
File diff suppressed because it is too large Load Diff
@@ -24,7 +24,9 @@
- 2026-07-10 补充:后台 Runtime 每次追加 `.agent/runtime/events/<agentId>.jsonl` 后会通过 Tauri `game-creator-agent-runtime-update` 事件广播当前 `AgentRuntimeResult`;开发单 Agent 聊天页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表都只把该事件作为实时 UI 通知并复用前端 runtime 归一化合并,事实源仍是 `.agent/runtime/agents``events``tasks` 文件。
- 2026-07-11 补充:开发单 Agent 聊天页保留整页纵向滚动,聊天消息区固定响应式高度并在内部滚动;Runtime 恢复确认区使用独立布局行,避免与 Runtime 详情或聊天内容重叠。Runtime 面板详情可折叠且折叠时不渲染详情 DOM,但状态标题与任务控制按钮继续保留;等待 LLM 时在消息区持续显示动态状态和进行中提示,连续流式 delta 合并到动画帧更新并跳过重复 Runtime state。OpenAI Chat SSE 会跳过空 `choices` 心跳 / 元数据事件,收集 usage-only 尾包、保留 finish reason 与上游 error message,收到 `[DONE]` 后立即结束;正文与 finish reason 已接收后出现尾包异常时保存已完成正文,不把整轮改写成失败。持久事件订阅失败时显示非致命错误,聊天事件监听不可用或首个文本片段前流式失败时降级普通回复并继续落盘。
- 2026-07-11 补充:为缩小单 Agent 与 Codex CLI 在代码任务上的差距,Runtime 工具箱新增 `project.search``file.patch`,并扩展 `file.read` 的按行分页。`project.search` 在项目内执行有界字面量检索,默认忽略大小写,返回相对路径、行号和匹配行,跳过 `.agent`、敏感配置、依赖和构建目录;权限继承 `file.read``file.read` 接受 `startLine / maxLines`,返回带行号的最多 240 行、8,000 字符上下文,允许 Agent 继续分页而不是只看到文件开头约 900 字符。`file.patch` 只做 `oldText -> newText` 精确替换,必须声明预期匹配数,匹配数不符时不写入;它继承 `file.write` 权限,复用项目写锁和 Runtime 动作账本,并追加不含代码正文的 `agent.runtime.file.patch` 审计记录。三者组成“搜索定位 -> 分段读取 -> 局部修改 -> 再次读取验证”的最小代码工作闭环,不开放任意 shell。
- 2026-07-11 补充2026-07-12 更新:单 Agent 代码闭环新增开发专用 `project.verify`,用于在修改后执行项目根 `package.json` 已定义的固定脚本 `check / typecheck / test / lint / build`,或以 `check: / test: / lint: / typecheck: / build: / verify: / validate:` 开头、后缀由安全非空段组成的命名脚本;当前只支持 npm,不接受自由命令、参数或工作目录。Agent 必须先读取普通文件 `package.json`,再把真实存在的脚本名、完整原始脚本文本 `expectedCommand` 和 1-300 秒超时一起提交;Runtime 在真正执行前重新解析 JSON,要求脚本仍存在且正文精确一致,脚本漂移时拒绝执行。非 npm `packageManager` 或 pnpm / yarn / bun 锁文件必须失败关闭;`pre* / post*` 生命周期脚本名不在允许范围,npm 执行再附加 `--ignore-scripts`,阻止所选脚本关联的 pre/post lifecycle。该工具使用独立、默认 `confirm``project.verify` 权限,不再与 `command.run_limited` / `game.static_smoke` 共用授权;确认指纹覆盖脚本正文和超时。执行时不经过 App 自行拼接的 `bash -c`;继承环境被清理到 PATH 与必要平台变量,HOME/TMP/npm cache 隔离,stdin 关闭,输出保留有界头尾。Unix 下验证根进程正常结束或超时都会清理同进程组残留后代;项目写锁会按持有 PID 回收崩溃遗留锁,并拒绝 `.agent` 符号链接逃逸。进入进程执行后的成功、非零退出、启动失败和超时会写 `.agent/logs/command.log`、manifest command run 与 `agent.runtime.project.verify` 审计;输入预检拒绝则只进入 Runtime observation / error 事件。输出先过滤敏感内容再进入 observation。只要最新 `project.verify` 未通过,或通过后又发生 `file.write / file.patch / project.restore`Runtime 就拒绝模型用空 actions 假完成,继续要求修复和重新验证;多窗口重复无进展而以 `loop-budget-exhausted` 终止时,仍未形成新通过结果则保持失败。该能力会执行用户项目自身脚本,环境隔离不等同于 OS 沙箱,不能把不可信项目脚本视为安全代码;它不是自由 shell 代理,也不进入普通用户命令入口
- 2026-07-12 补充:单 Agent Runtime 工具箱新增受策略保护的 `file.delete`,补齐项目文件的完整生命周期。该工具只接受项目内相对 `path`,使用独立且默认 `confirm``file.delete` 权限,不继承 `file.write`;绝对路径、`..`、反斜杠、有效或悬空符号链接、目录和整个 `.agent/**` 控制面都失败关闭。Runtime 在项目写锁内、删除前先推进 project revision 并锁存当前 run 的 verification gate,成功后写 `agent.runtime.file.delete` 审计而不记录文件正文;目标已不存在时返回幂等 observation,但不撤销保守推进的 revision。自动与确认删除都经过 durable action ledgerpending action v3 额外绑定创建时的全局 project revision,等待确认期间任一 Agent 推进 revision 后旧动作必须进入 `needs-reconciliation`,不得删除漂移后的目标。崩溃停在 `executing` 时同样进入 `needs-reconciliation`,不得自动重放。删除后必须通过当前 revision 的 `project.verify``game.static_smoke` 才能收束;普通用户聊天和正式用户窗口不新增删除入口,也不因此开放任意 shell
- 2026-07-12 加固:`file.delete` 取得项目写锁后必须重新读取当前项目和 Agent 权限策略;锁竞争期间从 allow 改为 deny 时立即阻断,从 allow 改为 confirm 时自动动作退回待确认,只有已确认动作可继续。Agent 私有记忆以及客户端开发面板的文件、记忆、资产、草案、导出和 checkpoint 恢复写入都在同一项目锁内保守推进全局 revision,确保等待中的旧删除动作不会作用于客户端刚改写的内容。manifest 持久化使用同目录临时文件;平台不能覆盖既有文件时先移动到 `.manifest.json.previous` 恢复副本,主文件缺失时从副本读取,安装新文件失败时恢复旧文件。
- 2026-07-11 补充,2026-07-12 更新:单 Agent 代码闭环新增开发专用 `project.verify`,用于在修改后执行项目根 `package.json` 已定义的固定脚本 `check / typecheck / test / lint / build`,或以 `check: / test: / lint: / typecheck: / build: / verify: / validate:` 开头、后缀由安全非空段组成的命名脚本;当前只支持 npm,不接受自由命令、参数或工作目录。Agent 必须先读取普通文件 `package.json`,再把真实存在的脚本名、完整原始脚本文本 `expectedCommand` 和 1-300 秒超时一起提交;Runtime 在真正执行前重新解析 JSON,要求脚本仍存在且正文精确一致,脚本漂移时拒绝执行。非 npm `packageManager` 或 pnpm / yarn / bun 锁文件必须失败关闭;`pre* / post*` 生命周期脚本名不在允许范围,npm 执行再附加 `--ignore-scripts`,阻止所选脚本关联的 pre/post lifecycle。该工具使用独立、默认 `confirm``project.verify` 权限,不再与 `command.run_limited` / `game.static_smoke` 共用授权;确认指纹覆盖脚本正文和超时。执行时不经过 App 自行拼接的 `bash -c`;继承环境被清理到 PATH 与必要平台变量,HOME/TMP/npm cache 隔离,stdin 关闭,输出保留有界头尾。Unix 下验证根进程正常结束或超时都会清理同进程组残留后代;项目写锁会按持有 PID 回收崩溃遗留锁,并拒绝 `.agent` 符号链接逃逸。进入进程执行后的成功、非零退出、启动失败和超时会写 `.agent/logs/command.log`、manifest command run 与 `agent.runtime.project.verify` 审计;输入预检拒绝则只进入 Runtime observation / error 事件。输出先过滤敏感内容再进入 observation。只要最新 `project.verify` 未通过,或通过后又发生 `file.write / file.patch / file.delete / project.restore`Runtime 就拒绝模型用空 actions 假完成,继续要求修复和重新验证;多窗口重复无进展而以 `loop-budget-exhausted` 终止时,仍未形成新通过结果则保持失败。该能力会执行用户项目自身脚本,环境隔离不等同于 OS 沙箱,不能把不可信项目脚本视为安全代码;它不是自由 shell 代理,也不进入普通用户命令入口。
- 2026-07-11 补充:开发侧新增 headless 单 Agent Runtime 入口 `npm run ai-game-creator-shell:agent-task -- [--init] <projectPath> <agentId> <task>`。该入口不实现第二套 Agent,只复用 Tauri App 的持久任务队列、per-agent 锁、LLM 路由、权限策略、工具 action / observation loop、对话和审计文件,并轮询到 `completed / failed / waiting-for-confirmation` 后用稳定键值行退出;`--init` 只在显式传入且 manifest 不存在时初始化项目。遇到待确认动作时 CLI 返回非零并打印 actionId、tool 和脱敏摘要,后续仍由开发窗口完成确认,不提供静默 `--yes` 绕过。
- 2026-07-11 补充:后台 Agent 的工具规划和最终回复请求对 `LlmError::EmptyResponse` 最多自动重试 3 次(含首次共 4 次请求),与既有 Generator 对上游 HTTP 成功但空 content 的恢复策略一致;对 `Timeout / Connectivity / Transport` 及上游 `408 / 429 / 5xx` 最多额外自动重试 2 次并做线性退避,配置、请求、流能力、反序列化错误及其他 `4xx` 仍立即失败。重试发生在工具计划被解析和执行前,或最终回复尚未落盘时,不会重复执行已经落盘的工具动作。
- 2026-07-11 调整,2026-07-12 更新:后台单 Agent 的 planning loop 每 6 轮形成一个上下文压缩窗口,每轮工具动作上限仍为 3;6 轮不再是整个 run 的固定上限。`loopIteration` 在同一 run 内连续递增,`maxLoopIterations` 指向当前窗口结束轮次,跨重启待确认动作按 context bundle 的 `nextLoopIndex` 继续。每个窗口结束时压缩已有 observation;窗口产生新的独立观察时继续同一 run,最近 6 轮没有独立进展或相邻窗口指纹重复时才进入 `failed / budget-exhausted`,并记录 `loop-budget-exhausted`,不会伪装完成。该调整只作用于后台单 Agent Runtime,不改变游戏草案 Generator/Evaluator 的 3 轮上限;旧实施摘要中“后台 3 轮后整理最终回复”或“整个 run 最多 6 轮”的描述由本条取代。
@@ -4149,11 +4151,13 @@
- 决策:开发单 Agent 对话默认使用可执行 Runtime,输入区通过 `执行 / 聊天` 分段控件显式区分;`执行` 调用 `start_game_creator_agent_runtime_task` 并保留工具策略、确认、取消、排队和状态事件,`聊天` 才使用无工具流式回复,不再保留并列的“后台运行”按钮。消息区使用固定响应式网格行和内部滚动,并在 Runtime 非终态期间显示当前等待对象。Runtime 完成前必须先把 assistant 回复写入发起 Session,再写 completed 终态和广播;落盘失败只能进入 failed。前端收到匹配当前项目、Agent、Session 和 runId 的终态后自动重读对话,切换 Session 会清除当前等待投影,旧 run 事件不得覆盖新 Session。
- 2026-07-12 修正:Runtime 状态为空时也要保留其网格行位,消息区和输入区显式固定到第 5、6 行,禁止空 Runtime 容器通过 `display:none` 让长消息落入 `auto` 行并撑高页面;等待 LLM 期间消息区同步使用 `aria-busy` 暴露忙碌状态。消息区只在用户仍接近底部时自动跟随最新片段,用户向上查看历史后暂停跟随,切换会话、重新读取或主动发送时再恢复。
- 2026-07-12 修正:OpenAI-compatible 流式响应中 `choices` 为空数组或 `null` 的 usage / metadata 包不得再报缺少 `choices[0]`,必须跳过元数据并继续等待正文。首个 delta 前只有 `StreamUnavailable / EmptyResponse / Deserialize` 协议兼容错误允许由 Rust 单 Agent 流式入口回退一次非流式请求;上游状态、鉴权、额度、超时、连接和请求错误直接保留原错误,前端不得再次发起普通 LLM 请求。已收到正文和完成原因后继续保留完整流式回复,不能被尾部坏包覆盖。
- 2026-07-12 修正:Tauri 聊天事件监听被拒绝后,前端选择的普通回复入口必须固定调用 `client.run`,即使 Agent 路由保留 `stream=true` 也不得再内部发 SSE。pending action 的 project revision 快照改为绑定 planning 请求发出前的版本;`file.delete` 取得项目写锁后必须再次校验 revision / verification gate,公共 pending 摘要必须与私有 ledger 完整相等,confirm / reject 只在迁移状态可靠落盘后启动 continuation。manifest 和 pending ledger 禁止 truncate/remove 旧文件后再替换,统一使用同目录临时文件的原子替换及可恢复 backup。
- 2026-07-12 修正:pending ledger 的 `inputSummary` 不是可独立修改的显示文本,每次读写都必须从完整 tool action 重新计算并全等校验。`file.delete` 已删除文件但 Agent DB 审计失败时,observation 必须标记 `needs-reconciliation`Runtime state / event 先落为 `failed / needs-reconciliation` 再尝试追加核对审计,禁止当作普通删除失败继续规划或重放。
- 决策:后台 Agent 首轮不得预加载任何需要工具权限控制的项目内容。planning 与 final reply 只拿身份、session/run 元数据、任务、工具策略和已获准 observation;记忆、黑板、对话、资产和文件内容必须通过对应工具进入。最新黑板、记忆和对话采用尾部保留截断。
- 决策:同一 Agent 的前台聊天与后台队列共享 per-Agent OS 执行锁,前台 LLM 等待期间不持有项目写锁;同 Agent 后台投递保持 pending,前台结束后把当前锁直接移交给 drain,drain 异常不得反写已经完成的聊天结果,不同 Agent 继续并行。
- 决策:重启恢复继续遵守 `agent.resume` 默认确认策略。自动 command 只允许 auto;默认 confirm 由主工作区或独立开发 Agent 聊天窗口的 UI 明确确认后调用独立 command,确认绑定发起项目,切换项目取消旧确认且旧项目异步结果不得污染新项目状态;独立 command 只忽略 confirm、不允许绕过 deny,临时失败必须允许重试。
- 决策:后台 Agent loop 只有空 actions 且不存在验证 blocker 才算收束。项目级 revision 的唯一事实源固定为 `.agent/runtime/project-revision.json`,每个 run 的验证门禁固定为 `.agent/runtime/verification/<agentId>/<runId>.json`。Runtime 在项目写锁内、执行 `file.write / file.patch / project.restore` 之前先保守推进 revision,并把当前 run 的 `requiresVerification` 单向置为 `true`;即使修改随后失败或进程中断也不得回退 revision 或门禁,只允许因此多做一次验证,不能留下漏验证窗口。`requiresVerification` 一旦为 `true`,在该 run 生命周期内永久保留;成功验证只记录其绑定的 revision,不把门禁改回 `false``project.verify``command.run_limited / game.static_smoke` 只有成功且绑定当前 revision 才能作为完成凭证,后续任一修改会让旧凭证失效;未修改项目的只读 run 可保持 `requiresVerification=false`。observation、压缩上下文和 UI 摘要只用于规划与展示,不再作为 revision 或验证门禁的权威真相。
- 决策:per-run context bundle 的 schema 固定升级`game-creator-runtime-context-bundle.v2`durable pending action 的 schema 固定升级为 `game-creator-pending-action.v2`两者都携带并校验 revision / gate 关联;v1 文件恢复必须失败关闭,不得把缺失字段解释为 `requiresVerification=false`,不得自动重放动作或写 completed。准备写最终 assistant 回复或 completed 终态时,Runtime 必须先取得项目写锁,再重读 `.agent/runtime/project-revision.json` 与当前 run 的 verification gate;只有 `requiresVerification=false`,或成功验证绑定的 revision 与锁内重读到的当前 revision 完全一致,才允许在同一把锁内依次写入发起 Session 的 assistant 消息和 completed 终态。缺失、损坏、版本不支持、revision 漂移或验证未通过一律失败关闭,并追加 `runtime.verification` blocker 后继续同一 run 或进入明确失败,不能用锁外旧快照收束。
- 决策:后台 Agent loop 只有空 actions 且不存在验证 blocker 才算收束。项目级 revision 的唯一事实源固定为 `.agent/runtime/project-revision.json`,每个 run 的验证门禁固定为 `.agent/runtime/verification/<agentId>/<runId>.json`。Runtime 在项目写锁内、执行 `file.write / file.patch / file.delete / project.restore` 之前先保守推进 revision,并把当前 run 的 `requiresVerification` 单向置为 `true`;即使修改随后失败或进程中断也不得回退 revision 或门禁,只允许因此多做一次验证,不能留下漏验证窗口。`requiresVerification` 一旦为 `true`,在该 run 生命周期内永久保留;成功验证只记录其绑定的 revision,不把门禁改回 `false``project.verify``command.run_limited / game.static_smoke` 只有成功且绑定当前 revision 才能作为完成凭证,后续任一修改会让旧凭证失效;未修改项目的只读 run 可保持 `requiresVerification=false`。observation、压缩上下文和 UI 摘要只用于规划与展示,不再作为 revision 或验证门禁的权威真相。
- 决策:per-run context bundle 的 schema 固定为 `game-creator-runtime-context-bundle.v2`durable pending action 的 schema 升级为 `game-creator-pending-action.v3`除 per-run verification gate 外还绑定动作创建时的全局 project revision。旧版 pending action 和 v1 context bundle 恢复必须失败关闭,不得把缺失字段解释为可执行,不得自动重放动作或写 completed。批准或自动执行 pending action 前,当前全局 revision 与 gate 必须同时等于创建快照,任一漂移都进入 `needs-reconciliation`。准备写最终 assistant 回复或 completed 终态时,Runtime 必须先取得项目写锁,再重读 `.agent/runtime/project-revision.json` 与当前 run 的 verification gate;只有 `requiresVerification=false`,或成功验证绑定的 revision 与锁内重读到的当前 revision 完全一致,才允许在同一把锁内依次写入发起 Session 的 assistant 消息和 completed 终态。缺失、损坏、版本不支持、revision 漂移或验证未通过一律失败关闭,并追加 `runtime.verification` blocker 后继续同一 run 或进入明确失败,不能用锁外旧快照收束。
- 2026-07-12 修正:后台 finalization 的锁内复核结果区分 `Completed`、可恢复 `Stale` 和真正错误。每次可形成最终回复的 planning 或 final reply LLM 请求开始前都记录项目 `responseRevision`;锁内当前 revision 与它不一致即为 `Stale`,包括 `requiresVerification=false` 的只读 run。`Stale` 必须丢弃旧回复、把 response plan step 恢复为 pending、注入完整 `runtime.verification` blocker,并保持原 Agent、Task、Session、Run、loop 计数和 per-Agent 锁继续 planning;不得创建 retry run,不得写 assistant、completed 或 `background_task.failed`。revision / gate 无法读取或 stale continuation 无法持久化时才进入 failed;一旦 finalization journal 已进入 `prepared`,后续对话或终态落盘失败必须保持可恢复 `finalizing`,不得把当前 run 误记为 failed。
- 2026-07-12 修正:完成预检、finalization、恢复和取消收束遇到同项目另一个 Agent 的短暂项目写锁时,最多等待约 1 秒并重试;锁持续占用才返回 blocker。毫秒级并发收束不能被误判为验证缺失、不能因此回到 planning 或重新请求 LLM。
- 决策:最终回复固定使用 `.agent/runtime/finalizations/<agentId>/<runId>.json``game-creator-runtime-finalization.v1` journal 跨越多文件落盘,状态严格按 `prepared -> assistant-persisted -> runtime-completed` 推进。journal 单独使用 512 KiB 上限,必须容纳 32,000 字符的最大合法回复及元数据;跨平台替换在不能原子覆盖旧文件时,先把旧 journal 原子移动为同目录 `.previous` 恢复副本,再安装新文件,主文件缺失时读取恢复副本,成功推进或终态清理时同时删除副本,不得先删除唯一旧 journal。`resume` 必须在 pending action、普通 running/pending task 和 delegate receipt 修复之前优先恢复 finalization,只按 journal 补齐 assistant 与终态,不重新请求 LLM、不重放工具或 receipt 任务。Runtime state 只是可重建投影;状态文件缺失或损坏但 task ledger 仍能唯一定位主 journal 或恢复副本时,从 task ledger 重建同一 run 后继续恢复。journal 处于 `prepared` 且 assistant 尚未落盘时,若任务已取消,或 revision / verification gate 漂移已使回复过期,必须丢弃 journal 并分别保持取消终态或回到同 run planning。assistant JSONL 是用户可见提交点;取消 command 必须在 per-Agent 锁内检查 journalassistant 尚未存在时立即删除 prepared journal 再取消,assistant 已存在时完成原 finalization 并忽略迟到取消,不能留下孤儿 journal 或把可见回复改判为 cancelled。journal 损坏、版本不支持、身份/回复指纹/幂等 ID 冲突一律失败关闭,并将同一 live run 投影为 `status=running / phase=finalizing` 供 UI 明确显示,不得降级走普通任务恢复。
File diff suppressed because one or more lines are too long
@@ -107,6 +107,10 @@ describe('AI 游戏创作 App 共享契约', () => {
GAME_CREATION_APP_COMMANDS.find((command) => command.id === 'file.read')
?.permission,
).toBe('auto');
expect(
GAME_CREATION_APP_COMMANDS.find((command) => command.id === 'file.delete')
?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find((command) => command.id === 'memory.read')
?.permission,
@@ -770,6 +770,12 @@ mod tests {
.expect("command should exist");
assert_eq!(read.permission, GameCreationAppPermission::Auto);
let delete = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "file.delete")
.expect("file.delete command should exist");
assert_eq!(delete.permission, GameCreationAppPermission::Confirm);
let memory_read = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "memory.read")