接入策划 Agent 脚本化假 Provider 定向测试
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled

拦截测试态 Provider 请求,按队列返回脚本响应或错误
覆盖五阶段推进、审批拒绝不唤醒、重启恢复、资源与工具失败及瞬态重试
同步迁移方案第 8 步验证口径
This commit is contained in:
2026-09-10 09:42:26 +00:00
parent cb79c4a9c6
commit af96174e07
2 changed files with 373 additions and 1 deletions
@@ -291,6 +291,10 @@ fn design_debug(root: &Path, kind: &str, data: Value) {
}
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");
// 此循环统一处理流中断与 HTTP 瞬态错误,避免与传输重试相乘。
@@ -333,6 +337,60 @@ async fn request_design_provider(root: &Path, session: &mut DesignSession, resou
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,
));
match fake_provider::take() {
Some(Ok(response)) => 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()
{
return Err(detail);
}
}
None => 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() {
@@ -452,6 +510,55 @@ pub(crate) fn read_design_workspace_file(project_path: String, path: String) ->
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::*;
@@ -531,4 +638,269 @@ mod tests {
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(),
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()
}
#[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(".workspace/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());
}
}
@@ -297,7 +297,7 @@ UI 选项提交应携带请求身份和所选选项,Runtime 根据已保存的
5. 接入阶段提示、必读资源注入和 `get_workflow_status`
6. 接入 `submit_phase_for_approval` 和 ✅/❌ 审批事件。
7. 复用 Game Agent 文件浏览实现,让用户查看 `.workspace` 文件。
8. 用假 Provider 验证五阶段、恢复、审批拒绝、资源读取失败和工具失败。
8. 用假 Provider 验证五阶段、恢复、审批拒绝、资源读取失败和工具失败。已由 `design_runtime` 脚本化假 Provider 定向测试覆盖。
9. 在生产入口切换到新设计 Agent。
10. 确认没有现役调用方后,再清理旧 Planning V2 入口和专用展示逻辑。