接入策划 Agent 脚本化假 Provider 定向测试
拦截测试态 Provider 请求,按队列返回脚本响应或错误 覆盖五阶段推进、审批拒绝不唤醒、重启恢复、资源与工具失败及瞬态重试 同步迁移方案第 8 步验证口径
This commit is contained in:
@@ -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 入口和专用展示逻辑。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user