5550e24f83
新增 Codex app-server 与 CLI 节点执行模式并保留 Provider 回退 加固节点凭据隔离、终态未知回收、进程生命周期与持久恢复边界 修复资源画布等价刷新闪烁 为 Supervisor steer 增加 LLM 回复与条件中断 补齐配置界面、测试和技术文档
784 lines
30 KiB
Rust
784 lines
30 KiB
Rust
use super::*;
|
||
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_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;
|
||
|
||
struct CodexCliStderrSummary {
|
||
byte_len: usize,
|
||
sha256: String,
|
||
classification: &'static str,
|
||
}
|
||
|
||
pub(in crate::agent) fn game_creator_codex_cli_version_identity() -> Result<String, String> {
|
||
let output = std::process::Command::new(GAME_CREATOR_CODEX_CLI_EXECUTABLE)
|
||
.arg("--version")
|
||
.stdin(Stdio::null())
|
||
.stderr(Stdio::null())
|
||
.output()
|
||
.map_err(|_| "Codex CLI 未安装或不在当前 Agent Runner PATH 中".to_string())?;
|
||
if !output.status.success() {
|
||
return Err("Codex CLI 版本检查失败".to_string());
|
||
}
|
||
let version = std::str::from_utf8(&output.stdout)
|
||
.map_err(|_| "Codex CLI 版本信息不是 UTF-8".to_string())?
|
||
.trim();
|
||
if !version.starts_with("codex-cli ") || version.len() > 120 {
|
||
return Err("Codex CLI 返回了无法识别的版本信息".to_string());
|
||
}
|
||
Ok(version.to_string())
|
||
}
|
||
|
||
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",
|
||
})
|
||
}
|
||
|
||
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、文件、网络、MCP、插件、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,
|
||
})
|
||
}
|
||
|
||
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())
|
||
})?;
|
||
stdin.write_all(prompt.as_bytes()).await.map_err(|error| {
|
||
platform_llm::LlmError::Transport(format!("写入 Codex CLI Agent prompt 失败:{error}"))
|
||
})?;
|
||
stdin.shutdown().await.map_err(|error| {
|
||
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
|
||
)));
|
||
}
|
||
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> {
|
||
request_game_creator_agent_codex_cli_with_executable(
|
||
std::ffi::OsStr::new(GAME_CREATOR_CODEX_CLI_EXECUTABLE),
|
||
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_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());
|
||
}
|
||
}
|