Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs
T
lhk229 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
修复局部修改工具的缺陷和偶发锁问题 (#386)
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>
2026-09-16 19:53:23 +08:00

2083 lines
74 KiB
Rust
Raw Blame History

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