Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs
T
kdletters dd24690b02
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 4m37s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 4m36s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m31s
Project CI / Backend tests (push) Failing after 10s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 4m2s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 4m45s
Project CI / Repository checks (push) Failing after 11s
Project CI / AI game creator shell web tests (push) Failing after 1m18s
Project CI / AI game creator shell Rust crates (push) Successful in 2m36s
Project CI / Native shell tests (push) Failing after 2m34s
Project CI / Frontend tests (push) Successful in 4m52s
新增图集连通域与可配置网格切分
增加 connected-components 与 grid 切分模式

支持 gridX/gridY 并同步 API、MCP、Skill、AGC 客户端

移除固定 2x2 图集切分契约与文档
2026-09-15 20:06:36 +08:00

6094 lines
227 KiB
Rust

use super::*;
use base64::Engine as _;
use serde_json::Value;
use sha2::{Digest as _, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::io::{self, Read, Write};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Barrier, Condvar, Mutex as StdMutex, MutexGuard as StdMutexGuard};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use zip::write::SimpleFileOptions;
static TEST_PROJECT_COUNTER: AtomicU64 = AtomicU64::new(0);
static TEST_MOCK_PORT_COUNTER: AtomicU64 = AtomicU64::new(20_000);
static TEST_CONFIG_LOCK: StdMutex<()> = StdMutex::new(());
const MANIFEST_INVALIDATION_RELAY_TEST_ACCEPT_TIMEOUT: Duration = Duration::from_millis(500);
const MANIFEST_INVALIDATION_RELAY_TEST_PAYLOAD_TIMEOUT: Duration = Duration::from_millis(500);
const MANIFEST_INVALIDATION_RELAY_TEST_MAX_BYTES: usize = 64 * 1024;
fn read_manifest_invalidation_relay_payload_with_deadline(
listener: &TcpListener,
) -> io::Result<Vec<u8>> {
listener.set_nonblocking(true)?;
let accept_deadline = Instant::now() + MANIFEST_INVALIDATION_RELAY_TEST_ACCEPT_TIMEOUT;
let (mut stream, _) = loop {
match listener.accept() {
Ok(accepted) => break accepted,
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
if Instant::now() >= accept_deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"manifest invalidation relay accept timed out",
));
}
std::thread::yield_now();
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) => return Err(error),
}
};
stream.set_nonblocking(true)?;
let payload_deadline = Instant::now() + MANIFEST_INVALIDATION_RELAY_TEST_PAYLOAD_TIMEOUT;
let mut payload = Vec::new();
let mut buffer = [0_u8; 4096];
loop {
match stream.read(&mut buffer) {
Ok(0) => return Ok(payload),
Ok(read) => {
payload.extend_from_slice(&buffer[..read]);
if payload.len() > MANIFEST_INVALIDATION_RELAY_TEST_MAX_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"manifest invalidation relay payload exceeded test limit",
));
}
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
if Instant::now() >= payload_deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"manifest invalidation relay payload timed out",
));
}
std::thread::yield_now();
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) => return Err(error),
}
}
}
#[test]
fn manifest_invalidation_sink_isolation_relays_non_supervisor_runtime_update() {
let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard();
let root = unique_project_path();
init_local_game_project_at(&root, "runtime-event-contract", "Runtime 事件合同测试")
.expect("init runtime event contract project");
let runtime = read_game_creator_agent_runtime_at(&root, "art-asset-plan")
.expect("read non-Supervisor runtime");
let event = game_creator_agent_runtime_update_event(&root, runtime);
let serialized = serde_json::to_value(event).expect("serialize runtime update event");
assert_eq!(serialized["agentId"], "art-asset-plan");
assert_eq!(serialized["manifestInvalidated"], true);
let relay_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.expect("bind manifest invalidation relay fixture");
let relay_port = relay_listener
.local_addr()
.expect("read manifest invalidation relay fixture address")
.port();
let relay_token = "a".repeat(64);
sink_guard
.configure(relay_port, &relay_token)
.expect("configure manifest invalidation relay fixture");
emit_game_creator_agent_runtime_update(&root, "art-asset-plan");
let relay_payload = read_manifest_invalidation_relay_payload_with_deadline(&relay_listener)
.expect("receive manifest invalidation relay within deadline");
let relay: GameCreatorManifestInvalidationRelayEnvelope =
serde_json::from_slice(&relay_payload).expect("parse manifest invalidation relay");
assert_eq!(relay.token, relay_token);
assert_eq!(relay.event.project_path, root.to_string_lossy());
assert_eq!(relay.event.agent_id, "art-asset-plan");
fs::remove_dir_all(root).ok();
}
#[test]
fn direct_codex_art_commit_relays_standalone_manifest_invalidation() {
let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard();
let root = unique_project_path();
let relay_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.expect("bind direct Codex manifest invalidation relay fixture");
let relay_port = relay_listener
.local_addr()
.expect("read direct Codex manifest invalidation relay fixture address")
.port();
let relay_token = "c".repeat(64);
sink_guard
.configure(relay_port, &relay_token)
.expect("configure direct Codex manifest invalidation relay fixture");
emit_game_creator_manifest_invalidated(&root, "direct-codex-art");
let relay_payload = read_manifest_invalidation_relay_payload_with_deadline(&relay_listener)
.expect("receive direct Codex manifest invalidation relay within deadline");
let relay: GameCreatorManifestInvalidationRelayEnvelope =
serde_json::from_slice(&relay_payload)
.expect("parse direct Codex manifest invalidation relay");
assert_eq!(relay.token, relay_token);
assert_eq!(relay.event.project_path, root.to_string_lossy());
assert_eq!(relay.event.agent_id, "direct-codex-art");
}
#[test]
fn manifest_invalidation_sink_isolation_bounds_timeouts_and_cleans_up_with_raii() {
let cleanup_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.expect("bind manifest invalidation cleanup fixture");
let cleanup_port = cleanup_listener
.local_addr()
.expect("read manifest invalidation cleanup fixture address")
.port();
let cleanup_token = "b".repeat(64);
let unwind = std::panic::catch_unwind(|| {
let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard();
sink_guard
.configure(cleanup_port, &cleanup_token)
.expect("configure manifest invalidation cleanup fixture");
assert_eq!(
sink_guard.configured_sink(),
Some(GameCreatorManifestInvalidationEventSink {
port: cleanup_port,
token: cleanup_token.clone(),
})
);
panic!("exercise manifest invalidation sink guard unwind cleanup");
});
assert!(unwind.is_err());
let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard();
assert_eq!(sink_guard.configured_sink(), None);
let empty_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.expect("bind empty manifest invalidation relay fixture");
let accept_started = Instant::now();
let accept_error = read_manifest_invalidation_relay_payload_with_deadline(&empty_listener)
.expect_err("missing relay must time out");
assert_eq!(accept_error.kind(), io::ErrorKind::TimedOut);
assert!(accept_started.elapsed() < Duration::from_secs(2));
let stalled_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.expect("bind stalled manifest invalidation relay fixture");
let stalled_stream = TcpStream::connect(
stalled_listener
.local_addr()
.expect("read stalled manifest invalidation relay fixture address"),
)
.expect("connect stalled manifest invalidation relay fixture");
let payload_started = Instant::now();
let payload_error = read_manifest_invalidation_relay_payload_with_deadline(&stalled_listener)
.expect_err("incomplete relay payload must time out");
assert_eq!(payload_error.kind(), io::ErrorKind::TimedOut);
assert!(payload_started.elapsed() < Duration::from_secs(2));
drop(stalled_stream);
}
#[test]
fn gui_final_exit_is_the_only_run_event_that_requests_runner_shutdown() {
assert!(game_creator_gui_run_event_requests_runner_shutdown(
&tauri::RunEvent::Exit
));
assert!(!game_creator_gui_run_event_requests_runner_shutdown(
&tauri::RunEvent::Ready
));
assert!(!game_creator_gui_run_event_requests_runner_shutdown(
&tauri::RunEvent::MainEventsCleared
));
assert_eq!(
resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Ready, || {
panic!("non-exit event must not contact Agent Runner")
}),
GameCreatorGuiRunnerShutdownOutcome::NotRequested
);
assert_eq!(
resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || Ok(())),
GameCreatorGuiRunnerShutdownOutcome::Requested
);
assert_eq!(
resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || {
Err("private shutdown diagnostic".to_string())
}),
GameCreatorGuiRunnerShutdownOutcome::Failed(GameCreatorGuiRunnerShutdownFailure::Other)
);
assert_eq!(
classify_game_creator_gui_runner_shutdown_error(
"Agent Runner 实例锁仍被占用,但 endpoint 未出现"
),
GameCreatorGuiRunnerShutdownFailure::LockTimeout
);
assert_eq!(
classify_game_creator_gui_runner_shutdown_error("打开 Agent Runner pidfd 失败"),
GameCreatorGuiRunnerShutdownFailure::ProcessIdentity
);
assert_eq!(
classify_game_creator_gui_runner_shutdown_error(
"读取响应失败;强制终止 Agent Runner 失败:pid 已被其他进程复用"
),
GameCreatorGuiRunnerShutdownFailure::ProcessIdentity
);
assert_eq!(
classify_game_creator_gui_runner_shutdown_error(
"macOS 不提供可绑定进程实例的安全强制终止句柄"
),
GameCreatorGuiRunnerShutdownFailure::PlatformUnsupported
);
}
fn valid_test_png_bytes() -> Vec<u8> {
base64::engine::general_purpose::STANDARD
.decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
.expect("valid 1x1 test png")
}
fn transparent_test_png_bytes() -> Vec<u8> {
rgba_test_png_bytes(&[[0, 0, 0, 0], [255, 96, 32, 255]])
}
fn spritesheet_slice_test_png_bytes(index: usize) -> Vec<u8> {
let colors = [
[240, 64, 64, 255],
[64, 208, 96, 255],
[64, 128, 240, 255],
[224, 96, 224, 255],
];
rgba_test_png_bytes(&[[0, 0, 0, 0], colors[index]])
}
fn rgba_test_png_bytes(pixels: &[[u8; 4]]) -> Vec<u8> {
let raw = pixels
.iter()
.flat_map(|pixel| pixel.iter().copied())
.collect::<Vec<_>>();
let image =
image::RgbaImage::from_raw(u32::try_from(pixels.len()).expect("test PNG width"), 1, raw)
.expect("test PNG pixel buffer");
let mut output = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(image)
.write_to(&mut output, image::ImageFormat::Png)
.expect("encode RGBA test PNG");
output.into_inner()
}
pub(crate) struct TestConfigGuard {
_lock: StdMutexGuard<'static, ()>,
path: PathBuf,
previous: Option<Vec<u8>>,
}
struct TestRuntimeConfigDirGuard {
_lock: StdMutexGuard<'static, ()>,
previous: Option<PathBuf>,
}
impl Drop for TestConfigGuard {
fn drop(&mut self) {
if let Some(previous) = &self.previous {
replace_test_local_config(&self.path, previous);
} else if self.path.exists() {
fs::remove_file(&self.path).expect("remove local config");
}
}
}
impl Drop for TestRuntimeConfigDirGuard {
fn drop(&mut self) {
*game_creator_runtime_config_dir_lock()
.lock()
.expect("runtime config dir lock") = self.previous.clone();
}
}
fn init_existing_html_project_at(
root: &Path,
project_id: &str,
name: &str,
) -> Result<InitLocalProjectResult, String> {
// 已有单 HTML 项目在初始化前就有入口,不能使用新建 npm 项目的脚手架。
let game_dir = root.join("game");
fs::create_dir_all(&game_dir).expect("create existing HTML fixture directory");
fs::write(
game_dir.join("index.html"),
"<!doctype html><html lang=\"zh-CN\"><meta charset=\"UTF-8\"><body>还没有生成游戏</body></html>",
)
.expect("write existing HTML fixture entry");
init_local_game_project_at(root, project_id, name)
}
fn unique_project_path() -> PathBuf {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after epoch")
.as_millis();
let counter = TEST_PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed);
let temp_root = std::env::temp_dir()
.canonicalize()
.unwrap_or_else(|_| std::env::temp_dir());
temp_root.join(format!(
"genarrative-ai-game-creator-test-{}-{millis}-{counter}",
std::process::id()
))
}
pub(crate) fn freeze_test_root_goal_contract_at(
root: &Path,
run_id: &str,
) -> AgentRuntimeGoalContract {
let task = read_latest_game_creator_agent_runtime_task_by_run_id(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
)
.expect("read test root task")
.expect("test root task exists")
.task;
create_game_creator_agent_runtime_goal_contract_at(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
&task,
&AgentRuntimeGoalContractDraft {
outcome: "完成当前测试根任务".to_string(),
non_negotiables: vec!["保留测试中的既有运行时约束".to_string()],
preferences: Vec::new(),
forbidden_assumptions: vec!["不能把工具成功直接当作目标完成".to_string()],
open_questions: Vec::new(),
acceptance_nodes: vec![AgentRuntimeGoalContractAcceptanceNodeDraft {
criterion_id: "test-acceptance".to_string(),
criterion: "测试声明的最终状态已由当前 revision 的持久回执证明".to_string(),
required: true,
required_evidence: vec!["tool:file.list".to_string()],
dependencies: Vec::new(),
}],
},
)
.expect("freeze test root Goal Contract")
}
pub(crate) fn pass_test_root_acceptance_graph_at(
root: &Path,
state: &AgentRuntimeState,
) -> AgentRuntimeAcceptanceGraphState {
let contract = read_game_creator_agent_runtime_goal_contract_at(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&state.run_id,
)
.expect("read test root Goal Contract")
.unwrap_or_else(|| freeze_test_root_goal_contract_at(root, &state.run_id));
let action = AgentRuntimeToolAction {
tool: "file.list".to_string(),
reason: Some("提供测试验收图的当前 revision 机器证据".to_string()),
input: serde_json::json!({ "path": "" }),
};
let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task);
let action_id =
agent_runtime_tool_action_id(&state.run_id, u32::MAX - 1, 0, 0, &action_fingerprint);
let revision = read_game_creator_agent_runtime_project_revision(root)
.expect("read test project revision")
.revision;
append_agent_runtime_action_receipt_with_project_revision_before(
root,
state,
&action_id,
&action_fingerprint,
"file.list",
"auto",
None,
&AgentRuntimeToolObservation {
tool: "file.list".to_string(),
status: "ok".to_string(),
summary: "测试验收证据已持久化".to_string(),
detail: None,
},
revision,
)
.expect("append test acceptance receipt");
update_game_creator_agent_runtime_acceptance_graph_at(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&state.run_id,
&contract.contract_fingerprint,
&[AgentRuntimeAcceptanceEvaluationDraft {
criterion_id: "test-acceptance".to_string(),
status: "passed".to_string(),
evidence: vec![AgentRuntimeAcceptanceEvidenceRef {
agent_id: state.agent_id.clone(),
run_id: state.run_id.clone(),
action_id,
}],
summary: "测试声明的验收条件已满足".to_string(),
}],
)
.expect("pass test root Acceptance Graph")
}
pub(crate) fn canonical_test_tempdir(prefix: &str) -> tempfile::TempDir {
let temp_root = std::env::temp_dir()
.canonicalize()
.expect("canonicalize test temp root");
let temporary = tempfile::Builder::new()
.prefix(prefix)
.tempdir_in(temp_root)
.expect("create test temp directory under canonical root");
#[cfg(windows)]
crate::config::initialize_windows_game_creator_directory_owner_for_current_user(
temporary.path(),
)
.expect("initialize test temp directory owner");
temporary
}
fn agent_goal_sidecar_path_for_test(root: &Path, agent_id: &str, session_id: &str) -> PathBuf {
let path_key = |value: &str| {
format!("{:x}", Sha256::digest(value.as_bytes()))
.chars()
.take(32)
.collect::<String>()
};
root.join(format!(
".agent/runtime/goals/current/{}/{}.json",
path_key(agent_id),
path_key(session_id)
))
}
fn wait_for_agent_runtime_idle(root: &Path, agent_id: &str) -> AgentRuntimeState {
let mut runtime = read_game_creator_agent_runtime_at(root, agent_id)
.expect("read runtime while waiting")
.state;
for _ in 0..250 {
if runtime.status == "idle" {
return runtime;
}
std::thread::sleep(Duration::from_millis(20));
runtime = read_game_creator_agent_runtime_at(root, agent_id)
.expect("read runtime while waiting")
.state;
}
runtime
}
async fn wait_for_captured_mock_request(
receiver: &mpsc::Receiver<String>,
description: &str,
) -> String {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
match receiver.try_recv() {
Ok(request) => return request,
Err(mpsc::TryRecvError::Empty) if std::time::Instant::now() < deadline => {
tokio::time::sleep(Duration::from_millis(20)).await;
}
Err(mpsc::TryRecvError::Empty) => panic!("{description}: Timeout"),
Err(mpsc::TryRecvError::Disconnected) => {
panic!("{description}: capture channel disconnected")
}
}
}
}
fn wait_for_agent_runtime_terminal_and_lane_release(
root: &Path,
agent_id: &str,
run_id: &str,
status: &str,
phase: &str,
) -> AgentRuntimeResult {
let mut result = read_game_creator_agent_runtime_at(root, agent_id)
.expect("read runtime while waiting for terminal lane release");
for _ in 0..250 {
let matches_terminal = result.state.run_id == run_id
&& result.state.status == status
&& result.state.phase == phase;
if matches_terminal
&& game_creator_agent_runtime_task_lock_is_available(root, agent_id)
.expect("probe runtime lane while waiting for terminal release")
{
let terminal = read_game_creator_agent_runtime_at(root, agent_id)
.expect("reread runtime after terminal lane release");
if terminal.state.run_id == run_id
&& terminal.state.status == status
&& terminal.state.phase == phase
{
return terminal;
}
}
std::thread::sleep(Duration::from_millis(20));
result = read_game_creator_agent_runtime_at(root, agent_id)
.expect("read runtime while waiting for terminal lane release");
}
panic!(
"runtime did not reach {status}/{phase} for run {run_id} before the Agent lane released; last run={} status={} phase={}",
result.state.run_id, result.state.status, result.state.phase
);
}
pub(crate) async fn wait_for_agent_runtime_terminal_and_lane_release_async(
root: &Path,
agent_id: &str,
run_id: &str,
status: &str,
phase: &str,
) -> AgentRuntimeResult {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let mut last_lane_probe_error = None;
let mut result = read_game_creator_agent_runtime_at(root, agent_id)
.expect("read runtime while asynchronously waiting for terminal lane release");
loop {
let matches_terminal = result.state.run_id == run_id
&& result.state.status == status
&& result.state.phase == phase;
let lane_is_available = if matches_terminal {
match game_creator_agent_runtime_task_lock_is_available(root, agent_id) {
Ok(is_available) => is_available,
Err(error) => {
last_lane_probe_error = Some(error);
false
}
}
} else {
false
};
if lane_is_available {
let terminal = read_game_creator_agent_runtime_at(root, agent_id)
.expect("reread runtime after asynchronous terminal lane release");
if terminal.state.run_id == run_id
&& terminal.state.status == status
&& terminal.state.phase == phase
{
return terminal;
}
result = terminal;
}
assert!(
std::time::Instant::now() < deadline,
"runtime did not reach {status}/{phase} for run {run_id} before the Agent lane released; last run={} status={} phase={}; last lane probe error={}",
result.state.run_id,
result.state.status,
result.state.phase,
last_lane_probe_error.as_deref().unwrap_or("none")
);
tokio::time::sleep(Duration::from_millis(20)).await;
result = read_game_creator_agent_runtime_at(root, agent_id)
.expect("read runtime while asynchronously waiting for terminal lane release");
}
}
pub(crate) async fn wait_for_agent_runtime_manifest_projection_async(
root: &Path,
agent_id: &str,
run_id: &str,
runtime_status: &str,
phase: &str,
manifest_status: GameCreationAppTaskStatus,
) -> AgentRuntimeResult {
let deadline = Instant::now() + Duration::from_secs(10);
let mut terminal = wait_for_agent_runtime_terminal_and_lane_release_async(
root,
agent_id,
run_id,
runtime_status,
phase,
)
.await;
let mut stable_samples = 0_u8;
loop {
if let Ok(project_lock) = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"test.wait_runtime_manifest_projection",
) {
let manifest = read_manifest_for_project(root)
.expect("read manifest while waiting for terminal projection");
let projected = manifest
.tasks
.iter()
.find(|task| task.id == agent_id)
.is_some_and(|task| task.status == manifest_status);
let reread = read_game_creator_agent_runtime_at(root, agent_id)
.expect("reread runtime while waiting for terminal projection");
drop(project_lock);
if projected
&& reread.state.run_id == run_id
&& reread.state.status == runtime_status
&& reread.state.phase == phase
{
stable_samples = stable_samples.saturating_add(1);
terminal = reread;
if stable_samples >= 2 {
return terminal;
}
} else {
stable_samples = 0;
terminal = reread;
}
}
assert!(
Instant::now() < deadline,
"runtime terminal manifest projection did not settle for {agent_id}/{run_id}"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
async fn wait_for_agent_runtime_lane_release_async(
root: &Path,
agent_id: &str,
) -> AgentRuntimeResult {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let mut last_lane_probe_error = None;
loop {
match game_creator_agent_runtime_task_lock_is_available(root, agent_id) {
Ok(true) => {
return read_game_creator_agent_runtime_at(root, agent_id)
.expect("read runtime after asynchronous lane release");
}
Ok(false) => {}
Err(error) => last_lane_probe_error = Some(error),
}
assert!(
std::time::Instant::now() < deadline,
"Agent lane did not release for {agent_id}; last lane probe error={}",
last_lane_probe_error.as_deref().unwrap_or("none")
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
fn wait_for_agent_runtime_phase(root: &Path, agent_id: &str, phase: &str) -> AgentRuntimeState {
let mut runtime = read_game_creator_agent_runtime_at(root, agent_id)
.expect("read runtime while waiting for phase")
.state;
for _ in 0..250 {
if runtime.phase == phase {
return runtime;
}
std::thread::sleep(Duration::from_millis(20));
runtime = read_game_creator_agent_runtime_at(root, agent_id)
.expect("read runtime while waiting for phase")
.state;
}
runtime
}
fn wait_for_response_stream_status(
root: &Path,
agent_id: &str,
run_id: &str,
status: &str,
minimum_sequence: u64,
) -> AgentRuntimeResponseStream {
let mut last_stream = None;
for _ in 0..250 {
match read_game_creator_agent_runtime_response_stream_at(root, agent_id, run_id) {
Ok(Some(stream)) => {
if stream.status == status && stream.sequence >= minimum_sequence {
return stream;
}
last_stream = Some(stream);
}
Ok(None) => {}
Err(error) => panic!("read response stream sidecar while waiting: {error}"),
}
std::thread::sleep(Duration::from_millis(10));
}
panic!(
"response stream did not reach status={status} sequence>={minimum_sequence}: {last_stream:?}"
);
}
fn assert_response_stream_public_surfaces_exclude(
root: &Path,
agent_id: &str,
forbidden_texts: &[&str],
) {
for path in [
game_creator_agent_runtime_event_path(root, agent_id),
root.join(".agent/agent.db"),
root.join(".agent/activity.jsonl"),
root.join(".agent/output.jsonl"),
] {
if !path.exists() {
continue;
}
let content = fs::read_to_string(&path).expect("read response stream public surface");
for forbidden in forbidden_texts {
assert!(
!content.contains(forbidden),
"{} leaked response text {forbidden:?}",
path.display()
);
}
}
let receipt_records = read_agent_db_records_for_test(root)
.into_iter()
.filter(|record| {
record["recordType"]
.as_str()
.is_some_and(|record_type| record_type.contains("receipt"))
})
.collect::<Vec<_>>();
let receipts = serde_json::to_string(&receipt_records).expect("serialize Runtime receipts");
for forbidden in forbidden_texts {
assert!(
!receipts.contains(forbidden),
"Runtime receipt leaked response text {forbidden:?}"
);
}
}
fn assert_response_stream_completion_event_details(
root: &Path,
agent_id: &str,
run_id: &str,
canonical_response: &str,
) {
let expected_detail = format!(
"responseSha256={:x} responseChars={}",
Sha256::digest(canonical_response.as_bytes()),
canonical_response.chars().count()
);
let runtime = read_game_creator_agent_runtime_at(root, agent_id)
.expect("read response stream completion events");
for event_type in ["turn.completed", "response"] {
let matching = runtime
.recent_events
.iter()
.filter(|event| event.run_id == run_id && event.event_type == event_type)
.collect::<Vec<_>>();
assert_eq!(
matching.len(),
1,
"expected exactly one {event_type} event for {run_id}"
);
assert_eq!(
matching[0].detail.as_deref(),
Some(expected_detail.as_str())
);
assert!(!matching[0]
.detail
.as_deref()
.unwrap_or_default()
.contains(canonical_response));
}
}
fn assert_response_stream_provider_lifecycles(root: &Path, run_id: &str, request_kinds: &[&str]) {
let records = read_agent_db_records_for_test(root);
let lifecycle_records = records
.iter()
.filter(|record| {
record["recordType"] == "agent.runtime.provider_request.lifecycle"
&& record["runId"] == run_id
})
.collect::<Vec<_>>();
assert_eq!(lifecycle_records.len(), request_kinds.len() * 2);
let mut request_ids = BTreeSet::new();
for request_kind in request_kinds {
let records = lifecycle_records
.iter()
.filter(|record| record["requestKind"] == *request_kind)
.collect::<Vec<_>>();
assert_eq!(records.len(), 2, "lifecycle count for {request_kind}");
assert_eq!(records[0]["status"], "started");
assert_eq!(records[1]["status"], "completed");
assert_eq!(records[0]["requestId"], records[1]["requestId"]);
request_ids.insert(
records[0]["requestId"]
.as_str()
.expect("Provider lifecycle requestId")
.to_string(),
);
}
assert_eq!(request_ids.len(), request_kinds.len());
let protocol_records = records
.iter()
.filter(|record| {
record["recordType"] == "agent.runtime.tool_plan.protocol" && record["runId"] == run_id
})
.collect::<Vec<_>>();
assert_eq!(protocol_records.len(), 1);
assert_eq!(protocol_records[0]["protocol"], "text_json");
}
fn wait_to_acquire_agent_runtime_lock(root: &Path, agent_id: &str) -> AgentRuntimeTaskLock {
for _ in 0..250 {
if let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(root, agent_id)
.expect("inspect Agent Runtime lock while waiting")
{
return runtime_lock;
}
std::thread::sleep(Duration::from_millis(20));
}
panic!("Agent Runtime lock was not released for {agent_id}");
}
fn wait_for_agent_runtime_confirmation(root: &Path, agent_id: &str) -> AgentRuntimeState {
let mut runtime = read_game_creator_agent_runtime_at(root, agent_id)
.expect("read runtime while waiting for confirmation")
.state;
for _ in 0..250 {
if runtime.status == "waiting-for-confirmation" {
return runtime;
}
std::thread::sleep(Duration::from_millis(20));
runtime = read_game_creator_agent_runtime_at(root, agent_id)
.expect("read runtime while waiting for confirmation")
.state;
}
runtime
}
fn wait_for_agent_runtime_user_input(root: &Path, agent_id: &str) -> AgentRuntimeResult {
let mut runtime = read_game_creator_agent_runtime_at(root, agent_id)
.expect("read runtime while waiting for user input");
for _ in 0..250 {
if runtime.state.status == "waiting-for-user-input" && runtime.user_input_request.is_some()
{
return runtime;
}
std::thread::sleep(Duration::from_millis(20));
runtime = read_game_creator_agent_runtime_at(root, agent_id)
.expect("read runtime while waiting for user input");
}
runtime
}
fn wait_for_agent_db_record_type(root: &Path, record_type: &str) -> Vec<Value> {
let mut records = read_agent_db_records_for_test(root);
for _ in 0..250 {
if records
.iter()
.any(|record| record["recordType"] == record_type)
{
return records;
}
std::thread::sleep(Duration::from_millis(10));
records = read_agent_db_records_for_test(root);
}
records
}
fn write_agent_runtime_verification_fixture(root: &Path) -> &'static str {
const CHECK_COMMAND: &str =
r#"node -e "process.stdout.write('AGENT_RUNTIME_CURRENT_REVISION_OK')""#;
fs::write(
root.join("package.json"),
serde_json::to_string_pretty(&serde_json::json!({
"name": "agent-runtime-verification-fixture",
"private": true,
"scripts": { "check:agent": CHECK_COMMAND }
}))
.expect("serialize agent runtime verification package json"),
)
.expect("write agent runtime verification package json");
CHECK_COMMAND
}
fn agent_runtime_verification_plan(check_command: &str) -> String {
serde_json::json!({
"thinkingSummary": "最后一次项目修改后必须执行当前 revision 验证",
"plan": ["运行当前 revision 检查"],
"actions": [{
"tool": "project.verify",
"reason": "确认最后一次文件修改没有破坏项目",
"input": {
"script": "check:agent",
"expectedCommand": check_command,
"timeoutSeconds": 15
}
}],
"response": ""
})
.to_string()
}
fn write_agent_runtime_task_record_for_test(root: &Path, record: &AgentRuntimeTaskRecord) {
let path = root
.join(".agent/runtime/tasks")
.join(format!("{}.jsonl", record.agent_id));
fs::create_dir_all(path.parent().expect("task parent")).expect("runtime task dir");
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.expect("open runtime task file");
serde_json::to_writer(&mut file, record).expect("task record json");
file.write_all(b"\n").expect("write runtime task record");
}
fn pending_tool_action_for_test(
root: &Path,
state: &AgentRuntimeState,
action: AgentRuntimeToolAction,
status: &str,
observation: Option<AgentRuntimeToolObservation>,
) -> AgentRuntimePendingToolAction {
let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task);
let input_summary = agent_runtime_tool_action_input_summary(root, &action);
let occurrence_nonce = unix_timestamp();
let action_index = 0;
let now = unix_timestamp();
AgentRuntimePendingToolAction {
schema_version: AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION.to_string(),
fingerprint_version: AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION.to_string(),
agent_id: state.agent_id.clone(),
task_id: state.task_id.clone(),
session_id: state.session_id.clone(),
run_id: state.run_id.clone(),
source: state.source.clone(),
run_profile: default_agent_runtime_run_profile(),
run_profile_binding_fingerprint: String::new(),
task: state.current_task.clone(),
goal_id: state.goal_id.clone(),
goal_revision: state.goal_revision,
goal_snapshot_fingerprint: agent_goal_snapshot_fingerprint_for_state_at(root, state)
.expect("read pending action Goal fingerprint"),
loop_iteration: state.loop_iteration.max(1),
action_index,
occurrence_nonce,
thinking_summary: "测试待确认动作".to_string(),
plan: vec!["执行测试工具动作".to_string()],
fallback_response: String::new(),
observations: Vec::new(),
project_revision_before: read_game_creator_agent_runtime_project_revision(root)
.expect("read project revision before pending action"),
verification_gate_before: read_game_creator_agent_runtime_verification_gate(
root,
&state.agent_id,
&state.run_id,
)
.expect("read verification gate before pending action"),
planned_repository_context_fingerprint: build_repository_startup_context_at(root)
.expect("build pending action repository context")
.fingerprint,
planned_steer_cursor: state.applied_steer_cursor,
action,
action_id: agent_runtime_tool_action_id(
&state.run_id,
state.loop_iteration.max(1),
action_index,
occurrence_nonce,
&action_fingerprint,
),
action_fingerprint,
input_summary,
execution_mode: AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION.to_string(),
status: status.to_string(),
observation,
created_at: now,
updated_at: now,
}
}
fn persist_needs_reconciliation_runtime_for_test(
root: &Path,
run_id: &str,
with_pending_ledger: bool,
) -> AgentRuntimeState {
let mut state = start_game_creator_agent_runtime_task_at(
root,
"design-director",
"核对结果未知的自动工具动作",
run_id,
"agent-background-task",
"模拟 Runtime 需要人工核对",
vec!["等待人工核对动作副作用".to_string()],
)
.expect("start reconciliation runtime");
state.loop_iteration = 1;
if with_pending_ledger {
let action = AgentRuntimeToolAction {
tool: "file.write".to_string(),
reason: Some("结果未知的项目文件写入".to_string()),
input: serde_json::json!({
"path": "game/reconciliation-side-effect.txt",
"content": "不允许在人工核对前重放"
}),
};
let mut pending = pending_tool_action_for_test(
root,
&state,
action,
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED,
None,
);
pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string();
write_game_creator_agent_runtime_pending_tool_action(root, &pending)
.expect("write reconciliation pending ledger");
state.pending_tool_action = Some(pending.summary());
}
state.status = "failed".to_string();
state.phase = "needs-reconciliation".to_string();
state.current_action = "工具动作结果需要人工核对".to_string();
state.waiting_on = "开发者核对项目副作用".to_string();
state.next_step = "核对后取消原任务".to_string();
state.error = Some("模拟工具副作用结果未知".to_string());
append_game_creator_agent_runtime_task(root, &state).expect("append reconciliation task");
write_game_creator_agent_runtime_state(root, &state).expect("write reconciliation state");
state
}
fn read_agent_db_records_for_test(root: &Path) -> Vec<Value> {
fs::read_to_string(root.join(".agent/agent.db"))
.expect("agent db")
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str::<Value>(line).expect("agent db record"))
.collect()
}
fn remove_jsonl_records_for_test(path: &Path, mut should_remove: impl FnMut(&Value) -> bool) {
let original = fs::read_to_string(path).expect("read jsonl fixture");
let retained = original
.lines()
.filter(|line| !line.trim().is_empty())
.filter(|line| {
let value = serde_json::from_str::<Value>(line).expect("parse jsonl fixture");
!should_remove(&value)
})
.collect::<Vec<_>>();
let content = if retained.is_empty() {
String::new()
} else {
format!("{}\n", retained.join("\n"))
};
fs::write(path, content).expect("rewrite jsonl fixture");
}
async fn execute_agent_runtime_file_delete_for_test(
root: &Path,
run_id: &str,
path: Option<&str>,
) -> AgentRuntimeToolObservation {
execute_game_creator_agent_runtime_tool_action(
root,
"design-director",
run_id,
"删除不再需要的项目文件",
&AgentRuntimeToolAction {
tool: "file.delete".to_string(),
reason: Some("清理废弃项目文件".to_string()),
input: path
.map(|path| serde_json::json!({ "path": path }))
.unwrap_or_else(|| serde_json::json!({})),
},
)
.await
}
fn assert_auto_tool_action_audit_pair(
records: &[Value],
run_id: &str,
action_id: &str,
action_fingerprint: &str,
tool: &str,
observation_status: &str,
) {
let matching = |record_type: &str| {
records
.iter()
.enumerate()
.filter(|(_, record)| {
record["recordType"] == record_type
&& record["runId"] == run_id
&& record["actionId"] == action_id
})
.collect::<Vec<_>>()
};
let executing = matching("agent.runtime.tool_action.executing");
let observed = matching("agent.runtime.tool_action.observed");
assert_eq!(executing.len(), 1, "executing audit for {action_id}");
assert_eq!(observed.len(), 1, "observed audit for {action_id}");
let (executing_index, executing) = executing[0];
let (observed_index, observed) = observed[0];
assert!(
executing_index < observed_index,
"executing audit must precede observed audit for {action_id}"
);
for record in [executing, observed] {
assert_eq!(record["runId"], run_id);
assert_eq!(record["actionId"], action_id);
assert_eq!(record["actionFingerprint"], action_fingerprint);
assert_eq!(record["tool"], tool);
assert_eq!(
record["executionMode"],
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO
);
}
assert_eq!(observed["observationStatus"], observation_status);
}
fn append_auto_tool_action_audit_pair_for_test(
root: &Path,
pending: &AgentRuntimePendingToolAction,
observation: &AgentRuntimeToolObservation,
) {
append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.tool_action.executing",
"agentId": pending.agent_id,
"taskId": pending.task_id,
"runId": pending.run_id,
"actionId": pending.action_id,
"actionFingerprint": pending.action_fingerprint,
"tool": pending.action.tool,
"executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
"inputSummary": pending.input_summary,
}),
)
.expect("append executing audit fixture");
append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.tool_action.observed",
"agentId": pending.agent_id,
"taskId": pending.task_id,
"runId": pending.run_id,
"actionId": pending.action_id,
"actionFingerprint": pending.action_fingerprint,
"tool": pending.action.tool,
"executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
"observationStatus": observation.status,
}),
)
.expect("append observed audit fixture");
}
fn assert_pending_runtime_decision_revalidates_after_lock(
decision: fn(&Path, &str, &str, &str, &str) -> Result<AgentRuntimeResult, String>,
decision_name: &str,
) {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "待确认锁顺序测试").expect("project init");
let run_id = format!("design-{decision_name}-lock-order");
let mut state = start_game_creator_agent_runtime_task_at(
&root,
"design-director",
"验证待确认动作必须在锁内重读",
&run_id,
"agent-background-task",
"等待测试动作确认",
vec!["执行测试动作".to_string()],
)
.expect("start runtime state");
state.loop_iteration = 1;
let action = AgentRuntimeToolAction {
tool: "file.read".to_string(),
reason: Some("读取测试文件".to_string()),
input: serde_json::json!({ "path": "game/notes.txt" }),
};
let pending = pending_tool_action_for_test(&root, &state, action, "pending-confirmation", None);
write_game_creator_agent_runtime_pending_tool_action(&root, &pending)
.expect("write pending action");
state.status = "waiting-for-confirmation".to_string();
state.phase = "waiting-for-confirmation".to_string();
state.pending_tool_action = Some(pending.summary());
append_game_creator_agent_runtime_task(&root, &state).expect("append waiting task");
write_game_creator_agent_runtime_state(&root, &state).expect("write waiting state");
let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director")
.expect("acquire runtime lock")
.expect("runtime lock owner");
let (started_sender, started_receiver) = mpsc::channel();
let decision_root = root.clone();
let decision_run_id = run_id.clone();
let action_id = pending.action_id.clone();
let handle = std::thread::spawn(move || {
started_sender.send(()).expect("signal decision start");
decision(
&decision_root,
"design-director",
&decision_run_id,
&action_id,
"并发测试",
)
});
started_receiver
.recv_timeout(Duration::from_secs(1))
.expect("decision starts");
std::thread::sleep(Duration::from_millis(40));
finish_game_creator_agent_runtime_turn_at(
&root,
state,
&format!("锁内操作已先完成待确认任务:{decision_name}"),
)
.expect("finish waiting task under lock");
drop(runtime_lock);
let error = handle
.join()
.expect("decision thread")
.expect_err("stale decision must be rejected");
assert!(
error.contains("不在待确认状态"),
"unexpected error: {error}"
);
let result =
read_game_creator_agent_runtime_at(&root, "design-director").expect("read runtime");
assert_eq!(result.state.status, "idle");
assert_eq!(result.state.phase, "completed");
assert!(result.recent_tasks.iter().any(|task| {
task.run_id == run_id && task.status == "completed" && task.phase == "completed"
}));
assert!(!root
.join(".agent/runtime/pending-actions/design-director")
.join(format!("{run_id}.json"))
.exists());
assert!(!root
.join(".agent/runtime/confirmations/design-director")
.join(&run_id)
.join("file.read.json")
.exists());
fs::remove_dir_all(root).ok();
}
fn test_local_config_path() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("app root")
.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME)
}
fn replace_test_local_config(path: &Path, content: impl AsRef<[u8]>) {
let temp_path = path.with_file_name(format!(
".{}.test.{}.{}",
path.file_name()
.and_then(|value| value.to_str())
.unwrap_or("game-creator.config.local.json"),
std::process::id(),
TEST_PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed)
));
fs::write(&temp_path, content).expect("write temporary local config");
fs::rename(&temp_path, path).expect("replace local config");
}
pub(crate) fn write_test_local_config(content: String) -> TestConfigGuard {
let lock = TEST_CONFIG_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let path = test_local_config_path();
let previous = fs::read(&path).ok();
let content = match serde_json::from_str::<serde_json::Value>(&content) {
Ok(mut config) => {
if let Some(config) = config.as_object_mut() {
config
.entry("agentMode")
.or_insert_with(|| serde_json::json!(GAME_CREATOR_AGENT_MODE_PROVIDER));
let llm = config.entry("llm").or_insert_with(|| serde_json::json!({}));
if !llm.is_object() {
*llm = serde_json::json!({});
}
llm.as_object_mut()
.expect("test config LLM value must be an object")
.entry("stream")
.or_insert_with(|| serde_json::json!(false));
}
serde_json::to_vec_pretty(&config).expect("serialize explicit Provider test config")
}
Err(_) => content.into_bytes(),
};
replace_test_local_config(&path, content);
TestConfigGuard {
_lock: lock,
path,
previous,
}
}
#[test]
fn test_local_config_defaults_mock_provider_to_non_streaming_and_preserves_explicit_stream() {
let guard =
write_test_local_config(r#"{"agentMode":"codex_cli","llm":{"stream":true}}"#.to_string());
let explicit_stream = serde_json::from_slice::<serde_json::Value>(
&fs::read(test_local_config_path()).expect("read explicit streaming test config"),
)
.expect("parse explicit streaming test config");
assert_eq!(explicit_stream["agentMode"], "codex_cli");
assert_eq!(explicit_stream["llm"]["stream"], true);
drop(guard);
let _guard = write_test_local_config(r#"{"agentLlm":{}}"#.to_string());
let defaulted = serde_json::from_slice::<serde_json::Value>(
&fs::read(test_local_config_path()).expect("read defaulted mock provider test config"),
)
.expect("parse defaulted mock provider test config");
assert_eq!(defaulted["agentMode"], GAME_CREATOR_AGENT_MODE_PROVIDER);
assert_eq!(defaulted["llm"]["stream"], false);
}
fn use_test_runtime_config_dir(path: PathBuf) -> TestRuntimeConfigDirGuard {
let lock = TEST_CONFIG_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let previous = game_creator_runtime_config_dir();
set_game_creator_runtime_config_dir(path);
TestRuntimeConfigDirGuard {
_lock: lock,
previous,
}
}
fn assert_task_status(manifest: &Value, task_id: &str, status: &str) {
let task = manifest["tasks"]
.as_array()
.unwrap()
.iter()
.find(|task| task["id"] == task_id)
.unwrap_or_else(|| panic!("missing task {task_id}"));
assert_eq!(task["status"], status);
}
fn fake_llm_game_draft() -> LlmGameDraft {
LlmGameDraft {
title: "月光弹幕厨房".to_string(),
design_markdown: "玩家在厨房里反弹月光弹幕,点亮三口锅后获胜。".to_string(),
balance: serde_json::json!({
"playerSpeed": 216,
"playerLives": 4,
"difficultyRamp": "mock-provider-output",
"source": "llm"
}),
art_manifest: serde_json::json!({
"source": "llm",
"items": [
{ "kind": "character", "title": "月光厨师", "status": "needs-canvas" },
{ "kind": "scene", "title": "夜间厨房", "status": "needs-canvas" }
]
}),
audio_manifest: serde_json::json!({
"source": "llm",
"items": [
{ "kind": "background-music", "title": "玻璃月光 BGM", "status": "needs-canvas" },
{ "kind": "sound-effect", "title": "锅盖反弹音", "status": "needs-canvas" }
]
}),
publish_readme: "## 标签\n\n弹幕 / 厨房 / 反弹\n\n## 下一步\n\n试玩月光反弹手感。".to_string(),
handoffs: fake_agent_handoffs(),
handoff_summary: "策划组 / Gameplay:定义反弹循环\n美术组 / Asset:规划厨房资产\n程序组 / Code:生成 canvas 原型\n数值组 / Difficulty:设置生命和速度\n音乐组 / SFX:规划反弹音效\n运营组 / Publish:整理标题和标签".to_string(),
game_html: r#"<!doctype html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>月光弹幕厨房</title></head>
<body>
<canvas id="game" width="320" height="180"></canvas>
<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const player = { x: 40, y: 90, hp: 4 };
let litPots = 0;
let state = 'playing';
const marker = 'MOCK_UNIQUE_MECHANIC:moon-kitchen-reflect';
function resetGame() {
player.hp = 4;
litPots = 0;
state = 'playing';
}
function frame() {
ctx.fillStyle = '#101827';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#facc15';
ctx.fillText(marker, 16, 32);
ctx.fillText('目标:反弹月光弹幕,点亮三口锅', 16, 58);
ctx.fillText('状态:继续 / 胜利 / 失败,R 重开', 16, 82);
if (litPots >= 3) state = '胜利';
if (player.hp <= 0) state = '失败';
requestAnimationFrame(frame);
}
window.addEventListener('keydown', (event) => {
if (event.key.toLowerCase() === 'r') resetGame();
});
requestAnimationFrame(frame);
</script>
</body>
</html>"#
.to_string(),
}
}
fn write_static_smoke_game_fixture_for_test(root: &Path, marker: &str) {
let html = format!(
"{}\n<!-- static-smoke fixture: {marker} -->\n",
fake_llm_game_draft().game_html
);
fs::write(root.join("game/index.html"), html)
.expect("write complete static-smoke game fixture");
}
fn fake_agent_handoffs() -> Vec<LlmAgentHandoff> {
vec![
handoff(
"design",
"Gameplay",
"定义反弹循环",
["game/game_design.md"],
"交给数值和程序组",
),
handoff(
"balance",
"Difficulty",
"设置生命和速度",
["game/balance.json"],
"交给程序组读取",
),
handoff(
"art",
"Asset",
"规划厨房角色和场景资产",
["assets/manifest.art.json"],
"进入画板链路",
),
handoff(
"audio",
"SFX",
"规划反弹音效和 BGM",
["assets/manifest.audio.json"],
"进入音频生成链路",
),
handoff(
"code",
"Code",
"生成 canvas 原型",
["game/index.html"],
"交给 Playtest",
),
handoff(
"publishing",
"Publish",
"整理标题和标签",
["exports/README.md"],
"等待预览验收",
),
]
}
fn handoff<const N: usize>(
group: &str,
role: &str,
summary: &str,
outputs: [&str; N],
next: &str,
) -> LlmAgentHandoff {
LlmAgentHandoff {
group: group.to_string(),
role: role.to_string(),
summary: summary.to_string(),
outputs: outputs.map(str::to_string).to_vec(),
next: next.to_string(),
}
}
fn write_test_canvas_export_zip(path: &Path) {
let file = File::create(path).expect("create canvas export zip");
let mut writer = zip::ZipWriter::new(file);
let options = SimpleFileOptions::default();
writer
.start_file("月光画布-画布素材/images/001-月光主角.png", options)
.expect("start image file");
writer.write_all(b"fake-png").expect("write image");
writer
.start_file("月光画布-画布素材/media/002-玻璃月光.mp3", options)
.expect("start audio file");
writer.write_all(b"fake-mp3").expect("write audio");
writer
.start_file("月光画布-画布素材/manifest.txt", options)
.expect("start manifest");
writer
.write_all("项目:月光画布\n素材数量:2\n".as_bytes())
.expect("write manifest");
writer
.start_file("月光画布-画布素材/metadata.json", options)
.expect("start metadata");
writer
.write_all(
serde_json::json!({
"projectTitle": "月光画布",
"exportedAt": "2026-06-24T00:00:00.000Z",
"layers": [
{
"title": "月光主角",
"file": "images/001-月光主角.png",
"visible": {
"type": "角色",
"generationInputs": null,
"model": "gpt-image-2",
"task": "42",
"object": "asset-object-1",
"resolution": "512 x 512 px"
}
},
{
"title": "玻璃月光 BGM",
"file": "media/002-玻璃月光.mp3",
"visible": {
"type": "音乐",
"generationInputs": null,
"model": "-",
"task": "-",
"object": "-",
"duration": "12s"
}
}
],
"failedImages": []
})
.to_string()
.as_bytes(),
)
.expect("write metadata");
writer.finish().expect("finish canvas export zip");
}
fn bind_test_tcp_listener(label: &str) -> TcpListener {
match TcpListener::bind(("127.0.0.1", 0)) {
Ok(listener) => return listener,
Err(ephemeral_error) => {
for _ in 0..12_000 {
let candidate =
20_000 + TEST_MOCK_PORT_COUNTER.fetch_add(1, Ordering::Relaxed) % 12_000;
if let Ok(port) = u16::try_from(candidate) {
if let Ok(listener) = TcpListener::bind(("127.0.0.1", port)) {
return listener;
}
}
}
panic!(
"{label}: ephemeral bind failed ({ephemeral_error}); fixed test range exhausted"
);
}
}
}
fn spawn_mock_llm_server(response_content: String) -> String {
spawn_mock_llm_server_responses(vec![response_content])
}
pub(crate) fn spawn_mock_llm_server_responses(response_contents: Vec<String>) -> String {
spawn_mock_llm_server_responses_with_capture(response_contents, None)
}
pub(crate) fn spawn_mock_llm_tool_plan_then_invalid_final_reply(
planning_response: String,
) -> String {
let listener = bind_test_tcp_listener("mock invalid final reply bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
std::thread::spawn(move || {
let (mut planning_stream, _) = listener.accept().expect("mock tool plan accept");
let planning_request = read_mock_http_request(&mut planning_stream);
let planning_streaming = planning_request.contains("\"stream\":true");
let (planning_body, planning_content_type) = if planning_streaming {
(
format!(
"data: {}\n\ndata: {}\n\n",
serde_json::json!({
"type": "response.output_text.delta",
"delta": planning_response
}),
serde_json::json!({ "type": "response.completed" })
),
"text/event-stream; charset=utf-8",
)
} else {
(
serde_json::json!({
"id": "resp_invalid_final_reply_planning",
"model": "mock-game-model",
"output_text": planning_response,
"status": "completed",
"usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 }
})
.to_string(),
"application/json",
)
};
let planning_response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: {planning_content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
planning_body.len(),
planning_body
);
planning_stream
.write_all(planning_response.as_bytes())
.expect("mock tool plan response");
// Autonomous runs enforce a 12-retry floor. Return the same malformed
// response for the initial final-reply request and every retry so this
// fixture tests deserialize exhaustion rather than an accidental
// connection-refused fallback after the first malformed response.
for _ in 0..=12 {
let (mut final_stream, _) = listener.accept().expect("mock final reply accept");
drop(read_mock_http_request(&mut final_stream));
let invalid_body = "{invalid-json";
let final_response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
invalid_body.len(),
invalid_body
);
final_stream
.write_all(final_response.as_bytes())
.expect("mock invalid final reply response");
}
});
base_url
}
pub(crate) fn final_tool_plan_response(response: impl Into<String>) -> String {
serde_json::json!({
"thinkingSummary": "已有工具观察足够,可以收束后台任务",
"planUpdate": null,
"plan": [],
"actions": [],
"response": response.into(),
})
.to_string()
}
pub(crate) fn native_anthropic_tool_plan_response(
call_id: &str,
function_name: &str,
arguments: &str,
) -> String {
let events = vec![
serde_json::json!({
"type": "message_start",
"message": { "usage": { "input_tokens": 11, "output_tokens": 22 } }
}),
serde_json::json!({
"type": "content_block_start",
"index": 0,
"content_block": {
"type": "tool_use",
"id": call_id,
"name": function_name,
"input": {}
}
}),
serde_json::json!({
"type": "content_block_delta",
"index": 0,
"delta": { "type": "input_json_delta", "partial_json": arguments }
}),
serde_json::json!({ "type": "content_block_stop", "index": 0 }),
serde_json::json!({ "type": "message_delta", "delta": { "stop_reason": "tool_use" } }),
serde_json::json!({ "type": "message_stop" }),
];
events
.iter()
.map(|event| format!("data: {event}\n\n"))
.collect()
}
pub(crate) fn native_anthropic_text_stream_response(text: &str) -> String {
let events = vec![
serde_json::json!({
"type": "message_start",
"message": { "usage": { "input_tokens": 11, "output_tokens": 0 } }
}),
serde_json::json!({
"type": "content_block_start",
"index": 0,
"content_block": { "type": "text", "text": "" }
}),
serde_json::json!({
"type": "content_block_delta",
"index": 0,
"delta": { "type": "text_delta", "text": text }
}),
serde_json::json!({
"type": "content_block_stop",
"index": 0
}),
serde_json::json!({
"type": "message_delta",
"delta": { "stop_reason": "end_turn" },
"usage": { "output_tokens": 22 }
}),
serde_json::json!({ "type": "message_stop" }),
];
events
.iter()
.map(|event| format!("data: {event}\n\n"))
.collect()
}
fn user_input_tool_plan_response(question: &str) -> String {
serde_json::json!({
"thinkingSummary": "实现路径取决于用户选择,需要先暂停并澄清",
"planUpdate": null,
"plan": [],
"actions": [{
"tool": GAME_CREATOR_USER_INPUT_REQUEST_TOOL,
"reason": "确认首版目标平台",
"input": {
"questions": [{
"id": "target_platform",
"header": "目标平台",
"question": question,
"options": [
{
"label": "Web",
"description": "先交付浏览器可运行版本。"
},
{
"label": "桌面端",
"description": "先交付桌面客户端版本。"
}
]
}]
}
}],
"response": ""
})
.to_string()
}
fn mock_http_request_total_bytes(request: &[u8]) -> Option<usize> {
let header_end = request
.windows(4)
.position(|window| window == b"\r\n\r\n")?;
let headers = String::from_utf8_lossy(&request[..header_end]);
let content_length = headers
.lines()
.filter_map(|line| line.split_once(':'))
.find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
.and_then(|(_, value)| value.trim().parse::<usize>().ok())
.unwrap_or_default();
Some(header_end + 4 + content_length)
}
fn read_mock_http_request(stream: &mut std::net::TcpStream) -> String {
const MAX_REQUEST_BYTES: usize = 1024 * 1024;
let mut request = Vec::new();
loop {
let mut chunk = [0_u8; 8192];
let read = stream.read(&mut chunk).expect("mock llm request read");
if read == 0 {
break;
}
request.extend_from_slice(&chunk[..read]);
assert!(
request.len() <= MAX_REQUEST_BYTES,
"mock llm request exceeds test limit"
);
if mock_http_request_total_bytes(&request).is_some_and(|expected| request.len() >= expected)
{
break;
}
}
String::from_utf8_lossy(&request).into_owned()
}
fn mock_http_request_json(request: &str) -> Value {
let (_, body) = request
.split_once("\r\n\r\n")
.expect("mock llm request body");
serde_json::from_str(body).expect("mock llm request json")
}
fn spawn_mock_llm_server_responses_with_capture(
response_contents: Vec<String>,
request_sender: Option<mpsc::Sender<String>>,
) -> String {
let listener = bind_test_tcp_listener("mock llm bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
std::thread::spawn(move || {
for response_content in response_contents {
let (mut stream, _) = listener.accept().expect("mock llm accept");
let request_text = read_mock_http_request(&mut stream);
if let Some(sender) = request_sender.as_ref() {
let _ = sender.send(request_text.clone());
}
let is_responses = request_text.contains("POST /responses HTTP/1.1");
let is_stream = request_text.contains("\"stream\":true");
let (body, content_type) = if is_stream && is_responses {
(
format!(
"data: {}\n\ndata: {}\n\n",
serde_json::json!({
"type": "response.output_text.delta",
"delta": response_content
}),
serde_json::json!({ "type": "response.completed" })
),
"text/event-stream; charset=utf-8",
)
} else if is_stream {
(
format!(
"data: {}\n\ndata: [DONE]\n\n",
serde_json::json!({
"id": "chatcmpl_game_creator_mock",
"object": "chat.completion.chunk",
"choices": [{
"index": 0,
"delta": { "role": "assistant", "content": response_content },
"finish_reason": "stop"
}]
})
),
"text/event-stream; charset=utf-8",
)
} else if is_responses {
(
serde_json::json!({
"id": "resp_game_creator_mock",
"model": "mock-game-model",
"output_text": response_content,
"status": "completed",
"usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 }
})
.to_string(),
"application/json",
)
} else {
(
serde_json::json!({
"id": "chatcmpl_game_creator_mock",
"model": "mock-game-model",
"choices": [{
"message": { "content": response_content },
"finish_reason": "stop"
}],
"usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 }
})
.to_string(),
"application/json",
)
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("mock llm response");
}
});
base_url
}
fn spawn_mock_llm_scripted_responses_with_capture(
response_contents: Vec<Option<String>>,
request_sender: mpsc::Sender<String>,
) -> String {
let listener = bind_test_tcp_listener("mock scripted llm bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
std::thread::spawn(move || {
for response_content in response_contents {
let (mut stream, _) = listener.accept().expect("mock scripted llm accept");
let request_text = read_mock_http_request(&mut stream);
let _ = request_sender.send(request_text.clone());
let Some(response_content) = response_content else {
drop(stream);
continue;
};
let body = if request_text.contains("POST /responses HTTP/1.1") {
serde_json::json!({
"id": "resp_game_creator_scripted_mock",
"model": "mock-game-model",
"output_text": response_content,
"status": "completed",
"usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 }
})
} else {
serde_json::json!({
"id": "chatcmpl_game_creator_scripted_mock",
"model": "mock-game-model",
"choices": [{
"message": { "content": response_content },
"finish_reason": "stop"
}],
"usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 }
})
}
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("mock scripted llm response");
}
});
base_url
}
fn spawn_interactive_mock_llm_server_with_capture(
response_count: usize,
request_sender: mpsc::Sender<String>,
response_receiver: mpsc::Receiver<String>,
) -> String {
let listener = bind_test_tcp_listener("interactive mock llm bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
std::thread::spawn(move || {
for _ in 0..response_count {
let (mut stream, _) = listener.accept().expect("interactive mock llm accept");
let request_text = read_mock_http_request(&mut stream);
request_sender
.send(request_text.clone())
.expect("capture interactive mock llm request");
let response_content = response_receiver
.recv_timeout(Duration::from_secs(10))
.expect("interactive mock llm response content");
let body = if request_text.contains("POST /responses HTTP/1.1") {
serde_json::json!({
"id": "resp_game_creator_interactive_mock",
"model": "mock-game-model",
"output_text": response_content,
"status": "completed",
"usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 }
})
} else {
serde_json::json!({
"id": "chatcmpl_game_creator_interactive_mock",
"model": "mock-game-model",
"choices": [{
"message": { "content": response_content },
"finish_reason": "stop"
}],
"usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 }
})
}
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("interactive mock llm response");
}
});
base_url
}
fn spawn_interruptible_mock_llm_server_with_capture(
response_count: usize,
request_sender: mpsc::Sender<String>,
response_receiver: mpsc::Receiver<String>,
) -> String {
let listener = bind_test_tcp_listener("interruptible mock llm bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
std::thread::spawn(move || {
for _ in 0..response_count {
let (mut stream, _) = listener.accept().expect("interruptible mock llm accept");
let request_text = read_mock_http_request(&mut stream);
request_sender
.send(request_text)
.expect("capture interruptible mock llm request");
let response_content = response_receiver
.recv_timeout(Duration::from_secs(10))
.expect("interruptible mock llm response content");
let body = serde_json::json!({
"id": "resp_game_creator_interruptible_mock",
"model": "mock-game-model",
"output_text": response_content,
"status": "completed",
"usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 }
})
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(response.as_bytes());
}
});
base_url
}
fn spawn_mock_llm_transport_failures_then_response(
failure_count: usize,
response_content: String,
request_notice_sender: Option<mpsc::Sender<()>>,
) -> String {
spawn_mock_llm_transport_failures_then_responses(
failure_count,
vec![response_content],
request_notice_sender,
)
}
fn spawn_mock_llm_transport_failures_then_responses(
failure_count: usize,
response_contents: Vec<String>,
request_notice_sender: Option<mpsc::Sender<()>>,
) -> String {
spawn_mock_llm_transport_failures_then_responses_with_capture(
failure_count,
response_contents,
request_notice_sender,
None,
)
}
fn spawn_mock_llm_transport_failures_then_responses_with_capture(
failure_count: usize,
response_contents: Vec<String>,
request_notice_sender: Option<mpsc::Sender<()>>,
request_capture_sender: Option<mpsc::Sender<String>>,
) -> String {
let listener = bind_test_tcp_listener("mock transient llm bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
std::thread::spawn(move || {
for _ in 0..failure_count {
let (mut stream, _) = listener.accept().expect("mock transient llm accept");
let request = read_mock_http_request(&mut stream);
if let Some(sender) = request_capture_sender.as_ref() {
let _ = sender.send(request);
}
if let Some(sender) = request_notice_sender.as_ref() {
let _ = sender.send(());
}
drop(stream);
}
for response_content in response_contents {
let (mut stream, _) = listener.accept().expect("mock recovered llm accept");
let request = read_mock_http_request(&mut stream);
if let Some(sender) = request_capture_sender.as_ref() {
let _ = sender.send(request);
}
if let Some(sender) = request_notice_sender.as_ref() {
let _ = sender.send(());
}
let body = serde_json::json!({
"id": "chatcmpl_transient_recovered",
"model": "mock-game-model",
"choices": [{
"message": { "content": response_content },
"finish_reason": "stop"
}],
"usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 }
})
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("mock recovered llm response");
}
});
base_url
}
fn spawn_mock_llm_non_transient_provider_error(
request_notice_sender: Option<mpsc::Sender<()>>,
) -> String {
let listener = bind_test_tcp_listener("mock provider error bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("mock provider error accept");
drop(read_mock_http_request(&mut stream));
if let Some(sender) = request_notice_sender.as_ref() {
let _ = sender.send(());
}
let body = serde_json::json!({
"error": {
"message": "mock invalid request",
"type": "invalid_request_error"
}
})
.to_string();
let response = format!(
"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("mock provider error response");
});
base_url
}
fn spawn_mock_llm_upstream_400_then_raw_response(
response_body: serde_json::Value,
request_notice_sender: Option<mpsc::Sender<()>>,
) -> String {
let listener = bind_test_tcp_listener("mock autonomous upstream 400 retry bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
std::thread::spawn(move || {
let (mut failed_stream, _) = listener
.accept()
.expect("mock autonomous upstream 400 accept");
drop(read_mock_http_request(&mut failed_stream));
if let Some(sender) = request_notice_sender.as_ref() {
let _ = sender.send(());
}
let failed_body = serde_json::json!({
"error": {
"message": "mock transient autonomous invalid request",
"type": "invalid_request_error"
}
})
.to_string();
let failed_response = format!(
"HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
failed_body.len(),
failed_body
);
failed_stream
.write_all(failed_response.as_bytes())
.expect("mock autonomous upstream 400 response");
let (mut recovered_stream, _) = listener
.accept()
.expect("mock autonomous upstream 400 recovery accept");
drop(read_mock_http_request(&mut recovered_stream));
if let Some(sender) = request_notice_sender.as_ref() {
let _ = sender.send(());
}
let recovered_body = response_body.to_string();
let recovered_response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
recovered_body.len(),
recovered_body
);
recovered_stream
.write_all(recovered_response.as_bytes())
.expect("mock autonomous upstream 400 recovery response");
});
base_url
}
fn spawn_mock_llm_http_failure_then_response(
failed_status_line: &'static str,
failed_body: String,
recovered_content: String,
request_notice_sender: Option<mpsc::Sender<()>>,
) -> String {
let listener = bind_test_tcp_listener("mock durable Provider classification bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
std::thread::spawn(move || {
let (mut failed_stream, _) = listener
.accept()
.expect("mock durable Provider failure accept");
drop(read_mock_http_request(&mut failed_stream));
if let Some(sender) = request_notice_sender.as_ref() {
let _ = sender.send(());
}
let failed_response = format!(
"HTTP/1.1 {failed_status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
failed_body.len(),
failed_body
);
failed_stream
.write_all(failed_response.as_bytes())
.expect("mock durable Provider failure response");
let (mut recovered_stream, _) = listener
.accept()
.expect("mock durable Provider recovery accept");
drop(read_mock_http_request(&mut recovered_stream));
if let Some(sender) = request_notice_sender.as_ref() {
let _ = sender.send(());
}
let recovered_body = serde_json::json!({
"id": "chatcmpl_durable_provider_recovered",
"model": "mock-game-model",
"choices": [{
"message": { "content": recovered_content },
"finish_reason": "stop"
}],
"usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 }
})
.to_string();
let recovered_response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
recovered_body.len(),
recovered_body
);
recovered_stream
.write_all(recovered_response.as_bytes())
.expect("mock durable Provider recovery response");
});
base_url
}
fn spawn_mock_llm_tool_plan_then_transient_final_reply(
planning_response: String,
final_response: String,
request_notice_sender: mpsc::Sender<()>,
request_capture_sender: mpsc::Sender<(u64, String)>,
) -> (String, std::thread::JoinHandle<()>) {
let listener = bind_test_tcp_listener("mock transient final reply bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
let handle = std::thread::spawn(move || {
let (mut planning_stream, _) = listener.accept().expect("mock tool plan accept");
let planning_accepted_at_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("planning accept time")
.as_millis()
.try_into()
.unwrap_or(u64::MAX);
let planning_request = read_mock_http_request(&mut planning_stream);
let _ = request_capture_sender.send((planning_accepted_at_ms, planning_request.clone()));
let _ = request_notice_sender.send(());
let planning_body = if planning_request.contains("\"stream\":true") {
format!(
"data: {}\n\ndata: {}\n\n",
serde_json::json!({
"type": "response.output_text.delta",
"delta": planning_response
}),
serde_json::json!({ "type": "response.completed" })
)
} else {
serde_json::json!({
"id": "resp_transient_final_reply_planning",
"model": "mock-game-model",
"output_text": planning_response,
"status": "completed",
"usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 }
})
.to_string()
};
let planning_content_type = if planning_request.contains("\"stream\":true") {
"text/event-stream; charset=utf-8"
} else {
"application/json"
};
let planning_http_response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: {planning_content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
planning_body.len(),
planning_body
);
planning_stream
.write_all(planning_http_response.as_bytes())
.expect("mock tool plan response");
let (mut failed_final_stream, _) =
listener.accept().expect("mock failed final reply accept");
let failed_final_accepted_at_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("failed final accept time")
.as_millis()
.try_into()
.unwrap_or(u64::MAX);
let failed_final_request = read_mock_http_request(&mut failed_final_stream);
let _ = request_capture_sender.send((failed_final_accepted_at_ms, failed_final_request));
let _ = request_notice_sender.send(());
drop(failed_final_stream);
let (mut recovered_final_stream, _) = listener
.accept()
.expect("mock recovered final reply accept");
let recovered_final_accepted_at_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("recovered final accept time")
.as_millis()
.try_into()
.unwrap_or(u64::MAX);
let recovered_final_request = read_mock_http_request(&mut recovered_final_stream);
let _ =
request_capture_sender.send((recovered_final_accepted_at_ms, recovered_final_request));
let _ = request_notice_sender.send(());
let response_events = format!(
"data: {}\n\ndata: {}\n\n",
serde_json::json!({
"type": "response.output_text.delta",
"delta": final_response
}),
serde_json::json!({ "type": "response.completed" })
);
let final_http_headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nContent-Length: {}\r\nx-request-id: req_transient_final_reply\r\nConnection: close\r\n\r\n",
response_events.len()
);
recovered_final_stream
.write_all(final_http_headers.as_bytes())
.expect("mock recovered final reply headers");
recovered_final_stream
.write_all(response_events.as_bytes())
.expect("mock recovered final reply events");
});
(base_url, handle)
}
#[allow(clippy::too_many_arguments)]
fn spawn_mock_llm_tool_plan_then_transient_final_compaction(
planning_response: String,
compaction_response: String,
final_response: String,
config_path: PathBuf,
request_notice_sender: mpsc::Sender<()>,
request_capture_sender: mpsc::Sender<(u64, String)>,
) -> (String, std::thread::JoinHandle<()>) {
let listener = bind_test_tcp_listener("mock transient final compaction bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
let recovered_config_base_url = base_url.clone();
let handle = std::thread::spawn(move || {
let accepted_at_ms = || {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Provider request accept time")
.as_millis()
.try_into()
.unwrap_or(u64::MAX)
};
let (mut planning_stream, _) = listener.accept().expect("mock tool plan accept");
let planning_accepted_at_ms = accepted_at_ms();
let planning_request = read_mock_http_request(&mut planning_stream);
let _ = request_capture_sender.send((planning_accepted_at_ms, planning_request.clone()));
replace_test_local_config(
&config_path,
format!(
r#"{{
"agentMode": "provider",
"agentLlm": {{
"design-director": {{
"apiKey": "final-compaction-key",
"baseUrl": {recovered_config_base_url:?},
"model": "final-compaction-model",
"apiKind": "openai_responses",
"stream": true,
"contextWindowTokens": 128000,
"autoCompactTokenLimit": 8000,
"toolOutputTokenLimit": 4000,
"maxRetries": 1,
"retryBackoffMs": 1000
}}
}}
}}"#
),
);
let _ = request_notice_sender.send(());
let planning_body = if planning_request.contains("\"stream\":true") {
format!(
"data: {}\n\ndata: {}\n\n",
serde_json::json!({
"type": "response.output_text.delta",
"delta": planning_response
}),
serde_json::json!({ "type": "response.completed" })
)
} else {
serde_json::json!({
"id": "resp_final_compaction_planning",
"model": "mock-game-model",
"output_text": planning_response,
"status": "completed",
"usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 }
})
.to_string()
};
let planning_content_type = if planning_request.contains("\"stream\":true") {
"text/event-stream; charset=utf-8"
} else {
"application/json"
};
let planning_http_response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: {planning_content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
planning_body.len(),
planning_body
);
planning_stream
.write_all(planning_http_response.as_bytes())
.expect("mock final compaction planning response");
let (mut failed_compaction_stream, _) = listener
.accept()
.expect("mock failed final compaction accept");
let failed_compaction_accepted_at_ms = accepted_at_ms();
let failed_compaction_request = read_mock_http_request(&mut failed_compaction_stream);
let _ = request_capture_sender
.send((failed_compaction_accepted_at_ms, failed_compaction_request));
let _ = request_notice_sender.send(());
drop(failed_compaction_stream);
let (mut recovered_compaction_stream, _) = listener
.accept()
.expect("mock recovered final compaction accept");
let recovered_compaction_accepted_at_ms = accepted_at_ms();
let recovered_compaction_request = read_mock_http_request(&mut recovered_compaction_stream);
let _ = request_capture_sender.send((
recovered_compaction_accepted_at_ms,
recovered_compaction_request.clone(),
));
let _ = request_notice_sender.send(());
let compaction_body = if recovered_compaction_request.contains("\"stream\":true") {
format!(
"data: {}\n\ndata: {}\n\n",
serde_json::json!({
"type": "response.output_text.delta",
"delta": compaction_response
}),
serde_json::json!({ "type": "response.completed" })
)
} else {
serde_json::json!({
"id": "resp_final_compaction_recovered",
"model": "mock-game-model",
"output_text": compaction_response,
"status": "completed",
"usage": { "input_tokens": 44, "output_tokens": 12, "total_tokens": 56 }
})
.to_string()
};
let compaction_content_type = if recovered_compaction_request.contains("\"stream\":true") {
"text/event-stream; charset=utf-8"
} else {
"application/json"
};
let compaction_http_response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: {compaction_content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
compaction_body.len(),
compaction_body
);
recovered_compaction_stream
.write_all(compaction_http_response.as_bytes())
.expect("mock recovered final compaction response");
let (mut final_stream, _) = listener.accept().expect("mock final reply accept");
let final_accepted_at_ms = accepted_at_ms();
let final_request = read_mock_http_request(&mut final_stream);
let _ = request_capture_sender.send((final_accepted_at_ms, final_request));
let _ = request_notice_sender.send(());
let response_events = format!(
"data: {}\n\ndata: {}\n\n",
serde_json::json!({
"type": "response.output_text.delta",
"delta": final_response
}),
serde_json::json!({ "type": "response.completed" })
);
let final_http_headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nContent-Length: {}\r\nx-request-id: req_final_compaction_reply\r\nConnection: close\r\n\r\n",
response_events.len()
);
final_stream
.write_all(final_http_headers.as_bytes())
.expect("mock final compaction reply headers");
final_stream
.write_all(response_events.as_bytes())
.expect("mock final compaction reply events");
});
(base_url, handle)
}
fn spawn_mock_llm_raw_responses_with_capture(
response_bodies: Vec<serde_json::Value>,
request_sender: Option<mpsc::Sender<String>>,
) -> String {
spawn_mock_llm_raw_responses_with_content_type_with_capture(
response_bodies
.into_iter()
.map(|body| body.to_string())
.collect(),
request_sender,
"application/json",
)
}
fn spawn_mock_llm_stream_responses_with_capture(
response_bodies: Vec<String>,
request_sender: Option<mpsc::Sender<String>>,
) -> String {
spawn_mock_llm_raw_responses_with_content_type_with_capture(
response_bodies,
request_sender,
"text/event-stream; charset=utf-8",
)
}
fn spawn_mock_llm_raw_responses_with_content_type_with_capture(
response_bodies: Vec<String>,
request_sender: Option<mpsc::Sender<String>>,
content_type: &str,
) -> String {
let listener = bind_test_tcp_listener("mock raw llm bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
let content_type = content_type.to_string();
std::thread::spawn(move || {
for response_body in response_bodies {
let (mut stream, _) = listener.accept().expect("mock raw llm accept");
let request_text = read_mock_http_request(&mut stream);
if let Some(sender) = request_sender.as_ref() {
let _ = sender.send(request_text);
}
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
content_type,
response_body.len(),
response_body
);
stream
.write_all(response.as_bytes())
.expect("mock raw llm response");
}
});
base_url
}
fn native_agent_tool_plan_chat_response(
call_id: &str,
function_name: &str,
arguments: impl Into<String>,
) -> serde_json::Value {
native_agent_tool_plan_chat_response_with_calls(vec![(
call_id,
function_name,
arguments.into(),
)])
}
fn native_agent_tool_plan_chat_response_with_calls(
calls: Vec<(&str, &str, String)>,
) -> serde_json::Value {
let tool_calls = calls
.into_iter()
.map(|(call_id, function_name, arguments)| {
serde_json::json!({
"id": call_id,
"type": "function",
"function": {
"name": function_name,
"arguments": arguments
}
})
})
.collect::<Vec<_>>();
serde_json::json!({
"id": "chatcmpl-native-tool-plan",
"model": "mock-game-model",
"choices": [{
"message": {
"content": null,
"tool_calls": tool_calls
},
"finish_reason": "tool_calls"
}],
"usage": {
"prompt_tokens": 11,
"completion_tokens": 22,
"total_tokens": 33
}
})
}
fn spawn_releasable_mock_llm_raw_response_with_capture(
response_body: serde_json::Value,
request_sender: mpsc::Sender<String>,
release_receiver: mpsc::Receiver<()>,
) -> String {
let listener = bind_test_tcp_listener("mock releasable raw llm bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("mock releasable raw llm accept");
let request_text = read_mock_http_request(&mut stream);
let _ = request_sender.send(request_text);
release_receiver
.recv_timeout(Duration::from_secs(10))
.expect("mock raw llm response release");
let body = response_body.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("mock releasable raw llm response");
});
base_url
}
fn spawn_releasable_mock_llm_server_responses_with_capture(
response_contents: Vec<String>,
request_sender: mpsc::Sender<String>,
first_release_receiver: mpsc::Receiver<()>,
) -> String {
spawn_releasable_mock_llm_server_responses_with_capture_at(
response_contents,
request_sender,
0,
first_release_receiver,
)
}
fn spawn_releasable_mock_llm_server_responses_with_capture_at(
response_contents: Vec<String>,
request_sender: mpsc::Sender<String>,
release_index: usize,
release_receiver: mpsc::Receiver<()>,
) -> String {
let listener = bind_test_tcp_listener("mock llm bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
std::thread::spawn(move || {
for (index, response_content) in response_contents.into_iter().enumerate() {
let (mut stream, _) = listener.accept().expect("mock llm accept");
let _ = request_sender.send(read_mock_http_request(&mut stream));
if index == release_index {
release_receiver
.recv_timeout(Duration::from_secs(10))
.expect("mock llm response release");
}
let body = serde_json::json!({
"id": "resp_game_creator_mock",
"model": "mock-game-model",
"output_text": response_content,
"status": "completed",
"usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 }
})
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("mock llm response");
}
});
base_url
}
fn spawn_mock_llm_stream_server_with_capture(
response_body: String,
request_sender: Option<mpsc::Sender<String>>,
) -> String {
let listener = bind_test_tcp_listener("mock stream llm bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("mock stream llm accept");
if let Some(sender) = request_sender.as_ref() {
let _ = sender.send(read_mock_http_request(&mut stream));
} else {
let _ = read_mock_http_request(&mut stream);
}
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nContent-Length: {}\r\nx-request-id: req_role_agent_stream\r\nConnection: close\r\n\r\n{}",
response_body.len(),
response_body
);
stream
.write_all(response.as_bytes())
.expect("mock stream llm response");
});
base_url
}
enum ResponseStreamMockFinalResponse {
Deltas(String, String),
Disconnect,
}
enum ResponseStreamMockPlanningResponse {
NonStream(String),
Stream(String),
}
struct ResponseStreamMockServer {
base_url: String,
first_delta_written: mpsc::Receiver<()>,
release_second_delta: mpsc::Sender<()>,
stop: mpsc::Sender<()>,
handle: std::thread::JoinHandle<Vec<String>>,
}
impl ResponseStreamMockServer {
fn stop_and_collect(self) -> Vec<String> {
let _ = self.stop.send(());
self.handle
.join()
.expect("response stream mock server join")
}
}
fn spawn_response_stream_mock_llm_server(
api_kind: &str,
planning_response: ResponseStreamMockPlanningResponse,
final_response: Option<ResponseStreamMockFinalResponse>,
) -> ResponseStreamMockServer {
let listener = bind_test_tcp_listener("response stream mock bind");
let base_url = format!(
"http://{}",
listener.local_addr().expect("response stream mock addr")
);
let api_kind = api_kind.to_string();
let (first_delta_sender, first_delta_written) = mpsc::channel();
let (release_second_delta, release_second_delta_receiver) = mpsc::channel();
let (stop, stop_receiver) = mpsc::channel();
let handle = std::thread::spawn(move || {
let mut requests = Vec::new();
let planning_http_response = match planning_response {
ResponseStreamMockPlanningResponse::NonStream(planning_response) => {
let planning_body = match api_kind.as_str() {
"openai_responses" => serde_json::json!({
"id": "resp_response_stream_planning",
"model": "response-stream-model",
"output_text": planning_response,
"status": "completed",
"usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 }
}),
"openai_chat" => serde_json::json!({
"id": "chatcmpl_response_stream_planning",
"model": "response-stream-model",
"choices": [{
"message": { "content": planning_response },
"finish_reason": "stop"
}],
"usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 }
}),
other => panic!("unsupported response stream mock api kind: {other}"),
}
.to_string();
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
planning_body.len(),
planning_body
)
}
ResponseStreamMockPlanningResponse::Stream(planning_response) => {
let planning_body = match api_kind.as_str() {
"openai_responses" => format!(
"data: {}\n\ndata: {}\n\n",
serde_json::json!({
"type": "response.output_text.delta",
"delta": planning_response
}),
serde_json::json!({ "type": "response.completed" })
),
"openai_chat" => format!(
"data: {}\n\ndata: {}\n\ndata: [DONE]\n\n",
serde_json::json!({
"choices": [{ "delta": { "content": planning_response } }]
}),
serde_json::json!({
"choices": [{ "finish_reason": "stop" }]
})
),
other => panic!("unsupported response stream mock api kind: {other}"),
};
format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
planning_body.len(),
planning_body
)
}
};
{
let (mut stream, _) = listener
.accept()
.expect("response stream planning request accept");
let planning_request = read_mock_http_request(&mut stream);
requests.push(planning_request);
stream
.write_all(planning_http_response.as_bytes())
.expect("response stream planning response");
}
if let Some(final_response) = final_response {
listener
.set_nonblocking(true)
.expect("response stream mock listener nonblocking");
let accept_deadline = std::time::Instant::now() + Duration::from_secs(10);
let (mut final_stream, _) = loop {
match listener.accept() {
Ok(connection) => break connection,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
assert!(
std::time::Instant::now() < accept_deadline,
"response stream final request was not received"
);
std::thread::sleep(Duration::from_millis(5));
}
Err(error) => panic!("response stream final request accept failed: {error}"),
}
};
final_stream
.set_nonblocking(false)
.expect("response stream final socket blocking");
requests.push(read_mock_http_request(&mut final_stream));
let ResponseStreamMockFinalResponse::Deltas(first_delta, second_delta) = final_response
else {
first_delta_sender
.send(())
.expect("signal response stream final disconnect");
release_second_delta_receiver
.recv_timeout(Duration::from_secs(10))
.expect("release response stream final disconnect");
drop(final_stream);
return monitor_response_stream_extra_requests(&listener, stop_receiver, requests);
};
let (first_event, remaining_events) = match api_kind.as_str() {
"openai_responses" => (
format!(
"data: {}\n\n",
serde_json::json!({
"type": "response.output_text.delta",
"delta": first_delta
})
),
format!(
"data: {}\n\ndata: {}\n\n",
serde_json::json!({
"type": "response.output_text.delta",
"delta": second_delta
}),
serde_json::json!({ "type": "response.completed" })
),
),
"openai_chat" => (
format!(
"data: {}\n\n",
serde_json::json!({
"choices": [{ "delta": { "content": first_delta } }]
})
),
format!(
"data: {}\n\ndata: {}\n\ndata: [DONE]\n\n",
serde_json::json!({
"choices": [{ "delta": { "content": second_delta } }]
}),
serde_json::json!({
"choices": [{ "finish_reason": "stop" }]
})
),
),
other => panic!("unsupported response stream mock api kind: {other}"),
};
let response_body_len = first_event.len() + remaining_events.len();
let response_headers = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nContent-Length: {response_body_len}\r\nx-request-id: req_response_stream_final\r\nConnection: close\r\n\r\n"
);
final_stream
.write_all(response_headers.as_bytes())
.expect("response stream final headers");
final_stream
.write_all(first_event.as_bytes())
.expect("response stream first delta");
final_stream
.flush()
.expect("flush response stream first delta");
first_delta_sender
.send(())
.expect("signal response stream first delta");
release_second_delta_receiver
.recv_timeout(Duration::from_secs(10))
.expect("release response stream second delta");
final_stream
.write_all(remaining_events.as_bytes())
.expect("response stream remaining deltas");
final_stream
.flush()
.expect("flush response stream remaining deltas");
}
monitor_response_stream_extra_requests(&listener, stop_receiver, requests)
});
ResponseStreamMockServer {
base_url,
first_delta_written,
release_second_delta,
stop,
handle,
}
}
fn monitor_response_stream_extra_requests(
listener: &TcpListener,
stop_receiver: mpsc::Receiver<()>,
mut requests: Vec<String>,
) -> Vec<String> {
listener
.set_nonblocking(true)
.expect("response stream mock listener monitor mode");
let monitor_deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let stop_requested = stop_receiver.try_recv().is_ok();
match listener.accept() {
Ok((mut unexpected_stream, _)) => {
unexpected_stream
.set_nonblocking(false)
.expect("unexpected response stream socket blocking");
requests.push(read_mock_http_request(&mut unexpected_stream));
let body = r#"{"error":"unexpected extra request"}"#;
let response = format!(
"HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = unexpected_stream.write_all(response.as_bytes());
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
if stop_requested || std::time::Instant::now() >= monitor_deadline {
break;
}
std::thread::sleep(Duration::from_millis(5));
}
Err(error) => panic!("response stream extra request monitor failed: {error}"),
}
}
requests
}
fn spawn_mock_llm_stream_fallback_server(
first_status_line: &'static str,
first_content_type: &'static str,
first_body: String,
fallback_body: String,
) -> (
String,
mpsc::Sender<()>,
std::thread::JoinHandle<Vec<String>>,
) {
let listener = bind_test_tcp_listener("mock fallback llm bind");
let base_url = format!(
"http://{}",
listener.local_addr().expect("mock fallback llm addr")
);
let (stop_sender, stop_receiver) = mpsc::channel();
let handle = std::thread::spawn(move || {
let mut requests = Vec::new();
let (mut first_stream, _) = listener.accept().expect("mock fallback first accept");
requests.push(read_mock_http_request(&mut first_stream));
let first_response = format!(
"HTTP/1.1 {first_status_line}\r\nContent-Type: {first_content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
first_body.len(),
first_body
);
first_stream
.write_all(first_response.as_bytes())
.expect("mock fallback first response");
listener
.set_nonblocking(true)
.expect("mock fallback listener nonblocking");
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
if stop_receiver.try_recv().is_ok() {
break;
}
match listener.accept() {
Ok((mut fallback_stream, _)) => {
fallback_stream
.set_nonblocking(false)
.expect("mock fallback stream blocking");
requests.push(read_mock_http_request(&mut fallback_stream));
let fallback_response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
fallback_body.len(),
fallback_body
);
fallback_stream
.write_all(fallback_response.as_bytes())
.expect("mock fallback response");
break;
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(5));
}
Err(error) => panic!("mock fallback accept failed: {error}"),
}
}
requests
});
(base_url, stop_sender, handle)
}
fn spawn_barrier_mock_llm_server(
response_content: String,
barrier: Arc<(StdMutex<usize>, Condvar)>,
expected_requests: usize,
request_sender: mpsc::Sender<String>,
) -> String {
let listener = bind_test_tcp_listener("mock llm bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("mock llm accept");
let _ = request_sender.send(read_mock_http_request(&mut stream));
let (lock, cvar) = &*barrier;
let mut count = lock.lock().expect("barrier lock");
*count += 1;
cvar.notify_all();
while *count < expected_requests {
let wait_result = cvar
.wait_timeout(count, Duration::from_secs(2))
.expect("barrier wait");
count = wait_result.0;
if wait_result.1.timed_out() && *count < expected_requests {
let body = r#"{"error":"parallel barrier timeout"}"#;
let response = format!(
"HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("mock llm timeout response");
return;
}
}
drop(count);
let body = serde_json::json!({
"id": "resp_game_creator_mock",
"model": "mock-game-model",
"output_text": response_content,
"status": "completed",
"usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 }
})
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("mock llm response");
});
base_url
}
fn spawn_mock_external_canvas_api_server_with_capture(
expected_requests: usize,
request_sender: Option<mpsc::Sender<String>>,
) -> String {
spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
expected_requests,
request_sender,
None,
)
}
fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
expected_requests: usize,
request_sender: Option<mpsc::Sender<String>>,
generation_response_gate: Option<mpsc::Receiver<()>>,
) -> String {
let listener = bind_test_tcp_listener("mock canvas api bind");
let base_url = format!(
"http://{}",
listener.local_addr().expect("mock canvas api addr")
);
let signed_url = format!("{base_url}/signed/hero.png");
let spritesheet_signed_url = format!("{base_url}/signed/spritesheet.png");
let slice_signed_urls = (0..4)
.map(|index| format!("{base_url}/signed/spritesheet-slice-{index}.png"))
.collect::<Vec<_>>();
let projects_body = serde_json::json!({
"data": {
"projects": [
{ "projectId": "canvas-project-1", "title": "月光厨房" },
{ "projectId": "canvas-project-1", "title": "未命名游戏原型" }
]
}
})
.to_string();
let library_body = serde_json::json!({
"data": {
"library": {
"folders": [
{ "folderId": "folder-1", "label": "月光厨房" },
{ "folderId": "folder-1", "label": "未命名游戏原型" }
]
}
}
})
.to_string();
let project_body = serde_json::json!({
"project": {
"projectId": "canvas-project-1",
"title": "月光画板",
"resources": [
{
"resourceId": "resource-1",
"projectId": "canvas-project-1",
"imageSrc": "/generated/canvas/hero.png",
"objectKey": "generated/canvas/hero.png",
"assetObjectId": "asset-object-1",
"width": 64,
"height": 64,
"sourceType": "generated",
"prompt": "像素月光主角",
"actualPrompt": "透明 PNG 像素月光主角",
"model": "gpt-image-2",
"provider": "vector-engine",
"taskId": "task-1",
"assetKind": "character"
}
],
"updatedAt": "2026-06-25T00:00:00Z"
}
})
.to_string();
let generation_body = serde_json::json!({
"imageSrc": "/generated/canvas/hero.png",
"objectKey": "generated/canvas/hero.png",
"assetObjectId": "asset-object-1",
"width": 64,
"height": 64,
"sourceType": "generated",
"prompt": "像素月光主角",
"actualPrompt": "透明 PNG 像素月光主角",
"model": "gpt-image-2",
"provider": "VectorEngine",
"taskId": "task-1",
"resource": {
"resourceId": "resource-1",
"projectId": "canvas-project-1",
"imageSrc": "/generated/canvas/hero.png",
"objectKey": "generated/canvas/hero.png",
"assetObjectId": "asset-object-1",
"width": 64,
"height": 64,
"sourceType": "generated",
"prompt": "像素月光主角",
"actualPrompt": "透明 PNG 像素月光主角",
"model": "gpt-image-2",
"provider": "VectorEngine",
"taskId": "task-1",
"assetKind": "character"
},
"asset": {
"assetId": "asset-1",
"assetObjectId": "asset-object-1",
"assetKind": "character"
}
})
.to_string();
let icon_image_srcs = ["玩家主体", "目标物", "场景障碍", "反馈特效"]
.into_iter()
.enumerate()
.map(|(index, name)| {
serde_json::json!({
"name": name,
"imageSrc": format!("/generated/canvas/spritesheet-slice-{index}.png"),
"width": 2,
"height": 1,
"resource": {
"resourceId": format!("slice-resource-{index}"),
"projectId": "canvas-project-1",
"imageSrc": format!("/generated/canvas/spritesheet-slice-{index}.png"),
"objectKey": format!("generated/canvas/spritesheet-slice-{index}.png"),
"assetObjectId": format!("slice-asset-object-{index}"),
"width": 2,
"height": 1,
"sourceType": "generated",
"taskId": "task-1",
"sourceResourceId": "resource-1",
"assetKind": "icon-spritesheet-slice"
},
"asset": {
"assetId": format!("slice-asset-{index}"),
"assetObjectId": format!("slice-asset-object-{index}"),
"assetKind": "icon-spritesheet-slice"
}
})
})
.collect::<Vec<_>>();
let icon_spritesheet_body = serde_json::json!({
"spritesheetImageSrc": "/generated/canvas/spritesheet.png",
"spritesheetWidth": 2,
"spritesheetHeight": 1,
"sliceMode": "grid",
"gridX": 2,
"gridY": 2,
"iconImageSrcs": icon_image_srcs,
"sliceWarning": null,
"prompt": "原创游戏素材图集",
"actualPrompt": "纯色抠像背景的原创游戏素材图集",
"model": "gpt-image-2",
"provider": "VectorEngine",
"taskId": "task-1",
"priceMudPoints": 3,
"spritesheetResource": {
"resourceId": "resource-1",
"projectId": "canvas-project-1",
"imageSrc": "/generated/canvas/spritesheet.png",
"objectKey": "generated/canvas/spritesheet.png",
"assetObjectId": "asset-object-1",
"width": 2,
"height": 1,
"sourceType": "generated",
"prompt": "原创游戏素材图集",
"actualPrompt": "纯色抠像背景的原创游戏素材图集",
"model": "gpt-image-2",
"provider": "VectorEngine",
"taskId": "task-1",
"assetKind": "icon-spritesheet"
},
"spritesheetAsset": {
"assetId": "asset-1",
"assetObjectId": "asset-object-1",
"assetKind": "icon-spritesheet"
}
})
.to_string();
let generation_accepted_body = serde_json::json!({
"data": {
"operationId": "task-external-fixture-1",
"kind": "editor_image_generation",
"status": "queued",
"statusUrl": "/api/external/v1/generations/task-external-fixture-1",
"pollAfterMs": 1,
"updatedAtMicros": 1
}
})
.to_string();
let read_body = serde_json::json!({
"read": {
"provider": "aliyun-oss",
"bucket": "mock",
"endpoint": "mock",
"host": "mock",
"objectKey": "generated/canvas/hero.png",
"expiresAt": "2026-06-25T00:10:00Z",
"signedUrl": signed_url
}
})
.to_string();
let spritesheet_read_body = serde_json::json!({
"read": {
"provider": "aliyun-oss",
"bucket": "mock",
"endpoint": "mock",
"host": "mock",
"objectKey": "generated/canvas/spritesheet.png",
"expiresAt": "2026-06-25T00:10:00Z",
"signedUrl": spritesheet_signed_url
}
})
.to_string();
let slice_read_bodies = slice_signed_urls
.iter()
.enumerate()
.map(|(index, signed_url)| {
serde_json::json!({
"read": {
"provider": "aliyun-oss",
"bucket": "mock",
"endpoint": "mock",
"host": "mock",
"objectKey": format!("generated/canvas/spritesheet-slice-{index}.png"),
"expiresAt": "2026-06-25T00:10:00Z",
"signedUrl": signed_url
}
})
.to_string()
})
.collect::<Vec<_>>();
std::thread::spawn(move || {
let mut generation_response_gate = generation_response_gate;
let mut pending_generation_result: Option<String> = None;
let mut generation_poll_index = 0_u8;
for _ in 0..expected_requests {
let (mut stream, _) = listener.accept().expect("mock canvas api accept");
let request = read_mock_http_request(&mut stream);
if let Some(sender) = request_sender.as_ref() {
let _ = sender.send(request.clone());
}
let normalized_request = request.to_ascii_lowercase();
let (status, content_type, body) = if request
.starts_with("GET /api/external/v1/editor/projects ")
|| request.starts_with("GET /api/editor/projects ")
{
assert!(normalized_request.contains("authorization: bearer "));
("200 OK", "application/json", projects_body.as_bytes().to_vec())
} else if request.starts_with("POST /api/external/v1/editor/projects ")
|| request.starts_with("POST /api/editor/projects ")
{
assert!(normalized_request.contains("authorization: bearer "));
assert!(normalized_request.contains("idempotency-key: game-creator-project-"));
(
"200 OK",
"application/json",
serde_json::json!({
"data": { "project": { "projectId": "canvas-project-1" } }
})
.to_string()
.into_bytes(),
)
} else if request.starts_with("GET /api/external/v1/editor/assets/library ")
|| request.starts_with("GET /api/editor/assets/library ")
{
assert!(normalized_request.contains("authorization: bearer "));
("200 OK", "application/json", library_body.as_bytes().to_vec())
} else if request.starts_with("POST /api/external/v1/editor/assets/folders ")
|| request.starts_with("POST /api/editor/assets/folders ")
{
assert!(normalized_request.contains("authorization: bearer "));
assert!(normalized_request.contains("idempotency-key: game-creator-folder-"));
(
"200 OK",
"application/json",
serde_json::json!({
"data": { "folder": { "folderId": "folder-1" } }
})
.to_string()
.into_bytes(),
)
} else if request.starts_with("GET /api/external/v1/editor/projects/canvas-project-1 ")
|| request.starts_with("GET /api/editor/projects/canvas-project-1 ")
{
assert!(normalized_request.contains("authorization: bearer "));
("200 OK", "application/json", project_body.as_bytes().to_vec())
} else if request.starts_with("POST /api/external/v1/editor/images/generations ")
|| request.starts_with("POST /api/editor/images/generations ")
{
assert!(normalized_request.contains("authorization: bearer "));
let idempotency_key = request
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("idempotency-key")
.then_some(value.trim())
})
.expect("generation request idempotency key");
uuid::Uuid::parse_str(idempotency_key.trim())
.expect("generation idempotency key must be UUID");
pending_generation_result = Some(generation_body.clone());
generation_poll_index = 0;
(
"202 Accepted",
"application/json",
generation_accepted_body.as_bytes().to_vec(),
)
} else if request
.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ")
|| request.starts_with("POST /api/editor/icon-spritesheets/generations ")
{
assert!(normalized_request.contains("authorization: bearer "));
let idempotency_key = request
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("idempotency-key")
.then_some(value.trim())
})
.expect("generation request idempotency key");
uuid::Uuid::parse_str(idempotency_key.trim())
.expect("generation idempotency key must be UUID");
pending_generation_result = Some(icon_spritesheet_body.clone());
generation_poll_index = 0;
(
"202 Accepted",
"application/json",
generation_accepted_body.as_bytes().to_vec(),
)
} else if request.starts_with(
"GET /api/external/v1/generations/task-external-fixture-1 ",
) || request.starts_with(
"GET /api/runtime/external-generation/jobs/task-external-fixture-1 ",
) {
assert!(normalized_request.contains("authorization: bearer "));
if let Some(gate) = generation_response_gate.take() {
gate.recv_timeout(Duration::from_secs(5))
.expect("release mock canvas generation response");
}
let status = match generation_poll_index {
0 => "queued",
1 => "running",
_ => "completed",
};
generation_poll_index = generation_poll_index.saturating_add(1);
let result = (status == "completed").then(|| {
serde_json::from_str::<serde_json::Value>(
pending_generation_result
.as_deref()
.expect("generation query follows one submission"),
)
.expect("fixture generation result JSON")
});
(
"200 OK",
"application/json",
serde_json::json!({
"data": {
"operationId": "task-external-fixture-1",
"kind": "editor_image_generation",
"status": status,
"phaseLabel": "图片画布生成图片",
"phaseDetail": if status == "completed" { "生成已完成。" } else { "正在生成。" },
"progress": if status == "completed" { 100 } else { 35 },
"result": result,
"pollAfterMs": 1,
"updatedAtMicros": 2
}
})
.to_string()
.into_bytes(),
)
} else if request.starts_with(
"GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fhero.png ",
) || request.starts_with(
"GET /api/assets/read-url?objectKey=generated%2Fcanvas%2Fhero.png ",
) {
assert!(normalized_request.contains("authorization: bearer "));
("200 OK", "application/json", read_body.as_bytes().to_vec())
} else if request.starts_with(
"GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fspritesheet.png ",
) || request.starts_with(
"GET /api/assets/read-url?objectKey=generated%2Fcanvas%2Fspritesheet.png ",
) {
assert!(normalized_request.contains("authorization: bearer "));
(
"200 OK",
"application/json",
spritesheet_read_body.as_bytes().to_vec(),
)
} else if let Some(index) = (0..4).find(|index| {
request.starts_with(&format!(
"GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fspritesheet-slice-{index}.png "
)) || request.starts_with(&format!(
"GET /api/assets/read-url?objectKey=generated%2Fcanvas%2Fspritesheet-slice-{index}.png "
))
}) {
assert!(normalized_request.contains("authorization: bearer "));
(
"200 OK",
"application/json",
slice_read_bodies[index].as_bytes().to_vec(),
)
} else if request.starts_with("GET /signed/hero.png ") {
("200 OK", "image/png", valid_test_png_bytes())
} else if request.starts_with("GET /signed/spritesheet.png ") {
("200 OK", "image/png", transparent_test_png_bytes())
} else if let Some(index) = (0..4).find(|index| {
request.starts_with(&format!("GET /signed/spritesheet-slice-{index}.png "))
}) {
(
"200 OK",
"image/png",
spritesheet_slice_test_png_bytes(index),
)
} else {
("404 Not Found", "text/plain", b"not found".to_vec())
};
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream
.write_all(response.as_bytes())
.expect("mock canvas api header");
stream.write_all(&body).expect("mock canvas api body");
}
});
base_url
}
fn spawn_mock_external_canvas_api_server() -> String {
spawn_mock_external_canvas_api_server_with_capture(3, None)
}
fn spawn_mock_external_canvas_generation_api_server(
request_sender: Option<mpsc::Sender<String>>,
) -> String {
spawn_mock_external_canvas_api_server_with_capture(20, request_sender)
}
fn spawn_mock_external_canvas_generation_api_server_with_gate(
request_sender: mpsc::Sender<String>,
generation_response_gate: mpsc::Receiver<()>,
) -> String {
spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
20,
Some(request_sender),
Some(generation_response_gate),
)
}
fn spawn_mock_external_canvas_generation_failure_server() -> String {
let listener = bind_test_tcp_listener("mock canvas api bind");
let base_url = format!(
"http://{}",
listener.local_addr().expect("mock canvas api addr")
);
std::thread::spawn(move || {
for index in 0..5 {
let (mut stream, _) = listener.accept().expect("mock canvas api accept");
let mut request_buffer = [0_u8; 8192];
let read_len = stream.read(&mut request_buffer).unwrap_or(0);
let request = String::from_utf8_lossy(&request_buffer[..read_len]);
assert!(request
.to_ascii_lowercase()
.contains("authorization: bearer "));
let (status, body) = match index {
0 => {
assert!(
request.starts_with("GET /api/external/v1/editor/projects ")
|| request.starts_with("GET /api/editor/projects ")
);
(
"200 OK",
serde_json::json!({
"data": { "projects": [{
"projectId": "canvas-project-1",
"title": "未命名游戏原型"
}] }
})
.to_string(),
)
}
1 => {
assert!(
request.starts_with("POST /api/external/v1/editor/projects ")
|| request.starts_with("POST /api/editor/projects ")
);
(
"200 OK",
serde_json::json!({
"data": { "project": { "projectId": "canvas-project-1" } }
})
.to_string(),
)
}
2 => {
assert!(
request.starts_with("GET /api/external/v1/editor/assets/library ")
|| request.starts_with("GET /api/editor/assets/library ")
);
(
"200 OK",
serde_json::json!({
"data": { "library": { "folders": [{
"folderId": "folder-1",
"label": "未命名游戏原型"
}] } }
})
.to_string(),
)
}
3 => {
assert!(
request.starts_with("POST /api/external/v1/editor/assets/folders ")
|| request.starts_with("POST /api/editor/assets/folders ")
);
(
"200 OK",
serde_json::json!({
"data": { "folder": { "folderId": "folder-1" } }
})
.to_string(),
)
}
_ => {
assert!(
request.starts_with("POST /api/external/v1/editor/images/generations ")
|| request.starts_with("POST /api/editor/images/generations ")
);
(
"500 Internal Server Error",
"{\"error\":\"generation failed\"}".to_string(),
)
}
};
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("mock canvas api response");
}
});
base_url
}
fn register_canvas_visual_asset_fixture(root: &Path, local_path: &str, kind: &str) {
let absolute_path = root.join(local_path);
fs::create_dir_all(absolute_path.parent().expect("visual asset parent"))
.expect("create visual asset fixture directory");
let bytes = if kind == "art-spritesheet" {
transparent_test_png_bytes()
} else {
valid_test_png_bytes()
};
fs::write(&absolute_path, bytes).expect("write visual asset fixture");
let (generation_route, generation_kind, reference_resource_ids) = match kind {
"icon-spec" => (
"/api/external/v1/editor/images/generations",
"spec",
Vec::new(),
),
"ui-prototype" => (
"/api/external/v1/editor/images/generations",
"ui-design",
vec!["resource-icon-spec".to_string()],
),
"art-spritesheet" => (
"/api/external/v1/editor/icon-spritesheets/generations",
"icon-spritesheet",
vec!["resource-icon-spec".to_string()],
),
_ => ("", "", Vec::new()),
};
register_local_asset_at(
root,
local_path,
kind,
"image/png",
"canvas",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("canvas-project-1".to_string()),
resource_id: Some(format!("resource-{kind}")),
asset_object_id: Some(format!("asset-object-{kind}")),
task_id: Some(format!("task-{kind}")),
prompt: Some("测试视觉资产".to_string()),
model: Some("gpt-image-2".to_string()),
generation_route: (!generation_route.is_empty()).then(|| generation_route.to_string()),
generation_kind: (!generation_kind.is_empty()).then(|| generation_kind.to_string()),
reference_resource_ids,
},
)
.expect("register canvas visual asset fixture");
}
fn bind_canvas_visual_asset_fixture_to_current_editor(
root: &Path,
local_path: &str,
remote_project_id: &str,
) {
let manifest = read_manifest_for_project(root).expect("read visual binding fixture manifest");
let source = manifest
.assets
.iter()
.find(|asset| asset.local_path == local_path)
.expect("registered visual binding fixture");
let bytes = fs::read(root.join(&source.local_path)).expect("read visual binding fixture bytes");
let (api_base_url, api_key, frozen_platform_session) =
resolve_canvas_sync_api_credentials(None, None).expect("resolve visual fixture account");
let frozen_platform_session = frozen_platform_session
.as_ref()
.expect("visual binding fixture requires a platform account");
let access =
ExternalEditorBindingAccess::new(&api_base_url, &api_key, Some(frozen_platform_session))
.expect("create visual binding fixture access");
let principal = external_editor_binding_principal(&access)
.expect("derive visual binding fixture principal");
let source_identity = new_external_editor_source_identity(
&source.id,
&format!("{:x}", Sha256::digest(&bytes)),
&source.media_type,
&source.kind,
)
.expect("derive visual binding fixture source identity");
let binding = new_external_editor_resource_binding(
&manifest.project_id,
&principal,
remote_project_id,
&source_identity,
source.source.resource_id.as_deref(),
&format!("fixtures/{}.png", source.id),
source
.source
.asset_object_id
.as_deref()
.expect("visual fixture asset object id"),
Some(1),
Some(1),
unix_timestamp(),
)
.expect("create visual resource binding fixture");
write_external_editor_resource_binding_at(root, &binding)
.expect("persist visual resource binding fixture");
}
fn ui_prototype_checks_fixture(passed: bool) -> serde_json::Value {
serde_json::json!({
"informationHud": passed,
"gameplaySurface": true,
"objectiveEntities": true,
"primaryControls": passed,
"failureRestartFlow": passed,
"responsiveLayout": passed,
"implementationClarity": passed,
"originalTheme": true,
})
}
fn ui_prototype_assessment_fixture(passed: bool) -> String {
serde_json::json!({
"checks": ui_prototype_checks_fixture(passed),
"issues": if passed {
Vec::<String>::new()
} else {
vec!["只有场景和角色,缺少状态 HUD、主要操作、失败重开与移动端布局".to_string()]
},
"summary": if passed {
"八项 UI 原型检查全部通过。"
} else {
"这是战斗场景概念图,不是可供实现的完整 UI 原型。"
},
})
.to_string()
}
fn supervisor_collaboration_delegate_action_for_test(
agent_id: &str,
repair_of_delegation_id: Option<&str>,
) -> AgentRuntimeToolAction {
AgentRuntimeToolAction {
tool: "agent.delegate".to_string(),
reason: Some("委派专业交付".to_string()),
input: serde_json::json!({
"agentId": agent_id,
"task": "完成专业交付并返回可核对证据",
"acceptanceCriteria": ["交付满足项目验收条件"],
"expectedArtifacts": [],
"repairOfDelegationId": repair_of_delegation_id,
"runId": null,
}),
}
}
fn supervisor_collaboration_spawn_action_for_test(children: usize) -> AgentRuntimeToolAction {
AgentRuntimeToolAction {
tool: "agent.spawn_isolated".to_string(),
reason: Some("并行执行隔离检查".to_string()),
input: serde_json::json!({
"children": (0..children).map(|index| serde_json::json!({
"templateAgentId": "code-prototype",
"task": format!("完成隔离检查 {index}"),
"acceptanceCriteria": ["检查形成可信结论"],
"expectedArtifacts": [],
"writeScopes": [format!("game/collaboration-check-{index}/**")],
})).collect::<Vec<_>>(),
"joinMode": "all",
}),
}
}
fn supervisor_collaboration_mixed_policy_for_test() -> SupervisorCollaborationPolicy {
SupervisorCollaborationPolicy {
schema_version: "game-creator-supervisor-collaboration-policy.v1".to_string(),
required_initial_wave: SupervisorInitialCollaborationWave::Mixed,
min_static_delegates: 2,
required_static_agent_ids: vec!["art-director".to_string(), "design-director".to_string()],
min_isolated_children: 2,
min_isolated_groups_before_claim: 0,
orchestrator_only_after_delegation: true,
}
}
fn supervisor_collaboration_plan_for_test(
actions: Vec<AgentRuntimeToolAction>,
) -> AgentRuntimeToolPlan {
AgentRuntimeToolPlan {
thinking_summary: "按项目合同编排专业与隔离协作".to_string(),
plan_update: None,
plan: vec!["提交完整协作波".to_string()],
actions,
response: String::new(),
}
}
fn downgrade_supervisor_collaboration_batch_to_v1_for_test(
mut batch: AgentRuntimeProviderActionBatch,
) -> AgentRuntimeProviderActionBatch {
batch.schema_version = "game-creator-provider-action-batch.v1".to_string();
batch.collaboration_contract = None;
let action_ids = batch
.actions
.iter()
.map(|pending| pending.action_id.as_str())
.collect::<Vec<_>>();
let identity = serde_json::to_vec(&serde_json::json!({
"projectId": batch.project_id,
"agentId": batch.agent_id,
"taskId": batch.task_id,
"sessionId": batch.session_id,
"runId": batch.run_id,
"loopIteration": batch.loop_iteration,
"plannedSteerCursor": batch.planned_steer_cursor,
"plan": batch.plan,
"projectRevisionBefore": batch.project_revision_before,
"plannedRepositoryContextFingerprint": batch.planned_repository_context_fingerprint,
"actionIds": action_ids,
}))
.expect("serialize v1 provider batch identity");
let fingerprint = format!("{:x}", Sha256::digest(identity));
batch.batch_id = format!(
"provider-action-{}",
fingerprint.chars().take(32).collect::<String>()
);
batch
}
fn agent_runtime_previous_sidecar_path_for_test(path: &Path) -> PathBuf {
path.with_file_name(format!(
".{}.previous",
path.file_name()
.and_then(|value| value.to_str())
.expect("Agent Runtime sidecar file name")
))
}
fn supervisor_collaboration_policy_snapshot_paths_for_test(
root: &Path,
parent_run_id: &str,
) -> (PathBuf, PathBuf) {
let primary = supervisor_collaboration_policy_snapshot_path(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
);
let previous = agent_runtime_previous_sidecar_path_for_test(&primary);
(primary, previous)
}
fn supervisor_collaboration_policy_snapshot_binding_paths_for_test(
root: &Path,
parent_run_id: &str,
) -> (PathBuf, PathBuf) {
let primary = supervisor_collaboration_policy_snapshot_binding_path(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
);
let previous = agent_runtime_previous_sidecar_path_for_test(&primary);
(primary, previous)
}
fn remove_supervisor_collaboration_policy_snapshot_for_test(root: &Path, parent_run_id: &str) {
let (snapshot_path, snapshot_previous_path) =
supervisor_collaboration_policy_snapshot_paths_for_test(root, parent_run_id);
let (binding_path, binding_previous_path) =
supervisor_collaboration_policy_snapshot_binding_paths_for_test(root, parent_run_id);
for candidate in [
snapshot_path,
snapshot_previous_path,
binding_path,
binding_previous_path,
] {
match fs::remove_file(&candidate) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => panic!(
"remove Supervisor collaboration snapshot fixture {}: {error}",
candidate.display()
),
}
}
}
fn read_supervisor_collaboration_policy_snapshot_for_test(
root: &Path,
parent_run_id: &str,
) -> SupervisorCollaborationPolicySnapshot {
read_supervisor_collaboration_policy_snapshot_at(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
)
.expect("read Supervisor collaboration policy snapshot")
.expect("Supervisor collaboration policy snapshot exists")
}
fn read_supervisor_collaboration_policy_snapshot_binding_for_test(
root: &Path,
parent_run_id: &str,
) -> SupervisorCollaborationPolicySnapshotBinding {
read_supervisor_collaboration_policy_snapshot_binding_at(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
)
.expect("read Supervisor collaboration policy snapshot binding")
.expect("Supervisor collaboration policy snapshot binding exists")
}
fn assert_supervisor_collaboration_has_zero_runtime_side_effects_for_test(
root: &Path,
parent_run_id: &str,
action_ids: &[String],
) {
assert!(static_delegate_target_agent_ids_at(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
)
.expect("read zero-side-effect static collaboration state")
.is_empty());
assert_eq!(
isolated_agent_group_summary_at(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
)
.expect("read zero-side-effect isolated collaboration state"),
IsolatedAgentGroupSummary::default(),
);
assert!(list_isolated_agent_instances_at(root)
.expect("list zero-side-effect isolated instances")
.is_empty());
assert!(list_isolated_join_claims_at(root)
.expect("list zero-side-effect isolated claims")
.is_empty());
let static_claim_dir = root.join(".agent/runtime/delegation-claims");
assert!(
!static_claim_dir.exists()
|| fs::read_dir(&static_claim_dir)
.expect("read zero-side-effect static claim directory")
.next()
.is_none(),
"no static claim sidecar may be created",
);
assert!(!game_creator_agent_runtime_pending_tool_action_path(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
)
.exists());
let records = read_agent_db_records_for_test(root);
for action_id in action_ids {
assert_eq!(
records
.iter()
.filter(|record| {
record.get("actionId").and_then(Value::as_str) == Some(action_id.as_str())
})
.count(),
0,
"no Agent DB side effect may be recorded for action {action_id}",
);
}
}
fn remove_supervisor_provider_action_batch_for_test(root: &Path, parent_run_id: &str) {
let path = game_creator_agent_runtime_provider_action_batch_path(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
);
let previous_path = agent_runtime_previous_sidecar_path_for_test(&path);
for candidate in [path, previous_path] {
match fs::remove_file(&candidate) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => panic!(
"remove Supervisor Provider action batch fixture {}: {error}",
candidate.display()
),
}
}
}
fn write_supervisor_provider_action_batch_fixture_for_test(
root: &Path,
batch: &AgentRuntimeProviderActionBatch,
) {
fs::write(
game_creator_agent_runtime_provider_action_batch_path(root, &batch.agent_id, &batch.run_id),
serde_json::to_vec_pretty(batch).expect("serialize Provider action batch fixture"),
)
.expect("write Provider action batch fixture");
}
async fn prepare_supervisor_collaboration_ready_batch_for_test(
root: &Path,
runtime: &AgentRuntimeState,
task: &str,
actions: Vec<AgentRuntimeToolAction>,
repository_fingerprint_seed: &str,
) -> AgentRuntimeProviderActionBatch {
let plan = supervisor_collaboration_plan_for_test(actions);
let revision = read_game_creator_agent_runtime_project_revision(root)
.expect("read Supervisor collaboration project revision");
let repository_fingerprint = format!(
"{:x}",
Sha256::digest(repository_fingerprint_seed.as_bytes())
);
let preparation = prepare_game_creator_agent_runtime_provider_action_batch(
root,
runtime,
task,
&plan,
&[],
&revision,
&repository_fingerprint,
)
.await
.expect("prepare Supervisor collaboration Provider action batch");
let AgentRuntimeProviderActionBatchPreparation::Ready(batch) = preparation else {
panic!("Supervisor collaboration plan must form a ready durable batch");
};
batch
}
fn read_provider_action_batch_for_test(root: &Path, agent_id: &str, run_id: &str) -> Value {
serde_json::from_str(
&fs::read_to_string(game_creator_agent_runtime_provider_action_batch_path(
root, agent_id, run_id,
))
.expect("read provider action batch"),
)
.expect("parse provider action batch")
}
fn provider_action_batch_action_ids_for_test(batch: &Value) -> Vec<String> {
batch["actions"]
.as_array()
.expect("provider action batch actions")
.iter()
.map(|action| {
action["actionId"]
.as_str()
.expect("provider batch actionId")
.to_string()
})
.collect()
}
fn provider_action_batch_receipts_for_test(root: &Path, run_id: &str) -> Vec<Value> {
read_agent_db_records_for_test(root)
.into_iter()
.filter(|record| {
record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE
&& record["runId"] == run_id
})
.collect()
}
fn provider_action_batch_event_count_for_test(
root: &Path,
agent_id: &str,
run_id: &str,
event_type: &str,
) -> usize {
fs::read_to_string(game_creator_agent_runtime_event_path(root, agent_id))
.expect("read provider action batch events")
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str::<Value>(line).expect("parse provider action batch event"))
.filter(|event| event["runId"] == run_id && event["eventType"] == event_type)
.count()
}
fn provider_action_batch_event_action_count_for_test(
root: &Path,
agent_id: &str,
run_id: &str,
event_type: &str,
action_id: &str,
) -> usize {
fs::read_to_string(game_creator_agent_runtime_event_path(root, agent_id))
.expect("read Provider action batch events")
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str::<Value>(line).expect("parse Provider batch event"))
.filter(|event| {
event["runId"] == run_id
&& event["eventType"] == event_type
&& event["actionId"] == action_id
})
.count()
}
fn write_provider_action_batch_test_config(base_url: &str) -> TestConfigGuard {
write_test_local_config(format!(
r#"{{
"agentLlm": {{
"design-director": {{
"apiKey": "provider-batch-key",
"baseUrl": {base_url:?},
"model": "provider-batch-model",
"apiKind": "openai_responses",
"stream": false,
"maxRetries": 0
}}
}}
}}"#
))
}
fn provider_action_batch_auto_confirm_auto_plan_for_test(
prefix_marker: &str,
suffix_marker: &str,
) -> String {
serde_json::json!({
"thinkingSummary": "先记录前缀,再读取受控文件,最后记录后缀",
"plan": ["记录前缀", "读取受控文件", "记录后缀"],
"actions": [
{
"tool": "memory.write",
"reason": "记录自动前缀",
"input": {
"scope": "agent",
"title": "Provider batch restart prefix",
"content": prefix_marker
}
},
{
"tool": "file.read",
"reason": "读取需要确认的文件",
"input": {"path": "game/confirm-target.txt"}
},
{
"tool": "memory.write",
"reason": "记录自动后缀",
"input": {
"scope": "agent",
"title": "Provider batch restart suffix",
"content": suffix_marker
}
}
],
"response": ""
})
.to_string()
}
fn force_provider_action_batch_ready_for_test(root: &Path, agent_id: &str, run_id: &str) -> Value {
let path = game_creator_agent_runtime_provider_action_batch_path(root, agent_id, run_id);
let mut batch: Value = serde_json::from_str(
&fs::read_to_string(&path).expect("read waiting Provider action batch"),
)
.expect("parse waiting Provider action batch");
let now = unix_timestamp();
for pending in batch["actions"]
.as_array_mut()
.expect("Provider batch actions")
{
if pending["status"] == AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING {
pending["status"] =
Value::String(AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string());
pending["observation"] = Value::Null;
pending["updatedAt"] = Value::from(now);
}
}
batch["status"] = Value::String("ready".to_string());
batch["updatedAt"] = Value::from(now);
fs::write(
path,
serde_json::to_vec_pretty(&batch).expect("serialize ready Provider action batch"),
)
.expect("write ready Provider action batch");
fs::remove_file(game_creator_agent_runtime_pending_tool_action_path(
root, agent_id, run_id,
))
.expect("remove confirmation pending sidecar");
batch
}
fn run_response_stream_distinct_final_reply_case(api_kind: &str, case_name: &str) {
let root = unique_project_path();
init_local_game_project_at(
&root,
&format!("project-response-stream-{case_name}"),
"后台最终回复真流式项目",
)
.expect("response stream project init");
let planning_fallback = format!("规划阶段备用回复【{case_name}】不得提前提交。");
let first_delta = format!("第一段公开中文回复【{case_name}】");
let second_delta = ",第二段完成。".to_string();
let canonical_response = format!("{first_delta}{second_delta}");
let mock = spawn_response_stream_mock_llm_server(
api_kind,
ResponseStreamMockPlanningResponse::Stream(final_tool_plan_response(&planning_fallback)),
Some(ResponseStreamMockFinalResponse::Deltas(
first_delta.clone(),
second_delta.clone(),
)),
);
let base_url = mock.base_url.clone();
let _config_guard = write_test_local_config(format!(
r#"{{
"agentLlm": {{
"design-director": {{
"apiKey": "response-stream-key",
"baseUrl": {base_url:?},
"model": "response-stream-model",
"apiKind": {api_kind:?},
"stream": true,
"maxRetries": 0
}}
}}
}}"#
));
let run_id = format!("response-stream-{case_name}-run");
let started = start_game_creator_agent_background_task_at(
&root,
"design-director",
"生成两段公开中文最终回复",
&run_id,
)
.expect("start streamed final reply task");
let session_id = started.state.session_id.clone();
mock.first_delta_written
.recv_timeout(Duration::from_secs(5))
.expect("first public final-reply delta");
let streaming =
wait_for_response_stream_status(&root, "design-director", &run_id, "streaming", 1);
assert_eq!(streaming.accumulated_text, first_delta);
let during_stream =
read_game_creator_agent_runtime_for_session_at(&root, "design-director", Some(&session_id))
.expect("read Runtime during first final-reply delta");
assert_eq!(during_stream.state.status, "running");
assert_eq!(during_stream.state.phase, "response");
assert_eq!(
during_stream
.response_stream
.as_ref()
.map(|stream| stream.sequence),
Some(streaming.sequence)
);
let before_release =
read_local_conversation_for_session_at(&root, Some("design-director"), Some(&session_id))
.expect("read conversation before second final-reply delta");
assert_eq!(
before_release
.messages
.iter()
.filter(|message| message.role == "assistant")
.count(),
0
);
mock.release_second_delta
.send(())
.expect("release second public final-reply delta");
let completed = wait_for_agent_runtime_idle(&root, "design-director");
assert_eq!(completed.status, "idle");
assert_eq!(completed.phase, "completed");
assert_eq!(
completed.last_response.as_deref(),
Some(canonical_response.as_str())
);
let committed = wait_for_response_stream_status(
&root,
"design-director",
&run_id,
"committed",
streaming.sequence.saturating_add(1),
);
assert_eq!(committed.accumulated_text, canonical_response);
let conversation =
read_local_conversation_for_session_at(&root, Some("design-director"), Some(&session_id))
.expect("read committed streamed conversation");
let assistants = conversation
.messages
.iter()
.filter(|message| message.role == "assistant")
.map(|message| message.content.as_str())
.collect::<Vec<_>>();
assert_eq!(assistants, vec![canonical_response.as_str()]);
let requests = mock.stop_and_collect();
assert_eq!(requests.len(), 2, "{api_kind} physical request count");
let expected_route = if api_kind == "openai_responses" {
"POST /responses HTTP/1.1"
} else {
"POST /chat/completions HTTP/1.1"
};
assert!(requests
.iter()
.all(|request| request.contains(expected_route)));
let planning_request = mock_http_request_json(&requests[0]);
let final_request = mock_http_request_json(&requests[1]);
assert_eq!(planning_request["stream"], Value::Bool(true));
assert_eq!(final_request["stream"], Value::Bool(true));
assert!(requests[0].contains("respond_to_user"));
assert!(!requests[0].contains("\"name\":\"submit_agent_tool_plan\""));
assert!(!requests[1].contains("respond_to_user"));
assert_response_stream_provider_lifecycles(&root, &run_id, &["tool-plan", "final-reply"]);
assert_response_stream_completion_event_details(
&root,
"design-director",
&run_id,
&canonical_response,
);
assert_response_stream_public_surfaces_exclude(
&root,
"design-director",
&[&first_delta, &second_delta, &canonical_response],
);
fs::remove_dir_all(root).ok();
}
fn verification_gate_observation(
tool: &str,
status: &str,
summary: &str,
) -> AgentRuntimeToolObservation {
AgentRuntimeToolObservation {
tool: tool.to_string(),
status: status.to_string(),
summary: summary.to_string(),
detail: None,
}
}
fn advance_project_revision_for_test(root: &Path, agent_id: &str, run_id: &str, tool: &str) -> u64 {
let _lock = acquire_project_write_lock(root, "test.project.mutation")
.expect("acquire test project mutation lock");
prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, tool)
.expect("prepare test project mutation")
}
fn persist_project_verification_for_test(
root: &Path,
agent_id: &str,
run_id: &str,
tool: &str,
passed: bool,
) {
let _lock = acquire_project_write_lock(root, "test.project.verification")
.expect("acquire test project verification lock");
let (revision, gate) =
begin_agent_runtime_project_verification_locked(root, agent_id, run_id, tool)
.expect("begin test project verification");
finish_agent_runtime_project_verification_locked(root, &revision, gate, passed)
.expect("finish test project verification");
}
fn wait_for_provider_handoff_test_stop(
root: &Path,
agent_id: &str,
run_id: &str,
) -> crate::provider_handoff::AgentRuntimeProviderHandoffRecord {
for _ in 0..250 {
let handoff = crate::provider_handoff::read_for_run_at(root, agent_id, run_id)
.expect("read Provider success handoff after test stop");
if let Some(handoff) = handoff {
if game_creator_agent_runtime_task_lock_is_available(root, agent_id)
.expect("probe Provider handoff test-stop lane")
{
return handoff;
}
}
std::thread::sleep(Duration::from_millis(20));
}
panic!("Provider success handoff was not committed before the test-stop lane released");
}
fn wait_for_provider_handoff_terminal_cleanup(root: &Path, agent_id: &str, run_id: &str) {
for _ in 0..250 {
let handoff = crate::provider_handoff::read_for_run_at(root, agent_id, run_id)
.expect("read Provider handoff during terminal cleanup");
let tool_plan_handoff = crate::tool_plan_handoff::read_for_run_at(root, agent_id, run_id)
.expect("read tool-plan handoff during terminal cleanup");
let retry = crate::provider_retry::read_for_run_at(root, agent_id, run_id)
.expect("read Provider retry during terminal cleanup");
let finalization =
read_game_creator_agent_runtime_finalization_journal(root, agent_id, run_id)
.expect("read finalization during Provider handoff terminal cleanup");
let lane_available = game_creator_agent_runtime_task_lock_is_available(root, agent_id)
.expect("probe Provider handoff terminal lane");
if handoff.is_none()
&& tool_plan_handoff.is_none()
&& retry.is_none()
&& finalization.is_none()
&& lane_available
{
return;
}
std::thread::sleep(Duration::from_millis(20));
}
panic!("Provider handoff/retry/finalization did not reach a clean terminal state");
}
fn wait_for_tool_plan_handoff_test_stop(
root: &Path,
agent_id: &str,
run_id: &str,
expected_entries: usize,
) -> crate::tool_plan_handoff::AgentRuntimeToolPlanHandoffLedger {
for _ in 0..250 {
let handoff = match crate::tool_plan_handoff::read_for_run_at(root, agent_id, run_id) {
Ok(handoff) => handoff,
Err(error) if error.contains("仍由活跃写入句柄持有") => {
std::thread::sleep(Duration::from_millis(20));
continue;
}
Err(error) => panic!("read tool-plan handoff after test stop: {error}"),
};
if let Some(handoff) = handoff {
if handoff.entries.len() == expected_entries
&& game_creator_agent_runtime_task_lock_is_available(root, agent_id)
.expect("probe tool-plan handoff test-stop lane")
{
return handoff;
}
}
std::thread::sleep(Duration::from_millis(20));
}
panic!("tool-plan handoff was not committed before the test-stop lane released");
}
const REAL_E2E_TOOL_PLAN_CHECKPOINT_SENTINEL_FILE: &str =
".agent-runtime-real-e2e-supervisor-swarm-tool-plan-handoff-runner-kill-appdata.json";
const REAL_E2E_TOOL_PLAN_CHECKPOINT_SENTINEL_SCHEMA: &str =
"genarrative-agent-runtime-real-e2e-supervisor-swarm-tool-plan-handoff-runner-kill-appdata.v1";
const REAL_E2E_TOOL_PLAN_CHECKPOINT_CONTROL_FILE: &str =
".agent-runtime-real-e2e-tool-plan-handoff-checkpoint.json";
const REAL_E2E_TOOL_PLAN_CHECKPOINT_CONTROL_SCHEMA: &str =
"game-creator-tool-plan-handoff-runner-kill-checkpoint.v1";
const REAL_E2E_TOOL_PLAN_CHECKPOINT_ACK_FILE: &str =
".agent-runtime-real-e2e-tool-plan-handoff-checkpoint-reached.json";
const REAL_E2E_TOOL_PLAN_CHECKPOINT_ACK_SCHEMA: &str =
"game-creator-tool-plan-handoff-runner-kill-checkpoint-reached.v1";
const REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE: &str =
"REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE";
const REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_ARGUMENT: &str =
"REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_ARGUMENT";
fn write_real_e2e_tool_plan_checkpoint_json(path: &Path, value: &Value, unix_mode: u32) {
let bytes = serde_json::to_vec(value).expect("serialize checkpoint fixture");
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
options.mode(unix_mode);
let mut file = options.open(path).expect("create checkpoint fixture");
file.set_permissions(fs::Permissions::from_mode(unix_mode))
.expect("set checkpoint fixture mode");
file.write_all(&bytes).expect("write checkpoint fixture");
file.sync_all().expect("sync checkpoint fixture");
return;
}
#[cfg(not(unix))]
{
let _ = unix_mode;
let mut file = options.open(path).expect("create checkpoint fixture");
file.write_all(&bytes).expect("write checkpoint fixture");
file.sync_all().expect("sync checkpoint fixture");
}
}
fn real_e2e_tool_plan_checkpoint_project_root_sha256(root: &Path) -> String {
let canonical = fs::canonicalize(root).expect("canonicalize checkpoint project root");
let canonical = canonical
.to_str()
.expect("checkpoint project root must be UTF-8");
format!("{:x}", Sha256::digest(canonical.as_bytes()))
}
fn real_e2e_tool_plan_checkpoint_response() -> platform_llm::LlmRunResponse {
platform_llm::LlmRunResponse {
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: "real-e2e-checkpoint-model".to_string(),
text: REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE.to_string(),
reasoning: String::new(),
finish_reason: Some("tool_calls".to_string()),
response_id: Some("real-e2e-checkpoint-private-response-id".to_string()),
usage: None,
tool_calls: vec![platform_llm::LlmToolCall {
id: "real-e2e-checkpoint-private-call-id".to_string(),
name: AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME.to_string(),
arguments: serde_json::json!({
"thinkingSummary": "checkpoint fixture",
"planUpdate": null,
"plan": [],
"actions": [],
"response": REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_ARGUMENT,
})
.to_string(),
}],
responses_output: Vec::new(),
}
}
struct RealE2eToolPlanCheckpointFixture {
root: PathBuf,
config_dir: PathBuf,
_config_guard: TestRuntimeConfigDirGuard,
state: AgentRuntimeState,
snapshot: AgentRuntimeProviderRequestSnapshot,
capability: String,
control: Value,
}
impl RealE2eToolPlanCheckpointFixture {
fn new(run_id: &str, expires_after_ms: u64) -> Self {
let root = unique_project_path();
let state = start_agent_runtime_steer_fixture(&root, run_id);
let request_slot = "loop-0-repair-0";
let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot(
&root,
&state.agent_id,
&state.session_id,
&state.run_id,
"tool-plan",
request_slot,
state.applied_steer_cursor,
)
.expect("capture checkpoint Provider snapshot");
let config_dir = prepare_game_creator_runtime_config_dir(&unique_project_path())
.expect("prepare private checkpoint AppData");
let config_guard = use_test_runtime_config_dir(config_dir.clone());
let sentinel_token = format!(
"sentinel-{:x}",
Sha256::digest(format!("sentinel-{run_id}").as_bytes())
);
let sentinel_created_at = crate::provider_retry::now_ms();
write_real_e2e_tool_plan_checkpoint_json(
&config_dir.join(REAL_E2E_TOOL_PLAN_CHECKPOINT_SENTINEL_FILE),
&serde_json::json!({
"schemaVersion": REAL_E2E_TOOL_PLAN_CHECKPOINT_SENTINEL_SCHEMA,
"token": sentinel_token,
"ownerPid": std::process::id(),
"createdAt": sentinel_created_at,
}),
0o600,
);
let created_at_ms = crate::provider_retry::now_ms().max(sentinel_created_at);
let capability = format!(
"{:x}",
Sha256::digest(format!("checkpoint-capability-{run_id}").as_bytes())
);
let control = serde_json::json!({
"schemaVersion": REAL_E2E_TOOL_PLAN_CHECKPOINT_CONTROL_SCHEMA,
"capability": capability,
"sentinelToken": sentinel_token,
"ownerPid": std::process::id(),
"createdAtMs": created_at_ms,
"expiresAtMs": created_at_ms.saturating_add(expires_after_ms),
"projectRootSha256": real_e2e_tool_plan_checkpoint_project_root_sha256(&root),
"agentId": state.agent_id,
"runId": state.run_id,
"requestSlot": request_slot,
});
Self {
root,
config_dir,
_config_guard: config_guard,
state,
snapshot,
capability,
control,
}
}
fn control_path(&self) -> PathBuf {
self.config_dir
.join(REAL_E2E_TOOL_PLAN_CHECKPOINT_CONTROL_FILE)
}
fn ack_path(&self) -> PathBuf {
self.config_dir.join(REAL_E2E_TOOL_PLAN_CHECKPOINT_ACK_FILE)
}
fn write_control(&self, unix_mode: u32) {
write_real_e2e_tool_plan_checkpoint_json(&self.control_path(), &self.control, unix_mode);
}
fn cleanup(self) {
let root = self.root.clone();
let config_dir = self.config_dir.clone();
drop(self);
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
}
async fn wait_for_real_e2e_tool_plan_checkpoint_ack(path: &Path) -> String {
for _ in 0..100 {
if let Ok(content) = fs::read_to_string(path) {
if serde_json::from_str::<Value>(&content).is_ok() {
return content;
}
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("tool-plan handoff checkpoint ack was not committed");
}
fn assert_real_e2e_tool_plan_checkpoint_phase(
fixture: &RealE2eToolPlanCheckpointFixture,
expected_phase: &str,
) {
let runtime = read_game_creator_agent_runtime_at(&fixture.root, &fixture.state.agent_id)
.expect("read checkpoint failed-closed Runtime");
assert_eq!(runtime.state.run_id, fixture.state.run_id);
assert_eq!(runtime.state.phase, expected_phase);
let lifecycle = read_agent_db_records_for_test(&fixture.root)
.into_iter()
.filter(|record| {
record["recordType"] == "agent.runtime.provider_request.lifecycle"
&& record["runId"] == fixture.state.run_id
&& record["requestKind"] == "tool-plan"
&& record["requestSlot"] == "loop-0-repair-0"
})
.collect::<Vec<_>>();
assert_eq!(lifecycle.len(), 1);
assert_eq!(lifecycle[0]["status"], "started");
}
fn assert_real_e2e_tool_plan_checkpoint_reconciliation(fixture: &RealE2eToolPlanCheckpointFixture) {
assert_real_e2e_tool_plan_checkpoint_phase(fixture, "needs-reconciliation");
}
fn assert_real_e2e_tool_plan_checkpoint_error_is_private(
fixture: &RealE2eToolPlanCheckpointFixture,
error: &str,
) {
assert!(error.contains("provider-request-needs-reconciliation"));
assert!(error.contains("toolPlanCheckpoint="));
assert!(!error.contains(&fixture.capability));
assert!(!error.contains(REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE));
assert!(!error.contains(REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_ARGUMENT));
assert!(!error.contains(
fixture
.root
.to_str()
.expect("checkpoint test root must be UTF-8")
));
assert!(!error.contains(
fixture
.config_dir
.to_str()
.expect("checkpoint AppData must be UTF-8")
));
}
fn real_e2e_tool_plan_checkpoint_temp_paths(config_dir: &Path) -> Vec<PathBuf> {
fs::read_dir(config_dir)
.expect("read checkpoint AppData")
.filter_map(Result::ok)
.filter_map(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| {
name.starts_with(&format!("{REAL_E2E_TOOL_PLAN_CHECKPOINT_ACK_FILE}.tmp-"))
})
.then(|| entry.path())
})
.collect()
}
fn run_agent_runtime_git_fixture(root: &Path, arguments: &[&str]) -> String {
let output = std::process::Command::new("git")
.current_dir(root)
.args(arguments)
.output()
.expect("run git fixture command");
assert!(
output.status.success(),
"git fixture command failed: {arguments:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout)
.expect("git fixture output should be utf-8")
.trim()
.to_string()
}
fn seed_agent_runtime_git_fixture(root: &Path) -> String {
run_agent_runtime_git_fixture(root, &["init", "--quiet"]);
run_agent_runtime_git_fixture(root, &["config", "--local", "user.name", "Runtime Test"]);
run_agent_runtime_git_fixture(
root,
&[
"config",
"--local",
"user.email",
"runtime-test@example.invalid",
],
);
run_agent_runtime_git_fixture(root, &["add", "-A"]);
run_agent_runtime_git_fixture(root, &["commit", "--quiet", "-m", "seed"]);
run_agent_runtime_git_fixture(root, &["rev-parse", "HEAD"])
}
fn exact_agent_db_json_record(record_type: &str, length: usize) -> Vec<u8> {
let prefix = format!(r#"{{"recordType":"{record_type}","content":""#).into_bytes();
let suffix = br#""}"#;
assert!(prefix.len() + suffix.len() <= length);
let mut record = prefix;
record.extend(std::iter::repeat_n(
b'x',
length.saturating_sub(record.len() + suffix.len()),
));
record.extend_from_slice(suffix);
assert_eq!(record.len(), length);
serde_json::from_slice::<Value>(&record).expect("exact length record must be valid json");
record
}
fn agent_tool_plan_llm_response(
text: impl Into<String>,
tool_calls: Vec<platform_llm::LlmToolCall>,
) -> platform_llm::LlmRunResponse {
platform_llm::LlmRunResponse {
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: "mock-game-model".to_string(),
text: text.into(),
reasoning: String::new(),
finish_reason: Some("tool_calls".to_string()),
response_id: Some("response-tool-plan-test".to_string()),
usage: None,
tool_calls,
responses_output: Vec::new(),
}
}
struct ProcessSessionIntegrationCleanup {
root: PathBuf,
agent_id: &'static str,
run_id: &'static str,
}
impl Drop for ProcessSessionIntegrationCleanup {
fn drop(&mut self) {
let _ = terminate_process_sessions_for_run_at(&self.root, self.agent_id, self.run_id);
clear_process_session_registry_for_tests();
fs::remove_dir_all(&self.root).ok();
}
}
fn persist_process_action_observation_for_test(
root: &Path,
state: &mut AgentRuntimeState,
action: &AgentRuntimeToolAction,
pending: &AgentRuntimePendingToolAction,
observation: &AgentRuntimeToolObservation,
) {
let task = state.current_task.clone();
append_agent_runtime_tool_call_record(
root,
state,
&task,
action,
observation,
Some(&pending.action_id),
Some(&pending.action_fingerprint),
);
append_agent_runtime_action_receipt(
root,
state,
&pending.action_id,
&pending.action_fingerprint,
&action.tool,
&pending.execution_mode,
pending.input_summary.as_deref(),
observation,
)
.expect("append process action receipt");
state.observations.push(observation.summary());
append_game_creator_agent_runtime_task(root, state).expect("append process action task");
write_game_creator_agent_runtime_state(root, state).expect("write process action state");
}
async fn execute_approved_process_action_for_test(
root: &Path,
state: &mut AgentRuntimeState,
action: AgentRuntimeToolAction,
execution_mode: &str,
) -> (AgentRuntimePendingToolAction, AgentRuntimeToolObservation) {
let mut pending = pending_tool_action_for_test(
root,
state,
action.clone(),
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED,
None,
);
pending.occurrence_nonce = unix_timestamp()
.saturating_mul(1_000_000)
.saturating_add(TEST_PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed));
pending.action_id = agent_runtime_tool_action_id(
&pending.run_id,
pending.loop_iteration,
pending.action_index,
pending.occurrence_nonce,
&pending.action_fingerprint,
);
pending.execution_mode = execution_mode.to_string();
write_game_creator_agent_runtime_pending_tool_action(root, &pending)
.expect("write approved process action");
state.status = "running".to_string();
state.phase = "action".to_string();
state.pending_tool_action = Some(pending.summary());
append_game_creator_agent_runtime_task(root, state).expect("append approved process task");
write_game_creator_agent_runtime_state(root, state).expect("write approved process state");
if execution_mode == AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION {
write_game_creator_agent_runtime_tool_confirmation(
root,
&state.agent_id,
&state.run_id,
&action.tool,
&pending.action_fingerprint,
"V1.10 测试确认",
)
.expect("write process action confirmation");
}
let task = state.current_task.clone();
let observation = execute_game_creator_agent_runtime_tool_action_with_pending_action(
root,
&state.agent_id,
&state.run_id,
&task,
&action,
Some(&pending.action_id),
Some(&pending),
)
.await;
persist_process_action_observation_for_test(root, state, &action, &pending, &observation);
(pending, observation)
}
#[cfg(unix)]
async fn process_session_supervisor_collaboration_governance_fixture() {
const READY_SENTINEL: &str = "SUPERVISOR_PROCESS_READY_PRIVATE";
const POLL_SENTINEL: &str = "SUPERVISOR_PROCESS_POLL_PRIVATE";
const BLOCKED_STDIN_SENTINEL: &str = "SUPERVISOR_PROCESS_BLOCKED_STDIN_PRIVATE";
const AGENT_ID: &str = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID;
const RUN_ID: &str = "supervisor-process-collaboration-governance-run";
clear_process_session_registry_for_tests();
let root = unique_project_path();
init_local_game_project_at(
&root,
"supervisor-process-collaboration-governance-project",
"Project Supervisor 持久进程治理项目",
)
.expect("project init");
let _cleanup = ProcessSessionIntegrationCleanup {
root: root.clone(),
agent_id: AGENT_ID,
run_id: RUN_ID,
};
fs::write(
root.join("package.json"),
r#"{"private":true,"scripts":{"dev":"node supervisor-process-fixture.mjs"}}"#,
)
.expect("write Supervisor process package");
fs::write(
root.join("supervisor-process-fixture.mjs"),
format!(
r#"process.stdin.setEncoding('utf8');
console.log('{READY_SENTINEL}');
process.stdin.on('data', (chunk) => console.log(`ECHO:${{chunk.trim()}}`));
setInterval(() => console.log('{POLL_SENTINEL}'), 50);
"#
),
)
.expect("write Supervisor process fixture");
write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default())
.expect("write Supervisor collaboration policy");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec![
"command.start".to_string(),
"command.stdin".to_string(),
"command.terminate".to_string(),
],
agent_policies: BTreeMap::new(),
},
)
.expect("write process tool policy");
let mut state = start_game_creator_agent_runtime_task_at(
&root,
AGENT_ID,
"在协作 delivery 前启动持久进程并验证后续治理边界",
RUN_ID,
"agent-chat",
"启动并治理持久进程",
vec!["进程可读取且可终止,协作后 stdin 被阻断".to_string()],
)
.expect("start Supervisor process runtime");
state.loop_iteration = 1;
let (_, start_observation) = execute_approved_process_action_for_test(
&root,
&mut state,
AgentRuntimeToolAction {
tool: "command.start".to_string(),
reason: Some("在 delivery 前启动真实持久进程".to_string()),
input: serde_json::json!({
"program": "npm",
"args": ["run", "dev"],
"cwd": ".",
"timeoutSeconds": 30
}),
},
AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION,
)
.await;
assert_eq!(start_observation.status, "ok", "{start_observation:?}");
let start_detail: Value = serde_json::from_str(
start_observation
.detail
.as_deref()
.expect("start observation detail"),
)
.expect("parse start observation detail");
let process_id = start_detail["processId"]
.as_str()
.expect("start processId")
.to_string();
let mut cursor = start_detail["nextCursor"]
.as_str()
.expect("start next cursor")
.to_string();
let mut ready_output = String::new();
for index in 0..20 {
let (_, observation) = execute_approved_process_action_for_test(
&root,
&mut state,
AgentRuntimeToolAction {
tool: "command.poll".to_string(),
reason: Some(format!("等待 Supervisor fixture ready {index}")),
input: serde_json::json!({
"processId": process_id,
"cursor": cursor,
"maxChars": 8_000,
"waitMs": 500
}),
},
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
)
.await;
assert_eq!(observation.status, "ok", "{observation:?}");
let detail: Value = serde_json::from_str(
observation
.detail
.as_deref()
.expect("ready poll observation detail"),
)
.expect("parse ready poll detail");
assert_eq!(detail["processId"], process_id);
ready_output.push_str(detail["output"].as_str().unwrap_or_default());
cursor = detail["nextCursor"]
.as_str()
.expect("ready poll next cursor")
.to_string();
if ready_output.contains(READY_SENTINEL) {
break;
}
}
assert!(ready_output.contains(READY_SENTINEL), "{ready_output}");
let delivery = new_static_delegate_delivery(
AGENT_ID,
&state.session_id,
RUN_ID,
"supervisor-process-collaboration-delegate-action",
"supervisor-process-collaboration-delivery",
"code-prototype",
"supervisor-process-collaboration-child-session",
"supervisor-process-collaboration-child-run",
);
create_or_read_static_delegate_delivery_at(&root, &delivery)
.expect("create same-run durable static delivery");
let blocked_stdin = execute_game_creator_agent_runtime_tool_action(
&root,
AGENT_ID,
RUN_ID,
&state.current_task,
&AgentRuntimeToolAction {
tool: "command.stdin".to_string(),
reason: Some("协作后不得继续写入持久进程".to_string()),
input: serde_json::json!({
"processId": process_id,
"data": BLOCKED_STDIN_SENTINEL,
"appendNewline": true,
"eof": false
}),
},
)
.await;
assert_eq!(blocked_stdin.status, "blocked", "{blocked_stdin:?}");
assert!(blocked_stdin.summary.contains("协作编排"));
assert!(!serde_json::to_string(&blocked_stdin)
.expect("serialize blocked stdin observation")
.contains(BLOCKED_STDIN_SENTINEL));
let mut post_delivery_output = String::new();
for index in 0..20 {
let (_, observation) = execute_approved_process_action_for_test(
&root,
&mut state,
AgentRuntimeToolAction {
tool: "command.poll".to_string(),
reason: Some(format!("协作后继续读取 Supervisor fixture {index}")),
input: serde_json::json!({
"processId": process_id,
"cursor": cursor,
"maxChars": 8_000,
"waitMs": 500
}),
},
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
)
.await;
assert_eq!(observation.status, "ok", "{observation:?}");
let detail: Value = serde_json::from_str(
observation
.detail
.as_deref()
.expect("post-delivery poll detail"),
)
.expect("parse post-delivery poll detail");
assert_eq!(detail["processId"], process_id);
let output = detail["output"].as_str().unwrap_or_default();
assert!(!output.contains(BLOCKED_STDIN_SENTINEL), "{output}");
post_delivery_output.push_str(output);
cursor = detail["nextCursor"]
.as_str()
.expect("post-delivery poll next cursor")
.to_string();
if post_delivery_output.contains(POLL_SENTINEL) {
break;
}
}
assert!(
post_delivery_output.contains(POLL_SENTINEL),
"{post_delivery_output}"
);
assert!(!post_delivery_output.contains(BLOCKED_STDIN_SENTINEL));
let transcript_path = root.join(format!(
".agent/runtime/process-sessions/{process_id}.output.json"
));
let transcript = fs::read_to_string(&transcript_path).expect("read process transcript");
assert!(transcript.contains(READY_SENTINEL));
assert!(!transcript.contains(BLOCKED_STDIN_SENTINEL));
let (_, terminate_observation) = execute_approved_process_action_for_test(
&root,
&mut state,
AgentRuntimeToolAction {
tool: "command.terminate".to_string(),
reason: Some("协作后只治理并终止既有持久进程".to_string()),
input: serde_json::json!({
"processId": process_id,
"cursor": cursor
}),
},
AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION,
)
.await;
assert_eq!(
terminate_observation.status, "ok",
"{terminate_observation:?}"
);
let terminate_detail: Value = serde_json::from_str(
terminate_observation
.detail
.as_deref()
.expect("terminate observation detail"),
)
.expect("parse terminate detail");
assert_eq!(terminate_detail["processId"], process_id);
assert!(matches!(
terminate_detail["status"].as_str(),
Some("terminated" | "exited" | "timed-out" | "output-limit-exceeded")
));
assert!(!has_active_process_sessions_at(&root).expect("process inactive after terminate"));
let terminal_transcript =
fs::read_to_string(&transcript_path).expect("read terminal process transcript");
assert!(!terminal_transcript.contains(BLOCKED_STDIN_SENTINEL));
}
#[cfg(target_os = "linux")]
async fn process_session_agent_runtime_start_audit_failure_fixture() {
const AGENT_ID: &str = "code-prototype";
const RUN_ID: &str = "code-process-start-audit-failure-run";
const TARGET_LOG: &str = "start-audit-target.log";
clear_process_session_registry_for_tests();
let root = unique_project_path();
init_local_game_project_at(
&root,
"process-start-audit-project",
"Agent Runtime 启动审计失败项目",
)
.expect("project init");
let _cleanup = ProcessSessionIntegrationCleanup {
root: root.clone(),
agent_id: AGENT_ID,
run_id: RUN_ID,
};
fs::write(
root.join("package.json"),
r#"{"private":true,"scripts":{"dev":"node process-start-audit-fixture.mjs"}}"#,
)
.expect("write start audit package");
fs::write(
root.join("process-start-audit-fixture.mjs"),
format!(
r#"import {{ appendFileSync }} from 'node:fs';
appendFileSync('{TARGET_LOG}', 'STARTED\n');
setInterval(() => appendFileSync('{TARGET_LOG}', 'TICK\n'), 20);
"#
),
)
.expect("write start audit fixture");
fs::write(
root.join(PROJECT_PERMISSION_POLICY_PATH),
r#"{"deniedCommands":[],"confirmCommands":[],"agentPolicies":{}}"#,
)
.expect("write legacy empty policy");
let mut state = start_game_creator_agent_runtime_task_at(
&root,
AGENT_ID,
"验证 command.start 在 Agent DB 审计失败后终止 target",
RUN_ID,
"agent-background-task",
"准备启动审计失败 fixture",
vec!["启动并确认失败关闭".to_string()],
)
.expect("start runtime");
state.loop_iteration = 1;
let start_action = AgentRuntimeToolAction {
tool: "command.start".to_string(),
reason: Some("启动持续写入 fixture 并注入 Agent DB 审计失败".to_string()),
input: serde_json::json!({
"program": "npm",
"args": ["run", "dev"],
"cwd": ".",
"timeoutSeconds": 30
}),
};
let start_pending = pending_tool_action_for_test(
&root,
&state,
start_action.clone(),
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED,
None,
);
write_game_creator_agent_runtime_pending_tool_action(&root, &start_pending)
.expect("write approved start action");
state.status = "running".to_string();
state.phase = "action".to_string();
state.pending_tool_action = Some(start_pending.summary());
append_game_creator_agent_runtime_task(&root, &state).expect("append approved start task");
write_game_creator_agent_runtime_state(&root, &state).expect("write approved start state");
write_game_creator_agent_runtime_tool_confirmation(
&root,
AGENT_ID,
RUN_ID,
"command.start",
&start_pending.action_fingerprint,
"确认启动 Agent DB 审计失败 fixture",
)
.expect("write start confirmation");
let agent_db = root.join(".agent/agent.db");
fs::remove_file(&agent_db).expect("remove agent db before start audit failure");
fs::create_dir(&agent_db).expect("replace agent db with directory");
let observation = execute_game_creator_agent_runtime_tool_action_with_pending_action(
&root,
AGENT_ID,
RUN_ID,
&state.current_task,
&start_action,
Some(&start_pending.action_id),
Some(&start_pending),
)
.await;
assert_eq!(
observation.status, "needs-reconciliation",
"{observation:?}"
);
let detail: Value = serde_json::from_str(
observation
.detail
.as_deref()
.expect("start audit failure detail"),
)
.expect("parse start audit failure detail");
assert_eq!(detail["status"], "needs-reconciliation");
assert_eq!(detail["needsReconciliation"], true);
assert_eq!(detail["launchFailureKind"], "start-audit-failed");
assert_eq!(detail["sandboxEstablishment"], "established");
assert_eq!(detail["targetExec"], "established");
let process_id = detail["processId"]
.as_str()
.expect("start audit failure processId");
let record = active_process_session_records_at(&root, Some(AGENT_ID), Some(RUN_ID))
.expect("read start audit failure record")
.into_iter()
.find(|record| record.process_id == process_id)
.expect("start audit failure process record");
assert_eq!(record.status, "needs-reconciliation");
assert!(record.needs_reconciliation);
assert!(!record.stdin_open);
assert_eq!(
record.launch_failure_kind.as_deref(),
Some("start-audit-failed")
);
assert_eq!(record.sandbox_establishment, "established");
assert_eq!(record.target_exec, "established");
let target_log = root.join(TARGET_LOG);
let mut previous_len = usize::MAX;
let mut stable_samples = 0;
for _ in 0..100 {
std::thread::sleep(Duration::from_millis(25));
let current_len = fs::metadata(&target_log)
.map(|metadata| usize::try_from(metadata.len()).unwrap_or(usize::MAX))
.unwrap_or(0);
if current_len == previous_len {
stable_samples += 1;
if stable_samples >= 8 {
break;
}
} else {
previous_len = current_len;
stable_samples = 0;
}
}
assert_eq!(
stable_samples, 8,
"business target kept writing after start audit failure"
);
let stopped_content = fs::read(&target_log).unwrap_or_default();
std::thread::sleep(Duration::from_millis(250));
assert_eq!(
fs::read(&target_log).unwrap_or_default(),
stopped_content,
"business target resumed after start audit failure"
);
}
async fn process_session_agent_runtime_confirmed_lifecycle_fixture() {
const READY_SENTINEL: &str = "PROCESS_SESSION_READY_PRIVATE";
const STDIN_SENTINEL: &str = "PROCESS_SESSION_STDIN_PRIVATE";
const AGENT_ID: &str = "code-prototype";
const RUN_ID: &str = "code-process-confirmed-lifecycle-run";
clear_process_session_registry_for_tests();
let root = unique_project_path();
init_local_game_project_at(&root, "process-agent-project", "Agent Runtime 持久进程项目")
.expect("project init");
let _cleanup = ProcessSessionIntegrationCleanup {
root: root.clone(),
agent_id: AGENT_ID,
run_id: RUN_ID,
};
fs::write(
root.join("package.json"),
r#"{"private":true,"scripts":{"dev":"node process-fixture.mjs"}}"#,
)
.expect("write process fixture package");
fs::write(
root.join("process-fixture.mjs"),
format!(
r#"process.stdin.setEncoding('utf8');
console.log('{READY_SENTINEL}');
process.stdin.on('data', (chunk) => console.log(`ECHO:${{chunk.trim()}}`));
setInterval(() => {{}}, 1000);
"#
),
)
.expect("write process fixture");
fs::write(
root.join(PROJECT_PERMISSION_POLICY_PATH),
r#"{"deniedCommands":[],"confirmCommands":[],"agentPolicies":{}}"#,
)
.expect("write legacy empty policy");
let mut state = start_game_creator_agent_runtime_task_at(
&root,
AGENT_ID,
"运行持久进程并验证确认、交互与收束",
RUN_ID,
"agent-background-task",
"准备启动持久进程",
vec!["启动并交互".to_string(), "终止并清理".to_string()],
)
.expect("start runtime");
state.loop_iteration = 1;
let start_action = AgentRuntimeToolAction {
tool: "command.start".to_string(),
reason: Some("启动交互式测试 fixture".to_string()),
input: serde_json::json!({
"program": "npm",
"args": ["run", "dev"],
"cwd": ".",
"timeoutSeconds": 30
}),
};
let mut start_pending = pending_tool_action_for_test(
&root,
&state,
start_action.clone(),
"pending-confirmation",
None,
);
write_game_creator_agent_runtime_pending_tool_action(&root, &start_pending)
.expect("write unapproved start action");
state.status = "waiting-for-confirmation".to_string();
state.phase = "waiting-for-confirmation".to_string();
state.pending_tool_action = Some(start_pending.summary());
append_game_creator_agent_runtime_task(&root, &state).expect("append waiting start task");
write_game_creator_agent_runtime_state(&root, &state).expect("write waiting start state");
let waiting = execute_game_creator_agent_runtime_tool_action_with_pending_action(
&root,
AGENT_ID,
RUN_ID,
&state.current_task,
&start_action,
Some(&start_pending.action_id),
Some(&start_pending),
)
.await;
assert_eq!(waiting.status, "waiting-for-confirmation");
assert!(
active_process_session_records_at(&root, Some(AGENT_ID), Some(RUN_ID))
.expect("active records before confirmation")
.is_empty()
);
assert_eq!(
read_game_creator_agent_runtime_project_revision(&root)
.expect("revision before confirmation")
.revision,
0
);
start_pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string();
start_pending.updated_at = unix_timestamp();
write_game_creator_agent_runtime_pending_tool_action(&root, &start_pending)
.expect("approve start action");
state.status = "running".to_string();
state.phase = "action".to_string();
state.pending_tool_action = Some(start_pending.summary());
append_game_creator_agent_runtime_task(&root, &state).expect("append approved start task");
write_game_creator_agent_runtime_state(&root, &state).expect("write approved start state");
write_game_creator_agent_runtime_tool_confirmation(
&root,
AGENT_ID,
RUN_ID,
"command.start",
&start_pending.action_fingerprint,
"确认启动 V1.10 fixture",
)
.expect("write start confirmation");
let start_observation = execute_game_creator_agent_runtime_tool_action_with_pending_action(
&root,
AGENT_ID,
RUN_ID,
&state.current_task,
&start_action,
Some(&start_pending.action_id),
Some(&start_pending),
)
.await;
assert_eq!(start_observation.status, "ok", "{start_observation:?}");
persist_process_action_observation_for_test(
&root,
&mut state,
&start_action,
&start_pending,
&start_observation,
);
let start_detail: Value = serde_json::from_str(
start_observation
.detail
.as_deref()
.expect("start observation detail"),
)
.expect("parse start detail");
assert!(start_detail.get("output").is_none());
assert_eq!(start_detail["revisionAdvanced"], true);
let process_id = start_detail["processId"]
.as_str()
.expect("start processId")
.to_string();
let mut cursor = start_detail["nextCursor"]
.as_str()
.expect("start cursor")
.to_string();
assert!(has_active_process_sessions_at(&root).expect("active after confirmed start"));
let blocker = process_session_completion_blocker_at(&root, AGENT_ID, RUN_ID)
.expect("active process must block completion");
assert_eq!(blocker.tool, "runtime.process_session");
let revision = read_game_creator_agent_runtime_project_revision(&root)
.expect("start revision")
.revision;
let finalization = finish_game_creator_agent_background_runtime_turn_at(
&root,
state.clone(),
"不应在活进程存在时完成",
revision,
std::slice::from_ref(&start_observation),
)
.expect("finalization blocker result");
assert!(matches!(
finalization,
AgentBackgroundFinalizationOutcome::Stale(AgentRuntimeToolObservation {
tool,
..
}) if tool == "runtime.process_session"
));
assert!(!game_creator_agent_runtime_finalization_path(&root, AGENT_ID, RUN_ID).exists());
let mut ready_output = String::new();
for index in 0..20 {
let poll_action = AgentRuntimeToolAction {
tool: "command.poll".to_string(),
reason: Some(format!("等待 fixture ready {index}")),
input: serde_json::json!({
"processId": process_id,
"cursor": cursor,
"maxChars": 8_000,
"waitMs": 500
}),
};
let (_, observation) = execute_approved_process_action_for_test(
&root,
&mut state,
poll_action,
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
)
.await;
assert_eq!(observation.status, "ok", "{observation:?}");
let detail: Value = serde_json::from_str(
observation
.detail
.as_deref()
.expect("poll observation detail"),
)
.expect("parse poll detail");
ready_output.push_str(detail["output"].as_str().unwrap_or_default());
cursor = detail["nextCursor"]
.as_str()
.expect("poll next cursor")
.to_string();
if ready_output.contains(READY_SENTINEL) {
break;
}
}
assert!(ready_output.contains(READY_SENTINEL), "{ready_output}");
let stdin_action = AgentRuntimeToolAction {
tool: "command.stdin".to_string(),
reason: Some("向 fixture 写入唯一 challenge".to_string()),
input: serde_json::json!({
"processId": process_id,
"data": STDIN_SENTINEL,
"appendNewline": true,
"eof": false
}),
};
let (stdin_pending, stdin_observation) = execute_approved_process_action_for_test(
&root,
&mut state,
stdin_action,
AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION,
)
.await;
assert_eq!(stdin_observation.status, "ok", "{stdin_observation:?}");
assert!(!stdin_pending
.input_summary
.as_deref()
.unwrap_or_default()
.contains(STDIN_SENTINEL));
assert!(!stdin_observation
.detail
.as_deref()
.unwrap_or_default()
.contains(STDIN_SENTINEL));
let mut echo_output = String::new();
for index in 0..20 {
let poll_action = AgentRuntimeToolAction {
tool: "command.poll".to_string(),
reason: Some(format!("等待 fixture echo {index}")),
input: serde_json::json!({
"processId": process_id,
"cursor": cursor,
"maxChars": 8_000,
"waitMs": 500
}),
};
let (_, observation) = execute_approved_process_action_for_test(
&root,
&mut state,
poll_action,
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
)
.await;
assert_eq!(observation.status, "ok", "{observation:?}");
let detail: Value =
serde_json::from_str(observation.detail.as_deref().expect("echo poll detail"))
.expect("parse echo poll detail");
echo_output.push_str(detail["output"].as_str().unwrap_or_default());
cursor = detail["nextCursor"]
.as_str()
.expect("echo next cursor")
.to_string();
if echo_output.contains(STDIN_SENTINEL) {
break;
}
}
assert!(echo_output.contains(STDIN_SENTINEL), "{echo_output}");
let terminate_action = AgentRuntimeToolAction {
tool: "command.terminate".to_string(),
reason: Some("测试完成后收束 fixture".to_string()),
input: serde_json::json!({ "processId": process_id, "cursor": cursor }),
};
let (_, terminate_observation) = execute_approved_process_action_for_test(
&root,
&mut state,
terminate_action,
AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION,
)
.await;
assert_eq!(
terminate_observation.status, "ok",
"{terminate_observation:?}"
);
let terminate_detail: Value = serde_json::from_str(
terminate_observation
.detail
.as_deref()
.expect("terminate detail"),
)
.expect("parse terminate detail");
assert!(matches!(
terminate_detail["status"].as_str(),
Some("terminated" | "exited" | "timed-out" | "output-limit-exceeded")
));
assert!(!has_active_process_sessions_at(&root).expect("inactive after terminate"));
let public_paths = [
root.join(".agent/agent.db"),
game_creator_agent_runtime_task_path(&root, AGENT_ID),
game_creator_agent_runtime_event_path(&root, AGENT_ID),
root.join(format!(".agent/runtime/agents/{AGENT_ID}.json")),
];
for path in public_paths {
if !path.exists() {
continue;
}
let content = fs::read_to_string(&path).expect("read public process surface");
assert!(!content.contains(READY_SENTINEL), "{}", path.display());
assert!(!content.contains(STDIN_SENTINEL), "{}", path.display());
}
let private_transcripts = fs::read_dir(root.join(".agent/runtime/process-sessions"))
.expect("process session directory")
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| name.ends_with(".output.json"))
})
.filter_map(|entry| fs::read_to_string(entry.path()).ok())
.collect::<Vec<_>>()
.join("\n");
assert!(private_transcripts.contains(READY_SENTINEL));
assert!(private_transcripts.contains(STDIN_SENTINEL));
let spec = resolve_project_command_spec_at(
&root,
"npm",
&["run".to_string(), "dev".to_string()],
".",
30,
)
.expect("resolve cancellation fixture");
let identity = ProcessSessionIdentity {
project_id: game_creator_agent_runtime_context_project_id(&root)
.expect("cancellation fixture project id"),
agent_id: AGENT_ID.to_string(),
task_id: state.task_id.clone(),
conversation_session_id: state.session_id.clone(),
run_id: RUN_ID.to_string(),
start_action_id: "action-process-cancel-cleanup".to_string(),
start_action_fingerprint: "b".repeat(64),
};
let cancellation_started = start_process_session_at(
&root,
identity.clone(),
&spec,
project_command_source_fingerprint(&root).expect("cancellation source fingerprint"),
)
.expect("start cancellation fixture");
assert!(has_active_process_sessions_at(&root).expect("active cancellation fixture"));
// Observe one real poll before cancellation so the ConPTY reader has attached and the
// process has entered its steady running state. Cancelling immediately after launch can
// race reader startup on Windows and is intentionally treated as needs-reconciliation.
let mut cancellation_cursor = None;
let mut cancellation_output = String::new();
for _ in 0..40 {
let cancellation_poll = poll_process_session_at(
&root,
&identity,
&cancellation_started.process_id,
cancellation_cursor.as_deref(),
Some(2_000),
Some(500),
)
.expect("observe cancellation fixture");
assert_ne!(cancellation_poll.status, "needs-reconciliation");
cancellation_output.push_str(&cancellation_poll.output);
cancellation_cursor = Some(cancellation_poll.next_cursor);
if cancellation_output.contains(READY_SENTINEL) {
break;
}
}
assert!(
cancellation_output.contains(READY_SENTINEL),
"{cancellation_output}"
);
terminate_process_sessions_for_run_at(&root, AGENT_ID, RUN_ID)
.expect("cancel cleanup terminates active process");
assert!(!has_active_process_sessions_at(&root).expect("cancel cleanup terminal"));
}
fn start_agent_runtime_steer_fixture(root: &Path, run_id: &str) -> AgentRuntimeState {
init_local_game_project_at(root, "project-steer", "运行中追加指令测试项目")
.expect("initialize steer fixture");
start_game_creator_agent_runtime_task_for_session_at(
root,
"code-prototype",
None,
"实现一个可验证的键盘操作原型",
run_id,
"agent-background-task",
"准备规划实现步骤",
vec!["读取项目".to_string(), "实现并验证".to_string()],
)
.expect("start steer runtime")
}
fn start_terminal_context_compaction_fixture(root: &Path, run_id: &str) -> AgentRuntimeState {
let mut state = start_agent_runtime_steer_fixture(root, run_id);
for index in 0..8 {
append_local_conversation_message_for_session_at(
root,
Some(&state.agent_id),
Some(&state.session_id),
LocalConversationMessage {
role: if index % 2 == 0 { "user" } else { "assistant" }.to_string(),
content: format!("CONTEXT_RECOVERY_MESSAGE_{index}"),
agent_id: (index % 2 == 1).then(|| state.agent_id.clone()),
},
)
.expect("append context recovery conversation");
}
state.status = "idle".to_string();
state.phase = "completed".to_string();
state.current_action = "上下文恢复测试任务已完成".to_string();
state.waiting_on = "开发者输入".to_string();
state.next_step = "等待输入".to_string();
state.updated_at = unix_timestamp();
append_game_creator_agent_runtime_task(root, &state)
.expect("append terminal context recovery task");
write_game_creator_agent_runtime_state(root, &state)
.expect("write terminal context recovery runtime");
state
}
fn context_compaction_provider_snapshot_for_test(
root: &Path,
state: &AgentRuntimeState,
) -> AgentRuntimeProviderRequestSnapshot {
let source = build_game_creator_agent_runtime_context_compaction_source(
root,
&state.agent_id,
&state.session_id,
&state.run_id,
&[],
"manual",
)
.expect("build context recovery source");
let request_slot = format!(
"source-{}",
source
.source_fingerprint
.chars()
.take(32)
.collect::<String>()
);
capture_game_creator_agent_runtime_provider_request_snapshot(
root,
&state.agent_id,
&state.session_id,
&state.run_id,
"context-compaction",
&request_slot,
state.applied_steer_cursor,
)
.expect("capture context recovery Provider snapshot")
}
fn start_structured_plan_with_applied_steer_for_test(
root: &Path,
run_id: &str,
explanation: &str,
steps: Vec<AgentRuntimePlanUpdateStep>,
steer_id: &str,
steer_body: &str,
) -> AgentRuntimeState {
let mut state = start_agent_runtime_steer_fixture(root, run_id);
apply_agent_runtime_plan_update(
&mut state,
&AgentRuntimePlanUpdate {
explanation: explanation.to_string(),
steps,
},
)
.expect("apply structured plan before steer");
write_game_creator_agent_runtime_state(root, &state)
.expect("persist structured plan before steer");
let task = state.current_task.clone();
let plan = AgentRuntimeToolPlan::default();
let mut observations = Vec::new();
let tracker = AgentRuntimeContextWindowTracker::default();
persist_game_creator_agent_runtime_context(
root,
&state,
&task,
&plan,
&observations,
0,
&tracker,
)
.expect("persist structured plan context before steer");
steer_game_creator_agent_runtime_task_at(
root,
&state.agent_id,
&state.session_id,
&state.run_id,
steer_id,
steer_body,
"test",
)
.expect("queue structured plan steer");
assert!(consume_game_creator_agent_runtime_steers(
root,
&mut state,
&task,
&plan,
&mut observations,
0,
&tracker,
)
.expect("consume structured plan steer"));
state
}
fn assert_structured_plan_blocker_audit_for_test(
record: &Value,
plan_revision: u64,
completed: usize,
pending: usize,
in_progress: usize,
failed: usize,
total: usize,
incomplete_steps: &[(&str, &str)],
forbidden_text: &[&str],
) {
let summary = record["summary"]
.as_str()
.expect("structured plan blocker audit summary");
let detail = record["detail"]
.as_str()
.expect("structured plan blocker audit detail");
assert!(summary.contains(&format!("{completed}/{total}")));
assert!(detail.contains(&format!("planRevision={plan_revision}")));
assert!(detail.contains(&format!("completed={completed}")));
assert!(detail.contains(&format!("pending={pending}")));
assert!(detail.contains(&format!("inProgress={in_progress}")));
assert!(detail.contains(&format!("failed={failed}")));
assert!(detail.contains("stepStatusSha256="));
for (status, title) in incomplete_steps {
let title_hash = format!("{:x}", Sha256::digest(title.as_bytes()));
assert_eq!(title_hash.len(), 64);
assert!(detail.contains(&format!("{status}:{title_hash}")));
}
let encoded = serde_json::to_string(record).expect("serialize structured blocker audit");
for forbidden in forbidden_text {
assert!(
!encoded.contains(forbidden),
"structured blocker audit leaked forbidden text: {forbidden}"
);
}
}
fn read_agent_runtime_steer_jsonl(
root: &Path,
agent_id: &str,
run_id: &str,
) -> Vec<serde_json::Value> {
fs::read_to_string(game_creator_agent_runtime_steer_ledger_path(
root, agent_id, run_id,
))
.expect("read steer ledger")
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("parse steer ledger line"))
.collect()
}
fn isolated_group_for_claim_test(
root: &Path,
parent_session_id: &str,
parent_run_id: &str,
parent_action_id: &str,
scope: &str,
) -> (
IsolatedAgentGroupRecord,
IsolatedAgentInstanceRecord,
String,
) {
use platform_agent::game_creation::{
GameCreationIsolatedAgentChildSpec, GameCreationIsolatedAgentJoinMode,
GameCreationIsolatedAgentSpawnRequest,
};
let artifact_path = format!("game/{scope}/result.txt");
let request = GameCreationIsolatedAgentSpawnRequest {
children: vec![GameCreationIsolatedAgentChildSpec {
template_agent_id: "code-prototype".to_string(),
task: format!("完成 {scope} 原子认领检查"),
acceptance_criteria: vec![format!("{scope} 检查已完成")],
expected_artifacts: vec![artifact_path.clone()],
write_scopes: vec![format!("game/{scope}/**")],
}],
join_mode: GameCreationIsolatedAgentJoinMode::All,
};
let group = create_or_read_isolated_group_at(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
parent_session_id,
parent_action_id,
&request,
)
.expect("create isolated atomic claim group");
let instance = resolve_isolated_agent_instance_at(root, &group.instance_ids[0])
.expect("resolve isolated atomic claim instance");
(group, instance, artifact_path)
}
fn complete_isolated_group_for_claim_test(
root: &Path,
instance: &IsolatedAgentInstanceRecord,
artifact_path: &str,
scope: &str,
) -> JoinDispatch {
use platform_agent::game_creation::{
GameCreationIsolatedAgentArtifact, GameCreationIsolatedAgentChildResult,
GameCreationIsolatedAgentResultStatus,
};
record_isolated_child_result_at(
root,
&GameCreationIsolatedAgentChildResult {
delegation_id: instance.delegation_id.clone(),
instance_id: instance.instance_id.clone(),
template_agent_id: instance.template_agent_id.clone(),
run_id: instance.run_id.clone(),
status: GameCreationIsolatedAgentResultStatus::Completed,
summary: format!("{scope} 原子认领检查已完成"),
artifacts: vec![GameCreationIsolatedAgentArtifact {
path: artifact_path.to_string(),
sha256: "a".repeat(64),
}],
evidence: Vec::new(),
verified_revision: None,
error: None,
},
)
.expect("record isolated atomic claim result")
.expect("isolated atomic claim join ready")
}
fn ready_isolated_join_for_claim_test(
root: &Path,
parent_session_id: &str,
parent_run_id: &str,
parent_action_id: &str,
scope: &str,
) -> JoinDispatch {
let (_, instance, artifact_path) = isolated_group_for_claim_test(
root,
parent_session_id,
parent_run_id,
parent_action_id,
scope,
);
complete_isolated_group_for_claim_test(root, &instance, &artifact_path, scope)
}
#[tokio::test]
async fn background_agent_runtime_marks_response_plan_step_failed_when_final_reply_fails() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
let plan_json = serde_json::json!({
"thinkingSummary": "已有上下文足够,准备回复开发者",
"plan": ["回复开发者"],
"actions": [],
"response": ""
})
.to_string();
let base_url = spawn_mock_llm_tool_plan_then_invalid_final_reply(plan_json);
let _config_guard = write_test_local_config(format!(
r#"{{
"agentLlm": {{
"design-director": {{
"apiKey": "design-key",
"baseUrl": {base_url:?},
"model": "design-runtime-model",
"apiKind": "openai_responses",
"retryBackoffMs": 1
}}
}}
}}"#
));
start_game_creator_agent_background_task_at(
&root,
"design-director",
"后台分析最终回复失败路径",
"design-response-fail-run",
)
.expect("start background task");
let failed_result = wait_for_agent_runtime_terminal_and_lane_release(
&root,
"design-director",
"design-response-fail-run",
"failed",
"failed",
);
let runtime = &failed_result.state;
assert_eq!(runtime.status, "failed");
assert_eq!(runtime.phase, "failed");
assert_eq!(runtime.active_plan_step_index, None);
assert_eq!(runtime.plan_steps.len(), 1);
assert_eq!(runtime.plan_steps[0].title, "回复开发者");
assert_eq!(runtime.plan_steps[0].status, "failed");
assert!(runtime.plan_steps[0]
.detail
.as_deref()
.is_some_and(|detail| detail.contains("后台 Agent 最终回复调用 LLM 失败")));
let event_types = failed_result
.recent_events
.iter()
.map(|event| event.event_type.as_str())
.collect::<Vec<_>>();
assert!(event_types.contains(&"error"));
assert!(event_types.contains(&"turn.failed"));
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_marks_unconverged_loop_budget_exhausted() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
let (sender, receiver) = mpsc::channel();
let responses = (1..=AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT)
.map(|iteration| {
serde_json::json!({
"thinkingSummary": format!("第 {iteration} 轮仍要求继续读取项目索引"),
"plan": ["读取项目索引", "继续行动"],
"actions": [{
"tool": "project.index",
"reason": "故意保持未收束以验证 loop 预算",
"input": {}
}],
"response": ""
})
.to_string()
})
.collect::<Vec<_>>();
let base_url = spawn_mock_llm_server_responses_with_capture(responses, Some(sender));
let _config_guard = write_test_local_config(format!(
r#"{{
"agentLlm": {{
"design-director": {{
"apiKey": "design-key",
"baseUrl": {base_url:?},
"model": "design-runtime-model",
"apiKind": "openai_responses"
}}
}}
}}"#
));
start_game_creator_agent_background_task_at(
&root,
"design-director",
"验证未收束 loop 预算",
"design-budget-exhausted-run",
)
.expect("start background task");
for iteration in 1..=AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT {
let request = receiver
.recv_timeout(Duration::from_secs(2))
.expect("planning request");
assert!(request.contains(&format!("第 {iteration} 轮")));
}
let result = wait_for_agent_runtime_terminal_and_lane_release(
&root,
"design-director",
"design-budget-exhausted-run",
"failed",
"budget-exhausted",
);
assert!(receiver.try_recv().is_err());
assert_eq!(result.state.status, "failed");
assert_eq!(result.state.phase, "budget-exhausted");
assert!(result
.state
.error
.as_deref()
.is_some_and(|error| error.contains("loop-budget-exhausted")));
assert_eq!(result.task_queue.failed, 1);
assert!(result.recent_tasks.iter().any(|task| {
task.run_id == "design-budget-exhausted-run"
&& task.status == "failed"
&& task.phase == "budget-exhausted"
}));
let event_types = result
.recent_events
.iter()
.map(|event| event.event_type.as_str())
.collect::<Vec<_>>();
assert!(event_types.contains(&"error"));
assert!(event_types.contains(&"turn.budget_exhausted"));
let conversation = read_local_conversation_at(&root, Some("design-director"))
.expect("read budget conversation");
assert!(conversation.messages.iter().any(|message| {
message.role == "assistant"
&& message.content == "专业 Agent 执行失败,请稍后重试"
&& message.message_id.as_deref().is_some_and(|message_id| {
message_id.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
})
}));
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.contains("\"failureKind\":\"loop-budget-exhausted\""));
assert!(!agent_db.contains(
"\"recordType\":\"agent.runtime.background_task.completed\",\"agentId\":\"design-director\",\"taskId\":\"design-director\",\"sessionId\":\"agent-session-design-director\",\"runId\":\"design-budget-exhausted-run\""
));
fs::remove_dir_all(root).ok();
}
mod asset_delete;
mod asset_rename;
mod collaboration;
mod command_runtime;
pub(crate) mod configuration;
mod goal;
mod project;
mod project_lock_recovery;
mod project_tools;
mod provider;
mod response_stream;
mod runtime_actions;
mod runtime_state;
mod sessions;
mod version_resource_replacement;