让 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 本轮附件 sidecarHome 与 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)
}
+1
View File
@@ -21,6 +21,7 @@
- [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。
- [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。
- [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md)Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。
- [项目开发工作台 PRD](./prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md):当前工作台页面和验收边界。
- [立项策划 AgentFast GDD](<./technical/【技术方案】立项策划AgentFast GDD-2026-08-10.md>):当前策划入口、审批和恢复合同。
- [GameAgent 资源自由画板与快速编辑](./technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md)
@@ -25,6 +25,14 @@
---
## 2026-08-31 Direct 回合把 Codex item 落成有界行为账本
- 背景:sidecar 已让模型看见本轮附件路径,但 native 读 / MCP / 写文件只存在于隔离 `CODEX_HOME` 的瞬时 stdout,回合结束即删。无法判断「没读附件」还是「读了仍走默认收集类」。
- 决策:GUI DirectProject 每个 `clientTurnId` 追加 `.agent/runtime/direct-codex/turns/<id>.jsonl`,并在 `agent.db` 写一条 `direct.codex.turn` 摘要。记 sidecar 提供的路径与文件 hash、`item/completed` 的 Read/List/Search/MCP/写文件(不含 stdout、patch、MCP result),以及 `offeredRead` / `firstDesign`。审计 fail-open,不阻断做游戏。Home、CLI、Supervisor 收据模型不接。不灌附件正文,不强制读取,不为 GDD 开特例。
- 影响范围:`direct_codex_audit.rs`、Direct GUI command 边界、Codex collect 循环;前端 / jsonl 气泡 / sidecar 文案不变。
- 验证方式:Rust fixture 覆盖 turn_start hash、绝对路径相对化、stdout/diff 不落盘、art brief 保留、list/search 不算已读、firstDesign 顺序、256 条截断、写盘失败不 panicsidecar 渲染与 Direct 活动词测试保持通过。
- 关联文档:`docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`、issue #212
## 2026-08-31 Direct 本轮附件只映射路径,不灌正文、不区别 GDD
- 背景:issue #212。首页附件已经复制到 `assets/uploads/` 并登记,但 Direct 首轮只把用户原文发给 Codex,原文件名不是磁盘路径,模型会另起一套玩法。
@@ -25,9 +25,10 @@ AI 游戏创作 / DirectProject / UI workflow
2. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`
3. `docs/technical/【技术方案】立项策划AgentFast GDD-2026-08-10.md`
4. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`
5. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`
6. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`
7. UI 编辑器、宿主壳和当前测试专题文档
5. `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`
6. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`
7. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`
8. UI 编辑器、宿主壳和当前测试专题文档
图片画布 / 媒体生成:
@@ -27,7 +27,7 @@
- 不把附件全文拼进 prompt,不按扩展名决定是否读取。
- 不把「没读到就阻断」做成门禁。
- 不扫 manifest 里历史 `kind=uploaded`
- 不做 native 读取审计issue #212 的第二问题,另排期)
- 不做 native 读取审计;该项由 [`【技术方案】Direct回合行为审计账本-2026-08-31.md`](./【技术方案】Direct回合行为审计账本-2026-08-31.md) 承接
- 不改 DirectHome 在「无项目路径」时的现有文案和列表格式。
- 不改 `enterCreatedHomeProject` 的空正文兜底句(与做方案共用)。
@@ -0,0 +1,389 @@
# Direct 回合行为审计账本
- 日期:2026-08-31
- 状态:现行合同(已按本文落地)
- 问题:Gitea issue #212 的第二段(Direct 原生读 / 工具行为无法从项目产物判断);用于分析「附件已映射仍未按文档实施」
- 关联:[`【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`](./【技术方案】DirectProject本轮附件路径映射-2026-08-31.md)、[`【技术说明】DirectProject未消费用户上传权威文档-2026-08-30.md`](./【技术说明】DirectProject未消费用户上传权威文档-2026-08-30.md)
- 原则:落地后代码简洁可维护;审计是 Direct 行为时间线,不是 GDD 特例,也不替代 sidecar
## 0. 一句话
Direct GUI 回合已经能看见 Codex `item/completed`,但只收成 UI 活动词,隔离 `CODEX_HOME` 随后删除。本方案在项目内留下有界、可共享的回合账本:本轮提供了哪些附件路径、按什么顺序做了读/搜/列表/MCP/写文件,以及第一次定玩法的动作是什么。用来区分「没读附件」和「读了仍走默认收集类」,不灌正文、不强制读取、不拷会话目录。
## 1. 目标与非目标
### 目标
一次带 `clientTurnId` 的 DirectProject GUI 回合结束后,只凭项目目录应能回答:
1. 本轮 sidecar 是否发出,原名映射到哪些项目相对路径,文件当时的 `contentSha256`
2. 模型是否用 native 命令 / 列表 / 搜索 / 看图 / MCP 打开过那些路径(路径 + 当时磁盘 hash,不是 stdout)。
3. **顺序**:读附件是在第一次美术 brief / 第一次写 `game/` 之前还是之后。
4. 第一次「定玩法」动作是什么(优先 `taonier_prepare_game_art.brief`,否则其它生成类 MCP 或对 `game/` 的写入)。
覆盖后续手打回合:只要 GUI Direct 有 `clientTurnId` 就记账本,附件可以为空。
### 非目标
- 不证明「理解并按 GDD 实施」。那是对照 `game/index.html`、美术产物做的产品判断;账本只提供行为时间线。
- 不把 issue #212 原文的 NLP / 「关键内容进入上下文」做成自动判决。
- 不灌附件正文进 prompt,不强制先读再继续,不为 GDD 开协议特例。
- 不改 sidecar 文案、jsonl 用户原文、工作台气泡。
- 不改做成游戏固定 prompt / PR #210 注入。
- 不复用 Supervisor `agent.runtime.action_receipt` / `file.read`
- 不拷隔离 `CODEX_HOME`、不落 `auth.json`、不落 `aggregated_output` / MCP `result` / patch `diff` / `FunctionCallOutput` 正文。
- 不把原始 item JSON 送进 Tauri 前端事件(现有 `DirectCodexTurnObservation` 仍只允许安全活动词和流式正文)。
- 不扫 `kind=uploaded` 历史附件;只记本轮 sidecar 提供的集合。
- DirectHome、ToolHost、CLI `--direct-codex-chat`(无 `clientTurnId`)本期不写这份账本。
- 本期不改 UI,不在聊天面板展示审计。
- 不把 issue #212 标成已修复;sidecar 与本账本是两段工作。
## 2. 现状
```text
Codex item/completed
commandExecution / mcpToolCall / fileChange / imageView / …
├─ 现用:收成 Activity("validation"|"controlled-tool"|…)
│ → Tauri 进度,不落盘
└─ 不用:隔离 CODEX_HOME session(含 stdout)→ tempdir Drop 删除
```
项目里现有:
| 产物 | 记下的 | 缺的 |
|---|---|---|
| `.agent/conversations/project.jsonl` | 用户原文 + 助手终稿 | sidecar、工具调用 |
| `.agent/agent.db` | init / upload / 美术登记 / 对话指针 | native 读、MCP 调用、`agc_write_file` |
| `.agent/logs/command.log` | 权限确认 | 原生命令 |
| `asset.register` / `canvas.asset_generate` | 路径、切片、部分 `source.prompt` | 与读附件的先后 |
| 隔离 `CODEX_HOME` | Codex 自己的 session | 回合结束即删 |
Codex app-server 协议里,`commandExecution.commandActions` 已分类为 `Read | ListFiles | Search | Unknown``Read.path` 在协议侧会拼成 cwd 绝对路径。Direct cwd 就是项目根(`resolve_direct_codex_project_authority` 不再强制 `game/` 子目录)。抽取时把绝对路径收回项目相对 POSIX,失败则丢路径,不写宿主绝对路径。
`agc_write_file` 经 tool bridge 落盘,当前不写 `agent.db`。不给每个 MCP 单独打点;统一在 `item/completed` 抽一次。
## 3. 分析用判据(相对 issue 收窄)
落地后,对类似 `gameagent-9baa5293` 的 run,应能三分:
| 时间线 | 结论 | 下一刀不该打哪 |
|---|---|---|
| `offeredRead.read=false``firstDesign` 已是 `taonier_prepare_game_art` 且 brief 是收集类 | 没打开附件就定了玩法 | 不是「GDD 解析不够」 |
| 先 `Read` 且 hash 对上,brief 仍是收集类 | 读了但没用 | sidecar 已够;看四切片 / icon-spec「收集物」/ 完成合同 |
| 只有 `ListFiles` / `Search` 命中 uploads,没有 `Read` | 发现了没读正文 | 映射可能够,缺的是读 |
| `Read` 的 path 是 `fast_gdd.md` 而不是 `assets/uploads/…` | sidecar 没被当成磁盘路径 | 还是路径合同 |
不在账本里写「已遵循 GDD」或「未遵循 GDD」布尔。
## 4. 落点
两层,都在项目 `.agent/` 控制面内,模型读不到:
1. **权威时间线**(每回合一个 jsonl,只追加)
`.agent/runtime/direct-codex/turns/<clientTurnId>.jsonl`
2. **总索引一条摘要**(方便继续翻现有 `agent.db`
`recordType: "direct.codex.turn"`
`clientTurnId` 沿用现有规则:trim 后 6–160 位 ASCII 字母数字或连字符,首位字母或数字。文件名用规范化后的 id,不再二次编码。
不升级 `GAME_CREATOR_AGENT_DB_SCHEMA_VERSION`;新 `recordType` 走 Ordinary 追加。`updatedAt` / `schemaVersion` 仍由 `serialize_agent_db_record` 写入。
jsonl 每条自带 `recordedAtMs``unix_millis`)。同一 `clientTurnId` 若再次进入(当前 GUI 运行中互斥,结束后理论上可再来):只追加,不截断;后一次 `turn_start` 视为新 attempt。读摘要时按文件内最后一次 `turn_start` 到对应 `turn_end` 计算 `offeredRead``agent.db` 每次 `turn_end` 再追加一条摘要,分析取该 `clientTurnId` 最后一条。
## 5. 记录合同
camelCase JSON。禁止出现附件正文、命令 stdout、patch diff、宿主绝对路径、Token、URL 签名。
### 5.1 `turn_start`
在 sidecar **已经渲染之后**、Codex turn **启动之前**写入。`promptSha256` 哈希的是 **用户原文**command 入参 `prompt`),不是带 sidecar 的全文。
```json
{
"recordType": "direct.codex.turn_start",
"clientTurnId": "Abc123-def",
"sidecarPresent": true,
"promptSha256": "<sha256 hex of original user text UTF-8>",
"promptChars": 120,
"attachments": [
{
"name": "fast_gdd.md",
"localPath": "assets/uploads/upload-1788164530559-fast_gdd.md",
"mediaType": "text/markdown",
"size": 8119,
"status": "imported",
"contentSha256": "<sha256 hex or omit>",
"hashSkipped": null
}
]
}
```
- `attachments` 清洗复用 sidecar`sanitize_attachment_name` / `media_type` / `status` / `local_path`。把这些函数改成 `pub(crate)`,审计模块不要复制一份。
- 条数上限仍 8;超出只在 sidecar 文案里写「另有 N 个未展开」,账本 `attachments` 同样只留前 8,另加 `attachmentsOmitted: N`
- `sidecarPresent`:本轮渲染走了 Project 头(任一条有合法 path 或 status)。Home 形态不会出现在本账本(Home 不记账)。
- `contentSha256`:对清洗后的 `localPath` 读项目文件做 SHA-256 小写 hex。文件不存在则省略 hash,`hashSkipped: "missing"`。超过 `DIRECT_CODEX_AUDIT_HASH_MAX_BYTES`2 MiB)则 `hashSkipped: "too-large"``.agent` / `.git` / `..` 路径本来就不会出现在 sidecar 输出里。
无附件:`attachments: []``sidecarPresent: false`,仍然写 `turn_start`
### 5.2 `item`
`item/completed``item/started``outputDelta` 不落盘。
公共字段:
```json
{
"recordType": "direct.codex.item",
"clientTurnId": "Abc123-def",
"seq": 1,
"itemId": "item-…",
"itemType": "commandExecution",
"status": "completed"
}
```
`seq` 从 1 起,按成功写入的 item 递增。`itemType` 取 Codex `item.type` 原词;未知类型仍记账 `itemType`,不附带未清洗 payload。
按类型附加字段:
| `item.type` | 追加 | 禁止 |
|---|---|---|
| `commandExecution` | `command` 截断 240 字;`exitCode``durationMs``actions[]` | `aggregatedOutput` |
| `mcpToolCall` | `tool``server`(可省略默认 `agc_tools`)、`durationMs`、§5.4 参数 | `result``error` 原文(只留 `status` / `errorKind` |
| `fileChange` | `changes: [{ path, kind }]``kind``add` / `delete` / `update` | `diff``movePath` 的宿主绝对路径(相对化失败则整条 change 丢 path |
| `imageView` | `path` | 图像字节 |
| `functionCallOutput` | `name``namespace` | `output` |
| `webSearch` | `query` 截断 400 字 | 结果页正文 |
| `agentMessage` / `userMessage` / `plan` / `reasoning` / `contextCompaction` / `hookPrompt` | **整类跳过**(终稿已在 jsonl;推理正文不是本账本) | — |
| 其它未知 | 只留公共字段 | 原始 `item` 对象 |
`commandExecution.actions[]`
```json
{ "type": "read", "path": "assets/uploads/upload-…-fast_gdd.md", "contentSha256": "…", "hashSkipped": null }
{ "type": "listFiles", "path": "assets" }
{ "type": "search", "query": "fast_gdd", "path": null }
{ "type": "unknown" }
```
- `Read.path` 先相对化再清洗;失败则该 action 记 `{ "type": "read", "pathRejected": true }`,不写绝对路径。
- 相对化成功后,对磁盘文件按 §5.1 同一套 hash 规则补 `contentSha256`
- `command` 里若相对化失败,把 `command` 整段丢掉,改 `commandRedacted: true`(避免 `type C:\Users\…\fast_gdd.md` 进账本)。
### 5.3 MCP 参数白名单
只抄这些键,其它键丢弃。字符串再经 path 清洗或截断。
| 工具 | 落盘参数 | 正文类字段 |
|---|---|---|
| `agc_list_project_files` | `path``query`120)、`kind``offset``limit` | 无 |
| `agc_write_file` | `path``contentChars``content` 的字符数,不是正文) | 不落 `content` |
| `taonier_prepare_game_art` | `mode``brief`(截断 4000)、`briefChars``briefSha256` | **要 brief 原文**(分析定玩法的吸烟枪;上限已是 MCP 合同) |
| `agc_generate_image` | `kind``aspectRatio``imageSize``assetName``outputPath``prompt` 截断 4000、`promptChars``promptSha256` | 不落 32k 全文 |
| `agc_edit_image` | `sourceLocalAssetId``assetName``prompt` 截断 4000、`promptChars``promptSha256` | 同上 |
| `agc_create_or_derive_resource` | `kind``mode``sourceLocalAssetId``assetName``prompt` 截断 4000、`promptChars``promptSha256` | MCP 上限已是 4000 |
| `agc_list_registered_assets` | `kind``assetId``includeSequenceFrames``offset``limit` | 无 |
| `agc_list_account_assets` | `folderId``query``offset``limit` | 无 |
| `agc_import_account_assets` | `assetIds`(最多 8 个 id,超出 `assetIdsOmitted`)、`localPaths`(清洗后相对路径,最多 8) | 无 |
| `agc_remove_background` | `sourceLocalAssetId``assetName` | 无 |
| `agc_browser_playtest` | `attempt` | 无 |
| `agc_web_search` | `query` 截断 400、`maxResults` | 无 |
| `agc_read_skill_resource` | `skillName``relativePath` | 不落 Skill 正文 |
| 未知 MCP 名 | 只留 `tool` + `status` | 不落 `arguments` |
`brief` / 截断后的 `prompt`**模型自己写的设计文本**,不是用户 GDD 转储。这是分析「仍走收集类」的关键,允许进 jsonl。`agent.db` 摘要只留 `briefPreview` 240 字。
### 5.4 `turn_end`
派生摘要,不是第二真相。字段必须能从本文件已写入的 `turn_start` + `item` 重算出来。
```json
{
"recordType": "direct.codex.turn_end",
"clientTurnId": "Abc123-def",
"completed": true,
"itemCount": 17,
"itemsTruncated": false,
"offeredRead": [
{
"localPath": "assets/uploads/upload-1788164530559-fast_gdd.md",
"read": false
}
],
"firstDesign": {
"kind": "mcp:taonier_prepare_game_art",
"seq": 3,
"tool": "taonier_prepare_game_art",
"briefPreview": "俯视角收集冒险小游戏…"
}
}
```
`offeredRead.read=true` 当且仅当本 attempt 内存在 `actions.type=read``imageView` 或 MCP 参数里的 `path` / `localPaths`,清洗后与 `localPath` 字符串相等。hash 对不上仍记 `read: true`,另加 `contentSha256Match: false`(读了另一份同路径文件或读时文件已变)。没有 hash 可对则省略 `contentSha256Match`
`firstDesign`:本 attempt 第一条满足任一条件的 item:
1. MCP`taonier_prepare_game_art` / `agc_generate_image` / `agc_edit_image` / `agc_create_or_derive_resource`
2. `agc_write_file` 且 path 以 `game/` 开头或文件名是 `index.html`
3. `fileChange` 且任一条 change path 满足 2
列表、搜索、读、Skill 读取、账户素材查询、playtest、web_search **不算** firstDesign。没有则 `firstDesign: null`
`kind` 取值:`mcp:<tool>` / `write:<path>` / `fileChange:<path>`
### 5.5 `agent.db` 摘要
```json
{
"recordType": "direct.codex.turn",
"clientTurnId": "Abc123-def",
"turnLog": ".agent/runtime/direct-codex/turns/Abc123-def.jsonl",
"sidecarPresent": true,
"offeredCount": 1,
"offeredRead": [ { "localPath": "assets/uploads/…-fast_gdd.md", "read": false } ],
"firstDesign": { "kind": "mcp:taonier_prepare_game_art", "seq": 3, "briefPreview": "…" },
"itemCount": 17,
"itemsTruncated": false,
"completed": true,
"auditWriteFailed": false
}
```
`turnLog` 必须是项目相对 POSIX。不要把 jsonl 全文复制进 `agent.db`。单条仍受 Ordinary 1 MiB 限制;摘要本身应远小于此。
### 5.6 上限
| 项 | 值 |
|---|---|
| 每回合 item 条数 | 256;超出再写一条 `recordType: "direct.codex.items_truncated"`,之后 item 丢弃但仍把 `turn_end.itemsTruncated=true` |
| `command` | 240 字 |
| `brief` / 生成类 `prompt` 落盘 | 4000 字 |
| `briefPreview` | 240 字 |
| 文件 hash | 2 MiB |
| 附件条数 | 8(与 sidecar 相同) |
| jsonl 单行 | 沿用现有 jsonl 追加上限;超长截断正文类字段,不截断结构 |
## 6. 调用链
```text
chat_with_game_creator_direct_codex
规范化 clientTurnId
DirectCodexTurnAudit::start(root, clientTurnId, originalPrompt, attachments)
→ 写 turn_startfail-open
render_direct_codex_user_prompt // 现有 sidecar,不变
run_direct_game_creator_turn_at_with_creation_type_and_emitter(..., audit)
→ Codex collect 循环在 DirectProject + item/completed 调 audit.observe_item
Ok/Err 都 audit.finish(completed)
→ 写 turn_end + agent.db 摘要
```
- CLI `run_direct_game_creator_turn_at` **不** 接 audit(无 `clientTurnId`)。
- Home command 不接 audit。
- 不要把 attachments / audit 顺着 CLI inner、pool、ToolHost 往下传。
- `DirectCodexTurnObservation` **不** 增加原始 `params`。审计走独立 `DirectCodexTurnAudit`,避免 stdout 正文进入 Tauri 事件。
`direct_game_creator_codex_chat_at_with_optional_observer` 增加可选 `audit: Option<&mut DirectCodexTurnAudit>`,再传到 `run_turn_with_direct_observer`。仅 `workspace_mode == DirectProject``audit` 为 Some 时抽取。
`run_direct_game_creator_turn_inner` 的 UI observer 保持只处理 `AccumulatedText` / `Activity`
回合失败(生成失败、浏览器试玩失败、回复落盘失败):只要 `start` 过就 `finish(false)`,保留已观察到的 item。Codex 尚未启动则 `itemCount=0`
## 7. 失败语义
审计 **不得** 把做游戏打失败。所有写盘包在 sink 内:
- 单次追加失败:记内存 `audit_write_failed=true`,后续 item 仍尝试写;`finish` 时摘要带 `auditWriteFailed: true`
- 连摘要都写不进去:只在 Direct debug / 现有进度通道能承受的前提下忽略;不新增用户可见报错文案。
- 不引入新的 Tauri 事件名。
`conversation.write` 失败不同:助手终稿落盘失败仍按现有逻辑拒绝返回。审计失败不走那条。
## 8. 安全
- 路径:与 sidecar 同一套相对 POSIX 清洗;相对化失败不写原绝对路径。
- 控制面:hash / 读文件只用 `resolve_local_project_path`;拒绝 `.agent` / `.git` / 敏感文件。这些路径若出现在 commandActions 里,只记 `pathRejected`
- 不把 `aggregated_output`、MCP result、function output、diff 暂存在内存再截断——抽取函数根本不读这些键。
- jsonl 位于 `.agent/runtime/**`,现有 Direct 控制面边界禁止模型当普通项目文档读。
- 前端观察者和审计 sink 分叉,禁止图省事 `observer(Item { params })`
## 9. 代码落地
新增 [`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs)
- `DirectCodexTurnAudit`
- `start` / `observe_item` / `finish`
- 相对化、hash、MCP 白名单、`firstDesign` / `offeredRead` 派生
- 单元测试(见 §11
[`agent.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent.rs)`mod direct_codex_audit` + `pub(crate) use`
[`direct_codex_attachments.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs):清洗函数改 `pub(crate)`,行为不变。
[`codex_app_server.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs)
- `run_turn_with_direct_observer` / `direct_game_creator_codex_chat_at_with_optional_observer` 增加 `audit: Option<&mut DirectCodexTurnAudit>`
- collect 循环 `Item { completed: true, .. }` 且 DirectProject 时 `audit.observe_item(&params)`
- **不要** 把 `params` 塞进 `DirectCodexTurnObservation`
- 现有 Home / ToolHost / 安全活动词测试保持逐字
[`direct_runtime.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs)
- `chat_with_game_creator_direct_codex` 创建 audit(原文 + attachments),Ok/Err 都 `finish`
- 把 audit 传入 `run_direct_game_creator_turn_at_with_creation_type_and_emitter`
- 该函数和 inner 增加可选 audit;CLI 入口签名不变
不要给 `agc_write_file` / 每个 MCP handler 再写一份平行审计。
前端、DTO、sidecar 文案、home.suite 附件断言:本期不改。不新增 UI。
文档:落地提交时把本文状态改为「现行合同(已按本文落地)」;`decision-log.md` 记一条;不要把 08-30 技术说明改成已修复。
## 10. 测试
全部是 Rust 单元测试,用 fixture item JSON,不拉真 Codex。
1. `turn_start`:原文 hash 稳定;sidecar 路径清洗后出现;非法 `../` 不进 attachments;无附件 `sidecarPresent=false`
2. 附件文件写入临时项目后 `contentSha256` 与直接 hash 一致;缺文件 `hashSkipped=missing`;超过 2 MiB `too-large`
3. `commandExecution` + `commandActions: [{type:read, path: <abs>}]` → 相对路径 + hash`aggregatedOutput` 即使在 fixture 里也不出现在落盘 JSON。
4. `Read` 相对化失败 → `pathRejected`,落盘 JSON 不含 `C:\\` / `Users`
5. `mcpToolCall` `taonier_prepare_game_art``brief` 保留;`result` 丢掉。
6. `agc_write_file`:有 path 与 `contentChars`,无 content。
7. `agc_generate_image`32k prompt 只留 4000 + `promptChars` + sha256。
8. `fileChange`path + kind,无 diff。
9. `offeredRead`:读路径等于 offered → `read=true`;只 list/search → `read=false`
10. `firstDesign`:先 read 再 art → kind 是 mcp artseq 是 art 那条;只有 read → `null`
11. 第 257 条 item 触发 truncated`turn_end.itemsTruncated=true`
12. `agent.db` 摘要含 `turnLog` 相对路径、`offeredRead``firstDesign.briefPreview`
13. 写盘注入失败:`finish` 不 panic、不返回 Err 给调用方(sink 方法是 `()`)。
14. 未知 `item.type` 只留公共字段。
15. 现有 DirectHome 活动词测试、sidecar 渲染测试不受影响。
不测:真模型是否读 GDD、是否生成弹幕射击、浏览器验收文案。
## 11. 验收(方案落地后的人工分析)
用一次「上传 md + 做成游戏 / 首页做游戏」的本地项目:
1. 存在 `.agent/runtime/direct-codex/turns/<clientTurnId>.jsonl`
2. `agent.db` 有对应 `direct.codex.turn`
3. `turn_start.attachments[].localPath` 与 sidecar 项目路径一致。
4. jsonl **没有** GDD 正文、没有 `aggregatedOutput`、没有 patch。
5. 能根据 `offeredRead` + `firstDesign` 填上 §3 四行表的其中一行,而不用猜隔离 session。
6. 工作台气泡仍是用户原文;jsonl 对话仍无 sidecar。
7. `npm run check:encoding``git diff --check`、相关 Rust 单测通过。
## 12. 实现顺序
1. `direct_codex_audit.rs` + 清洗函数 `pub(crate)` + fixture 测试
2. `codex_app_server` collect 接 sink;观察者枚举不变
3. GUI Direct command 创建 / finish sink
4. encoding 与定向 `cargo test`
5. 合入时改本文状态、decision-log、sidecar 文档里「native 审计另排期」那一行改为指向本文
## 13. 与 sidecar / issue 的边界
- sidecar:让模型 **知道路径**。已落地,合同不变。
- 本账本:让人 **看见模型做了什么**。不替代 sidecar,也不在本方案里做强制读取。
- issue #212 主问题仍是消费失败;本账本是为下一刀修复提供证据,不是把 212 关单。
@@ -206,6 +206,7 @@ Direct app-server 使用 ephemeral thread。stdout 中的 `item/started`、`item
- 策划 run ID`gameagent-cd7f6c81`
- 做游戏 run ID`gameagent-77b5aa31`
- 上传 GDD(项目相对路径):`assets/uploads/upload-1788083777445-fast_gdd.md`
- 审计账本方案(已落地,不关闭本 Issue):[`【技术方案】Direct回合行为审计账本-2026-08-31.md`](./【技术方案】Direct回合行为审计账本-2026-08-31.md)
- 建议随 Issue 附上或引用对应 run 的以下复核材料:
- Direct 对话:`.agent/conversations/project.jsonl`
- Direct Agent DB`.agent/agent.db`