Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs
T
kdletters 485ed50b26
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
AGC 七项交付效率优化并升级捆绑 Codex 到 0.155.1 (#439)
## 背景

净月潭案例(2026-09-19 21:53 → 09-20 01:26,3 小时 33 分)的问题不是模型慢,而是环境未就绪、验收靠模型自述、预算只约束单个工具入口、工具调用被 SDK 全局串行闸门卡住。本 PR 落地确认后的七项交付效率合同,并把捆绑 Codex 升到当前 npm latest。

## 结果

- 新建 Web 游戏在正式生成前由宿主自动预检:捆绑 Node/npm、真实 Vite 构建、受限浏览器桌面/移动截图;失败不启动生成或付费素材。
- 首个副作用前冻结交付合同,宿主保存权威证据与预算;证据齐全后先封口、排空并取得执行器完整退出证明,再产出交付报告,模型回复不再当作验收。
- 原生 shell、内置浏览器与托管命令、第三方 MCP 共用同一执行许可和累计执行时间;`validation.maxRuns` 改为执行/返修批次语义,另设 `maxTurnSeconds` 墙钟上限。
- 所有工具可并发:移除 SDK 全局串行的 `apply_patch`/`update_plan` 注册,改由宿主 `agc_apply_patch`(官方 parser + 当前回合写许可 + 受控进程树 + 短项目事务)和 `agc_update_plan` 提供等价能力;真实请求目录里已无串行注册,长 MCP 与补丁/计划实测在同一响应内重叠。
- 付费提交与本地写入绑定原回合原租约:容量与同动作锁等待可取消,封口、取消或预算耗尽后零新增提交;已越过提交边界的请求保留 operation ID 走 GET 对账,不自动重放。
- 新增请求与工具分段计时、有界并行批读和首轮上下文预取,未知耗时不补零。
- 捆绑 Codex 0.147.0 → 0.155.1:固定版本收敛到 `build_support/codex_bundle.rs` 单一声明,vendor 解析源码按 `rust-v0.155.1` 逐字节重取并更新 UPSTREAM 证据,适配 0.155 统一 exec(`exec_command`/`write_stdin`);macOS 侧车最小系统版本仍为 15.0。

## 验证

- 生产 CLI → 捆绑 Codex 0.155.1 → 本地 Responses/MCP 夹具 9/9:补丁、完成收尾、批次、只读/可写 MCP 并发、原生命令并发、原生资源并发、统一 exec 会话、期限终止(真实重叠 775 / 764 / 999 ms)。
- 真实模型目录 2/2;vendor 上游库 111 项;宿主定向与回归 106 项;真实 Windows 进程与取消 21 项;Node 侧门禁 52 项。
- 发行载荷:artifact-only 重建 NSIS,解包验证侧车清单 `codex-cli 0.155.1`、6 个组件哈希、打包后 `bin/codex.exe --version`、Node 运行时 2130 文件与双端 PNG、`--environment-check` ready。
- `check-config`、TypeScript、编码(4991 文件)、文档索引、`git diff --check`、`cargo fmt --check` 全部通过。

## 未覆盖

- 真实陶泥儿登录态 Provider 生成尚未执行(本机 `--llm-status` 返回 `authentication-required`)。
- macOS 侧车只在本机做静态 Mach-O 与清单解析,未在 macOS 上跑 `check-macos-bundle.mjs`。
- 全仓库聚合套件在本机仍因负载敏感的后台 mock-provider 用例而红,与本次改动无关:改动前的旧二进制同样失败,换单线程后相关用例 3/3 通过。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/439
2026-09-21 02:29:29 +08:00

1279 lines
49 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use sha2::{Digest, Sha256};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
#[path = "../../build_support/codex_bundle.rs"]
pub(crate) mod codex_bundle;
const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex";
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();
if let Some(bundled) = game_creator_bundled_codex_cli_path(resource_dir) {
candidates.push(bundled);
}
#[cfg(windows)]
{
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> {
let executable = std::env::current_exe().ok()?;
game_creator_bundled_resource_dir_for(&executable)
}
fn game_creator_bundled_resource_dir_for(executable: &Path) -> Option<PathBuf> {
#[cfg(windows)]
{
executable.parent().map(Path::to_path_buf)
}
#[cfg(target_os = "macos")]
{
let macos = executable.parent()?;
let contents = macos.parent()?;
// 只接受真正的 app bundle 结构,开发态不从任意相邻目录加载程序。
if macos.file_name()? != "MacOS"
|| contents.file_name()? != "Contents"
|| contents.parent()?.extension()? != "app"
{
return None;
}
Some(contents.join("Resources"))
}
#[cfg(not(any(windows, target_os = "macos")))]
{
let _ = executable;
None
}
}
fn game_creator_bundled_codex_cli_path(resource_dir: Option<&Path>) -> Option<PathBuf> {
let layout = codex_bundle::for_target(env!("AGC_BUILD_TARGET"))?;
Some(
resource_dir?
.join("coding-agent")
.join(layout.directory)
.join(layout.executable),
)
}
fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result<String, String> {
let layout = codex_bundle::for_target(env!("AGC_BUILD_TARGET"))
.ok_or_else(|| "当前平台不支持内置 Codex CLI".to_string())?;
let bundle_root = executable
.parent()
.and_then(Path::parent)
.ok_or_else(|| "内置 Codex CLI 路径无效".to_string())?;
let manifest_path = bundle_root.join("manifest.json");
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 != codex_bundle::SCHEMA
|| manifest.platform != layout.platform
|| manifest.version.trim().is_empty()
|| layout.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 layout.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)
}
pub(in crate::agent) 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": prompt_text!("execution.codexCli.argumentsDescription")
}
}
}
}
}
}))
}
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() {
prompt_text!("execution.codexCli.finalTextContract").to_string()
} else {
prompt_text!("execution.codexCli.toolCallsContract").to_string()
};
let prompt = format!(
prompt_text!("execution.codexCli.request"),
output_contract = output_contract,
messages = messages,
functions = 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()
},
reasoning: 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 = game_creator_bundled_codex_cli_path(Some(&resources)).unwrap();
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(any(windows, target_os = "macos"))]
#[test]
fn bundled_codex_cli_requires_matching_manifest_hash() {
let temp = tempfile::tempdir().expect("temp dir");
let executable = game_creator_bundled_codex_cli_path(Some(temp.path())).unwrap();
let layout = codex_bundle::for_target(env!("AGC_BUILD_TARGET")).unwrap();
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 layout.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 manifest = serde_json::json!({
"schemaVersion": codex_bundle::SCHEMA,
"platform": layout.platform,
"version": "codex-cli test",
"files": hashes,
});
let manifest_path = bundle_root.join("manifest.json");
std::fs::write(&manifest_path, manifest.to_string()).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("完整性校验失败"));
std::fs::write(&executable, format!("trusted {}", layout.executable)).unwrap();
let mut wrong_platform = manifest.clone();
wrong_platform["platform"] = serde_json::json!("wrong-platform");
std::fs::write(&manifest_path, wrong_platform.to_string()).unwrap();
assert!(validate_game_creator_bundled_codex_cli(&executable)
.expect_err("wrong platform must be rejected")
.contains("完整性清单不受支持"));
std::fs::write(&manifest_path, manifest.to_string()).unwrap();
let helper = bundle_root.join(layout.files[1]);
std::fs::write(&helper, b"tampered helper").unwrap();
assert!(validate_game_creator_bundled_codex_cli(&executable)
.expect_err("tampered helper must be rejected")
.contains("完整性校验失败"));
std::fs::remove_file(&helper).unwrap();
assert!(validate_game_creator_bundled_codex_cli(&executable)
.expect_err("missing helper must be rejected")
.contains("缺少必需组件"));
}
#[cfg(any(windows, target_os = "macos"))]
#[test]
fn bundled_codex_cli_rejects_a_manifest_without_code_mode_host() {
let temp = tempfile::tempdir().expect("temp dir");
let executable = game_creator_bundled_codex_cli_path(Some(temp.path())).unwrap();
let layout = codex_bundle::for_target(env!("AGC_BUILD_TARGET")).unwrap();
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"),
serde_json::json!({
"schemaVersion": codex_bundle::SCHEMA,
"platform": layout.platform,
"version": "codex-cli test",
"files": { layout.executable: hash },
})
.to_string(),
)
.expect("sidecar manifest");
assert!(validate_game_creator_bundled_codex_cli(&executable)
.expect_err("missing code-mode host must fail closed")
.contains("完整性清单不受支持"));
}
#[cfg(target_os = "macos")]
#[test]
fn macos_bundle_resources_and_candidates_do_not_require_a_development_path() {
let executable = Path::new("/Applications/陶泥儿 测试.app/Contents/MacOS/taonier");
let resources = game_creator_bundled_resource_dir_for(executable).unwrap();
assert_eq!(
resources,
Path::new("/Applications/陶泥儿 测试.app/Contents/Resources")
);
let bundled = game_creator_bundled_codex_cli_path(Some(&resources)).unwrap();
let candidates = game_creator_codex_cli_executable_candidates_for(
Some(&resources),
None,
None,
None,
None,
);
assert_eq!(candidates, vec![bundled, PathBuf::from("codex")]);
assert!(
game_creator_bundled_resource_dir_for(Path::new("/tmp/target/release/taonier"))
.is_none()
);
assert!(
game_creator_bundled_resource_dir_for(Path::new("/tmp/Contents/MacOS/taonier"))
.is_none()
);
assert_eq!(
game_creator_codex_cli_executable_candidates_for(None, None, None, None, None),
vec![PathBuf::from("codex")],
);
}
#[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());
}
}