use super::*; const PREVIEW_SERVE_USAGE: &str = "用法:--preview-serve <本地项目绝对路径>"; #[derive(Debug, Eq, PartialEq)] pub(crate) enum CliCommand { LlmStatus, PreviewServe { project_path: PathBuf, }, AgentChat { project_path: PathBuf, agent_id: String, prompt: String, }, AgentTask { project_path: PathBuf, agent_id: String, task: String, initialize: bool, }, SwarmChat { project_path: PathBuf, parent_agent_id: String, initialize: bool, run_profile: String, }, AgentEnqueue { project_path: PathBuf, agent_id: String, run_id: String, task: String, initialize: bool, }, AgentRuntimeStatus { project_path: PathBuf, agent_id: String, }, AgentContextCompact { project_path: PathBuf, agent_id: String, session_id: Option, }, AgentGoalStatus { project_path: PathBuf, agent_id: String, session_id: String, }, AgentGoalStart { project_path: PathBuf, agent_id: String, session_id: String, run_id: String, initialize: bool, }, AgentGoalEdit { project_path: PathBuf, agent_id: String, session_id: String, goal_id: String, expected_revision: u64, }, AgentGoalPause { project_path: PathBuf, agent_id: String, session_id: String, goal_id: String, expected_revision: u64, }, AgentGoalResume { project_path: PathBuf, agent_id: String, session_id: String, goal_id: String, expected_revision: u64, }, AgentGoalClear { project_path: PathBuf, agent_id: String, session_id: String, goal_id: String, expected_revision: u64, }, AgentConfirm { project_path: PathBuf, agent_id: String, run_id: String, action_id: String, }, AgentCancel { project_path: PathBuf, agent_id: String, run_id: String, }, AgentRetry { project_path: PathBuf, agent_id: String, run_id: String, next_run_id: String, }, AgentSteer { project_path: PathBuf, agent_id: String, session_id: String, run_id: String, steer_id: String, }, AgentResume { project_path: PathBuf, }, RunnerStatus, RunnerShutdownIfIdle, AgentRun { project_path: PathBuf, prompt: String, wait_for_enter: bool, }, } #[derive(Debug, Deserialize, Eq, PartialEq)] #[serde(deny_unknown_fields)] struct CliAgentGoalPayload { outcome: String, constraints: Vec, verification: Vec, } impl CliCommand { pub(crate) fn requires_external_agent_runner(&self) -> bool { matches!( self, Self::AgentTask { .. } | Self::SwarmChat { .. } | Self::AgentEnqueue { .. } | Self::AgentContextCompact { .. } | Self::AgentConfirm { .. } | Self::AgentCancel { .. } | Self::AgentRetry { .. } | Self::AgentSteer { .. } | Self::AgentGoalStart { .. } | Self::AgentGoalEdit { .. } | Self::AgentGoalPause { .. } | Self::AgentGoalResume { .. } | Self::AgentGoalClear { .. } | Self::AgentResume { .. } | Self::RunnerShutdownIfIdle ) } pub(crate) fn is_read_only_status(&self) -> bool { matches!( self, Self::AgentRuntimeStatus { .. } | Self::AgentGoalStatus { .. } | Self::RunnerStatus ) } pub(crate) fn requires_started_external_agent_runner(&self) -> bool { self.requires_external_agent_runner() && !matches!(self, Self::AgentCancel { .. } | Self::RunnerShutdownIfIdle) } fn project_path_mut(&mut self) -> Option<(&mut PathBuf, bool)> { match self { Self::AgentTask { project_path, initialize, .. } | Self::SwarmChat { project_path, initialize, .. } | Self::AgentEnqueue { project_path, initialize, .. } | Self::AgentGoalStart { project_path, initialize, .. } => Some((project_path, *initialize)), Self::AgentChat { project_path, .. } | Self::AgentRuntimeStatus { project_path, .. } | Self::AgentContextCompact { project_path, .. } | Self::AgentGoalStatus { project_path, .. } | Self::AgentGoalEdit { project_path, .. } | Self::AgentGoalPause { project_path, .. } | Self::AgentGoalResume { project_path, .. } | Self::AgentGoalClear { project_path, .. } | Self::AgentConfirm { project_path, .. } | Self::AgentCancel { project_path, .. } | Self::AgentRetry { project_path, .. } | Self::AgentSteer { project_path, .. } | Self::AgentResume { project_path } | Self::PreviewServe { project_path } | Self::AgentRun { project_path, .. } => Some((project_path, false)), Self::LlmStatus | Self::RunnerStatus | Self::RunnerShutdownIfIdle => None, } } } fn canonicalize_cli_path( path: &Path, label: &str, allow_missing_leaf: bool, ) -> Result { if !path.is_absolute() { return Err(format!("{label} 必须是绝对路径")); } match fs::canonicalize(path) { Ok(path) => Ok(path), Err(error) if allow_missing_leaf && error.kind() == std::io::ErrorKind::NotFound => { let parent = path .parent() .ok_or_else(|| format!("{label} 缺少可解析的父目录"))?; let file_name = path .file_name() .ok_or_else(|| format!("{label} 缺少目录名"))?; let parent = fs::canonicalize(parent).map_err(|parent_error| { format!( "解析 {label} 父目录失败:{}: {parent_error}", parent.display() ) })?; Ok(parent.join(file_name)) } Err(error) => Err(format!("解析 {label} 失败:{}: {error}", path.display())), } } pub(crate) fn prepare_cli_command_paths( command: &mut CliCommand, runtime_config_dir: Option<&Path>, ) -> Result, String> { let project_path = if let Some((path, allow_missing_leaf)) = command.project_path_mut() { let canonical = canonicalize_cli_path(path, "本地项目路径", allow_missing_leaf)?; *path = canonical.clone(); Some(canonical) } else { None }; let config_dir = runtime_config_dir .map(|path| canonicalize_cli_path(path, "--config-dir", true)) .transpose()?; if command.requires_external_agent_runner() && config_dir.is_none() { return Err( "Agent Runtime 写命令必须显式传入 --config-dir <项目外 AppData 绝对路径>".to_string(), ); } if let (Some(config_dir), Some(project_path)) = (&config_dir, &project_path) { validate_game_creator_runtime_config_dir_outside_project(config_dir, project_path)?; } Ok(config_dir) } pub(crate) fn take_cli_runtime_config_dir( args: &mut Vec, ) -> Result, String> { let positions = args .iter() .enumerate() .filter_map(|(index, arg)| (arg == "--config-dir").then_some(index)) .collect::>(); if positions.len() > 1 { return Err("--config-dir 只能指定一次".to_string()); } let Some(index) = positions.first().copied() else { return Ok(None); }; let value = args .get(index + 1) .map(String::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| "--config-dir 缺少目录路径".to_string())?; let path = PathBuf::from(value); if !path.is_absolute() { return Err("--config-dir 必须是绝对路径".to_string()); } args.drain(index..=index + 1); Ok(Some(path)) } pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) -> Vec { let mut lines = vec![ format!("llm.configured={}", status.configured), format!("llm.apiKeyPresent={}", status.api_key_present), format!( "llm.baseUrl={}", status.base_url.as_deref().unwrap_or_default() ), format!("llm.model={}", status.model.as_deref().unwrap_or_default()), format!("llm.apiKind={}", status.api_kind), format!("llm.reasoningEffort={}", status.reasoning_effort), format!("llm.stream={}", status.stream), format!("llm.webSearchEnabled={}", status.web_search_enabled), format!("llm.contextWindowTokens={}", status.context_window_tokens), format!( "llm.autoCompactTokenLimit={}", status.auto_compact_token_limit ), format!( "llm.toolOutputTokenLimit={}", status.tool_output_token_limit ), ]; for agent in &status.agents { lines.push(format!( "llm.agent.{}.configured={}", agent.agent_id, agent.configured )); lines.push(format!( "llm.agent.{}.apiKeyPresent={}", agent.agent_id, agent.api_key_present )); lines.push(format!( "llm.agent.{}.baseUrl={}", agent.agent_id, agent.base_url.as_deref().unwrap_or_default() )); lines.push(format!( "llm.agent.{}.model={}", agent.agent_id, agent.model.as_deref().unwrap_or_default() )); lines.push(format!( "llm.agent.{}.apiKind={}", agent.agent_id, agent.api_kind )); lines.push(format!( "llm.agent.{}.reasoningEffort={}", agent.agent_id, agent.reasoning_effort )); lines.push(format!( "llm.agent.{}.stream={}", agent.agent_id, agent.stream )); lines.push(format!( "llm.agent.{}.webSearchEnabled={}", agent.agent_id, agent.web_search_enabled )); lines.push(format!( "llm.agent.{}.contextWindowTokens={}", agent.agent_id, agent.context_window_tokens )); lines.push(format!( "llm.agent.{}.autoCompactTokenLimit={}", agent.agent_id, agent.auto_compact_token_limit )); lines.push(format!( "llm.agent.{}.toolOutputTokenLimit={}", agent.agent_id, agent.tool_output_token_limit )); if let Some(error) = agent.error.as_deref() { lines.push(format!("llm.agent.{}.error={error}", agent.agent_id)); } } if let Some(error) = status.error.as_deref() { lines.push(format!("llm.error={error}")); } lines } fn read_cli_agent_steer_instruction(reader: &mut impl Read) -> Result { const MAX_INSTRUCTION_BYTES: usize = 4 * 1024; const MAX_STDIN_BYTES: u64 = 8 * 1024; let mut bytes = Vec::new(); reader .take(MAX_STDIN_BYTES + 1) .read_to_end(&mut bytes) .map_err(|error| format!("从 stdin 读取 Agent 追加指令失败:{error}"))?; if bytes.len() as u64 > MAX_STDIN_BYTES { return Err(format!( "Agent 追加指令 stdin 超过 {MAX_STDIN_BYTES} 字节上限" )); } let instruction = String::from_utf8(bytes) .map_err(|_| "Agent 追加指令 stdin 必须是 UTF-8 文本".to_string())?; let instruction = instruction.trim(); if instruction.is_empty() { return Err("Agent 追加指令 stdin 不能为空".to_string()); } if instruction.len() > MAX_INSTRUCTION_BYTES { return Err(format!( "Agent 追加指令 stdin 超过 {MAX_INSTRUCTION_BYTES} 字节上限" )); } Ok(instruction.to_string()) } fn read_cli_agent_goal_payload(reader: &mut impl Read) -> Result { const MAX_STDIN_BYTES: u64 = 64 * 1024; let mut bytes = Vec::new(); reader .take(MAX_STDIN_BYTES + 1) .read_to_end(&mut bytes) .map_err(|error| format!("从 stdin 读取 Agent Goal JSON 失败:{error}"))?; if bytes.len() as u64 > MAX_STDIN_BYTES { return Err(format!("Agent Goal stdin 超过 {MAX_STDIN_BYTES} 字节上限")); } if bytes.iter().all(u8::is_ascii_whitespace) { return Err("Agent Goal stdin 不能为空".to_string()); } let mut payload = serde_json::from_slice::(&bytes) .map_err(|error| format!("Agent Goal stdin 必须是结构化 JSON:{error}"))?; payload.outcome = payload.outcome.trim().to_string(); if payload.outcome.is_empty() { return Err("Agent Goal outcome 不能为空".to_string()); } Ok(payload) } fn parse_cli_agent_goal_revision(value: &str, usage: &str) -> Result { let revision = value.parse::().map_err(|_| usage.to_string())?; if revision == 0 { return Err(usage.to_string()); } Ok(revision) } pub(crate) fn parse_cli_command(args: &[String]) -> Result, String> { if args.first().map(String::as_str) == Some("--preview-serve") { if args.len() != 2 || args[1].trim().is_empty() { return Err(PREVIEW_SERVE_USAGE.to_string()); } return Ok(Some(CliCommand::PreviewServe { project_path: PathBuf::from(&args[1]), })); } if args.first().map(String::as_str) == Some("--llm-status") { return Ok(Some(CliCommand::LlmStatus)); } if args.first().map(String::as_str) == Some("--runner-status") { if args.len() != 1 { return Err("用法:--runner-status".to_string()); } return Ok(Some(CliCommand::RunnerStatus)); } if args.first().map(String::as_str) == Some("--runner-shutdown-if-idle") { if args.len() != 1 { return Err("用法:--runner-shutdown-if-idle".to_string()); } return Ok(Some(CliCommand::RunnerShutdownIfIdle)); } if args.first().map(String::as_str) == Some("--agent-runtime-status") { if args.len() != 3 { return Err("用法:--agent-runtime-status <本地项目绝对路径> ".to_string()); } return Ok(Some(CliCommand::AgentRuntimeStatus { project_path: PathBuf::from(&args[1]), agent_id: args[2].trim().to_string(), })); } if args.first().map(String::as_str) == Some("--agent-context-compact") { const USAGE: &str = "用法:--agent-context-compact <本地项目绝对路径> [sessionId]"; if !(args.len() == 3 || args.len() == 4) || args[1..].iter().any(|value| value.trim().is_empty()) { return Err(USAGE.to_string()); } return Ok(Some(CliCommand::AgentContextCompact { project_path: PathBuf::from(&args[1]), agent_id: args[2].trim().to_string(), session_id: args.get(3).map(|value| value.trim().to_string()), })); } if args.first().map(String::as_str) == Some("--agent-goal-status") { const USAGE: &str = "用法:--agent-goal-status <本地项目绝对路径> "; if args.len() != 4 || args[1..].iter().any(|value| value.trim().is_empty()) { return Err(USAGE.to_string()); } return Ok(Some(CliCommand::AgentGoalStatus { project_path: PathBuf::from(&args[1]), agent_id: args[2].trim().to_string(), session_id: args[3].trim().to_string(), })); } if args.first().map(String::as_str) == Some("--agent-goal-start") { const USAGE: &str = "用法:--agent-goal-start [--init] <本地项目绝对路径> --stdin"; let mut rest = args[1..].to_vec(); let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") { rest.remove(index); true } else { false }; if rest.len() != 5 || rest.last().map(String::as_str) != Some("--stdin") || rest[..4].iter().any(|value| value.trim().is_empty()) { return Err(USAGE.to_string()); } return Ok(Some(CliCommand::AgentGoalStart { project_path: PathBuf::from(&rest[0]), agent_id: rest[1].trim().to_string(), session_id: rest[2].trim().to_string(), run_id: rest[3].trim().to_string(), initialize, })); } if args.first().map(String::as_str) == Some("--agent-goal-edit") { const USAGE: &str = "用法:--agent-goal-edit <本地项目绝对路径> --stdin"; if args.len() != 7 || args.last().map(String::as_str) != Some("--stdin") || args[1..5].iter().any(|value| value.trim().is_empty()) { return Err(USAGE.to_string()); } return Ok(Some(CliCommand::AgentGoalEdit { project_path: PathBuf::from(&args[1]), agent_id: args[2].trim().to_string(), session_id: args[3].trim().to_string(), goal_id: args[4].trim().to_string(), expected_revision: parse_cli_agent_goal_revision(&args[5], USAGE)?, })); } if args.first().map(String::as_str) == Some("--agent-goal-pause") { const USAGE: &str = "用法:--agent-goal-pause <本地项目绝对路径> "; if args.len() != 6 || args[1..5].iter().any(|value| value.trim().is_empty()) { return Err(USAGE.to_string()); } return Ok(Some(CliCommand::AgentGoalPause { project_path: PathBuf::from(&args[1]), agent_id: args[2].trim().to_string(), session_id: args[3].trim().to_string(), goal_id: args[4].trim().to_string(), expected_revision: parse_cli_agent_goal_revision(&args[5], USAGE)?, })); } if args.first().map(String::as_str) == Some("--agent-goal-resume") { const USAGE: &str = "用法:--agent-goal-resume <本地项目绝对路径> "; if args.len() != 6 || args[1..5].iter().any(|value| value.trim().is_empty()) { return Err(USAGE.to_string()); } return Ok(Some(CliCommand::AgentGoalResume { project_path: PathBuf::from(&args[1]), agent_id: args[2].trim().to_string(), session_id: args[3].trim().to_string(), goal_id: args[4].trim().to_string(), expected_revision: parse_cli_agent_goal_revision(&args[5], USAGE)?, })); } if args.first().map(String::as_str) == Some("--agent-goal-clear") { const USAGE: &str = "用法:--agent-goal-clear <本地项目绝对路径> "; if args.len() != 6 || args[1..5].iter().any(|value| value.trim().is_empty()) { return Err(USAGE.to_string()); } return Ok(Some(CliCommand::AgentGoalClear { project_path: PathBuf::from(&args[1]), agent_id: args[2].trim().to_string(), session_id: args[3].trim().to_string(), goal_id: args[4].trim().to_string(), expected_revision: parse_cli_agent_goal_revision(&args[5], USAGE)?, })); } if args.first().map(String::as_str) == Some("--agent-confirm") { if args.len() != 5 { return Err( "用法:--agent-confirm <本地项目绝对路径> ".to_string(), ); } return Ok(Some(CliCommand::AgentConfirm { project_path: PathBuf::from(&args[1]), agent_id: args[2].trim().to_string(), run_id: args[3].trim().to_string(), action_id: args[4].trim().to_string(), })); } if args.first().map(String::as_str) == Some("--agent-cancel") { const USAGE: &str = "用法:--agent-cancel <本地项目绝对路径> "; if args.len() != 4 || args[1..].iter().any(|value| value.trim().is_empty()) { return Err(USAGE.to_string()); } return Ok(Some(CliCommand::AgentCancel { project_path: PathBuf::from(&args[1]), agent_id: args[2].trim().to_string(), run_id: args[3].trim().to_string(), })); } if args.first().map(String::as_str) == Some("--agent-retry") { const USAGE: &str = "用法:--agent-retry <本地项目绝对路径> "; if args.len() != 5 || args[1..].iter().any(|value| value.trim().is_empty()) { return Err(USAGE.to_string()); } return Ok(Some(CliCommand::AgentRetry { project_path: PathBuf::from(&args[1]), agent_id: args[2].trim().to_string(), run_id: args[3].trim().to_string(), next_run_id: args[4].trim().to_string(), })); } if args.first().map(String::as_str) == Some("--agent-steer") { const USAGE: &str = "用法:--agent-steer <本地项目绝对路径> --stdin"; if args.len() != 7 || args.last().map(String::as_str) != Some("--stdin") { return Err(USAGE.to_string()); } let values = &args[1..6]; if values.iter().any(|value| value.trim().is_empty()) { return Err(USAGE.to_string()); } return Ok(Some(CliCommand::AgentSteer { project_path: PathBuf::from(&values[0]), agent_id: values[1].trim().to_string(), session_id: values[2].trim().to_string(), run_id: values[3].trim().to_string(), steer_id: values[4].trim().to_string(), })); } if args.first().map(String::as_str) == Some("--agent-resume") { if args.len() != 2 { return Err("用法:--agent-resume <本地项目绝对路径>".to_string()); } return Ok(Some(CliCommand::AgentResume { project_path: PathBuf::from(&args[1]), })); } if args.first().map(String::as_str) == Some("--agent-enqueue") { let mut rest = args[1..].to_vec(); let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") { rest.remove(index); true } else { false }; if rest.len() < 4 { return Err( "用法:--agent-enqueue [--init] <本地项目绝对路径> <任务>" .to_string(), ); } let task = rest[3..].join(" "); if task.trim().is_empty() { return Err("Agent 任务不能为空".to_string()); } return Ok(Some(CliCommand::AgentEnqueue { project_path: PathBuf::from(&rest[0]), agent_id: rest[1].trim().to_string(), run_id: rest[2].trim().to_string(), task: task.trim().to_string(), initialize, })); } if args.first().map(String::as_str) == Some("--agent-chat") { let project_path = args.get(1).map(String::as_str).ok_or_else(|| { "用法:--agent-chat <本地项目绝对路径> <聊天内容>".to_string() })?; let agent_id = args.get(2).map(String::as_str).ok_or_else(|| { "用法:--agent-chat <本地项目绝对路径> <聊天内容>".to_string() })?; if args.len() < 4 { return Err("用法:--agent-chat <本地项目绝对路径> <聊天内容>".to_string()); } let prompt = args[3..].join(" "); let prompt = prompt.trim(); if prompt.is_empty() { return Err("聊天内容不能为空".to_string()); } return Ok(Some(CliCommand::AgentChat { project_path: PathBuf::from(project_path), agent_id: agent_id.trim().to_string(), prompt: prompt.to_string(), })); } if args.first().map(String::as_str) == Some("--swarm-chat") { const USAGE: &str = "用法:--swarm-chat [--init] [--autonomous-game-build] <本地项目绝对路径> [parentAgentId]"; let mut rest = args[1..].to_vec(); let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") { rest.remove(index); true } else { false }; let autonomous_game_build = match rest .iter() .filter(|arg| arg.as_str() == "--autonomous-game-build") .count() { 0 => false, 1 => { let index = rest .iter() .position(|arg| arg == "--autonomous-game-build") .expect("counted autonomous game build flag"); rest.remove(index); true } _ => return Err(USAGE.to_string()), }; if !(1..=2).contains(&rest.len()) || rest.iter().any(|value| value.trim().is_empty()) { return Err(USAGE.to_string()); } return Ok(Some(CliCommand::SwarmChat { project_path: PathBuf::from(&rest[0]), parent_agent_id: rest .get(1) .map(|value| value.trim().to_string()) .unwrap_or_else(|| GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), initialize, run_profile: if autonomous_game_build { AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD.to_string() } else { AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string() }, })); } if args.first().map(String::as_str) == Some("--agent-task") { let mut rest = args[1..].to_vec(); let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") { rest.remove(index); true } else { false }; let project_path = rest.first().map(String::as_str).ok_or_else(|| { "用法:--agent-task [--init] <本地项目绝对路径> <任务>".to_string() })?; let agent_id = rest.get(1).map(String::as_str).ok_or_else(|| { "用法:--agent-task [--init] <本地项目绝对路径> <任务>".to_string() })?; if rest.len() < 3 { return Err( "用法:--agent-task [--init] <本地项目绝对路径> <任务>".to_string(), ); } let task = rest[2..].join(" "); let task = task.trim(); if task.is_empty() { return Err("Agent 任务不能为空".to_string()); } return Ok(Some(CliCommand::AgentTask { project_path: PathBuf::from(project_path), agent_id: agent_id.trim().to_string(), task: task.to_string(), initialize, })); } if args.first().map(String::as_str) != Some("--agent-run") { return Ok(None); } let mut rest = args[1..].to_vec(); let wait_for_enter = if let Some(index) = rest.iter().position(|arg| arg == "--no-wait") { rest.remove(index); false } else { true }; let project_path = rest .first() .map(String::as_str) .ok_or_else(|| "用法:--agent-run [--no-wait] <本地项目绝对路径> <创作需求>".to_string())?; if rest.len() < 2 { return Err("用法:--agent-run [--no-wait] <本地项目绝对路径> <创作需求>".to_string()); } let prompt = rest[1..].join(" "); let prompt = prompt.trim(); if prompt.is_empty() { return Err("创作需求不能为空".to_string()); } Ok(Some(CliCommand::AgentRun { project_path: PathBuf::from(project_path), prompt: prompt.to_string(), wait_for_enter, })) } fn strip_agent_runtime_cli_private_paths(value: &mut serde_json::Value) { match value { serde_json::Value::Array(values) => { for value in values { strip_agent_runtime_cli_private_paths(value); } } serde_json::Value::Object(object) => { object.remove("sessionPath"); object.remove("eventPath"); object.remove("taskPath"); for value in object.values_mut() { strip_agent_runtime_cli_private_paths(value); } } _ => {} } } fn serialize_agent_runtime_cli_payload(payload: &T) -> Result { let mut value = serde_json::to_value(payload) .map_err(|error| format!("序列化 Agent Runtime 状态失败:{error}"))?; strip_agent_runtime_cli_private_paths(&mut value); serde_json::to_string(&value).map_err(|error| format!("序列化 Agent Runtime 状态失败:{error}")) } pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { match command { CliCommand::LlmStatus => { let status = check_game_creator_llm_config_from_config(); for line in game_creator_llm_status_lines(&status) { println!("{line}"); } if status.configured { Ok(()) } else { Err("LLM 配置未就绪".to_string()) } } CliCommand::PreviewServe { project_path } => { let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .map_err(|error| format!("创建 CLI runtime 失败:{error}"))?; let registry = game_creator_preview_registry(); let preview = start_local_game_preview_at(&project_path, ®istry)?; println!("preview.running"); println!("projectPath={}", project_path.display()); println!("previewUrl={}", preview.url); let wait_result = std::io::stdout() .flush() .map_err(|error| format!("刷新本地预览 CLI 输出失败:{error}")) .and_then(|()| { runtime .block_on(tokio::signal::ctrl_c()) .map_err(|error| format!("等待 Ctrl+C 失败:{error}")) }); let stop_result = stop_local_game_preview_for_root(Some(&project_path), ®istry) .map(|_| ()) .map_err(|error| format!("停止本地预览失败:{error}")); match (wait_result, stop_result) { (Ok(()), Ok(())) => Ok(()), (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error), (Err(wait_error), Err(stop_error)) => { Err(format!("{wait_error};同时{stop_error}")) } } } CliCommand::AgentChat { project_path, agent_id, prompt, } => { let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .map_err(|error| format!("创建 CLI runtime 失败:{error}"))?; let reply = runtime.block_on(chat_with_game_creator_role_agent_at( &project_path, &agent_id, &prompt, ))?; println!("agent.chat.completed"); println!("projectPath={}", project_path.display()); println!("agentId={agent_id}"); println!("replyText={}", reply.reply_text); Ok(()) } CliCommand::AgentTask { project_path, agent_id, task, initialize, } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", initialize)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; if initialize && !project_path.join(".agent/manifest.json").is_file() { let project_name = project_path .file_name() .and_then(|value| value.to_str()) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("CLI Agent 项目"); init_local_game_project_at( &project_path, &format!("cli-agent-{}", unix_millis()), project_name, )?; } if !project_path.join(".agent/manifest.json").is_file() { return Err("项目尚未初始化;请先在 App 中创建项目,或显式传入 --init".to_string()); } let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .map_err(|error| format!("创建 CLI runtime 失败:{error}"))?; let run_id = format!("cli-{agent_id}-{}", unix_millis()); let terminal = runtime.block_on(async { let started = start_game_creator_agent_background_task_at( &project_path, &agent_id, &task, &run_id, )?; let canonical_run_id = started.state.run_id.clone(); let deadline = std::time::Instant::now() + Duration::from_secs(600); loop { let current = read_game_creator_agent_runtime_at(&project_path, &agent_id)?; if current.state.run_id == canonical_run_id && (current.state.status == "idle" || current.state.status == "failed" || current.state.status == "waiting-for-confirmation" || current.state.status == "waiting-for-user-input") { break Ok::(current.state); } if std::time::Instant::now() >= deadline { break Err(format!( "等待单 Agent 任务超时:agentId={agent_id} runId={canonical_run_id}" )); } tokio::time::sleep(Duration::from_millis(100)).await; } })?; println!("agent.task.terminal"); println!("projectPath={}", project_path.display()); println!("agentId={agent_id}"); println!("runId={}", terminal.run_id); println!("status={}", terminal.status); println!("phase={}", terminal.phase); if let Some(reply) = terminal.last_response.as_deref() { println!("replyText={reply}"); } if let Some(pending) = terminal.pending_tool_action.as_ref() { println!("pendingActionId={}", pending.action_id); println!("pendingTool={}", pending.tool); if let Some(summary) = pending.input_summary.as_deref() { println!("pendingInput={summary}"); } } if let Some(error) = terminal.error.as_deref() { println!("error={error}"); } if terminal.status == "idle" && terminal.phase == "completed" { Ok(()) } else if terminal.status == "waiting-for-confirmation" { Err("单 Agent 任务正在等待开发者确认,请在开发窗口继续".to_string()) } else if terminal.status == "waiting-for-user-input" { Err("单 Agent 任务正在等待用户回答,请使用 agc:chat 继续".to_string()) } else { Err(format!( "单 Agent 任务未完成:{} / {}", terminal.status, terminal.phase )) } } CliCommand::SwarmChat { project_path, parent_agent_id, initialize, run_profile, } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", initialize)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; initialize_cli_agent_project(&project_path, initialize)?; run_game_creator_swarm_chat_at(&project_path, &parent_agent_id, &run_profile) } CliCommand::AgentEnqueue { project_path, agent_id, run_id, task, initialize, } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", initialize)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; initialize_cli_agent_project(&project_path, initialize)?; let runtime = start_game_creator_agent_background_task_at( &project_path, &agent_id, &task, &run_id, )?; println!("agent.enqueue.accepted"); println!("agentId={agent_id}"); println!("requestedRunId={run_id}"); println!( "runtimeJson={}", serialize_agent_runtime_cli_payload(&runtime)? ); Ok(()) } CliCommand::AgentRuntimeStatus { project_path, agent_id, } => { let runtime = read_game_creator_agent_runtime_at(&project_path, &agent_id)?; println!( "runtimeJson={}", serialize_agent_runtime_cli_payload(&runtime)? ); Ok(()) } CliCommand::AgentContextCompact { project_path, agent_id, session_id, } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; let result = compact_external_agent_runner_context( &project_path, &agent_id, session_id.as_deref(), )?; println!( "contextCompactionJson={}", serde_json::to_string(&result) .map_err(|error| format!("序列化上下文压缩结果失败:{error}"))? ); Ok(()) } CliCommand::AgentGoalStatus { project_path, agent_id, session_id, } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; let goal = read_game_creator_agent_goal( project_path.display().to_string(), agent_id, session_id, )?; println!("agent.goal.status"); println!("goalJson={}", serialize_agent_runtime_cli_payload(&goal)?); Ok(()) } CliCommand::AgentGoalStart { project_path, agent_id, session_id, run_id, initialize, } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", initialize)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; if initialize && !project_path.join(".agent/manifest.json").is_file() { let project_name = project_path .file_name() .and_then(|value| value.to_str()) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("CLI Goal 项目"); init_local_game_project_at( &project_path, &format!("cli-goal-{}", unix_millis()), project_name, )?; } if !project_path.join(".agent/manifest.json").is_file() { return Err("项目尚未初始化;请先在 App 中创建项目,或显式传入 --init".to_string()); } let payload = read_cli_agent_goal_payload(&mut std::io::stdin().lock())?; let result = start_game_creator_agent_goal( project_path.display().to_string(), agent_id, Some(session_id), payload.outcome, payload.constraints, payload.verification, run_id, )?; println!("agent.goal.started"); println!( "goalMutationJson={}", serialize_agent_runtime_cli_payload(&result)? ); Ok(()) } CliCommand::AgentGoalEdit { project_path, agent_id, session_id, goal_id, expected_revision, } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; let payload = read_cli_agent_goal_payload(&mut std::io::stdin().lock())?; let result = edit_game_creator_agent_goal( project_path.display().to_string(), agent_id, session_id, goal_id, expected_revision, payload.outcome, payload.constraints, payload.verification, )?; println!("agent.goal.edited"); println!( "goalMutationJson={}", serialize_agent_runtime_cli_payload(&result)? ); Ok(()) } CliCommand::AgentGoalPause { project_path, agent_id, session_id, goal_id, expected_revision, } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; let result = pause_game_creator_agent_goal( project_path.display().to_string(), agent_id, session_id, goal_id, expected_revision, )?; println!("agent.goal.paused"); println!( "goalMutationJson={}", serialize_agent_runtime_cli_payload(&result)? ); Ok(()) } CliCommand::AgentGoalResume { project_path, agent_id, session_id, goal_id, expected_revision, } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; let result = resume_game_creator_agent_goal( project_path.display().to_string(), agent_id, session_id, goal_id, expected_revision, )?; println!("agent.goal.resumed"); println!( "goalMutationJson={}", serialize_agent_runtime_cli_payload(&result)? ); Ok(()) } CliCommand::AgentGoalClear { project_path, agent_id, session_id, goal_id, expected_revision, } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; let result = clear_game_creator_agent_goal( project_path.display().to_string(), agent_id, session_id, goal_id, expected_revision, )?; println!("agent.goal.cleared"); println!( "goalMutationJson={}", serialize_agent_runtime_cli_payload(&result)? ); Ok(()) } CliCommand::AgentConfirm { project_path, agent_id, run_id, action_id, } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; let runtime = confirm_game_creator_agent_runtime_task_at( &project_path, &agent_id, &run_id, &action_id, "真实 E2E CLI 精确确认", )?; println!("agent.confirm.accepted"); println!( "runtimeJson={}", serialize_agent_runtime_cli_payload(&runtime)? ); Ok(()) } CliCommand::AgentCancel { project_path, agent_id, run_id, } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; // Cancellation must remain available when a busy older Runner blocks a build // handover. It writes the durable cancel tombstone/state locally; retry still // requires the current executable's Runner after the old run becomes idle. require_external_agent_runner_configured_for_cli_runtime_write(&project_path)?; let runtime = cancel_game_creator_agent_runtime_task_at(&project_path, &agent_id, &run_id)?; println!("agent.cancel.accepted"); println!( "runtimeJson={}", serialize_agent_runtime_cli_payload(&runtime)? ); Ok(()) } CliCommand::AgentRetry { project_path, agent_id, run_id, next_run_id, } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; let runtime = retry_game_creator_agent_runtime_task_at( &project_path, &agent_id, &run_id, &next_run_id, )?; println!("agent.retry.accepted"); println!( "runtimeJson={}", serialize_agent_runtime_cli_payload(&runtime)? ); Ok(()) } CliCommand::AgentSteer { project_path, agent_id, session_id, run_id, steer_id, } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; let instruction = read_cli_agent_steer_instruction(&mut std::io::stdin().lock())?; let mut result = steer_game_creator_agent_runtime_task_at( &project_path, &agent_id, &session_id, &run_id, &steer_id, &instruction, "cli", )?; if !result.provider_interrupted { result.provider_interrupted = steer_external_agent_runner(&project_path, &agent_id, &run_id, &steer_id)?; } println!("agent.steer.accepted"); println!("agentId={agent_id}"); println!("sessionId={session_id}"); println!("runId={run_id}"); println!("steerId={steer_id}"); println!( "steerJson={}", serialize_agent_runtime_cli_payload(&result)? ); Ok(()) } CliCommand::AgentResume { project_path } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; let runtimes = resume_game_creator_agent_background_tasks_at(&project_path)?; println!("agent.resume.accepted"); println!( "runtimesJson={}", serialize_agent_runtime_cli_payload(&runtimes)? ); Ok(()) } CliCommand::RunnerStatus => { println!( "runnerJson={}", serde_json::to_string(&read_external_agent_runner_status()) .map_err(|error| format!("序列化 Agent Runner 状态失败:{error}"))? ); Ok(()) } CliCommand::RunnerShutdownIfIdle => { let stopped = shutdown_external_agent_runner_if_idle()?; println!("runner.stopped={stopped}"); Ok(()) } CliCommand::AgentRun { project_path, prompt, wait_for_enter, } => { let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .map_err(|error| format!("创建 CLI runtime 失败:{error}"))?; let result = runtime.block_on(generate_local_game_draft_at(&project_path, &prompt, None))?; let (preview, stop) = start_local_game_preview_for_project(&project_path)?; record_preview_state( &project_path, GameCreationAppPreviewStatus::Running, Some(preview.url.clone()), Some(preview.port), )?; append_preview_log(&project_path, "running", Some(&preview.url))?; append_preview_start_trace_step(&project_path, &preview)?; println!("agent.run.completed"); println!("projectPath={}", result.project_path); println!("gameIndexPath={}", result.game_index_path); println!("designPath={}", result.design_path); println!( "tracePath={}", project_path.join(".agent/run.latest.json").display() ); println!("previewUrl={}", preview.url); if wait_for_enter { println!("按 Enter 停止本地预览。"); let mut line = String::new(); let _ = std::io::stdin().read_line(&mut line); } let _ = stop.send(()); let _ = record_preview_state( &project_path, GameCreationAppPreviewStatus::Stopped, None, None, ); let _ = append_preview_log(&project_path, "stopped", None); let _ = append_preview_stop_trace_step(&project_path); Ok(()) } } } fn initialize_cli_agent_project(project_path: &Path, initialize: bool) -> Result<(), String> { if initialize && !project_path.join(".agent/manifest.json").is_file() { let project_name = project_path .file_name() .and_then(|value| value.to_str()) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("CLI Agent 项目"); init_local_game_project_at( project_path, &format!("cli-agent-{}", unix_millis()), project_name, )?; } if !project_path.join(".agent/manifest.json").is_file() { return Err("项目尚未初始化;请先在 App 中创建项目,或显式传入 --init".to_string()); } Ok(()) } #[cfg(test)] mod tests { use super::*; use std::io::Cursor; #[test] fn runtime_cli_payload_omits_private_storage_paths_recursively() { let payload = serde_json::json!({ "sessionPath": "/tmp/private/session.json", "runtime": { "eventPath": "/tmp/private/events.jsonl", "state": { "sessionId": "session-7", "runId": "run-9" } }, "runtimes": [{ "taskPath": "/tmp/private/tasks.jsonl", "state": { "status": "running" } }] }); let encoded = serialize_agent_runtime_cli_payload(&payload) .expect("serialize redacted Runtime CLI payload"); assert!(!encoded.contains("/tmp/private")); let parsed = serde_json::from_str::(&encoded) .expect("parse redacted Runtime CLI payload"); assert!(parsed.get("sessionPath").is_none()); assert!(parsed["runtime"].get("eventPath").is_none()); assert!(parsed["runtimes"][0].get("taskPath").is_none()); assert_eq!(parsed["runtime"]["state"]["sessionId"], "session-7"); assert_eq!(parsed["runtimes"][0]["state"]["status"], "running"); } #[test] fn parses_preview_serve_without_runner_or_config() { let project_path = std::env::current_dir().expect("current directory"); let mut command = parse_cli_command(&[ "--preview-serve".to_string(), project_path.display().to_string(), ]) .expect("parse preview serve") .expect("preview serve command"); assert_eq!( command, CliCommand::PreviewServe { project_path: project_path.clone(), } ); assert!(!command.requires_external_agent_runner()); assert!(!command.requires_started_external_agent_runner()); assert!(!command.is_read_only_status()); assert_eq!( prepare_cli_command_paths(&mut command, None) .expect("prepare preview serve without config dir"), None ); assert_eq!( command, CliCommand::PreviewServe { project_path: fs::canonicalize(project_path).expect("canonical project path"), } ); } #[test] fn preview_serve_requires_exactly_one_non_empty_project_path() { for args in [ vec!["--preview-serve"], vec!["--preview-serve", " "], vec!["--preview-serve", "/tmp/game-project", "/tmp/extra"], ] { let args = args.into_iter().map(str::to_string).collect::>(); assert_eq!( parse_cli_command(&args), Err(PREVIEW_SERVE_USAGE.to_string()), "unexpected parse result for {args:?}" ); } } #[test] fn parses_agent_steer_with_stdin_only_contract() { let command = parse_cli_command(&[ "--agent-steer".to_string(), "/tmp/game-project".to_string(), "code-prototype".to_string(), "session-7".to_string(), "run-9".to_string(), "steer-11".to_string(), "--stdin".to_string(), ]) .expect("parse agent steer") .expect("agent steer command"); assert_eq!( command, CliCommand::AgentSteer { project_path: PathBuf::from("/tmp/game-project"), agent_id: "code-prototype".to_string(), session_id: "session-7".to_string(), run_id: "run-9".to_string(), steer_id: "steer-11".to_string(), } ); assert!(command.requires_external_agent_runner()); } #[test] fn parses_agent_cancel_and_retry_with_explicit_run_identity() { let project_path = PathBuf::from("/tmp/game-project"); let cancel = parse_cli_command(&[ "--agent-cancel".to_string(), project_path.display().to_string(), " project-supervisor ".to_string(), " run-9 ".to_string(), ]) .expect("parse agent cancel") .expect("agent cancel command"); assert_eq!( cancel, CliCommand::AgentCancel { project_path: project_path.clone(), agent_id: "project-supervisor".to_string(), run_id: "run-9".to_string(), } ); assert!(cancel.requires_external_agent_runner()); assert!(!cancel.requires_started_external_agent_runner()); assert!(!cancel.is_read_only_status()); let retry = parse_cli_command(&[ "--agent-retry".to_string(), project_path.display().to_string(), " project-supervisor ".to_string(), " run-9 ".to_string(), " run-10 ".to_string(), ]) .expect("parse agent retry") .expect("agent retry command"); assert_eq!( retry, CliCommand::AgentRetry { project_path, agent_id: "project-supervisor".to_string(), run_id: "run-9".to_string(), next_run_id: "run-10".to_string(), } ); assert!(retry.requires_external_agent_runner()); assert!(retry.requires_started_external_agent_runner()); assert!(!retry.is_read_only_status()); } #[test] fn agent_cancel_and_retry_reject_missing_or_blank_identity() { for args in [ vec!["--agent-cancel"], vec![ "--agent-cancel", "/tmp/game-project", "project-supervisor", " ", ], vec![ "--agent-retry", "/tmp/game-project", "project-supervisor", "run-9", ], vec![ "--agent-retry", "/tmp/game-project", "project-supervisor", "run-9", "\t", ], ] { let args = args.into_iter().map(str::to_string).collect::>(); assert!( parse_cli_command(&args).is_err(), "args should fail: {args:?}" ); } } #[test] fn agent_steer_rejects_missing_params_and_argv_instruction() { assert!(parse_cli_command(&["--agent-steer".to_string()]).is_err()); assert!(parse_cli_command(&[ "--agent-steer".to_string(), "/tmp/game-project".to_string(), "code-prototype".to_string(), "session-7".to_string(), "run-9".to_string(), "steer-11".to_string(), ]) .is_err()); assert!(parse_cli_command(&[ "--agent-steer".to_string(), "/tmp/game-project".to_string(), "code-prototype".to_string(), "session-7".to_string(), "run-9".to_string(), "steer-11".to_string(), "正文不能出现在 argv".to_string(), ]) .is_err()); } #[test] fn reads_agent_steer_instruction_only_from_stdin() { let mut stdin = Cursor::new(" 先停下当前方案,改用键盘操作。\n"); assert_eq!( read_cli_agent_steer_instruction(&mut stdin).as_deref(), Ok("先停下当前方案,改用键盘操作。") ); let mut empty = Cursor::new(" \r\n\t"); assert!(read_cli_agent_steer_instruction(&mut empty).is_err()); let mut exact_with_newline = Cursor::new(format!("{}\n", "x".repeat(4 * 1024))); assert_eq!( read_cli_agent_steer_instruction(&mut exact_with_newline) .expect("read exact-size instruction") .len(), 4 * 1024 ); let mut oversized = Cursor::new(vec![b'x'; 4 * 1024 + 1]); assert!(read_cli_agent_steer_instruction(&mut oversized).is_err()); } #[test] fn agent_steer_requires_external_config_dir() { let mut command = CliCommand::AgentSteer { project_path: std::env::current_dir().expect("current directory"), agent_id: "code-prototype".to_string(), session_id: "session-7".to_string(), run_id: "run-9".to_string(), steer_id: "steer-11".to_string(), }; let error = prepare_cli_command_paths(&mut command, None) .expect_err("agent steer must require config dir"); assert!(error.contains("--config-dir")); } #[test] fn parses_all_non_interactive_goal_commands_with_explicit_cas_identity() { let project_path = PathBuf::from("/tmp/game-project"); let status = parse_cli_command(&[ "--agent-goal-status".to_string(), project_path.display().to_string(), "code-prototype".to_string(), "session-7".to_string(), ]) .expect("parse goal status") .expect("goal status command"); assert_eq!( status, CliCommand::AgentGoalStatus { project_path: project_path.clone(), agent_id: "code-prototype".to_string(), session_id: "session-7".to_string(), } ); assert!(status.is_read_only_status()); assert!(!status.requires_external_agent_runner()); let start = parse_cli_command(&[ "--agent-goal-start".to_string(), project_path.display().to_string(), "code-prototype".to_string(), "session-7".to_string(), "goal-run-9".to_string(), "--stdin".to_string(), ]) .expect("parse goal start") .expect("goal start command"); assert_eq!( start, CliCommand::AgentGoalStart { project_path: project_path.clone(), agent_id: "code-prototype".to_string(), session_id: "session-7".to_string(), run_id: "goal-run-9".to_string(), initialize: false, } ); let mut initialized_start = parse_cli_command(&[ "--agent-goal-start".to_string(), "--init".to_string(), project_path.display().to_string(), "code-prototype".to_string(), "session-8".to_string(), "goal-run-10".to_string(), "--stdin".to_string(), ]) .expect("parse initialized goal start") .expect("initialized goal start command"); assert_eq!( initialized_start, CliCommand::AgentGoalStart { project_path: project_path.clone(), agent_id: "code-prototype".to_string(), session_id: "session-8".to_string(), run_id: "goal-run-10".to_string(), initialize: true, } ); let (_, initialize) = initialized_start .project_path_mut() .expect("initialized Goal start project path"); assert!(initialize); let edit = parse_cli_command(&[ "--agent-goal-edit".to_string(), project_path.display().to_string(), "code-prototype".to_string(), "session-7".to_string(), "goal-11".to_string(), "3".to_string(), "--stdin".to_string(), ]) .expect("parse goal edit") .expect("goal edit command"); assert_eq!( edit, CliCommand::AgentGoalEdit { project_path: project_path.clone(), agent_id: "code-prototype".to_string(), session_id: "session-7".to_string(), goal_id: "goal-11".to_string(), expected_revision: 3, } ); let cas_commands = [ ("--agent-goal-pause", "pause"), ("--agent-goal-resume", "resume"), ("--agent-goal-clear", "clear"), ]; for (flag, expected) in cas_commands { let command = parse_cli_command(&[ flag.to_string(), project_path.display().to_string(), "code-prototype".to_string(), "session-7".to_string(), "goal-11".to_string(), "3".to_string(), ]) .expect("parse Goal CAS command") .expect("Goal CAS command"); match (expected, &command) { ( "pause", CliCommand::AgentGoalPause { expected_revision, .. }, ) | ( "resume", CliCommand::AgentGoalResume { expected_revision, .. }, ) | ( "clear", CliCommand::AgentGoalClear { expected_revision, .. }, ) => { assert_eq!(*expected_revision, 3) } _ => panic!("unexpected Goal CAS command: {command:?}"), } assert!(command.requires_external_agent_runner()); assert!(!command.is_read_only_status()); } assert!(start.requires_external_agent_runner()); assert!(edit.requires_external_agent_runner()); } #[test] fn goal_cli_rejects_argv_bodies_bad_revisions_and_keeps_global_resume() { assert!(parse_cli_command(&[ "--agent-goal-start".to_string(), "/tmp/game-project".to_string(), "code-prototype".to_string(), "session-7".to_string(), "goal-run-9".to_string(), "正文不能出现在 argv".to_string(), ]) .is_err()); assert!(parse_cli_command(&[ "--agent-goal-edit".to_string(), "/tmp/game-project".to_string(), "code-prototype".to_string(), "session-7".to_string(), "goal-11".to_string(), "0".to_string(), "--stdin".to_string(), ]) .is_err()); assert!(parse_cli_command(&[ "--agent-goal-pause".to_string(), "/tmp/game-project".to_string(), "code-prototype".to_string(), "session-7".to_string(), "goal-11".to_string(), "not-a-revision".to_string(), ]) .is_err()); assert_eq!( parse_cli_command(&[ "--agent-resume".to_string(), "/tmp/game-project".to_string(), ]) .expect("parse global agent resume") .expect("global agent resume command"), CliCommand::AgentResume { project_path: PathBuf::from("/tmp/game-project"), } ); } #[test] fn reads_strict_structured_goal_payload_from_stdin() { let mut stdin = Cursor::new( r#"{ "outcome": " 完成首个可玩版本 ", "constraints": ["不新增平行 Runtime"], "verification": ["键盘与触屏均可完成一局"] }"#, ); assert_eq!( read_cli_agent_goal_payload(&mut stdin).expect("read Goal JSON"), CliAgentGoalPayload { outcome: "完成首个可玩版本".to_string(), constraints: vec!["不新增平行 Runtime".to_string()], verification: vec!["键盘与触屏均可完成一局".to_string()], } ); for invalid in [ r#"{"outcome":"目标","constraints":[]}"#, r#"{"outcome":"目标","constraints":[],"verification":[],"extra":true}"#, r#"{"outcome":" ","constraints":[],"verification":[]}"#, "not-json", " ", ] { let mut stdin = Cursor::new(invalid); assert!( read_cli_agent_goal_payload(&mut stdin).is_err(), "invalid payload should fail: {invalid}" ); } let mut oversized = Cursor::new(vec![b'x'; 64 * 1024 + 1]); assert!(read_cli_agent_goal_payload(&mut oversized).is_err()); } #[test] fn parses_swarm_chat_and_requires_external_config_dir() { let project_path = std::env::current_dir().expect("current directory"); let mut command = parse_cli_command(&[ "--swarm-chat".to_string(), "--init".to_string(), project_path.display().to_string(), "code-prototype".to_string(), ]) .expect("parse swarm chat") .expect("swarm chat command"); assert_eq!( command, CliCommand::SwarmChat { project_path, parent_agent_id: "code-prototype".to_string(), initialize: true, run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), } ); assert!(command.requires_external_agent_runner()); let error = prepare_cli_command_paths(&mut command, None) .expect_err("swarm chat must require config dir"); assert!(error.contains("--config-dir")); } #[test] fn parses_idle_runner_shutdown_without_starting_a_new_runner() { let mut command = parse_cli_command(&["--runner-shutdown-if-idle".to_string()]) .expect("parse idle Runner shutdown") .expect("idle Runner shutdown command"); assert_eq!(command, CliCommand::RunnerShutdownIfIdle); assert!(command.requires_external_agent_runner()); assert!(!command.requires_started_external_agent_runner()); let error = prepare_cli_command_paths(&mut command, None) .expect_err("idle Runner shutdown must require config dir"); assert!(error.contains("--config-dir")); assert!(parse_cli_command( &["--runner-shutdown-if-idle".to_string(), "extra".to_string(),] ) .is_err()); } #[test] fn parses_agent_context_compaction_with_optional_session() { assert_eq!( parse_cli_command(&[ "--agent-context-compact".to_string(), "/tmp/game-project".to_string(), "code-prototype".to_string(), "agent-session-code-prototype".to_string(), ]) .expect("parse context compaction"), Some(CliCommand::AgentContextCompact { project_path: PathBuf::from("/tmp/game-project"), agent_id: "code-prototype".to_string(), session_id: Some("agent-session-code-prototype".to_string()), }) ); assert_eq!( parse_cli_command(&[ "--agent-context-compact".to_string(), "/tmp/game-project".to_string(), "code-prototype".to_string(), ]) .expect("parse active-session compaction"), Some(CliCommand::AgentContextCompact { project_path: PathBuf::from("/tmp/game-project"), agent_id: "code-prototype".to_string(), session_id: None, }) ); assert!(parse_cli_command(&[ "--agent-context-compact".to_string(), "/tmp/game-project".to_string(), ]) .is_err()); } #[test] fn swarm_chat_defaults_to_project_supervisor() { let project_path = std::env::current_dir().expect("current directory"); let command = parse_cli_command(&[ "--swarm-chat".to_string(), project_path.display().to_string(), ]) .expect("parse supervisor chat") .expect("supervisor chat command"); assert_eq!( command, CliCommand::SwarmChat { project_path, parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), initialize: false, run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), } ); } #[test] fn swarm_chat_autonomous_game_build_flag_selects_autonomous_profile() { let project_path = std::env::current_dir().expect("current directory"); let command = parse_cli_command(&[ "--swarm-chat".to_string(), project_path.display().to_string(), "--autonomous-game-build".to_string(), ]) .expect("parse autonomous supervisor chat") .expect("autonomous supervisor chat command"); assert_eq!( command, CliCommand::SwarmChat { project_path, parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), initialize: false, run_profile: AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD.to_string(), } ); } #[test] fn swarm_chat_rejects_missing_or_extra_arguments() { assert!(parse_cli_command(&["--swarm-chat".to_string()]).is_err()); assert!(parse_cli_command(&[ "--swarm-chat".to_string(), "/tmp/game-project".to_string(), "code-prototype".to_string(), "extra".to_string(), ]) .is_err()); assert!(parse_cli_command(&[ "--swarm-chat".to_string(), "--autonomous-game-build".to_string(), "--autonomous-game-build".to_string(), "/tmp/game-project".to_string(), ]) .is_err()); } }