2417 lines
90 KiB
Rust
2417 lines
90 KiB
Rust
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,
|
||
},
|
||
DirectCodexChat {
|
||
project_path: PathBuf,
|
||
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,
|
||
supervisor_source: &'static str,
|
||
},
|
||
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,
|
||
},
|
||
PlanGddStatus {
|
||
project_path: PathBuf,
|
||
},
|
||
PlanGddDecide {
|
||
project_path: PathBuf,
|
||
action: String,
|
||
response_id: Option<String>,
|
||
read_comment_from_stdin: bool,
|
||
},
|
||
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::PlanGddDecide { .. }
|
||
| Self::RunnerShutdownIfIdle
|
||
)
|
||
}
|
||
|
||
pub(crate) fn is_read_only_status(&self) -> bool {
|
||
matches!(
|
||
self,
|
||
Self::AgentRuntimeStatus { .. }
|
||
| Self::AgentGoalStatus { .. }
|
||
| Self::PlanGddStatus { .. }
|
||
| Self::RunnerStatus
|
||
)
|
||
}
|
||
|
||
pub(crate) fn requires_started_external_agent_runner(&self) -> bool {
|
||
self.requires_external_agent_runner()
|
||
&& !matches!(
|
||
self,
|
||
Self::AgentCancel { .. } | Self::AgentResume { .. } | 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::DirectCodexChat { 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::PlanGddStatus { project_path }
|
||
| Self::PlanGddDecide { 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!("agent.mode={}", status.agent_mode),
|
||
format!("llm.configured={}", status.configured),
|
||
format!(
|
||
"llm.accountCredentialState={}",
|
||
status.account_credential_state
|
||
),
|
||
format!("llm.officialRouteLocked={}", status.official_route_locked),
|
||
format!("llm.reasoningEffort={}", status.reasoning_effort),
|
||
format!("llm.stream={}", status.stream),
|
||
format!("llm.contextWindowTokens={}", status.context_window_tokens),
|
||
format!(
|
||
"llm.autoCompactTokenLimit={}",
|
||
status.auto_compact_token_limit
|
||
),
|
||
format!(
|
||
"llm.toolOutputTokenLimit={}",
|
||
status.tool_output_token_limit
|
||
),
|
||
format!("llm.requestTimeoutMs={}", status.request_timeout_ms),
|
||
format!("llm.maxRetries={}", status.max_retries),
|
||
format!("llm.retryBackoffMs={}", status.retry_backoff_ms),
|
||
];
|
||
if status.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
|
||
lines.push(format!(
|
||
"llm.controlledWebSearchEnabled={}",
|
||
status.web_search_enabled
|
||
));
|
||
lines.push("llm.codexNativeWebSearch=disabled".to_string());
|
||
} else {
|
||
lines.push(format!(
|
||
"llm.webSearchEnabled={}",
|
||
status.web_search_enabled
|
||
));
|
||
}
|
||
for agent in &status.agents {
|
||
lines.push(format!(
|
||
"llm.agent.{}.configured={}",
|
||
agent.agent_id, agent.configured
|
||
));
|
||
lines.push(format!(
|
||
"llm.agent.{}.accountCredentialState={}",
|
||
agent.agent_id, agent.account_credential_state
|
||
));
|
||
lines.push(format!(
|
||
"llm.agent.{}.officialRouteLocked={}",
|
||
agent.agent_id, agent.official_route_locked
|
||
));
|
||
lines.push(format!(
|
||
"llm.agent.{}.reasoningEffort={}",
|
||
agent.agent_id, agent.reasoning_effort
|
||
));
|
||
lines.push(format!(
|
||
"llm.agent.{}.stream={}",
|
||
agent.agent_id, agent.stream
|
||
));
|
||
if agent.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
|
||
lines.push(format!(
|
||
"llm.agent.{}.controlledWebSearchEnabled={}",
|
||
agent.agent_id, agent.web_search_enabled
|
||
));
|
||
lines.push(format!(
|
||
"llm.agent.{}.codexNativeWebSearch=disabled",
|
||
agent.agent_id
|
||
));
|
||
} else {
|
||
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
|
||
));
|
||
lines.push(format!(
|
||
"llm.agent.{}.requestTimeoutMs={}",
|
||
agent.agent_id, agent.request_timeout_ms
|
||
));
|
||
lines.push(format!(
|
||
"llm.agent.{}.maxRetries={}",
|
||
agent.agent_id, agent.max_retries
|
||
));
|
||
lines.push(format!(
|
||
"llm.agent.{}.retryBackoffMs={}",
|
||
agent.agent_id, agent.retry_backoff_ms
|
||
));
|
||
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_plan_gdd_approval_comment(reader: &mut impl Read) -> Result<String, String> {
|
||
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 读取 GDD 审批意见失败:{error}"))?;
|
||
if bytes.len() as u64 > MAX_STDIN_BYTES {
|
||
return Err(format!(
|
||
"GDD 审批意见 stdin 超过 {MAX_STDIN_BYTES} 字节上限"
|
||
));
|
||
}
|
||
let comment =
|
||
String::from_utf8(bytes).map_err(|_| "GDD 审批意见 stdin 必须是 UTF-8 文本".to_string())?;
|
||
let comment = comment.trim();
|
||
if comment.is_empty() {
|
||
return Err("GDD 审批意见 stdin 不能为空".to_string());
|
||
}
|
||
// 长度上界交给 normalize_plan_gdd_approval_comment:审批意见的 1~1000 scalar
|
||
// 约束是写入侧权威,CLI 再抄一份就会有两个会漂移的判据。
|
||
Ok(comment.to_string())
|
||
}
|
||
|
||
fn take_cli_named_flag_value(
|
||
args: &mut Vec<String>,
|
||
flag: &str,
|
||
usage: &str,
|
||
) -> Result<Option<String>, String> {
|
||
let positions = args
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(index, arg)| (arg == flag).then_some(index))
|
||
.collect::<Vec<_>>();
|
||
if positions.len() > 1 {
|
||
return Err(usage.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(|| usage.to_string())?
|
||
.to_string();
|
||
args.drain(index..=index + 1);
|
||
Ok(Some(value))
|
||
}
|
||
|
||
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("--plan-gdd-status") {
|
||
const USAGE: &str = "用法:--plan-gdd-status <本地项目绝对路径>";
|
||
if args.len() != 2 || args[1].trim().is_empty() {
|
||
return Err(USAGE.to_string());
|
||
}
|
||
return Ok(Some(CliCommand::PlanGddStatus {
|
||
project_path: PathBuf::from(&args[1]),
|
||
}));
|
||
}
|
||
if args.first().map(String::as_str) == Some("--plan-gdd-decide") {
|
||
const USAGE: &str = "用法:--plan-gdd-decide <本地项目绝对路径> <approve|revise|reject> [--response-id <gdd-response-…>] [--stdin]";
|
||
let mut rest = args[1..].to_vec();
|
||
let read_comment_from_stdin =
|
||
match rest.iter().filter(|arg| arg.as_str() == "--stdin").count() {
|
||
0 => false,
|
||
1 => {
|
||
let index = rest
|
||
.iter()
|
||
.position(|arg| arg == "--stdin")
|
||
.ok_or_else(|| USAGE.to_string())?;
|
||
rest.remove(index);
|
||
true
|
||
}
|
||
_ => return Err(USAGE.to_string()),
|
||
};
|
||
let response_id = take_cli_named_flag_value(&mut rest, "--response-id", USAGE)?;
|
||
if rest.len() != 2 || rest.iter().any(|value| value.trim().is_empty()) {
|
||
return Err(USAGE.to_string());
|
||
}
|
||
let action = rest[1].trim().to_string();
|
||
if !matches!(action.as_str(), "approve" | "revise" | "reject") {
|
||
return Err(USAGE.to_string());
|
||
}
|
||
// revise/reject 的 comment 是写入侧硬约束,缺了必然在落盘前失败。在解析期就拒绝,
|
||
// 错误才指得回命令行本身,而不是变成一条读起来像后端故障的存储错误。
|
||
if action != "approve" && !read_comment_from_stdin {
|
||
return Err(
|
||
"revise/reject 审批必须通过 --stdin 提供 1~1000 scalar 的修改意见".to_string(),
|
||
);
|
||
}
|
||
return Ok(Some(CliCommand::PlanGddDecide {
|
||
project_path: PathBuf::from(&rest[0]),
|
||
action,
|
||
response_id,
|
||
read_comment_from_stdin,
|
||
}));
|
||
}
|
||
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("--direct-codex-chat") {
|
||
if args.len() < 3 {
|
||
return Err("用法:--direct-codex-chat <本地项目绝对路径> <聊天内容>".to_string());
|
||
}
|
||
let prompt = args[2..].join(" ");
|
||
if prompt.trim().is_empty() {
|
||
return Err("聊天内容不能为空".to_string());
|
||
}
|
||
return Ok(Some(CliCommand::DirectCodexChat {
|
||
project_path: PathBuf::from(&args[1]),
|
||
prompt: prompt.trim().to_string(),
|
||
}));
|
||
}
|
||
if args.first().map(String::as_str) == Some("--swarm-chat") {
|
||
const USAGE: &str = "用法:--swarm-chat [--init] [--autonomous-game-build] [--plan] <本地项目绝对路径> [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()),
|
||
};
|
||
let plan = match rest.iter().filter(|arg| arg.as_str() == "--plan").count() {
|
||
0 => false,
|
||
1 => {
|
||
let index = rest
|
||
.iter()
|
||
.position(|arg| arg == "--plan")
|
||
.expect("counted plan 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());
|
||
}
|
||
// 立项策划根 Run 只跑 standard 档(后端 reject_supervisor_plan_autonomous_profile
|
||
// 同样否决),且必须挂在总控上;这里先拦一道,免得建完项目才失败。
|
||
if plan
|
||
&& (autonomous_game_build
|
||
|| rest.get(1).is_some_and(|parent| {
|
||
parent.trim() != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|
||
}))
|
||
{
|
||
return Err(
|
||
"--plan 仅允许 project-supervisor 的 standard 档,不能搭配 --autonomous-game-build"
|
||
.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()
|
||
},
|
||
supervisor_source: if plan {
|
||
AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE
|
||
} else {
|
||
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
|
||
},
|
||
}));
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
pub(crate) fn start_cli_agent_task_at(
|
||
project_path: &Path,
|
||
agent_id: &str,
|
||
task: &str,
|
||
run_id: &str,
|
||
) -> Result<AgentRuntimeResult, String> {
|
||
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
||
start_game_creator_supervisor_background_task_for_session_at(
|
||
project_path,
|
||
None,
|
||
task,
|
||
run_id,
|
||
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
|
||
AGENT_RUNTIME_RUN_PROFILE_STANDARD,
|
||
)
|
||
} else {
|
||
start_game_creator_agent_background_task_at(project_path, agent_id, task, run_id)
|
||
}
|
||
}
|
||
|
||
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, ®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::DirectCodexChat {
|
||
project_path,
|
||
prompt,
|
||
} => {
|
||
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", true)?;
|
||
if !project_path.join(".agent/manifest.json").is_file() {
|
||
let project_name = project_path
|
||
.file_name()
|
||
.and_then(|value| value.to_str())
|
||
.unwrap_or("Codex 直连项目");
|
||
init_local_game_project_at(
|
||
&project_path,
|
||
&format!("direct-codex-{}", unix_millis()),
|
||
project_name,
|
||
)?;
|
||
}
|
||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||
.enable_all()
|
||
.build()
|
||
.map_err(|error| format!("创建 CLI runtime 失败:{error}"))?;
|
||
let reply_result = runtime
|
||
.block_on(async { run_direct_game_creator_turn_at(&project_path, &prompt).await });
|
||
let shutdown_result = shutdown_game_creator_codex_app_servers();
|
||
let reply = reply_result?;
|
||
shutdown_result?;
|
||
println!("direct-codex.chat.completed");
|
||
println!("projectPath={}", project_path.display());
|
||
println!("replyText={reply}");
|
||
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_cli_agent_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,
|
||
supervisor_source,
|
||
} => {
|
||
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,
|
||
supervisor_source,
|
||
)
|
||
}
|
||
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)?;
|
||
let completed_finalizations_cleaned =
|
||
cleanup_game_creator_agent_runtime_completed_finalizations_at(&project_path)?;
|
||
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!("completedFinalizationsCleaned={completed_finalizations_cleaned}");
|
||
println!(
|
||
"runtimesJson={}",
|
||
serialize_agent_runtime_cli_payload(&runtimes)?
|
||
);
|
||
Ok(())
|
||
}
|
||
CliCommand::PlanGddStatus { project_path } => {
|
||
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
|
||
let state =
|
||
hydrate_game_creator_plan_gdd_state_for_path(&project_path.display().to_string())?;
|
||
println!("plan.gdd.status");
|
||
println!(
|
||
"planGddStateJson={}",
|
||
serialize_agent_runtime_cli_payload(&state)?
|
||
);
|
||
Ok(())
|
||
}
|
||
CliCommand::PlanGddDecide {
|
||
project_path,
|
||
action,
|
||
response_id,
|
||
read_comment_from_stdin,
|
||
} => {
|
||
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
|
||
require_external_agent_runner_for_cli_runtime_write(&project_path)?;
|
||
let comment = if read_comment_from_stdin {
|
||
Some(read_cli_plan_gdd_approval_comment(
|
||
&mut std::io::stdin().lock(),
|
||
)?)
|
||
} else {
|
||
None
|
||
};
|
||
let project_path_value = project_path.display().to_string();
|
||
// 审批卡的 identity 只有投影这一个权威来源。CLI 不接受手工传 gddId/fingerprint:
|
||
// 那样每个调用方都要自己拼一遍身份,拼错的后果是 PLAN_STALE_APPROVAL,
|
||
// 而不是一条能读懂的用法错误。
|
||
let state = hydrate_game_creator_plan_gdd_state_for_path(&project_path_value)?;
|
||
let pending = state
|
||
.pending_approval
|
||
.ok_or_else(|| "当前没有待决定的 Fast GDD 审批".to_string())?;
|
||
// 每次调用换新 responseId 是安全方向:重复键的最坏后果是 replayed 降级成
|
||
// already-decided(两者都成功),而复用键改 action 会撞「同 responseId 的
|
||
// 审批意图不一致」硬错误。要复放同一次决定时才显式传 --response-id。
|
||
let response_id = response_id
|
||
.unwrap_or_else(|| format!("gdd-response-{}", uuid::Uuid::new_v4().hyphenated()));
|
||
let result = decide_game_creator_plan_gdd(
|
||
project_path_value,
|
||
pending.gdd_ref.gdd_id.clone(),
|
||
pending.gdd_ref.version,
|
||
pending.gdd_ref.fingerprint.clone(),
|
||
pending.pending_action_id.clone(),
|
||
pending.approval_request_id.clone(),
|
||
response_id,
|
||
action,
|
||
comment,
|
||
)?;
|
||
println!("plan.gdd.decided");
|
||
println!(
|
||
"planGddDecisionJson={}",
|
||
serialize_agent_runtime_cli_payload(&result)?
|
||
);
|
||
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 parses_plan_gdd_headless_approval_entries() {
|
||
assert_eq!(
|
||
parse_cli_command(&[
|
||
"--plan-gdd-status".to_string(),
|
||
"/tmp/game-project".to_string(),
|
||
])
|
||
.expect("parse plan gdd status")
|
||
.expect("plan gdd status command"),
|
||
CliCommand::PlanGddStatus {
|
||
project_path: PathBuf::from("/tmp/game-project"),
|
||
}
|
||
);
|
||
|
||
assert_eq!(
|
||
parse_cli_command(&[
|
||
"--plan-gdd-decide".to_string(),
|
||
"/tmp/game-project".to_string(),
|
||
" approve ".to_string(),
|
||
])
|
||
.expect("parse plan gdd approve")
|
||
.expect("plan gdd approve command"),
|
||
CliCommand::PlanGddDecide {
|
||
project_path: PathBuf::from("/tmp/game-project"),
|
||
action: "approve".to_string(),
|
||
response_id: None,
|
||
read_comment_from_stdin: false,
|
||
}
|
||
);
|
||
|
||
assert_eq!(
|
||
parse_cli_command(&[
|
||
"--plan-gdd-decide".to_string(),
|
||
"/tmp/game-project".to_string(),
|
||
"revise".to_string(),
|
||
"--response-id".to_string(),
|
||
" gdd-response-1b4e28ba-2fa1-11d2-883f-0016d3cca427 ".to_string(),
|
||
"--stdin".to_string(),
|
||
])
|
||
.expect("parse plan gdd revise")
|
||
.expect("plan gdd revise command"),
|
||
CliCommand::PlanGddDecide {
|
||
project_path: PathBuf::from("/tmp/game-project"),
|
||
action: "revise".to_string(),
|
||
response_id: Some("gdd-response-1b4e28ba-2fa1-11d2-883f-0016d3cca427".to_string()),
|
||
read_comment_from_stdin: true,
|
||
}
|
||
);
|
||
|
||
// revise/reject 没有 --stdin 时必须在解析期就失败,否则错误会伪装成写入侧故障。
|
||
for action in ["revise", "reject"] {
|
||
assert!(parse_cli_command(&[
|
||
"--plan-gdd-decide".to_string(),
|
||
"/tmp/game-project".to_string(),
|
||
action.to_string(),
|
||
])
|
||
.is_err());
|
||
}
|
||
for args in [
|
||
vec!["--plan-gdd-decide"],
|
||
vec!["--plan-gdd-decide", "/tmp/game-project"],
|
||
vec!["--plan-gdd-decide", "/tmp/game-project", "confirm"],
|
||
vec!["--plan-gdd-decide", "/tmp/game-project", "approve", "extra"],
|
||
vec![
|
||
"--plan-gdd-decide",
|
||
"/tmp/game-project",
|
||
"approve",
|
||
"--response-id",
|
||
],
|
||
vec!["--plan-gdd-status", "/tmp/game-project", "extra"],
|
||
] {
|
||
assert!(
|
||
parse_cli_command(&args.into_iter().map(str::to_string).collect::<Vec<_>>())
|
||
.is_err()
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn plan_gdd_decision_requires_started_external_runner_but_status_does_not() {
|
||
let decide = CliCommand::PlanGddDecide {
|
||
project_path: PathBuf::from("/tmp/game-project"),
|
||
action: "approve".to_string(),
|
||
response_id: None,
|
||
read_comment_from_stdin: false,
|
||
};
|
||
assert!(decide.requires_external_agent_runner());
|
||
assert!(decide.requires_started_external_agent_runner());
|
||
assert!(!decide.is_read_only_status());
|
||
|
||
let status = CliCommand::PlanGddStatus {
|
||
project_path: PathBuf::from("/tmp/game-project"),
|
||
};
|
||
assert!(!status.requires_external_agent_runner());
|
||
assert!(status.is_read_only_status());
|
||
}
|
||
|
||
#[test]
|
||
fn reads_plan_gdd_approval_comment_from_stdin() {
|
||
assert_eq!(
|
||
read_cli_plan_gdd_approval_comment(&mut Cursor::new(" 把核心循环写具体 \n"))
|
||
.expect("read approval comment"),
|
||
"把核心循环写具体"
|
||
);
|
||
assert!(read_cli_plan_gdd_approval_comment(&mut Cursor::new(" \n")).is_err());
|
||
assert!(
|
||
read_cli_plan_gdd_approval_comment(&mut Cursor::new("a".repeat(9 * 1024))).is_err()
|
||
);
|
||
}
|
||
|
||
#[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(),
|
||
supervisor_source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
|
||
}
|
||
);
|
||
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(),
|
||
supervisor_source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
|
||
}
|
||
);
|
||
}
|
||
|
||
#[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(),
|
||
supervisor_source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn swarm_chat_plan_flag_selects_plan_source_on_standard_profile() {
|
||
let project_path = std::env::current_dir().expect("current directory");
|
||
let command = parse_cli_command(&[
|
||
"--swarm-chat".to_string(),
|
||
"--init".to_string(),
|
||
"--plan".to_string(),
|
||
project_path.display().to_string(),
|
||
])
|
||
.expect("parse plan swarm chat")
|
||
.expect("plan swarm chat command");
|
||
assert_eq!(
|
||
command,
|
||
CliCommand::SwarmChat {
|
||
project_path,
|
||
parent_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(),
|
||
initialize: true,
|
||
run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(),
|
||
supervisor_source: AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE,
|
||
}
|
||
);
|
||
assert!(parse_cli_command(&[
|
||
"--swarm-chat".to_string(),
|
||
"--plan".to_string(),
|
||
"--autonomous-game-build".to_string(),
|
||
"/tmp/game-project".to_string(),
|
||
])
|
||
.is_err());
|
||
assert!(parse_cli_command(&[
|
||
"--swarm-chat".to_string(),
|
||
"--plan".to_string(),
|
||
"/tmp/game-project".to_string(),
|
||
"code-prototype".to_string(),
|
||
])
|
||
.is_err());
|
||
assert!(parse_cli_command(&[
|
||
"--swarm-chat".to_string(),
|
||
"--plan".to_string(),
|
||
"--plan".to_string(),
|
||
"/tmp/game-project".to_string(),
|
||
])
|
||
.is_err());
|
||
}
|
||
|
||
#[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());
|
||
}
|
||
}
|