让 Direct GUI 回合落下有界行为审计账本
Project CI / Repository checks (pull_request) Failing after 3m49s
Project CI / Frontend tests (pull_request) Successful in 4m6s
Project CI / Backend tests (pull_request) Successful in 8m6s
Project CI / Native shell tests (pull_request) Successful in 17m50s

- 新增 DirectCodexTurnAudit,把 item/completed 抽成项目内 jsonl 与 agent.db 摘要
- GUI Direct 回合记录 sidecar 路径、native 读/MCP/写文件、offeredRead 与 firstDesign
- 不落 stdout、patch、MCP result,不进聊天 jsonl,失败不阻断做游戏
- 补充技术方案、decision-log 与文档索引
This commit is contained in:
2026-08-31 10:21:04 +00:00
parent 1cb8e92e17
commit 0951bb2013
11 changed files with 1955 additions and 46 deletions
@@ -13,6 +13,7 @@ mod codex_app_server;
mod codex_cli;
mod codex_provider_proxy;
mod direct_codex_attachments;
mod direct_codex_audit;
mod direct_runtime;
mod direct_tool_bridge;
mod direct_tools_mcp;
@@ -36,6 +37,7 @@ pub(crate) use codex_cli::{
};
pub(crate) use codex_provider_proxy::*;
pub(crate) use direct_codex_attachments::*;
pub(crate) use direct_codex_audit::*;
pub(crate) use direct_runtime::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tools_mcp::*;
@@ -1905,8 +1905,15 @@ impl CodexAppServerConnection {
request: LlmRunRequest,
on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
self.run_turn_with_direct_observer(snapshot, llm, request, on_agent_message_delta, None)
.await
self.run_turn_with_direct_observer(
snapshot,
llm,
request,
on_agent_message_delta,
None,
None,
)
.await
}
async fn run_turn_with_direct_observer(
@@ -1916,6 +1923,7 @@ impl CodexAppServerConnection {
request: LlmRunRequest,
mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
mut audit: Option<&mut DirectCodexTurnAudit>,
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
let _turn_guard = self.inner.turn_gate.lock().await;
let thread_lease = self.thread_for(snapshot, &request, llm).await?;
@@ -2085,6 +2093,11 @@ impl CodexAppServerConnection {
completed,
&params,
);
if completed {
if let Some(audit) = audit.as_mut() {
audit.observe_item(&params);
}
}
}
if item_type == "agentMessage" {
if let Some(text) = item
@@ -2796,8 +2809,14 @@ pub(crate) async fn direct_game_creator_codex_chat_at(
system_prompt: String,
user_prompt: String,
) -> Result<String, String> {
direct_game_creator_codex_chat_at_with_optional_observer(root, system_prompt, user_prompt, None)
.await
direct_game_creator_codex_chat_at_with_optional_observer(
root,
system_prompt,
user_prompt,
None,
None,
)
.await
}
pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
@@ -2811,6 +2830,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
system_prompt,
user_prompt,
Some(observer),
None,
)
.await
}
@@ -2861,11 +2881,12 @@ fn direct_codex_project_identity_digest(path_identity: &[u8], project_id: &[u8])
format!("{:x}", digest.finalize())
}
async fn direct_game_creator_codex_chat_at_with_optional_observer(
pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
root: &std::path::Path,
system_prompt: String,
user_prompt: String,
observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
audit: Option<&mut DirectCodexTurnAudit>,
) -> Result<String, String> {
// Resolve project authority before deriving the pool/thread identity. A
// caller may hold a stable symlink path whose target changes between
@@ -2917,7 +2938,7 @@ async fn direct_game_creator_codex_chat_at_with_optional_observer(
.await
.map_err(|error| error.to_string())?;
connection
.run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer)
.run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer, audit)
.await
.map(|value| value.text)
.map_err(|error| error.to_string())
@@ -4245,6 +4266,7 @@ while IFS= read -r line; do :; done
tool_request(),
Some(&mut on_delta),
Some(&mut observer),
None,
)
.await
.expect("run fake app-server turn");
@@ -1,7 +1,7 @@
//! Direct Codex 本轮附件 sidecar:Home 与 Project 共用同一 DTO 和渲染函数。
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
@@ -14,17 +14,17 @@ const PROJECT_ATTACHMENT_HEADER: &str =
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexTurnAttachment {
name: String,
media_type: String,
pub(crate) name: String,
pub(crate) media_type: String,
#[serde(default)]
size: u64,
pub(crate) size: u64,
#[serde(default)]
local_path: Option<String>,
pub(crate) local_path: Option<String>,
#[serde(default)]
status: Option<String>,
pub(crate) status: Option<String>,
}
fn sanitize_attachment_name(value: &str) -> String {
pub(crate) fn sanitize_attachment_name(value: &str) -> String {
let basename = value.rsplit(['/', '\\']).next().unwrap_or_default().trim();
let sanitized = basename
.chars()
@@ -38,7 +38,7 @@ fn sanitize_attachment_name(value: &str) -> String {
}
}
fn sanitize_attachment_media_type(value: &str) -> String {
pub(crate) fn sanitize_attachment_media_type(value: &str) -> String {
let value = value.trim();
if value.is_empty()
|| value.chars().any(|character| {
@@ -54,7 +54,7 @@ fn sanitize_attachment_media_type(value: &str) -> String {
}
}
fn sanitize_attachment_status(value: Option<&str>) -> Option<&'static str> {
pub(crate) fn sanitize_attachment_status(value: Option<&str>) -> Option<&'static str> {
match value.map(str::trim) {
Some("imported") => Some("imported"),
Some("failed") => Some("failed"),
@@ -62,7 +62,7 @@ fn sanitize_attachment_status(value: Option<&str>) -> Option<&'static str> {
}
}
fn sanitize_attachment_local_path(value: &str) -> Option<String> {
pub(crate) fn sanitize_attachment_local_path(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty()
|| trimmed.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS
@@ -104,7 +104,7 @@ fn sanitize_attachment_local_path(value: &str) -> Option<String> {
Some(path)
}
fn attachments_use_project_mapping(attachments: &[DirectCodexTurnAttachment]) -> bool {
pub(crate) fn attachments_use_project_mapping(attachments: &[DirectCodexTurnAttachment]) -> bool {
attachments.iter().any(|attachment| {
attachment
.local_path
@@ -421,13 +421,8 @@ mod tests {
#[test]
fn unknown_status_keeps_home_attachment_metadata_shape() {
let attachment = project_attachment(
"pending.md",
"text/markdown",
1,
None,
Some("pending"),
);
let attachment =
project_attachment("pending.md", "text/markdown", 1, None, Some("pending"));
let prompt = render_direct_codex_user_prompt("x", &[attachment]).expect("render");
assert!(prompt.contains(HOME_ATTACHMENT_HEADER));
assert!(!prompt.contains(PROJECT_ATTACHMENT_HEADER));
File diff suppressed because it is too large Load Diff
@@ -2174,9 +2174,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec<String> {
fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec<String> {
let sources = direct_codex_game_outputs(root)
.into_iter()
.filter_map(|(relative_path, _, _)| {
std::fs::read_to_string(root.join(relative_path)).ok()
})
.filter_map(|(relative_path, _, _)| std::fs::read_to_string(root.join(relative_path)).ok())
.collect::<Vec<_>>();
let mut available_paths = Vec::new();
if direct_taonier_art_base_is_valid(root) {
@@ -2264,9 +2262,7 @@ fn direct_browser_evidence_needs_art_repair(
fn direct_game_output_completion_error(root: &Path) -> Option<String> {
let entry = agent_runtime_game_entry_relative_path(root);
if !root.join(entry).is_file() {
return Some(format!(
"Codex 返回后未找到 {entry},项目未进入可运行状态"
));
return Some(format!("Codex 返回后未找到 {entry},项目未进入可运行状态"));
}
if !direct_game_sources_reference_taonier_art_package(root) {
return Some(
@@ -3781,6 +3777,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type(
prompt,
creation_type,
None,
None,
)
.await
}
@@ -3790,6 +3787,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
prompt: &str,
creation_type: Option<&str>,
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
audit: Option<&mut DirectCodexTurnAudit>,
) -> Result<String, String> {
if !root.is_absolute() || !root.is_dir() {
return Err("当前项目目录不存在或不是绝对路径".to_string());
@@ -3805,7 +3803,8 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
if let Some(emitter) = turn_emitter {
emitter.emit("accepted", Some("request-accepted"), None);
}
match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter).await {
match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter, audit).await
{
Ok(reply) => Ok(reply),
Err(failure) => {
let error = record_direct_codex_turn_failure(root, failure);
@@ -3822,6 +3821,7 @@ async fn run_direct_game_creator_turn_inner(
prompt: &str,
creation_type: Option<&str>,
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
audit: Option<&mut DirectCodexTurnAudit>,
) -> Result<String, DirectCodexTurnFailure> {
emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息");
if let Some(emitter) = turn_emitter {
@@ -3850,15 +3850,23 @@ async fn run_direct_game_creator_turn_inner(
);
}
};
direct_game_creator_codex_chat_at_with_observer(
direct_game_creator_codex_chat_at_with_optional_observer(
root,
system_prompt,
prompt.to_string(),
&mut observer,
Some(&mut observer),
audit,
)
.await
} else {
direct_game_creator_codex_chat_at(root, system_prompt, prompt.to_string()).await
direct_game_creator_codex_chat_at_with_optional_observer(
root,
system_prompt,
prompt.to_string(),
None,
audit,
)
.await
}
.map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?;
if let Some(emitter) = turn_emitter {
@@ -4194,23 +4202,47 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
})?;
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
let user_prompt =
render_direct_codex_user_prompt(&prompt, attachments.as_deref().unwrap_or_default())?;
let reply = run_direct_game_creator_turn_at_with_creation_type_and_emitter(
let mut audit = DirectCodexTurnAudit::start(
root,
&turn_id,
&prompt,
attachments.as_deref().unwrap_or_default(),
);
let user_prompt = match render_direct_codex_user_prompt(
&prompt,
attachments.as_deref().unwrap_or_default(),
) {
Ok(prompt) => prompt,
Err(error) => {
audit.finish(false);
return Err(error);
}
};
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
root,
&user_prompt,
creation_type.as_deref(),
Some(&turn_emitter),
Some(&mut audit),
)
.await?;
persist_direct_codex_assistant_reply_at(root, &turn_id, &reply).map_err(|error| {
.await
{
Ok(reply) => reply,
Err(error) => {
audit.finish(false);
return Err(error);
}
};
if let Err(error) = persist_direct_codex_assistant_reply_at(root, &turn_id, &reply) {
audit.finish(false);
turn_emitter.emit("failed", Some("none"), None);
redact_agent_runtime_error(
return Err(redact_agent_runtime_error(
root,
&format!("Direct 成功回复持久化失败,已拒绝以未落盘状态返回:{error}"),
500,
)
})?;
));
}
audit.finish(true);
turn_emitter.emit("completed", Some("none"), Some(reply.clone()));
Ok(reply)
}