合并 origin/master 到资源工作台 V3 分支:解 4 处冲突并保留两侧行为
- WorkspaceLauncher.tsx:保留本分支清单快照 CAS 拒收提示(manifestMergeNotice 提示条、data-manifest-merge-* 观察点、recoverRejectedManifestSnapshot「重新读取清单」)与 activeVersionId/onActiveVersionChange 版本口径,并入 master 的策划/游戏运行态切换(onMakeGame + switchToGameRuntime、suppressInitialGameTurn 抑制首轮、supervisor key 带 agentRuntimeMode、onSwitchToGameRuntime),planningStartMode 统一取 master 的派生值(上下文 startMode 为 planning 且未切到 game 运行时) - ProjectSupervisorView.tsx:props 同时保留本分支 versions/activeVersionId 与 master 的 designView/onDesignApprove/onDesignClarify/onDesignRetry,DesignAgentSurface 审批链路与本分支资源引用输入区并存;directCodex 输入区保留本分支 @ 引用按钮 + 模型选择 + 发送按钮控制条,发送按钮禁用条件并入 master 的 designView 审批待定口径,模型选择沿用 master 的「对话中可切换」口径 - styles.css:本分支追加的资源画布/输入区样式块与 master 追加的 .design-agent-reasoning 样式块都保留,并补上本分支最后一条规则的收尾大括号 - view/project-development/index.tsx:保留本分支的 image-editor 引用(ImageCanvasProjectAssetPickerDialog、ImageCanvasQuickEditPanelView、ImageCanvasSelectedLayerToolbarView、useImageCanvasFloatingOptionDismiss),去掉 master 对已退役 features/asset-canvas 的 import(本分支已由 resource-canvas 取代),保留 master 的 DesignWorkspacePanel 与 planningStartMode 策划工作台分支 - tests/workspaceLauncherManifestMerge.test.tsx:补 master 新引入的 get_design_agent_runtime_mode mock(返回 null,与 master 各套件同口径),拒收提示断言不变 - tests/appSurface/design-agent.suite.ts:审批待定禁用输入的断言改用本分支 Lexical 输入区的禁用口径(容器 data-disabled + editor.setEditable(false)),断言意图不变 - 验证:npm run typecheck 通过;apps/ai-game-creator-shell typecheck 通过;apps/ai-game-creator-shell/tests 96 个测试文件 1380 通过 4 跳过 0 失败;全仓 vitest 297 通过 2 失败(scripts 下两例为 Windows 权限语义导致的既有失败,相关文件与实现均未参与本次合并);npm run check:encoding 与 git diff --check 通过
This commit is contained in:
@@ -12,6 +12,8 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
mod codex_app_server;
|
||||
mod codex_cli;
|
||||
mod codex_provider_proxy;
|
||||
mod design_runtime;
|
||||
mod design_tools;
|
||||
mod direct_codex_attachments;
|
||||
mod direct_codex_audit;
|
||||
mod direct_codex_references;
|
||||
@@ -39,6 +41,7 @@ pub(crate) use codex_cli::{
|
||||
game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity,
|
||||
};
|
||||
pub(crate) use codex_provider_proxy::*;
|
||||
pub(crate) use design_runtime::*;
|
||||
pub(crate) use direct_codex_attachments::*;
|
||||
pub(crate) use direct_codex_audit::*;
|
||||
pub(crate) use direct_codex_references::*;
|
||||
|
||||
@@ -3109,6 +3109,7 @@ fn parse_game_creator_codex_app_server_text(
|
||||
response_id: Some(thread_id.to_string()),
|
||||
usage: None,
|
||||
tool_calls,
|
||||
responses_output: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -593,6 +593,7 @@ fn parse_game_creator_codex_cli_response(
|
||||
response_id,
|
||||
usage,
|
||||
tool_calls,
|
||||
responses_output: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1479,9 +1479,7 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
|
||||
return Err("工具参数 content 不能包含 NUL".to_string());
|
||||
}
|
||||
reject_command_output_wrapper(content)?;
|
||||
// Direct 写入原本用零等待取锁:任何重叠都在 24-42ms 内直接被判成"别人正在写",
|
||||
// 而 `file.write / file.patch / file.delete` 等写入口用的是约 10 秒有界等待。
|
||||
// 这是用户直接触发、失败即整轮无法落盘的项目写入通道,必须和其它写入口同语义:
|
||||
// 这是用户直接触发、失败即整轮无法落盘的项目写入通道:
|
||||
// 短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误。
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
@@ -1507,10 +1505,7 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
|
||||
}
|
||||
|
||||
/// 项目写锁的有界等待是同步轮询(2_000 × 5ms,最多约 10 秒)。handler 是 async,
|
||||
/// 直接在 handler 里走完整条写路径会占住一个 tokio worker:争用窗口内同一轮并行写多个
|
||||
/// 文件时会有多个 worker 被占,而这条 bridge 与只读端点、UI 命令共享同一个 runtime,
|
||||
/// 正是 Issue #318 现场"只读工具全部正常"这条诊断特征会被破坏的情形。
|
||||
/// 因此整条写路径挪进阻塞线程池,等待语义与错误文案都不变。
|
||||
/// 整条写路径必须放进阻塞线程池,避免同步等待占用 tokio worker。
|
||||
async fn bridge_write_file_in_blocking_pool(root: PathBuf, arguments: Value) -> Value {
|
||||
let task_root = root.clone();
|
||||
match tokio::task::spawn_blocking(move || bridge_write_file(&task_root, &arguments)).await {
|
||||
@@ -2748,8 +2743,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #318 第 1 条验收:同一轮里并行的多个文件写必须排队成功,
|
||||
/// 而不是互相报"项目正在被其他写操作占用"。
|
||||
/// 同一轮里并行的多个文件写必须排队成功,而不是互相报"项目正在被其他写操作占用"。
|
||||
#[test]
|
||||
fn bridge_write_file_serializes_parallel_writes_in_one_round() {
|
||||
let temporary = tempfile::tempdir().expect("create parallel direct write root");
|
||||
@@ -2794,8 +2788,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #318 第 1 条验收:App 自己另一条写通道正在写该项目时,
|
||||
/// Direct 写入必须等待后成功,而不是在 24-42ms 内被判成"别人正在写"。
|
||||
/// App 自己另一条写通道正在写该项目时,Direct 写入必须等待后成功。
|
||||
#[test]
|
||||
fn bridge_write_file_waits_for_a_short_same_process_project_writer() {
|
||||
let temporary = tempfile::tempdir().expect("create contended direct write root");
|
||||
@@ -2889,7 +2882,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #318 第 3 条验收:权限拒绝不得被投影成"被其他写操作占用"。
|
||||
/// 权限拒绝不得被投影成"被其他写操作占用"。
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn bridge_write_file_does_not_project_permission_denial_as_contention() {
|
||||
|
||||
@@ -503,6 +503,7 @@ mod tests {
|
||||
response_id: Some("interaction-response".to_string()),
|
||||
usage: None,
|
||||
tool_calls,
|
||||
responses_output: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1846,8 +1846,7 @@ fn acquire_game_creator_agent_runtime_project_write_lock_within(
|
||||
};
|
||||
if attempt + 1 == max_attempts {
|
||||
// 等待预算耗尽才记一条:争用本身可能重试上千次,逐次记账会淹掉日志。
|
||||
// 这条记录回答的正是 Issue #318 现场缺的问题——"谁在持锁、是不是自己人",
|
||||
// 以及等满预算之后这到底是争用还是权限拒绝(`projection=`)。
|
||||
// 这条记录保留持锁方身份和最终分类(`projection=`),便于排查等待耗尽。
|
||||
// 单次试探(max_attempts == 1,例如 hydrate 的 try_acquire_*)根本没有等待:
|
||||
// 既不写 `wait_exhausted`(waitedMs≈0 会让"耗尽"这个词失去意义,而 hydrate
|
||||
// 每次状态变化都会撞一次锁,会把它变成噪声),也不做终态改判。
|
||||
|
||||
+8
@@ -119,6 +119,7 @@ fn persist_tool_plan_handoff_repair_chain(
|
||||
response_id: None,
|
||||
usage: None,
|
||||
tool_calls: Vec::new(),
|
||||
responses_output: Vec::new(),
|
||||
};
|
||||
tool_plan_handoff::write_at(
|
||||
root,
|
||||
@@ -1006,6 +1007,7 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
|
||||
response_id: None,
|
||||
usage: None,
|
||||
tool_calls: Vec::new(),
|
||||
responses_output: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("write old Provider handoff");
|
||||
@@ -1118,6 +1120,7 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
|
||||
response_id: None,
|
||||
usage: None,
|
||||
tool_calls: Vec::new(),
|
||||
responses_output: Vec::new(),
|
||||
};
|
||||
tool_plan_handoff::write_at(
|
||||
root,
|
||||
@@ -1303,6 +1306,7 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
|
||||
response_id: None,
|
||||
usage: None,
|
||||
tool_calls: Vec::new(),
|
||||
responses_output: Vec::new(),
|
||||
};
|
||||
match tool_plan_handoff::write_at(
|
||||
root,
|
||||
@@ -1436,6 +1440,7 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
|
||||
response_id: None,
|
||||
usage: None,
|
||||
tool_calls: Vec::new(),
|
||||
responses_output: Vec::new(),
|
||||
};
|
||||
tool_plan_handoff::write_at(
|
||||
root,
|
||||
@@ -1551,6 +1556,7 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
|
||||
response_id: None,
|
||||
usage: None,
|
||||
tool_calls: Vec::new(),
|
||||
responses_output: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("write cleanup handoff");
|
||||
@@ -1621,6 +1627,7 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
|
||||
response_id: None,
|
||||
usage: None,
|
||||
tool_calls: Vec::new(),
|
||||
responses_output: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("write terminal handoff");
|
||||
@@ -1715,6 +1722,7 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
|
||||
response_id: None,
|
||||
usage: None,
|
||||
tool_calls: Vec::new(),
|
||||
responses_output: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("write Provider handoff");
|
||||
|
||||
@@ -3,6 +3,7 @@ use super::*;
|
||||
mod autonomous_completion;
|
||||
mod context_bundle;
|
||||
mod context_window;
|
||||
mod design_session;
|
||||
mod finalization;
|
||||
mod json_sidecar;
|
||||
mod models;
|
||||
@@ -19,6 +20,7 @@ mod verification;
|
||||
|
||||
pub(in crate::agent) use autonomous_completion::*;
|
||||
pub(in crate::agent) use context_bundle::*;
|
||||
pub(crate) use design_session::*;
|
||||
pub(in crate::agent) use finalization::*;
|
||||
pub(in crate::agent) use json_sidecar::*;
|
||||
pub(in crate::agent) use models::*;
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
use super::*;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) const DESIGN_SESSION_SCHEMA_VERSION: &str = "design-agent-session.v1";
|
||||
pub(crate) const DESIGN_SESSION_ENGINE: &str = "design-agent";
|
||||
pub(crate) const DESIGN_SESSION_PATH: &str = ".agent/design-agent/session.json";
|
||||
pub(crate) const DESIGN_RUNTIME_MODE_PATH: &str = ".agent/runtime-mode.json";
|
||||
const DESIGN_SESSION_MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DesignRuntimeMode {
|
||||
pub(crate) active_runtime: String,
|
||||
}
|
||||
|
||||
pub(crate) fn read_design_runtime_mode(root: &Path) -> Result<Option<DesignRuntimeMode>, String> {
|
||||
read_agent_runtime_json_sidecar_with_max_bytes(
|
||||
root,
|
||||
DESIGN_RUNTIME_MODE_PATH,
|
||||
"项目 Agent 运行时模式",
|
||||
4096,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn write_design_runtime_mode(
|
||||
root: &Path,
|
||||
active_runtime: &str,
|
||||
) -> Result<DesignRuntimeMode, String> {
|
||||
if !matches!(active_runtime, "design" | "game") {
|
||||
return Err("未知项目 Agent 运行时模式".to_string());
|
||||
}
|
||||
let mode = DesignRuntimeMode {
|
||||
active_runtime: active_runtime.to_string(),
|
||||
};
|
||||
write_agent_runtime_json_sidecar_with_max_bytes(
|
||||
root,
|
||||
DESIGN_RUNTIME_MODE_PATH,
|
||||
"项目 Agent 运行时模式",
|
||||
&mode,
|
||||
4096,
|
||||
)?;
|
||||
Ok(mode)
|
||||
}
|
||||
|
||||
pub(crate) const DESIGN_PHASES: [&str; 6] = [
|
||||
"concept",
|
||||
"top_design",
|
||||
"architecture",
|
||||
"systems",
|
||||
"tdd",
|
||||
"consultant",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DesignApprovalRequest {
|
||||
pub(crate) request_id: String,
|
||||
pub(crate) phase: String,
|
||||
pub(crate) submitted_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DesignClarificationRequest {
|
||||
pub(crate) request_id: String,
|
||||
pub(crate) question: String,
|
||||
#[serde(default)]
|
||||
pub(crate) options: Vec<String>,
|
||||
pub(crate) created_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DesignSession {
|
||||
pub(crate) schema_version: String,
|
||||
pub(crate) engine: String,
|
||||
pub(crate) session_id: String,
|
||||
pub(crate) project_id: String,
|
||||
/// 入口选择的 AGC 模型目录 ID;同一会话内保持稳定,不保存上游真实模型名。
|
||||
#[serde(default)]
|
||||
pub(crate) model_id: String,
|
||||
pub(crate) current_phase: String,
|
||||
#[serde(default)]
|
||||
pub(crate) approved_phases: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) pending_approval: Option<DesignApprovalRequest>,
|
||||
#[serde(default)]
|
||||
pub(crate) pending_clarification: Option<DesignClarificationRequest>,
|
||||
#[serde(default)]
|
||||
pub(crate) required_artifacts: BTreeMap<String, Vec<String>>,
|
||||
pub(crate) turn_index: u64,
|
||||
pub(crate) created_at: u64,
|
||||
pub(crate) updated_at: u64,
|
||||
#[serde(default)]
|
||||
pub(crate) last_error: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) history: Vec<Value>,
|
||||
#[serde(default)]
|
||||
pub(crate) messages: Vec<DesignMessage>,
|
||||
#[serde(default)]
|
||||
pub(crate) commands: BTreeMap<String, Value>,
|
||||
#[serde(default)]
|
||||
pub(crate) turn: Option<DesignTurn>,
|
||||
#[serde(default)]
|
||||
pub(crate) pending_batch: Option<DesignToolBatch>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub(crate) struct DesignMessage {
|
||||
pub(crate) id: String,
|
||||
pub(crate) role: String,
|
||||
pub(crate) text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DesignTurn {
|
||||
pub(crate) id: String,
|
||||
pub(crate) pending: bool,
|
||||
pub(crate) request_index: u64,
|
||||
pub(crate) attempt: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DesignToolBatch {
|
||||
pub(crate) calls: Vec<platform_llm::LlmToolCall>,
|
||||
pub(crate) cursor: usize,
|
||||
pub(crate) executing: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn design_session_path(root: &Path) -> PathBuf {
|
||||
root.join(DESIGN_SESSION_PATH)
|
||||
}
|
||||
|
||||
pub(crate) fn design_phase_required_artifacts() -> BTreeMap<String, Vec<String>> {
|
||||
BTreeMap::from([
|
||||
(
|
||||
"concept".to_string(),
|
||||
vec![
|
||||
"project/00_concept/design.md".to_string(),
|
||||
"project/速览卡.md".to_string(),
|
||||
],
|
||||
),
|
||||
(
|
||||
"top_design".to_string(),
|
||||
vec!["project/01_top_design/design.md".to_string()],
|
||||
),
|
||||
(
|
||||
"architecture".to_string(),
|
||||
vec!["project/02_architecture/design.md".to_string()],
|
||||
),
|
||||
("systems".to_string(), Vec::new()),
|
||||
(
|
||||
"tdd".to_string(),
|
||||
vec![
|
||||
"project/04_tdd/01_技术实现.md".to_string(),
|
||||
"project/04_tdd/02_美术圣经.md".to_string(),
|
||||
"project/04_tdd/03_数据与配表.md".to_string(),
|
||||
"project/04_tdd/总册.md".to_string(),
|
||||
],
|
||||
),
|
||||
("consultant".to_string(), Vec::new()),
|
||||
])
|
||||
}
|
||||
|
||||
pub(crate) fn new_design_session(
|
||||
project_id: impl Into<String>,
|
||||
model_id: impl Into<String>,
|
||||
) -> DesignSession {
|
||||
let now = unix_timestamp();
|
||||
DesignSession {
|
||||
schema_version: DESIGN_SESSION_SCHEMA_VERSION.to_string(),
|
||||
engine: DESIGN_SESSION_ENGINE.to_string(),
|
||||
session_id: Uuid::new_v4().to_string(),
|
||||
project_id: project_id.into(),
|
||||
model_id: model_id.into(),
|
||||
current_phase: DESIGN_PHASES[0].to_string(),
|
||||
approved_phases: Vec::new(),
|
||||
pending_approval: None,
|
||||
pending_clarification: None,
|
||||
required_artifacts: design_phase_required_artifacts(),
|
||||
turn_index: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_error: None,
|
||||
history: Vec::new(),
|
||||
messages: Vec::new(),
|
||||
commands: BTreeMap::new(),
|
||||
turn: None,
|
||||
pending_batch: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_design_session(session: &DesignSession) -> Result<(), String> {
|
||||
if session.schema_version != DESIGN_SESSION_SCHEMA_VERSION
|
||||
|| session.engine != DESIGN_SESSION_ENGINE
|
||||
|| session.session_id.trim().is_empty()
|
||||
|| session.project_id.trim().is_empty()
|
||||
|| !DESIGN_PHASES.contains(&session.current_phase.as_str())
|
||||
|| session
|
||||
.approved_phases
|
||||
.iter()
|
||||
.any(|phase| !DESIGN_PHASES.contains(&phase.as_str()))
|
||||
{
|
||||
return Err("策划 Agent 会话字段无效".to_string());
|
||||
}
|
||||
if let Some(pending) = &session.pending_approval {
|
||||
if pending.request_id.trim().is_empty()
|
||||
|| pending.phase != session.current_phase
|
||||
|| !DESIGN_PHASES.contains(&pending.phase.as_str())
|
||||
|| pending.phase == "consultant"
|
||||
{
|
||||
return Err("策划 Agent 待审批请求无效".to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn read_design_session(root: &Path) -> Result<Option<DesignSession>, String> {
|
||||
let Some(session) = read_agent_runtime_json_sidecar_with_max_bytes::<DesignSession>(
|
||||
root,
|
||||
DESIGN_SESSION_PATH,
|
||||
"策划 Agent 会话",
|
||||
DESIGN_SESSION_MAX_BYTES,
|
||||
)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
validate_design_session(&session)?;
|
||||
Ok(Some(session))
|
||||
}
|
||||
|
||||
pub(crate) fn write_design_session(root: &Path, session: &DesignSession) -> Result<(), String> {
|
||||
validate_design_session(session)?;
|
||||
write_agent_runtime_json_sidecar_with_max_bytes(
|
||||
root,
|
||||
DESIGN_SESSION_PATH,
|
||||
"策划 Agent 会话",
|
||||
session,
|
||||
DESIGN_SESSION_MAX_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn design_phase_index(phase: &str) -> Result<usize, String> {
|
||||
DESIGN_PHASES
|
||||
.iter()
|
||||
.position(|candidate| *candidate == phase)
|
||||
.ok_or_else(|| format!("未知策划阶段:{phase}"))
|
||||
}
|
||||
|
||||
pub(crate) fn check_design_phase_artifacts(
|
||||
root: &Path,
|
||||
session: &DesignSession,
|
||||
phase: &str,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let target_index = design_phase_index(phase)?;
|
||||
let mut missing = Vec::new();
|
||||
for required_phase in DESIGN_PHASES.iter().take(target_index + 1) {
|
||||
for relative in session
|
||||
.required_artifacts
|
||||
.get(*required_phase)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let path = resolve_local_project_path(root, &format!("design_artifacts/{relative}"))?;
|
||||
if !path.is_file() {
|
||||
missing.push(relative.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(missing)
|
||||
}
|
||||
|
||||
pub(crate) fn submit_design_phase_for_approval(
|
||||
root: &Path,
|
||||
session: &mut DesignSession,
|
||||
) -> Result<DesignApprovalRequest, String> {
|
||||
if session.current_phase == "consultant" {
|
||||
return Err("顾问态不提交阶段审批".to_string());
|
||||
}
|
||||
if let Some(request) = &session.pending_approval {
|
||||
return Ok(request.clone());
|
||||
}
|
||||
let missing = check_design_phase_artifacts(root, session, &session.current_phase)?;
|
||||
if !missing.is_empty() {
|
||||
return Err(format!("缺少必需产物:{}", missing.join("、")));
|
||||
}
|
||||
let request = DesignApprovalRequest {
|
||||
request_id: Uuid::new_v4().to_string(),
|
||||
phase: session.current_phase.clone(),
|
||||
submitted_at: unix_timestamp(),
|
||||
};
|
||||
session.pending_approval = Some(request.clone());
|
||||
session.updated_at = unix_timestamp();
|
||||
session.last_error = None;
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
pub(crate) fn approve_design_phase(
|
||||
session: &mut DesignSession,
|
||||
request_id: &str,
|
||||
) -> Result<String, String> {
|
||||
let pending = session
|
||||
.pending_approval
|
||||
.take()
|
||||
.ok_or_else(|| "当前没有待审批阶段".to_string())?;
|
||||
if pending.request_id != request_id {
|
||||
session.pending_approval = Some(pending);
|
||||
return Err("审批请求已过期".to_string());
|
||||
}
|
||||
session.approved_phases.push(session.current_phase.clone());
|
||||
let next = design_phase_index(&session.current_phase)? + 1;
|
||||
session.current_phase = DESIGN_PHASES
|
||||
.get(next)
|
||||
.ok_or_else(|| "顾问态没有下一阶段".to_string())?
|
||||
.to_string();
|
||||
session.pending_clarification = None;
|
||||
session.updated_at = unix_timestamp();
|
||||
Ok(session.current_phase.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn reject_design_phase(
|
||||
session: &mut DesignSession,
|
||||
request_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let pending = session
|
||||
.pending_approval
|
||||
.take()
|
||||
.ok_or_else(|| "当前没有待审批阶段".to_string())?;
|
||||
if pending.request_id != request_id {
|
||||
session.pending_approval = Some(pending);
|
||||
return Err("审批请求已过期".to_string());
|
||||
}
|
||||
session.updated_at = unix_timestamp();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn phase_artifact_check_includes_previous_phases() {
|
||||
let root = tempfile::tempdir().expect("tempdir");
|
||||
let mut session = new_design_session("project", "quality");
|
||||
fs::create_dir_all(root.path().join("design_artifacts/project/00_concept")).expect("mkdir");
|
||||
fs::write(
|
||||
root.path()
|
||||
.join("design_artifacts/project/00_concept/design.md"),
|
||||
"x",
|
||||
)
|
||||
.expect("write");
|
||||
fs::write(root.path().join("design_artifacts/project/速览卡.md"), "x").expect("write");
|
||||
fs::create_dir_all(root.path().join("design_artifacts/project/01_top_design"))
|
||||
.expect("mkdir");
|
||||
fs::write(
|
||||
root.path()
|
||||
.join("design_artifacts/project/01_top_design/design.md"),
|
||||
"x",
|
||||
)
|
||||
.expect("write");
|
||||
let missing =
|
||||
check_design_phase_artifacts(root.path(), &session, "top_design").expect("check");
|
||||
assert!(missing.is_empty());
|
||||
session.current_phase = "top_design".to_string();
|
||||
let request = submit_design_phase_for_approval(root.path(), &mut session).expect("submit");
|
||||
assert_eq!(request.phase, "top_design");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejecting_approval_keeps_phase_and_clears_request() {
|
||||
let root = tempfile::tempdir().expect("tempdir");
|
||||
let mut session = new_design_session("project", "quality");
|
||||
fs::create_dir_all(root.path().join("design_artifacts/project/00_concept")).expect("mkdir");
|
||||
fs::write(
|
||||
root.path()
|
||||
.join("design_artifacts/project/00_concept/design.md"),
|
||||
"x",
|
||||
)
|
||||
.expect("write");
|
||||
fs::write(root.path().join("design_artifacts/project/速览卡.md"), "x").expect("write");
|
||||
let request = submit_design_phase_for_approval(root.path(), &mut session).expect("submit");
|
||||
reject_design_phase(&mut session, &request.request_id).expect("reject");
|
||||
assert_eq!(session.current_phase, "concept");
|
||||
assert!(session.pending_approval.is_none());
|
||||
}
|
||||
}
|
||||
@@ -807,6 +807,7 @@ mod provider_reconciliation_diagnostic_tests {
|
||||
name: "runtime_tool_plan_submit_gdd".to_string(),
|
||||
arguments: "{\"path\":\"C:\\\\private\\\\argument\"}".to_string(),
|
||||
}],
|
||||
responses_output: Vec::new(),
|
||||
};
|
||||
let relative = write_provider_reconciliation_diagnostic_in_dir(
|
||||
directory.path(),
|
||||
|
||||
@@ -604,6 +604,70 @@ pub(crate) fn register_local_asset_at(
|
||||
register_local_asset_entry(root, local_path, kind, media_type, id_prefix, source)
|
||||
}
|
||||
|
||||
pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<usize, String> {
|
||||
let design_root = root.join("design_artifacts");
|
||||
if !design_root.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
let mut directories = vec![design_root];
|
||||
while let Some(directory) = directories.pop() {
|
||||
for entry in fs::read_dir(&directory)
|
||||
.map_err(|error| format!("读取策划产物目录失败:{}: {error}", directory.display()))?
|
||||
{
|
||||
let entry = entry.map_err(|error| format!("读取策划产物失败:{error}"))?;
|
||||
let path = entry.path();
|
||||
let metadata = fs::symlink_metadata(&path)
|
||||
.map_err(|error| format!("读取策划产物元数据失败:{}: {error}", path.display()))?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
continue;
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
directories.push(path);
|
||||
} else if metadata.is_file() {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
let mut registered = 0;
|
||||
for path in files {
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.map_err(|_| "策划产物路径不在项目根目录内".to_string())?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let media_type = match path.extension().and_then(|value| value.to_str()) {
|
||||
Some("md") => "text/markdown",
|
||||
Some("txt") => "text/plain",
|
||||
Some("json") => "application/json",
|
||||
Some("yaml" | "yml") => "text/yaml",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
register_local_asset_at(
|
||||
root,
|
||||
&relative,
|
||||
"design-document",
|
||||
media_type,
|
||||
"design-document",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Uploaded,
|
||||
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(),
|
||||
},
|
||||
)?;
|
||||
registered += 1;
|
||||
}
|
||||
Ok(registered)
|
||||
}
|
||||
|
||||
pub(crate) fn import_canvas_asset_at(
|
||||
root: &Path,
|
||||
local_path: &str,
|
||||
|
||||
@@ -455,6 +455,7 @@ fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result<PathBuf,
|
||||
pub(crate) fn create_automatic_local_game_project_at(
|
||||
projects_root: &Path,
|
||||
requested_name: Option<&str>,
|
||||
planning: bool,
|
||||
) -> Result<InitLocalProjectResult, String> {
|
||||
let requested_name = requested_name
|
||||
.map(normalize_game_creation_project_name)
|
||||
@@ -477,9 +478,14 @@ pub(crate) fn create_automatic_local_game_project_at(
|
||||
for _ in 0..16 {
|
||||
let workspace_id = uuid::Uuid::new_v4().simple().to_string();
|
||||
let short_id = &workspace_id[..8];
|
||||
let project_name = requested_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("GameAgent 项目 {short_id}"));
|
||||
let project_name = requested_name.clone().unwrap_or_else(|| {
|
||||
let prefix = if planning {
|
||||
"策划项目"
|
||||
} else {
|
||||
"GameAgent 项目"
|
||||
};
|
||||
format!("{prefix} {short_id}")
|
||||
});
|
||||
let project_root = projects_root.join(format!("gameagent-{short_id}"));
|
||||
match fs::create_dir(&project_root) {
|
||||
Ok(()) => {
|
||||
@@ -518,10 +524,12 @@ pub(crate) fn create_automatic_local_game_project_at(
|
||||
pub(crate) fn create_automatic_local_game_project(
|
||||
app: tauri::AppHandle,
|
||||
name: Option<String>,
|
||||
planning: Option<bool>,
|
||||
) -> Result<InitLocalProjectResult, String> {
|
||||
create_automatic_local_game_project_at(
|
||||
&automatic_local_game_projects_root(&app)?,
|
||||
name.as_deref(),
|
||||
planning.unwrap_or(false),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,6 @@ fn user_selected_path_is_authorized(path: &Path, is_directory: bool) -> bool {
|
||||
}
|
||||
|
||||
pub(crate) const OFFICIAL_LLM_ROUTER_BASE_URL: &str = "https://router.genarrative.world/v1";
|
||||
pub(crate) const OFFICIAL_LLM_ROUTER_MODEL: &str = "gpt-6-astra";
|
||||
|
||||
pub(crate) const GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS: [(&str, &str); 21] = [
|
||||
(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "high"),
|
||||
@@ -208,11 +207,12 @@ fn build_game_creator_official_platform_llm_config(
|
||||
LlmProvider::OpenAiCompatible,
|
||||
proxy_base_url,
|
||||
session.access_token,
|
||||
OFFICIAL_LLM_ROUTER_MODEL.to_string(),
|
||||
llm.model.clone(),
|
||||
llm.request_timeout_ms,
|
||||
llm.max_retries,
|
||||
llm.retry_backoff_ms,
|
||||
)
|
||||
.map(|config| config.with_agc_client_marker(true))
|
||||
.map_err(|error| format!("官方 LLM Router 代理配置无效:{error}"))
|
||||
}
|
||||
|
||||
|
||||
@@ -913,6 +913,7 @@ mod tests {
|
||||
total_tokens: 366,
|
||||
}),
|
||||
tool_calls: Vec::new(),
|
||||
responses_output: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2592,6 +2592,15 @@ fn main() {
|
||||
continue_planning_session_v2,
|
||||
decide_planning_artifact_v2,
|
||||
hydrate_planning_session_v2,
|
||||
hydrate_design_agent_session,
|
||||
reset_design_agent_session,
|
||||
get_design_agent_runtime_mode,
|
||||
set_design_agent_runtime_mode,
|
||||
debug_fast_forward_design_session,
|
||||
continue_design_agent_session,
|
||||
decide_design_phase,
|
||||
list_design_workspace,
|
||||
read_design_workspace_file,
|
||||
start_game_creator_agent_runtime_task,
|
||||
start_game_creator_supervisor_runtime_task,
|
||||
compact_game_creator_agent_runtime_context,
|
||||
|
||||
@@ -55,6 +55,7 @@ impl AgentRuntimeProviderHandoffRecord {
|
||||
response_id: self.response.response_id.clone(),
|
||||
usage: self.response.usage.clone(),
|
||||
tool_calls: Vec::new(),
|
||||
responses_output: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,6 +348,7 @@ mod tests {
|
||||
total_tokens: 18,
|
||||
}),
|
||||
tool_calls: Vec::new(),
|
||||
responses_output: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2779,6 +2779,7 @@ fn durable_provider_handoff_prevents_shutdown_even_when_corrupt() {
|
||||
response_id: Some("provider-handoff-response".to_string()),
|
||||
usage: None,
|
||||
tool_calls: Vec::new(),
|
||||
responses_output: Vec::new(),
|
||||
};
|
||||
let provider_request_id = format!("provider-request-{}", "f".repeat(64));
|
||||
crate::provider_handoff::write_at(
|
||||
|
||||
@@ -4484,6 +4484,7 @@ fn real_e2e_tool_plan_checkpoint_response() -> platform_llm::LlmRunResponse {
|
||||
})
|
||||
.to_string(),
|
||||
}],
|
||||
responses_output: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4717,6 +4718,7 @@ fn agent_tool_plan_llm_response(
|
||||
response_id: Some("response-tool-plan-test".to_string()),
|
||||
usage: None,
|
||||
tool_calls,
|
||||
responses_output: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1411,15 +1411,27 @@ fn project_picker_uses_the_closest_existing_parent_for_a_suggested_new_path() {
|
||||
fn automatic_local_game_project_allocates_unique_initialized_workspaces() {
|
||||
let projects_root = unique_project_path();
|
||||
|
||||
let first = create_automatic_local_game_project_at(&projects_root, None)
|
||||
let first = create_automatic_local_game_project_at(&projects_root, None, false)
|
||||
.expect("create first automatic workspace");
|
||||
let second = create_automatic_local_game_project_at(&projects_root, Some("自定义项目名"))
|
||||
.expect("create second automatic workspace");
|
||||
let second =
|
||||
create_automatic_local_game_project_at(&projects_root, Some("自定义项目名"), false)
|
||||
.expect("create second automatic workspace");
|
||||
let planning = create_automatic_local_game_project_at(&projects_root, None, true)
|
||||
.expect("create planning workspace");
|
||||
|
||||
assert_ne!(first.project_path, second.project_path);
|
||||
assert!(first.manifest.name.starts_with("GameAgent 项目 "));
|
||||
assert_eq!(second.manifest.name, "自定义项目名");
|
||||
for result in [first, second] {
|
||||
let planning_directory = Path::new(&planning.project_path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.expect("planning directory name");
|
||||
let short_id = planning_directory.strip_prefix("gameagent-").unwrap();
|
||||
assert_eq!(short_id.len(), 8);
|
||||
assert_eq!(planning.manifest.name, format!("策划项目 {short_id}"));
|
||||
assert_ne!(planning.project_path, first.project_path);
|
||||
assert_ne!(planning.project_path, second.project_path);
|
||||
for result in [first, second, planning] {
|
||||
let root = PathBuf::from(&result.project_path);
|
||||
assert_eq!(root.parent(), Some(projects_root.as_path()));
|
||||
assert!(root
|
||||
@@ -1437,8 +1449,9 @@ fn automatic_local_game_project_allocates_unique_initialized_workspaces() {
|
||||
fn automatic_local_game_project_accepts_only_a_safe_custom_name() {
|
||||
let projects_root = unique_project_path();
|
||||
|
||||
let result = create_automatic_local_game_project_at(&projects_root, Some(" 星轨夜航 "))
|
||||
.expect("create named automatic workspace");
|
||||
let result =
|
||||
create_automatic_local_game_project_at(&projects_root, Some(" 星轨夜航 "), false)
|
||||
.expect("create named automatic workspace");
|
||||
assert_eq!(result.manifest.name, "星轨夜航");
|
||||
|
||||
for name in [
|
||||
@@ -1447,7 +1460,7 @@ fn automatic_local_game_project_accepts_only_a_safe_custom_name() {
|
||||
&"长".repeat(GAME_CREATION_PROJECT_NAME_MAX_CHARS + 1),
|
||||
] {
|
||||
assert!(
|
||||
create_automatic_local_game_project_at(&projects_root, Some(name)).is_err(),
|
||||
create_automatic_local_game_project_at(&projects_root, Some(name), false).is_err(),
|
||||
"unsafe name must fail: {name:?}"
|
||||
);
|
||||
}
|
||||
@@ -1589,7 +1602,7 @@ fn automatic_local_game_project_rejects_symlinked_projects_root() {
|
||||
fs::create_dir_all(&target).expect("create automatic workspace target");
|
||||
symlink(&target, &projects_root).expect("symlink automatic workspace root");
|
||||
|
||||
let error = create_automatic_local_game_project_at(&projects_root, None)
|
||||
let error = create_automatic_local_game_project_at(&projects_root, None, false)
|
||||
.expect_err("symlinked automatic workspace root must fail");
|
||||
|
||||
assert!(error.contains("不能包含符号链接"));
|
||||
|
||||
@@ -136,6 +136,7 @@ impl AgentRuntimeToolPlanHandoffEntry {
|
||||
.iter()
|
||||
.map(LlmToolCall::from)
|
||||
.collect(),
|
||||
responses_output: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ fn response(text: &str, tool_calls: Vec<LlmToolCall>) -> LlmRunResponse {
|
||||
total_tokens: 34,
|
||||
}),
|
||||
tool_calls,
|
||||
responses_output: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user