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 图集切分契约与文档
6094 lines
227 KiB
Rust
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;
|