Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/cli.rs
T
AIGameCreator App ddbca15e30
Project CI / Frontend tests (push) Failing after 50s
Project CI / Backend tests (push) Successful in 6m58s
Project CI / Repository checks (push) Successful in 2m42s
Project CI / Native shell tests (push) Failing after 11m11s
修复Agent Swarm聊天测试Runner冲突
为人工聊天测试复制隔离 AppData,避免复用正式 Runner
新增空闲 Runner 安全收束与临时目录清理
补齐初始化占位页校验、回归测试和开发文档
2026-07-23 13:52:06 +08:00

1959 lines
72 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String>,
},
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<String>,
verification: Vec<String>,
}
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<PathBuf, String> {
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<Option<PathBuf>, 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<String>,
) -> Result<Option<PathBuf>, String> {
let positions = args
.iter()
.enumerate()
.filter_map(|(index, arg)| (arg == "--config-dir").then_some(index))
.collect::<Vec<_>>();
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<String> {
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<String, String> {
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<CliAgentGoalPayload, String> {
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::<CliAgentGoalPayload>(&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<u64, String> {
let revision = value.parse::<u64>().map_err(|_| usage.to_string())?;
if revision == 0 {
return Err(usage.to_string());
}
Ok(revision)
}
pub(crate) fn parse_cli_command(args: &[String]) -> Result<Option<CliCommand>, 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 <本地项目绝对路径> <agentId>".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 <本地项目绝对路径> <agentId> [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 <本地项目绝对路径> <agentId> <sessionId>";
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] <本地项目绝对路径> <agentId> <sessionId> <runId> --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 <本地项目绝对路径> <agentId> <sessionId> <goalId> <revision> --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 <本地项目绝对路径> <agentId> <sessionId> <goalId> <revision>";
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 <本地项目绝对路径> <agentId> <sessionId> <goalId> <revision>";
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 <本地项目绝对路径> <agentId> <sessionId> <goalId> <revision>";
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 <本地项目绝对路径> <agentId> <runId> <actionId>".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 <本地项目绝对路径> <agentId> <runId>";
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 <本地项目绝对路径> <agentId> <runId> <nextRunId>";
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 <本地项目绝对路径> <agentId> <sessionId> <runId> <steerId> --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] <本地项目绝对路径> <agentId> <runId> <任务>"
.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 <本地项目绝对路径> <agentId> <聊天内容>".to_string()
})?;
let agent_id = args.get(2).map(String::as_str).ok_or_else(|| {
"用法:--agent-chat <本地项目绝对路径> <agentId> <聊天内容>".to_string()
})?;
if args.len() < 4 {
return Err("用法:--agent-chat <本地项目绝对路径> <agentId> <聊天内容>".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] <本地项目绝对路径> <agentId> <任务>".to_string()
})?;
let agent_id = rest.get(1).map(String::as_str).ok_or_else(|| {
"用法:--agent-task [--init] <本地项目绝对路径> <agentId> <任务>".to_string()
})?;
if rest.len() < 3 {
return Err(
"用法:--agent-task [--init] <本地项目绝对路径> <agentId> <任务>".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<T: serde::Serialize>(payload: &T) -> Result<String, String> {
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, &registry)?;
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), &registry)
.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::<AgentRuntimeState, String>(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::<serde_json::Value>(&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::<Vec<_>>();
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::<Vec<_>>();
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());
}
}