54ae60576d
按 rustfmt 整理策划 Runtime 与相关测试字面量 首页做方案用例改为断言设计 Agent 命令
1212 lines
47 KiB
Rust
1212 lines
47 KiB
Rust
use super::*;
|
||
use std::path::{Path, PathBuf};
|
||
use std::process::Stdio;
|
||
|
||
use sha2::{Digest, Sha256};
|
||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
|
||
|
||
const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex";
|
||
const GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH: &str = "codex/win-x64/bin/codex.exe";
|
||
const GAME_CREATOR_BUNDLED_CODEX_CLI_MANIFEST_RELATIVE_PATH: &str = "codex/win-x64/manifest.json";
|
||
const GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES: [&str; 6] = [
|
||
"bin/codex.exe",
|
||
"bin/codex-code-mode-host.exe",
|
||
"codex-path/rg.exe",
|
||
"codex-resources/codex-command-runner.exe",
|
||
"codex-resources/codex-windows-sandbox-setup.exe",
|
||
"codex-package.json",
|
||
];
|
||
const GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||
const GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||
const GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES: usize = 256 * 1024;
|
||
|
||
#[derive(serde::Deserialize)]
|
||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||
struct GameCreatorBundledCodexCliManifest {
|
||
schema_version: String,
|
||
platform: String,
|
||
version: String,
|
||
files: std::collections::BTreeMap<String, String>,
|
||
}
|
||
|
||
fn game_creator_codex_cli_executable_candidates_for(
|
||
resource_dir: Option<&Path>,
|
||
app_data: Option<&Path>,
|
||
local_app_data: Option<&Path>,
|
||
runtime_config_dir: Option<&Path>,
|
||
path: Option<&std::ffi::OsStr>,
|
||
) -> Vec<PathBuf> {
|
||
let mut candidates = Vec::new();
|
||
#[cfg(windows)]
|
||
{
|
||
if let Some(resource_dir) = resource_dir {
|
||
candidates.push(resource_dir.join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH));
|
||
}
|
||
fn append_native_npm_candidates(candidates: &mut Vec<PathBuf>, npm_root: &Path) {
|
||
let vendor_root = npm_root
|
||
.join("node_modules")
|
||
.join("@openai")
|
||
.join("codex")
|
||
.join("node_modules")
|
||
.join("@openai")
|
||
.join("codex-win32-x64")
|
||
.join("vendor");
|
||
if let Ok(entries) = std::fs::read_dir(vendor_root) {
|
||
let mut targets = entries
|
||
.filter_map(Result::ok)
|
||
.map(|entry| entry.path().join("bin").join("codex.exe"))
|
||
.collect::<Vec<_>>();
|
||
targets.sort();
|
||
candidates.extend(targets);
|
||
}
|
||
}
|
||
|
||
fn append_desktop_codex_candidates(candidates: &mut Vec<PathBuf>, local_app_data: &Path) {
|
||
let bin_root = local_app_data.join("OpenAI").join("Codex").join("bin");
|
||
if let Ok(entries) = std::fs::read_dir(bin_root) {
|
||
let mut targets = entries
|
||
.filter_map(Result::ok)
|
||
.map(|entry| entry.path().join("codex.exe"))
|
||
.collect::<Vec<_>>();
|
||
targets.sort();
|
||
targets.reverse();
|
||
candidates.extend(targets);
|
||
}
|
||
}
|
||
|
||
if let Some(app_data) = app_data {
|
||
append_native_npm_candidates(&mut candidates, &app_data.join("npm"));
|
||
}
|
||
if let Some(local_app_data) = local_app_data {
|
||
append_desktop_codex_candidates(&mut candidates, local_app_data);
|
||
}
|
||
if let Some(app_data) = runtime_config_dir.and_then(Path::parent) {
|
||
append_native_npm_candidates(&mut candidates, &app_data.join("npm"));
|
||
if let Some(user_profile) = app_data.parent() {
|
||
append_desktop_codex_candidates(&mut candidates, &user_profile.join("Local"));
|
||
}
|
||
}
|
||
if let Some(path) = path {
|
||
for entry in std::env::split_paths(&path) {
|
||
append_native_npm_candidates(&mut candidates, &entry);
|
||
candidates.push(entry.join("codex.exe"));
|
||
}
|
||
}
|
||
}
|
||
candidates.push(PathBuf::from(GAME_CREATOR_CODEX_CLI_EXECUTABLE));
|
||
candidates
|
||
}
|
||
|
||
fn game_creator_codex_cli_executable_candidates() -> Vec<PathBuf> {
|
||
game_creator_codex_cli_executable_candidates_for(
|
||
game_creator_bundled_resource_dir().as_deref(),
|
||
std::env::var_os("APPDATA").as_deref().map(Path::new),
|
||
std::env::var_os("LOCALAPPDATA").as_deref().map(Path::new),
|
||
game_creator_runtime_config_dir().as_deref(),
|
||
std::env::var_os("PATH").as_deref(),
|
||
)
|
||
}
|
||
|
||
fn game_creator_bundled_resource_dir() -> Option<PathBuf> {
|
||
#[cfg(windows)]
|
||
{
|
||
std::env::current_exe()
|
||
.ok()
|
||
.and_then(|path| path.parent().map(Path::to_path_buf))
|
||
}
|
||
#[cfg(not(windows))]
|
||
{
|
||
None
|
||
}
|
||
}
|
||
|
||
fn game_creator_bundled_codex_cli_path(resource_dir: Option<&Path>) -> Option<PathBuf> {
|
||
resource_dir.map(|resource_dir| resource_dir.join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH))
|
||
}
|
||
|
||
fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result<String, String> {
|
||
let bundle_root = executable
|
||
.parent()
|
||
.and_then(Path::parent)
|
||
.ok_or_else(|| "内置 Codex CLI 路径无效".to_string())?;
|
||
let manifest_path = bundle_root.join(
|
||
Path::new(GAME_CREATOR_BUNDLED_CODEX_CLI_MANIFEST_RELATIVE_PATH)
|
||
.file_name()
|
||
.expect("bundled Codex manifest file name"),
|
||
);
|
||
let manifest = std::fs::read_to_string(&manifest_path)
|
||
.map_err(|_| "内置 Codex CLI 缺少完整性清单".to_string())
|
||
.and_then(|value| {
|
||
serde_json::from_str::<GameCreatorBundledCodexCliManifest>(&value)
|
||
.map_err(|_| "内置 Codex CLI 完整性清单无效".to_string())
|
||
})?;
|
||
if manifest.schema_version != "genarrative-codex-sidecar.v2"
|
||
|| manifest.platform != "win32-x64"
|
||
|| manifest.version.trim().is_empty()
|
||
|| GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES
|
||
.iter()
|
||
.any(|relative| {
|
||
manifest.files.get(*relative).map_or(true, |hash| {
|
||
hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||
})
|
||
})
|
||
{
|
||
return Err("内置 Codex CLI 完整性清单不受支持".to_string());
|
||
}
|
||
for relative in GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES {
|
||
let path = bundle_root.join(relative);
|
||
let bytes = std::fs::read(&path).map_err(|_| {
|
||
format!(
|
||
"内置 Codex CLI 缺少必需组件:{}",
|
||
path.file_name().unwrap_or_default().to_string_lossy()
|
||
)
|
||
})?;
|
||
let actual = format!("{:x}", Sha256::digest(bytes));
|
||
if !actual.eq_ignore_ascii_case(manifest.files.get(relative).expect("validated hash")) {
|
||
return Err(format!("内置 Codex CLI 完整性校验失败:组件 {relative}"));
|
||
}
|
||
}
|
||
Ok(manifest.version)
|
||
}
|
||
|
||
fn game_creator_codex_cli_version_at(executable: &Path) -> Result<String, String> {
|
||
let mut command = crate::new_windows_background_std_command(executable);
|
||
let output = command
|
||
.arg("--version")
|
||
.stdin(Stdio::null())
|
||
.stderr(Stdio::null())
|
||
.output()
|
||
.map_err(|error| error.to_string())?;
|
||
if !output.status.success() {
|
||
return Err(format!("版本检查退出状态为 {}", output.status));
|
||
}
|
||
let version = std::str::from_utf8(&output.stdout)
|
||
.map_err(|_| "版本信息不是 UTF-8".to_string())?
|
||
.trim();
|
||
if !version.starts_with("codex-cli ") || version.len() > 120 {
|
||
return Err("返回了无法识别的版本信息".to_string());
|
||
}
|
||
Ok(version.to_string())
|
||
}
|
||
|
||
pub(crate) fn game_creator_codex_cli_executable_path() -> Result<PathBuf, String> {
|
||
let mut last_error = None;
|
||
let mut seen = std::collections::HashSet::new();
|
||
let bundled =
|
||
game_creator_bundled_codex_cli_path(game_creator_bundled_resource_dir().as_deref());
|
||
for candidate in game_creator_codex_cli_executable_candidates() {
|
||
let identity = candidate.to_string_lossy().to_ascii_lowercase();
|
||
if !seen.insert(identity) {
|
||
continue;
|
||
}
|
||
let bundled_version = (bundled.as_ref() == Some(&candidate))
|
||
.then(|| validate_game_creator_bundled_codex_cli(&candidate))
|
||
.transpose();
|
||
match bundled_version.and_then(|expected_version| {
|
||
game_creator_codex_cli_version_at(&candidate).and_then(|actual_version| {
|
||
if expected_version
|
||
.as_deref()
|
||
.is_some_and(|expected| expected != actual_version)
|
||
{
|
||
Err("内置 Codex CLI 版本与完整性清单不一致".to_string())
|
||
} else {
|
||
Ok(actual_version)
|
||
}
|
||
})
|
||
}) {
|
||
Ok(_) => return Ok(candidate),
|
||
Err(error) => last_error = Some(error),
|
||
}
|
||
}
|
||
Err(format!(
|
||
"Codex CLI 未安装或当前 Agent Runner 无法启动;已检查内置 sidecar、PATH 和 npm 全局安装目录{}",
|
||
last_error
|
||
.map(|error| format!("(最后错误:{error})"))
|
||
.unwrap_or_default()
|
||
))
|
||
}
|
||
|
||
struct CodexCliStderrSummary {
|
||
byte_len: usize,
|
||
sha256: String,
|
||
classification: &'static str,
|
||
}
|
||
|
||
pub(crate) fn game_creator_codex_cli_version_identity() -> Result<String, String> {
|
||
let executable = game_creator_codex_cli_executable_path()?;
|
||
game_creator_codex_cli_version_at(&executable)
|
||
}
|
||
|
||
pub(in crate::agent) fn game_creator_codex_cli_reasoning_effort(
|
||
request: &LlmRunRequest,
|
||
) -> Option<&'static str> {
|
||
request
|
||
.response_reasoning_effort
|
||
.map(|effort| match effort {
|
||
platform_llm::LlmResponseReasoningEffort::Low => "low",
|
||
platform_llm::LlmResponseReasoningEffort::Medium => "medium",
|
||
platform_llm::LlmResponseReasoningEffort::High => "high",
|
||
platform_llm::LlmResponseReasoningEffort::Max => "max",
|
||
})
|
||
}
|
||
|
||
pub(in crate::agent) fn game_creator_codex_cli_tool_output_schema(
|
||
request: &LlmRunRequest,
|
||
) -> Option<serde_json::Value> {
|
||
if request.function_tools.is_empty() {
|
||
return None;
|
||
}
|
||
let names = request
|
||
.function_tools
|
||
.iter()
|
||
.map(|tool| serde_json::Value::String(tool.name.clone()))
|
||
.collect::<Vec<_>>();
|
||
Some(serde_json::json!({
|
||
"type": "object",
|
||
"required": ["toolCalls"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"toolCalls": {
|
||
"type": "array",
|
||
"minItems": 1,
|
||
"maxItems": AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT,
|
||
"items": {
|
||
"type": "object",
|
||
"required": ["name", "arguments"],
|
||
"additionalProperties": false,
|
||
"properties": {
|
||
"name": { "type": "string", "enum": names },
|
||
"arguments": {
|
||
"type": "string",
|
||
"description": "严格 JSON 对象字符串,必须符合对应函数 parameters schema"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}))
|
||
}
|
||
|
||
pub(in crate::agent) fn render_game_creator_codex_cli_prompt(
|
||
request: &LlmRunRequest,
|
||
) -> Result<String, String> {
|
||
let messages = serde_json::to_string_pretty(&request.messages)
|
||
.map_err(|error| format!("序列化 Codex CLI Agent 消息失败:{error}"))?;
|
||
let functions = serde_json::to_string_pretty(&request.function_tools)
|
||
.map_err(|error| format!("序列化 Codex CLI Agent 函数目录失败:{error}"))?;
|
||
let output_contract = if request.function_tools.is_empty() {
|
||
"本次没有 Runtime 函数目录。请直接返回请求要求的最终正文;不要使用 Markdown 代码围栏。"
|
||
.to_string()
|
||
} else {
|
||
"本次必须只返回 output schema 要求的 toolCalls 对象。每个 name 必须来自函数目录;arguments 必须是一个严格 JSON 对象的字符串形式,并符合该函数 parameters。不要返回普通正文、Markdown、解释或额外字段。"
|
||
.to_string()
|
||
};
|
||
let prompt = format!(
|
||
"你是 Genarrative AI 游戏创作 Runtime 当前节点的推理 Agent。\n\n安全边界:不要调用 Codex 内置 shell、文件、网络、插件、Skill 或子 Agent;不要读取当前临时目录。项目事实只来自下面的消息,项目行动只能通过返回 Runtime 函数请求完成。不得声称已经执行尚未由 Runtime observation 证明的动作。\n\n{output_contract}\n\n以下消息按 role 保持原顺序,是本次节点的完整请求:\n{messages}\n\n以下是当前 Runtime 实际广告的函数目录:\n{functions}"
|
||
);
|
||
if prompt.len() > GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES {
|
||
return Err(format!(
|
||
"Codex CLI Agent prompt 超过 {} 字节上限",
|
||
GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES
|
||
));
|
||
}
|
||
Ok(prompt)
|
||
}
|
||
|
||
pub(in crate::agent) fn game_creator_codex_cli_minimal_environment(
|
||
command: &mut tokio::process::Command,
|
||
) {
|
||
command.env_clear();
|
||
for name in [
|
||
"PATH",
|
||
"HOME",
|
||
"USERPROFILE",
|
||
"APPDATA",
|
||
"LOCALAPPDATA",
|
||
"CODEX_HOME",
|
||
"TMPDIR",
|
||
"TEMP",
|
||
"TMP",
|
||
"HTTP_PROXY",
|
||
"HTTPS_PROXY",
|
||
"ALL_PROXY",
|
||
"NO_PROXY",
|
||
"SSL_CERT_FILE",
|
||
"SSL_CERT_DIR",
|
||
"SystemRoot",
|
||
"WINDIR",
|
||
"ComSpec",
|
||
"PATHEXT",
|
||
] {
|
||
if let Some(value) = std::env::var_os(name) {
|
||
command.env(name, value);
|
||
}
|
||
}
|
||
command.env("NO_COLOR", "1").env("TERM", "dumb");
|
||
}
|
||
|
||
pub(in crate::agent) fn configure_game_creator_codex_cli_process(
|
||
command: &mut tokio::process::Command,
|
||
) {
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::process::CommandExt;
|
||
command.as_std_mut().process_group(0);
|
||
#[cfg(target_os = "linux")]
|
||
unsafe {
|
||
command.as_std_mut().pre_exec(|| {
|
||
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 {
|
||
return Err(std::io::Error::last_os_error());
|
||
}
|
||
if libc::getppid() == 1 {
|
||
return Err(std::io::Error::new(
|
||
std::io::ErrorKind::Interrupted,
|
||
"Codex CLI parent exited before child exec",
|
||
));
|
||
}
|
||
Ok(())
|
||
});
|
||
}
|
||
}
|
||
#[cfg(windows)]
|
||
crate::configure_windows_background_tokio_command(command, true);
|
||
}
|
||
|
||
pub(in crate::agent) async fn terminate_game_creator_codex_cli_process_tree(
|
||
child: &mut tokio::process::Child,
|
||
) {
|
||
if let Some(process_id) = child.id() {
|
||
#[cfg(unix)]
|
||
unsafe {
|
||
libc::kill(-(process_id as i32), libc::SIGKILL);
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
let mut command = tokio::process::Command::new("taskkill");
|
||
command
|
||
.args(["/PID", &process_id.to_string(), "/T", "/F"])
|
||
.stdin(Stdio::null())
|
||
.stdout(Stdio::null())
|
||
.stderr(Stdio::null());
|
||
crate::configure_windows_background_tokio_command(&mut command, false);
|
||
let _ = command.status().await;
|
||
}
|
||
}
|
||
let _ = child.kill().await;
|
||
let _ = child.wait().await;
|
||
}
|
||
|
||
async fn read_game_creator_codex_cli_output<R>(
|
||
mut reader: R,
|
||
max_bytes: usize,
|
||
) -> Result<Vec<u8>, String>
|
||
where
|
||
R: AsyncRead + Unpin,
|
||
{
|
||
let mut bytes = Vec::new();
|
||
let mut buffer = [0_u8; 8 * 1024];
|
||
let mut exceeded = false;
|
||
loop {
|
||
let count = reader
|
||
.read(&mut buffer)
|
||
.await
|
||
.map_err(|error| format!("读取 Codex CLI Agent 输出失败:{error}"))?;
|
||
if count == 0 {
|
||
break;
|
||
}
|
||
let remaining = max_bytes.saturating_sub(bytes.len());
|
||
bytes.extend_from_slice(&buffer[..count.min(remaining)]);
|
||
exceeded |= count > remaining;
|
||
}
|
||
if exceeded {
|
||
return Err(format!("Codex CLI Agent 输出超过 {max_bytes} 字节上限"));
|
||
}
|
||
Ok(bytes)
|
||
}
|
||
|
||
async fn summarize_game_creator_codex_cli_stderr<R>(
|
||
mut reader: R,
|
||
max_bytes: usize,
|
||
) -> Result<CodexCliStderrSummary, String>
|
||
where
|
||
R: AsyncRead + Unpin,
|
||
{
|
||
let mut byte_len = 0_usize;
|
||
let mut sha256 = Sha256::new();
|
||
let mut buffer = [0_u8; 8 * 1024];
|
||
loop {
|
||
let count = reader
|
||
.read(&mut buffer)
|
||
.await
|
||
.map_err(|_| "读取 Codex CLI Agent stderr 失败".to_string())?;
|
||
if count == 0 {
|
||
break;
|
||
}
|
||
byte_len = byte_len.saturating_add(count);
|
||
sha256.update(&buffer[..count]);
|
||
}
|
||
Ok(CodexCliStderrSummary {
|
||
byte_len,
|
||
sha256: format!("{:x}", sha256.finalize()),
|
||
classification: if byte_len == 0 {
|
||
"empty"
|
||
} else if byte_len > max_bytes {
|
||
"oversized"
|
||
} else {
|
||
"nonempty"
|
||
},
|
||
})
|
||
}
|
||
|
||
fn parse_game_creator_codex_cli_response(
|
||
stdout: &[u8],
|
||
request: &LlmRunRequest,
|
||
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
|
||
let stdout = std::str::from_utf8(stdout).map_err(|_| {
|
||
platform_llm::LlmError::Deserialize("Codex CLI Agent JSONL 不是 UTF-8".to_string())
|
||
})?;
|
||
let mut response_id = None;
|
||
let mut final_message = None;
|
||
let mut usage = None;
|
||
let mut completed = false;
|
||
for (index, line) in stdout.lines().enumerate() {
|
||
if line.trim().is_empty() {
|
||
continue;
|
||
}
|
||
let event = serde_json::from_str::<serde_json::Value>(line).map_err(|_| {
|
||
platform_llm::LlmError::Deserialize(format!(
|
||
"Codex CLI Agent JSONL 第 {} 行无效",
|
||
index.saturating_add(1)
|
||
))
|
||
})?;
|
||
match event.get("type").and_then(serde_json::Value::as_str) {
|
||
Some("thread.started") => {
|
||
response_id = event
|
||
.get("thread_id")
|
||
.and_then(serde_json::Value::as_str)
|
||
.map(str::to_string);
|
||
}
|
||
Some("item.completed")
|
||
if event
|
||
.pointer("/item/type")
|
||
.and_then(serde_json::Value::as_str)
|
||
== Some("agent_message") =>
|
||
{
|
||
final_message = event
|
||
.pointer("/item/text")
|
||
.and_then(serde_json::Value::as_str)
|
||
.map(str::to_string);
|
||
}
|
||
Some("turn.completed") => {
|
||
completed = true;
|
||
let prompt_tokens = event
|
||
.pointer("/usage/input_tokens")
|
||
.and_then(serde_json::Value::as_u64)
|
||
.unwrap_or(0);
|
||
let completion_tokens = event
|
||
.pointer("/usage/output_tokens")
|
||
.and_then(serde_json::Value::as_u64)
|
||
.unwrap_or(0);
|
||
let total_tokens = prompt_tokens.saturating_add(completion_tokens);
|
||
if total_tokens > 0 {
|
||
usage = Some(platform_llm::LlmTokenUsage {
|
||
prompt_tokens,
|
||
completion_tokens,
|
||
total_tokens,
|
||
});
|
||
}
|
||
}
|
||
Some("turn.failed") => {
|
||
return Err(platform_llm::LlmError::Transport(
|
||
"Codex CLI Agent 返回失败终态".to_string(),
|
||
));
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
if !completed {
|
||
return Err(platform_llm::LlmError::Deserialize(
|
||
"Codex CLI Agent 缺少 turn.completed 终态".to_string(),
|
||
));
|
||
}
|
||
let text = final_message
|
||
.filter(|text| !text.trim().is_empty())
|
||
.ok_or(platform_llm::LlmError::EmptyResponse)?;
|
||
let tool_calls = if request.function_tools.is_empty() {
|
||
Vec::new()
|
||
} else {
|
||
let envelope = serde_json::from_str::<serde_json::Value>(&text).map_err(|_| {
|
||
platform_llm::LlmError::Deserialize(
|
||
"Codex CLI Agent structured output 不是严格 JSON".to_string(),
|
||
)
|
||
})?;
|
||
let calls = envelope
|
||
.get("toolCalls")
|
||
.and_then(serde_json::Value::as_array)
|
||
.ok_or_else(|| {
|
||
platform_llm::LlmError::Deserialize(
|
||
"Codex CLI Agent structured output 缺少 toolCalls".to_string(),
|
||
)
|
||
})?;
|
||
calls
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(index, call)| {
|
||
let name = call
|
||
.get("name")
|
||
.and_then(serde_json::Value::as_str)
|
||
.ok_or_else(|| {
|
||
platform_llm::LlmError::Deserialize(
|
||
"Codex CLI Agent tool call 缺少 name".to_string(),
|
||
)
|
||
})?;
|
||
let arguments = call
|
||
.get("arguments")
|
||
.and_then(serde_json::Value::as_str)
|
||
.ok_or_else(|| {
|
||
platform_llm::LlmError::Deserialize(
|
||
"Codex CLI Agent tool call 缺少 arguments".to_string(),
|
||
)
|
||
})?;
|
||
if !request.function_tools.iter().any(|tool| tool.name == name) {
|
||
return Err(platform_llm::LlmError::Deserialize(
|
||
"Codex CLI Agent 返回未广告函数".to_string(),
|
||
));
|
||
}
|
||
Ok(platform_llm::LlmToolCall {
|
||
id: format!("codex-cli-call-{}", index.saturating_add(1)),
|
||
name: name.to_string(),
|
||
arguments: arguments.to_string(),
|
||
})
|
||
})
|
||
.collect::<Result<Vec<_>, _>>()?
|
||
};
|
||
Ok(platform_llm::LlmRunResponse {
|
||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||
model: "codex-cli".to_string(),
|
||
text: if tool_calls.is_empty() {
|
||
text
|
||
} else {
|
||
String::new()
|
||
},
|
||
finish_reason: Some("stop".to_string()),
|
||
response_id,
|
||
usage,
|
||
tool_calls,
|
||
responses_output: Vec::new(),
|
||
})
|
||
}
|
||
|
||
async fn request_game_creator_agent_codex_cli_with_executable(
|
||
executable: &std::ffi::OsStr,
|
||
request: LlmRunRequest,
|
||
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
|
||
let prompt = render_game_creator_codex_cli_prompt(&request)
|
||
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||
let temp_dir = tempfile::Builder::new()
|
||
.prefix("genarrative-agc-codex-cli-")
|
||
.tempdir()
|
||
.map_err(|error| {
|
||
platform_llm::LlmError::Transport(format!("创建 Codex CLI Agent 临时目录失败:{error}"))
|
||
})?;
|
||
let schema_path = if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) {
|
||
let path = temp_dir.path().join("tool-output.schema.json");
|
||
let content = serde_json::to_vec(&schema).map_err(|error| {
|
||
platform_llm::LlmError::InvalidRequest(format!(
|
||
"序列化 Codex CLI Agent output schema 失败:{error}"
|
||
))
|
||
})?;
|
||
std::fs::write(&path, content).map_err(|error| {
|
||
platform_llm::LlmError::Transport(format!(
|
||
"写入 Codex CLI Agent output schema 失败:{error}"
|
||
))
|
||
})?;
|
||
Some(path)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let mut command = tokio::process::Command::new(executable);
|
||
command
|
||
.arg("exec")
|
||
.arg("--json")
|
||
.arg("--ephemeral")
|
||
.arg("--ignore-user-config")
|
||
.arg("--ignore-rules")
|
||
.arg("--skip-git-repo-check")
|
||
.arg("--sandbox")
|
||
.arg("read-only")
|
||
.arg("--disable")
|
||
.arg("shell_tool")
|
||
.arg("--config")
|
||
.arg("approval_policy=\"never\"")
|
||
.arg("--cd")
|
||
.arg(temp_dir.path());
|
||
if let Some(effort) = game_creator_codex_cli_reasoning_effort(&request) {
|
||
command
|
||
.arg("--config")
|
||
.arg(format!("model_reasoning_effort=\"{effort}\""));
|
||
}
|
||
if let Some(path) = schema_path.as_ref() {
|
||
command.arg("--output-schema").arg(path);
|
||
}
|
||
command
|
||
.arg("-")
|
||
.stdin(Stdio::piped())
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::piped())
|
||
.kill_on_drop(true);
|
||
game_creator_codex_cli_minimal_environment(&mut command);
|
||
configure_game_creator_codex_cli_process(&mut command);
|
||
|
||
let mut child = command.spawn().map_err(|error| {
|
||
platform_llm::LlmError::InvalidConfig(format!(
|
||
"Codex CLI Agent 不可用,请安装并登录 Codex CLI:{error}"
|
||
))
|
||
})?;
|
||
let mut stdin = child.stdin.take().ok_or_else(|| {
|
||
platform_llm::LlmError::Transport("Codex CLI Agent stdin 未建立".to_string())
|
||
})?;
|
||
// 短生命周期 CLI 可能在 prompt 写完前退出;继续采集退出状态和脱敏
|
||
// stderr,确保调用方拿到可行动的进程错误,而不是平台相关的 BrokenPipe。
|
||
let mut stdin_error = None;
|
||
if let Err(error) = stdin.write_all(prompt.as_bytes()).await {
|
||
if error.kind() == std::io::ErrorKind::BrokenPipe {
|
||
stdin_error = Some(format!("写入 Codex CLI Agent prompt 失败:{error}"));
|
||
} else {
|
||
return Err(platform_llm::LlmError::Transport(format!(
|
||
"写入 Codex CLI Agent prompt 失败:{error}"
|
||
)));
|
||
}
|
||
}
|
||
if let Err(error) = stdin.shutdown().await {
|
||
if error.kind() == std::io::ErrorKind::BrokenPipe {
|
||
stdin_error.get_or_insert_with(|| format!("关闭 Codex CLI Agent stdin 失败:{error}"));
|
||
} else {
|
||
return Err(platform_llm::LlmError::Transport(format!(
|
||
"关闭 Codex CLI Agent stdin 失败:{error}"
|
||
)));
|
||
}
|
||
}
|
||
drop(stdin);
|
||
|
||
let stdout = child.stdout.take().ok_or_else(|| {
|
||
platform_llm::LlmError::Transport("Codex CLI Agent stdout 未建立".to_string())
|
||
})?;
|
||
let stderr = child.stderr.take().ok_or_else(|| {
|
||
platform_llm::LlmError::Transport("Codex CLI Agent stderr 未建立".to_string())
|
||
})?;
|
||
let stdout_task = tokio::spawn(read_game_creator_codex_cli_output(
|
||
stdout,
|
||
GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES,
|
||
));
|
||
let stderr_task = tokio::spawn(summarize_game_creator_codex_cli_stderr(
|
||
stderr,
|
||
GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES,
|
||
));
|
||
let timeout_ms = request
|
||
.request_timeout_ms
|
||
.unwrap_or(GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS)
|
||
.max(1);
|
||
let wait =
|
||
tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), child.wait()).await;
|
||
let (status, timed_out) = match wait {
|
||
Ok(Ok(status)) => (Some(status), false),
|
||
Ok(Err(error)) => {
|
||
terminate_game_creator_codex_cli_process_tree(&mut child).await;
|
||
return Err(platform_llm::LlmError::Transport(format!(
|
||
"等待 Codex CLI Agent 退出失败:{error}"
|
||
)));
|
||
}
|
||
Err(_) => {
|
||
terminate_game_creator_codex_cli_process_tree(&mut child).await;
|
||
(None, true)
|
||
}
|
||
};
|
||
let stdout = stdout_task
|
||
.await
|
||
.map_err(|_| {
|
||
platform_llm::LlmError::Transport("Codex CLI Agent stdout 读取任务失败".to_string())
|
||
})?
|
||
.map_err(platform_llm::LlmError::Transport)?;
|
||
let stderr = stderr_task
|
||
.await
|
||
.map_err(|_| {
|
||
platform_llm::LlmError::Transport("Codex CLI Agent stderr 读取任务失败".to_string())
|
||
})?
|
||
.map_err(platform_llm::LlmError::Transport)?;
|
||
if timed_out {
|
||
return Err(platform_llm::LlmError::Timeout { attempts: 1 });
|
||
}
|
||
let status = status.expect("non-timeout Codex CLI wait has exit status");
|
||
if stderr.classification == "oversized" {
|
||
return Err(platform_llm::LlmError::Transport(format!(
|
||
"Codex CLI Agent stderr 超过 {} 字节上限;stderrClass={};stderrBytes={};stderrSha256={}",
|
||
GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES,
|
||
stderr.classification,
|
||
stderr.byte_len,
|
||
stderr.sha256
|
||
)));
|
||
}
|
||
if !status.success() {
|
||
return Err(platform_llm::LlmError::Transport(format!(
|
||
"Codex CLI Agent 退出失败(code={});stderrClass={};stderrBytes={};stderrSha256={};请检查 Codex CLI 登录状态、网络和本机配置",
|
||
status
|
||
.code()
|
||
.map(|code| code.to_string())
|
||
.unwrap_or_else(|| "none".to_string()),
|
||
stderr.classification,
|
||
stderr.byte_len,
|
||
stderr.sha256
|
||
)));
|
||
}
|
||
if let Some(error) = stdin_error {
|
||
return Err(platform_llm::LlmError::Transport(error));
|
||
}
|
||
parse_game_creator_codex_cli_response(&stdout, &request)
|
||
}
|
||
|
||
pub(in crate::agent) async fn request_game_creator_agent_codex_cli(
|
||
request: LlmRunRequest,
|
||
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
|
||
let executable =
|
||
game_creator_codex_cli_executable_path().map_err(platform_llm::LlmError::InvalidConfig)?;
|
||
request_game_creator_agent_codex_cli_with_executable(executable.as_os_str(), request).await
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn tool_request() -> LlmRunRequest {
|
||
LlmRunRequest::single_turn("系统", "任务").with_function_tools(vec![
|
||
platform_llm::LlmFunctionTool::new(
|
||
"runtime_tool_file_read",
|
||
"读取文件",
|
||
serde_json::json!({"type":"object"}),
|
||
),
|
||
])
|
||
}
|
||
|
||
#[test]
|
||
fn codex_cli_reasoning_effort_preserves_max_wire_value() {
|
||
let request = LlmRunRequest::single_turn("系统", "任务")
|
||
.with_response_reasoning_effort(platform_llm::LlmResponseReasoningEffort::Max);
|
||
assert_eq!(
|
||
game_creator_codex_cli_reasoning_effort(&request),
|
||
Some("max")
|
||
);
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn codex_cli_candidates_prefer_sorted_native_npm_targets_before_path() {
|
||
let temp = tempfile::tempdir().expect("temp dir");
|
||
let app_data = temp.path().join("app-data");
|
||
let vendor = app_data
|
||
.join("npm/node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor");
|
||
std::fs::create_dir_all(vendor.join("z-target/bin")).expect("z target");
|
||
std::fs::create_dir_all(vendor.join("a-target/bin")).expect("a target");
|
||
let path_dir = temp.path().join("path");
|
||
std::fs::create_dir_all(&path_dir).expect("path dir");
|
||
|
||
let candidates = game_creator_codex_cli_executable_candidates_for(
|
||
None,
|
||
Some(&app_data),
|
||
None,
|
||
None,
|
||
Some(path_dir.as_os_str()),
|
||
);
|
||
assert_eq!(
|
||
candidates[0],
|
||
vendor.join("a-target/bin/codex.exe"),
|
||
"native npm targets must be deterministic and precede PATH"
|
||
);
|
||
assert_eq!(candidates[1], vendor.join("z-target/bin/codex.exe"));
|
||
assert_eq!(candidates[2], path_dir.join("codex.exe"));
|
||
assert_eq!(candidates.last(), Some(&PathBuf::from("codex")));
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn codex_cli_candidates_discover_native_npm_target_from_path_without_appdata() {
|
||
let temp = tempfile::tempdir().expect("temp dir");
|
||
let npm_root = temp.path().join("npm");
|
||
let native = npm_root
|
||
.join("node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor")
|
||
.join("x86_64-pc-windows-msvc/bin/codex.exe");
|
||
std::fs::create_dir_all(native.parent().expect("native parent"))
|
||
.expect("native target directory");
|
||
|
||
let candidates = game_creator_codex_cli_executable_candidates_for(
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
Some(npm_root.as_os_str()),
|
||
);
|
||
assert_eq!(candidates[0], native);
|
||
assert_eq!(candidates[1], npm_root.join("codex.exe"));
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn codex_cli_candidates_discover_native_npm_target_from_runtime_config_dir() {
|
||
let temp = tempfile::tempdir().expect("temp dir");
|
||
let app_data = temp.path().join("roaming");
|
||
let config_dir = app_data.join("world.genarrative.ai-game-creator");
|
||
let native = app_data
|
||
.join("npm/node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor")
|
||
.join("x86_64-pc-windows-msvc/bin/codex.exe");
|
||
std::fs::create_dir_all(native.parent().expect("native parent"))
|
||
.expect("native target directory");
|
||
|
||
let candidates = game_creator_codex_cli_executable_candidates_for(
|
||
None,
|
||
None,
|
||
None,
|
||
Some(&config_dir),
|
||
None,
|
||
);
|
||
assert_eq!(candidates[0], native);
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn codex_cli_candidates_discover_desktop_native_target() {
|
||
let temp = tempfile::tempdir().expect("temp dir");
|
||
let local_app_data = temp.path().join("local");
|
||
let older = local_app_data.join("OpenAI/Codex/bin/111/codex.exe");
|
||
let newer = local_app_data.join("OpenAI/Codex/bin/222/codex.exe");
|
||
std::fs::create_dir_all(older.parent().expect("older parent")).expect("older dir");
|
||
std::fs::create_dir_all(newer.parent().expect("newer parent")).expect("newer dir");
|
||
|
||
let candidates = game_creator_codex_cli_executable_candidates_for(
|
||
None,
|
||
None,
|
||
Some(&local_app_data),
|
||
None,
|
||
None,
|
||
);
|
||
assert_eq!(candidates[0], newer);
|
||
assert_eq!(candidates[1], older);
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn codex_cli_candidates_prefer_bundled_sidecar_before_npm_and_path() {
|
||
let temp = tempfile::tempdir().expect("temp dir");
|
||
let resources = temp.path().join("resources");
|
||
let bundled = resources.join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH);
|
||
std::fs::create_dir_all(bundled.parent().expect("bundled parent")).expect("bundled dir");
|
||
let app_data = temp.path().join("app-data");
|
||
let native = app_data
|
||
.join("npm/node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor")
|
||
.join("x86_64-pc-windows-msvc/bin/codex.exe");
|
||
std::fs::create_dir_all(native.parent().expect("native parent")).expect("native dir");
|
||
|
||
let candidates = game_creator_codex_cli_executable_candidates_for(
|
||
Some(&resources),
|
||
Some(&app_data),
|
||
None,
|
||
None,
|
||
None,
|
||
);
|
||
assert_eq!(candidates[0], bundled);
|
||
assert_eq!(candidates[1], native);
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn bundled_codex_cli_requires_matching_manifest_hash() {
|
||
let temp = tempfile::tempdir().expect("temp dir");
|
||
let executable = temp
|
||
.path()
|
||
.join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH);
|
||
std::fs::create_dir_all(executable.parent().expect("sidecar parent")).expect("sidecar dir");
|
||
let bundle_root = executable
|
||
.parent()
|
||
.and_then(Path::parent)
|
||
.expect("bundle root");
|
||
let mut hashes = std::collections::BTreeMap::new();
|
||
for relative in GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES {
|
||
let path = bundle_root.join(relative);
|
||
std::fs::create_dir_all(path.parent().expect("component parent"))
|
||
.expect("component dir");
|
||
let bytes = format!("trusted {relative}");
|
||
std::fs::write(&path, bytes.as_bytes()).expect("component bytes");
|
||
hashes.insert(relative, format!("{:x}", Sha256::digest(bytes.as_bytes())));
|
||
}
|
||
let files = serde_json::to_string(&hashes).expect("component hashes");
|
||
std::fs::write(
|
||
bundle_root.join("manifest.json"),
|
||
format!(
|
||
r#"{{"schemaVersion":"genarrative-codex-sidecar.v2","platform":"win32-x64","version":"codex-cli test","files":{files}}}"#
|
||
),
|
||
)
|
||
.expect("sidecar manifest");
|
||
|
||
assert_eq!(
|
||
validate_game_creator_bundled_codex_cli(&executable).expect("trusted sidecar"),
|
||
"codex-cli test"
|
||
);
|
||
std::fs::write(&executable, b"modified sidecar").expect("tamper sidecar");
|
||
assert!(validate_game_creator_bundled_codex_cli(&executable)
|
||
.expect_err("tampered sidecar must be rejected")
|
||
.contains("完整性校验失败"));
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn bundled_codex_cli_rejects_a_manifest_without_code_mode_host() {
|
||
let temp = tempfile::tempdir().expect("temp dir");
|
||
let executable = temp
|
||
.path()
|
||
.join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH);
|
||
std::fs::create_dir_all(executable.parent().expect("sidecar parent")).expect("sidecar dir");
|
||
let bundle_root = executable
|
||
.parent()
|
||
.and_then(Path::parent)
|
||
.expect("bundle root");
|
||
let bytes = b"codex only";
|
||
std::fs::write(&executable, bytes).expect("codex bytes");
|
||
let hash = format!("{:x}", Sha256::digest(bytes));
|
||
std::fs::write(
|
||
bundle_root.join("manifest.json"),
|
||
format!(
|
||
r#"{{"schemaVersion":"genarrative-codex-sidecar.v2","platform":"win32-x64","version":"codex-cli test","files":{{"bin/codex.exe":"{hash}"}}}}"#
|
||
),
|
||
)
|
||
.expect("sidecar manifest");
|
||
|
||
assert!(validate_game_creator_bundled_codex_cli(&executable)
|
||
.expect_err("missing code-mode host must fail closed")
|
||
.contains("完整性清单不受支持"));
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn codex_cli_resolver_finds_current_native_install() {
|
||
let executable = game_creator_codex_cli_executable_path().expect("Codex CLI executable");
|
||
assert!(executable.is_absolute());
|
||
assert_eq!(
|
||
game_creator_codex_cli_version_identity().expect("Codex CLI version"),
|
||
game_creator_codex_cli_version_at(&executable).expect("same executable version")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_cli_mode_renders_runtime_messages_and_structured_tool_contract() {
|
||
let prompt = render_game_creator_codex_cli_prompt(&tool_request()).expect("render prompt");
|
||
assert!(prompt.contains("runtime_tool_file_read"));
|
||
assert!(prompt.contains("项目行动只能通过返回 Runtime 函数请求完成"));
|
||
assert!(!prompt.contains(GAME_CREATOR_CODEX_CLI_EXECUTABLE));
|
||
let schema = game_creator_codex_cli_tool_output_schema(&tool_request()).expect("schema");
|
||
assert_eq!(
|
||
schema.pointer("/properties/toolCalls/items/properties/name/enum/0"),
|
||
Some(&serde_json::json!("runtime_tool_file_read"))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_cli_mode_removes_ambient_api_key_from_effective_environment() {
|
||
let mut command = tokio::process::Command::new("codex");
|
||
command.env("CODEX_API_KEY", "ambient-secret");
|
||
game_creator_codex_cli_minimal_environment(&mut command);
|
||
assert!(!command
|
||
.as_std()
|
||
.get_envs()
|
||
.any(|(name, value)| name == "CODEX_API_KEY" && value.is_some()));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn codex_cli_mode_bounds_unterminated_output_and_summarizes_stderr_without_raw_text() {
|
||
let output = vec![b'x'; 33];
|
||
let error = read_game_creator_codex_cli_output(output.as_slice(), 32)
|
||
.await
|
||
.expect_err("unterminated output over the cap must fail");
|
||
assert!(error.contains("超过 32 字节上限"));
|
||
|
||
let stderr = b"private-auth-detail-without-newline";
|
||
let summary = summarize_game_creator_codex_cli_stderr(stderr.as_slice(), 256)
|
||
.await
|
||
.expect("summarize stderr");
|
||
assert_eq!(summary.classification, "nonempty");
|
||
assert_eq!(summary.byte_len, stderr.len());
|
||
assert_eq!(summary.sha256, format!("{:x}", Sha256::digest(stderr)));
|
||
assert!(!summary.sha256.contains("private-auth-detail"));
|
||
}
|
||
|
||
#[test]
|
||
fn codex_cli_mode_maps_structured_agent_message_to_existing_tool_calls() {
|
||
let stdout = concat!(
|
||
"{\"type\":\"thread.started\",\"thread_id\":\"thread-1\"}\n",
|
||
"{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"{\\\"toolCalls\\\":[{\\\"name\\\":\\\"runtime_tool_file_read\\\",\\\"arguments\\\":\\\"{\\\\\\\"reason\\\\\\\":\\\\\\\"检查\\\\\\\",\\\\\\\"input\\\\\\\":{\\\\\\\"path\\\\\\\":\\\\\\\"game/index.html\\\\\\\"}}\\\"}]}\"}}\n",
|
||
"{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":7}}\n"
|
||
);
|
||
let response = parse_game_creator_codex_cli_response(stdout.as_bytes(), &tool_request())
|
||
.expect("parse response");
|
||
assert_eq!(response.response_id.as_deref(), Some("thread-1"));
|
||
assert_eq!(response.tool_calls.len(), 1);
|
||
assert_eq!(response.tool_calls[0].name, "runtime_tool_file_read");
|
||
assert_eq!(response.usage.expect("usage").total_tokens, 19);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_cli_mode_maps_plain_final_message_and_requires_terminal_event() {
|
||
let request = LlmRunRequest::single_turn("系统", "任务");
|
||
let stdout = concat!(
|
||
"{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"完成\"}}\n",
|
||
"{\"type\":\"turn.completed\",\"usage\":{}}\n"
|
||
);
|
||
let response = parse_game_creator_codex_cli_response(stdout.as_bytes(), &request)
|
||
.expect("parse response");
|
||
assert_eq!(response.text, "完成");
|
||
assert!(response.tool_calls.is_empty());
|
||
assert!(matches!(
|
||
parse_game_creator_codex_cli_response(
|
||
b"{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"x\"}}\n",
|
||
&request
|
||
),
|
||
Err(platform_llm::LlmError::Deserialize(_))
|
||
));
|
||
|
||
let recovered_stdout = concat!(
|
||
"{\"type\":\"error\",\"message\":\"Reconnecting\"}\n",
|
||
"{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"恢复成功\"}}\n",
|
||
"{\"type\":\"turn.completed\",\"usage\":{}}\n"
|
||
);
|
||
assert_eq!(
|
||
parse_game_creator_codex_cli_response(recovered_stdout.as_bytes(), &request)
|
||
.expect("短暂 error 事件后完成")
|
||
.text,
|
||
"恢复成功"
|
||
);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[tokio::test]
|
||
async fn codex_cli_mode_builds_ephemeral_json_exec_with_prompt_on_stdin() {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
|
||
let temp = tempfile::tempdir().expect("temp dir");
|
||
let executable = temp.path().join("fake-codex");
|
||
std::fs::write(
|
||
&executable,
|
||
r#"#!/bin/sh
|
||
prompt="$(cat)"
|
||
case "$prompt" in
|
||
*"CODEx_STDIN_MARKER"*) ;;
|
||
*) exit 41 ;;
|
||
esac
|
||
has_exec=0
|
||
has_json=0
|
||
has_ephemeral=0
|
||
has_ignore_user_config=0
|
||
has_read_only=0
|
||
has_shell_disabled=0
|
||
previous=""
|
||
for argument in "$@"; do
|
||
[ "$argument" = "exec" ] && has_exec=1
|
||
[ "$argument" = "--json" ] && has_json=1
|
||
[ "$argument" = "--ephemeral" ] && has_ephemeral=1
|
||
[ "$argument" = "--ignore-user-config" ] && has_ignore_user_config=1
|
||
[ "$previous:$argument" = "--sandbox:read-only" ] && has_read_only=1
|
||
[ "$previous:$argument" = "--disable:shell_tool" ] && has_shell_disabled=1
|
||
previous="$argument"
|
||
done
|
||
[ "$has_exec$has_json$has_ephemeral$has_ignore_user_config$has_read_only$has_shell_disabled" = "111111" ] || exit 42
|
||
printf '%s\n' '{"type":"thread.started","thread_id":"fake-thread"}'
|
||
printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"fixture-ok"}}'
|
||
printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens":2}}'
|
||
"#,
|
||
)
|
||
.expect("write fake codex");
|
||
let mut permissions = std::fs::metadata(&executable)
|
||
.expect("fake metadata")
|
||
.permissions();
|
||
permissions.set_mode(0o700);
|
||
std::fs::set_permissions(&executable, permissions).expect("chmod fake codex");
|
||
|
||
let request = LlmRunRequest::single_turn("系统", "CODEx_STDIN_MARKER");
|
||
let response =
|
||
request_game_creator_agent_codex_cli_with_executable(executable.as_os_str(), request)
|
||
.await
|
||
.expect("fake codex request");
|
||
assert_eq!(response.text, "fixture-ok");
|
||
assert_eq!(response.response_id.as_deref(), Some("fake-thread"));
|
||
assert_eq!(response.usage.expect("usage").total_tokens, 5);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[tokio::test]
|
||
async fn codex_cli_mode_does_not_expose_process_stderr_on_failure() {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
|
||
let temp = tempfile::tempdir().expect("temp dir");
|
||
let executable = temp.path().join("fake-codex-failure");
|
||
std::fs::write(
|
||
&executable,
|
||
"#!/bin/sh\nprintf '%s\\n' 'secret-auth-detail' >&2\nexit 43\n",
|
||
)
|
||
.expect("write fake codex");
|
||
let mut permissions = std::fs::metadata(&executable)
|
||
.expect("fake metadata")
|
||
.permissions();
|
||
permissions.set_mode(0o700);
|
||
std::fs::set_permissions(&executable, permissions).expect("chmod fake codex");
|
||
|
||
let error = request_game_creator_agent_codex_cli_with_executable(
|
||
executable.as_os_str(),
|
||
LlmRunRequest::single_turn("系统", "任务"),
|
||
)
|
||
.await
|
||
.expect_err("fake Codex should fail")
|
||
.to_string();
|
||
assert!(error.contains("code=43"));
|
||
assert!(error.contains("stderrClass=nonempty"));
|
||
assert!(error.contains("stderrBytes=19"));
|
||
assert!(error.contains("stderrSha256="));
|
||
assert!(error.contains("登录状态"));
|
||
assert!(!error.contains("secret-auth-detail"));
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[tokio::test]
|
||
async fn codex_cli_mode_enforces_runtime_request_timeout() {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
|
||
let temp = tempfile::tempdir().expect("temp dir");
|
||
let executable = temp.path().join("fake-codex-timeout");
|
||
std::fs::write(&executable, "#!/bin/sh\ncat >/dev/null\nsleep 30\n")
|
||
.expect("write fake codex");
|
||
let mut permissions = std::fs::metadata(&executable)
|
||
.expect("fake metadata")
|
||
.permissions();
|
||
permissions.set_mode(0o700);
|
||
std::fs::set_permissions(&executable, permissions).expect("chmod fake codex");
|
||
|
||
let error = request_game_creator_agent_codex_cli_with_executable(
|
||
executable.as_os_str(),
|
||
LlmRunRequest::single_turn("系统", "任务").with_request_timeout_ms(20),
|
||
)
|
||
.await
|
||
.expect_err("fake Codex should time out");
|
||
assert_eq!(error, platform_llm::LlmError::Timeout { attempts: 1 });
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires an installed and authenticated Codex CLI"]
|
||
async fn codex_cli_real_smoke_uses_existing_local_auth() {
|
||
let request = LlmRunRequest::single_turn(
|
||
"只回复指定文本,不使用任何工具。",
|
||
"请只回复 CODEX_CLI_SMOKE_OK",
|
||
);
|
||
let response = request_game_creator_agent_codex_cli(request)
|
||
.await
|
||
.expect("real Codex CLI request");
|
||
assert_eq!(response.text.trim(), "CODEX_CLI_SMOKE_OK");
|
||
assert!(response.tool_calls.is_empty());
|
||
}
|
||
}
|