Merge branch 'master' into codex/admin-database-table-query
Project CI / Frontend tests (pull_request) Successful in 3m57s
Project CI / Repository checks (pull_request) Successful in 3m59s
Project CI / Backend tests (pull_request) Successful in 6m34s
Project CI / Native shell tests (pull_request) Successful in 13m25s

This commit is contained in:
2026-08-31 20:03:46 +08:00
19 changed files with 2923 additions and 170 deletions
@@ -12,6 +12,8 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
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;
@@ -34,6 +36,8 @@ pub(crate) use codex_cli::{
game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity,
};
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");
@@ -0,0 +1,431 @@
//! Direct Codex 本轮附件 sidecarHome 与 Project 共用同一 DTO 和渲染函数。
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
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;
const HOME_ATTACHMENT_HEADER: &str =
"[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]";
const PROJECT_ATTACHMENT_HEADER: &str =
"[本轮用户附件:已复制到当前项目。请用「项目路径」读取;原文件名不是磁盘路径。]";
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexTurnAttachment {
pub(crate) name: String,
pub(crate) media_type: String,
#[serde(default)]
pub(crate) size: u64,
#[serde(default)]
pub(crate) local_path: Option<String>,
#[serde(default)]
pub(crate) status: Option<String>,
}
pub(crate) fn sanitize_attachment_name(value: &str) -> String {
let basename = value.rsplit(['/', '\\']).next().unwrap_or_default().trim();
let sanitized = basename
.chars()
.filter(|character| !character.is_control())
.take(MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS)
.collect::<String>();
if sanitized.is_empty() {
"未命名附件".to_string()
} else {
sanitized
}
}
pub(crate) fn sanitize_attachment_media_type(value: &str) -> String {
let value = value.trim();
if value.is_empty()
|| value.chars().any(|character| {
!(character.is_ascii_alphanumeric() || matches!(character, '/' | '+' | '-' | '.' | '_'))
})
{
"application/octet-stream".to_string()
} else {
value
.chars()
.take(MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS)
.collect()
}
}
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"),
_ => None,
}
}
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
|| trimmed.chars().any(char::is_control)
{
return None;
}
let normalized = trimmed.replace('\\', "/");
if normalized.starts_with('/') {
return None;
}
let mut chars = normalized.chars();
if let (Some(letter), Some(':')) = (chars.next(), chars.next()) {
if letter.is_ascii_alphabetic() {
return None;
}
}
let mut segments = Vec::new();
for segment in normalized.split('/') {
if segment.is_empty() || segment == "." {
continue;
}
if segment == ".." {
return None;
}
segments.push(segment);
}
let first = segments.first()?;
if *first == ".agent" || *first == ".git" {
return None;
}
let path = segments.join("/");
if path.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS {
return None;
}
Some(path)
}
pub(crate) fn attachments_use_project_mapping(attachments: &[DirectCodexTurnAttachment]) -> bool {
attachments.iter().any(|attachment| {
attachment
.local_path
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
|| sanitize_attachment_status(attachment.status.as_deref()).is_some()
})
}
fn render_project_attachment_line(attachment: &DirectCodexTurnAttachment) -> String {
let name = sanitize_attachment_name(&attachment.name);
let media_type = sanitize_attachment_media_type(&attachment.media_type);
let raw_path = attachment
.local_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let sanitized_path = raw_path.and_then(sanitize_attachment_local_path);
let path_rejected = raw_path.is_some() && sanitized_path.is_none();
let status = if path_rejected {
Some("failed")
} else {
sanitize_attachment_status(attachment.status.as_deref())
};
let mut parts = vec![format!("原文件名:{name}")];
if let Some(path) = sanitized_path {
parts.push(format!("项目路径:{path}"));
}
parts.push(format!("类型:{media_type}"));
parts.push(format!("大小:{} 字节", attachment.size));
if let Some(status) = status {
parts.push(format!("状态:{status}"));
}
format!("- {}", parts.join(""))
}
pub(crate) fn render_direct_codex_user_prompt(
prompt: &str,
attachments: &[DirectCodexTurnAttachment],
) -> Result<String, String> {
let prompt = prompt.trim();
if prompt.is_empty() && attachments.is_empty() {
return Err("聊天内容不能为空".to_string());
}
if attachments.is_empty() {
return Ok(prompt.to_string());
}
let mut sections = Vec::new();
if !prompt.is_empty() {
sections.push(prompt.to_string());
sections.push(String::new());
}
if attachments_use_project_mapping(attachments) {
sections.push(PROJECT_ATTACHMENT_HEADER.to_string());
for attachment in attachments.iter().take(MAX_DIRECT_CODEX_ATTACHMENTS) {
sections.push(render_project_attachment_line(attachment));
}
} else {
sections.push(HOME_ATTACHMENT_HEADER.to_string());
for attachment in attachments.iter().take(MAX_DIRECT_CODEX_ATTACHMENTS) {
sections.push(format!(
"- {};类型:{};大小:{} 字节",
sanitize_attachment_name(&attachment.name),
sanitize_attachment_media_type(&attachment.media_type),
attachment.size,
));
}
}
if attachments.len() > MAX_DIRECT_CODEX_ATTACHMENTS {
sections.push(format!(
"- 另有 {} 个附件未展开",
attachments.len() - MAX_DIRECT_CODEX_ATTACHMENTS
));
}
Ok(sections.join("\n"))
}
#[cfg(test)]
mod tests {
use super::*;
fn home_attachment(name: &str, media_type: &str, size: u64) -> DirectCodexTurnAttachment {
DirectCodexTurnAttachment {
name: name.to_string(),
media_type: media_type.to_string(),
size,
local_path: None,
status: None,
}
}
fn project_attachment(
name: &str,
media_type: &str,
size: u64,
local_path: Option<&str>,
status: Option<&str>,
) -> DirectCodexTurnAttachment {
DirectCodexTurnAttachment {
name: name.to_string(),
media_type: media_type.to_string(),
size,
local_path: local_path.map(str::to_string),
status: status.map(str::to_string),
}
}
#[test]
fn plain_prompt_is_trimmed_and_empty_prompt_without_attachments_is_rejected() {
assert_eq!(
render_direct_codex_user_prompt(" 你好 ", &[]).expect("plain prompt"),
"你好"
);
assert_eq!(
render_direct_codex_user_prompt("", &[]).expect_err("empty prompt"),
"聊天内容不能为空"
);
}
#[test]
fn home_user_prompt_preserves_the_message_and_adds_only_bounded_attachment_metadata() {
let attachments = vec![home_attachment(
r"C:\Users\secret\角色参考.png",
"image/png\nBearer secret",
3,
)];
let prompt = render_direct_codex_user_prompt(" 先看看这个附件 ", &attachments)
.expect("home prompt");
assert_eq!(
prompt,
"先看看这个附件\n\n[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]\n- 角色参考.png;类型:application/octet-stream;大小:3 字节"
);
assert!(!prompt.contains("C:\\Users"));
assert!(!prompt.contains("\nBearer secret"));
}
#[test]
fn home_user_prompt_keeps_plain_messages_plain_and_caps_attachment_count() {
assert_eq!(
render_direct_codex_user_prompt("你好", &[]).expect("plain prompt"),
"你好"
);
let attachments = (0..MAX_DIRECT_CODEX_ATTACHMENTS + 2)
.map(|index| home_attachment(&format!("asset-{index}.png"), "image/png", index as u64))
.collect::<Vec<_>>();
let prompt =
render_direct_codex_user_prompt("看看素材", &attachments).expect("bounded attachments");
assert!(prompt.contains("asset-7.png"));
assert!(!prompt.contains("asset-8.png"));
assert!(prompt.contains("另有 2 个附件未展开"));
assert!(render_direct_codex_user_prompt("", &attachments).is_ok());
assert!(render_direct_codex_user_prompt("", &[]).is_err());
}
#[test]
fn home_json_without_path_or_status_still_deserializes() {
let attachment: DirectCodexTurnAttachment =
serde_json::from_str(r#"{"name":"a.png","mediaType":"image/png","size":3}"#)
.expect("home json");
assert!(attachment.local_path.is_none());
assert!(attachment.status.is_none());
assert_eq!(attachment.size, 3);
}
#[test]
fn project_prompt_keeps_user_text_and_maps_original_name_to_project_path() {
let attachments = vec![project_attachment(
"fast_gdd.md",
"text/markdown",
7944,
Some("assets/uploads/upload-1788083777445-fast_gdd.md"),
Some("imported"),
)];
let prompt = render_direct_codex_user_prompt("请根据附件做游戏", &attachments)
.expect("project prompt");
assert_eq!(
prompt,
"请根据附件做游戏\n\n[本轮用户附件:已复制到当前项目。请用「项目路径」读取;原文件名不是磁盘路径。]\n- 原文件名:fast_gdd.md;项目路径:assets/uploads/upload-1788083777445-fast_gdd.md;类型:text/markdown;大小:7944 字节;状态:imported"
);
assert!(!prompt.contains("GDD"));
assert!(!prompt.contains("规格"));
assert!(!prompt.contains("权威"));
assert!(!prompt.contains("必须读取"));
}
#[test]
fn project_png_and_markdown_share_the_same_line_shape() {
let attachments = vec![
project_attachment(
"角色参考.png",
"image/png",
12,
Some("assets/uploads/upload-1-角色参考.png"),
Some("imported"),
),
project_attachment(
"notes.md",
"text/markdown",
80,
Some("assets/uploads/upload-2-notes.md"),
Some("imported"),
),
];
let prompt =
render_direct_codex_user_prompt("看这两个附件", &attachments).expect("mixed types");
let lines: Vec<_> = prompt
.lines()
.filter(|line| line.starts_with("- 原文件名:"))
.collect();
assert_eq!(lines.len(), 2);
for line in &lines {
assert!(line.contains(";项目路径:assets/uploads/"));
assert!(line.contains(";类型:"));
assert!(line.contains(";大小:"));
assert!(line.contains(";状态:imported"));
}
assert!(lines[0].contains("角色参考.png"));
assert!(lines[0].contains("image/png"));
assert!(lines[1].contains("notes.md"));
assert!(lines[1].contains("text/markdown"));
}
#[test]
fn failed_attachment_without_path_has_status_and_no_error_body() {
let attachments = vec![project_attachment(
"lost.bin",
"application/octet-stream",
2,
None,
Some("failed"),
)];
let prompt =
render_direct_codex_user_prompt("附件失败了", &attachments).expect("failed prompt");
assert!(prompt.contains(PROJECT_ATTACHMENT_HEADER));
assert!(prompt.contains("原文件名:lost.bin"));
assert!(prompt.contains("状态:failed"));
assert!(!prompt.contains("项目路径:"));
assert!(!prompt.contains("error"));
assert!(!prompt.contains("失败原因"));
}
#[test]
fn illegal_local_paths_are_omitted_and_marked_failed() {
let attachments = vec![
project_attachment(
"up.md",
"text/markdown",
1,
Some("../secret.md"),
Some("imported"),
),
project_attachment(
"agent.md",
"text/markdown",
1,
Some(".agent/conversations/x.md"),
Some("imported"),
),
project_attachment(
"abs.md",
"text/markdown",
1,
Some(r"C:\tmp\abs.md"),
Some("imported"),
),
project_attachment(
"unix.md",
"text/markdown",
1,
Some("/tmp/unix.md"),
Some("imported"),
),
];
let prompt =
render_direct_codex_user_prompt("非法路径", &attachments).expect("illegal paths");
assert!(!prompt.contains("../secret.md"));
assert!(!prompt.contains(".agent/conversations/x.md"));
assert!(!prompt.contains("C:\\tmp\\abs.md"));
assert!(!prompt.contains("/tmp/unix.md"));
assert!(!prompt.contains("项目路径:"));
assert_eq!(prompt.matches("状态:failed").count(), 4);
assert!(!prompt.contains("状态:imported"));
}
#[test]
fn empty_prompt_with_project_attachments_still_renders() {
let attachments = vec![project_attachment(
"ref.png",
"image/png",
4,
Some("assets/uploads/upload-1-ref.png"),
Some("imported"),
)];
let prompt = render_direct_codex_user_prompt(" ", &attachments).expect("empty user text");
assert!(prompt.starts_with(PROJECT_ATTACHMENT_HEADER));
assert!(prompt.contains("项目路径:assets/uploads/upload-1-ref.png"));
}
#[test]
fn unknown_error_field_is_not_forwarded_to_the_model() {
let attachment: DirectCodexTurnAttachment = serde_json::from_str(
r#"{"name":"a.md","mediaType":"text/markdown","size":1,"status":"failed","error":"secret boom"}"#,
)
.expect("extra error field");
let prompt = render_direct_codex_user_prompt("x", &[attachment]).expect("render");
assert!(!prompt.contains("secret boom"));
assert!(!prompt.contains("error"));
}
#[test]
fn unknown_status_keeps_home_attachment_metadata_shape() {
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));
assert!(!prompt.contains("状态:"));
}
}
File diff suppressed because it is too large Load Diff
@@ -7,9 +7,6 @@ use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024;
const MAX_DIRECT_HOME_ATTACHMENTS: usize = 8;
const MAX_DIRECT_HOME_ATTACHMENT_NAME_CHARS: usize = 160;
const MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6;
const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160;
const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。";
@@ -2177,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) {
@@ -2267,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(
@@ -3718,14 +3711,6 @@ pub(crate) fn build_direct_codex_home_system_prompt() -> String {
.join("\n")
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexHomeAttachment {
name: String,
media_type: String,
size: u64,
}
#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexHomeReply {
@@ -3758,78 +3743,11 @@ fn parse_direct_codex_home_reply(reply: String) -> DirectCodexHomeReply {
}
}
fn direct_codex_home_attachment_name(value: &str) -> String {
let basename = value.rsplit(['/', '\\']).next().unwrap_or_default().trim();
let sanitized = basename
.chars()
.filter(|character| !character.is_control())
.take(MAX_DIRECT_HOME_ATTACHMENT_NAME_CHARS)
.collect::<String>();
if sanitized.is_empty() {
"未命名附件".to_string()
} else {
sanitized
}
}
fn direct_codex_home_attachment_media_type(value: &str) -> String {
let value = value.trim();
if value.is_empty()
|| value.chars().any(|character| {
!(character.is_ascii_alphanumeric() || matches!(character, '/' | '+' | '-' | '.' | '_'))
})
{
"application/octet-stream".to_string()
} else {
value
.chars()
.take(MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS)
.collect()
}
}
fn render_direct_codex_home_user_prompt(
prompt: &str,
attachments: &[DirectCodexHomeAttachment],
) -> Result<String, String> {
let prompt = prompt.trim();
if prompt.is_empty() && attachments.is_empty() {
return Err("聊天内容不能为空".to_string());
}
if attachments.is_empty() {
return Ok(prompt.to_string());
}
let mut sections = Vec::new();
if !prompt.is_empty() {
sections.push(prompt.to_string());
sections.push(String::new());
}
sections.push(
"[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]".to_string(),
);
for attachment in attachments.iter().take(MAX_DIRECT_HOME_ATTACHMENTS) {
sections.push(format!(
"- {};类型:{};大小:{} 字节",
direct_codex_home_attachment_name(&attachment.name),
direct_codex_home_attachment_media_type(&attachment.media_type),
attachment.size,
));
}
if attachments.len() > MAX_DIRECT_HOME_ATTACHMENTS {
sections.push(format!(
"- 另有 {} 个附件未展开",
attachments.len() - MAX_DIRECT_HOME_ATTACHMENTS
));
}
Ok(sections.join("\n"))
}
pub(crate) async fn run_direct_game_creator_home_turn(
prompt: &str,
attachments: &[DirectCodexHomeAttachment],
attachments: &[DirectCodexTurnAttachment],
) -> Result<DirectCodexHomeReply, String> {
let user_prompt = render_direct_codex_home_user_prompt(prompt, attachments)?;
let user_prompt = render_direct_codex_user_prompt(prompt, attachments)?;
direct_game_creator_home_codex_chat(build_direct_codex_home_system_prompt(), user_prompt)
.await
.map(parse_direct_codex_home_reply)
@@ -3859,6 +3777,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type(
prompt,
creation_type,
None,
None,
)
.await
}
@@ -3868,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());
@@ -3883,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);
@@ -3900,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 {
@@ -3928,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 {
@@ -4263,6 +4193,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
prompt: String,
creation_type: Option<String>,
client_turn_id: Option<String>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
) -> Result<String, String> {
let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
@@ -4271,21 +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 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)
}
@@ -4293,7 +4250,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
#[tauri::command]
pub(crate) async fn chat_with_game_creator_home_direct_codex(
prompt: String,
attachments: Option<Vec<DirectCodexHomeAttachment>>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
) -> Result<DirectCodexHomeReply, String> {
run_direct_game_creator_home_turn(&prompt, attachments.as_deref().unwrap_or_default()).await
}
@@ -4495,47 +4452,6 @@ mod tests {
assert!(!prompt.contains("game/index.html"));
}
#[test]
fn home_user_prompt_preserves_the_message_and_adds_only_bounded_attachment_metadata() {
let attachments = vec![DirectCodexHomeAttachment {
name: r"C:\Users\secret\角色参考.png".to_string(),
media_type: "image/png\nBearer secret".to_string(),
size: 3,
}];
let prompt = render_direct_codex_home_user_prompt(" 先看看这个附件 ", &attachments)
.expect("home prompt");
assert!(prompt.starts_with("先看看这个附件\n\n[首页附件说明"));
assert!(prompt.contains("角色参考.png"));
assert!(prompt.contains("类型:application/octet-stream"));
assert!(prompt.contains("大小:3 字节"));
assert!(!prompt.contains("C:\\Users"));
assert!(!prompt.contains("\nBearer secret"));
}
#[test]
fn home_user_prompt_keeps_plain_messages_plain_and_caps_attachment_count() {
assert_eq!(
render_direct_codex_home_user_prompt("你好", &[]).expect("plain prompt"),
"你好"
);
let attachments = (0..MAX_DIRECT_HOME_ATTACHMENTS + 2)
.map(|index| DirectCodexHomeAttachment {
name: format!("asset-{index}.png"),
media_type: "image/png".to_string(),
size: index as u64,
})
.collect::<Vec<_>>();
let prompt = render_direct_codex_home_user_prompt("看看素材", &attachments)
.expect("bounded attachments");
assert!(prompt.contains("asset-7.png"));
assert!(!prompt.contains("asset-8.png"));
assert!(prompt.contains("另有 2 个附件未展开"));
assert!(render_direct_codex_home_user_prompt("", &attachments).is_ok());
assert!(render_direct_codex_home_user_prompt("", &[]).is_err());
}
#[test]
fn home_create_marker_is_accepted_only_as_the_first_reply_token() {
let requested = parse_direct_codex_home_reply(format!(
+41 -21
View File
@@ -60,6 +60,7 @@ import type {
GenerateLocalGameDraftResult,
ImportCanvasExportResult,
InitLocalProjectResult,
LauncherImportedAttachment,
LimitedLocalCommandResult,
ListLocalProjectFilesResult,
LocalAgentMemoryResult,
@@ -127,6 +128,10 @@ import {
submitProjectSupervisorRuntimeTask,
taskRowsFromManifest,
} from './features/agent-runtime';
import {
type DirectCodexTurnAttachment,
toDirectCodexTurnAttachments,
} from './features/app-shell/directCodexTurnAttachments';
import {
isDeveloperMode,
isTransientProjectOpenMessage,
@@ -407,6 +412,7 @@ type AppProps = {
supervisorChatOnly?: boolean;
initialSupervisorMessage?: string;
initialCreationType?: HomeCreationType | null;
initialAttachments?: LauncherImportedAttachment[];
playRequest?: ProjectSupervisorComponentProps['playRequest'];
onPlayRequestHandled?: ProjectSupervisorComponentProps['onPlayRequestHandled'];
onManifestChange?: (
@@ -422,6 +428,13 @@ type AppProps = {
onMakeGameFromApprovedGdd?: (projectPath: string) => Promise<void>;
};
type ExecuteChatAgentReplyInput = {
prompt: string;
clientTurnId?: string;
creationType?: HomeCreationType | null;
attachments?: DirectCodexTurnAttachment[];
};
export function App({
initialProjectPath: initialProjectPathOverride = '',
initialProjectManifest,
@@ -432,6 +445,7 @@ export function App({
supervisorChatOnly = false,
initialSupervisorMessage = '',
initialCreationType = null,
initialAttachments = [],
playRequest = null,
onPlayRequestHandled,
onManifestChange,
@@ -495,6 +509,7 @@ export function App({
projectPath: initialProjectPath,
prompt: initialSupervisorMessage.trim(),
creationType: initialCreationType,
attachments: toDirectCodexTurnAttachments(initialAttachments),
});
const handledPlayRequestRef = useRef<string | null>(null);
@@ -990,11 +1005,7 @@ export function App({
| null
>(null);
const executeChatAgentReplyRef = useRef<
(
prompt: string,
directConversationTurnId?: string,
creationType?: HomeCreationType | null,
) => Promise<void>
(input: ExecuteChatAgentReplyInput) => Promise<void>
>(async () => undefined);
const agentConversationSavingRef = useRef(false);
const agentConversationBackgroundBusyRef = useRef(false);
@@ -2753,10 +2764,10 @@ export function App({
return;
}
recoveredDirectCodexTurnClaimsRef.current.add(claimKey);
void executeChatAgentReply(
unansweredDirectTurn.prompt,
unansweredDirectTurn.turnId,
);
void executeChatAgentReply({
prompt: unansweredDirectTurn.prompt,
clientTurnId: unansweredDirectTurn.turnId,
});
});
}
} catch (error) {
@@ -5225,7 +5236,7 @@ export function App({
return;
}
void executeChatAgentReply(prompt);
void executeChatAgentReply({ prompt });
}
async function executeLlmConfigStatus() {
@@ -5372,11 +5383,12 @@ export function App({
}
}
async function executeChatAgentReply(
prompt: string,
directConversationTurnId?: string,
creationType?: HomeCreationType | null,
) {
async function executeChatAgentReply({
prompt,
clientTurnId: directConversationTurnId,
creationType,
attachments,
}: ExecuteChatAgentReplyInput) {
// Product default: send the conversation directly to Codex app-server.
// The legacy Supervisor/harness path remains below for rollback and tests.
if (directCodexProductRuntime) {
@@ -5486,6 +5498,7 @@ export function App({
prompt: string;
clientTurnId: string;
creationType?: HomeCreationType;
attachments?: DirectCodexTurnAttachment[];
} = {
projectPath: directProjectPath,
prompt,
@@ -5494,6 +5507,9 @@ export function App({
if (creationType) {
directTurnInput.creationType = creationType;
}
if (attachments?.length) {
directTurnInput.attachments = attachments;
}
const reply = await directInvoke<string>(
'chat_with_game_creator_direct_codex',
directTurnInput,
@@ -5811,11 +5827,12 @@ export function App({
updatedAt: Date.now(),
},
]);
void executeChatAgentReplyRef.current(
latch.prompt,
directConversationTurnId,
latch.creationType,
);
void executeChatAgentReplyRef.current({
prompt: latch.prompt,
clientTurnId: directConversationTurnId,
creationType: latch.creationType,
attachments: latch.attachments,
});
}, [
chatAgentBusy,
directCodexProductRuntime,
@@ -10826,7 +10843,10 @@ export function App({
updatedAt: Date.now(),
},
]);
void executeChatAgentReply(prompt, directConversationTurnId);
void executeChatAgentReply({
prompt,
clientTurnId: directConversationTurnId,
});
}
const visibleProfessionalAgentCards = agentStatusCards.filter(
@@ -48,6 +48,7 @@ export type LauncherImportedAttachment = {
localPath?: string;
status: 'imported' | 'failed';
error?: string;
size?: number;
};
export type LocalProjectKind = 'web' | 'godot';
@@ -339,6 +339,7 @@ export function WorkspaceLauncherShell({
initialProjectKind={currentProjectContext.projectKind}
initialSupervisorMessage={currentProjectContext.initialPrompt}
initialCreationType={currentProjectContext.creationType}
initialAttachments={currentProjectContext.attachments}
orchestrationMode="single-supervisor"
projectSupervisorOnly
planningStartMode={
@@ -0,0 +1,38 @@
import type { LauncherImportedAttachment } from '../../app/types';
export type DirectCodexTurnAttachment = {
name: string;
mediaType: string;
size?: number;
localPath?: string;
status?: 'imported' | 'failed';
};
export function toDirectCodexTurnAttachments(
imported: readonly LauncherImportedAttachment[] | null | undefined,
): DirectCodexTurnAttachment[] {
if (!imported?.length) {
return [];
}
return imported.map((item) => {
const attachment: DirectCodexTurnAttachment = {
name: item.fileName,
mediaType: item.mediaType,
};
if (
typeof item.size === 'number' &&
Number.isFinite(item.size) &&
item.size >= 0
) {
attachment.size = Math.trunc(item.size);
}
const localPath = item.localPath?.trim();
if (localPath) {
attachment.localPath = localPath;
}
if (item.status === 'imported' || item.status === 'failed') {
attachment.status = item.status;
}
return attachment;
});
}
@@ -5,7 +5,11 @@ import type {
GameCreationAppManifest,
GameCreationAppPreviewState,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import type { ChatMessage, LocalProjectDirectoryStatus } from '../../app/types';
import type {
ChatMessage,
LauncherImportedAttachment,
LocalProjectDirectoryStatus,
} from '../../app/types';
import type { HomeCreationType } from '../../view/home';
import type { LauncherView } from '../../view/layout';
import type {
@@ -32,6 +36,7 @@ export type ProjectSupervisorComponentProps = {
initialProjectKind?: 'web' | 'godot';
initialSupervisorMessage?: string;
initialCreationType?: HomeCreationType | null;
initialAttachments?: LauncherImportedAttachment[];
orchestrationMode?: 'single-supervisor' | 'professional-dag';
projectSupervisorOnly?: boolean;
planningStartMode?: boolean;
@@ -212,6 +212,7 @@ export function useHomeProjectCreation({
mediaType,
localPath: result.localPath,
status: 'imported',
size: attachment.file.size,
});
} catch (error) {
imported.push({
@@ -219,6 +220,7 @@ export function useHomeProjectCreation({
mediaType,
status: 'failed',
error: error instanceof Error ? error.message : String(error),
size: attachment.file.size,
});
}
}
@@ -1529,11 +1529,44 @@ export function registerHomeProjectCreationTests() {
prompt: '按这个角色做游戏',
creationType: 'game',
clientTurnId: expect.any(String),
attachments: [
{
name: '角色参考.png',
mediaType: 'image/png',
size: attachment.size,
localPath: 'assets/uploads/reference.png',
status: 'imported',
},
],
});
expect(invoke).not.toHaveBeenCalledWith(
'chat_with_game_creator_home_direct_codex',
expect.anything(),
);
expect(await screen.findByText('附件已经进入当前项目。')).not.toBeNull();
const followUpInput = screen.getByLabelText('陶泥儿对话内容');
fireEvent.change(followUpInput, { target: { value: '再补一句玩法' } });
fireEvent.submit(followUpInput.closest('form') as HTMLFormElement);
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'chat_with_game_creator_direct_codex',
),
).toHaveLength(2);
});
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', {
projectPath: automaticProjectPath,
prompt: '再补一句玩法',
clientTurnId: expect.any(String),
});
const followUpPayload = invoke.mock.calls.find(
([command, args]) =>
command === 'chat_with_game_creator_direct_codex' &&
(args as Record<string, unknown> | undefined)?.prompt ===
'再补一句玩法',
)?.[1] as Record<string, unknown> | undefined;
expect(followUpPayload).not.toHaveProperty('attachments');
});
it('starts an automatic game project from the approved GDD', async () => {
@@ -1730,6 +1763,18 @@ export function registerHomeProjectCreationTests() {
} else {
expect(startCall?.[1]).not.toHaveProperty('source');
}
expect(startCall?.[1]).not.toHaveProperty('attachments');
expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain(
'本轮用户附件',
);
expect(invoke).not.toHaveBeenCalledWith(
'chat_with_game_creator_direct_codex',
expect.anything(),
);
expect(invoke).not.toHaveBeenCalledWith(
'chat_with_game_creator_agent',
expect.anything(),
);
expect(
invoke.mock.calls.filter(
([command]) => command === 'create_automatic_local_game_project',
@@ -1738,6 +1783,98 @@ export function registerHomeProjectCreationTests() {
},
);
it('keeps 做方案 first turn on Supervisor without a Direct attachment sidecar', async () => {
const projectPath = '/tmp/home-planning-attachment';
const manifest = createGameCreationAppManifest(
'home-planning-attachment',
'首页策划附件',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
expectedRunProfile: 'standard',
});
const fileBytes = Array.from(new TextEncoder().encode('png'));
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'create_automatic_local_game_project') {
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'upload_local_asset') {
return {
id: 'asset-upload-plan-1',
localPath: 'assets/uploads/reference.png',
absolutePath: `${projectPath}/assets/uploads/reference.png`,
manifestPath: `${projectPath}/.agent/manifest.json`,
};
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherAt('/?launcher', 'home', true);
fireEvent.click(screen.getByRole('button', { name: '做方案' }));
const fileInput =
document.querySelector<HTMLInputElement>('input[type="file"]');
expect(fileInput).not.toBeNull();
const attachment = new File(['png'], '角色参考.png', {
type: 'image/png',
lastModified: 1,
});
Object.defineProperty(attachment, 'arrayBuffer', {
value: async () => new Uint8Array(fileBytes).buffer,
});
fireEvent.change(fileInput!, { target: { files: [attachment] } });
const promptInput = screen.getByLabelText('创作想法');
nativeClipboardMock.text = '整理一份可玩原型';
fireEvent.paste(promptInput);
await waitFor(() => {
expect(promptInput.textContent).toContain('整理一份可玩原型');
expect(promptInput.textContent).toContain('角色参考.png');
});
fireEvent.click(screen.getByRole('button', { name: '进入立项策划' }));
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'start_game_creator_supervisor_runtime_task',
expect.objectContaining({
projectPath,
source: PROJECT_SUPERVISOR_PLAN_SOURCE,
}),
);
});
expect(invoke).toHaveBeenCalledWith('upload_local_asset', {
projectPath,
fileName: '角色参考.png',
mediaType: 'image/png',
bytes: fileBytes,
});
const startCall = invoke.mock.calls.find(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
);
expect(startCall?.[1]).not.toHaveProperty('attachments');
expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain('本轮用户附件');
expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain(
'assets/uploads/reference.png',
);
expect(invoke).not.toHaveBeenCalledWith(
'chat_with_game_creator_direct_codex',
expect.anything(),
);
expect(invoke).not.toHaveBeenCalledWith(
'chat_with_game_creator_agent',
expect.anything(),
);
});
it('surfaces the planning clarification card after 做方案 creates the project from home', async () => {
// 上面那条只断言到「run 起来了、source 对」。真实故障恰好落在它之后:plan 根 run
// 停在 waiting-for-user-input 并带回澄清请求,而工作台一直停在前端本地的占位文案,
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { toDirectCodexTurnAttachments } from '../src/features/app-shell/directCodexTurnAttachments';
describe('toDirectCodexTurnAttachments', () => {
it('maps imported files and omits error plus empty localPath', () => {
expect(
toDirectCodexTurnAttachments([
{
fileName: '角色参考.png',
mediaType: 'image/png',
localPath: 'assets/uploads/upload-1-角色参考.png',
status: 'imported',
size: 12,
},
{
fileName: 'lost.bin',
mediaType: 'application/octet-stream',
status: 'failed',
error: '磁盘不可写',
size: 2,
},
]),
).toEqual([
{
name: '角色参考.png',
mediaType: 'image/png',
size: 12,
localPath: 'assets/uploads/upload-1-角色参考.png',
status: 'imported',
},
{
name: 'lost.bin',
mediaType: 'application/octet-stream',
size: 2,
status: 'failed',
},
]);
});
it('returns an empty list when there is nothing to map', () => {
expect(toDirectCodexTurnAttachments(undefined)).toEqual([]);
expect(toDirectCodexTurnAttachments([])).toEqual([]);
});
});
+2
View File
@@ -20,6 +20,8 @@
## AI 游戏创作与 Agent Runtime
- [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,22 @@
---
## 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,原文件名不是磁盘路径,模型会另起一套玩法。
- 决策:Home 与 Project 共用 `DirectCodexTurnAttachment`。有项目路径或导入状态时,只在发给 Codex 的 user prompt 末尾附有界 sidecar(原名 → 项目相对路径、类型、大小、状态);无路径且无状态时保持首页元数据文案。不灌正文、不强制读取、不按 GDD 开特例。做成游戏固定 prompt 不改,同一条 Direct 首轮附件链自动吃到 sidecar。jsonl 与工作台气泡仍只写用户原文。
- 影响范围:`direct_codex_attachments.rs`、Direct command 边界、首页建项 latch、工作台首轮 invokeSupervisor / 做方案首轮忽略附件 sidecar。
- 验证方式:Rust 渲染测试(Home 逐字兼容、Project 映射、非法路径);home.suite 附件 Direct invoke 含 `localPath`;无附件不出现 `attachments` 键;做方案首轮仍走 Supervisor 且无 sidecar;后续手打消息不带 attachments。
- 关联文档:`docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`、issue #212
## 2026-08-26 运行中自主扩图提案留在编排层
- 背景:`agent-runtime-orchestration` 已能构造和调度动态 DAG,但 LLM 在执行中发现缺少步骤时没有通用的安全扩图合同。
@@ -39,6 +55,7 @@
- 产品边界:16 个游戏任务、六组角色、产物/验收条件、Evaluator Markdown 和中文语义路由继续留在 `platform-agent`;AGC 组合根使用公共层校验任务图与 `AgentCatalog`。Runtime store、Runner、Provider、权限、ToolHost、委派 journal、isolated write scope 和 `.agent/runtime/**` 不迁移、不双写。
- 验证方式:非游戏 conformance 覆盖并行分支、汇合、repair closure、AgentCatalog 和非法图失败关闭;`platform-agent` 锁定种子 DAG 与现役波次/返工顺序,并验证环拒绝和 catalog 注入。根检查脚本必须执行新 crate 测试。
- 关联文档:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.54。
## 2026-08-27 `plan.submit_gdd` 拒绝无审批决定的 `user_revision`
- 背景:结构校验允许 `round=0 + user_revision + confirmed`,提交闸原先只做结构、身份和 Session CAS。Provider 可在首次 collecting、澄清续跑或提交前质量返工里把未确认项标成用户审批修改,审批卡显示「已确认」。
@@ -1,6 +1,6 @@
# 文档地图与阅读索引
更新时间:`2026-08-25`
更新时间:`2026-08-31`
## 阅读顺序
@@ -24,9 +24,11 @@ AI 游戏创作 / DirectProject / UI workflow
1. `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
2. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`
3. `docs/technical/【技术方案】立项策划AgentFast GDD-2026-08-10.md`
4. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`
5. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`
6. UI 编辑器、宿主壳和当前测试专题文档
4. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`
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 编辑器、宿主壳和当前测试专题文档
图片画布 / 媒体生成:
@@ -0,0 +1,262 @@
# DirectProject 本轮附件路径映射
- 日期:2026-08-31
- 状态:现行合同(已按本文落地)
- 问题:Gitea issue #212DirectProject 未消费用户上传权威文档)
- 关联入口:PR #210「批准 GDD 回填做游戏入口」(`feat/create_entrance`,未合入时仍按该 PR 的调用链理解)
- 原则:落地后代码简洁可维护,不为了 diff 最小而打补丁;附件一律同等对待,不给 GDD 开协议特例
## 0. 一句话
首页带进项目的附件已经复制并登记,但 Direct 首轮只拿到用户原文。本方案让 Direct 回合在发给 Codex 的 user prompt 末尾附上**有界路径映射**(原文件名 → 项目相对路径),不灌正文、不强制读取、不改做方案注入。
## 1. 目标与非目标
### 目标
1. Direct 首轮知道本轮用户附件的原文件名、项目相对路径、媒体类型、大小和导入状态。
2. 用户原文不被改写;路径映射是独立 sidecar。
3. 图片、Markdown、其它文件走同一条协议。
4. 做成游戏(PR #210:读 `game/fast_gdd.md` → 当成 `fast_gdd.md` 附件 → `createHomeDraftAutomatically`)自动吃到效果,因为那条链就是「带附件的 Direct 首轮」。
### 非目标
- 不改 PR #210 的按钮、固定 prompt、`startGameFromApprovedGdd`、读 `game/fast_gdd.md` 的方式。
- 不改 `approvedGddRef`、审批 receipt、策划项目里的 `game/fast_gdd.md` 投影。
- 不改上传命名 `assets/uploads/upload-<ts>-<name>`
- 不把附件全文拼进 prompt,不按扩展名决定是否读取。
- 不把「没读到就阻断」做成门禁。
- 不扫 manifest 里历史 `kind=uploaded`
- 不做 native 读取审计;该项由 [`【技术方案】Direct回合行为审计账本-2026-08-31.md`](./【技术方案】Direct回合行为审计账本-2026-08-31.md) 承接。
- 不改 DirectHome 在「无项目路径」时的现有文案和列表格式。
- 不改 `enterCreatedHomeProject` 的空正文兜底句(与做方案共用)。
## 2. 现行断点
```text
Home 附件 / 做成游戏 File(fast_gdd.md)
→ upload_local_asset
→ LauncherProjectContext.attachments // 已有,只给资源画布
→ ProjectSupervisor // 无 attachments 字段
→ chat_with_game_creator_direct_codex
{ projectPath, prompt, clientTurnId, creationType? }
```
做成游戏的固定 prompt 仍写「附件中的 `fast_gdd.md`」,磁盘文件却是 `assets/uploads/upload-<ts>-fast_gdd.md`。映射没有进 Direct。
DirectHome 已有 `{ name, mediaType, size }` 元数据注入,但标明「尚未打开项目,内容尚不可读取」。项目已落盘后这条元数据被丢掉。
## 3. 目标合同
### 3.1 唯一 DTO
Home 与 Project 共用一个附件结构,缺省字段表示 Home 现状:
```ts
{
name: string; // 原文件名
mediaType: string;
size?: number; // 缺省按 0
localPath?: string; // 仅已落入项目时出现
status?: 'imported' | 'failed';
}
```
- 不把 `error` 字符串送给模型。
- 前端 `LauncherImportedAttachment` 继续给画布;invoke 前映射成上述瘦 DTO。
- `importHomeAttachments``File.size` 写入可选 `size`,不改 `upload_local_asset` 返回值。
Rust
```rust
struct DirectCodexTurnAttachment {
name: String,
media_type: String,
#[serde(default)]
size: u64,
#[serde(default)]
local_path: Option<String>,
#[serde(default)]
status: Option<String>, // 只接受 imported | failed,其它忽略
}
```
`DirectCodexHomeAttachment` 删除,Home command 改用同一类型。现有 Home JSON(无 `localPath` / `status`)继续能反序列化。
### 3.2 渲染
一个函数 `render_direct_codex_user_prompt(prompt, attachments) -> Result<String, String>`
| 输入 | 输出 |
|---|---|
| 无附件 | `prompt.trim()`;若也空则 `Err("聊天内容不能为空")` |
| 附件都没有 `localPath` 且都没有 `status` | 保持现有 Home 文案与行格式,测试须逐字兼容 |
| 任一条有 `localPath``status` | Project 头 + Project 行格式 |
Home 行(禁止改字):
```text
[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]
- {name};类型:{mediaType};大小:{n} 字节
```
Project 头与行(禁止出现 GDD / 规格 / 权威 / 必须读取):
```text
<用户原文>
[本轮用户附件:已复制到当前项目。请用「项目路径」读取;原文件名不是磁盘路径。]
- 原文件名:fast_gdd.md;项目路径:assets/uploads/upload-1788083777445-fast_gdd.md;类型:text/markdown;大小:7944 字节;状态:imported
```
规则:
- 条数上限仍为 8,超出写 `- 另有 N 个附件未展开`
- 名字清洗沿用 Home:basename、去掉控制字符、最多 160 字、空则「未命名附件」。
- 媒体类型清洗沿用 Home。
- `localPath` 只接受项目相对 POSIX 路径:无 `..`、无盘符/根路径、首段不是 `.agent` / `.git``\` 归一为 `/`,最长 512;不合法则该条不输出路径,状态按 `failed`
- 有附件时允许原文为空(Home 已如此)。Direct 内层若仍要求非空,在 command 边界先渲染再下传,避免空原文 + 有附件被拒。
不在 sidecar 里写「若用户要求按附件实施请先读取」。意图留在用户原文;做成游戏的固定 prompt 已经在说这件事。
### 3.3 谁渲染、谁看见
- sidecar **只在进 Codex 前由 Rust 拼装**
- `.agent/conversations/project.jsonl` 继续写用户原文(现有 `append_local_conversation_message`)。
- 工作台气泡继续显示 latch / 输入框原文,不把 sidecar 画进 UI。
- 未完成首轮的 hydration 重放目前只带 prompt:本期不把附件写进 jsonl,进程重启后的未完成首轮可能丢映射。完整成功首轮不受影响。不为此新增会话 schema。
## 4. 数据流
```text
Home 上传 / 做成游戏 File
→ upload_local_asset(已有)
→ LauncherProjectContext.attachments(已有)
→ ProjectSupervisor.initialAttachments
→ 首轮 latch(与 prompt、creationType 同级)
→ 仅 chat_with_game_creator_direct_codex.attachments
→ render_direct_codex_user_prompt
→ 现有 Direct turncwd = 项目根)
```
Supervisor / 做方案首轮忽略 `attachments`,行为不变。
后续工作台手打消息不带 `attachments`。附件是这一轮带来的,不是项目终身上下文。
## 5. 代码落地(按最终结构,不按最小补丁)
### 5.1 Rust
新增 [`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs)
- DTO、清洗、上限常量、`render_direct_codex_user_prompt`
- 单元测试(见第 6 节)
[`agent.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent.rs) 增加 `mod direct_codex_attachments`
[`direct_runtime.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs)
- 删除 Home 专用 struct / sanitizer / `render_direct_codex_home_user_prompt`
- `run_direct_game_creator_home_turn` 改为调用共享渲染
- `chat_with_game_creator_direct_codex` 增加 `attachments: Option<Vec<DirectCodexTurnAttachment>>`,先渲染再调用现有 `run_direct_game_creator_turn_at_with_creation_type_and_emitter`
- 把现有 Home 渲染测试迁到新文件;本文件不再保留一份平行实现
不要把 attachments 顺着 inner turn / emitter / CLI 往下传。CLI `run_direct_game_creator_turn_at` 不变。
### 5.2 前端
[`model.ts`](../../apps/ai-game-creator-shell/src/features/app-shell/model.ts) `ProjectSupervisorComponentProps` 增加:
```ts
initialAttachments?: LauncherImportedAttachment[];
```
[`WorkspaceLauncher.tsx`](../../apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx)
```ts
initialAttachments={currentProjectContext.attachments}
```
[`App.tsx`](../../apps/ai-game-creator-shell/src/App.tsx)
- props / latch 增加 `attachments`(默认 `[]`
- `executeChatAgentReply` 不要再加第 5 个位置参数,收成:
```ts
{
prompt: string;
clientTurnId?: string;
creationType?: HomeCreationType | null;
attachments?: DirectCodexTurnAttachment[];
}
```
- Direct invoke:有 `creationType` 才写该字段(现有);`attachments?.length` 才写 `attachments`
- Supervisor 分支完全不读 `attachments`
- 现有 `executeChatAgentReply(prompt)` / 恢复未完成 turn 的调用改为对象形式,不传 attachments
瘦映射不要写在 1 万行的 `App.tsx` 里,放到例如 [`apps/ai-game-creator-shell/src/features/app-shell/directCodexTurnAttachments.ts`](../../apps/ai-game-creator-shell/src/features/app-shell/directCodexTurnAttachments.ts)`toDirectCodexTurnAttachments(imported)`,去掉 `error`,空 `localPath` 不输出该键。
[`useHomeProjectCreation.ts`](../../apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts)`importHomeAttachments` 写入 `size: attachment.file.size`。不改 `initialPrompt` 兜底句,不改做成游戏(即便本分支尚未合入 PR #210,也不预埋 GDD 字段)。
[`types.ts`](../../apps/ai-game-creator-shell/src/app/types.ts)`LauncherImportedAttachment` 增加可选 `size?: number`
### 5.3 文档
落地提交时(不是本方案文件自身):
- 本文件标为现行合同
- `docs/README.md``docs/project-memory/shared-memory/document-map.md` 增加条目
- `decision-log.md` 记一条:Direct 本轮附件只映射路径,不灌正文、不区别 GDD
- 不把 issue #212 技术说明改写成「已修复」,等代码合入后再改状态
## 6. 测试
### Rust(新文件)
1. 无附件:原文 trim 后原样返回;空原文报错。
2. Home 形态(无 path、无 status):与迁过来的两条现有测试逐字一致(含路径剥离、非法 mediaType、8 条上限)。
3. Project 形态:原文保留;含原名与 `assets/uploads/...`;**断言不得出现「GDD」「规格」「权威」**。
4. 同一列表里 png 与 md 行格式相同(只是 name/path/type 不同)。
5. `status=failed` 且无 path:有状态、无项目路径、无 error 正文。
6. 非法 `localPath``../``.agent/x`、绝对路径)不出现在输出中。
7. 空原文 + 有附件:成功,且含 Project 头。
### 前端
1. [`home.suite.ts`](../../apps/ai-game-creator-shell/tests/appSurface/home.suite.ts)「imports home attachments…」:Direct invoke 必须带 `attachments`,其中 `name``角色参考.png``localPath` 为 upload 返回路径、`status: 'imported'`。用 png 证明不是 md 特例。
2. 无附件的 Direct invoke 仍不得出现 `attachments` 键(或等价:不传该字段)。
3. `planningStartMode` 首轮仍走 Supervisor`chat_with_game_creator_agent` 的 payload 不含附件 sidecar。
4. 工作台后发的普通消息:`chat_with_game_creator_direct_codex` 只有 `projectPath/prompt/clientTurnId`(及既有 creationType 规则),不带 attachments。
5. 若本分支已能跑 PR #210 的 home.suite / plan-gdd 做成游戏用例:只断言它仍调用 `createHomeDraftAutomatically` / 仍使用原固定 prompt;**不要**给做成游戏加第二条附件协议。sidecar 由通用 Direct 断言覆盖。
### 不测
- 不把「必须生成弹幕射击」写成单测。
- 不测 native `file.read` 是否进 `agent.db`
## 7. 做成游戏为什么不用改
PR #210 `startGameFromApprovedGdd`
1. 读策划项目 `game/fast_gdd.md`
2. `new File([content], 'fast_gdd.md')`
3. `createHomeDraftAutomatically({ creationType: 'game', prompt: APPROVED_GDD_BUILD_PROMPT, attachments }, 'direct-build')`
之后与首页拖一个 md 完全相同。sidecar 见到的是「原文件名 `fast_gdd.md` + 新项目 `assets/uploads/upload-…-fast_gdd.md`」。固定 prompt 继续说「读附件中的 fast_gdd.md」,映射补上真实路径。
## 8. 验收
1. 首页做游戏:上传任意文本或图片 + 一句话,Direct 首轮 prompt 含原名和 `assets/uploads/...`
2. 做成游戏(PR #210 合入后或该分支上):固定 prompt 一字不改,同时出现改写后的项目路径。
3. 做方案首轮:Supervisor 行为与现在一致,无 sidecar。
4. 无附件:Direct 入参与现在一致。
5. `npm run check:encoding``git diff --check`、相关 `home.suite` / Direct Rust 测试通过。
## 9. 实现顺序
1. Rust 共享渲染 + 迁 Home 测试 + Direct command 接 `attachments`
2. 前端 latch / invoke / 映射 / `size`
3.`home.suite` 附件断言
4. encoding 与定向测试
5. 合入时补 decision-log 与文档索引
@@ -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`