cec971c438
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m49s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 4m50s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m53s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m16s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m1s
Project CI / AI game creator shell Rust crates (push) Successful in 2m14s
Project CI / Frontend tests (push) Failing after 4m6s
Project CI / Repository checks (push) Successful in 4m33s
Project CI / Native shell tests (push) Successful in 6m33s
Project CI / Backend tests (push) Successful in 8m26s
Project CI / AI game creator shell web tests (push) Successful in 3m43s
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/386 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
2083 lines
74 KiB
Rust
2083 lines
74 KiB
Rust
use super::design_tools::*;
|
||
use super::*;
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{json, Value};
|
||
use std::fs::File;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::OnceLock;
|
||
use std::time::Duration;
|
||
use tauri::Emitter;
|
||
use uuid::Uuid;
|
||
|
||
const DESIGN_ACTIVE_LOCK: &str = ".agent/design-agent/active.lock";
|
||
|
||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||
#[serde(
|
||
tag = "type",
|
||
rename_all = "camelCase",
|
||
rename_all_fields = "camelCase"
|
||
)]
|
||
pub(crate) enum DesignInput {
|
||
Message {
|
||
text: String,
|
||
},
|
||
Clarification {
|
||
request_id: String,
|
||
option_index: Option<usize>,
|
||
text: Option<String>,
|
||
},
|
||
Retry,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct DesignSessionSummary {
|
||
session_id: String,
|
||
project_id: String,
|
||
current_phase: String,
|
||
approved_phases: Vec<String>,
|
||
pending_approval: Option<DesignApprovalRequest>,
|
||
pending_clarification: Option<DesignClarificationRequest>,
|
||
turn_index: u64,
|
||
last_error: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct DesignView {
|
||
session: DesignSessionSummary,
|
||
messages: Vec<DesignMessage>,
|
||
running: bool,
|
||
can_retry: bool,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
reasoning_text: Option<String>,
|
||
reasoning_entries: Vec<DesignReasoningEntry>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct DesignReasoningEntry {
|
||
id: String,
|
||
text: String,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
message_id: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct DesignEvent {
|
||
project_path: String,
|
||
client_turn_id: String,
|
||
kind: String,
|
||
message_id: Option<String>,
|
||
text: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
reasoning_text: Option<String>,
|
||
view: Option<DesignView>,
|
||
}
|
||
|
||
fn design_view(session: &DesignSession, running: bool) -> DesignView {
|
||
let reasoning_entries = persisted_design_reasoning_entries(session);
|
||
DesignView {
|
||
session: DesignSessionSummary {
|
||
session_id: session.session_id.clone(),
|
||
project_id: session.project_id.clone(),
|
||
current_phase: session.current_phase.clone(),
|
||
approved_phases: session.approved_phases.clone(),
|
||
pending_approval: session.pending_approval.clone(),
|
||
pending_clarification: session.pending_clarification.clone(),
|
||
turn_index: session.turn_index,
|
||
last_error: session.last_error.clone(),
|
||
},
|
||
messages: session.messages.clone(),
|
||
running,
|
||
can_retry: !running
|
||
&& session.turn.as_ref().is_some_and(|turn| turn.pending)
|
||
&& session.pending_approval.is_none()
|
||
&& session.pending_clarification.is_none(),
|
||
reasoning_text: reasoning_entries.last().map(|entry| entry.text.clone()),
|
||
reasoning_entries,
|
||
}
|
||
}
|
||
|
||
fn reasoning_text_from_history_item(item: &Value) -> Option<String> {
|
||
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
|
||
return None;
|
||
}
|
||
let mut text = String::new();
|
||
if let Some(summary) = item.get("summary").and_then(Value::as_array) {
|
||
for part in summary {
|
||
if let Some(value) = part.get("text").and_then(Value::as_str) {
|
||
text.push_str(value.trim());
|
||
}
|
||
}
|
||
}
|
||
if let Some(content) = item.get("content").and_then(Value::as_array) {
|
||
for part in content {
|
||
let part_type = part.get("type").and_then(Value::as_str).unwrap_or_default();
|
||
if matches!(
|
||
part_type,
|
||
"reasoning" | "reasoning_content" | "reasoning_text" | "analysis" | "thinking"
|
||
) {
|
||
if let Some(value) = part.get("text").and_then(Value::as_str) {
|
||
text.push_str(value.trim());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
(!text.trim().is_empty()).then_some(text)
|
||
}
|
||
|
||
fn persisted_design_reasoning_entries(session: &DesignSession) -> Vec<DesignReasoningEntry> {
|
||
// Responses history contains tool-only provider responses. Their reasoning is
|
||
// followed by function calls and only the next provider response may contain
|
||
// visible assistant text, so pairing on the next `message` item makes the
|
||
// earlier reasoning look like an orphan and moves it to the bottom of the UI.
|
||
// Both persisted streams retain user-turn boundaries; pair reasoning and
|
||
// visible assistant messages by their response order within each turn.
|
||
let mut assistant_groups: Vec<Vec<String>> = vec![Vec::new()];
|
||
for message in &session.messages {
|
||
if message.role == "user" {
|
||
assistant_groups.push(Vec::new());
|
||
} else if message.role == "assistant" {
|
||
assistant_groups
|
||
.last_mut()
|
||
.expect("assistant group always exists")
|
||
.push(message.id.clone());
|
||
}
|
||
}
|
||
let mut entries = Vec::new();
|
||
let mut group_index = 0;
|
||
let mut assistant_index = 0;
|
||
let mut sequence = 0_u64;
|
||
let mut current_reasoning: Vec<DesignReasoningEntry> = Vec::new();
|
||
let mut pending_reasoning: Vec<DesignReasoningEntry> = Vec::new();
|
||
let mut saw_response_output = false;
|
||
|
||
for item in &session.history {
|
||
if item.get("role").and_then(Value::as_str) == Some("user") {
|
||
if !pending_reasoning.is_empty() || !current_reasoning.is_empty() {
|
||
pending_reasoning.append(&mut current_reasoning);
|
||
// A user item closes the previous turn. Resolve its reasoning
|
||
// against that turn's last assistant message before moving to
|
||
// the next group; otherwise it is incorrectly attached to the
|
||
// next turn and rendered at the bottom as an orphan.
|
||
let assistant_id = assistant_groups
|
||
.get(group_index)
|
||
.and_then(|ids| ids.last())
|
||
.cloned();
|
||
for mut entry in pending_reasoning.drain(..) {
|
||
entry.message_id = assistant_id.clone();
|
||
entries.push(entry);
|
||
}
|
||
}
|
||
group_index += 1;
|
||
assistant_index = 0;
|
||
saw_response_output = false;
|
||
continue;
|
||
}
|
||
if item.get("type").and_then(Value::as_str) == Some("reasoning") {
|
||
if saw_response_output {
|
||
pending_reasoning.append(&mut current_reasoning);
|
||
saw_response_output = false;
|
||
}
|
||
if let Some(text) = reasoning_text_from_history_item(item) {
|
||
sequence += 1;
|
||
current_reasoning.push(DesignReasoningEntry {
|
||
id: item
|
||
.get("id")
|
||
.and_then(Value::as_str)
|
||
.map(str::to_string)
|
||
.unwrap_or_else(|| format!("reasoning-{sequence}")),
|
||
text,
|
||
message_id: None,
|
||
});
|
||
}
|
||
continue;
|
||
}
|
||
if item.get("role").and_then(Value::as_str) == Some("assistant")
|
||
|| item.get("type").and_then(Value::as_str) == Some("message")
|
||
{
|
||
pending_reasoning.extend(current_reasoning.drain(..));
|
||
let assistant_id = assistant_groups
|
||
.get(group_index)
|
||
.and_then(|ids| ids.get(assistant_index))
|
||
.cloned();
|
||
assistant_index += 1;
|
||
for mut entry in pending_reasoning.drain(..) {
|
||
entry.message_id = assistant_id.clone();
|
||
entries.push(entry);
|
||
}
|
||
saw_response_output = false;
|
||
} else if item.get("type").is_some() {
|
||
saw_response_output = true;
|
||
}
|
||
}
|
||
pending_reasoning.append(&mut current_reasoning);
|
||
let fallback_id = assistant_groups
|
||
.get(group_index)
|
||
.and_then(|ids| ids.last())
|
||
.cloned();
|
||
for mut entry in pending_reasoning {
|
||
entry.message_id = fallback_id.clone();
|
||
entries.push(entry);
|
||
}
|
||
entries
|
||
}
|
||
|
||
fn design_event(
|
||
root: &Path,
|
||
turn_id: &str,
|
||
kind: &str,
|
||
id: Option<&str>,
|
||
text: Option<String>,
|
||
view: Option<DesignView>,
|
||
) -> DesignEvent {
|
||
DesignEvent {
|
||
project_path: root.to_string_lossy().into_owned(),
|
||
client_turn_id: turn_id.to_string(),
|
||
kind: kind.to_string(),
|
||
message_id: id.map(str::to_string),
|
||
text,
|
||
reasoning_text: None,
|
||
view,
|
||
}
|
||
}
|
||
|
||
fn design_reasoning_event(
|
||
root: &Path,
|
||
turn_id: &str,
|
||
id: Option<&str>,
|
||
reasoning: String,
|
||
) -> DesignEvent {
|
||
let mut event = design_event(root, turn_id, "reasoning", id, None, None);
|
||
event.reasoning_text = Some(reasoning);
|
||
event
|
||
}
|
||
|
||
fn design_project_id(root: &Path) -> Result<String, String> {
|
||
validate_project_root(root)?;
|
||
Ok(read_existing_manifest_for_project(root)?.project_id)
|
||
}
|
||
|
||
fn design_command_replayed(
|
||
session: &DesignSession,
|
||
id: &str,
|
||
input: &Value,
|
||
) -> Result<bool, String> {
|
||
if id.trim().is_empty() || id.len() > 160 || id.chars().any(char::is_control) {
|
||
return Err("回合身份不能为空或包含控制字符,且最多 160 字节".to_string());
|
||
}
|
||
if let Some(previous) = session.commands.get(id) {
|
||
if previous != input {
|
||
return Err("回合身份已用于另一条请求".to_string());
|
||
}
|
||
return Ok(true);
|
||
}
|
||
Ok(false)
|
||
}
|
||
|
||
fn append_design_user(session: &mut DesignSession, id: &str, text: String) {
|
||
session.history.push(json!({"role":"user", "content":text}));
|
||
session.messages.push(DesignMessage {
|
||
id: format!("{id}:user"),
|
||
role: "user".into(),
|
||
text,
|
||
});
|
||
}
|
||
|
||
fn begin_design_turn(session: &mut DesignSession, id: &str) {
|
||
session.turn_index += 1;
|
||
session.turn = Some(DesignTurn {
|
||
id: id.to_string(),
|
||
pending: true,
|
||
request_index: 0,
|
||
attempt: 0,
|
||
});
|
||
session.last_error = None;
|
||
session.updated_at = unix_timestamp();
|
||
}
|
||
|
||
fn prepare_design_input(
|
||
session: &mut DesignSession,
|
||
id: &str,
|
||
input: DesignInput,
|
||
) -> Result<bool, String> {
|
||
let command = serde_json::to_value(&input).map_err(|e| e.to_string())?;
|
||
if design_command_replayed(session, id, &command)? {
|
||
return Ok(false);
|
||
}
|
||
if session.pending_approval.is_some() {
|
||
return Err("请先处理当前阶段审批".into());
|
||
}
|
||
if matches!(input, DesignInput::Retry) {
|
||
if session.pending_clarification.is_some() {
|
||
return Err("请先回答当前澄清问题".into());
|
||
}
|
||
if !session.turn.as_ref().is_some_and(|turn| turn.pending) {
|
||
return Err("当前没有需要恢复的回合".into());
|
||
}
|
||
session.last_error = None;
|
||
begin_design_turn(session, id);
|
||
} else {
|
||
if session.turn.as_ref().is_some_and(|turn| turn.pending) {
|
||
return Err("上次回合尚未完成,请先恢复回合".into());
|
||
}
|
||
let text = match input {
|
||
DesignInput::Message { text } => {
|
||
if text.trim().is_empty() {
|
||
return Err("请输入消息".into());
|
||
}
|
||
if let Some(question) = session.pending_clarification.take() {
|
||
format!("对于问题“{}”,用户回答:{}", question.question, text)
|
||
} else {
|
||
text
|
||
}
|
||
}
|
||
DesignInput::Clarification {
|
||
request_id,
|
||
option_index,
|
||
text,
|
||
} => {
|
||
let question = session
|
||
.pending_clarification
|
||
.as_ref()
|
||
.filter(|q| q.request_id == request_id)
|
||
.ok_or("澄清请求已过期")?;
|
||
let mut answer = format!("对于问题“{}”,", question.question);
|
||
if let Some(index) = option_index {
|
||
let label = question.options.get(index).ok_or("所选选项不存在")?;
|
||
answer.push_str(&format!("用户选择第 {} 项:{}。", index + 1, label));
|
||
if let Some(text) = text.filter(|t| !t.trim().is_empty()) {
|
||
answer.push_str(&format!("用户补充:{}", text));
|
||
}
|
||
} else if let Some(text) = text.filter(|t| !t.trim().is_empty()) {
|
||
answer.push_str(&format!("用户回答:{}", text));
|
||
} else {
|
||
return Err("请选择选项或填写回答".into());
|
||
}
|
||
session.pending_clarification = None;
|
||
answer
|
||
}
|
||
DesignInput::Retry => unreachable!(),
|
||
};
|
||
append_design_user(session, id, text);
|
||
begin_design_turn(session, id);
|
||
}
|
||
session.commands.insert(id.into(), command);
|
||
Ok(true)
|
||
}
|
||
|
||
fn prepare_design_decision(
|
||
session: &mut DesignSession,
|
||
id: &str,
|
||
request_id: &str,
|
||
approved: bool,
|
||
) -> Result<bool, String> {
|
||
let command = json!({"type":"approval", "requestId":request_id,"approved":approved});
|
||
if design_command_replayed(session, id, &command)? {
|
||
return Ok(false);
|
||
}
|
||
if approved {
|
||
let phase = approve_design_phase(session, request_id)?;
|
||
append_design_user(
|
||
session,
|
||
id,
|
||
if phase == "consultant" {
|
||
format!(
|
||
"用户已批准上一阶段,现在进入 {phase} 阶段。开始本轮工作前,先调用 get_workflow_status 确认 Runtime 当前阶段;在工具返回前,不要开始顾问工作或断言阶段状态。"
|
||
)
|
||
} else {
|
||
format!(
|
||
"用户已批准上一阶段,现在进入 {phase} 阶段。开始本轮工作前,先调用 get_workflow_status 确认 Runtime 当前阶段;在工具返回前,不要开始新阶段工作、写入文件、提交审批或断言阶段状态。"
|
||
)
|
||
},
|
||
);
|
||
begin_design_turn(session, id);
|
||
} else {
|
||
reject_design_phase(session, request_id)?;
|
||
}
|
||
session.commands.insert(id.into(), command);
|
||
Ok(approved)
|
||
}
|
||
|
||
fn checkpoint_design(root: &Path, session: &DesignSession) -> Result<(), String> {
|
||
let _write =
|
||
acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "design.session")?;
|
||
write_design_session(root, session)
|
||
}
|
||
|
||
fn design_workflow_status(session: &DesignSession) -> Value {
|
||
json!({"phases":DESIGN_PHASES, "current_phase":session.current_phase,
|
||
"approved_phases":session.approved_phases,
|
||
"pending_approval": session.pending_approval.as_ref().map(|request| &request.phase)})
|
||
}
|
||
|
||
fn execute_design_tool(
|
||
root: &Path,
|
||
resources: &DesignResources,
|
||
session: &mut DesignSession,
|
||
call: &platform_llm::LlmToolCall,
|
||
) -> Result<Value, String> {
|
||
let args: Value = serde_json::from_str(&call.arguments)
|
||
.map_err(|error| format!("工具参数不是有效 JSON:{error}"))?;
|
||
match call.name.as_str() {
|
||
"get_workflow_status" => Ok(design_workflow_status(session)),
|
||
"list_resources" => resources.list().map(Value::String),
|
||
"read_resource" => resources
|
||
.read(
|
||
args.get("resource_id")
|
||
.and_then(Value::as_str)
|
||
.ok_or("缺少 resource_id")?,
|
||
)
|
||
.map(Value::String),
|
||
"submit_phase_for_approval" => {
|
||
let request = submit_design_phase_for_approval(root, session)?;
|
||
Ok(json!({"status":"waiting_for_approval", "phase":request.phase}))
|
||
}
|
||
"ask_clarification" => {
|
||
let question = args
|
||
.get("question")
|
||
.and_then(Value::as_str)
|
||
.filter(|q| !q.trim().is_empty())
|
||
.ok_or("缺少 question")?;
|
||
let options = match args.get("options") {
|
||
None => Vec::new(),
|
||
Some(value) => serde_json::from_value::<Vec<String>>(value.clone())
|
||
.map_err(|_| "options 必须是文本列表")?,
|
||
};
|
||
session.pending_clarification = Some(DesignClarificationRequest {
|
||
request_id: Uuid::new_v4().to_string(),
|
||
question: question.into(),
|
||
options,
|
||
created_at: unix_timestamp(),
|
||
});
|
||
Ok(json!({"status":"waiting_for_user", "question":question}))
|
||
}
|
||
_ => execute_design_file_tool(root, &call.name, &args),
|
||
}
|
||
}
|
||
|
||
fn design_tool_line(call: &platform_llm::LlmToolCall, error: Option<&str>) -> String {
|
||
let label = match call.name.as_str() {
|
||
"list_dir" => "列出目录",
|
||
"read_file" => "读取文件",
|
||
"write_file" => "写入文件",
|
||
"patch_file" => "局部修改",
|
||
"delete_path" => "删除",
|
||
"search_text" => "搜索文本",
|
||
"list_resources" => "列出资源目录",
|
||
"read_resource" => "读取资源",
|
||
"ask_clarification" => "等待你的回答",
|
||
"submit_phase_for_approval" => "等待阶段审批",
|
||
"get_workflow_status" => "查询工作阶段",
|
||
_ => &call.name,
|
||
};
|
||
let args: Value = serde_json::from_str(&call.arguments).unwrap_or(Value::Null);
|
||
let path = args
|
||
.get("path")
|
||
.or_else(|| args.get("resource_id"))
|
||
.and_then(Value::as_str);
|
||
let line = match (path, error) {
|
||
(_, Some(error)) => format!("{label}失败:{error}"),
|
||
(Some(path), None) => format!("{label}:{path}"),
|
||
_ => label.to_string(),
|
||
};
|
||
line.replace(['\r', '\n'], " ").chars().take(220).collect()
|
||
}
|
||
|
||
fn record_design_tool_result(
|
||
session: &mut DesignSession,
|
||
call: &platform_llm::LlmToolCall,
|
||
result: Value,
|
||
line: String,
|
||
) {
|
||
let output = match result {
|
||
Value::String(text) => text,
|
||
value => value.to_string(),
|
||
};
|
||
session
|
||
.history
|
||
.push(json!({"type":"function_call_output", "call_id":call.id,"output":output}));
|
||
session.messages.push(DesignMessage {
|
||
id: format!("{}:tool", call.id),
|
||
role: "tool".into(),
|
||
text: line,
|
||
});
|
||
}
|
||
|
||
fn process_design_batch(
|
||
root: &Path,
|
||
resources: &DesignResources,
|
||
session: &mut DesignSession,
|
||
emit: &mut (impl FnMut(DesignEvent) + Send),
|
||
) -> Result<(), String> {
|
||
while let Some(batch) = &session.pending_batch {
|
||
if batch.cursor == batch.calls.len() {
|
||
session.pending_batch = None;
|
||
checkpoint_design(root, session)?;
|
||
break;
|
||
}
|
||
let call = batch.calls[batch.cursor].clone();
|
||
let uncertain = batch.executing;
|
||
if !uncertain {
|
||
session.pending_batch.as_mut().unwrap().executing = true;
|
||
checkpoint_design(root, session)?;
|
||
}
|
||
let result = if uncertain {
|
||
Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string())
|
||
} else {
|
||
execute_design_tool(root, resources, session, &call)
|
||
};
|
||
let error = result
|
||
.as_ref()
|
||
.err()
|
||
.map(|e| redact_agent_runtime_error(root, e, 1800));
|
||
let line = design_tool_line(&call, error.as_deref());
|
||
let result = match result {
|
||
Ok(value) => value,
|
||
Err(_) => json!({"error":error}),
|
||
};
|
||
record_design_tool_result(session, &call, result, line.clone());
|
||
let waiting = session.pending_approval.is_some() || session.pending_clarification.is_some();
|
||
let batch = session.pending_batch.as_mut().unwrap();
|
||
batch.cursor += 1;
|
||
batch.executing = false;
|
||
if waiting || uncertain {
|
||
let remaining = batch.calls[batch.cursor..].to_vec();
|
||
for skipped in remaining {
|
||
let reason = if waiting {
|
||
"正在等待用户,本次调用未执行"
|
||
} else {
|
||
"前一调用结果不确定,本次调用未执行"
|
||
};
|
||
record_design_tool_result(
|
||
session,
|
||
&skipped,
|
||
json!({"status":"not_executed","reason":reason}),
|
||
format!("未执行 {}:{reason}", skipped.name),
|
||
);
|
||
}
|
||
session.pending_batch = None;
|
||
if waiting {
|
||
session.turn.as_mut().unwrap().pending = false;
|
||
}
|
||
}
|
||
session.updated_at = unix_timestamp();
|
||
checkpoint_design(root, session)?;
|
||
let id = &session.turn.as_ref().unwrap().id;
|
||
emit(design_event(
|
||
root,
|
||
id,
|
||
"tool",
|
||
Some(&format!("{}:tool", call.id)),
|
||
Some(line),
|
||
None,
|
||
));
|
||
emit(design_event(
|
||
root,
|
||
id,
|
||
"state",
|
||
None,
|
||
None,
|
||
Some(design_view(session, !waiting)),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn build_design_request(
|
||
session: &DesignSession,
|
||
resources: &DesignResources,
|
||
llm: &GameCreatorLlmConfig,
|
||
) -> Result<LlmRunRequest, String> {
|
||
let messages = vec![
|
||
platform_llm::LlmMessage::system(resources.system_prompt()),
|
||
platform_llm::LlmMessage::system(resources.phase_context(session)),
|
||
];
|
||
let mut input = messages
|
||
.iter()
|
||
.map(|m| json!({"role":"system","content":m.content}))
|
||
.collect::<Vec<_>>();
|
||
input.extend(session.history.clone());
|
||
let request = LlmRunRequest::new(messages)
|
||
.with_openai_responses()
|
||
.with_responses_input(input)
|
||
.with_model(llm.model.clone())
|
||
.with_request_timeout_ms(llm.request_timeout_ms)
|
||
.with_function_tools(resources.function_tools())
|
||
.with_tool_choice(platform_llm::LlmToolChoice::Auto)
|
||
.with_web_search(false);
|
||
apply_game_creator_llm_reasoning_effort(request, llm)
|
||
.map(|request| request.with_reasoning_capture(true))
|
||
}
|
||
|
||
// 调试队列只接收副本,写盘慢或失败时丢弃,不参与会话恢复。
|
||
fn design_debug(root: &Path, kind: &str, data: Value) {
|
||
if !design_debug_enabled() {
|
||
return;
|
||
}
|
||
type Entry = (PathBuf, Value);
|
||
static QUEUE: OnceLock<std::sync::mpsc::SyncSender<Entry>> = OnceLock::new();
|
||
let sender = QUEUE.get_or_init(|| {
|
||
let (sender, receiver) = std::sync::mpsc::sync_channel::<Entry>(256);
|
||
let _ = std::thread::Builder::new()
|
||
.name("design-debug".into())
|
||
.spawn(move || {
|
||
for (path, data) in receiver {
|
||
if let Ok(bytes) = serde_json::to_vec(&data) {
|
||
let _ = write_game_creator_private_file(&path, &bytes, "策划调试资料");
|
||
}
|
||
}
|
||
});
|
||
sender
|
||
});
|
||
let path = root
|
||
.join(".debug/design-agent")
|
||
.join(format!("{}-{kind}.json", Uuid::new_v4()));
|
||
let _ = sender.try_send((path, data));
|
||
}
|
||
|
||
async fn request_design_provider(
|
||
root: &Path,
|
||
session: &mut DesignSession,
|
||
resources: &DesignResources,
|
||
emit: &mut (impl FnMut(DesignEvent) + Send),
|
||
) -> Result<platform_llm::LlmRunResponse, String> {
|
||
#[cfg(test)]
|
||
if fake_provider::is_active() {
|
||
return request_scripted_design_provider(root, session, emit).await;
|
||
}
|
||
let config = load_game_creator_app_config()?;
|
||
let mut llm = resolve_game_creator_llm_config_for_agent(&config, "design-agent");
|
||
if !session.model_id.trim().is_empty() {
|
||
llm.model = session.model_id.clone();
|
||
}
|
||
// 此循环统一处理流中断与 HTTP 瞬态错误,避免与传输重试相乘。
|
||
let max_retries = llm.max_retries;
|
||
llm.max_retries = 0;
|
||
llm.api_kind = "openai_responses".into();
|
||
llm.web_search_enabled = false;
|
||
let client = build_game_creator_llm_client_from_llm_config(&llm, "design-agent")?;
|
||
let request = build_design_request(session, resources, &llm)?;
|
||
let turn = session.turn.as_ref().unwrap();
|
||
let turn_id = turn.id.clone();
|
||
let message_id = format!("{}:response:{}", turn.id, turn.request_index);
|
||
for attempt in 0..=max_retries {
|
||
session.turn.as_mut().unwrap().attempt = attempt;
|
||
checkpoint_design(root, session)?;
|
||
design_debug(
|
||
root,
|
||
"request",
|
||
json!({"turnId":turn_id,"requestIndex":session.turn.as_ref().unwrap().request_index,"attempt":attempt,"input":session.history,"model":llm.model}),
|
||
);
|
||
emit(design_event(
|
||
root,
|
||
&turn_id,
|
||
"tool",
|
||
None,
|
||
Some(if attempt == 0 {
|
||
"正在请求 Provider…".into()
|
||
} else {
|
||
format!("Provider 重试 {attempt}/{max_retries}…")
|
||
}),
|
||
None,
|
||
));
|
||
// 相同响应槽重试会替换临时文本,已保存的上一条消息不受影响。
|
||
emit(design_event(
|
||
root,
|
||
&turn_id,
|
||
"text",
|
||
Some(&message_id),
|
||
Some(String::new()),
|
||
None,
|
||
));
|
||
emit(design_reasoning_event(
|
||
root,
|
||
&turn_id,
|
||
Some(&message_id),
|
||
String::new(),
|
||
));
|
||
let result = if llm.stream {
|
||
let mut stream_sequence = 0_u64;
|
||
let mut emitted_reasoning = String::new();
|
||
client
|
||
.stream_run(request.clone(), |delta| {
|
||
stream_sequence = stream_sequence.saturating_add(1);
|
||
design_debug(
|
||
root,
|
||
"stream",
|
||
json!({
|
||
"turnId": turn_id,
|
||
"requestIndex": session.turn.as_ref().map(|turn| turn.request_index),
|
||
"attempt": attempt,
|
||
"sequence": stream_sequence,
|
||
"occurredAtUnixNanos": unix_timestamp_nanos().to_string(),
|
||
"model": llm.model,
|
||
"deltaChars": delta.delta_text.chars().count(),
|
||
"accumulatedChars": delta.accumulated_text.chars().count(),
|
||
"reasoningDeltaChars": delta.reasoning_delta.chars().count(),
|
||
"reasoningAccumulatedChars": delta.accumulated_reasoning.chars().count(),
|
||
"deltaText": delta.delta_text,
|
||
"finishReason": delta.finish_reason,
|
||
}),
|
||
);
|
||
if !delta.delta_text.is_empty() || delta.finish_reason.is_some() {
|
||
emit(design_event(
|
||
root,
|
||
&turn_id,
|
||
"text",
|
||
Some(&message_id),
|
||
Some(delta.accumulated_text.clone()),
|
||
None,
|
||
));
|
||
}
|
||
if !delta.reasoning_delta.is_empty()
|
||
|| delta.accumulated_reasoning != emitted_reasoning
|
||
{
|
||
emit(design_reasoning_event(
|
||
root,
|
||
&turn_id,
|
||
Some(&message_id),
|
||
delta.accumulated_reasoning.clone(),
|
||
));
|
||
emitted_reasoning = delta.accumulated_reasoning.clone();
|
||
}
|
||
})
|
||
.await
|
||
} else {
|
||
client.run(request.clone()).await
|
||
};
|
||
match result {
|
||
Ok(response) => {
|
||
if !response.reasoning.is_empty() {
|
||
emit(design_reasoning_event(
|
||
root,
|
||
&turn_id,
|
||
Some(&message_id),
|
||
response.reasoning.clone(),
|
||
));
|
||
}
|
||
design_debug(
|
||
root,
|
||
"response",
|
||
json!({"turnId":turn_id,"responseId":response.response_id,"output":response.responses_output,"text":response.text}),
|
||
);
|
||
return Ok(response);
|
||
}
|
||
Err(error) => {
|
||
let detail = redact_agent_runtime_error(
|
||
root,
|
||
&game_creator_agent_llm_error_public_summary(&error),
|
||
1800,
|
||
);
|
||
design_debug(
|
||
root,
|
||
"error",
|
||
json!({"turnId":turn_id,"attempt":attempt,"error":detail}),
|
||
);
|
||
if attempt == max_retries
|
||
|| game_creator_agent_runtime_transient_provider_error_kind(&error, false)
|
||
.is_none()
|
||
{
|
||
emit(design_reasoning_event(
|
||
root,
|
||
&turn_id,
|
||
Some(&message_id),
|
||
String::new(),
|
||
));
|
||
return Err(detail);
|
||
}
|
||
tokio::time::sleep(Duration::from_millis(
|
||
game_creator_agent_runtime_transient_retry_backoff_ms(
|
||
llm.retry_backoff_ms,
|
||
attempt + 1,
|
||
),
|
||
))
|
||
.await;
|
||
}
|
||
}
|
||
}
|
||
unreachable!()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
async fn request_scripted_design_provider(
|
||
root: &Path,
|
||
session: &mut DesignSession,
|
||
emit: &mut (impl FnMut(DesignEvent) + Send),
|
||
) -> Result<platform_llm::LlmRunResponse, String> {
|
||
let max_retries = fake_provider::max_retries();
|
||
let turn = session.turn.as_ref().unwrap();
|
||
let turn_id = turn.id.clone();
|
||
let message_id = format!("{}:response:{}", turn.id, turn.request_index);
|
||
for attempt in 0..=max_retries {
|
||
session.turn.as_mut().unwrap().attempt = attempt;
|
||
checkpoint_design(root, session)?;
|
||
emit(design_event(
|
||
root,
|
||
&turn_id,
|
||
"tool",
|
||
None,
|
||
Some(if attempt == 0 {
|
||
"正在请求 Provider…".into()
|
||
} else {
|
||
format!("Provider 重试 {attempt}/{max_retries}…")
|
||
}),
|
||
None,
|
||
));
|
||
emit(design_event(
|
||
root,
|
||
&turn_id,
|
||
"text",
|
||
Some(&message_id),
|
||
Some(String::new()),
|
||
None,
|
||
));
|
||
emit(design_reasoning_event(
|
||
root,
|
||
&turn_id,
|
||
Some(&message_id),
|
||
String::new(),
|
||
));
|
||
match fake_provider::take() {
|
||
Some(Ok(response)) => {
|
||
if !response.reasoning.is_empty() {
|
||
emit(design_reasoning_event(
|
||
root,
|
||
&turn_id,
|
||
Some(&message_id),
|
||
response.reasoning.clone(),
|
||
));
|
||
}
|
||
return Ok(response);
|
||
}
|
||
Some(Err(error)) => {
|
||
let detail = redact_agent_runtime_error(
|
||
root,
|
||
&game_creator_agent_llm_error_public_summary(&error),
|
||
1800,
|
||
);
|
||
if attempt == max_retries
|
||
|| game_creator_agent_runtime_transient_provider_error_kind(&error, false)
|
||
.is_none()
|
||
{
|
||
emit(design_reasoning_event(
|
||
root,
|
||
&turn_id,
|
||
Some(&message_id),
|
||
String::new(),
|
||
));
|
||
return Err(detail);
|
||
}
|
||
}
|
||
None => {
|
||
emit(design_reasoning_event(
|
||
root,
|
||
&turn_id,
|
||
Some(&message_id),
|
||
String::new(),
|
||
));
|
||
return Err("假 Provider 脚本耗尽".into());
|
||
}
|
||
}
|
||
}
|
||
unreachable!()
|
||
}
|
||
|
||
fn accept_design_response(
|
||
session: &mut DesignSession,
|
||
response: platform_llm::LlmRunResponse,
|
||
) -> Result<(), String> {
|
||
let turn = session.turn.as_mut().ok_or("缺少当前回合")?;
|
||
if !response.text.is_empty() {
|
||
session.messages.push(DesignMessage {
|
||
id: format!("{}:response:{}", turn.id, turn.request_index),
|
||
role: "assistant".into(),
|
||
text: response.text.clone(),
|
||
});
|
||
}
|
||
if response.responses_output.is_empty() {
|
||
if !response.tool_calls.is_empty() {
|
||
return Err("Provider 返回工具调用但未提供完整 Responses output".into());
|
||
}
|
||
session
|
||
.history
|
||
.push(json!({"role":"assistant","content":response.text}));
|
||
} else {
|
||
session.history.extend(response.responses_output);
|
||
}
|
||
turn.request_index += 1;
|
||
turn.attempt = 0;
|
||
turn.pending = !response.tool_calls.is_empty();
|
||
if !response.tool_calls.is_empty() {
|
||
session.pending_batch = Some(DesignToolBatch {
|
||
calls: response.tool_calls,
|
||
cursor: 0,
|
||
executing: false,
|
||
});
|
||
}
|
||
session.updated_at = unix_timestamp();
|
||
Ok(())
|
||
}
|
||
|
||
async fn run_design_loop(
|
||
root: &Path,
|
||
resources: &DesignResources,
|
||
session: &mut DesignSession,
|
||
emit: &mut (impl FnMut(DesignEvent) + Send),
|
||
) -> Result<(), String> {
|
||
while session.turn.as_ref().is_some_and(|turn| turn.pending) {
|
||
process_design_batch(root, resources, session, emit)?;
|
||
if session.pending_approval.is_some() || session.pending_clarification.is_some() {
|
||
break;
|
||
}
|
||
let response = request_design_provider(root, session, resources, emit).await?;
|
||
accept_design_response(session, response)?;
|
||
checkpoint_design(root, session)?;
|
||
let turn = session.turn.as_ref().unwrap();
|
||
emit(design_event(
|
||
root,
|
||
&turn.id,
|
||
"state",
|
||
None,
|
||
None,
|
||
Some(design_view(session, turn.pending)),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
async fn finish_design_command(
|
||
root: &Path,
|
||
resources: &DesignResources,
|
||
mut session: DesignSession,
|
||
active: File,
|
||
run: bool,
|
||
mut emit: impl FnMut(DesignEvent) + Send,
|
||
) -> Result<DesignView, String> {
|
||
checkpoint_design(root, &session)?;
|
||
let turn_id = session
|
||
.turn
|
||
.as_ref()
|
||
.map(|turn| turn.id.clone())
|
||
.unwrap_or_default();
|
||
emit(design_event(
|
||
root,
|
||
&turn_id,
|
||
"state",
|
||
None,
|
||
None,
|
||
Some(design_view(&session, run)),
|
||
));
|
||
if run {
|
||
if let Err(error) = run_design_loop(root, resources, &mut session, &mut emit).await {
|
||
// 从最后一个持久检查点恢复,防止写后未记结果被误认为已完成。
|
||
session = read_design_session(root)?.ok_or("策划会话丢失")?;
|
||
session.last_error = Some(redact_agent_runtime_error(root, &error, 1800));
|
||
checkpoint_design(root, &session)?;
|
||
}
|
||
}
|
||
let view = design_view(&session, false);
|
||
drop(active);
|
||
emit(design_event(
|
||
root,
|
||
&turn_id,
|
||
"state",
|
||
None,
|
||
None,
|
||
Some(view.clone()),
|
||
));
|
||
Ok(view)
|
||
}
|
||
|
||
pub(crate) async fn continue_design_agent_at(
|
||
root: &Path,
|
||
resources: &DesignResources,
|
||
id: &str,
|
||
input: DesignInput,
|
||
emit: impl FnMut(DesignEvent) + Send,
|
||
) -> Result<DesignView, String> {
|
||
ensure_design_runtime_active(root)?;
|
||
let project_id = design_project_id(root)?;
|
||
ensure_design_workspace(root)?;
|
||
let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?
|
||
.ok_or("策划 Agent 当前正在工作")?;
|
||
let mut session = match read_design_session(root)? {
|
||
Some(session) => session,
|
||
None => {
|
||
if read_planning_session_v2(root)?.is_some() {
|
||
return Err("此项目包含旧策划会话,请查看原有记录或在新项目开始五阶段策划".into());
|
||
}
|
||
new_design_session(
|
||
&project_id,
|
||
&load_game_creator_app_config()?.selected_model_id,
|
||
)
|
||
}
|
||
};
|
||
if session.project_id != project_id {
|
||
return Err("策划会话与当前项目不匹配".into());
|
||
}
|
||
let run = prepare_design_input(&mut session, id, input)?;
|
||
finish_design_command(root, resources, session, active, run, emit).await
|
||
}
|
||
|
||
async fn recover_uncertain_design_batch(
|
||
root: &Path,
|
||
resources: &DesignResources,
|
||
session: DesignSession,
|
||
active: File,
|
||
) -> Result<DesignView, String> {
|
||
finish_design_command(root, resources, session, active, true, |_| {}).await
|
||
}
|
||
|
||
pub(crate) async fn decide_design_phase_at(
|
||
root: &Path,
|
||
resources: &DesignResources,
|
||
id: &str,
|
||
request_id: &str,
|
||
approved: bool,
|
||
emit: impl FnMut(DesignEvent) + Send,
|
||
) -> Result<DesignView, String> {
|
||
ensure_design_runtime_active(root)?;
|
||
let project_id = design_project_id(root)?;
|
||
ensure_design_workspace(root)?;
|
||
let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?
|
||
.ok_or("策划 Agent 当前正在工作")?;
|
||
let mut session = read_design_session(root)?.ok_or("策划会话不存在")?;
|
||
if session.project_id != project_id {
|
||
return Err("策划会话与当前项目不匹配".into());
|
||
}
|
||
let run = prepare_design_decision(&mut session, id, request_id, approved)?;
|
||
finish_design_command(root, resources, session, active, run, emit).await
|
||
}
|
||
|
||
fn ensure_design_runtime_active(root: &Path) -> Result<(), String> {
|
||
if read_design_runtime_mode(root)?
|
||
.as_ref()
|
||
.is_some_and(|mode| mode.active_runtime == "game")
|
||
{
|
||
return Err("当前项目已切换到游戏运行态,不能继续策划 Agent".into());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn hydrate_design_agent_session(
|
||
app: tauri::AppHandle,
|
||
project_path: String,
|
||
) -> Result<Option<DesignView>, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
if read_design_runtime_mode(root)?
|
||
.as_ref()
|
||
.is_some_and(|mode| mode.active_runtime == "game")
|
||
{
|
||
return Ok(None);
|
||
}
|
||
let project_id = design_project_id(root)?;
|
||
let session = match read_design_session(root) {
|
||
Ok(session) => session,
|
||
Err(error) if design_session_error_is_recoverable(&error) => {
|
||
quarantine_corrupted_design_session(root)?;
|
||
return Ok(None);
|
||
}
|
||
Err(error) => return Err(error),
|
||
};
|
||
let Some(session) = session else {
|
||
return Ok(None);
|
||
};
|
||
if session.project_id != project_id {
|
||
return Err("策划会话与当前项目不匹配".into());
|
||
}
|
||
let Some(active) =
|
||
try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?
|
||
else {
|
||
return Ok(Some(design_view(&session, true)));
|
||
};
|
||
if design_session_has_uncertain_batch(&session) {
|
||
let resources = DesignResources::new(resolve_design_resources_root(&app)?)?;
|
||
let view = recover_uncertain_design_batch(root, &resources, session, active).await?;
|
||
return Ok(Some(view));
|
||
}
|
||
drop(active);
|
||
Ok(Some(design_view(&session, false)))
|
||
}
|
||
|
||
fn design_session_has_uncertain_batch(session: &DesignSession) -> bool {
|
||
let Some(batch) = session.pending_batch.as_ref() else {
|
||
return false;
|
||
};
|
||
if !batch.executing || batch.cursor >= batch.calls.len() {
|
||
return false;
|
||
}
|
||
let call_id = batch.calls[batch.cursor].id.as_str();
|
||
session.turn.as_ref().is_some_and(|turn| turn.pending)
|
||
&& !session.history.iter().any(|item| {
|
||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||
&& item.get("call_id").and_then(Value::as_str) == Some(call_id)
|
||
})
|
||
}
|
||
|
||
fn design_session_error_is_recoverable(error: &str) -> bool {
|
||
error.contains("策划 Agent 会话")
|
||
&& (error.contains("解析")
|
||
|| error.contains("字段无效")
|
||
|| error.contains("超过")
|
||
|| error.contains("JSON"))
|
||
}
|
||
|
||
fn quarantine_corrupted_design_session(root: &Path) -> Result<(), String> {
|
||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||
root,
|
||
"design.session-quarantine",
|
||
)?;
|
||
let path = crate::agent::runtime_protocol::design_session_path(root);
|
||
if !path.exists() {
|
||
return Ok(());
|
||
}
|
||
let backup = path.with_file_name(format!("session.corrupted.{}.json", unix_timestamp()));
|
||
std::fs::rename(&path, &backup).map_err(|error| format!("隔离损坏的策划会话失败:{error}"))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn reset_design_agent_session(project_path: String) -> Result<(), String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
design_project_id(root)?;
|
||
let path = crate::agent::runtime_protocol::design_session_path(root);
|
||
if path.exists() {
|
||
quarantine_corrupted_design_session(root)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn get_design_agent_runtime_mode(
|
||
project_path: String,
|
||
) -> Result<Option<DesignRuntimeMode>, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.read")?;
|
||
design_project_id(root)?;
|
||
resolve_design_runtime_mode(root)
|
||
}
|
||
|
||
fn resolve_design_runtime_mode(root: &Path) -> Result<Option<DesignRuntimeMode>, String> {
|
||
if let Some(mode) = read_design_runtime_mode(root)? {
|
||
return Ok(Some(mode));
|
||
}
|
||
// 已有策划会话可能早于运行模式文件;明确的 game 标记始终优先。
|
||
Ok(read_design_session(root)?.map(|_| DesignRuntimeMode {
|
||
active_runtime: "design".into(),
|
||
}))
|
||
}
|
||
|
||
fn design_debug_enabled() -> bool {
|
||
std::env::var("GENARRATIVE_AGC_DESIGN_DEBUG")
|
||
.ok()
|
||
.as_deref()
|
||
== Some("1")
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn is_design_agent_debug_enabled() -> bool {
|
||
cfg!(debug_assertions) && design_debug_enabled()
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn set_design_agent_runtime_mode(
|
||
project_path: String,
|
||
active_runtime: String,
|
||
) -> Result<DesignRuntimeMode, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "conversation.write")?;
|
||
design_project_id(root)?;
|
||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||
root,
|
||
"design.runtime-mode",
|
||
)?;
|
||
if active_runtime.trim() == "game" {
|
||
if crate::assets::register_design_artifacts_at(root)? {
|
||
advance_agent_runtime_project_revision_locked(root)?;
|
||
}
|
||
}
|
||
write_design_runtime_mode(root, active_runtime.trim())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn debug_fast_forward_design_session(
|
||
app: tauri::AppHandle,
|
||
project_path: String,
|
||
target_phase: String,
|
||
) -> Result<DesignRuntimeMode, String> {
|
||
if !is_design_agent_debug_enabled() {
|
||
return Err("策划 Agent 快速推进仅可用于 Debug 构建".to_string());
|
||
}
|
||
let root = Path::new(project_path.trim());
|
||
let project_id = design_project_id(root)?;
|
||
let target_index = design_phase_index(target_phase.trim())?;
|
||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||
root,
|
||
"design.debug-fast-forward",
|
||
)?;
|
||
let artifact_paths = [
|
||
"project/00_concept/design.md",
|
||
"project/速览卡.md",
|
||
"project/01_top_design/design.md",
|
||
"project/02_architecture/design.md",
|
||
"project/03_systems/debug-system.md",
|
||
"project/04_tdd/01_技术实现.md",
|
||
"project/04_tdd/02_美术圣经.md",
|
||
"project/04_tdd/03_数据与配表.md",
|
||
"project/04_tdd/总册.md",
|
||
];
|
||
for relative in artifact_paths {
|
||
let path = root.join("design_artifacts").join(relative);
|
||
if let Some(parent) = path.parent() {
|
||
std::fs::create_dir_all(parent).map_err(|error| {
|
||
format!("创建调试策划产物目录失败:{}: {error}", parent.display())
|
||
})?;
|
||
}
|
||
if !path.exists() {
|
||
std::fs::write(
|
||
&path,
|
||
"# Debug Design Artifact\n\n这是生产 Runtime 快速推进测试生成的占位产物,不代表真实策划内容。\n",
|
||
)
|
||
.map_err(|error| format!("写入调试策划产物失败:{}: {error}", path.display()))?;
|
||
}
|
||
}
|
||
let mut session =
|
||
read_design_session(root)?.unwrap_or_else(|| new_design_session(&project_id, ""));
|
||
session.current_phase = target_phase.trim().to_string();
|
||
session.approved_phases = DESIGN_PHASES[..target_index]
|
||
.iter()
|
||
.map(|phase| phase.to_string())
|
||
.collect();
|
||
session.pending_approval = None;
|
||
session.pending_clarification = None;
|
||
session.turn = None;
|
||
session.pending_batch = None;
|
||
session.last_error = None;
|
||
session.updated_at = unix_timestamp();
|
||
write_design_session(root, &session)?;
|
||
let mode = write_design_runtime_mode(root, "design")?;
|
||
app.emit(
|
||
"design-agent-update",
|
||
design_event(
|
||
root,
|
||
"debug-fast-forward",
|
||
"state",
|
||
None,
|
||
None,
|
||
Some(design_view(&session, false)),
|
||
),
|
||
)
|
||
.map_err(|error| format!("刷新策划调试状态失败:{error}"))?;
|
||
Ok(mode)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn continue_design_agent_session(
|
||
app: tauri::AppHandle,
|
||
project_path: String,
|
||
client_turn_id: String,
|
||
input: DesignInput,
|
||
) -> Result<DesignView, String> {
|
||
let root = PathBuf::from(project_path.trim());
|
||
enforce_project_permission_policy(&root, "conversation.write")?;
|
||
let resources = DesignResources::new(resolve_design_resources_root(&app)?)?;
|
||
continue_design_agent_at(&root, &resources, &client_turn_id, input, |event| {
|
||
let _ = app.emit("design-agent-update", event);
|
||
})
|
||
.await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) async fn decide_design_phase(
|
||
app: tauri::AppHandle,
|
||
project_path: String,
|
||
client_turn_id: String,
|
||
request_id: String,
|
||
approved: bool,
|
||
) -> Result<DesignView, String> {
|
||
let root = PathBuf::from(project_path.trim());
|
||
enforce_project_permission_policy(&root, "conversation.write")?;
|
||
let resources = DesignResources::new(resolve_design_resources_root(&app)?)?;
|
||
decide_design_phase_at(
|
||
&root,
|
||
&resources,
|
||
&client_turn_id,
|
||
&request_id,
|
||
approved,
|
||
|event| {
|
||
let _ = app.emit("design-agent-update", event);
|
||
},
|
||
)
|
||
.await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn list_design_workspace(
|
||
project_path: String,
|
||
) -> Result<Vec<DesignWorkspaceEntry>, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "file.list")?;
|
||
design_project_id(root)?;
|
||
list_design_workspace_files(root)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub(crate) fn read_design_workspace_file(
|
||
project_path: String,
|
||
path: String,
|
||
) -> Result<String, String> {
|
||
let root = Path::new(project_path.trim());
|
||
enforce_project_permission_policy(root, "file.read")?;
|
||
design_project_id(root)?;
|
||
read_design_workspace_file_at(root, &path)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod fake_provider {
|
||
use super::*;
|
||
use std::cell::{Cell, RefCell};
|
||
use std::collections::VecDeque;
|
||
|
||
// 队列按测试线程隔离,配合 current_thread runtime,避免并行测试互相抢脚本。
|
||
thread_local! {
|
||
static ACTIVE: Cell<bool> = const { Cell::new(false) };
|
||
static MAX_RETRIES: Cell<u32> = const { Cell::new(0) };
|
||
static QUEUE: RefCell<VecDeque<Result<platform_llm::LlmRunResponse, platform_llm::LlmError>>> =
|
||
RefCell::new(VecDeque::new());
|
||
}
|
||
|
||
pub(super) struct Guard;
|
||
|
||
impl Drop for Guard {
|
||
fn drop(&mut self) {
|
||
ACTIVE.with(|flag| flag.set(false));
|
||
QUEUE.with(|queue| queue.borrow_mut().clear());
|
||
MAX_RETRIES.with(|value| value.set(0));
|
||
}
|
||
}
|
||
|
||
pub(super) fn install(
|
||
items: Vec<Result<platform_llm::LlmRunResponse, platform_llm::LlmError>>,
|
||
max_retries: u32,
|
||
) -> Guard {
|
||
ACTIVE.with(|flag| flag.set(true));
|
||
MAX_RETRIES.with(|value| value.set(max_retries));
|
||
QUEUE.with(|queue| {
|
||
*queue.borrow_mut() = items.into();
|
||
});
|
||
Guard
|
||
}
|
||
|
||
pub(super) fn is_active() -> bool {
|
||
ACTIVE.with(Cell::get)
|
||
}
|
||
|
||
pub(super) fn max_retries() -> u32 {
|
||
MAX_RETRIES.with(Cell::get)
|
||
}
|
||
|
||
pub(super) fn take() -> Option<Result<platform_llm::LlmRunResponse, platform_llm::LlmError>> {
|
||
QUEUE.with(|queue| queue.borrow_mut().pop_front())
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn runtime_mode_restore_prefers_explicit_mode_over_existing_design_session() {
|
||
let temporary = tempfile::tempdir().expect("tempdir");
|
||
let root = temporary.path();
|
||
assert_eq!(resolve_design_runtime_mode(root).unwrap(), None);
|
||
write_design_session(root, &new_design_session("project", "")).unwrap();
|
||
assert_eq!(
|
||
resolve_design_runtime_mode(root)
|
||
.unwrap()
|
||
.unwrap()
|
||
.active_runtime,
|
||
"design"
|
||
);
|
||
write_design_runtime_mode(root, "game").unwrap();
|
||
assert_eq!(
|
||
resolve_design_runtime_mode(root)
|
||
.unwrap()
|
||
.unwrap()
|
||
.active_runtime,
|
||
"game"
|
||
);
|
||
write_design_runtime_mode(root, "design").unwrap();
|
||
assert_eq!(
|
||
resolve_design_runtime_mode(root)
|
||
.unwrap()
|
||
.unwrap()
|
||
.active_runtime,
|
||
"design"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn design_runtime_rejects_design_execution_in_game_mode() {
|
||
let temporary = tempfile::tempdir().expect("create runtime mode root");
|
||
let root = temporary.path();
|
||
write_design_runtime_mode(root, "game").expect("write game runtime mode");
|
||
|
||
let error = ensure_design_runtime_active(root).expect_err("game mode must reject design");
|
||
assert!(error.contains("游戏运行态"));
|
||
}
|
||
|
||
#[test]
|
||
fn switching_to_game_pairs_design_artifact_registration_with_revision() {
|
||
let temporary = tempfile::tempdir().expect("create runtime mode root");
|
||
let root = temporary.path();
|
||
crate::project::init_local_game_project_at(root, "design-switch-test", "策划切换")
|
||
.expect("init project");
|
||
fs::create_dir_all(root.join("design_artifacts/project")).expect("create artifacts");
|
||
fs::write(root.join("design_artifacts/project/design.md"), "设计内容")
|
||
.expect("write artifact");
|
||
|
||
let before = read_game_creator_agent_runtime_project_revision(root)
|
||
.expect("read initial revision")
|
||
.revision;
|
||
assert_eq!(
|
||
set_design_agent_runtime_mode(root.to_string_lossy().into_owned(), "game".to_string(),)
|
||
.expect("switch to game")
|
||
.active_runtime,
|
||
"game"
|
||
);
|
||
let after = read_game_creator_agent_runtime_project_revision(root)
|
||
.expect("read committed revision")
|
||
.revision;
|
||
assert_eq!(after, before + 1);
|
||
|
||
set_design_agent_runtime_mode(root.to_string_lossy().into_owned(), "game".to_string())
|
||
.expect("repeat switch to game");
|
||
let repeated = read_game_creator_agent_runtime_project_revision(root)
|
||
.expect("read repeated revision")
|
||
.revision;
|
||
assert_eq!(repeated, after);
|
||
}
|
||
use serde_json::json;
|
||
use std::fs;
|
||
|
||
fn pack() -> DesignResources {
|
||
DesignResources::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent"))
|
||
.expect("design pack")
|
||
}
|
||
|
||
fn concept_artifacts(root: &Path) {
|
||
fs::create_dir_all(root.join("design_artifacts/project/00_concept")).expect("mkdir");
|
||
fs::write(
|
||
root.join("design_artifacts/project/00_concept/design.md"),
|
||
"概念",
|
||
)
|
||
.expect("write");
|
||
fs::write(root.join("design_artifacts/project/速览卡.md"), "速览").expect("write");
|
||
}
|
||
|
||
#[test]
|
||
fn approval_submission_skips_remaining_tools() {
|
||
let temp = tempfile::tempdir().expect("tempdir");
|
||
let root = temp.path();
|
||
concept_artifacts(root);
|
||
let mut session = new_design_session("project", "quality");
|
||
session.turn = Some(DesignTurn {
|
||
id: "turn-1".into(),
|
||
pending: true,
|
||
request_index: 0,
|
||
attempt: 0,
|
||
});
|
||
session.pending_batch = Some(DesignToolBatch {
|
||
calls: vec![
|
||
platform_llm::LlmToolCall {
|
||
id: "call-1".into(),
|
||
name: "submit_phase_for_approval".into(),
|
||
arguments: "{}".into(),
|
||
},
|
||
platform_llm::LlmToolCall {
|
||
id: "call-2".into(),
|
||
name: "list_dir".into(),
|
||
arguments: json!({"path":"."}).to_string(),
|
||
},
|
||
],
|
||
cursor: 0,
|
||
executing: false,
|
||
});
|
||
process_design_batch(root, &pack(), &mut session, &mut |_| {}).expect("batch");
|
||
assert_eq!(
|
||
session
|
||
.pending_approval
|
||
.as_ref()
|
||
.map(|request| request.phase.as_str()),
|
||
Some("concept")
|
||
);
|
||
assert!(session.pending_batch.is_none());
|
||
assert!(!session.turn.as_ref().unwrap().pending);
|
||
let skipped = session
|
||
.history
|
||
.iter()
|
||
.find(|item| item.get("call_id").and_then(Value::as_str) == Some("call-2"))
|
||
.expect("skipped tool");
|
||
assert!(skipped
|
||
.get("output")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or_default()
|
||
.contains("not_executed"));
|
||
}
|
||
|
||
#[test]
|
||
fn text_cannot_approve_without_event() {
|
||
let mut session = new_design_session("project", "quality");
|
||
let err = prepare_design_input(
|
||
&mut session,
|
||
"turn-1",
|
||
DesignInput::Message {
|
||
text: "批准,进入下一阶段".into(),
|
||
},
|
||
)
|
||
.expect("message accepted");
|
||
assert!(err);
|
||
assert_eq!(session.current_phase, "concept");
|
||
assert!(session.pending_approval.is_none());
|
||
}
|
||
|
||
fn fake_response(
|
||
id: &str,
|
||
text: &str,
|
||
calls: Vec<platform_llm::LlmToolCall>,
|
||
) -> platform_llm::LlmRunResponse {
|
||
let mut output = Vec::new();
|
||
if !text.is_empty() {
|
||
output.push(json!({
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"content": [{"type": "output_text", "text": text}]
|
||
}));
|
||
}
|
||
for call in &calls {
|
||
output.push(json!({
|
||
"type": "function_call",
|
||
"call_id": call.id,
|
||
"name": call.name,
|
||
"arguments": call.arguments
|
||
}));
|
||
}
|
||
platform_llm::LlmRunResponse {
|
||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||
model: "fake-design".into(),
|
||
text: text.into(),
|
||
reasoning: String::new(),
|
||
finish_reason: Some(if calls.is_empty() {
|
||
"stop".into()
|
||
} else {
|
||
"tool_calls".into()
|
||
}),
|
||
response_id: Some(id.into()),
|
||
usage: None,
|
||
tool_calls: calls,
|
||
responses_output: output,
|
||
}
|
||
}
|
||
|
||
fn write_call(id: &str, path: &str, content: &str) -> platform_llm::LlmToolCall {
|
||
platform_llm::LlmToolCall {
|
||
id: id.into(),
|
||
name: "write_file".into(),
|
||
arguments: json!({"path": path, "content": content}).to_string(),
|
||
}
|
||
}
|
||
|
||
fn submit_call(id: &str) -> platform_llm::LlmToolCall {
|
||
platform_llm::LlmToolCall {
|
||
id: id.into(),
|
||
name: "submit_phase_for_approval".into(),
|
||
arguments: "{}".into(),
|
||
}
|
||
}
|
||
|
||
fn phase_write_and_submit(
|
||
prefix: &str,
|
||
files: &[(&str, &str)],
|
||
) -> platform_llm::LlmRunResponse {
|
||
let mut calls = files
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(index, (path, content))| {
|
||
write_call(&format!("{prefix}-w{index}"), path, content)
|
||
})
|
||
.collect::<Vec<_>>();
|
||
calls.push(submit_call(&format!("{prefix}-submit")));
|
||
fake_response(prefix, "", calls)
|
||
}
|
||
|
||
fn init_design_project() -> (tempfile::TempDir, PathBuf, DesignResources) {
|
||
let temp = tempfile::tempdir().expect("tempdir");
|
||
let root = temp.path().to_path_buf();
|
||
crate::project::init_local_game_project_at(&root, "design-fake", "策划假Provider")
|
||
.expect("init project");
|
||
(temp, root, pack())
|
||
}
|
||
|
||
fn request_id(view: &DesignView) -> String {
|
||
view.session
|
||
.pending_approval
|
||
.as_ref()
|
||
.expect("pending approval")
|
||
.request_id
|
||
.clone()
|
||
}
|
||
|
||
#[test]
|
||
fn design_request_enables_reasoning_capture_only_for_design_runtime() {
|
||
let session = new_design_session("project", "quality");
|
||
let request = build_design_request(&session, &pack(), &GameCreatorLlmConfig::default())
|
||
.expect("design request");
|
||
assert!(request.capture_reasoning);
|
||
}
|
||
|
||
#[test]
|
||
fn persisted_reasoning_follows_response_order_across_tool_only_responses() {
|
||
let mut session = new_design_session("project", "quality");
|
||
session.messages = vec![
|
||
DesignMessage {
|
||
id: "turn:user".into(),
|
||
role: "user".into(),
|
||
text: "需求".into(),
|
||
},
|
||
DesignMessage {
|
||
id: "call-1:tool".into(),
|
||
role: "tool".into(),
|
||
text: "读取资源".into(),
|
||
},
|
||
DesignMessage {
|
||
id: "turn:response:0".into(),
|
||
role: "assistant".into(),
|
||
text: "给出方案".into(),
|
||
},
|
||
];
|
||
session.history = vec![
|
||
json!({"role":"user", "content":"需求"}),
|
||
json!({"type":"reasoning", "id":"r1", "content":[{"type":"reasoning_text", "text":"第一段思考"}]}),
|
||
json!({"type":"function_call", "call_id":"call-1", "name":"read_resource", "arguments":"{}"}),
|
||
json!({"type":"reasoning", "id":"r2", "content":[{"type":"reasoning_text", "text":"第二段思考"}]}),
|
||
json!({"type":"message", "role":"assistant", "content":[{"type":"output_text", "text":"给出方案"}]}),
|
||
];
|
||
|
||
let entries = persisted_design_reasoning_entries(&session);
|
||
assert_eq!(
|
||
entries
|
||
.iter()
|
||
.map(|entry| (entry.id.as_str(), entry.message_id.as_deref()))
|
||
.collect::<Vec<_>>(),
|
||
vec![
|
||
("r1", Some("turn:response:0")),
|
||
("r2", Some("turn:response:0")),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn persisted_reasoning_stays_with_the_turn_before_an_approval_boundary() {
|
||
let mut session = new_design_session("project", "quality");
|
||
session.messages = vec![
|
||
DesignMessage {
|
||
id: "turn-1:user".into(),
|
||
role: "user".into(),
|
||
text: "第一轮需求".into(),
|
||
},
|
||
DesignMessage {
|
||
id: "turn-1:assistant".into(),
|
||
role: "assistant".into(),
|
||
text: "第一轮已提交审批".into(),
|
||
},
|
||
DesignMessage {
|
||
id: "turn-2:user".into(),
|
||
role: "user".into(),
|
||
text: "用户已批准,进入下一阶段".into(),
|
||
},
|
||
DesignMessage {
|
||
id: "turn-2:assistant".into(),
|
||
role: "assistant".into(),
|
||
text: "查询工作阶段".into(),
|
||
},
|
||
];
|
||
session.history = vec![
|
||
json!({"role":"user", "content":"第一轮需求"}),
|
||
json!({"type":"reasoning", "id":"before-approval", "content":[{"type":"reasoning_text", "text":"审批前的思考"}]}),
|
||
json!({"type":"message", "role":"assistant", "content":[{"type":"output_text", "text":"第一轮已提交审批"}]}),
|
||
json!({"role":"user", "content":"用户已批准,进入下一阶段"}),
|
||
json!({"type":"reasoning", "id":"after-approval", "content":[{"type":"reasoning_text", "text":"审批后的思考"}]}),
|
||
json!({"type":"message", "role":"assistant", "content":[{"type":"output_text", "text":"查询工作阶段"}]}),
|
||
];
|
||
|
||
let entries = persisted_design_reasoning_entries(&session);
|
||
assert_eq!(
|
||
entries
|
||
.iter()
|
||
.map(|entry| (entry.id.as_str(), entry.message_id.as_deref()))
|
||
.collect::<Vec<_>>(),
|
||
vec![
|
||
("before-approval", Some("turn-1:assistant")),
|
||
("after-approval", Some("turn-2:assistant")),
|
||
]
|
||
);
|
||
}
|
||
|
||
#[tokio::test(flavor = "current_thread")]
|
||
async fn scripted_design_provider_emits_reasoning_without_persisting_it() {
|
||
let (_temp, root, _resources) = init_design_project();
|
||
let mut session = new_design_session("design-fake", "quality");
|
||
begin_design_turn(&mut session, "turn-reasoning");
|
||
let mut response = fake_response("reasoning", "正文", Vec::new());
|
||
response.reasoning = "先分析需求,再组织方案。".into();
|
||
let _fake = fake_provider::install(vec![Ok(response)], 0);
|
||
let mut events = Vec::new();
|
||
let response =
|
||
request_scripted_design_provider(&root, &mut session, &mut |event| events.push(event))
|
||
.await
|
||
.expect("scripted provider");
|
||
|
||
let reasoning_events = events
|
||
.iter()
|
||
.filter_map(|event| event.reasoning_text.as_deref())
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(reasoning_events, vec!["", "先分析需求,再组织方案。"]);
|
||
assert_eq!(response.text, "正文");
|
||
assert!(session.history.is_empty());
|
||
}
|
||
|
||
#[tokio::test(flavor = "current_thread")]
|
||
async fn scripted_design_provider_retry_clears_previous_reasoning_attempt() {
|
||
let (_temp, root, _resources) = init_design_project();
|
||
let mut session = new_design_session("design-fake", "quality");
|
||
begin_design_turn(&mut session, "turn-reasoning-retry");
|
||
let mut response = fake_response("reasoning-retry", "重试后的正文", Vec::new());
|
||
response.reasoning = "重试后的推理".into();
|
||
let _fake = fake_provider::install(
|
||
vec![
|
||
Err(platform_llm::LlmError::Upstream {
|
||
status_code: 503,
|
||
message: "busy".into(),
|
||
}),
|
||
Ok(response),
|
||
],
|
||
1,
|
||
);
|
||
let mut events = Vec::new();
|
||
let response =
|
||
request_scripted_design_provider(&root, &mut session, &mut |event| events.push(event))
|
||
.await
|
||
.expect("scripted retry provider");
|
||
|
||
let reasoning_events = events
|
||
.iter()
|
||
.filter_map(|event| event.reasoning_text.as_deref())
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(reasoning_events, vec!["", "", "重试后的推理"]);
|
||
assert_eq!(response.text, "重试后的正文");
|
||
assert!(session.history.is_empty());
|
||
}
|
||
|
||
#[tokio::test(flavor = "current_thread")]
|
||
async fn fake_provider_walks_five_phases_and_enters_consultant() {
|
||
let (_temp, root, resources) = init_design_project();
|
||
let _fake = fake_provider::install(
|
||
vec![
|
||
Ok(phase_write_and_submit(
|
||
"concept",
|
||
&[
|
||
("project/00_concept/design.md", "概念"),
|
||
("project/速览卡.md", "速览"),
|
||
],
|
||
)),
|
||
Ok(phase_write_and_submit(
|
||
"top",
|
||
&[("project/01_top_design/design.md", "顶层")],
|
||
)),
|
||
Ok(phase_write_and_submit(
|
||
"arch",
|
||
&[("project/02_architecture/design.md", "架构")],
|
||
)),
|
||
Ok(phase_write_and_submit("systems", &[])),
|
||
Ok(phase_write_and_submit(
|
||
"tdd",
|
||
&[
|
||
("project/04_tdd/01_技术实现.md", "技术"),
|
||
("project/04_tdd/02_美术圣经.md", "美术"),
|
||
("project/04_tdd/03_数据与配表.md", "数据"),
|
||
("project/04_tdd/总册.md", "总册"),
|
||
],
|
||
)),
|
||
Ok(fake_response("consultant", "顾问阶段待命。", Vec::new())),
|
||
],
|
||
0,
|
||
);
|
||
let view = continue_design_agent_at(
|
||
&root,
|
||
&resources,
|
||
"t-concept",
|
||
DesignInput::Message {
|
||
text: "做一个网页迷宫".into(),
|
||
},
|
||
|_| {},
|
||
)
|
||
.await
|
||
.expect("concept");
|
||
assert_eq!(view.session.current_phase, "concept");
|
||
assert!(view.session.pending_approval.is_some());
|
||
assert!(!view.running);
|
||
let listed = list_design_workspace_files(&root).expect("list");
|
||
assert!(listed
|
||
.iter()
|
||
.any(|entry| entry.path == "project/00_concept/design.md"));
|
||
|
||
let mut request = request_id(&view);
|
||
for (turn, expected) in [
|
||
("t-top", "top_design"),
|
||
("t-arch", "architecture"),
|
||
("t-systems", "systems"),
|
||
("t-tdd", "tdd"),
|
||
("t-consultant", "consultant"),
|
||
] {
|
||
let view = decide_design_phase_at(&root, &resources, turn, &request, true, |_| {})
|
||
.await
|
||
.expect("approve");
|
||
assert_eq!(view.session.current_phase, expected);
|
||
if expected == "consultant" {
|
||
assert!(view.session.pending_approval.is_none());
|
||
assert!(view.session.approved_phases.ends_with(&["tdd".into()]));
|
||
assert!(view
|
||
.messages
|
||
.iter()
|
||
.any(|message| message.text.contains("顾问阶段待命")));
|
||
} else {
|
||
assert!(view.session.pending_approval.is_some());
|
||
request = request_id(&view);
|
||
}
|
||
}
|
||
let restored = read_design_session(&root).expect("read").expect("session");
|
||
assert_eq!(restored.current_phase, "consultant");
|
||
assert_eq!(
|
||
design_workflow_status(&restored)["current_phase"],
|
||
json!("consultant")
|
||
);
|
||
}
|
||
|
||
#[tokio::test(flavor = "current_thread")]
|
||
async fn fake_provider_reject_does_not_wake_and_session_survives_restart() {
|
||
let (_temp, root, resources) = init_design_project();
|
||
let _fake = fake_provider::install(
|
||
vec![Ok(phase_write_and_submit(
|
||
"concept",
|
||
&[
|
||
("project/00_concept/design.md", "概念"),
|
||
("project/速览卡.md", "速览"),
|
||
],
|
||
))],
|
||
0,
|
||
);
|
||
let view = continue_design_agent_at(
|
||
&root,
|
||
&resources,
|
||
"t-submit",
|
||
DesignInput::Message {
|
||
text: "开工".into(),
|
||
},
|
||
|_| {},
|
||
)
|
||
.await
|
||
.expect("submit");
|
||
let request = request_id(&view);
|
||
let persisted = read_design_session(&root).expect("read").expect("session");
|
||
assert_eq!(persisted.current_phase, "concept");
|
||
assert_eq!(
|
||
persisted
|
||
.pending_approval
|
||
.as_ref()
|
||
.map(|item| item.request_id.as_str()),
|
||
Some(request.as_str())
|
||
);
|
||
let rejected =
|
||
decide_design_phase_at(&root, &resources, "t-reject", &request, false, |_| {})
|
||
.await
|
||
.expect("reject");
|
||
assert_eq!(rejected.session.current_phase, "concept");
|
||
assert!(rejected.session.pending_approval.is_none());
|
||
assert!(rejected.session.approved_phases.is_empty());
|
||
assert!(fake_provider::take().is_none());
|
||
|
||
let debug = root.join(".debug/design-agent");
|
||
if debug.exists() {
|
||
fs::remove_dir_all(&debug).expect("remove debug");
|
||
}
|
||
let restored = read_design_session(&root).expect("read").expect("session");
|
||
assert_eq!(restored.current_phase, "concept");
|
||
assert!(restored.pending_approval.is_none());
|
||
assert!(restored
|
||
.history
|
||
.iter()
|
||
.any(|item| item.get("role") == Some(&json!("user"))));
|
||
}
|
||
|
||
#[tokio::test(flavor = "current_thread")]
|
||
async fn fake_provider_survives_resource_and_tool_failures_then_retries_transient_error() {
|
||
let (_temp, root, resources) = init_design_project();
|
||
let _fake = fake_provider::install(
|
||
vec![
|
||
Ok(fake_response(
|
||
"fail-tools",
|
||
"",
|
||
vec![
|
||
platform_llm::LlmToolCall {
|
||
id: "missing-resource".into(),
|
||
name: "read_resource".into(),
|
||
arguments: json!({"resource_id": "skills.missing"}).to_string(),
|
||
},
|
||
write_call("escape", "../secret.md", "no"),
|
||
write_call("ok", "project/00_concept/design.md", "概念"),
|
||
write_call("card", "project/速览卡.md", "速览"),
|
||
submit_call("submit"),
|
||
],
|
||
)),
|
||
Err(platform_llm::LlmError::Upstream {
|
||
status_code: 503,
|
||
message: "busy".into(),
|
||
}),
|
||
Ok(fake_response("recovered", "重试后继续。", Vec::new())),
|
||
],
|
||
1,
|
||
);
|
||
let view = continue_design_agent_at(
|
||
&root,
|
||
&resources,
|
||
"t-fail",
|
||
DesignInput::Message {
|
||
text: "写概念".into(),
|
||
},
|
||
|_| {},
|
||
)
|
||
.await
|
||
.expect("submit after failures");
|
||
assert!(view.session.pending_approval.is_some());
|
||
assert!(view
|
||
.messages
|
||
.iter()
|
||
.any(|message| message.text.contains("读取资源失败")
|
||
|| message.text.contains("未知资源")));
|
||
assert!(view
|
||
.messages
|
||
.iter()
|
||
.any(|message| message.text.contains("失败") && message.text.contains("路径")));
|
||
assert!(root
|
||
.join("design_artifacts/project/00_concept/design.md")
|
||
.is_file());
|
||
assert!(!root.join("secret.md").exists());
|
||
|
||
let request = request_id(&view);
|
||
let next = decide_design_phase_at(&root, &resources, "t-retry", &request, true, |_| {})
|
||
.await
|
||
.expect("approve after transient retry");
|
||
assert_eq!(next.session.current_phase, "top_design");
|
||
assert!(next
|
||
.messages
|
||
.iter()
|
||
.any(|message| message.text.contains("重试后继续")));
|
||
assert!(next.session.last_error.is_none());
|
||
}
|
||
|
||
#[tokio::test(flavor = "current_thread")]
|
||
async fn uncertain_batch_hydrate_continues_the_original_turn_without_replaying_file_tools() {
|
||
let (_temp, root, resources) = init_design_project();
|
||
execute_design_file_tool(
|
||
&root,
|
||
"write_file",
|
||
&json!({"path":"project/00_concept/design.md","content":"概念"}),
|
||
)
|
||
.expect("write concept");
|
||
let mut session = new_design_session("design-fake", "quality");
|
||
let call = platform_llm::LlmToolCall {
|
||
id: "interrupted-call".into(),
|
||
name: "patch_file".into(),
|
||
arguments: json!({
|
||
"path":"project/00_concept/design.md",
|
||
"old_text":"概念",
|
||
"new_text":"概念设计"
|
||
})
|
||
.to_string(),
|
||
};
|
||
session.history.push(json!({
|
||
"type":"function_call",
|
||
"call_id":call.id,
|
||
"name":call.name,
|
||
"arguments":call.arguments,
|
||
}));
|
||
session.messages = vec![DesignMessage {
|
||
id: "turn:user".into(),
|
||
role: "user".into(),
|
||
text: "继续".into(),
|
||
}];
|
||
session.turn = Some(DesignTurn {
|
||
id: "turn-recovery".into(),
|
||
pending: true,
|
||
request_index: 0,
|
||
attempt: 0,
|
||
});
|
||
session.pending_batch = Some(DesignToolBatch {
|
||
calls: vec![call],
|
||
cursor: 0,
|
||
executing: true,
|
||
});
|
||
assert!(design_session_has_uncertain_batch(&session));
|
||
write_design_session(&root, &session).expect("write interrupted session");
|
||
|
||
let _fake = fake_provider::install(
|
||
vec![Ok(fake_response(
|
||
"recovered-after-uncertain-tool",
|
||
"已读取文件并确认。",
|
||
Vec::new(),
|
||
))],
|
||
0,
|
||
);
|
||
let view = recover_uncertain_design_batch(&root, &resources, session, {
|
||
try_open_game_creator_agent_runtime_task_lock_file(
|
||
&root,
|
||
".agent/design-agent/active.lock",
|
||
)
|
||
.expect("open active lock")
|
||
.expect("active lock is free")
|
||
})
|
||
.await
|
||
.expect("recover uncertain batch");
|
||
|
||
assert!(!view.running);
|
||
assert!(view.session.last_error.is_none());
|
||
let restored = read_design_session(&root)
|
||
.expect("read restored")
|
||
.expect("session");
|
||
assert!(restored.pending_batch.is_none());
|
||
assert!(!restored.turn.expect("turn").pending);
|
||
assert!(restored.history.iter().any(|item| {
|
||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||
&& item.get("call_id").and_then(Value::as_str) == Some("interrupted-call")
|
||
&& item
|
||
.get("output")
|
||
.and_then(Value::as_str)
|
||
.is_some_and(|output| output.contains("执行结果未保存"))
|
||
}));
|
||
assert!(restored.history.iter().any(|item| {
|
||
item.get("role").and_then(Value::as_str) == Some("assistant")
|
||
&& item.get("content").is_some()
|
||
}));
|
||
assert!(
|
||
fs::read_to_string(root.join("design_artifacts/project/00_concept/design.md"))
|
||
.expect("read target")
|
||
== "概念"
|
||
);
|
||
}
|
||
}
|