From dfd6fadedf184c8779c3e801bc6168036763c21d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 14:45:58 +0800 Subject: [PATCH 01/92] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20DirectProject=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=20Response=20item=20=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Rust canonical user item 与 Codex wire 转换模块 增加 ts-rs 绑定目录及生成文件检查边界 更新聊天素材引用规范与里程碑实施计划 --- .eslintrc.cjs | 1 + .../src-tauri/src/agent.rs | 2 + .../src/agent/direct_codex_user_item.rs | 231 ++++++++++++++++++ .../generated/DirectCodexUserContentPart.ts | 10 + .../generated/DirectCodexUserItem.ts | 5 + .../generated/DirectCodexUserMessageItem.ts | 11 + .../generated/DirectCodexUserRole.ts | 3 + .../DirectCodexUserRuntimeRegionPart.ts | 13 + .../project-workspace/generated/index.ts | 5 + ...DirectProject用户ResponseItem输入-2026-09-15.md | 36 +++ ...DirectProject用户ResponseItem输入-2026-09-15.md | 51 ++++ .../【功能说明】AGC聊天素材引用-2026-09-08.md | 8 +- 12 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts create mode 100644 docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md create mode 100644 docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 793e2b4a4..8a4b2d0c6 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -164,6 +164,7 @@ module.exports = { 'server-rs/target-*', 'apps/desktop-shell/src-tauri/target', 'apps/ai-game-creator-shell/src/features/ui-editor/types/**', + 'apps/ai-game-creator-shell/src/features/project-workspace/generated/**', 'target', 'src/main.tsx', 'src/App.tsx', diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 17b8a6700..53d1f1db4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -17,6 +17,7 @@ mod design_tools; mod direct_codex_attachments; mod direct_codex_audit; mod direct_codex_references; +mod direct_codex_user_item; mod direct_project_history; mod direct_project_turn_history; mod direct_runtime; @@ -45,6 +46,7 @@ pub(crate) use design_runtime::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_audit::*; pub(crate) use direct_codex_references::*; +pub(crate) use direct_codex_user_item::*; pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs new file mode 100644 index 000000000..1c042b25f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs @@ -0,0 +1,231 @@ +use super::direct_codex_references::MAX_DIRECT_CODEX_REFERENCES; +use super::*; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::Path; +use ts_rs::TS; + +/// DirectProject 本轮 user input 的唯一结构化入口。 +/// +/// 该模块只负责 user item 的 schema、校验和 Codex wire 投影;assistant/raw item +/// 仍由现有 app-server 链路处理,避免把两个方向的协议耦合在一个浅层 DTO 中。 +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(tag = "type")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserItem { + #[serde(rename = "message")] + Message(DirectCodexUserMessageItem), +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserMessageItem { + pub(crate) role: DirectCodexUserRole, + pub(crate) content: Vec, + pub(crate) id: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserRole { + User, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(tag = "type")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserContentPart { + #[serde(rename = "input_text")] + InputText { text: String }, + #[serde(rename = "agc_resource_reference")] + AgcResourceReference { resource_id: String }, + #[serde(rename = "agc_runtime_region_reference")] + AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserRuntimeRegionPart { + pub(crate) label: String, + #[serde(default)] + pub(crate) run_id: Option, + #[serde(default)] + pub(crate) version_id: Option, + #[serde(default)] + pub(crate) element_tag: Option, + #[serde(default)] + pub(crate) element_role: Option, + #[serde(default)] + pub(crate) text: Option, + #[serde(default)] + pub(crate) width: Option, + #[serde(default)] + pub(crate) height: Option, + #[serde(default)] + pub(crate) resource_ids: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserMessageEnvelope { + pub(crate) item: DirectCodexUserItem, +} + +pub(crate) fn validate_direct_codex_user_item( + root: &Path, + item: &DirectCodexUserItem, +) -> Result<(), String> { + let DirectCodexUserItem::Message(message) = item; + if !matches!(message.role, DirectCodexUserRole::User) { + return Err("DirectProject 只接受 user message item".to_string()); + } + if message.id.trim().is_empty() { + return Err("DirectProject user item 缺少稳定 id".to_string()); + } + if message.content.is_empty() { + return Err("DirectProject user item content 不能为空".to_string()); + } + let manifest = read_manifest_for_project(root)?; + let mut reference_count = 0usize; + for part in &message.content { + match part { + DirectCodexUserContentPart::InputText { text } => { + if text.trim().is_empty() { + return Err("DirectProject input_text 不能为空".to_string()); + } + } + DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + reference_count = reference_count.saturating_add(1); + validate_resource_id_and_manifest(&manifest, resource_id)?; + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + reference_count = reference_count.saturating_add(1); + validate_runtime_region_reference(&manifest, reference)?; + } + } + } + if reference_count > MAX_DIRECT_CODEX_REFERENCES { + return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); + } + Ok(()) +} + +fn validate_resource_id_and_manifest( + manifest: &GameCreationAppManifest, + resource_id: &str, +) -> Result<(), String> { + let resource_id = resource_id.trim(); + if resource_id.is_empty() + || resource_id.chars().count() > 200 + || resource_id.chars().any(char::is_control) + { + return Err("引用的素材 ID 无效,请移除后重新选择".to_string()); + } + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == resource_id) + .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; + sanitize_attachment_local_path(&asset.local_path) + .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; + Ok(()) +} + +fn validate_runtime_region_reference( + manifest: &GameCreationAppManifest, + reference: &DirectCodexUserRuntimeRegionPart, +) -> Result<(), String> { + if reference.label.trim().is_empty() { + return Err("运行画面区域缺少名称".to_string()); + } + if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES { + return Err(format!( + "运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材" + )); + } + for resource_id in &reference.resource_ids { + validate_resource_id_and_manifest(manifest, resource_id)?; + } + Ok(()) +} + +/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。 +/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 +pub(crate) fn direct_codex_user_item_to_wire_input( + root: &Path, + item: &DirectCodexUserItem, +) -> Result { + validate_direct_codex_user_item(root, item)?; + let manifest = read_manifest_for_project(root)?; + let DirectCodexUserItem::Message(message) = item; + let mut input = Vec::with_capacity(message.content.len()); + for part in &message.content { + let text = match part { + DirectCodexUserContentPart::InputText { text } => text.clone(), + DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == resource_id.trim()) + .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; + let path = sanitize_attachment_local_path(&asset.local_path) + .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; + format!( + "[素材引用 resourceId={};项目路径={path}]", + resource_id.trim() + ) + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + let resources = reference + .resource_ids + .iter() + .map(|id| id.trim()) + .collect::>() + .join(","); + let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim()); + if let Some(run_id) = reference.run_id.as_deref() { + summary.push_str(&format!("运行标识={} ", run_id.trim())); + } + if let Some(role) = reference.element_role.as_deref() { + summary.push_str(&format!("角色={} ", role.trim())); + } + if let Some(text) = reference.text.as_deref() { + summary.push_str(&format!("文本={} ", text.trim())); + } + if !resources.is_empty() { + summary.push_str(&format!("关联素材={resources}")); + } + summary.push(']'); + summary + } + }; + input.push(serde_json::json!({ "type": "text", "text": text })); + } + Ok(Value::Array(input)) +} + +pub(crate) fn direct_codex_user_item_to_prompt( + root: &Path, + item: &DirectCodexUserItem, +) -> Result { + let wire = direct_codex_user_item_to_wire_input(root, item)?; + wire.as_array() + .ok_or_else(|| "DirectProject user item wire input 不是数组".to_string()) + .map(|parts| { + parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::() + }) + .and_then(|prompt| { + if prompt.trim().is_empty() { + Err("DirectProject user item 不能转换为空 prompt".to_string()) + } else { + Ok(prompt) + } + }) +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts new file mode 100644 index 000000000..10c4e0f5a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts @@ -0,0 +1,10 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; + +export type DirectCodexUserContentPart = + | { type: 'input_text'; text: string } + | { type: 'agc_resource_reference'; resourceId: string } + | ({ + type: 'agc_runtime_region_reference'; + } & DirectCodexUserRuntimeRegionPart); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts new file mode 100644 index 000000000..4c9ea8ca3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts @@ -0,0 +1,5 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; + +export type DirectCodexUserItem = DirectCodexUserMessageItem; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts new file mode 100644 index 000000000..f7447dc5d --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts @@ -0,0 +1,11 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; +import type { DirectCodexUserRole } from './DirectCodexUserRole'; + +export type DirectCodexUserMessageItem = { + type: 'message'; + role: DirectCodexUserRole; + content: DirectCodexUserContentPart[]; + id: string; +}; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts new file mode 100644 index 000000000..e1dc59c59 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts @@ -0,0 +1,3 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DirectCodexUserRole = 'user'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts new file mode 100644 index 000000000..296c0d19a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts @@ -0,0 +1,13 @@ +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DirectCodexUserRuntimeRegionPart = { + label: string; + runId?: string; + versionId?: string; + elementTag?: string; + elementRole?: string; + text?: string; + width?: number; + height?: number; + resourceIds: string[]; +}; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts new file mode 100644 index 000000000..f1012a4c5 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts @@ -0,0 +1,5 @@ +export type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; +export type { DirectCodexUserItem } from './DirectCodexUserItem'; +export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; +export type { DirectCodexUserRole } from './DirectCodexUserRole'; +export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; diff --git a/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md b/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md new file mode 100644 index 000000000..8742e52fb --- /dev/null +++ b/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md @@ -0,0 +1,36 @@ +# 【实施计划】DirectProject 用户 Response item 输入 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md` | +| Status | ready | +| Owner | Codex | + +## 修改边界 + +- 允许修改:AGC 壳 Rust agent 输入合同、DirectProject 历史适配、前端聊天引用模型、ts-rs 生成配置、当前聊天素材文档。 +- 明确不修改:assistant 返回协议、工具 activity、附件/图片协议、SpacetimeDB、HTTP API。 + +## 实现顺序 + +1. 提取深模块:Rust canonical user item 的定义、校验和 Codex wire 转换。 +2. 生成并接入 ts-rs 类型,排除生成文件 lint/style。 +3. 将前端 Lexical 草稿从 `text + references[]` 改为 inline content parts。 +4. 修改 Tauri command 与 DirectProject turn:校验通过后持久化 canonical item,再发送转换后的 Codex 输入。 +5. 删除本链路对 legacy conversation 行的读取 fallback,保留标准 `response_item`。 +6. 补齐定向测试与文档验证;每个独立切片形成小提交。 + +## 验证命令 + +1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_codex` +2. 相关前端 Vitest 与类型检查。 +3. `npm run check:encoding` +4. `npm run check:doc-index` +5. `git diff --check` + +## 风险与回滚点 + +- ts-rs 生成路径或 workspace lint 配置不一致:先固定生成入口,再接入业务。 +- 历史中 canonical item 含 AGC part 时,thread replay 必须经过同一 wire converter;转换失败不得启动 turn。 +- 共享工作树存在用户 `.env` 修改,禁止覆盖或提交。 +- 每个切片保持独立提交,出现协议问题时按提交粒度回滚。 diff --git a/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md b/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md new file mode 100644 index 000000000..cc88e97af --- /dev/null +++ b/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md @@ -0,0 +1,51 @@ +# 【里程碑】DirectProject 用户 Response item 输入 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | proposed | +| Date | 2026-09-15 | +| Parent Spec | `docs/【功能说明】AGC聊天素材引用-2026-09-08.md` | + +## 目标 + +将 DirectProject 的用户消息升级为受限 Response API `message` item:文本与 AGC 引用按编辑顺序内联,Rust 校验后持久化 canonical item,并转换为 Codex 可接受的输入。 + +## 范围 + +- Rust 定义 user item 与 content part,并通过 `ts-rs` 生成 TypeScript 绑定。 +- 前端 Lexical 草稿生成 inline `content[]`。 +- `agc_resource_reference` 只使用稳定 `resourceId`。 +- `agc_runtime_region_reference` 保留运行区域语义摘要。 +- Rust 在持久化前完成白名单、manifest 与路径校验。 +- 现有标准 `response_item` 原样兼容;legacy conversation 行不提供 fallback。 +- 保持 assistant 返回、工具 activity、附件/图片协议不变。 + +## 不在范围内 + +- assistant item 前端投影或 Tauri 返回值改造。 +- 工具 item、reasoning、file change、MCP item 的 UI 模型化。 +- 附件/图片 content part。 +- SpacetimeDB schema 或 HTTP API 变更。 + +## 依赖与前置条件 + +- DirectProject 现有 app-server thread/inject_items/turn/start 链路。 +- 项目 manifest 作为资源身份与路径权威。 +- 现有 `project.jsonl` 的 `response_item` envelope。 + +## 验收标准 + +- [ ] 前端生成的 canonical user item 保留 Lexical 中文本与引用的相对顺序。 +- [ ] `agc_resource_reference` 仅包含 `resourceId`,显示信息由 manifest 派生。 +- [ ] runtime-region 字段经过 Rust 有界清洗并验证关联资源。 +- [ ] 未知 part、失效资源或非法路径在持久化前失败关闭。 +- [ ] canonical item 以 `response_item` 写入历史,标准旧 item 原样可读。 +- [ ] Codex wire input 不含 AGC 私有 part,且顺序与 canonical content 一致。 +- [ ] assistant、附件和工具链路行为无变化。 + +## 证据要求 + +- 自动化:Rust item 校验/转换/历史测试;前端草稿顺序与类型测试;ts-rs 生成检查。 +- 运行时:DirectProject 本地 app-server smoke(如环境可用)。 +- 边界:未知 part、资源删除、非法路径、重复提交 clientTurnId、legacy 行拒绝。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index e8a921a69..ca8874954 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -13,7 +13,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。 确认后素材以 `@素材名` 芯片插入编辑器,用户可以在芯片前后继续编辑自然语言,也可以单独删除芯片。芯片内部保存稳定 `resourceId`,展示名称只用于界面,不参与引用解析;资源改名后,编辑区已有芯片与候选列表都会按 `resourceId` 刷新成 manifest 的最新显示名,并同步回父级草稿。 -提交时前端同时发送用户文本和 `references` 数组。Rust 在发起 Agent 回合前读取当前项目 manifest,逐项复核资源是否存在、路径是否安全,并以 manifest 中的 `id / kind / mediaType / localPath` 作为权威投影;客户端传入的路径、名称和类型不会被直接信任。已删除或不存在的资源会阻止发送并提示用户移除后重新选择。 +提交时前端把 Lexical 草稿直接编码为受限 Response API user `message` item:`input_text` 与 AGC 引用 part 按编辑顺序内联在同一个 `content[]` 中。资源引用只携带稳定 `resourceId`;运行画面引用携带区域语义摘要及关联资源 ID。Rust 是唯一 schema source(通过 `ts-rs` 生成 TypeScript 绑定),在发起回合前完成 item 白名单、字段边界、manifest 归属和路径安全校验;校验失败时本轮不持久化、不发送。通过校验的 canonical item 以 `response_item` envelope 写入项目历史,随后由 Rust 将 AGC part 临时转换为 Codex 可接受的 `input_text`,保持原始 content 顺序。已有标准 `response_item` 原样读取与复用;旧 legacy conversation 行不再提供 fallback。 当前已完成: @@ -24,9 +24,9 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。 - 素材芯片可插入、编辑和删除; - 资源画布素材卡的选中工具条提供「引用」入口:图标本身就是 `@`,可见文案与 `title` 都只写「引用」,插入对话里的仍是 `@素材名` 芯片; - 运行画面提供“点选素材”,可选中 HTML 区域并生成 `runtime-region` 引用; -- 提交请求携带结构化 `references`; -- Rust 按 manifest 二次校验并生成安全投影; +- 提交请求携带 canonical user message item; +- Rust 按 manifest 二次校验、持久化 canonical item,并生成 Codex wire input; - 普通无引用消息保持原有行为; - 素材选择面板的「当前版本素材 / 全部画布素材」两个页签与独立筛选、搜索状态; - 资源改名后引用芯片与候选列表的显示名自动刷新; -- 切换 / 重开会话恢复草稿后光标落在文本末尾,引用按顺序追加到文本之后。 +- 切换 / 重开会话恢复草稿后光标落在文本末尾,引用按原 content 顺序恢复为 inline 芯片。 -- 2.52.0 From 948a80fc49ec76ca483294d0f219385939503fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 15:19:17 +0800 Subject: [PATCH 02/92] =?UTF-8?q?=E9=87=8D=E6=9E=84=20DirectProject=20Agen?= =?UTF-8?q?t=20=E6=B7=B1=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 direct_runtime 与 codex_app_server 改为目录模块 拆分 user item 的模型、校验与 wire 投影文件 抽离 DirectProject 历史注入载荷构造 seam --- .../direct_project_history_wire.rs | 25 ++ .../mod.rs} | 50 ++-- .../src/agent/direct_codex_user_item.rs | 231 ------------------ .../src/agent/direct_codex_user_item/mod.rs | 12 + .../src/agent/direct_codex_user_item/model.rs | 69 ++++++ .../direct_codex_user_item/validation.rs | 87 +++++++ .../src/agent/direct_codex_user_item/wire.rs | 82 +++++++ .../mod.rs} | 107 ++------ .../src/agent/direct_runtime/user_input.rs | 84 +++++++ 9 files changed, 402 insertions(+), 345 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs rename apps/ai-game-creator-shell/src-tauri/src/agent/{codex_app_server.rs => codex_app_server/mod.rs} (99%) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs rename apps/ai-game-creator-shell/src-tauri/src/agent/{direct_runtime.rs => direct_runtime/mod.rs} (99%) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs new file mode 100644 index 000000000..b958bd982 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs @@ -0,0 +1,25 @@ +//! DirectProject 历史注入载荷的单一构造 seam。 +//! +//! 历史读取与大小前置校验集中在这里;调用方只负责线程生命周期与 RPC 传输。 + +use super::super::*; +use super::direct_project_history_injection_oversize_error; +use serde_json::Value; +use std::path::Path; + +pub(super) fn build_direct_project_history_injection_params( + history_root: &Path, + thread_id: &str, +) -> Result { + let items = read_direct_project_history_items_at(history_root) + .map_err(platform_llm::LlmError::InvalidRequest)?; + let params = serde_json::json!({"threadId": thread_id, "items": items}); + let payload_bytes = serde_json::to_vec(¶ms) + .map(|bytes| bytes.len().saturating_add(1)) + .unwrap_or(usize::MAX); + // 注入前的前置校验:失败关闭并指名 itemId 与字节数,**不截断、不摘要、不改写**。 + if let Some(error) = direct_project_history_injection_oversize_error(¶ms, payload_bytes) { + return Err(platform_llm::LlmError::InvalidRequest(error)); + } + Ok(params) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs similarity index 99% rename from apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs rename to apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 48edc498f..9259eaf48 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -10,6 +10,9 @@ use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufR use tokio::sync::{mpsc, oneshot, Mutex, Notify}; use uuid::Uuid; +mod direct_project_history_wire; +use direct_project_history_wire::build_direct_project_history_injection_params; + const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc"; const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY"; const GAME_CREATOR_CODEX_APP_SERVER_REMOTE_CONTROL_DISABLED_ENV: &str = @@ -2620,6 +2623,7 @@ impl CodexAppServerConnection { request, None, None, + None, on_agent_message_delta, direct_observer, audit, @@ -2634,6 +2638,7 @@ impl CodexAppServerConnection { request: LlmRunRequest, direct_history_root: Option<&std::path::Path>, direct_client_turn_id: Option<&str>, + direct_user_item: Option<&serde_json::Value>, 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>, @@ -2649,12 +2654,15 @@ impl CodexAppServerConnection { )); } if let Some(client_turn_id) = direct_client_turn_id { - let user_item = direct_project_local_message_item( - "user", - current_prompt, - Some(&format!("direct-codex:{client_turn_id}:user")), - ) - .map_err(platform_llm::LlmError::InvalidRequest)?; + let user_item = match direct_user_item { + Some(item) => item.clone(), + None => direct_project_local_message_item( + "user", + current_prompt, + Some(&format!("direct-codex:{client_turn_id}:user")), + ) + .map_err(platform_llm::LlmError::InvalidRequest)?, + }; append_direct_project_user_message_at(history_root, &user_item) .map_err(platform_llm::LlmError::InvalidRequest)?; } @@ -2664,24 +2672,14 @@ impl CodexAppServerConnection { let thread_id = thread_lease.thread_id.clone(); if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { if thread_created { - let items = match read_direct_project_history_items_at(history_root) { - Ok(items) => items, - Err(error) => { - self.release_thread(snapshot, &thread_id).await; - return Err(platform_llm::LlmError::InvalidRequest(error)); - } - }; - let params = serde_json::json!({"threadId": thread_id.clone(), "items": items}); - let payload_bytes = serde_json::to_vec(¶ms) - .map(|bytes| bytes.len().saturating_add(1)) - .unwrap_or(usize::MAX); - // 注入前的前置校验:失败关闭并指名 itemId 与字节数,**不截断、不摘要、不改写**。 - if let Some(error) = - direct_project_history_injection_oversize_error(¶ms, payload_bytes) - { - self.release_thread(snapshot, &thread_id).await; - return Err(platform_llm::LlmError::InvalidRequest(error)); - } + let params = + match build_direct_project_history_injection_params(history_root, &thread_id) { + Ok(params) => params, + Err(error) => { + self.release_thread(snapshot, &thread_id).await; + return Err(error); + } + }; if let Err(error) = self.request("thread/inject_items", params).await { self.release_thread(snapshot, &thread_id).await; return Err(platform_llm::LlmError::Transport(error)); @@ -3712,6 +3710,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at( None, None, None, + None, ) .await } @@ -3729,6 +3728,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( None, Some(observer), None, + None, ) .await } @@ -3786,6 +3786,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( client_turn_id: Option<&str>, observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, audit: Option<&mut DirectCodexTurnAudit>, + direct_user_item: Option, ) -> Result { // Resolve project authority before deriving the pool/thread identity. A // caller may hold a stable symlink path whose target changes between @@ -3849,6 +3850,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( request, Some(&codex_root), effective_client_turn_id, + direct_user_item.as_ref(), None, observer, audit, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs deleted file mode 100644 index 1c042b25f..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs +++ /dev/null @@ -1,231 +0,0 @@ -use super::direct_codex_references::MAX_DIRECT_CODEX_REFERENCES; -use super::*; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::path::Path; -use ts_rs::TS; - -/// DirectProject 本轮 user input 的唯一结构化入口。 -/// -/// 该模块只负责 user item 的 schema、校验和 Codex wire 投影;assistant/raw item -/// 仍由现有 app-server 链路处理,避免把两个方向的协议耦合在一个浅层 DTO 中。 -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(tag = "type")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) enum DirectCodexUserItem { - #[serde(rename = "message")] - Message(DirectCodexUserMessageItem), -} - -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) struct DirectCodexUserMessageItem { - pub(crate) role: DirectCodexUserRole, - pub(crate) content: Vec, - pub(crate) id: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) enum DirectCodexUserRole { - User, -} - -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(tag = "type")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) enum DirectCodexUserContentPart { - #[serde(rename = "input_text")] - InputText { text: String }, - #[serde(rename = "agc_resource_reference")] - AgcResourceReference { resource_id: String }, - #[serde(rename = "agc_runtime_region_reference")] - AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), -} - -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) struct DirectCodexUserRuntimeRegionPart { - pub(crate) label: String, - #[serde(default)] - pub(crate) run_id: Option, - #[serde(default)] - pub(crate) version_id: Option, - #[serde(default)] - pub(crate) element_tag: Option, - #[serde(default)] - pub(crate) element_role: Option, - #[serde(default)] - pub(crate) text: Option, - #[serde(default)] - pub(crate) width: Option, - #[serde(default)] - pub(crate) height: Option, - #[serde(default)] - pub(crate) resource_ids: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) struct DirectCodexUserMessageEnvelope { - pub(crate) item: DirectCodexUserItem, -} - -pub(crate) fn validate_direct_codex_user_item( - root: &Path, - item: &DirectCodexUserItem, -) -> Result<(), String> { - let DirectCodexUserItem::Message(message) = item; - if !matches!(message.role, DirectCodexUserRole::User) { - return Err("DirectProject 只接受 user message item".to_string()); - } - if message.id.trim().is_empty() { - return Err("DirectProject user item 缺少稳定 id".to_string()); - } - if message.content.is_empty() { - return Err("DirectProject user item content 不能为空".to_string()); - } - let manifest = read_manifest_for_project(root)?; - let mut reference_count = 0usize; - for part in &message.content { - match part { - DirectCodexUserContentPart::InputText { text } => { - if text.trim().is_empty() { - return Err("DirectProject input_text 不能为空".to_string()); - } - } - DirectCodexUserContentPart::AgcResourceReference { resource_id } => { - reference_count = reference_count.saturating_add(1); - validate_resource_id_and_manifest(&manifest, resource_id)?; - } - DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { - reference_count = reference_count.saturating_add(1); - validate_runtime_region_reference(&manifest, reference)?; - } - } - } - if reference_count > MAX_DIRECT_CODEX_REFERENCES { - return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); - } - Ok(()) -} - -fn validate_resource_id_and_manifest( - manifest: &GameCreationAppManifest, - resource_id: &str, -) -> Result<(), String> { - let resource_id = resource_id.trim(); - if resource_id.is_empty() - || resource_id.chars().count() > 200 - || resource_id.chars().any(char::is_control) - { - return Err("引用的素材 ID 无效,请移除后重新选择".to_string()); - } - let asset = manifest - .assets - .iter() - .find(|asset| asset.id == resource_id) - .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; - sanitize_attachment_local_path(&asset.local_path) - .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; - Ok(()) -} - -fn validate_runtime_region_reference( - manifest: &GameCreationAppManifest, - reference: &DirectCodexUserRuntimeRegionPart, -) -> Result<(), String> { - if reference.label.trim().is_empty() { - return Err("运行画面区域缺少名称".to_string()); - } - if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES { - return Err(format!( - "运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材" - )); - } - for resource_id in &reference.resource_ids { - validate_resource_id_and_manifest(manifest, resource_id)?; - } - Ok(()) -} - -/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。 -/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 -pub(crate) fn direct_codex_user_item_to_wire_input( - root: &Path, - item: &DirectCodexUserItem, -) -> Result { - validate_direct_codex_user_item(root, item)?; - let manifest = read_manifest_for_project(root)?; - let DirectCodexUserItem::Message(message) = item; - let mut input = Vec::with_capacity(message.content.len()); - for part in &message.content { - let text = match part { - DirectCodexUserContentPart::InputText { text } => text.clone(), - DirectCodexUserContentPart::AgcResourceReference { resource_id } => { - let asset = manifest - .assets - .iter() - .find(|asset| asset.id == resource_id.trim()) - .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; - let path = sanitize_attachment_local_path(&asset.local_path) - .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; - format!( - "[素材引用 resourceId={};项目路径={path}]", - resource_id.trim() - ) - } - DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { - let resources = reference - .resource_ids - .iter() - .map(|id| id.trim()) - .collect::>() - .join(","); - let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim()); - if let Some(run_id) = reference.run_id.as_deref() { - summary.push_str(&format!("运行标识={} ", run_id.trim())); - } - if let Some(role) = reference.element_role.as_deref() { - summary.push_str(&format!("角色={} ", role.trim())); - } - if let Some(text) = reference.text.as_deref() { - summary.push_str(&format!("文本={} ", text.trim())); - } - if !resources.is_empty() { - summary.push_str(&format!("关联素材={resources}")); - } - summary.push(']'); - summary - } - }; - input.push(serde_json::json!({ "type": "text", "text": text })); - } - Ok(Value::Array(input)) -} - -pub(crate) fn direct_codex_user_item_to_prompt( - root: &Path, - item: &DirectCodexUserItem, -) -> Result { - let wire = direct_codex_user_item_to_wire_input(root, item)?; - wire.as_array() - .ok_or_else(|| "DirectProject user item wire input 不是数组".to_string()) - .map(|parts| { - parts - .iter() - .filter_map(|part| part.get("text").and_then(Value::as_str)) - .collect::() - }) - .and_then(|prompt| { - if prompt.trim().is_empty() { - Err("DirectProject user item 不能转换为空 prompt".to_string()) - } else { - Ok(prompt) - } - }) -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs new file mode 100644 index 000000000..d591f4dad --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs @@ -0,0 +1,12 @@ +//! DirectProject user input 的 canonical Response item 深模块。 + +mod model; +mod validation; +mod wire; + +pub(crate) use model::{ + DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope, + DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, +}; +pub(crate) use validation::validate_direct_codex_user_item; +pub(crate) use wire::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_wire_input}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs new file mode 100644 index 000000000..f3837c648 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -0,0 +1,69 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// DirectProject 本轮 user input 的唯一结构化入口。 +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(tag = "type")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserItem { + #[serde(rename = "message")] + Message(DirectCodexUserMessageItem), +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserMessageItem { + pub(crate) role: DirectCodexUserRole, + pub(crate) content: Vec, + pub(crate) id: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserRole { + User, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(tag = "type")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserContentPart { + #[serde(rename = "input_text")] + InputText { text: String }, + #[serde(rename = "agc_resource_reference")] + AgcResourceReference { resource_id: String }, + #[serde(rename = "agc_runtime_region_reference")] + AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserRuntimeRegionPart { + pub(crate) label: String, + #[serde(default)] + pub(crate) run_id: Option, + #[serde(default)] + pub(crate) version_id: Option, + #[serde(default)] + pub(crate) element_tag: Option, + #[serde(default)] + pub(crate) element_role: Option, + #[serde(default)] + pub(crate) text: Option, + #[serde(default)] + pub(crate) width: Option, + #[serde(default)] + pub(crate) height: Option, + #[serde(default)] + pub(crate) resource_ids: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserMessageEnvelope { + pub(crate) item: DirectCodexUserItem, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs new file mode 100644 index 000000000..ed7bf88ee --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs @@ -0,0 +1,87 @@ +use super::model::{ + DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserRole, + DirectCodexUserRuntimeRegionPart, +}; +use crate::agent::direct_codex_references::MAX_DIRECT_CODEX_REFERENCES; +use crate::agent::{ + read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest, +}; +use std::path::Path; + +pub(crate) fn validate_direct_codex_user_item( + root: &Path, + item: &DirectCodexUserItem, +) -> Result<(), String> { + let DirectCodexUserItem::Message(message) = item; + if !matches!(message.role, DirectCodexUserRole::User) { + return Err("DirectProject 只接受 user message item".to_string()); + } + if message.id.trim().is_empty() { + return Err("DirectProject user item 缺少稳定 id".to_string()); + } + if message.content.is_empty() { + return Err("DirectProject user item content 不能为空".to_string()); + } + let manifest = read_manifest_for_project(root)?; + let mut reference_count = 0usize; + for part in &message.content { + match part { + DirectCodexUserContentPart::InputText { text } => { + if text.trim().is_empty() { + return Err("DirectProject input_text 不能为空".to_string()); + } + } + DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + reference_count = reference_count.saturating_add(1); + validate_resource_id_and_manifest(&manifest, resource_id)?; + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + reference_count = reference_count.saturating_add(1); + validate_runtime_region_reference(&manifest, reference)?; + } + } + } + if reference_count > MAX_DIRECT_CODEX_REFERENCES { + return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); + } + Ok(()) +} + +pub(crate) fn validate_resource_id_and_manifest( + manifest: &GameCreationAppManifest, + resource_id: &str, +) -> Result<(), String> { + let resource_id = resource_id.trim(); + if resource_id.is_empty() + || resource_id.chars().count() > 200 + || resource_id.chars().any(char::is_control) + { + return Err("引用的素材 ID 无效,请移除后重新选择".to_string()); + } + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == resource_id) + .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; + sanitize_attachment_local_path(&asset.local_path) + .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; + Ok(()) +} + +fn validate_runtime_region_reference( + manifest: &GameCreationAppManifest, + reference: &DirectCodexUserRuntimeRegionPart, +) -> Result<(), String> { + if reference.label.trim().is_empty() { + return Err("运行画面区域缺少名称".to_string()); + } + if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES { + return Err(format!( + "运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材" + )); + } + for resource_id in &reference.resource_ids { + validate_resource_id_and_manifest(manifest, resource_id)?; + } + Ok(()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs new file mode 100644 index 000000000..c2ab37897 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -0,0 +1,82 @@ +use super::model::{DirectCodexUserContentPart, DirectCodexUserItem}; +use super::validation::validate_direct_codex_user_item; +use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path}; +use serde_json::Value; +use std::path::Path; + +/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。 +/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 +pub(crate) fn direct_codex_user_item_to_wire_input( + root: &Path, + item: &DirectCodexUserItem, +) -> Result { + validate_direct_codex_user_item(root, item)?; + let manifest = read_manifest_for_project(root)?; + let DirectCodexUserItem::Message(message) = item; + let mut input = Vec::with_capacity(message.content.len()); + for part in &message.content { + let text = match part { + DirectCodexUserContentPart::InputText { text } => text.clone(), + DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == resource_id.trim()) + .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; + let path = sanitize_attachment_local_path(&asset.local_path) + .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; + format!( + "[素材引用 resourceId={};项目路径={path}]", + resource_id.trim() + ) + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + let resources = reference + .resource_ids + .iter() + .map(|id| id.trim()) + .collect::>() + .join(","); + let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim()); + if let Some(run_id) = reference.run_id.as_deref() { + summary.push_str(&format!("运行标识={} ", run_id.trim())); + } + if let Some(role) = reference.element_role.as_deref() { + summary.push_str(&format!("角色={} ", role.trim())); + } + if let Some(text) = reference.text.as_deref() { + summary.push_str(&format!("文本={} ", text.trim())); + } + if !resources.is_empty() { + summary.push_str(&format!("关联素材={resources}")); + } + summary.push(']'); + summary + } + }; + input.push(serde_json::json!({ "type": "text", "text": text })); + } + Ok(Value::Array(input)) +} + +pub(crate) fn direct_codex_user_item_to_prompt( + root: &Path, + item: &DirectCodexUserItem, +) -> Result { + let wire = direct_codex_user_item_to_wire_input(root, item)?; + wire.as_array() + .ok_or_else(|| "DirectProject user item wire input 不是数组".to_string()) + .map(|parts| { + parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::() + }) + .and_then(|prompt| { + if prompt.trim().is_empty() { + Err("DirectProject user item 不能转换为空 prompt".to_string()) + } else { + Ok(prompt) + } + }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs similarity index 99% rename from apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs rename to apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 2e4cdad61..d72be32c5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -6,6 +6,9 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; +mod user_input; +pub(crate) use user_input::chat_with_game_creator_direct_codex; + const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; @@ -4055,6 +4058,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( creation_type, None, None, + None, ) .await } @@ -4065,6 +4069,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( creation_type: Option<&str>, turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, audit: Option<&mut DirectCodexTurnAudit>, + direct_user_item: Option, ) -> Result { if !root.is_absolute() || !root.is_dir() { return Err("当前项目目录不存在或不是绝对路径".to_string()); @@ -4080,7 +4085,15 @@ 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, audit).await + match run_direct_game_creator_turn_inner( + root, + prompt, + creation_type, + turn_emitter, + audit, + direct_user_item, + ) + .await { Ok(reply) => Ok(reply), Err(failure) => { @@ -4099,6 +4112,7 @@ async fn run_direct_game_creator_turn_inner( creation_type: Option<&str>, turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, audit: Option<&mut DirectCodexTurnAudit>, + direct_user_item: Option, ) -> Result { emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); if let Some(emitter) = turn_emitter { @@ -4152,6 +4166,7 @@ async fn run_direct_game_creator_turn_inner( Some(&client_turn_id), Some(&mut observer), audit, + direct_user_item.clone(), ) .await } else { @@ -4162,6 +4177,7 @@ async fn run_direct_game_creator_turn_inner( None, None, audit, + direct_user_item.clone(), ) .await } @@ -4453,95 +4469,6 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( )) } -fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result { - let Some(client_turn_id) = client_turn_id else { - return Err("Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份".to_string()); - }; - let client_turn_id = client_turn_id.trim(); - let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS) - .contains(&client_turn_id.len()); - let mut bytes = client_turn_id.bytes(); - let valid_first = bytes - .next() - .is_some_and(|byte| byte.is_ascii_alphanumeric()); - let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); - if !valid_length || !valid_first || !valid_rest { - return Err(format!( - "clientTurnId 必须为 {MIN_DIRECT_CLIENT_TURN_ID_CHARS} 到 {MAX_DIRECT_CLIENT_TURN_ID_CHARS} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" - )); - } - Ok(client_turn_id.to_string()) -} - -#[tauri::command] -pub(crate) async fn chat_with_game_creator_direct_codex( - project_path: String, - prompt: String, - creation_type: Option, - client_turn_id: Option, - attachments: Option>, - references: Option>, -) -> Result { - let root = Path::new(project_path.trim()); - let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; - let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; - recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| { - redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500) - })?; - let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); - let mut audit = DirectCodexTurnAudit::start( - root, - &turn_id, - &prompt, - attachments.as_deref().unwrap_or_default(), - ); - let attachments = attachments.unwrap_or_default(); - let references = references.unwrap_or_default(); - let mut user_prompt = match render_direct_codex_user_prompt(&prompt, &attachments) { - Ok(prompt) => prompt, - Err(_) if !references.is_empty() && prompt.trim().is_empty() => String::new(), - Err(error) => { - audit.finish(false); - return Err(error); - } - }; - if let Some(reference_section) = match render_direct_codex_references_section(root, &references) - { - Ok(section) => section, - Err(error) => { - audit.finish(false); - return Err(error); - } - } { - if !user_prompt.trim().is_empty() { - user_prompt.push_str("\n\n"); - } - user_prompt.push_str(&reference_section); - } - if user_prompt.trim().is_empty() { - audit.finish(false); - return Err("聊天内容不能为空".to_string()); - } - 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 - { - Ok(reply) => reply, - Err(error) => { - audit.finish(false); - return Err(error); - } - }; - audit.finish(true); - turn_emitter.emit("completed", Some("none"), Some(reply.clone())); - Ok(reply) -} - #[tauri::command] pub(crate) async fn chat_with_game_creator_home_direct_codex( prompt: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs new file mode 100644 index 000000000..4188bfd8f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -0,0 +1,84 @@ +//! DirectProject 用户输入命令适配器。 +//! +//! Tauri 只在这里接收前端 item,校验与 canonical→prompt 投影交给 user-item +//! 深模块,回合编排仍由父模块负责。 + +use super::*; + +fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result { + let Some(client_turn_id) = client_turn_id else { + return Err("Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份".to_string()); + }; + let client_turn_id = client_turn_id.trim(); + let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS) + .contains(&client_turn_id.len()); + let mut bytes = client_turn_id.bytes(); + let valid_first = bytes + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric()); + let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); + if !valid_length || !valid_first || !valid_rest { + return Err(format!( + "clientTurnId 必须为 {MIN_DIRECT_CLIENT_TURN_ID_CHARS} 到 {MAX_DIRECT_CLIENT_TURN_ID_CHARS} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" + )); + } + Ok(client_turn_id.to_string()) +} + +#[tauri::command] +pub(crate) async fn chat_with_game_creator_direct_codex( + project_path: String, + prompt: String, + user_item: DirectCodexUserItem, + creation_type: Option, + client_turn_id: Option, + attachments: Option>, +) -> Result { + let root = Path::new(project_path.trim()); + let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; + let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; + recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| { + redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500) + })?; + let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); + let mut audit = DirectCodexTurnAudit::start( + root, + &turn_id, + &prompt, + attachments.as_deref().unwrap_or_default(), + ); + let attachments = attachments.unwrap_or_default(); + validate_direct_codex_user_item(root, &user_item).map_err(|error| { + audit.finish(false); + error + })?; + let user_prompt = direct_codex_user_item_to_prompt(root, &user_item).map_err(|error| { + audit.finish(false); + error + })?; + if user_prompt.trim().is_empty() { + audit.finish(false); + return Err("聊天内容不能为空".to_string()); + } + let canonical_user_item = + Some(serde_json::to_value(user_item).map_err(|error| error.to_string())?); + 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), + canonical_user_item, + ) + .await + { + Ok(reply) => reply, + Err(error) => { + audit.finish(false); + return Err(error); + } + }; + audit.finish(true); + turn_emitter.emit("completed", Some("none"), Some(reply.clone())); + Ok(reply) +} -- 2.52.0 From 5ec40c8b83e2efc7b778562080bc949e6d6ea0dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 15:22:39 +0800 Subject: [PATCH 03/92] =?UTF-8?q?=E6=8A=BD=E7=A6=BB=20DirectProject=20?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E8=A7=A3=E6=9E=90=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将项目 canonical identity 与 digest 放入独立文件 保持 Codex app-server 线程池调用接口不变 --- .../direct_project_identity.rs | 50 +++++++++++++++++++ .../src/agent/codex_app_server/mod.rs | 48 +----------------- 2 files changed, 52 insertions(+), 46 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs new file mode 100644 index 000000000..4454a2a48 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs @@ -0,0 +1,50 @@ +//! DirectProject 线程池身份的 canonical 解析与摘要。 + +use super::super::*; +use sha2::{Digest, Sha256}; + +pub(super) fn direct_codex_canonical_project_identity( + root: &std::path::Path, +) -> Result<(std::path::PathBuf, String), String> { + let (canonical_root, _) = resolve_direct_codex_project_authority(root)?; + let manifest = read_manifest(&canonical_root.join(".agent/manifest.json")) + .map_err(|error| format!("读取 DirectProject 权威项目身份失败:{error}"))?; + let manifest_project_id = manifest.project_id.trim(); + if manifest_project_id.is_empty() || manifest_project_id.chars().count() > 256 { + return Err("DirectProject manifest.projectId 不满足身份边界".to_string()); + } + let path_identity = direct_codex_os_path_identity_bytes(&canonical_root); + Ok(( + canonical_root, + direct_codex_project_identity_digest(&path_identity, manifest_project_id.as_bytes()), + )) +} + +fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + return path.as_os_str().as_bytes().to_vec(); + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + let mut bytes = Vec::new(); + for unit in path.as_os_str().encode_wide() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + return bytes; + } + #[cfg(not(any(unix, windows)))] + path.as_os_str().to_string_lossy().as_bytes().to_vec() +} + +fn direct_codex_project_identity_digest(path_identity: &[u8], project_id: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(b"genarrative-direct-project-identity.v1\0"); + digest.update((path_identity.len() as u64).to_le_bytes()); + digest.update(path_identity); + digest.update((project_id.len() as u64).to_le_bytes()); + digest.update(project_id); + format!("{:x}", digest.finalize()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 9259eaf48..16221300a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -12,6 +12,8 @@ use uuid::Uuid; mod direct_project_history_wire; use direct_project_history_wire::build_direct_project_history_injection_params; +mod direct_project_identity; +use direct_project_identity::*; const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc"; const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY"; @@ -3733,52 +3735,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( .await } -fn direct_codex_canonical_project_identity( - root: &std::path::Path, -) -> Result<(std::path::PathBuf, String), String> { - let (canonical_root, _) = resolve_direct_codex_project_authority(root)?; - let manifest = read_manifest(&canonical_root.join(".agent/manifest.json")) - .map_err(|error| format!("读取 DirectProject 权威项目身份失败:{error}"))?; - let manifest_project_id = manifest.project_id.trim(); - if manifest_project_id.is_empty() || manifest_project_id.chars().count() > 256 { - return Err("DirectProject manifest.projectId 不满足身份边界".to_string()); - } - let path_identity = direct_codex_os_path_identity_bytes(&canonical_root); - Ok(( - canonical_root, - direct_codex_project_identity_digest(&path_identity, manifest_project_id.as_bytes()), - )) -} - -fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec { - #[cfg(unix)] - { - use std::os::unix::ffi::OsStrExt; - return path.as_os_str().as_bytes().to_vec(); - } - #[cfg(windows)] - { - use std::os::windows::ffi::OsStrExt; - let mut bytes = Vec::new(); - for unit in path.as_os_str().encode_wide() { - bytes.extend_from_slice(&unit.to_le_bytes()); - } - return bytes; - } - #[cfg(not(any(unix, windows)))] - path.as_os_str().to_string_lossy().as_bytes().to_vec() -} - -fn direct_codex_project_identity_digest(path_identity: &[u8], project_id: &[u8]) -> String { - let mut digest = Sha256::new(); - digest.update(b"genarrative-direct-project-identity.v1\0"); - digest.update((path_identity.len() as u64).to_le_bytes()); - digest.update(path_identity); - digest.update((project_id.len() as u64).to_le_bytes()); - digest.update(project_id); - format!("{:x}", digest.finalize()) -} - pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( root: &std::path::Path, system_prompt: String, -- 2.52.0 From 7e35d7c344443271243127e4095e5e51f10f9561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 15:42:27 +0800 Subject: [PATCH 04/92] =?UTF-8?q?=E4=B8=A5=E6=A0=BC=E4=BD=BF=E7=94=A8=20Di?= =?UTF-8?q?rectProject=20Response=20item=20=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 game-creator-conversation.v1 读取 fallback 历史注入前逐项投影 canonical user item 为 Codex message 无效 item 在持久化前失败关闭 --- .../direct_project_history_wire.rs | 9 +- .../src/agent/codex_app_server/mod.rs | 6 +- .../src/agent/direct_codex_user_item/mod.rs | 5 +- .../src/agent/direct_codex_user_item/wire.rs | 30 +++ .../src/agent/direct_project_history.rs | 240 ++---------------- 5 files changed, 66 insertions(+), 224 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs index b958bd982..627002154 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs @@ -11,8 +11,15 @@ pub(super) fn build_direct_project_history_injection_params( history_root: &Path, thread_id: &str, ) -> Result { - let items = read_direct_project_history_items_at(history_root) + let canonical_items = read_direct_project_history_items_at(history_root) .map_err(platform_llm::LlmError::InvalidRequest)?; + let items = canonical_items + .iter() + .map(|item| { + direct_codex_user_item_to_response_item(history_root, item) + .map_err(platform_llm::LlmError::InvalidRequest) + }) + .collect::, _>>()?; let params = serde_json::json!({"threadId": thread_id, "items": items}); let payload_bytes = serde_json::to_vec(¶ms) .map(|bytes| bytes.len().saturating_add(1)) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 16221300a..17f96f93f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -2657,7 +2657,11 @@ impl CodexAppServerConnection { } if let Some(client_turn_id) = direct_client_turn_id { let user_item = match direct_user_item { - Some(item) => item.clone(), + Some(item) => { + direct_codex_user_item_to_response_item(history_root, item) + .map_err(platform_llm::LlmError::InvalidRequest)?; + item.clone() + } None => direct_project_local_message_item( "user", current_prompt, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs index d591f4dad..d5ccc94c4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs @@ -9,4 +9,7 @@ pub(crate) use model::{ DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, }; pub(crate) use validation::validate_direct_codex_user_item; -pub(crate) use wire::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_wire_input}; +pub(crate) use wire::{ + direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item, + direct_codex_user_item_to_wire_input, +}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index c2ab37897..1a07d2c95 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -4,6 +4,36 @@ use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path}; use serde_json::Value; use std::path::Path; +/// 将历史中的 canonical user item 投影为 Codex `response_item` message。 +/// 非 user message 的标准 Response item 原样返回;未知形状直接失败。 +pub(crate) fn direct_codex_user_item_to_response_item( + root: &Path, + item: &Value, +) -> Result { + let is_user_message = item.get("type").and_then(Value::as_str) == Some("message") + && item.get("role").and_then(Value::as_str) == Some("user"); + if !is_user_message { + if item.get("type").and_then(Value::as_str).is_some() { + return Ok(item.clone()); + } + return Err("DirectProject 历史 item 缺少 type,无法投影为 Codex item".to_string()); + } + let canonical: DirectCodexUserItem = serde_json::from_value(item.clone()) + .map_err(|error| format!("DirectProject user item 无法转换为 Codex item:{error}"))?; + let Value::Array(content) = direct_codex_user_item_to_wire_input(root, &canonical)? else { + return Err("DirectProject user item wire content 不是数组".to_string()); + }; + let mut projected = serde_json::json!({ + "type": "message", + "role": "user", + "content": content, + }); + if let Some(id) = item.get("id").and_then(Value::as_str) { + projected["id"] = Value::String(id.to_string()); + } + Ok(projected) +} + /// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。 /// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 pub(crate) fn direct_codex_user_item_to_wire_input( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index df80cc7ca..086a9b1f2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -17,9 +17,6 @@ const DIRECT_PROJECT_HISTORY_SCAN_PROBE_FINISHED: usize = 1; /// 写入侧与读取侧共用同一个信封类型:`project.jsonl` 由项目主对话与 DirectProject 共享, /// 这个值一旦只在写入侧改动,读取侧就会把对方的行当成坏行,整份历史立刻读不出来。 pub(crate) const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item"; -/// 格式切换到 `response_item`(#282)之前,DirectProject 主对话通过通用对话写入器 -/// 落到同一份 `project.jsonl`,行形状是 `PersistedLocalConversationMessageRecord`。 -const DIRECT_PROJECT_HISTORY_LEGACY_SCHEMA_VERSION: &str = "game-creator-conversation.v1"; const DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS: &[&str] = &[ "host_skills.instructions", "permissions.instructions", @@ -180,79 +177,22 @@ fn direct_project_history_item_from_line( Ok(Some(item)) } -/// 只接受两种行信封:`response_item`,以及白名单化的 legacy 行。返回 `None` 表示行已 -/// 被识别、但不该进入 Codex 上下文(legacy 的 `tool` 行)。 -/// 其余任何形状(含换了 `schemaVersion`、带 `type` 却不是 `response_item`、role 不在 -/// legacy 写入器自己的角色集合内、content 不是非空字符串)都判定为损坏并失败关闭。 +/// 只接受 `response_item` 行信封;其它历史格式不提供迁移或 fallback,直接失败关闭。 fn direct_project_history_item_from_parsed_line( path: &Path, parsed: &Value, ) -> Result, String> { - if parsed.get("type").and_then(Value::as_str) == Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) { - return parsed - .get("payload") - .cloned() - .map(Some) - .ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display())); + if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) { + return Err(format!( + "DirectProject 历史记录类型无效:{}", + path.display() + )); } - direct_project_legacy_row(parsed) - .map(|row| row.message_item()) - .ok_or_else(|| format!("DirectProject 历史记录类型无效:{}", path.display())) -} - -/// 白名单化 legacy 行的投影结果。 -enum DirectProjectLegacyRow { - /// user/assistant 行:投影成 message item 进入历史。 - Message(Value), - /// 已识别但不可注入的行(`tool`):与 developer/system item 同样处理,不进 Codex - /// 上下文。legacy 行不是 Responses item,`tool` 行无法还原成真正的工具 item, - /// 注入会造出假的工具消息;聊天投影本来也只展示 user/assistant。 - NotChat, -} - -impl DirectProjectLegacyRow { - fn message_item(self) -> Option { - match self { - DirectProjectLegacyRow::Message(item) => Some(item), - DirectProjectLegacyRow::NotChat => None, - } - } -} - -/// 格式切换前的 legacy 行投影。存量用户项目的历史文件全是这种行,读取时投影成与 -/// `direct_project_local_message_item` 同形状的 message item,`role` 与 `content` -/// 原样保留(不 trim、不改写、不合并),未知字段忽略。 -/// -/// 角色白名单取的是 legacy 写入器自己的角色集合,也就是 -/// `project/conversation.rs` 里 `matches!(role, "user" | "assistant" | "tool")` 这一 -/// 条校验,所以「legacy 写入器能写出的行」被完整覆盖,不会有第三种角色漏进来; -/// 白名单之外的角色(手改文件、未来写入器)仍按损坏失败关闭。 -fn direct_project_legacy_row(parsed: &Value) -> Option { - let object = parsed.as_object()?; - if object.contains_key("type") - || object.get("schemaVersion").and_then(Value::as_str) - != Some(DIRECT_PROJECT_HISTORY_LEGACY_SCHEMA_VERSION) - { - return None; - } - let role = object.get("role").and_then(Value::as_str)?; - if !matches!(role, "user" | "assistant" | "tool") { - return None; - } - let content = object.get("content").and_then(Value::as_str)?; - if content.is_empty() { - return None; - } - if role == "tool" { - return Some(DirectProjectLegacyRow::NotChat); - } - let message_id = object - .get("messageId") - .and_then(Value::as_str) - .filter(|message_id| !message_id.is_empty()); - Some(DirectProjectLegacyRow::Message( - direct_project_message_item(role, content, message_id), - )) + parsed + .get("payload") + .cloned() + .map(Some) + .ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display())) } /// 测试专用探针:在"锁外幂等回扫"这一段的两端回调。 @@ -637,9 +577,8 @@ mod tests { std::fs::write(&path, format!("{}\n", lines.join("\n"))).expect("write history fixture"); } - const LEGACY_USER_ROW: &str = r#"{"schemaVersion":"game-creator-conversation.v1","role":"user","content":"请创建菜单","agentId":null,"messageId":"direct-codex:turn-0001:user","updatedAt":1757000000}"#; - const LEGACY_ASSISTANT_ROW: &str = r#"{"schemaVersion":"game-creator-conversation.v1","role":"assistant","content":"已完成 第一行\n第二行 ","agentId":null,"updatedAt":1757000001}"#; const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#; + const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#; /// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。 /// @@ -648,7 +587,7 @@ mod tests { #[test] fn contention_failure_is_retried_with_bounded_backoff() { let root = init_history_project("contention-retry"); - write_history_lines(root.path(), &[LEGACY_USER_ROW]); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); let marker = root .path() .join(".agent/runtime/test-fail-next-direct-project-history-append"); @@ -680,7 +619,7 @@ mod tests { #[test] fn contention_failure_beyond_the_backoff_budget_fails_closed() { let root = init_history_project("contention-bounded"); - write_history_lines(root.path(), &[LEGACY_USER_ROW]); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); let marker = root .path() .join(".agent/runtime/test-fail-next-direct-project-history-append"); @@ -723,7 +662,7 @@ mod tests { write_history_lines( root.path(), &[ - LEGACY_USER_ROW, + RESPONSE_ITEM_ROW, r#"{"schemaVersion":"game-creator"#, RESPONSE_ITEM_ROW, ], @@ -761,7 +700,7 @@ mod tests { let root = init_history_project("scan-outside-lock"); write_history_lines( root.path(), - &[LEGACY_USER_ROW, RESPONSE_ITEM_ROW, LEGACY_ASSISTANT_ROW], + &[RESPONSE_ITEM_ROW, RESPONSE_ITEM_ROW, RESPONSE_ASSISTANT_ROW], ); let path = history_path(root.path()); let fired = Arc::new(AtomicBool::new(false)); @@ -805,7 +744,7 @@ mod tests { #[test] fn concurrent_append_after_the_out_of_lock_scan_still_prevents_a_duplicate_row() { let root = init_history_project("scan-stale-state"); - write_history_lines(root.path(), &[LEGACY_USER_ROW]); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); let path = history_path(root.path()); let item = json!({ "type": "message", @@ -845,7 +784,7 @@ mod tests { #[test] fn conflicting_item_id_still_fails_closed_with_the_scan_outside_the_lock() { let root = init_history_project("id-conflict-outside-lock"); - write_history_lines(root.path(), &[LEGACY_USER_ROW]); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); append_direct_project_history_item_at( root.path(), &json!({ @@ -1033,91 +972,7 @@ mod tests { } #[test] - fn legacy_conversation_rows_project_into_responses_message_items() { - let root = init_history_project("legacy-projection"); - write_history_lines(root.path(), &[LEGACY_USER_ROW, LEGACY_ASSISTANT_ROW]); - - let items = read_direct_project_history_items_at(root.path()).expect("read legacy history"); - assert_eq!( - items, - vec![ - json!({ - "type": "message", - "role": "user", - "id": "direct-codex:turn-0001:user", - "content": [{"type": "input_text", "text": "请创建菜单"}], - }), - json!({ - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "已完成 第一行\n第二行 "}], - }), - ] - ); - - let chat = read_direct_project_chat_history_at(root.path()).expect("read legacy chat"); - assert_eq!(chat.messages.len(), 2); - assert_eq!(chat.messages[0].role, "user"); - assert_eq!(chat.messages[0].content, "请创建菜单"); - assert_eq!( - chat.messages[0].message_id.as_deref(), - Some("direct-codex:turn-0001:user") - ); - assert_eq!(chat.messages[1].role, "assistant"); - assert_eq!(chat.messages[1].content, "已完成 第一行\n第二行 "); - assert_eq!(chat.messages[1].message_id, None); - } - - #[test] - fn mixed_legacy_and_response_item_history_reads_in_file_order() { - let root = init_history_project("mixed-history"); - write_history_lines( - root.path(), - &[LEGACY_USER_ROW, RESPONSE_ITEM_ROW, LEGACY_ASSISTANT_ROW], - ); - - let items = read_direct_project_history_items_at(root.path()).expect("read mixed history"); - assert_eq!( - items - .iter() - .map(|item| item.get("id").and_then(Value::as_str)) - .collect::>(), - vec![ - Some("direct-codex:turn-0001:user"), - Some("codex-item-2"), - None - ] - ); - assert_eq!( - items[1], - json!({ - "type": "message", - "role": "user", - "id": "codex-item-2", - "content": [{"type": "input_text", "text": "再加一个按钮"}], - }) - ); - assert_eq!( - items[2]["content"][0]["text"], - json!("已完成 第一行\n第二行 ") - ); - - let chat = read_direct_project_chat_history_at(root.path()).expect("read mixed chat"); - assert_eq!( - chat.messages - .iter() - .map(|message| (message.role.as_str(), message.content.as_str())) - .collect::>(), - vec![ - ("user", "请创建菜单"), - ("user", "再加一个按钮"), - ("assistant", "已完成 第一行\n第二行 "), - ] - ); - } - - #[test] - fn legacy_compat_keeps_unrecognized_rows_failing_closed() { + fn non_response_item_history_fails_closed() { let unrecognized_rows = [ // 带 type 却不是 response_item r#"{"type":"message","role":"user","content":[{"type":"input_text","text":"x"}]}"#, @@ -1159,61 +1014,4 @@ mod tests { .expect_err("broken json must fail closed"); assert!(error.starts_with("解析 DirectProject 历史失败"), "{error}"); } - - #[test] - fn legacy_tool_row_is_recognized_but_stays_out_of_the_codex_context() { - let root = init_history_project("legacy-tool-row"); - write_history_lines( - root.path(), - &[ - LEGACY_USER_ROW, - r#"{"schemaVersion":"game-creator-conversation.v1","role":"tool","content":"{\"ok\":true}","agentId":null,"updatedAt":1757000002}"#, - LEGACY_ASSISTANT_ROW, - ], - ); - - let items = read_direct_project_history_items_at(root.path()).expect("read history"); - assert_eq!( - items - .iter() - .map(|item| item["role"].as_str().unwrap_or_default()) - .collect::>(), - vec!["user", "assistant"] - ); - - let chat = read_direct_project_chat_history_at(root.path()).expect("read chat"); - assert_eq!( - chat.messages - .iter() - .map(|message| message.role.as_str()) - .collect::>(), - vec!["user", "assistant"] - ); - } - - #[test] - fn generic_writer_rows_are_covered_by_the_legacy_whitelist() { - let root = init_history_project("generic-writer-shape"); - crate::append_local_conversation_message_at( - root.path(), - None, - crate::LocalConversationMessage { - role: "user".to_string(), - content: "通用写入器写的旧格式回合".to_string(), - agent_id: None, - }, - ) - .expect("append legacy row through the generic writer"); - - let items = - read_direct_project_history_items_at(root.path()).expect("read generic writer row"); - assert_eq!( - items, - vec![json!({ - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": "通用写入器写的旧格式回合"}], - })] - ); - } } -- 2.52.0 From 9e83f1d88cea5d41dc6af6422b72768e24bbf141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 15:55:15 +0800 Subject: [PATCH 05/92] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20Response=20item=20?= =?UTF-8?q?=E4=B8=A5=E6=A0=BC=E5=90=88=E5=90=8C=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拒绝资源引用额外身份字段与未知 content part 补充历史 item wire 投影边界测试 同步 DirectProject 历史文档为无 fallback 合同 --- .../src/agent/direct_codex_user_item/model.rs | 66 +++++++++++++++++-- .../src/agent/direct_codex_user_item/wire.rs | 31 +++++++++ ...ectProject Codex原始历史与异常恢复-2026-09-04.md | 12 ++-- 3 files changed, 97 insertions(+), 12 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs index f3837c648..b46336b66 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -3,7 +3,7 @@ use ts_rs::TS; /// DirectProject 本轮 user input 的唯一结构化入口。 #[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(tag = "type")] +#[serde(tag = "type", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) enum DirectCodexUserItem { #[serde(rename = "message")] @@ -11,7 +11,7 @@ pub(crate) enum DirectCodexUserItem { } #[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) struct DirectCodexUserMessageItem { pub(crate) role: DirectCodexUserRole, @@ -27,7 +27,7 @@ pub(crate) enum DirectCodexUserRole { } #[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(tag = "type")] +#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) enum DirectCodexUserContentPart { #[serde(rename = "input_text")] @@ -39,7 +39,7 @@ pub(crate) enum DirectCodexUserContentPart { } #[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) struct DirectCodexUserRuntimeRegionPart { pub(crate) label: String, @@ -62,8 +62,64 @@ pub(crate) struct DirectCodexUserRuntimeRegionPart { } #[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) struct DirectCodexUserMessageEnvelope { pub(crate) item: DirectCodexUserItem, } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn resource_reference_serializes_with_only_camel_case_resource_id() { + let item = DirectCodexUserItem::Message(DirectCodexUserMessageItem { + role: DirectCodexUserRole::User, + content: vec![DirectCodexUserContentPart::AgcResourceReference { + resource_id: "asset-hero".to_string(), + }], + id: "turn-1".to_string(), + }); + assert_eq!( + serde_json::to_value(item).expect("serialize user item"), + json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "agc_resource_reference", + "resourceId": "asset-hero" + }], + "id": "turn-1" + }) + ); + } + + #[test] + fn resource_reference_rejects_extra_identity_fields() { + let error = serde_json::from_value::(json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "agc_resource_reference", + "resourceId": "asset-hero", + "label": "主角" + }], + "id": "turn-1" + })) + .expect_err("label must not be accepted on resource reference"); + assert!(error.to_string().contains("unknown field"), "{error}"); + } + + #[test] + fn unknown_content_part_fails_closed() { + serde_json::from_value::(json!({ + "type": "message", + "role": "user", + "content": [{"type": "future_part", "value": "x"}], + "id": "turn-1" + })) + .expect_err("unknown content part must fail closed"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index 1a07d2c95..99d4262c4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -110,3 +110,34 @@ pub(crate) fn direct_codex_user_item_to_prompt( } }) } + +#[cfg(test)] +mod tests { + use super::direct_codex_user_item_to_response_item; + use serde_json::json; + use std::path::Path; + + #[test] + fn standard_response_item_passes_through_without_agc_private_parts() { + let item = json!({ + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "完成"}] + }); + assert_eq!( + direct_codex_user_item_to_response_item(Path::new("/unused"), &item) + .expect("assistant response item should pass through"), + item + ); + } + + #[test] + fn history_item_without_type_fails_closed() { + let error = direct_codex_user_item_to_response_item( + Path::new("/unused"), + &json!({"role": "assistant"}), + ) + .expect_err("history item without type must fail"); + assert!(error.contains("缺少 type"), "{error}"); + } +} diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index bf46784af..17bd7c6a3 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -16,16 +16,16 @@ DirectProject 只使用 `.agent/conversations/project.jsonl` 作为对话历史 {"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"你好"}]}} ``` -`payload` 必须是未经改写的 Responses item。Direct 回合不由浏览器预写用户 message;Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。显式的本地 user/assistant 补写只能通过受权限保护的 `append_direct_project_conversation_message` 命令完成。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存 delta/started 事件。 +`payload` 必须是未经改写的 Responses item。AGC 前端 user input 先以 canonical user message item 形式写入;发送给 app-server 前由 Rust 投影为 Codex 可接受的 `message` item,AGC 私有 content part 不会穿透到 wire。Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存 delta/started 事件。 DirectProject 自己的写侧只写新格式:格式切换(#282)时仍会写旧行的路径已收口——显式 Codex 返回只落在自己的 journal `.agent/conversations/codex-responses.jsonl`,不再投影进 `project.jsonl`。 -读侧白名单兼容旧 `{role,content}` 行:格式切换前,DirectProject 主对话由通用对话写入器落到同一份 `project.jsonl`,存量用户项目的历史文件整份都是这种行。读取时把**明确枚举的那一种**旧行形状(`schemaVersion=game-creator-conversation.v1`、无 `type`、role 在 legacy 写入器自己的角色集合 `user`/`assistant`/`tool` 内、content 为非空字符串)认下来:`user`/`assistant` 投影成与 `direct_project_local_message_item` 同形状的 message item,`role` 与 `content` 逐字节保留;`tool` 行已识别但不进 Codex 上下文(它不是 Responses item,无法还原成真正的工具 item,聊天投影本来也只展示 user/assistant),与 developer/system item 同样过滤。角色集合取的是 `project/conversation.rs` 里那条 `matches!(role, "user" | "assistant" | "tool")` 校验,所以「legacy 写入器能写出的行」被完整覆盖;白名单之外的角色、换了 `schemaVersion`、带 `type`、content 非字符串或为空、缺 `payload`、坏 JSON 仍按损坏失败关闭。这条兼容是只读的,不迁移、不改写历史文件。 +读取只接受 `response_item` envelope;旧 `game-creator-conversation.v1` 行不提供迁移或 fallback,直接失败关闭。未知或无法投影的 canonical item 在发送前失败,不能产生本轮新增历史。 ## 正常回合 1. 启动 `ephemeral: true` 线程,并启用 `experimentalRawEvents: true`。 -2. 新线程先把历史 item 数组一次注入;注入成功后执行新的 `turn/start`。本轮用户 item 只接受 Codex 回传的 `rawResponseItem/completed`,不由 AGC 预写。 +2. 新线程先把历史 item 数组逐项投影为 Codex 可接受 item 后一次注入;注入成功后执行新的 `turn/start`。本轮 canonical user item 在发送前完成同样的投影校验,再写入项目历史。 3. 收到 `rawResponseItem/completed` 后立即追加其 `params.item` 并 flush。 4. 正常 `turn/completed: completed` 不生成额外记录。 @@ -55,12 +55,10 @@ Codex 启动时注入的 `host_skills.instructions`、`permissions.instructions` DirectProject 的浏览器层只负责显示和乐观状态,不再调用通用对话写入器。历史读写与回合累计分别位于 `agent/direct_project_history.rs` 和 `agent/direct_project_turn_history.rs`。 -`project.jsonl` 不是 DirectProject 独享的写入方:通用对话链(`project/conversation.rs`)把「项目主对话」(`agent_id=None`)映射到同一份文件,非 DirectProject 模式(`codex_cli`/`provider`)的项目对话、以及 Agent Runtime 的项目级公开状态消息(`agent/runtime_state.rs` 的 public status 写入点)都由它追加 `game-creator-conversation.v1` 行。两条链的行形状不同但**互读兼容**:DirectProject 侧投影旧行(见上),通用对话侧跳过 `type=response_item` 且带 `payload` 的行(不把它二次投影成自己的记录,DirectProject 侧已经拥有那份投影),其余坏行两侧都失败关闭。因此同一份文件里出现两种行不会让任何一侧失败。 - -这里**故意不给通用对话写入器加「文件已属于 DirectProject 就拒绝追加」的硬报错**:这些写入点不是尽力而为的旁路——`agent/runtime_driver/task_start.rs` 在 `ensure_game_creator_agent_runtime_accepted_public_status_at` 返回 `Err` 时会直接中止本次后台任务(「后台任务启动确认落盘失败,任务未执行」),`agent/runtime_protocol/steering.rs` 的三处调用也用 `?` 上抛。加硬报错会把「旧行噪声」换成「任务起不来」,比它要解决的问题更糟;而毒化本身已经不可能发生——legacy 写入器的行形状与角色集合都被 `conversation.rs` 的校验穷举,全部落在 DirectProject 的读侧白名单内。 +`project.jsonl` 的 DirectProject 现行合同只允许 `response_item` envelope。其它模式产生的旧 conversation 行不属于本合同,不得注入 DirectProject。 ## 写入与损坏边界 -写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;白名单化的旧行投影成 message item;其余中间坏行直接失败。兼容只发生在读取侧,不对旧格式做数据迁移或改写。 +写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;非 `response_item` 行和无法投影的 item 直接失败,不做数据迁移或 fallback。 该失败有专门恢复提示,并按不可重试处理:同一份历史文件每次读都会得到同一结论,重试不会改变结果,因此不会向用户显示「可直接重试」。 -- 2.52.0 From 7d5b9071e7842e8806f4a740e6aa3ef1daa77213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 16:01:45 +0800 Subject: [PATCH 06/92] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=97=A7=20DirectProje?= =?UTF-8?q?ct=20=E5=BC=95=E7=94=A8=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 DirectCodexTurnReference 平行 DTO 与渲染路径 将引用数量上限收回 canonical user item 校验模块 --- .../src-tauri/src/agent.rs | 2 - .../src/agent/direct_codex_references.rs | 382 ------------------ .../direct_codex_user_item/validation.rs | 3 +- 3 files changed, 2 insertions(+), 385 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 53d1f1db4..35377ee7d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -16,7 +16,6 @@ mod design_runtime; mod design_tools; mod direct_codex_attachments; mod direct_codex_audit; -mod direct_codex_references; mod direct_codex_user_item; mod direct_project_history; mod direct_project_turn_history; @@ -45,7 +44,6 @@ pub(crate) use codex_provider_proxy::*; pub(crate) use design_runtime::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_audit::*; -pub(crate) use direct_codex_references::*; pub(crate) use direct_codex_user_item::*; pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs deleted file mode 100644 index f1b78b1ca..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs +++ /dev/null @@ -1,382 +0,0 @@ -use super::*; - -pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32; -const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200; -const MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS: usize = 160; -const MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS: usize = 32; -const MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS: usize = 80; -const MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS: usize = 240; - -const DIRECT_CODEX_REFERENCE_HEADER: &str = - "[本轮用户引用素材:以下均为当前项目已确认的安全引用。请使用稳定资源 ID 和项目相对路径读取,不要读取或输出其它路径。]"; - -#[derive(Clone, Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase", tag = "type")] -pub(crate) enum DirectCodexTurnReference { - #[serde(rename = "resource")] - Resource(DirectCodexResourceReference), - #[serde(rename = "runtime-region")] - RuntimeRegion(DirectCodexRuntimeRegionReference), -} - -#[derive(Clone, Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectCodexResourceReference { - pub(crate) resource_id: String, - #[serde(default)] - pub(crate) label: Option, - #[serde(default)] - pub(crate) source: Option, -} - -#[derive(Clone, Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectCodexRuntimeRegionReference { - #[serde(default)] - pub(crate) label: Option, - #[serde(default)] - pub(crate) run_id: Option, - #[serde(default)] - pub(crate) version_id: Option, - #[serde(default)] - pub(crate) element_tag: Option, - #[serde(default)] - pub(crate) element_role: Option, - #[serde(default)] - pub(crate) text: Option, - #[serde(default)] - pub(crate) width: Option, - #[serde(default)] - pub(crate) height: Option, - #[serde(default)] - pub(crate) resource_ids: Vec, -} - -fn sanitize_reference_text(value: &str, max_chars: usize) -> Option { - let sanitized = value - .trim() - .chars() - .filter(|character| !character.is_control()) - .take(max_chars) - .collect::(); - if sanitized.is_empty() { - None - } else { - Some(sanitized) - } -} - -fn sanitize_reference_label(value: Option<&str>) -> Option { - value.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS)) -} - -fn sanitize_reference_source(value: Option<&str>) -> Option { - let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS)?; - if value - .chars() - .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')) - { - Some(value) - } else { - None - } -} - -fn sanitize_reference_element(value: Option<&str>) -> Option { - let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS)?; - if value - .chars() - .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')) - { - Some(value) - } else { - None - } -} - -fn sanitize_reference_dimension(value: Option) -> Option { - value - .filter(|value| value.is_finite() && *value > 0.0) - .map(|value| value.round().clamp(1.0, 100_000.0) as u32) -} - -fn asset_display_label(asset: &GameCreationAppAssetManifestEntry) -> String { - let basename = asset - .local_path - .rsplit(['/', '\\']) - .next() - .unwrap_or(&asset.id); - let without_extension = basename - .rsplit_once('.') - .map(|(name, _)| name) - .unwrap_or(basename) - .trim(); - if without_extension.is_empty() { - asset.id.clone() - } else { - without_extension - .chars() - .filter(|character| !character.is_control()) - .take(MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS) - .collect() - } -} - -fn validate_resource_reference_id(value: &str) -> Result { - let resource_id = value.trim(); - if resource_id.is_empty() - || resource_id.chars().count() > MAX_DIRECT_CODEX_REFERENCE_ID_CHARS - || resource_id.chars().any(char::is_control) - { - return Err("引用的素材 ID 无效,请移除后重新选择".to_string()); - } - Ok(resource_id.to_string()) -} - -fn render_resource_reference_line( - manifest: &GameCreationAppManifest, - reference: &DirectCodexResourceReference, -) -> Result { - let resource_id = validate_resource_reference_id(&reference.resource_id)?; - let asset = manifest - .assets - .iter() - .find(|asset| asset.id == resource_id) - .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; - let local_path = sanitize_attachment_local_path(&asset.local_path) - .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; - let label = sanitize_reference_label(reference.label.as_deref()) - .unwrap_or_else(|| asset_display_label(asset)); - let source = sanitize_reference_source(reference.source.as_deref()) - .unwrap_or_else(|| "unknown".to_string()); - Ok(format!( - "- 素材 ID:{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}", - asset.kind, asset.media_type - )) -} - -fn render_runtime_region_reference_line( - manifest: &GameCreationAppManifest, - reference: &DirectCodexRuntimeRegionReference, -) -> Result { - let label = sanitize_reference_label(reference.label.as_deref()) - .unwrap_or_else(|| "运行画面区域".to_string()); - let run_id = sanitize_reference_source(reference.run_id.as_deref()); - let version_id = sanitize_reference_source(reference.version_id.as_deref()); - let element_tag = sanitize_reference_element(reference.element_tag.as_deref()); - let element_role = sanitize_reference_element(reference.element_role.as_deref()); - let text = reference - .text - .as_deref() - .and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS)); - let width = sanitize_reference_dimension(reference.width); - let height = sanitize_reference_dimension(reference.height); - - // `resourceIds` 是本模块唯一由客户端直接给出、且自身还是一条列表的字段:条数不设界时, - // 每个 id 都要扫一遍 manifest(O(assets)),注入提示词的 `关联素材 ID:…` 行也会跟着无界 - // 变长(最终只被 32 MiB 写入护栏拦下,变成一条和原因无关的连接级错误)。这里按模块的 - // 失败关闭口径直接拒绝超限,而不是静默丢掉用户选中的关联。 - if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES { - return Err(format!( - "运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材,请重新点选" - )); - } - let mut related_resource_ids = Vec::new(); - for resource_id in &reference.resource_ids { - let resource_id = validate_resource_reference_id(resource_id)?; - if !manifest.assets.iter().any(|asset| asset.id == resource_id) { - return Err("运行画面引用的素材已变化,请重新点选".to_string()); - } - // 去重:同一个 id 在注入提示词里重复出现没有信息量,只是把行撑长。 - // 条数已按上限收口,所以这里的逐项比较不会退化成大面积二次扫描。 - if !related_resource_ids.contains(&resource_id) { - related_resource_ids.push(resource_id); - } - } - - let mut parts = vec![format!("名称:{label}")]; - if let Some(run_id) = run_id { - parts.push(format!("运行标识:{run_id}")); - } - if let Some(version_id) = version_id { - parts.push(format!("版本标识:{version_id}")); - } - if let Some(element_tag) = element_tag { - parts.push(format!("元素:{element_tag}")); - } - if let Some(element_role) = element_role { - parts.push(format!("角色:{element_role}")); - } - if let Some(text) = text { - parts.push(format!("文本摘要:{text}")); - } - if let (Some(width), Some(height)) = (width, height) { - parts.push(format!("尺寸:{width}x{height}")); - } - if !related_resource_ids.is_empty() { - parts.push(format!("关联素材 ID:{}", related_resource_ids.join(","))); - } - Ok(format!("- 运行画面区域:{}", parts.join(";"))) -} - -pub(crate) fn render_direct_codex_references_section( - root: &Path, - references: &[DirectCodexTurnReference], -) -> Result, String> { - if references.is_empty() { - return Ok(None); - } - if references.len() > MAX_DIRECT_CODEX_REFERENCES { - return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); - } - let manifest = read_manifest_for_project(root)?; - let mut lines = Vec::with_capacity(references.len()); - for reference in references { - lines.push(match reference { - DirectCodexTurnReference::Resource(reference) => { - render_resource_reference_line(&manifest, reference)? - } - DirectCodexTurnReference::RuntimeRegion(reference) => { - render_runtime_region_reference_line(&manifest, reference)? - } - }); - } - Ok(Some( - std::iter::once(DIRECT_CODEX_REFERENCE_HEADER.to_string()) - .chain(lines) - .collect::>() - .join("\n"), - )) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn fixture_project() -> tempfile::TempDir { - let directory = tempfile::tempdir().expect("temp project"); - let root = directory.path(); - std::fs::create_dir_all(root.join(".agent")).expect("create agent dir"); - let mut manifest = new_game_creation_app_manifest("project-1", "测试项目"); - manifest.assets.push(GameCreationAppAssetManifestEntry { - id: "asset-hero".to_string(), - kind: "character".to_string(), - media_type: "image/png".to_string(), - local_path: "assets/hero.png".to_string(), - source: GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Uploaded, - canvas_project_id: None, - resource_id: None, - asset_object_id: None, - task_id: None, - prompt: None, - model: None, - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - image_sequence_frames: None, - image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind("character"), - tags: Vec::new(), - }); - write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("write manifest"); - directory - } - - #[test] - fn resource_reference_uses_manifest_identity_and_never_accepts_client_paths() { - let project = fixture_project(); - let reference: DirectCodexTurnReference = serde_json::from_str( - r#"{"type":"resource","resourceId":"asset-hero","label":"主角","source":"asset-picker","localPath":"C:\\secret.png"}"#, - ) - .expect("reference json"); - let section = render_direct_codex_references_section( - project.path(), - std::slice::from_ref(&reference), - ) - .expect("render") - .expect("section"); - assert!(section.contains("素材 ID:asset-hero")); - assert!(section.contains("名称:主角")); - assert!(section.contains("项目路径:assets/hero.png")); - assert!(!section.contains("C:\\secret.png")); - } - - #[test] - fn deleted_resource_fails_closed() { - let project = fixture_project(); - let reference: DirectCodexTurnReference = serde_json::from_str( - r#"{"type":"resource","resourceId":"asset-missing","label":"不存在"}"#, - ) - .expect("reference json"); - let error = render_direct_codex_references_section( - project.path(), - std::slice::from_ref(&reference), - ) - .expect_err("missing resource"); - assert_eq!(error, "引用的素材已不存在,请移除后重新选择"); - } - - #[test] - fn runtime_region_keeps_only_safe_summary_and_existing_resource_ids() { - let project = fixture_project(); - let reference: DirectCodexTurnReference = serde_json::from_str( - r#"{"type":"runtime-region","label":"开始按钮","runId":"run-1","elementTag":"button","elementRole":"button","text":"开始游戏","width":120.4,"height":40.2,"resourceIds":["asset-hero"],"html":" { - onChange(next); - setDraft(next); - }} + ref={composerRef} + value={null} + initialDraft={{ text: '原始需求', references: [reference] }} + onChange={onChange} assets={assets} projectPath="C:/project" ariaLabel="聊天" @@ -228,10 +224,9 @@ describe('ResourceReferenceInput', () => { await settleComposer(); await settleComposer(); - // 重建必须收敛,而且不许把「重建后的编辑器内容」当成一次用户编辑回抛给宿主: - // 旧实现用 props 覆写 lastEmittedDraftRef,读回来的文本里多出的 `@显示名` - // 会触发下一轮重建,文本一轮轮变长(渲染循环)。 - expect(onChange).not.toHaveBeenCalled(); + // 这次替换是对唯一 EditorState 的明确编辑动作,只产生一次派生快照, + // 不会因为 props 回写而重复触发。 + expect(onChange).toHaveBeenCalledTimes(1); const editorText = document.querySelector('.resource-reference-input-editor')?.textContent ?? ''; @@ -766,10 +761,12 @@ describe('ResourceReferenceInput', () => { test('restores a cross-session draft with the caret at the end of the text', async () => { const user = userEvent.setup(); const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); - const { rerender } = render( + const composerRef = createRef(); + render( { />, ); - // 切换 / 重开会话:外部草稿被整体替换。 - rerender( - , - ); + // 切换 / 重开会话:通过输入区 handle 替换 Lexical 唯一状态。 + composerRef.current?.replaceText('恢复出来的草稿'); await user.click(screen.getByRole('button', { name: '插入素材引用' })); await user.click(screen.getByRole('option', { name: /hero/u })); diff --git a/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md b/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md index ad2f53f07..92604ff51 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md +++ b/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md @@ -9,7 +9,8 @@ ## 修改边界 - 允许修改:AGC 壳 Rust agent 输入合同、DirectProject 历史适配、前端聊天引用模型、ts-rs 生成配置、当前聊天素材文档。 -- 明确不修改:assistant 返回协议、工具 activity、附件/图片协议、SpacetimeDB、HTTP API。 +- 明确不修改:assistant 返回协议、工具 activity、SpacetimeDB、HTTP API。 +- 本轮补齐现有附件/图片 sidecar 在 DirectProject prompt 的消费;不新增附件 content-part,也不改附件 DTO。 ## 实现顺序 diff --git a/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md b/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md index bcf6efc68..7d3ae4f63 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md +++ b/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md @@ -19,13 +19,13 @@ - `agc_runtime_region_reference` 保留运行区域语义摘要。 - Rust 在持久化前完成白名单、manifest 与路径校验。 - 现有标准 `response_item` 原样兼容;legacy conversation 行不提供 fallback。 -- 保持 assistant 返回、工具 activity、附件/图片协议不变。 +- 保持 assistant 返回、工具 activity、附件/图片 DTO 不变;DirectProject 消费已有 sidecar 的路径映射。 ## 不在范围内 - assistant item 前端投影或 Tauri 返回值改造。 - 工具 item、reasoning、file change、MCP item 的 UI 模型化。 -- 附件/图片 content part。 +- 附件/图片不进入 canonical content part;沿用现有 sidecar DTO,并在 DirectProject prompt 末尾渲染有界路径映射。 - SpacetimeDB schema 或 HTTP API 变更。 ## 依赖与前置条件 @@ -42,7 +42,7 @@ - [x] 未知 part、失效资源或非法路径在持久化前失败关闭。 - [x] canonical item 以 `response_item` 写入历史,标准旧 item 原样可读。 - [x] Codex wire input 不含 AGC 私有 part,且顺序与 canonical content 一致。 -- [ ] assistant、附件和工具链路行为无变化。 +- [x] assistant、工具链路行为无变化;已有附件/图片 sidecar 必须进入 DirectProject prompt,且附件-only 输入有效。 ## 证据要求 -- 2.52.0 From 46e3cd9744285f9bb4f564843ee7976946fa468f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 13:03:11 +0800 Subject: [PATCH 25/92] =?UTF-8?q?=E5=86=85=E8=81=94=20DirectProject=20?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E4=B8=8E=E5=9B=BE=E7=89=87=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增附件与图片 canonical content part 及 ts-rs 绑定 移除 DirectProject Tauri 附件 sidecar 追加路径 修正前端引用字段与生成类型适配 补充附件图片顺序投影测试并同步里程碑文档 --- .../src/agent/direct_codex_user_item/mod.rs | 5 +- .../src/agent/direct_codex_user_item/model.rs | 17 +++++++ .../direct_codex_user_item/validation.rs | 13 +++++ .../src/agent/direct_codex_user_item/wire.rs | 39 +++++++++++++++ .../src/agent/direct_runtime/user_input.rs | 22 +-------- apps/ai-game-creator-shell/src/App.tsx | 49 +++++++++++++++---- .../DirectCodexUserAttachmentReferencePart.ts | 9 ++++ .../generated/DirectCodexUserContentPart.ts | 10 ++-- .../generated/DirectCodexUserItem.ts | 10 ++-- .../DirectCodexUserMessageEnvelope.ts | 4 ++ .../generated/DirectCodexUserMessageItem.ts | 6 +-- .../generated/DirectCodexUserRole.ts | 2 +- .../DirectCodexUserRuntimeRegionPart.ts | 18 +++---- .../project-workspace/generated/index.ts | 2 + .../project-workspace/resourceReferences.ts | 17 +++---- ...DirectProject用户ResponseItem输入-2026-09-15.md | 2 +- ...DirectProject用户ResponseItem输入-2026-09-15.md | 6 +-- 17 files changed, 167 insertions(+), 64 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserAttachmentReferencePart.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs index d5ccc94c4..2488ae4ad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs @@ -5,8 +5,9 @@ mod validation; mod wire; pub(crate) use model::{ - DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope, - DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, + DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem, + DirectCodexUserMessageEnvelope, DirectCodexUserMessageItem, DirectCodexUserRole, + DirectCodexUserRuntimeRegionPart, }; pub(crate) use validation::validate_direct_codex_user_item; pub(crate) use wire::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs index b46336b66..e739b420b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -36,6 +36,23 @@ pub(crate) enum DirectCodexUserContentPart { AgcResourceReference { resource_id: String }, #[serde(rename = "agc_runtime_region_reference")] AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), + /// Uploaded project attachment kept inline in canonical content. + #[serde(rename = "agc_attachment_reference")] + AgcAttachmentReference(DirectCodexUserAttachmentReferencePart), + #[serde(rename = "agc_image_reference")] + AgcImageReference(DirectCodexUserAttachmentReferencePart), +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserAttachmentReferencePart { + pub(crate) name: String, + pub(crate) media_type: String, + #[ts(type = "number")] + pub(crate) size: u64, + pub(crate) local_path: String, + pub(crate) status: String, } #[derive(Clone, Debug, Deserialize, Serialize, TS)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs index 01f6a6868..50c285729 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs @@ -40,6 +40,19 @@ pub(crate) fn validate_direct_codex_user_item( reference_count = reference_count.saturating_add(1); validate_runtime_region_reference(&manifest, reference)?; } + DirectCodexUserContentPart::AgcAttachmentReference(reference) + | DirectCodexUserContentPart::AgcImageReference(reference) => { + if reference.name.trim().is_empty() { + return Err("附件缺少文件名".to_string()); + } + if !reference.local_path.trim().is_empty() { + sanitize_attachment_local_path(&reference.local_path) + .ok_or_else(|| "附件项目路径无效".to_string())?; + } + if !matches!(reference.status.trim(), "imported" | "failed") { + return Err("附件状态无效".to_string()); + } + } } } if reference_count > MAX_DIRECT_CODEX_REFERENCES { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index 2ab90cc62..c4352f0cf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -100,6 +100,21 @@ pub(crate) fn direct_codex_user_item_to_wire_input( summary.push(']'); summary } + DirectCodexUserContentPart::AgcAttachmentReference(reference) + | DirectCodexUserContentPart::AgcImageReference(reference) => { + let mut summary = format!( + "[附件:名称={};类型={};大小={} 字节", + reference.name.trim(), + reference.media_type.trim(), + reference.size + ); + if !reference.local_path.trim().is_empty() { + summary.push_str(&format!(";项目路径={}", reference.local_path.trim())); + } + summary.push_str(&format!(";状态={}", reference.status.trim())); + summary.push(']'); + summary + } }; input.push(serde_json::json!({ "type": "text", "text": text })); } @@ -174,4 +189,28 @@ mod tests { .expect_err("history item without type must fail"); assert!(error.contains("缺少 type"), "{error}"); } + + #[test] + fn attachment_and_image_parts_remain_in_canonical_order_when_projected() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") + .expect("init project"); + let item = json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [ + {"type": "input_text", "text": "先看"}, + {"type": "agc_image_reference", "name": "hero.png", "mediaType": "image/png", "size": 12, "localPath": "assets/hero.png", "status": "imported"}, + {"type": "agc_attachment_reference", "name": "notes.txt", "mediaType": "text/plain", "size": 4, "localPath": "assets/notes.txt", "status": "imported"} + ] + }); + let projected = direct_codex_user_item_to_response_item(root.path(), &item) + .expect("user response item should project"); + let content = projected["content"].as_array().expect("content array"); + assert_eq!(content.len(), 3); + assert!(content[0]["text"].as_str().unwrap().contains("先看")); + assert!(content[1]["text"].as_str().unwrap().contains("hero.png")); + assert!(content[2]["text"].as_str().unwrap().contains("notes.txt")); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index aa9bbe8f7..1e631cd1f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -31,10 +31,9 @@ pub(crate) fn normalize_direct_client_turn_id( pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, prompt: String, - mut user_item: DirectCodexUserItem, + user_item: DirectCodexUserItem, creation_type: Option, client_turn_id: Option, - attachments: Option>, ) -> Result { let root = Path::new(project_path.trim()); let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; @@ -43,24 +42,7 @@ 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 mut audit = DirectCodexTurnAudit::start( - root, - &turn_id, - &prompt, - attachments.as_deref().unwrap_or_default(), - ); - let attachments = attachments.unwrap_or_default(); - if !attachments.is_empty() { - let attachment_context = - render_direct_codex_user_prompt("", &attachments).map_err(|error| { - audit.finish(false); - error - })?; - let DirectCodexUserItem::Message(message) = &mut user_item; - message.content.push(DirectCodexUserContentPart::InputText { - text: attachment_context, - }); - } + let mut audit = DirectCodexTurnAudit::start(root, &turn_id, &prompt, &[]); validate_direct_codex_user_item(root, &user_item).map_err(|error| { audit.finish(false); error diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index b2a05ae88..937e48ec6 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -893,8 +893,7 @@ export function App({ /** * 输入盒待发送附件(direct-codex 回合附件):上传成功后先生成 chip,随下次提交一起 - * 交给 `chat_with_game_creator_direct_codex` 的 `attachments`。附件只存在于前端状态, - * 提交后即清空——后端协议不变。 + * 转换为 canonical user item 的 `content[]`。附件只存在于前端待发状态,提交后即清空。 */ const [chatAttachments, setChatAttachments] = useState< DirectCodexTurnAttachment[] @@ -7035,12 +7034,32 @@ export function App({ if (directProjectPath && directProjectId && directInvoke) { const clientTurnId = directConversationTurnId ?? createDirectCodexConversationTurnId(); - const effectiveUserItem = + const baseUserItem = userItem ?? chatComposerDraftToDirectCodexUserItem( { text: prompt, references: references ?? [], content: [] }, directCodexConversationMessageId(clientTurnId, 'user'), ); + const effectiveUserItem = attachments?.length + ? { + ...baseUserItem, + content: [ + ...baseUserItem.content, + ...attachments.map((attachment) => ({ + type: attachment.mediaType.toLowerCase().startsWith('image/') + ? ('agc_image_reference' as const) + : ('agc_attachment_reference' as const), + name: attachment.name, + mediaType: attachment.mediaType, + size: attachment.size ?? 0, + localPath: attachment.localPath ?? '', + status: + attachment.status ?? + (attachment.localPath ? 'imported' : 'failed'), + })), + ], + } + : baseUserItem; if ( !directPolicyChecked && projectConversationWriteConfirmedRef.current !== directProjectPath @@ -7181,7 +7200,6 @@ export function App({ prompt: string; clientTurnId: string; creationType?: HomeCreationType; - attachments?: DirectCodexTurnAttachment[]; userItem: ReturnType; } = { projectPath: directProjectPath, @@ -7192,9 +7210,6 @@ export function App({ if (creationType) { directTurnInput.creationType = creationType; } - if (attachments?.length) { - directTurnInput.attachments = attachments; - } directTurnInput.userItem = effectiveUserItem; const reply = await withDirectCodexSessionRefresh(() => { // 每次调用都会新建 Rust 事件流;续期重试需重新接收同一回合的进度。 @@ -12595,6 +12610,20 @@ export function App({ content?: DirectCodexUserContentPart[]; }) { const clientTurnId = createDirectCodexConversationTurnId(); + const attachmentContent: DirectCodexUserContentPart[] = ( + input.attachments ?? [] + ).map((attachment) => ({ + type: attachment.mediaType.toLowerCase().startsWith('image/') + ? ('agc_image_reference' as const) + : ('agc_attachment_reference' as const), + name: attachment.name, + mediaType: attachment.mediaType, + size: attachment.size ?? 0, + localPath: attachment.localPath ?? '', + status: + attachment.status ?? (attachment.localPath ? 'imported' : 'failed'), + })); + const content = [...(input.content ?? []), ...attachmentContent]; supervisorChatShouldFollowLatestRef.current = true; setMessages((current) => [ ...current, @@ -12609,16 +12638,18 @@ export function App({ void executeChatAgentReply({ prompt: input.prompt, clientTurnId, - attachments: input.attachments?.length ? input.attachments : undefined, references: input.references, userItem: chatComposerDraftToDirectCodexUserItem( { text: input.prompt, references: input.references ?? [], - content: input.content ?? [], + content, }, directCodexConversationMessageId(clientTurnId, 'user'), ), + // DirectProject 的附件已经是 canonical content part;不能再作为 sidecar + // 传给 Rust,否则会重复追加。 + attachments: undefined, }); } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserAttachmentReferencePart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserAttachmentReferencePart.ts new file mode 100644 index 000000000..7394ae07d --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserAttachmentReferencePart.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DirectCodexUserAttachmentReferencePart = { + name: string; + mediaType: string; + size: number; + localPath: string; + status: string; +}; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts index 10c4e0f5a..c3b7b0ea1 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts @@ -1,5 +1,5 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectCodexUserAttachmentReferencePart } from './DirectCodexUserAttachmentReferencePart'; import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; export type DirectCodexUserContentPart = @@ -7,4 +7,8 @@ export type DirectCodexUserContentPart = | { type: 'agc_resource_reference'; resourceId: string } | ({ type: 'agc_runtime_region_reference'; - } & DirectCodexUserRuntimeRegionPart); + } & DirectCodexUserRuntimeRegionPart) + | ({ + type: 'agc_attachment_reference'; + } & DirectCodexUserAttachmentReferencePart) + | ({ type: 'agc_image_reference' } & DirectCodexUserAttachmentReferencePart); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts index 4c9ea8ca3..aa1ee88dd 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts @@ -1,5 +1,9 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; -export type DirectCodexUserItem = DirectCodexUserMessageItem; +/** + * DirectProject 本轮 user input 的唯一结构化入口。 + */ +export type DirectCodexUserItem = { + type: 'message'; +} & DirectCodexUserMessageItem; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts new file mode 100644 index 000000000..97e0b0dae --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectCodexUserItem } from './DirectCodexUserItem'; + +export type DirectCodexUserMessageEnvelope = { item: DirectCodexUserItem }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts index f7447dc5d..a1101c8cb 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts @@ -1,11 +1,9 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; import type { DirectCodexUserRole } from './DirectCodexUserRole'; export type DirectCodexUserMessageItem = { - type: 'message'; role: DirectCodexUserRole; - content: DirectCodexUserContentPart[]; + content: Array; id: string; }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts index e1dc59c59..0c2540122 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts @@ -1,3 +1,3 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type DirectCodexUserRole = 'user'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts index 296c0d19a..b134fb7bc 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts @@ -1,13 +1,13 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type DirectCodexUserRuntimeRegionPart = { label: string; - runId?: string; - versionId?: string; - elementTag?: string; - elementRole?: string; - text?: string; - width?: number; - height?: number; - resourceIds: string[]; + runId: string | null; + versionId: string | null; + elementTag: string | null; + elementRole: string | null; + text: string | null; + width: number | null; + height: number | null; + resourceIds: Array; }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts index f1012a4c5..aff993c0d 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts @@ -1,5 +1,7 @@ export type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; +export type { DirectCodexUserAttachmentReferencePart } from './DirectCodexUserAttachmentReferencePart'; export type { DirectCodexUserItem } from './DirectCodexUserItem'; export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; export type { DirectCodexUserRole } from './DirectCodexUserRole'; export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; +export type { DirectCodexUserMessageEnvelope } from './DirectCodexUserMessageEnvelope'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index c6e6959c8..72f46c677 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -13,7 +13,6 @@ import { import type { DirectCodexUserContentPart, DirectCodexUserItem, - DirectCodexUserMessageItem, } from './generated'; export type ResourceReferenceSource = @@ -103,13 +102,13 @@ export function chatReferenceToContentPart( return { type: 'agc_runtime_region_reference', label: reference.label, - runId: reference.runId, - versionId: reference.versionId, - elementTag: reference.elementTag, - elementRole: reference.elementRole, - text: reference.text, - width: reference.width, - height: reference.height, + runId: reference.runId ?? null, + versionId: reference.versionId ?? null, + elementTag: reference.elementTag ?? null, + elementRole: reference.elementRole ?? null, + text: reference.text ?? null, + width: reference.width ?? null, + height: reference.height ?? null, resourceIds: reference.resourceIds, }; } @@ -126,7 +125,7 @@ export function chatComposerDraftToDirectCodexUserItem( role: 'user', content, id, - } satisfies DirectCodexUserMessageItem; + } satisfies DirectCodexUserItem; } export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) { diff --git a/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md b/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md index dac2a7297..51b9ea7b8 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md +++ b/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md @@ -9,7 +9,7 @@ ## 修改边界 - 允许修改:AGC 壳 Rust agent 输入合同、DirectProject 历史适配、前端聊天引用模型、ts-rs 生成配置、当前聊天素材文档。 -- 明确不修改:assistant 返回协议、工具 activity、附件/图片 DTO 协议、SpacetimeDB、HTTP API;仅补齐既有 sidecar 在 DirectProject prompt 的消费。 +- 明确不修改:assistant 返回协议、工具 activity、附件/图片上传 DTO、SpacetimeDB、HTTP API;上传 DTO 仅在入口转换为 canonical `content[]`,不再以 sidecar 文本追加。 ## 实现顺序 diff --git a/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md b/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md index 392505040..963ad4791 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md +++ b/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md @@ -19,13 +19,13 @@ - `agc_runtime_region_reference` 保留运行区域语义摘要。 - Rust 在持久化前完成白名单、manifest 与路径校验。 - 现有标准 `response_item` 原样兼容;legacy conversation 行不提供 fallback。 -- 保持 assistant 返回、工具 activity、附件/图片协议不变;沿用 sidecar DTO 将附件与图片路径映射纳入 DirectProject prompt。 +- 保持 assistant 返回、工具 activity 与上传 DTO 不变;但 DirectProject canonical user item 不再保存附件 sidecar,附件和图片必须作为 `content[]` 中按顺序排列的 `agc_attachment_reference` / `agc_image_reference`。 ## 不在范围内 - assistant item 前端投影或 Tauri 返回值改造。 - 工具 item、reasoning、file change、MCP item 的 UI 模型化。 -- 附件/图片 content part(不新增 content-part,继续消费现有 sidecar DTO)。 +- 附件/图片 content part:本里程碑明确新增并生成绑定;上传 DTO 只作为输入适配,不作为历史事实源。 - SpacetimeDB schema 或 HTTP API 变更。 ## 依赖与前置条件 @@ -42,7 +42,7 @@ - [x] 未知 part、失效资源或非法路径在持久化前失败关闭。 - [x] canonical item 以 `response_item` 写入历史,标准旧 item 原样可读。 - [x] Codex wire input 不含 AGC 私有 part,且顺序与 canonical content 一致。 -- [x] assistant、附件和工具链路行为无变化;附件-only 输入也能进入 DirectProject prompt。 +- [x] assistant、附件和工具链路行为无变化;附件与图片按 content 顺序内联,附件-only 输入也能进入 DirectProject prompt。 ## 证据要求 -- 2.52.0 From b6075019167c67b620d382633b0df716da0bfde8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 13:15:08 +0800 Subject: [PATCH 26/92] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=80=BB=E6=8E=A7?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E6=81=A2=E5=A4=8D=E5=90=8E=E8=A2=AB=E6=B8=85?= =?UTF-8?q?=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 仅在非纯聊天模式清空聊天编辑器 - 保留纯聊天模式恢复的会话草稿 --- apps/ai-game-creator-shell/src/App.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 937e48ec6..c42c14175 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -4374,8 +4374,9 @@ export function App({ chatComposerRef.current?.replaceText( readSupervisorChatDraft(openedProject.projectPath), ); + } else { + clearChatComposer(); } - clearChatComposer(); setManifest(openedProject.manifest); setProjectFiles([]); setProjectCheckpoints([]); -- 2.52.0 From 30e27abc609eb39b918582f6bce43f4919644112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 13:16:07 +0800 Subject: [PATCH 27/92] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BB=BA=E8=AE=AE?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E8=8D=89=E7=A8=BF=E6=AE=8B=E7=95=99=E5=BC=95?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 准备命令草稿前先清空编辑器 - 避免提交用户上一轮选择的素材引用 --- apps/ai-game-creator-shell/src/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index c42c14175..458b5bcc4 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -4615,6 +4615,7 @@ export function App({ } function prepareChatCommandDraft(commandDraft: string) { + chatComposerRef.current?.clear(); chatComposerRef.current?.replaceText(commandDraft); window.setTimeout(() => chatInputRef.current?.focus(), 0); } -- 2.52.0 From d5c4e0e78069d2ae5bd37421a4caad6157c39f8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 13:17:50 +0800 Subject: [PATCH 28/92] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BF=AB=E9=80=9F?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E6=B6=A6=E8=89=B2=E7=BB=93=E6=9E=9C=E6=9C=AA?= =?UTF-8?q?=E5=9B=9E=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为快速编辑输入区绑定编辑器句柄 - 润色回调同时更新面板状态与编辑器文本 --- .../src/view/project-development/index.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index df3cd2f22..a584dede9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -105,7 +105,10 @@ import { type LocalGamePreviewInspectSelection, resolveEmbeddedPreviewUrl, } from '../../features/project-workspace/LocalGamePreviewFrame'; -import { ResourceReferenceInput } from '../../features/project-workspace/ResourceReferenceInput'; +import { + ResourceReferenceInput, + type ResourceReferenceInputHandle, +} from '../../features/project-workspace/ResourceReferenceInput'; import { type ChatComposerDraft, dispatchResourceReferenceInsert, @@ -1554,6 +1557,9 @@ export default function ProjectDevelopmentView({ useState(null); const [quickEditPanel, setQuickEditPanel] = useState(null); + const quickEditPromptInputRef = useRef( + null, + ); /** * 快速编辑提示词里的 `@` 资源引用。 * @@ -6484,6 +6490,7 @@ export default function ProjectDevelopmentView({ * 失败的那份请求,状态回到 idle、错误清空——同时提交侧会按新提示词重铸请求身份。 */ const applyResourceQuickEditPrompt = useCallback((text: string) => { + quickEditPromptInputRef.current?.replaceText(text); setQuickEditPanel((current) => current ? { @@ -8122,6 +8129,7 @@ export default function ProjectDevelopmentView({ // 由它拼装,因此出站 payload 与聊天 `@` 一致。
Date: Wed, 16 Sep 2026 13:18:31 +0800 Subject: [PATCH 29/92] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B6=A6=E8=89=B2?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E6=97=A0=E6=B3=95=E6=84=9F=E7=9F=A5=E8=BE=93?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用 React 状态同步编辑器当前文本 - 让按钮禁用态随用户输入即时更新 --- .../features/project-workspace/ResourceReferenceInput.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index d8354c565..72524200e 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -763,6 +763,7 @@ function ResourceReferenceEditor({ references: initialDraft?.references ?? [], content: [], }); + const [draftText, setDraftText] = useState(initialDraft?.text ?? ''); const reminderDisabledRef = useRef(reminderDisabled); reminderDisabledRef.current = reminderDisabled; @@ -1007,9 +1008,7 @@ function ResourceReferenceEditor({ aria-label="AI 润色" title="AI 润色" aria-busy={polishing} - disabled={ - disabled || polishing || liveDraftRef.current.text.trim() === '' - } + disabled={disabled || polishing || draftText.trim() === ''} onMouseDown={(event) => event.preventDefault()} onClick={() => void polishPrompt()} > @@ -1215,6 +1214,7 @@ function ResourceReferenceEditor({ onChange={(editorState) => { const nextDraft = readResourceReferenceDraft(editorState); liveDraftRef.current = nextDraft; + setDraftText(nextDraft.text); if (skipInitialDraftChangeRef.current) { skipInitialDraftChangeRef.current = false; return; -- 2.52.0 From 645d806fedfd9e6533d98eb932ebeafb00fe2d0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 13:19:13 +0800 Subject: [PATCH 30/92] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=A9=BA=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E8=8D=89=E7=A8=BF=E5=90=9E=E6=8E=89=E9=A6=96=E6=AC=A1?= =?UTF-8?q?=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 仅在初始内容实际非空时跳过初始化回调 - 保证快速编辑首次输入同步到宿主状态 --- .../src/features/project-workspace/ResourceReferenceInput.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 72524200e..b435f5031 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -642,9 +642,11 @@ function ResourceReferenceEditor({ return; } initialDraftAppliedRef.current = true; - skipInitialDraftChangeRef.current = true; editor.update(() => { applyDraftToRoot(initialDraft.text, initialDraft.references); + skipInitialDraftChangeRef.current = + initialDraft.text.trim().length > 0 || + initialDraft.references.length > 0; $getRoot().selectEnd(); }); }, [editor, initialDraft]); -- 2.52.0 From 64a52005f856d170d166c5c5caa2827b2b8a31b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 13:20:01 +0800 Subject: [PATCH 31/92] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B8=85=E7=A9=BA?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E5=90=8E=E6=B6=A6=E8=89=B2=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=AE=8B=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在编辑器发出空草稿时重置润色结果 - 清除本轮发送提醒确认标记 --- .../features/project-workspace/ResourceReferenceInput.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index b435f5031..67b45d6c9 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -1217,6 +1217,13 @@ function ResourceReferenceEditor({ const nextDraft = readResourceReferenceDraft(editorState); liveDraftRef.current = nextDraft; setDraftText(nextDraft.text); + if ( + nextDraft.text.trim() === '' && + nextDraft.references.length === 0 + ) { + resetPromptPolish(); + acknowledgedDraftKeyRef.current = null; + } if (skipInitialDraftChangeRef.current) { skipInitialDraftChangeRef.current = false; return; -- 2.52.0 From fa99d9ad62b0cc1bdb645f5b0a4632d68220fe40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 13:20:47 +0800 Subject: [PATCH 32/92] =?UTF-8?q?=E9=81=BF=E5=85=8D=E6=80=BB=E6=8E=A7?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E5=88=9D=E5=A7=8B=E5=8C=96=E8=AF=AF=E5=88=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 编辑器尚未恢复时跳过空草稿持久化 - 保留已有会话草稿等待输入区完成恢复 --- apps/ai-game-creator-shell/src/App.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 458b5bcc4..42dabda45 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -3080,7 +3080,10 @@ export function App({ return; } const draft = chatComposerRef.current?.getDraft(); - persistSupervisorChatDraft(initialProjectPath, draft?.text ?? ''); + if (!draft?.text.trim()) { + return; + } + persistSupervisorChatDraft(initialProjectPath, draft.text); }, [initialProjectPath, supervisorChatOnly]); useEffect(() => { -- 2.52.0 From fc0fd846f95e1945f96d5edbde61d204218a210e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 13:22:57 +0800 Subject: [PATCH 33/92] =?UTF-8?q?=E9=81=BF=E5=85=8D=E5=BF=AB=E9=80=9F?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E6=B6=A6=E8=89=B2=E5=9B=9E=E5=A1=AB=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 回填前比较编辑器当前文本 - 防止输入区 onChange 与宿主回调形成循环 --- .../src/view/project-development/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index a584dede9..ffe964a78 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -6490,7 +6490,10 @@ export default function ProjectDevelopmentView({ * 失败的那份请求,状态回到 idle、错误清空——同时提交侧会按新提示词重铸请求身份。 */ const applyResourceQuickEditPrompt = useCallback((text: string) => { - quickEditPromptInputRef.current?.replaceText(text); + const currentDraft = quickEditPromptInputRef.current?.getDraft(); + if (currentDraft && currentDraft.text !== text) { + quickEditPromptInputRef.current?.replaceText(text); + } setQuickEditPanel((current) => current ? { -- 2.52.0 From f4b496cacab755f5e18d2182298a6aa2cdf7a5ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 15:28:25 +0800 Subject: [PATCH 34/92] =?UTF-8?q?=E8=A1=A5=E5=85=85DirectProject=20canonic?= =?UTF-8?q?al=E5=86=85=E5=AE=B9=E4=B8=A5=E6=A0=BC=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 明确 content-only 输入与最终空内容校验 - 规划删除 userItem 兜底并修正 Direct caller --- ...tProject canonical content严格边界-2026-09-16.md | 35 ++++++++++++++++ ...Project canonical content严格边界-2026-09-16.md | 41 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 docs/project-memory/plans/【实施计划】DirectProject canonical content严格边界-2026-09-16.md create mode 100644 docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md diff --git a/docs/project-memory/plans/【实施计划】DirectProject canonical content严格边界-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject canonical content严格边界-2026-09-16.md new file mode 100644 index 000000000..072f9468b --- /dev/null +++ b/docs/project-memory/plans/【实施计划】DirectProject canonical content严格边界-2026-09-16.md @@ -0,0 +1,35 @@ +# 【实施计划】DirectProject canonical content 严格边界 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md` | +| Status | in-progress | +| Owner | Codex | + +## 实施顺序 + +1. 先把主转换函数改为原样传递 `draft.content`,并在 content-only 测试中覆盖空白 part 保留。 +2. 将最终 content 有效性判断抽为纯函数,接入普通提交、队列入队和其它 Direct Codex 入口。 +3. 删除 Direct Codex 的 `userItem` fallback;首页首轮和队列出队直接构造 canonical user item。 +4. 收敛策略确认重试为复用同一 canonical user item;旧 Supervisor/Planning caller 加 TODO,不改变其非 Direct Codex 行为。 +5. 迁移现有测试 fixture,删除旧字段构造,不增加“字段不存在”测试。 + +## 修改边界 + +- 允许修改:AGC shell 前端 `resourceReferences`、`App`、聊天队列、Direct Codex 相关测试和当前里程碑文档。 +- 不修改:Rust user item schema、附件 DTO、SpacetimeDB、HTTP API、用户已有 `.env` / `package-lock.json` 修改。 +- 不引入:`text` / `references` 到 canonical Direct Codex item 的任何兼容 fallback。 + +## 验证命令 + +- `npm run test -- apps/ai-game-creator-shell/tests/resourceReferences.test.ts apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts` +- `npm run ai-game-creator-shell:typecheck` +- `npm run check:encoding` +- `npm run check:doc-index` +- `git diff --check` + +## 风险与回滚点 + +- 空白 `input_text` 不再前端删除,需确保最终有效性判断只拒绝全空 content,不改变其它 part。 +- Direct caller 漏传 user item 时应在类型检查或明确错误分支暴露,不能静默重建。 +- 共享工作树含用户未提交修改;提交时只 stage 本计划和本次代码 hunk。 diff --git a/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md new file mode 100644 index 000000000..a1f7eb659 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md @@ -0,0 +1,41 @@ +# 【里程碑】DirectProject canonical content 严格边界 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | proposed | +| Date | 2026-09-16 | +| Parent Spec | `docs/【功能说明】AGC聊天素材引用-2026-09-08.md` | + +## 目标 + +让 DirectProject 的用户消息只以 `content[]` 作为 canonical 输入:保留 Lexical 产生的全部 content part(包括纯空白 `input_text`),只在最终 content 上判断是否存在有效输入;所有 Direct Codex caller 必须显式提供完整 `userItem`。 + +## 范围 + +- 移除 `chatComposerDraftToDirectCodexUserItem` 对纯空白 `input_text` 的预过滤。 +- Direct Codex 发送前只做最终 content 的有效性判断,不改写 content。 +- 删除 `executeChatAgentReply` 在 `userItem` 缺失时的构造兜底。 +- 修正首页首轮、队列出队和其它 Direct Codex caller,使其直接构造 canonical user item。 +- 将队列与策略确认重试按 canonical user item 传递,避免拆回 `text` / `references`。 +- 仍在使用的旧 Supervisor/Planning caller 保持非 Direct Codex 行为,并添加后续迁移 TODO。 + +## 不在范围内 + +- 不保留从 `draft.text` / `draft.references` 重建 Direct Codex content 的兼容分支。 +- 不新增断言 legacy 字段“不存在”的测试;现有测试直接迁移到 content-only 输入。 +- 不修改 Rust user item schema、SpacetimeDB schema、HTTP API 或历史迁移。 + +## 验收标准 + +- `chatComposerDraftToDirectCodexUserItem` 输出与输入 `draft.content` 顺序和值完全一致。 +- 只有当最终 content 不含非空文本且不含任何非文本 part 时,发送入口才拒绝本轮。 +- Direct Codex 路径不存在 `userItem ?? ...` 或等价 fallback。 +- 首页首轮、普通聊天、队列出队、策略确认重试均发送同一个 canonical user item 语义。 +- 旧 Supervisor/Planning caller 上有明确 TODO,且不进入 Direct Codex canonical 发送路径。 + +## 证据 + +- 前端 canonical content、有效性判断、caller 与队列定向测试。 +- AGC shell 类型检查与定向 Vitest。 +- `npm run check:encoding`、`git diff --check`、必要的文档索引检查。 -- 2.52.0 From 721e45f01b4fc9b9dac1281b275e3bbc44f411be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 17:45:28 +0800 Subject: [PATCH 35/92] =?UTF-8?q?=E7=BB=9F=E4=B8=80=20DirectProject=20?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=80=81=E6=9D=A1=E7=9B=AE=E7=9A=84=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E8=BA=AB=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Thread Manager 事件新增 callId,条目身份统一为 callId 优先、其次 itemId - 工具条目按 callId 归并 started/completed,不再留下永远收不到完成事件的幽灵活跃条目 - 历史锚点仍取 response item id,保证分页锚点能在 project.jsonl 中命中 - 补充跨 id 空间归并的单元测试 --- .../src/agent/codex_app_server/mod.rs | 17 ++++ .../src/agent/direct_thread_manager.rs | 88 +++++++++++++++++-- 2 files changed, 99 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 25d1591e4..fcfa250be 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -760,6 +760,17 @@ fn direct_thread_item_id(item: &serde_json::Value) -> Option { .map(str::to_string) } +/// 原始 response item 的调用 id:工具条目的 app-server `itemId` 就是这个值, +/// 所以它是两个 id 空间唯一的对齐点。 +fn direct_thread_item_call_id(item: &serde_json::Value) -> Option { + item.get("call_id") + .or_else(|| item.get("callId")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + fn direct_codex_command_is_game_verification(command: &str) -> bool { let command = command.to_ascii_lowercase(); command.contains("game.static_smoke") @@ -2932,6 +2943,7 @@ impl CodexAppServerConnection { event_type: "turn.started".to_string(), turn_id: turn_id.clone(), item_id: None, + call_id: None, payload: serde_json::json!({ "threadId": thread_id, "turnId": turn_id, @@ -2996,6 +3008,7 @@ impl CodexAppServerConnection { event_type: "item.delta".to_string(), turn_id: turn_id.clone(), item_id: Some(item_id.clone()), + call_id: None, payload: serde_json::json!({ "delta": delta.clone() }), }, ); @@ -3060,6 +3073,7 @@ impl CodexAppServerConnection { event_type: "item.completed".to_string(), turn_id: turn_id.clone(), item_id, + call_id: direct_thread_item_call_id(&item), payload: serde_json::json!({}), }, ); @@ -3079,6 +3093,7 @@ impl CodexAppServerConnection { event_type: event_type.to_string(), turn_id: turn_id.clone(), item_id: None, + call_id: None, payload: request_id .map(|id| serde_json::json!({ "requestId": id })) .unwrap_or_else(|| serde_json::json!({})), @@ -3200,6 +3215,7 @@ impl CodexAppServerConnection { event_type: "item.started".to_string(), turn_id: turn_id.clone(), item_id, + call_id: None, payload: direct_thread_item_started_payload(item), }, ); @@ -3248,6 +3264,7 @@ impl CodexAppServerConnection { event_type: "turn.completed".to_string(), turn_id: turn_id.clone(), item_id: None, + call_id: None, payload: serde_json::json!({ "status": status }), }, ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index e3bc497ea..f72f37380 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -28,6 +28,10 @@ pub(crate) struct DirectThreadRawEvent { pub(crate) turn_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) item_id: Option, + /// 合并身份的另一半:工具类条目的 `itemId` 是调用 id,原始 item 的 `id` 是 response item id, + /// 两者只在 `callId` 上对齐。前端按 `callId ?? itemId` 归并同一张卡片。 + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) call_id: Option, pub(crate) payload: Value, } @@ -36,9 +40,17 @@ pub(crate) struct DirectThreadRawEventDraft { pub(crate) event_type: String, pub(crate) turn_id: String, pub(crate) item_id: Option, + pub(crate) call_id: Option, pub(crate) payload: Value, } +impl DirectThreadRawEvent { + /// 条目合并身份:有 `callId` 就用它,否则用 `itemId`。 + fn item_identity(&self) -> Option<&str> { + self.call_id.as_deref().or(self.item_id.as_deref()) + } +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct DirectThreadSubscriptionBootstrap { @@ -81,6 +93,13 @@ struct ThreadState { total_bytes: usize, active_items: HashSet, unresolved_requests: HashSet, + /// 最新一条 `turn.started` / `turn.completed` 的独立拷贝。 + /// + /// TODO(thread-manager): 这里有意只保留"锚点",因为 replay 队列会回收可回收事件, + /// 队列本身不是完美事件日志——被回收的 `turn.started` / `turn.completed` 不会回放, + /// 只有这份拷贝保证新订阅仍能判定"最新回合是否还在跑"。若将来需要回放多个回合的 + /// 生命周期(回合账本、跨进程恢复、按回合统计),必须另建持久 ledger, + /// 不能靠扩大这份拷贝或放宽回收规则来模拟。 lifecycle_anchor: Option, last_completed_item_id: Option, subscribers: HashMap, @@ -140,6 +159,7 @@ impl DirectThreadManager { event_type: draft.event_type, turn_id: draft.turn_id, item_id: draft.item_id, + call_id: draft.call_id, payload: draft.payload, }; let cleanable = Self::observe_event(thread, &event); @@ -152,7 +172,7 @@ impl DirectThreadManager { bytes, cleanable, }); - Self::mark_item_events_cleanable(thread, event.item_id.as_deref()); + Self::mark_item_events_cleanable(thread, event.item_identity()); if matches!( event.event_type.as_str(), "approval.resolved" | "request.resolved" | "ask.resolved" @@ -256,19 +276,24 @@ impl DirectThreadManager { fn observe_event(thread: &mut ThreadState, event: &DirectThreadRawEvent) -> bool { match event.event_type.as_str() { "item.started" => { - if let Some(item_id) = event.item_id.as_deref() { - thread.active_items.insert(item_id.to_string()); + if let Some(identity) = event.item_identity() { + thread.active_items.insert(identity.to_string()); } false } "item.completed" => { + if let Some(identity) = event.item_identity() { + thread.active_items.remove(identity); + } + // 历史锚点必须是 project.jsonl 里的 response item id,不能用 call id。 if let Some(item_id) = event.item_id.as_deref() { - thread.active_items.remove(item_id); thread.last_completed_item_id = Some(item_id.to_string()); } true } "turn.started" | "turn.completed" => { + // 队列只保留最新一条生命周期事件,更早的可能已被回收;见 + // `ThreadState::lifecycle_anchor` 的 TODO:这不是完整事件日志。 thread.lifecycle_anchor = Some(event.clone()); true } @@ -301,8 +326,8 @@ impl DirectThreadManager { { return true; } - if let Some(item_id) = event.item_id.as_deref() { - return thread.active_items.contains(item_id); + if let Some(identity) = event.item_identity() { + return thread.active_items.contains(identity); } if let Some(request_id) = request_id(event) { return thread.unresolved_requests.contains(&request_id); @@ -476,10 +501,57 @@ mod tests { event_type: event_type.to_string(), turn_id: turn_id.to_string(), item_id: item_id.map(str::to_string), + call_id: None, payload: serde_json::json!({}), } } + /// 工具条目的两个 id 空间必须靠 `callId` 对齐:app-server `item.started` 的 itemId 是调用 id, + /// 原始 item 的 `item.completed` 的 itemId 是 response item id。 + fn draft_with_call_id( + event_type: &str, + turn_id: &str, + item_id: Option<&str>, + call_id: Option<&str>, + ) -> DirectThreadRawEventDraft { + DirectThreadRawEventDraft { + event_type: event_type.to_string(), + turn_id: turn_id.to_string(), + item_id: item_id.map(str::to_string), + call_id: call_id.map(str::to_string), + payload: serde_json::json!({}), + } + } + + #[test] + fn call_id_joins_started_and_completed_across_id_spaces() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager.append( + "thread-1", + draft_with_call_id("item.started", "turn-1", Some("call-1"), None), + ); + manager.append( + "thread-1", + draft_with_call_id("item.completed", "turn-1", Some("item-9"), Some("call-1")), + ); + // 活跃条目按合并身份清理,不留下永远收不到完成事件的幽灵条目。 + let bootstrap = manager.subscribe("thread-1"); + assert!( + bootstrap + .events + .iter() + .all(|event| event.event_type != "item.started"), + "已完成的条目不得再作为运行态事件回到 bootstrap" + ); + // 没有订阅者时,可回收事件全部被回收;历史锚点靠独立字段保留,不占队列。 + assert_eq!( + manager.thread_debug("thread-1").map(|debug| debug.0), + Some(0) + ); + // 历史锚点仍然是 response item id,前端才能拿它当分页锚点。 + assert_eq!(bootstrap.last_completed_item_id.as_deref(), Some("item-9")); + } + #[test] fn subscribers_have_independent_cursors_on_one_global_queue() { let mut manager = DirectThreadManager::with_limits(100, 100_000); @@ -582,6 +654,7 @@ mod tests { event_type: "approval.requested".to_string(), turn_id: "turn-1".to_string(), item_id: None, + call_id: None, payload: serde_json::json!({"requestId": "request-1"}), }, ); @@ -593,6 +666,7 @@ mod tests { event_type: "approval.resolved".to_string(), turn_id: "turn-1".to_string(), item_id: None, + call_id: None, payload: serde_json::json!({"requestId": "request-1"}), }, ); @@ -615,6 +689,7 @@ mod tests { event_type: "approval.requested".to_string(), turn_id: "turn-1".to_string(), item_id: None, + call_id: None, payload: serde_json::json!({"requestId": "request-1"}), }, ); @@ -625,6 +700,7 @@ mod tests { event_type: "approval.resolved".to_string(), turn_id: "turn-1".to_string(), item_id: None, + call_id: None, payload: serde_json::json!({"requestId": "request-1"}), }, ); -- 2.52.0 From 41366dd71d339d503726f4741d048d226e617c7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 17:46:41 +0800 Subject: [PATCH 36/92] =?UTF-8?q?=E4=BF=AE=E5=A4=8DDirectProject=20canonic?= =?UTF-8?q?al=E5=86=85=E5=AE=B9=E8=BE=B9=E7=95=8C=EF=BC=88review=20?= =?UTF-8?q?=E7=AC=AC=208=20=E6=9D=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 chatComposerDraftToDirectCodexUserItem 对纯空白 input_text 的预过滤,原样传递 draft.content - ChatComposerDraft 只保留 canonical content,删除 text / references 旧字段 - 新增 directCodexContentToPromptText、hasMeaningfulDirectCodexContent、directCodexUserItemFromContent 供 caller 与展示派生 - 删除 executeChatAgentReply 的 userItem 兜底分支,首页首轮、队列出队、普通提交与策略重试显式构造 canonical user item - QueuedChatTurn 只保存 clientTurnId 与 userItem,队列 chip 文案由 content 派生 - 旧 Planner / legacy Supervisor caller 显式构造纯文本 item 并留下迁移 TODO - 资源输入区对外只暴露 canonical content,内部文本草稿改为文本 → content 重建 - 迁移草稿、润色、队列与 appSurface 测试 fixture 到 content-only - 更新 canonical content 里程碑与实施计划文档结论 --- apps/ai-game-creator-shell/src/App.tsx | 233 +++++++++--------- .../ResourceReferenceInput.tsx | 176 ++++++++----- .../project-workspace/chatComposerQueue.ts | 39 +-- .../project-workspace/chatPromptPolish.ts | 21 +- .../project-workspace/resourceReferences.ts | 48 +++- .../src/view/project-development/index.tsx | 20 +- .../tests/appSurface/chat-composer.suite.ts | 53 ++-- .../tests/appSurface/home.suite.ts | 28 ++- .../appSurface/project-conversation.suite.ts | 45 ++-- .../tests/chatPromptPolish.test.tsx | 87 ++++--- .../tests/resourceReferenceInput.test.tsx | 109 ++++---- .../tests/resourceReferences.test.ts | 61 +++-- ...tProject canonical content严格边界-2026-09-16.md | 15 +- ...Project canonical content严格边界-2026-09-16.md | 26 +- 14 files changed, 551 insertions(+), 410 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 42dabda45..77ea9afae 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -246,6 +246,7 @@ import { } from './features/project-workspace/directThreadEvents'; import { normalizeDirectTimestamp } from './features/project-workspace/directTurnPresentation'; import type { DirectCodexUserContentPart } from './features/project-workspace/generated'; +import type { DirectCodexUserItem } from './features/project-workspace/generated'; import { appendMemoryContent, memoryScopeLabel, @@ -278,12 +279,12 @@ import { handleProjectSummaryChatCommand } from './features/project-workspace/pr import { ProjectSupervisorView } from './features/project-workspace/ProjectSupervisorView'; import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWorkspaceChatPane'; import type { ResourceReferenceInputHandle } from './features/project-workspace/ResourceReferenceInput'; -import type { - ChatComposerDraft, - ChatReference, -} from './features/project-workspace/resourceReferences'; +import type { ChatComposerDraft } from './features/project-workspace/resourceReferences'; import { chatComposerDraftToDirectCodexUserItem, + directCodexContentToPromptText, + directCodexUserItemFromContent, + hasMeaningfulDirectCodexContent, RESOURCE_REFERENCE_INSERT_EVENT, type ResourceReferenceInsertEventDetail, } from './features/project-workspace/resourceReferences'; @@ -704,17 +705,15 @@ type ExecuteChatAgentReplyInput = { prompt: string; clientTurnId?: string; creationType?: HomeCreationType | null; - attachments?: DirectCodexTurnAttachment[]; directPolicyChecked?: boolean; - references?: ChatReference[]; - userItem?: ReturnType; + userItem: DirectCodexUserItem; }; /** * `conversation.write` 策略确认后重跑同一轮直连回合的入参。 * * 确认弹窗会在**同一轮输入**上二次进入 `executeChatAgentReply`。这里从首轮的入参整体派生, - * 而不是手写一遍字段:一旦重跑时漏掉某一项(历史缺陷就是漏了 `references`), + * 而不是手写一遍字段:一旦重跑时漏掉某一项(历史缺陷就是漏了 canonical user item), * 用户在确认之后拿到的就不是他原本提交的那一轮——`@` 引用会被静默丢掉。 */ export function directCodexPolicyRetryInput( @@ -3080,11 +3079,16 @@ export function App({ return; } const draft = chatComposerRef.current?.getDraft(); - if (!draft?.text.trim()) { + if (!draft || !hasMeaningfulDirectCodexContent(draft.content)) { return; } - persistSupervisorChatDraft(initialProjectPath, draft.text); - }, [initialProjectPath, supervisorChatOnly]); + persistSupervisorChatDraft( + initialProjectPath, + directCodexContentToPromptText(draft.content, manifest.assets), + ); + // 资源改名后草稿里的 @ 显示名要跟着变,所以内容签名(manifest.assets)也是这条 + // 持久化的依赖;重复写入的是同一份快照,不会把用户的编辑改坏。 + }, [initialProjectPath, manifest.assets, supervisorChatOnly]); useEffect(() => { latestMessagesRef.current = messages; @@ -4626,8 +4630,6 @@ export function App({ function readChatComposerDraft(): ChatComposerDraft { return ( chatComposerRef.current?.getDraft() ?? { - text: '', - references: [], content: [], } ); @@ -4637,9 +4639,14 @@ export function App({ chatComposerRef.current?.clear(); } - function handleChatComposerChange(draft: ChatComposerDraft) { + function handleChatComposerChange(draft: { + content: DirectCodexUserContentPart[]; + }) { if (supervisorChatOnly) { - persistSupervisorChatDraft(initialProjectPath, draft.text); + persistSupervisorChatDraft( + initialProjectPath, + directCodexContentToPromptText(draft.content, manifest.assets), + ); } } @@ -5669,13 +5676,15 @@ export function App({ async function handleChatSubmit(event: FormEvent) { event.preventDefault(); const draft = readChatComposerDraft(); - const prompt = draft.text.trim(); - const references = draft.references; + const prompt = directCodexContentToPromptText( + draft.content, + manifest.assets, + ); if (agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current)) { setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); return; } - if ((!prompt && references.length === 0) || chatAgentBusy) { + if (!hasMeaningfulDirectCodexContent(draft.content) || chatAgentBusy) { return; } @@ -6772,7 +6781,7 @@ export function App({ draft, directCodexConversationMessageId(clientTurnId, 'user'), ); - void executeChatAgentReply({ prompt, references, userItem, clientTurnId }); + void executeChatAgentReply({ prompt, userItem, clientTurnId }); } async function executeLlmConfigStatus() { @@ -6998,9 +7007,7 @@ export function App({ prompt, clientTurnId: directConversationTurnId, creationType, - attachments, directPolicyChecked = false, - references, userItem, }: ExecuteChatAgentReplyInput) { if (planningV2ActiveRef.current || planningStartMode) { @@ -7039,32 +7046,6 @@ export function App({ if (directProjectPath && directProjectId && directInvoke) { const clientTurnId = directConversationTurnId ?? createDirectCodexConversationTurnId(); - const baseUserItem = - userItem ?? - chatComposerDraftToDirectCodexUserItem( - { text: prompt, references: references ?? [], content: [] }, - directCodexConversationMessageId(clientTurnId, 'user'), - ); - const effectiveUserItem = attachments?.length - ? { - ...baseUserItem, - content: [ - ...baseUserItem.content, - ...attachments.map((attachment) => ({ - type: attachment.mediaType.toLowerCase().startsWith('image/') - ? ('agc_image_reference' as const) - : ('agc_attachment_reference' as const), - name: attachment.name, - mediaType: attachment.mediaType, - size: attachment.size ?? 0, - localPath: attachment.localPath ?? '', - status: - attachment.status ?? - (attachment.localPath ? 'imported' : 'failed'), - })), - ], - } - : baseUserItem; if ( !directPolicyChecked && projectConversationWriteConfirmedRef.current !== directProjectPath @@ -7074,9 +7055,7 @@ export function App({ prompt, clientTurnId, creationType, - attachments, - references, - userItem: effectiveUserItem, + userItem, }); try { const policyPaused = await queueProjectPolicyConfirmationIfNeeded( @@ -7210,12 +7189,12 @@ export function App({ projectPath: directProjectPath, prompt, clientTurnId, - userItem: effectiveUserItem, + userItem, }; if (creationType) { directTurnInput.creationType = creationType; } - directTurnInput.userItem = effectiveUserItem; + directTurnInput.userItem = userItem; const reply = await withDirectCodexSessionRefresh(() => { // 每次调用都会新建 Rust 事件流;续期重试需重新接收同一回合的进度。 activeDirectCodexTurnRef.current = { @@ -7529,9 +7508,14 @@ export function App({ return; } supervisorChatShouldFollowLatestRef.current = true; - const directConversationTurnId = directCodexProductRuntime - ? createDirectCodexConversationTurnId() - : undefined; + const directConversationTurnId = createDirectCodexConversationTurnId(); + const initialUserItem = directCodexUserItemFromContent( + [ + { type: 'input_text', text: latch.prompt }, + ...attachmentContentParts(latch.attachments), + ], + directCodexConversationMessageId(directConversationTurnId, 'user'), + ); setMessages((current) => [ ...current, { @@ -7553,7 +7537,9 @@ export function App({ prompt: latch.prompt, clientTurnId: directConversationTurnId, creationType: latch.creationType, - attachments: latch.attachments, + // TODO:该首页策划入口在 planning/legacy 模式仍复用此函数;迁移完成后拆出 + // 非 Direct Codex 的提交函数,避免旧链路携带 canonical user item 参数。 + userItem: initialUserItem, }); }, [ chatAgentBusy, @@ -12609,52 +12595,28 @@ export function App({ * 消息落盘/回合 id/附件参数走样。 */ function startDirectCodexConversationTurn(input: { - prompt: string; - attachments?: DirectCodexTurnAttachment[]; - references?: ChatReference[]; - content?: DirectCodexUserContentPart[]; + clientTurnId: string; + userItem: DirectCodexUserItem; }) { - const clientTurnId = createDirectCodexConversationTurnId(); - const attachmentContent: DirectCodexUserContentPart[] = ( - input.attachments ?? [] - ).map((attachment) => ({ - type: attachment.mediaType.toLowerCase().startsWith('image/') - ? ('agc_image_reference' as const) - : ('agc_attachment_reference' as const), - name: attachment.name, - mediaType: attachment.mediaType, - size: attachment.size ?? 0, - localPath: attachment.localPath ?? '', - status: - attachment.status ?? (attachment.localPath ? 'imported' : 'failed'), - })); - const content = [...(input.content ?? []), ...attachmentContent]; + const prompt = directCodexContentToPromptText( + input.userItem.content, + manifest.assets, + ); supervisorChatShouldFollowLatestRef.current = true; setMessages((current) => [ ...current, { role: 'user', - text: input.prompt, + text: prompt, runtimeOwned: true, - messageId: directCodexConversationMessageId(clientTurnId, 'user'), + messageId: input.userItem.id, updatedAt: Date.now(), }, ]); void executeChatAgentReply({ - prompt: input.prompt, - clientTurnId, - references: input.references, - userItem: chatComposerDraftToDirectCodexUserItem( - { - text: input.prompt, - references: input.references ?? [], - content, - }, - directCodexConversationMessageId(clientTurnId, 'user'), - ), - // DirectProject 的附件已经是 canonical content part;不能再作为 sidecar - // 传给 Rust,否则会重复追加。 - attachments: undefined, + prompt, + clientTurnId: input.clientTurnId, + userItem: input.userItem, }); } @@ -12715,12 +12677,24 @@ export function App({ ); } + function attachmentContentParts( + attachments: readonly DirectCodexTurnAttachment[], + ): DirectCodexUserContentPart[] { + return attachments.map((attachment) => ({ + type: 'agc_attachment_reference' as const, + name: attachment.name, + mediaType: attachment.mediaType, + size: attachment.size ?? 0, + localPath: attachment.localPath ?? '', + status: + attachment.status ?? (attachment.localPath ? 'imported' : 'failed'), + })); + } + /** 回合运行中再次发送:进本地 FIFO 队列;队列满时拒绝并保留草稿,不静默丢消息。 */ function enqueueChatTurnForRunningTurn(input: { - prompt: string; - attachments: DirectCodexTurnAttachment[]; - references: ChatReference[]; - content: DirectCodexUserContentPart[]; + clientTurnId: string; + userItem: DirectCodexUserItem; }): boolean { if (isChatTurnQueueFull(chatTurnQueueRef.current)) { setChatComposerNotice(chatQueueFullNotice()); @@ -12729,10 +12703,8 @@ export function App({ queuedChatTurnSequenceRef.current += 1; const turn = createQueuedChatTurn({ id: `queued-chat-turn-${Date.now()}-${queuedChatTurnSequenceRef.current}`, - prompt: input.prompt, - attachments: input.attachments, - references: input.references, - content: input.content, + clientTurnId: input.clientTurnId, + userItem: input.userItem, createdAt: Date.now(), }); const nextQueue = enqueueChatTurn(chatTurnQueueRef.current, turn); @@ -12763,10 +12735,8 @@ export function App({ setChatComposerNotice(''); } startDirectCodexConversationTurn({ - prompt: next.prompt, - attachments: next.attachments, - references: next.references, - content: next.content, + clientTurnId: next.clientTurnId, + userItem: next.userItem, }); } @@ -12824,10 +12794,12 @@ export function App({ ) { event.preventDefault(); const draft = readChatComposerDraft(); - const prompt = draft.text.trim(); - const references = draft.references; - const content = draft.content ?? []; const pendingAttachments = chatAttachments; + const content: DirectCodexUserContentPart[] = [ + ...draft.content, + ...attachmentContentParts(pendingAttachments), + ]; + const prompt = directCodexContentToPromptText(content, manifest.assets); if ( !directCodexProductRuntime && supervisorChatOnly && @@ -12843,23 +12815,20 @@ export function App({ setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); return; } - if ( - !prompt && - references.length === 0 && - content.length === 0 && - pendingAttachments.length === 0 - ) { + if (!hasMeaningfulDirectCodexContent(content)) { return; } if (chatAgentBusy) { // 回合运行中再次发送:direct-codex 面板把消息放进本地 FIFO 队列,当前回合结束后 // 依次发出;其它面板保持原有"运行中不接受新输入"的行为。 if (directCodexProductRuntime) { + const queuedTurnId = createDirectCodexConversationTurnId(); const enqueued = enqueueChatTurnForRunningTurn({ - prompt, - attachments: pendingAttachments, - references, - content, + clientTurnId: queuedTurnId, + userItem: directCodexUserItemFromContent( + content, + directCodexConversationMessageId(queuedTurnId, 'user'), + ), }); if (enqueued) { clearChatComposer(); @@ -12895,7 +12864,16 @@ export function App({ updatedAt: Date.now(), }, ]); - void executeChatAgentReply({ prompt, clientTurnId }); + // TODO:planning V2 仍未迁移到 canonical user item,这里只是为了满足必填契约 + // 显式构造一份纯文本 item;该链路迁走后应改走独立的非 Direct Codex 提交函数。 + void executeChatAgentReply({ + prompt, + clientTurnId, + userItem: directCodexUserItemFromContent( + [{ type: 'input_text', text: prompt }], + directCodexConversationMessageId(clientTurnId, 'user'), + ), + }); return; } if (supervisorChatOnly || directCodexProductRuntime) { @@ -12907,11 +12885,13 @@ export function App({ setChatAttachments([]); setChatAttachmentNotice(''); setChatComposerNotice(''); + const clientTurnId = createDirectCodexConversationTurnId(); startDirectCodexConversationTurn({ - prompt, - attachments: pendingAttachments, - references, - content, + clientTurnId, + userItem: directCodexUserItemFromContent( + content, + directCodexConversationMessageId(clientTurnId, 'user'), + ), }); return; } @@ -12925,7 +12905,16 @@ export function App({ updatedAt: Date.now(), }, ]); - void executeChatAgentReply({ prompt, references }); + // TODO:旧 Supervisor harness 链路仍未迁移到 canonical user item,这里只是为了满足 + // 必填契约显式构造一份纯文本 item;该链路退役后应连同此分支一起删除。 + const legacyTurnId = createAgentChatRunId('supervisor-chat-turn'); + void executeChatAgentReply({ + prompt, + userItem: directCodexUserItemFromContent( + [{ type: 'input_text', text: prompt }], + directCodexConversationMessageId(legacyTurnId, 'user'), + ), + }); } const visibleProfessionalAgentCards = agentStatusCards.filter( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 67b45d6c9..da7eda167 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -80,6 +80,7 @@ import { chatReferenceToContentPart, currentIterationVersionAssets, dedupeChatReferences, + directCodexContentToPromptText, refreshResourceReference, RESOURCE_REFERENCE_FILTERS, RESOURCE_REFERENCE_SCOPES, @@ -96,12 +97,9 @@ import { import { usePromptPolish } from './usePromptPolish'; type ResourceReferenceInputProps = { - value?: EditorState | string | null; - /** 仅用于尚未迁移的调用方提供初始引用;不会参与后续状态同步。 */ - references?: ChatReference[]; onChange?: (draft: ChatComposerDraft) => void; onEditorStateChange?: (editorState: EditorState) => void; - initialDraft?: Pick; + initialContent?: DirectCodexUserContentPart[]; assets: GameCreationAppAssetManifestEntry[]; projectPath: string; /** @@ -189,23 +187,18 @@ function appendInputText(content: DirectCodexUserContentPart[], text: string) { function collectDraftParts( node: LexicalNode, - textParts: string[], references: ChatReference[], content: DirectCodexUserContentPart[], ) { if ($isTextNode(node)) { - const text = node.getTextContent(); - textParts.push(text); - appendInputText(content, text); + appendInputText(content, node.getTextContent()); return; } if ($isLineBreakNode(node)) { - textParts.push('\n'); appendInputText(content, '\n'); return; } if ($isResourceReferenceNode(node)) { - textParts.push(`@${node.__reference.label}`); references.push(node.__reference); content.push(chatReferenceToContentPart(node.__reference)); return; @@ -213,34 +206,41 @@ function collectDraftParts( if ($isElementNode(node)) { node.getChildren().forEach((child, index) => { if (index > 0 && node.getType() === 'root') { - textParts.push('\n'); appendInputText(content, '\n'); } - collectDraftParts(child, textParts, references, content); + collectDraftParts(child, references, content); }); } } -/** 按编辑器自己的口径读草稿;只能在 Lexical 的读 / 更新上下文里调用。 */ -function readDraftFromNodes(): ChatComposerDraft { - const textParts: string[] = []; +type DraftProjection = { + references: ChatReference[]; + content: DirectCodexUserContentPart[]; +}; + +/** 仅供编辑器内部派生引用(重建文本草稿时用);对外只暴露 canonical content。 */ +function readDraftProjectionFromNodes(): DraftProjection { const references: ChatReference[] = []; const content: DirectCodexUserContentPart[] = []; - collectDraftParts($getRoot(), textParts, references, content); + collectDraftParts($getRoot(), references, content); return { - text: textParts.join('').trim(), references: dedupeChatReferences(references), content, }; } +/** 按编辑器自己的口径读 canonical 草稿;只能在 Lexical 读/更新上下文里调用。 */ +function readDraftFromNodes(): ChatComposerDraft { + return { content: readDraftProjectionFromNodes().content }; +} + // The pure projection is exported for submit-time reads and focused tests. // eslint-disable-next-line react-refresh/only-export-components export function readResourceReferenceDraft( editorState: EditorState | null, ): ChatComposerDraft { if (!editorState) { - return { text: '', references: [], content: [] }; + return { content: [] }; } return editorState.read(readDraftFromNodes); } @@ -352,6 +352,59 @@ function applyDraftToRoot(value: string, references: ChatReference[]) { }); } +function referenceFromContentPart( + part: DirectCodexUserContentPart, + assetsById: ReadonlyMap, +): ChatReference | null { + if (part.type === 'agc_resource_reference') { + const asset = assetsById.get(part.resourceId); + return asset ? resourceReferenceFromAsset(asset, 'asset-picker') : null; + } + if (part.type === 'agc_runtime_region_reference') { + return { + type: 'runtime-region', + label: part.label, + runId: part.runId ?? undefined, + versionId: part.versionId ?? undefined, + elementTag: part.elementTag ?? undefined, + elementRole: part.elementRole ?? undefined, + text: part.text ?? undefined, + width: part.width ?? undefined, + height: part.height ?? undefined, + resourceIds: part.resourceIds, + source: 'runtime-picker', + }; + } + return null; +} + +function applyContentToRoot( + content: readonly DirectCodexUserContentPart[], + assetsById: ReadonlyMap, +) { + const root = $getRoot(); + root.clear(); + let paragraph = $createParagraphNode(); + root.append(paragraph); + content.forEach((part) => { + if (part.type === 'input_text') { + const lines = part.text.split('\n'); + lines.forEach((line, index) => { + if (line) paragraph.append($createTextNode(line)); + if (index < lines.length - 1) { + paragraph = $createParagraphNode(); + root.append(paragraph); + } + }); + return; + } + const reference = referenceFromContentPart(part, assetsById); + if (reference) { + paragraph.append($createResourceReferenceNode(reference)); + } + }); +} + function isMentionableAsset(asset: GameCreationAppAssetManifestEntry) { return Boolean(asset.localPath) && !asset.localPath.startsWith('.agent/'); } @@ -446,7 +499,7 @@ function $staleResourceReferenceNodes( function ResourceReferenceEditor({ onChange, onEditorStateChange, - initialDraft, + initialContent, assets, projectPath, activeVersionId = null, @@ -487,6 +540,12 @@ function ResourceReferenceEditor({ } | null>(null); const assetsContentSignature = assetsSignature(assets); const versionsContentSignature = iterationsSignature(versions); + const assetsById = useMemo( + () => new Map(assets.map((asset) => [asset.id, asset])), + // 依赖内容签名:assets 数组身份每次渲染都会变,内容不变时没必要重建索引。 + // eslint-disable-next-line react-hooks/exhaustive-deps + [assetsContentSignature], + ); const assetReferences = useMemo( () => mentionableAssetReferences(assets, 'asset-picker'), // 依赖内容签名:调用方每次渲染都会重建 assets 数组,内容不变时没必要重算。 @@ -616,20 +675,20 @@ function ResourceReferenceEditor({ focus: () => editor.focus(), clear: () => { editor.update(() => { - applyDraftToRoot('', []); + applyContentToRoot([], assetsById); $getRoot().selectEnd(); }); }, replaceText: (text: string) => { editor.update(() => { - const current = readDraftFromNodes(); + const current = readDraftProjectionFromNodes(); applyDraftToRoot(text, current.references); $getRoot().selectEnd(); }); }, getDraft: () => readResourceReferenceDraft(editor.getEditorState()), }), - [editor, insertReferences, insertText, openPicker], + [assetsById, editor, insertReferences, insertText, openPicker], ); useEffect(() => { @@ -638,25 +697,16 @@ function ResourceReferenceEditor({ const initialDraftAppliedRef = useRef(false); useEffect(() => { - if (initialDraftAppliedRef.current || !initialDraft) { + if (initialDraftAppliedRef.current || !initialContent) { return; } initialDraftAppliedRef.current = true; editor.update(() => { - applyDraftToRoot(initialDraft.text, initialDraft.references); - skipInitialDraftChangeRef.current = - initialDraft.text.trim().length > 0 || - initialDraft.references.length > 0; + applyContentToRoot(initialContent, assetsById); + skipInitialDraftChangeRef.current = initialContent.length > 0; $getRoot().selectEnd(); }); - }, [editor, initialDraft]); - - const assetsById = useMemo( - () => new Map(assets.map((asset) => [asset.id, asset])), - // 依赖内容签名:assets 数组身份每次渲染都会变,内容不变时没必要重建索引。 - // eslint-disable-next-line react-hooks/exhaustive-deps - [assetsContentSignature], - ); + }, [assetsById, editor, initialContent]); // 资源改名后刷新已有引用 chip 的显示名:改写节点会触发 OnChangePlugin, // 把带新显示名的草稿同步回父级,chip 与候选列表都不会残留旧名。 @@ -761,25 +811,32 @@ function ResourceReferenceEditor({ const acknowledgedDraftKeyRef = useRef(null); // 拦截表单提交需要读到最新草稿,用 ref 保存本次渲染的草稿与派生值,避免闭包读到旧值。 const liveDraftRef = useRef({ - text: initialDraft?.text ?? '', - references: initialDraft?.references ?? [], - content: [], + content: initialContent ?? [], }); - const [draftText, setDraftText] = useState(initialDraft?.text ?? ''); + const [draftText, setDraftText] = useState(() => + directCodexContentToPromptText(initialContent ?? [], assets), + ); const reminderDisabledRef = useRef(reminderDisabled); reminderDisabledRef.current = reminderDisabled; + // 提交拦截只注册一次,但草稿里的 @ 显示名依赖最新 assets;用 ref 让监听器拿到当前值。 + const assetsRef = useRef(assets); + assetsRef.current = assets; const applyPromptText = useCallback( (text: string) => { editor.update(() => { - applyDraftToRoot(text, liveDraftRef.current.references); + const current = readDraftProjectionFromNodes(); + applyDraftToRoot(text, current.references); $getRoot().selectEnd(); }); }, [editor], ); - const readPromptText = useCallback(() => liveDraftRef.current.text, []); + const readPromptText = useCallback( + () => directCodexContentToPromptText(liveDraftRef.current.content, assets), + [assets], + ); const resolvePolishContext = useCallback( () => projectPath || null, [projectPath], @@ -813,7 +870,9 @@ function ResourceReferenceEditor({ // 输入区不在表单里时(例如单独渲染的单元测试)没有可提交的表单事件,仅取消提醒状态。 const submitCurrentDraft = useCallback(() => { setReminderOpen(false); - acknowledgedDraftKeyRef.current = chatPromptDraftKey(liveDraftRef.current); + acknowledgedDraftKeyRef.current = chatPromptDraftKey( + liveDraftRef.current.content, + ); rootRef.current?.closest('form')?.requestSubmit(); }, [rootRef]); @@ -850,7 +909,11 @@ function ResourceReferenceEditor({ const handleFormSubmit = (event: Event) => { if ( !shouldRemindChatPromptPolish({ - draft: liveDraftRef.current, + content: liveDraftRef.current.content, + prompt: directCodexContentToPromptText( + liveDraftRef.current.content, + assetsRef.current, + ), acknowledgedDraftKey: acknowledgedDraftKeyRef.current, reminderDisabled: reminderDisabledRef.current, }) @@ -867,8 +930,7 @@ function ResourceReferenceEditor({ // 草稿发出去或被清空后重新开始一轮:清掉润色结果与「本轮已确认」标记。 useEffect(() => { - const draft = liveDraftRef.current; - if (draft.text.trim() !== '' || draft.references.length > 0) return; + if (liveDraftRef.current.content.length > 0) return; resetPromptPolish(); acknowledgedDraftKeyRef.current = null; }, [resetPromptPolish]); @@ -1216,11 +1278,10 @@ function ResourceReferenceEditor({ onChange={(editorState) => { const nextDraft = readResourceReferenceDraft(editorState); liveDraftRef.current = nextDraft; - setDraftText(nextDraft.text); - if ( - nextDraft.text.trim() === '' && - nextDraft.references.length === 0 - ) { + setDraftText( + directCodexContentToPromptText(nextDraft.content, assets), + ); + if (nextDraft.content.length === 0) { resetPromptPolish(); acknowledgedDraftKeyRef.current = null; } @@ -1305,18 +1366,10 @@ export const ResourceReferenceInput = forwardRef< ResourceReferenceInputProps >(function ResourceReferenceInput(props, ref) { const rootRef = useRef(null); - const initialEditorState = - typeof props.value === 'string' ? null : props.value; - const initialDraft = - props.initialDraft ?? - (typeof props.value === 'string' - ? { text: props.value, references: props.references ?? [] } - : undefined); return ( } > - + ); }); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts b/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts index ff017ce91..11ac62f89 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts @@ -4,36 +4,32 @@ * 回合运行中用户再次发送时,消息进入 FIFO 队列而不是被丢弃;当前回合结束后按入队顺序 * 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。 */ -import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments'; -import type { DirectCodexUserContentPart } from './generated'; -import type { ChatReference } from './resourceReferences'; +import type { DirectCodexUserItem } from './generated'; +import { directCodexContentToPromptText } from './resourceReferences'; /** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */ export const MAX_QUEUED_CHAT_TURNS = 5; export type QueuedChatTurn = { id: string; - prompt: string; - attachments: DirectCodexTurnAttachment[]; - references: ChatReference[]; - content?: DirectCodexUserContentPart[]; + clientTurnId: string; + userItem: DirectCodexUserItem; createdAt: number; }; export function createQueuedChatTurn(input: { id: string; - prompt: string; - attachments?: readonly DirectCodexTurnAttachment[]; - references?: readonly ChatReference[]; - content?: readonly DirectCodexUserContentPart[]; + clientTurnId: string; + userItem: DirectCodexUserItem; createdAt: number; }): QueuedChatTurn { return { id: input.id, - prompt: input.prompt, - attachments: [...(input.attachments ?? [])], - references: [...(input.references ?? [])], - content: [...(input.content ?? [])], + clientTurnId: input.clientTurnId, + userItem: { + ...input.userItem, + content: [...input.userItem.content], + }, createdAt: input.createdAt, }; } @@ -79,14 +75,19 @@ export function chatQueueFullNotice(): string { /** 队列 chip 上显示的文字:单行、有长度上限。 */ export function queuedChatTurnLabel(turn: QueuedChatTurn): string { - const text = turn.prompt.trim().replace(/\s+/gu, ' '); + const text = directCodexContentToPromptText(turn.userItem.content) + .trim() + .replace(/\s+/gu, ' '); if (text) { return text.length > 24 ? `${text.slice(0, 24)}…` : text; } - if (turn.attachments.length > 0) { - return `附件 · ${turn.attachments[0]?.name ?? '未命名'}`; + const attachment = turn.userItem.content.find( + (part) => part.type === 'agc_attachment_reference', + ); + if (attachment?.type === 'agc_attachment_reference') { + return `附件 · ${attachment.name || '未命名'}`; } - if (turn.references.length > 0) { + if (turn.userItem.content.some((part) => part.type !== 'input_text')) { return '素材引用'; } return '未命名消息'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts b/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts index 68372d749..cc509e140 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts @@ -1,8 +1,5 @@ import { resolveTauriInvoke } from '../../app/tauri'; -import { - type ChatComposerDraft, - chatReferenceListKey, -} from './resourceReferences'; +import type { DirectCodexUserContentPart } from './generated'; /** * 「不再提醒」偏好存本机 localStorage,不进 manifest、不进后端。 @@ -61,8 +58,10 @@ export function writeChatPromptPolishReminderDisabled(disabled: boolean) { } /** 草稿指纹:用于判断「本轮草稿」是否已经被润色或确认过。 */ -export function chatPromptDraftKey(draft: ChatComposerDraft) { - return `${draft.text}\u0000${chatReferenceListKey(draft.references)}`; +export function chatPromptDraftKey( + content: readonly DirectCodexUserContentPart[], +) { + return JSON.stringify(content); } /** @@ -73,25 +72,27 @@ export function chatPromptDraftKey(draft: ChatComposerDraft) { * 4. 草稿不是以 `/` 开头的命令 —— 命令走直通路径,不参与提醒。 */ export function shouldRemindChatPromptPolish({ - draft, + content, + prompt, acknowledgedDraftKey, reminderDisabled, }: { - draft: ChatComposerDraft; + content: readonly DirectCodexUserContentPart[]; + prompt: string; acknowledgedDraftKey: string | null; reminderDisabled: boolean; }) { if (reminderDisabled) { return false; } - const text = draft.text.trim(); + const text = prompt.trim(); if (text.length < CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH) { return false; } if (text.startsWith('/')) { return false; } - return chatPromptDraftKey(draft) !== acknowledgedDraftKey; + return chatPromptDraftKey(content) !== acknowledgedDraftKey; } /** diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index 72f46c677..f6a8f6712 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -49,9 +49,7 @@ export type RuntimeRegionReference = { export type ChatReference = ResourceReference | RuntimeRegionReference; export type ChatComposerDraft = { - text: string; - references: ChatReference[]; - /** Lexical 顺序对应的 canonical user content;只从 EditorState 派生。 */ + /** Lexical 顺序对应的 canonical user content;这是唯一草稿真相。 */ content: DirectCodexUserContentPart[]; }; @@ -88,11 +86,39 @@ export function isResourceReferenceOverlayTarget(target: EventTarget | null) { } export const EMPTY_CHAT_COMPOSER_DRAFT: ChatComposerDraft = { - text: '', - references: [], content: [], }; +export function directCodexContentToPromptText( + content: readonly DirectCodexUserContentPart[], + assets: readonly GameCreationAppAssetManifestEntry[] = [], +) { + const labels = new Map( + assets.map((asset) => [asset.id, resourceDisplayName(asset)]), + ); + return content + .map((part) => { + if (part.type === 'input_text') return part.text; + if (part.type === 'agc_resource_reference') { + return `@${labels.get(part.resourceId) ?? part.resourceId}`; + } + if (part.type === 'agc_runtime_region_reference') { + return `@${part.label}`; + } + return ''; + }) + .join('') + .trim(); +} + +export function hasMeaningfulDirectCodexContent( + content: readonly DirectCodexUserContentPart[], +) { + return content.some( + (part) => part.type !== 'input_text' || part.text.trim().length > 0, + ); +} + export function chatReferenceToContentPart( reference: ChatReference, ): DirectCodexUserContentPart { @@ -117,17 +143,21 @@ export function chatComposerDraftToDirectCodexUserItem( draft: ChatComposerDraft, id: string, ): DirectCodexUserItem { - const content = draft.content.filter( - (part) => part.type !== 'input_text' || part.text.trim().length > 0, - ); return { type: 'message', role: 'user', - content, + content: [...draft.content], id, } satisfies DirectCodexUserItem; } +export function directCodexUserItemFromContent( + content: readonly DirectCodexUserContentPart[], + id: string, +): DirectCodexUserItem { + return chatComposerDraftToDirectCodexUserItem({ content: [...content] }, id); +} + export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) { const fileName = asset.localPath.split(/[\\/]/u).pop() ?? asset.id; return fileName.replace(/\.[^.]+$/u, '').trim() || asset.id; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index ffe964a78..38146ff4a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -111,6 +111,7 @@ import { } from '../../features/project-workspace/ResourceReferenceInput'; import { type ChatComposerDraft, + directCodexContentToPromptText, dispatchResourceReferenceInsert, isResourceReferenceOverlayTarget, resolveActiveIterationVersion, @@ -6491,7 +6492,10 @@ export default function ProjectDevelopmentView({ */ const applyResourceQuickEditPrompt = useCallback((text: string) => { const currentDraft = quickEditPromptInputRef.current?.getDraft(); - if (currentDraft && currentDraft.text !== text) { + if ( + currentDraft && + directCodexContentToPromptText(currentDraft.content) !== text + ) { quickEditPromptInputRef.current?.replaceText(text); } setQuickEditPanel((current) => @@ -6515,7 +6519,9 @@ export default function ProjectDevelopmentView({ */ const applyResourceQuickEditDraft = useCallback( (draft: ChatComposerDraft) => { - applyResourceQuickEditPrompt(draft.text); + applyResourceQuickEditPrompt( + directCodexContentToPromptText(draft.content), + ); }, [applyResourceQuickEditPrompt], ); @@ -8135,10 +8141,12 @@ export default function ProjectDevelopmentView({ ref={quickEditPromptInputRef} key={quickEditSourceLayer?.id} ariaLabel="快速编辑提示词" - initialDraft={{ - text: quickEditPanel.prompt, - references: [], - }} + initialContent={[ + { + type: 'input_text', + text: quickEditPanel.prompt, + }, + ]} onChange={applyResourceQuickEditDraft} assets={manifest.assets} projectPath={projectPath} diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 45f59dcd4..ed9c30264 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -4,6 +4,7 @@ import { dequeueChatTurn, enqueueChatTurn, isChatTurnQueueFull, + queuedChatTurnLabel, removeQueuedChatTurn, } from '../../src/features/project-workspace/chatComposerQueue'; import { @@ -15,6 +16,7 @@ import { VOICE_INPUT_UNSUPPORTED_MESSAGE, } from '../../src/features/project-workspace/chatComposerVoice'; import { ComposerVoiceButton } from '../../src/features/project-workspace/ComposerControls'; +import { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences'; import { act, createGameCreationAppManifest, @@ -175,23 +177,29 @@ function installFakeSpeechRecognition(): { }; } +/** 排队回合只持 canonical user item;展示文案由它派生。 */ +function queuedTurn( + id: string, + clientTurnId: string, + text: string, + createdAt: number, +) { + return createQueuedChatTurn({ + id, + clientTurnId, + userItem: directCodexUserItemFromContent( + [{ type: 'input_text', text }], + `${clientTurnId}:user`, + ), + createdAt, + }); +} + export function registerChatComposerControlTests() { it('keeps queued chat turns in FIFO order and drops only the cancelled one', () => { - const first = createQueuedChatTurn({ - id: 'turn-1', - prompt: '第一条', - createdAt: 1, - }); - const second = createQueuedChatTurn({ - id: 'turn-2', - prompt: '第二条', - createdAt: 2, - }); - const third = createQueuedChatTurn({ - id: 'turn-3', - prompt: '第三条', - createdAt: 3, - }); + const first = queuedTurn('turn-1', 'client-1', '第一条', 1); + const second = queuedTurn('turn-2', 'client-2', '第二条', 2); + const third = queuedTurn('turn-3', 'client-3', '第三条', 3); let queue = enqueueChatTurn([], first); queue = enqueueChatTurn(queue, second); @@ -201,15 +209,18 @@ export function registerChatComposerControlTests() { // FIFO:先入先出,不丢、不乱序。 const firstOut = dequeueChatTurn(queue); - expect(firstOut.next?.prompt).toBe('第一条'); - expect(firstOut.rest.map((turn) => turn.prompt)).toEqual([ + expect(firstOut.next?.clientTurnId).toBe('client-1'); + expect(firstOut.next && queuedChatTurnLabel(firstOut.next)).toBe('第一条'); + expect(firstOut.rest.map((turn) => queuedChatTurnLabel(turn))).toEqual([ '第二条', '第三条', ]); // 单条取消只移除那一条,顺序不变。 expect( - removeQueuedChatTurn(firstOut.rest, 'turn-2').map((turn) => turn.prompt), + removeQueuedChatTurn(firstOut.rest, 'turn-2').map((turn) => + queuedChatTurnLabel(turn), + ), ).toEqual(['第三条']); expect(removeQueuedChatTurn(firstOut.rest, 'turn-missing')).toHaveLength(2); @@ -222,11 +233,7 @@ export function registerChatComposerControlTests() { for (let index = 0; index < 5; index += 1) { queue = enqueueChatTurn( queue, - createQueuedChatTurn({ - id: `turn-${index}`, - prompt: `第 ${index} 条`, - createdAt: index, - }), + queuedTurn(`turn-${index}`, `client-${index}`, `第 ${index} 条`, index), ); } expect(isChatTurnQueueFull(queue)).toBe(true); diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 01e5f217d..416bd28e9 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1672,17 +1672,19 @@ export function registerHomeProjectCreationTests() { id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), type: 'message', role: 'user', - content: [{ type: 'input_text', text: '按这个角色做游戏' }], + // 首页附件随首轮一起进 canonical content:它就是这一轮输入的一部分。 + content: [ + { type: 'input_text', text: '按这个角色做游戏' }, + { + type: 'agc_attachment_reference', + name: '角色参考.png', + mediaType: 'image/png', + size: attachment.size, + localPath: 'assets/uploads/reference.png', + status: 'imported', + }, + ], }, - 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', @@ -1717,7 +1719,11 @@ export function registerHomeProjectCreationTests() { (args as Record | undefined)?.prompt === '再补一句玩法', )?.[1] as Record | undefined; - expect(followUpPayload).not.toHaveProperty('attachments'); + // 后续这一轮没有附件:canonical content 里只有文本 part。 + expect( + (followUpPayload?.userItem as { content?: unknown[] } | undefined) + ?.content, + ).toEqual([{ type: 'input_text', text: '再补一句玩法' }]); }); it('starts an automatic game project from the approved GDD', async () => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index eac40a8c4..1b85e31a0 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -2,6 +2,7 @@ import { directCodexPolicyRetryInput, isDirectCodexTurnAlreadyRunningError, } from '../../src/App'; +import { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences'; import { act, agentRuntimeUserInputRequest, @@ -49,27 +50,30 @@ export function registerProjectConversationTests() { it('carries the whole direct turn input, including @ references, into the policy-confirmation retry', () => { // 确认 `conversation.write` 之后重跑的是同一轮输入:漏掉任何一项都会让用户 - // 在确认之后拿到另一轮内容。历史缺陷正是漏了 `references`(@ 引用被静默丢掉), - // 所以这里把「首轮入参整体带过去」钉成硬约束。 + // 在确认之后拿到另一轮内容。历史缺陷正是漏了 canonical user item 里的 @ 引用 + // (引用被静默丢掉),所以这里把「首轮入参整体带过去」钉成硬约束。 const firstTurn = { prompt: '用这张图改一下', clientTurnId: 'direct-turn-1', creationType: 'game' as const, - attachments: [ - { name: '角色草图.png', mediaType: 'image/png', size: 128 }, - ], - references: [ - { - type: 'resource' as const, - resourceId: 'reference-hero', - kind: 'image', - mediaType: 'image/png', - label: 'hero.png', - category: 'scene' as const, - tags: ['主舞台'], - source: 'resource-card' as const, - }, - ], + userItem: directCodexUserItemFromContent( + [ + { type: 'input_text' as const, text: '用这张图改一下' }, + { + type: 'agc_resource_reference' as const, + resourceId: 'reference-hero', + }, + { + type: 'agc_attachment_reference' as const, + name: '角色草图.png', + mediaType: 'image/png', + size: 128, + localPath: '', + status: 'imported' as const, + }, + ], + 'direct-turn-1:user', + ), }; expect(directCodexPolicyRetryInput(firstTurn)).toEqual({ @@ -77,9 +81,10 @@ export function registerProjectConversationTests() { directPolicyChecked: true, }); - // 引用是这一次输入的判别项,单独再断言一遍,避免上面整体相等被未来字段扩展掩盖。 - expect(directCodexPolicyRetryInput(firstTurn).references).toEqual( - firstTurn.references, + // canonical content 是这一次输入的判别项,单独再断言一遍,避免上面整体相等 + // 被未来字段扩展掩盖。 + expect(directCodexPolicyRetryInput(firstTurn).userItem.content).toEqual( + firstTurn.userItem.content, ); }); diff --git a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx index ac2e37ffa..4439cd42f 100644 --- a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx +++ b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx @@ -21,11 +21,14 @@ import { shouldRemindChatPromptPolish, writeChatPromptPolishReminderDisabled, } from '../src/features/project-workspace/chatPromptPolish'; +import type { DirectCodexUserContentPart } from '../src/features/project-workspace/generated'; import type { ResourceReferenceInputHandle } from '../src/features/project-workspace/ResourceReferenceInput'; import { ResourceReferenceInput } from '../src/features/project-workspace/ResourceReferenceInput'; import { type ChatComposerDraft, type ChatReference, + chatReferenceToContentPart, + hasMeaningfulDirectCodexContent, resourceReferenceFromAsset, } from '../src/features/project-workspace/resourceReferences'; @@ -82,6 +85,11 @@ async function composerText() { return screen.getByLabelText('创作想法').textContent ?? ''; } +/** 纯文本草稿的 canonical content。 */ +function textContent(text: string): DirectCodexUserContentPart[] { + return text ? [{ type: 'input_text', text }] : []; +} + function ControlledChatComposer({ initialText, initialReferences = [], @@ -92,28 +100,28 @@ function ControlledChatComposer({ onSubmitDraft: (draft: ChatComposerDraft) => void; }) { const composerRef = useRef(null); + const initialContent: DirectCodexUserContentPart[] = [ + ...textContent(initialText), + ...initialReferences.map(chatReferenceToContentPart), + ]; return ( { event.preventDefault(); const draft = composerRef.current?.getDraft(); - const submitted = - draft && (draft.text || draft.references.length > 0) - ? draft - : { - text: initialText, - references: initialReferences, - content: [], - }; + const submittedContent = hasMeaningfulDirectCodexContent( + draft?.content ?? [], + ) + ? draft!.content + : initialContent; onSubmitDraft({ - text: submitted.text, - references: submitted.references, - } as ChatComposerDraft); + content: submittedContent, + }); }} > {}} assets={[]} projectPath="C:/project" @@ -161,41 +169,45 @@ afterEach(() => { describe('发送前提醒判据', () => { test('only reminds for long plain prompts that were not acknowledged this round', () => { - const draft: ChatComposerDraft = { - text: '字'.repeat(CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH), - references: [], - }; + const longPrompt = '字'.repeat(CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH); + const content = textContent(longPrompt); expect( shouldRemindChatPromptPolish({ - draft, + content, + prompt: longPrompt, acknowledgedDraftKey: null, reminderDisabled: false, }), ).toBe(true); expect( shouldRemindChatPromptPolish({ - draft: { text: '短需求', references: [] }, + content: textContent('短需求'), + prompt: '短需求', acknowledgedDraftKey: null, reminderDisabled: false, }), ).toBe(false); expect( shouldRemindChatPromptPolish({ - draft, + content, + prompt: longPrompt, acknowledgedDraftKey: null, reminderDisabled: true, }), ).toBe(false); expect( shouldRemindChatPromptPolish({ - draft, - acknowledgedDraftKey: chatPromptDraftKey(draft), + content, + prompt: longPrompt, + acknowledgedDraftKey: chatPromptDraftKey(content), reminderDisabled: false, }), ).toBe(false); + const command = `/${'长'.repeat(60)}`; expect( shouldRemindChatPromptPolish({ - draft: { text: `/${'长'.repeat(60)}`, references: [] }, + content: textContent(command), + prompt: command, acknowledgedDraftKey: null, reminderDisabled: false, }), @@ -213,8 +225,11 @@ describe('发送前提醒判据', () => { }, 'asset-picker', ); - expect(chatPromptDraftKey({ text: '需求', references: [] })).not.toBe( - chatPromptDraftKey({ text: '需求', references: [reference] }), + expect(chatPromptDraftKey(textContent('需求'))).not.toBe( + chatPromptDraftKey([ + ...textContent('需求'), + chatReferenceToContentPart(reference), + ]), ); }); @@ -325,8 +340,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { ); await waitFor(() => { expect(onSubmitDraft).toHaveBeenCalledWith({ - text: LONG_PROMPT, - references: [], + content: textContent(LONG_PROMPT), }); }); expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); @@ -357,8 +371,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { ); await waitFor(() => { expect(onSubmitDraft).toHaveBeenCalledWith({ - text: '润色后的长需求', - references: [], + content: textContent('润色后的长需求'), }); }); expect(await composerText()).toBe('润色后的长需求'); @@ -386,8 +399,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { ); await waitFor(() => { expect(onSubmitDraft).toHaveBeenCalledWith({ - text: LONG_PROMPT, - references: [], + content: textContent(LONG_PROMPT), }); }); }); @@ -424,8 +436,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { resolvePolish('润色后的长需求'); await waitFor(() => { expect(onSubmitDraft).toHaveBeenCalledWith({ - text: '润色后的长需求', - references: [], + content: textContent('润色后的长需求'), }); }); }); @@ -447,8 +458,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { fireEvent.click(sendButton()); await waitFor(() => { expect(onSubmitDraft).toHaveBeenCalledWith({ - text: LONG_PROMPT, - references: [], + content: textContent(LONG_PROMPT), }); }); expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); @@ -464,8 +474,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { fireEvent.click(sendButton()); expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); expect(onSubmitDraft).toHaveBeenCalledWith({ - text: LONG_PROMPT, - references: [], + content: textContent(LONG_PROMPT), }); }); @@ -474,8 +483,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { fireEvent.click(sendButton()); expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); expect(shortSubmit).toHaveBeenCalledWith({ - text: '做个跳跃游戏', - references: [], + content: textContent('做个跳跃游戏'), }); cleanup(); @@ -485,8 +493,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { fireEvent.click(sendButton()); expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); expect(commandSubmit).toHaveBeenCalledWith({ - text: `/${'命令'.repeat(40)}`, - references: [], + content: textContent(`/${'命令'.repeat(40)}`), }); }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index a21ddbacd..68ace0833 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -28,8 +28,10 @@ import { type ChatComposerDraft, type ChatReference, chatReferenceListKey, + chatReferenceToContentPart, currentIterationVersionAssets, dedupeChatReferences, + directCodexContentToPromptText, dispatchResourceReferenceInsert, resolveActiveIterationVersion, RESOURCE_REFERENCE_FILTERS, @@ -83,6 +85,18 @@ async function settleComposer() { }); } +/** 草稿展示文本:canonical content 是唯一真相,引用按稳定 id 展开成 `@id`。 */ +function draftText(draft: ChatComposerDraft | undefined) { + return directCodexContentToPromptText(draft?.content ?? []); +} + +/** 草稿里的资源引用 id,按 content 顺序。 */ +function draftResourceIds(draft: ChatComposerDraft | undefined) { + return (draft?.content ?? []).flatMap((part) => + part.type === 'agc_resource_reference' ? [part.resourceId] : [], + ); +} + /** * 编辑器文本模型里的字符数。断言「一个引用 = 一个字符」用它而不是 DOM 文本: * DOM 里 chip 仍要显示 `@显示名` 给用户看,两者本来就不该相等。 @@ -207,8 +221,10 @@ describe('ResourceReferenceInput', () => { { const user = userEvent.setup(); function Controlled() { const [draft, setDraft] = useState({ - text: '要一个', - references: [], + content: [{ type: 'input_text', text: '要一个' }], }); return ( { }} > { const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); render( { expect(onChange).toHaveBeenCalled(); }); const draft = onChange.mock.calls.at(-1)?.[0]; - expect(draft?.text).toBe('@hero @enemy'); - expect(draft?.references.map((reference) => reference.resourceId)).toEqual([ - 'hero', - 'enemy', - ]); + // canonical content 只承载有意义的 part:chip 之间的分隔空格是纯 UI 排版, + // 引用本身由稳定 resourceId 表达。空格也不进内容——Rust 会拒绝纯空白 input_text。 + expect(draftText(draft)).toBe('@hero@enemy'); + expect(draftResourceIds(draft)).toEqual(['hero', 'enemy']); expect( document.querySelector('[data-resource-reference-id="hero"]'), ).not.toBeNull(); @@ -327,8 +338,6 @@ describe('ResourceReferenceInput', () => { render( { const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); render( { await user.click(screen.getByRole('button', { name: '移除引用 hero' })); await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0].references).toHaveLength(0); + expect(draftResourceIds(onChange.mock.calls.at(-1)?.[0])).toHaveLength(0); }); }); @@ -521,8 +528,6 @@ describe('ResourceReferenceInput', () => { const user = userEvent.setup(); render( { const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); render( { expect(chip?.contains(deleteButton)).toBe(true); // 提交用的结构化引用完整保留,末尾那个分隔空格提交前会被 trim 掉。 - expect(onChange.mock.calls.at(-1)?.[0].text).toBe('@hero'); - expect(onChange.mock.calls.at(-1)?.[0].references[0]?.resourceId).toBe( - 'hero', - ); + expect(draftText(onChange.mock.calls.at(-1)?.[0])).toBe('@hero'); + expect(draftResourceIds(onChange.mock.calls.at(-1)?.[0])).toEqual(['hero']); // 引用节点是原子的:一次操作删掉整个 chip,不存在"删一半"的中间态。 await user.click(deleteButton); @@ -585,8 +586,8 @@ describe('ResourceReferenceInput', () => { expect( document.querySelector('[data-resource-reference-id="hero"]'), ).toBeNull(); - expect(onChange.mock.calls.at(-1)?.[0].references).toHaveLength(0); - expect(onChange.mock.calls.at(-1)?.[0].text).toBe(''); + expect(draftResourceIds(onChange.mock.calls.at(-1)?.[0])).toHaveLength(0); + expect(draftText(onChange.mock.calls.at(-1)?.[0])).toBe(''); }); test('exposes the current-version and all-canvas scopes as the only two tabs', () => { @@ -631,8 +632,6 @@ describe('ResourceReferenceInput', () => { const user = userEvent.setup(); render( { const user = userEvent.setup(); render( { const user = userEvent.setup(); render( { test('refreshes chip and candidate display names after a resource rename', async () => { const user = userEvent.setup(); const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const composerRef = createRef(); const renamedAssets = [ asset('hero', 'character', 'image/png', 'assets/hero-final.png'), assets[1]!, @@ -733,8 +729,13 @@ describe('ResourceReferenceInput', () => { ]; render( { document.querySelector('.resource-reference-chip-label')?.textContent, ).toBe('hero-final'); }); - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0].references[0]?.label).toBe( - 'hero-final', - ); - }); + // 改名只影响显示名:canonical content 仍只记稳定 resourceId。 + expect(draftResourceIds(composerRef.current?.getDraft())).toEqual(['hero']); await user.click(screen.getByRole('button', { name: '插入素材引用' })); expect(screen.getByRole('option', { name: /hero-final/u })).not.toBeNull(); @@ -764,8 +762,7 @@ describe('ResourceReferenceInput', () => { const composerRef = createRef(); render( { await user.click(screen.getByRole('button', { name: '插入引用' })); await settleComposer(); - expect(onChange.mock.calls.at(-1)?.[0].text).toBe('恢复出来的草稿@hero'); + expect( + onChange.mock.calls + .at(-1)?.[0] + .content.filter((part) => part.type === 'input_text') + .map((part) => (part.type === 'input_text' ? part.text : '')) + .join(''), + ).toBe('恢复出来的草稿'); }); test('标签库按 manifest 标签派生:计数只算候选、排序稳定、多标签取交集', () => { @@ -814,8 +817,6 @@ describe('ResourceReferenceInput', () => { const user = userEvent.setup(); render( { const user = userEvent.setup(); render( { render( <> { const quickEditDraft = quickEditOnChange.mock.calls.at(-1)?.[0]; // 同一个资产在两条入口上插入,回填文本与结构化引用必须逐字相同: // 「快速编辑」不允许出现第二种引用格式。 - expect(chatDraft?.text).toBe('@hero'); - expect(quickEditDraft).toEqual(chatDraft); - expect(quickEditDraft?.references).toEqual([ - resourceReferenceFromAsset(taggedAssets[0]!, 'asset-picker'), + expect(chatDraft?.content).toEqual([ + { type: 'agc_resource_reference', resourceId: 'hero' }, ]); + expect(quickEditDraft).toEqual(chatDraft); expect( document.querySelectorAll('[data-resource-reference-id="hero"]'), ).toHaveLength(2); @@ -961,8 +955,7 @@ describe('ResourceReferenceInput', () => { test('快速编辑提示词输入区不渲染内置润色入口:润色归宿主的 ResourcePromptPolishSlot', async () => { render( { it('保留 Lexical content 的文本与引用交错顺序', () => { const draft: ChatComposerDraft = { - text: '忽略的扁平摘要', - references: [], content: [ { type: 'input_text', text: '先看 ' }, { type: 'agc_resource_reference', resourceId: 'asset-hero' }, @@ -41,19 +41,6 @@ describe('DirectProject user Response item', () => { it('资源引用只投影稳定 resourceId,不携带展示字段', () => { const draft: ChatComposerDraft = { - text: '请使用素材', - references: [ - { - type: 'resource', - resourceId: 'asset-hero', - kind: 'character', - mediaType: 'image/png', - label: '主角', - category: 'character', - tags: ['hero'], - source: 'asset-picker', - }, - ], content: [ { type: 'input_text', text: '请使用素材' }, { type: 'agc_resource_reference', resourceId: 'asset-hero' }, @@ -72,4 +59,48 @@ describe('DirectProject user Response item', () => { ], }); }); + + it('原样保留纯空白 input_text,不替用户改写提示词', () => { + const draft: ChatComposerDraft = { + content: [ + { type: 'input_text', text: '先看' }, + { type: 'input_text', text: ' ' }, + { type: 'agc_resource_reference', resourceId: 'asset-hero' }, + { type: 'input_text', text: '\n' }, + ], + }; + + expect( + chatComposerDraftToDirectCodexUserItem(draft, 'turn-3:user').content, + ).toEqual(draft.content); + }); + + it('只有最终 content 全为空白时才判定为空输入', () => { + expect( + hasMeaningfulDirectCodexContent([{ type: 'input_text', text: ' \n ' }]), + ).toBe(false); + expect(hasMeaningfulDirectCodexContent([])).toBe(false); + // 空白文本仍然保留,但只要有实际文本或引用就不能当空输入拒发。 + expect( + hasMeaningfulDirectCodexContent([ + { type: 'input_text', text: ' \n' }, + { type: 'input_text', text: '看' }, + ]), + ).toBe(true); + expect( + hasMeaningfulDirectCodexContent([ + { type: 'agc_resource_reference', resourceId: 'asset-hero' }, + ]), + ).toBe(true); + }); + + it('展示用文本由 content 派生,引用按 @ 显示名展开', () => { + expect( + directCodexContentToPromptText([ + { type: 'input_text', text: '用 ' }, + { type: 'agc_resource_reference', resourceId: 'asset-hero' }, + { type: 'input_text', text: ' 做主视觉' }, + ]), + ).toBe('用 @asset-hero 做主视觉'); + }); }); diff --git a/docs/project-memory/plans/【实施计划】DirectProject canonical content严格边界-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject canonical content严格边界-2026-09-16.md index 072f9468b..6bdbed240 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject canonical content严格边界-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】DirectProject canonical content严格边界-2026-09-16.md @@ -1,10 +1,10 @@ # 【实施计划】DirectProject canonical content 严格边界 -| 字段 | 值 | -| --- | --- | +| 字段 | 值 | +| --------- | ------------------------------------------------------------------------------------------- | | Milestone | `docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md` | -| Status | in-progress | -| Owner | Codex | +| Status | implemented | +| Owner | Codex | ## 实施顺序 @@ -14,6 +14,13 @@ 4. 收敛策略确认重试为复用同一 canonical user item;旧 Supervisor/Planning caller 加 TODO,不改变其非 Direct Codex 行为。 5. 迁移现有测试 fixture,删除旧字段构造,不增加“字段不存在”测试。 +## 落地结果 + +- `chatComposerDraftToDirectCodexUserItem` 原样传递 `draft.content`;新增 `directCodexUserItemFromContent` 供 caller 直接构造 canonical item。 +- `ChatComposerDraft` 只保留 `content`;`ResourceReferenceInput` 的对外草稿、`chatPromptDraftKey`、`QueuedChatTurn` 全部改为 content-only。 +- 首页首轮、普通聊天提交、运行中队列出队、策略确认重试都携带同一个 canonical user item;`executeChatAgentReply` 的 `userItem` 兜底分支已删除。 +- 旧 Planner / legacy Supervisor caller 显式构造纯文本 item 并留下迁移 TODO。 + ## 修改边界 - 允许修改:AGC shell 前端 `resourceReferences`、`App`、聊天队列、Direct Codex 相关测试和当前里程碑文档。 diff --git a/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md index a1f7eb659..80b8b6a13 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md @@ -1,21 +1,22 @@ # 【里程碑】DirectProject canonical content 严格边界 -| 字段 | 值 | -| --- | --- | -| Version | 1.0 | -| Status | proposed | -| Date | 2026-09-16 | +| 字段 | 值 | +| ----------- | ------------------------------------------------ | +| Version | 1.0 | +| Status | implemented | +| Date | 2026-09-16 | | Parent Spec | `docs/【功能说明】AGC聊天素材引用-2026-09-08.md` | ## 目标 -让 DirectProject 的用户消息只以 `content[]` 作为 canonical 输入:保留 Lexical 产生的全部 content part(包括纯空白 `input_text`),只在最终 content 上判断是否存在有效输入;所有 Direct Codex caller 必须显式提供完整 `userItem`。 +让 DirectProject 的用户消息只以 `content[]` 作为 canonical 输入:转换函数原样传递编辑器草稿的 content part,不做二次预过滤;只在最终 content 上判断是否存在有效输入;所有 Direct Codex caller 必须显式提供完整 `userItem`。 ## 范围 - 移除 `chatComposerDraftToDirectCodexUserItem` 对纯空白 `input_text` 的预过滤。 - Direct Codex 发送前只做最终 content 的有效性判断,不改写 content。 - 删除 `executeChatAgentReply` 在 `userItem` 缺失时的构造兜底。 +- 删除 `ChatComposerDraft` 的 `text` / `references` 字段,草稿只保留 canonical `content`。 - 修正首页首轮、队列出队和其它 Direct Codex caller,使其直接构造 canonical user item。 - 将队列与策略确认重试按 canonical user item 传递,避免拆回 `text` / `references`。 - 仍在使用的旧 Supervisor/Planning caller 保持非 Direct Codex 行为,并添加后续迁移 TODO。 @@ -31,11 +32,18 @@ - `chatComposerDraftToDirectCodexUserItem` 输出与输入 `draft.content` 顺序和值完全一致。 - 只有当最终 content 不含非空文本且不含任何非文本 part 时,发送入口才拒绝本轮。 - Direct Codex 路径不存在 `userItem ?? ...` 或等价 fallback。 +- `ChatComposerDraft` 只有 `content` 一个字段;`text` / `references` 不再是草稿契约的一部分。 - 首页首轮、普通聊天、队列出队、策略确认重试均发送同一个 canonical user item 语义。 - 旧 Supervisor/Planning caller 上有明确 TODO,且不进入 Direct Codex canonical 发送路径。 +## 实现结论 + +- 唯一的前端空白过滤留在 Lexical 投影层:`agc_attachment`/`input_text` 之外的纯空白文本不作为 content part,因为 Rust `validate_direct_codex_user_item` 会拒绝空 `input_text`。转换函数不再重复过滤。 +- 显示文本、队列 chip 文案、草稿持久化和润色判据统一由 `directCodexContentToPromptText(content, assets)` 从 content 派生,不再维护并行的 `text` 字段。 +- 需要文本草稿的旧入口(`replaceText`、快速编辑)仍由编辑器把文本 + 引用重建为 content,方向是「文本 → content」,不存在「legacy 字段 → content」的回退。 + ## 证据 -- 前端 canonical content、有效性判断、caller 与队列定向测试。 -- AGC shell 类型检查与定向 Vitest。 -- `npm run check:encoding`、`git diff --check`、必要的文档索引检查。 +- `apps/ai-game-creator-shell/tests/resourceReferences.test.ts`:content 原样传递、空白 part 保留、有效性判断、文本派生。 +- `apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx`、`chatPromptPolish.test.tsx`、`tests/appSurface/*.suite.ts`:草稿读取、提醒判据、队列与 caller 迁移到 content-only。 +- AGC shell 类型检查、定向 Vitest、`npm run check:encoding`、`git diff --check` 通过。 -- 2.52.0 From 29d0cbb4dfa87ffd14d1d0884840bb9eba7ea5aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 18:01:50 +0800 Subject: [PATCH 37/92] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=80=81=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E4=B8=8E=E5=8E=86=E5=8F=B2=E5=88=87=E7=89=87=E5=85=B1?= =?UTF-8?q?=E7=94=A8=E8=81=8A=E5=A4=A9=E6=9D=A1=E7=9B=AE=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 direct_chat_entry 深模块,把原始 response item 与 app-server item 投影成同一聊天条目 - item.started/item.completed 载荷改为完整条目,前端不再需要按 itemId 取快照 - 原始 function_call 与 function_call_output 按 callId 并成一张卡片,文本与明细统一脱敏截断 - 历史切片新增 entries 与 first_item_id,分页锚点不再依赖是否出现可显示条目 --- .../src-tauri/src/agent.rs | 2 + .../src/agent/codex_app_server/mod.rs | 58 +- .../src-tauri/src/agent/direct_chat_entry.rs | 523 ++++++++++++++++++ .../src/agent/direct_thread_manager.rs | 6 + .../src-tauri/src/commands.rs | 8 + 5 files changed, 585 insertions(+), 12 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index e64b321dd..bf380a888 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -14,6 +14,7 @@ mod codex_cli; mod codex_provider_proxy; mod design_runtime; mod design_tools; +mod direct_chat_entry; mod direct_codex_attachments; mod direct_codex_audit; mod direct_codex_user_item; @@ -48,6 +49,7 @@ pub(crate) use codex_cli::{ }; pub(crate) use codex_provider_proxy::*; pub(crate) use design_runtime::*; +pub(crate) use direct_chat_entry::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_audit::*; pub(crate) use direct_codex_user_item::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index fcfa250be..27f81fc6b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -744,13 +744,17 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat /// Project an app-server item into the small public payload carried by the /// DirectProject event queue. Full item contents are persisted in JSONL and /// must not be forwarded through the runtime event stream. -fn direct_thread_item_started_payload(item: &serde_json::Value) -> serde_json::Value { - serde_json::json!({ - "itemType": item - .get("type") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"), - }) +/// 运行态事件载荷:与历史切片同形的聊天条目;拿不到条目时给空对象。 +fn direct_chat_entry_payload( + root: &std::path::Path, + item: &serde_json::Value, + turn_id: Option<&str>, + completed: bool, + now_ms: u64, +) -> serde_json::Value { + direct_chat_entry_from_item(root, item, turn_id, completed, now_ms) + .and_then(|entry| serde_json::to_value(entry).ok()) + .unwrap_or_else(|| serde_json::json!({})) } fn direct_thread_item_id(item: &serde_json::Value) -> Option { @@ -3053,6 +3057,13 @@ impl CodexAppServerConnection { "rawResponseItem/completed 缺少 item".to_string(), )); } + let entry_payload = direct_chat_entry_payload( + history_root, + &item, + Some(turn_id.as_str()), + true, + direct_tool_call_now_ms(), + ); let history_root = history_root.to_path_buf(); let history_item = item.clone(); tokio::task::spawn_blocking(move || { @@ -3074,7 +3085,7 @@ impl CodexAppServerConnection { turn_id: turn_id.clone(), item_id, call_id: direct_thread_item_call_id(&item), - payload: serde_json::json!({}), + payload: entry_payload, }, ); } @@ -3216,7 +3227,13 @@ impl CodexAppServerConnection { turn_id: turn_id.clone(), item_id, call_id: None, - payload: direct_thread_item_started_payload(item), + payload: direct_chat_entry_payload( + history_root, + item, + Some(turn_id.as_str()), + completed, + direct_tool_call_now_ms(), + ), }, ); } @@ -4527,10 +4544,27 @@ mod tests { "result": { "content": "large output" } }); assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1")); - assert_eq!( - direct_thread_item_started_payload(&item), - serde_json::json!({ "itemType": "mcpToolCall" }) + // 运行态事件必须自足:载荷是投影后的聊天条目,前端不需要再按 itemId 取快照。 + let payload = direct_chat_entry_payload( + std::path::Path::new("."), + &item, + Some("turn-1"), + false, + 1000, ); + assert_eq!( + payload.get("kind").and_then(serde_json::Value::as_str), + Some("tool") + ); + assert_eq!( + payload.get("itemId").and_then(serde_json::Value::as_str), + Some("item-1") + ); + assert_eq!( + payload.get("turnId").and_then(serde_json::Value::as_str), + Some("turn-1") + ); + assert!(payload.get("toolCall").is_some(), "{payload}"); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs new file mode 100644 index 000000000..a348cddbd --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs @@ -0,0 +1,523 @@ +//! DirectProject 聊天条目投影。 +//! +//! 前端只消费一种形状:`DirectChatEntry`。运行态事件与历史切片都由这里把 +//! Codex 原始 response item / app-server thread item 投影成同一形状,并使用 +//! 同一套脱敏、截断与项目路径归一,避免实时与回读两套口径分叉。 +//! +//! 这里只做机械投影:不判断"哪些条目要显示",也不决定顺序策略——可见性与 +//! 排列属于前端聊天投影。 + +use super::direct_tool_calls::{direct_tool_call_from_item, sanitize_detail_text}; +use crate::DirectToolCall; +use serde::Serialize; +use serde_json::Value; +use std::path::Path; + +pub(crate) const DIRECT_CHAT_ENTRY_KIND_MESSAGE: &str = "message"; +pub(crate) const DIRECT_CHAT_ENTRY_KIND_REASONING: &str = "reasoning"; +pub(crate) const DIRECT_CHAT_ENTRY_KIND_TOOL: &str = "tool"; + +/// 单条条目文本上限:与回合流同一口径,避免单条正文无界。 +const DIRECT_CHAT_ENTRY_TEXT_MAX_CHARS: usize = 8000; +/// 工具输入/输出上限:与卡片明细同口径。 +const DIRECT_CHAT_ENTRY_TOOL_TEXT_MAX_CHARS: usize = 4000; + +/// 前端唯一消费的聊天条目。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectChatEntry { + /// 稳定身份:`project.jsonl` 里的 response item id(分页锚点也用它)。 + pub(crate) item_id: String, + /// 工具条目的调用 id:app-server `item.started` 的 `itemId` 与原始 item 的 `call_id`。 + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) call_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) turn_id: Option, + /// `message` | `reasoning` | `tool` + pub(crate) kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) tool_call: Option, + /// 条目自带的毫秒时间;拿不到就是 0,由调用方用文件记录时间补齐。 + pub(crate) at: u64, +} + +impl DirectChatEntry { + /// 合并身份:前端按 `callId ?? itemId` 归并实时与历史里的同一条目。 + pub(crate) fn identity(&self) -> &str { + self.call_id.as_deref().unwrap_or(&self.item_id) + } +} + +fn bounded(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_string(); + } + let mut truncated = value.chars().take(max_chars).collect::(); + truncated.push('…'); + truncated +} + +fn item_text(item: &Value) -> Option { + if let Some(text) = item.get("text").and_then(Value::as_str) { + if !text.trim().is_empty() { + return Some(text.to_string()); + } + } + for key in ["content", "summary"] { + let Some(parts) = item.get(key).and_then(Value::as_array) else { + continue; + }; + let joined = parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join(""); + if !joined.trim().is_empty() { + return Some(joined); + } + } + None +} + +fn item_turn_id(item: &Value) -> Option { + let from_metadata = item + .get("internal_chat_message_metadata_passthrough") + .and_then(|meta| meta.get("turn_id").or_else(|| meta.get("turnId"))) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if from_metadata.is_some() { + return from_metadata; + } + // 用户条目没有元数据,但 id 里带回合:`direct-codex::user`。 + let rest = item + .get("id") + .and_then(Value::as_str)? + .trim() + .strip_prefix("direct-codex:")?; + let turn = rest.rsplit_once(':')?.0.trim(); + (!turn.is_empty()).then(|| turn.to_string()) +} + +fn item_at_ms(item: &Value) -> u64 { + item.get("internal_chat_message_metadata_passthrough") + .and_then(|meta| meta.get("create_time")) + .and_then(Value::as_f64) + .map(|seconds| (seconds * 1000.0).clamp(0.0, u64::MAX as f64) as u64) + .unwrap_or_default() +} + +fn item_identity(item: &Value) -> Option<(String, Option)> { + let call_id = item + .get("call_id") + .or_else(|| item.get("callId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let id = item + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + match (id, call_id) { + (Some(id), call_id) => Some((id, call_id)), + (None, Some(call_id)) => Some((call_id.clone(), Some(call_id))), + (None, None) => None, + } +} + +fn raw_tool_kind(name: &str) -> &'static str { + match name { + "exec_command" | "shell" | "exec" => "command", + "apply_patch" | "write_file" | "edit_file" | "create_file" => "file_change", + "web_search" | "web_search_preview" => "web_search", + _ => "mcp_tool", + } +} + +fn raw_tool_title(kind: &str) -> &'static str { + match kind { + "command" => "执行命令", + "file_change" => "编辑文件", + "web_search" => "联网检索", + "mcp_tool" => "调用工具", + _ => "工具调用", + } +} + +fn stringified(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(text) => (!text.trim().is_empty()).then(|| text.clone()), + other => serde_json::to_string(other).ok(), + } +} + +fn first_line(value: &str, max_chars: usize) -> String { + bounded(value.lines().next().unwrap_or_default().trim(), max_chars) +} + +/// 原始 response item 词汇里的工具条目:`function_call` 带输入,`function_call_output` 带输出。 +/// 两者共用 `call_id`,前端与历史投影都按它归并成同一张卡片。 +fn raw_tool_entry( + root: &Path, + item: &Value, + item_id: String, + call_id: Option, + turn_id: Option, + at: u64, + completed: bool, + now_ms: u64, +) -> Option { + let item_type = item.get("type").and_then(Value::as_str)?; + let call_id = call_id?; + let (kind, title, summary, detail) = match item_type { + "function_call" => { + let name = item.get("name").and_then(Value::as_str).unwrap_or_default(); + let arguments = item + .get("arguments") + .and_then(Value::as_str) + .unwrap_or_default(); + let kind = raw_tool_kind(name); + let command = bounded( + &sanitize_detail_text(root, arguments), + DIRECT_CHAT_ENTRY_TOOL_TEXT_MAX_CHARS, + ); + let summary = first_line(&command, 120); + ( + kind, + raw_tool_title(kind).to_string(), + summary, + crate::DirectToolCallDetail { + command: Some(command), + output: None, + changes: Vec::new(), + }, + ) + } + "function_call_output" => { + let output = item.get("output").and_then(stringified).map(|output| { + bounded( + &sanitize_detail_text(root, &output), + DIRECT_CHAT_ENTRY_TOOL_TEXT_MAX_CHARS, + ) + }); + ( + "other", + String::new(), + String::new(), + crate::DirectToolCallDetail { + command: None, + output, + changes: Vec::new(), + }, + ) + } + _ => return None, + }; + let status = if completed { "completed" } else { "running" }; + let started_at = if at > 0 { at } else { now_ms }; + Some(DirectChatEntry { + item_id, + call_id: Some(call_id.clone()), + turn_id: turn_id.clone(), + kind: DIRECT_CHAT_ENTRY_KIND_TOOL.to_string(), + role: None, + text: None, + tool_call: Some(DirectToolCall { + schema_version: "agc-tool-call.v1".to_string(), + id: call_id, + turn_id: turn_id.unwrap_or_default(), + kind: kind.to_string(), + title, + summary, + status: status.to_string(), + detail, + started_at, + updated_at: if at > 0 { at } else { now_ms }, + }), + at, + }) +} + +/// 把一条 Codex 条目投影成聊天条目;不属于聊天内容的条目(如 `function_call_output`)返回 `None`。 +pub(crate) fn direct_chat_entry_from_item( + root: &Path, + item: &Value, + turn_id: Option<&str>, + completed: bool, + now_ms: u64, +) -> Option { + let (item_id, call_id) = item_identity(item)?; + let turn_id = turn_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| item_turn_id(item)); + let at = item_at_ms(item); + + if let Some(entry) = raw_tool_entry( + root, + item, + item_id.clone(), + call_id.clone(), + turn_id.clone(), + at, + completed, + now_ms, + ) { + return Some(entry); + } + + if let Some(tool_call) = direct_tool_call_from_item( + root, + item, + turn_id.as_deref().unwrap_or_default(), + completed, + now_ms, + ) { + let call_id = call_id.or_else(|| Some(tool_call.id.clone())); + return Some(DirectChatEntry { + item_id, + call_id, + turn_id, + kind: DIRECT_CHAT_ENTRY_KIND_TOOL.to_string(), + role: None, + text: None, + tool_call: Some(tool_call), + at, + }); + } + + let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default(); + let (kind, role) = if item_type == "reasoning" { + (DIRECT_CHAT_ENTRY_KIND_REASONING, None) + } else { + let role = item.get("role").and_then(Value::as_str)?; + if !matches!(role, "user" | "assistant") { + return None; + } + (DIRECT_CHAT_ENTRY_KIND_MESSAGE, Some(role.to_string())) + }; + let text = item_text(item)?; + let text = bounded( + &sanitize_detail_text(root, &text), + DIRECT_CHAT_ENTRY_TEXT_MAX_CHARS, + ); + Some(DirectChatEntry { + item_id, + call_id, + turn_id, + kind: kind.to_string(), + role, + text: Some(text), + tool_call: None, + at, + }) +} + +/// 历史切片投影:文件顺序就是条目顺序。 +/// 同一调用的输入(`function_call`)与输出(`function_call_output`)在这里并成一张卡片。 +pub(crate) fn direct_chat_entries_from_history( + root: &Path, + items: &[Value], +) -> Vec { + let now_ms = super::direct_tool_calls::direct_tool_call_now_ms(); + let mut entries: Vec = Vec::new(); + for item in items { + let Some(entry) = direct_chat_entry_from_item(root, item, None, true, now_ms) else { + continue; + }; + match entries + .iter_mut() + .find(|existing| existing.identity() == entry.identity()) + { + Some(existing) => merge_chat_entry(existing, entry), + None => entries.push(entry), + } + } + entries +} + +/// 同一条目的后续快照只补空字段:后到的输出不能抹掉先到的命令与标题。 +fn merge_chat_entry(existing: &mut DirectChatEntry, incoming: DirectChatEntry) { + if let (Some(existing_call), Some(incoming_call)) = + (existing.tool_call.as_mut(), incoming.tool_call.as_ref()) + { + existing_call.detail.command = existing_call + .detail + .command + .take() + .or_else(|| incoming_call.detail.command.clone()); + existing_call.detail.output = existing_call + .detail + .output + .take() + .or_else(|| incoming_call.detail.output.clone()); + if existing_call.detail.changes.is_empty() { + existing_call.detail.changes = incoming_call.detail.changes.clone(); + } + if existing_call.title.trim().is_empty() { + existing_call.title = incoming_call.title.clone(); + } + if existing_call.summary.trim().is_empty() { + existing_call.summary = incoming_call.summary.clone(); + } + if existing_call.kind == "other" && incoming_call.kind != "other" { + existing_call.kind = incoming_call.kind.clone(); + } + existing_call.updated_at = existing_call.updated_at.max(incoming_call.updated_at); + existing_call.status = incoming_call.status.clone(); + } + if existing.text.is_none() { + existing.text = incoming.text; + } + if existing.role.is_none() { + existing.role = incoming.role; + } + if existing.turn_id.is_none() { + existing.turn_id = incoming.turn_id; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::path::Path; + + fn root() -> &'static Path { + Path::new(".") + } + + #[test] + fn message_item_projects_role_and_text() { + let entry = direct_chat_entry_from_item( + root(), + &json!({ + "id": "direct-codex:turn-1:user", + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "做一个拼图游戏"}], + }), + None, + true, + 0, + ) + .expect("user entry"); + assert_eq!(entry.kind, DIRECT_CHAT_ENTRY_KIND_MESSAGE); + assert_eq!(entry.role.as_deref(), Some("user")); + assert_eq!(entry.text.as_deref(), Some("做一个拼图游戏")); + assert_eq!(entry.turn_id.as_deref(), Some("turn-1")); + } + + #[test] + fn tool_item_carries_call_id_and_card() { + let entry = direct_chat_entry_from_item( + root(), + &json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + "internal_chat_message_metadata_passthrough": {"turn_id": "turn-1"}, + }), + None, + true, + 1000, + ) + .expect("tool entry"); + assert_eq!(entry.kind, DIRECT_CHAT_ENTRY_KIND_TOOL); + assert_eq!( + entry.call_id.as_deref(), + Some("call_00_Gpd0s0Ytm9YgIbwbEXva1473") + ); + assert_eq!(entry.identity(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); + assert_eq!(entry.item_id, "05dc0af1-8023-47fd-ad22-d54df2837b1b"); + assert!(entry.tool_call.is_some()); + } + + #[test] + fn reasoning_item_projects_text_without_role() { + let entry = direct_chat_entry_from_item( + root(), + &json!({ + "id": "reason-1", + "type": "reasoning", + "content": [{"type": "output_text", "text": "先看目录"}], + }), + Some("turn-1"), + true, + 0, + ) + .expect("reasoning entry"); + assert_eq!(entry.kind, DIRECT_CHAT_ENTRY_KIND_REASONING); + assert_eq!(entry.role, None); + assert_eq!(entry.text.as_deref(), Some("先看目录")); + } + + #[test] + fn non_chat_items_and_secrets_are_not_leaked() { + assert!(direct_chat_entry_from_item( + root(), + &json!({"id": "sys-1", "type": "message", "role": "system", "content": [{"type": "input_text", "text": "x"}]}), + Some("turn-1"), + true, + 0, + ) + .is_none()); + + let entry = direct_chat_entry_from_item( + root(), + &json!({ + "id": "msg-1", + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "key=sk-abcdefghijklmnop"}], + }), + Some("turn-1"), + true, + 0, + ) + .expect("assistant entry"); + let text = entry.text.unwrap_or_default(); + assert!( + !text.contains("sk-abcdefghijklmnop"), + "不得泄漏明文密钥:{text}" + ); + } + + #[test] + fn history_merges_call_input_and_output_into_one_card() { + let items = vec![ + json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + }), + json!({ + "id": "fco_01a06fa5-d636-7452-b337-a641c2e6bc76", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call_output", + "output": "assets\ngame\n", + }), + ]; + let entries = direct_chat_entries_from_history(root(), &items); + assert_eq!(entries.len(), 1, "同一调用只能有一张卡片"); + let card = entries[0].tool_call.as_ref().expect("card"); + assert_eq!(card.kind, "command"); + assert_eq!(card.title, "执行命令"); + assert_eq!(card.detail.command.as_deref(), Some("{\"cmd\": \"ls\"}")); + assert_eq!(card.detail.output.as_deref(), Some("assets\ngame")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index f72f37380..44a2b67ce 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -10,6 +10,8 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Mutex, OnceLock}; use uuid::Uuid; +use crate::agent::DirectChatEntry; + const DEFAULT_MAX_EVENTS: usize = 8_192; const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; @@ -71,6 +73,10 @@ pub(crate) struct DirectThreadHistorySlice { pub(crate) items: Vec, pub(crate) has_more: bool, pub(crate) item_timestamps: std::collections::BTreeMap, + /// 与运行态事件同形的聊天条目(顺序即文件顺序)。 + pub(crate) entries: Vec, + /// 本次切片的原始条目锚点:无论切片里有没有可显示条目,分页都要靠它继续向前。 + pub(crate) first_item_id: Option, } #[derive(Clone, Debug)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index ca97077f1..d941f1c14 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5383,10 +5383,18 @@ pub(crate) async fn read_direct_project_history_slice( before_item_id.as_deref(), limit.unwrap_or(20), )?; + let entries = direct_chat_entries_from_history(root, &items); + let first_item_id = items + .first() + .and_then(|item| item.get("id")) + .and_then(serde_json::Value::as_str) + .map(str::to_string); Ok(DirectThreadHistorySlice { items, has_more, item_timestamps, + entries, + first_item_id, }) }) .await -- 2.52.0 From ae0f9376c94fd5ca6862a5a28214012763f156c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 18:05:10 +0800 Subject: [PATCH 38/92] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20DirectProject=20?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=20reducer=20=E6=B7=B1=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - directThreadChat 把运行态事件与历史切片归并成同一份聊天条目,活动回合只由 turn.started/turn.completed 判定 - item.delta 载荷补 kind,reducer 不再靠 itemId 猜条目类型 - bootstrap 原子替换运行态并保留历史窗口,历史与运行态按 callId ?? itemId 去重合并 - 新增 5 条 reducer 单测 --- .../src/agent/codex_app_server/mod.rs | 7 +- .../project-workspace/directThreadChat.ts | 253 ++++++++++++++++++ .../tests/directThreadChat.test.ts | 170 ++++++++++++ 3 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts create mode 100644 apps/ai-game-creator-shell/tests/directThreadChat.test.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 27f81fc6b..a5db418f6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -3013,7 +3013,12 @@ impl CodexAppServerConnection { turn_id: turn_id.clone(), item_id: Some(item_id.clone()), call_id: None, - payload: serde_json::json!({ "delta": delta.clone() }), + // 事件自足:增量也要说明它是哪类条目的正文, + // 前端 reducer 不允许靠猜 itemId 的来源决定 kind。 + payload: serde_json::json!({ + "delta": delta.clone(), + "kind": "message", + }), }, ); } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts new file mode 100644 index 000000000..e2764e51e --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts @@ -0,0 +1,253 @@ +/** + * DirectProject 聊天 reducer:把运行态 raw event 与历史切片归并成同一份聊天条目。 + * + * 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。这里不判断哪些条目 + * 要显示,也不决定顺序策略之外的任何东西:顺序 = 历史文件顺序 + 运行态独有条目。 + */ + +import type { GameCreatorDirectToolCall } from '../../app/types'; +import type { + DirectThreadRawEvent, + DirectThreadSubscriptionBootstrap, +} from './directThreadEvents'; + +export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool'; + +/** 与 Rust `DirectChatEntry` 同形:运行态事件与历史切片共用的唯一形状。 */ +export type DirectChatEntry = { + itemId: string; + callId?: string | null; + turnId?: string | null; + kind: DirectChatEntryKind; + role?: 'user' | 'assistant' | null; + text?: string | null; + toolCall?: GameCreatorDirectToolCall | null; + at?: number; +}; + +export type DirectThreadChatState = { + subscriptionId: string | null; + /** 首屏历史锚点:`subscribe` 给出的最后一个完成条目 id。 */ + lastCompletedItemId: string | null; + /** 活动回合:只有出现 `turn.started` 且没有对应 `turn.completed` 才有值。 */ + activeTurnId: string | null; + /** 历史切片条目,保持文件顺序。 */ + history: DirectChatEntry[]; + /** 运行态条目,保持到达顺序。 */ + live: DirectChatEntry[]; + /** 流式正文累计(itemId → text)。 */ + deltaText: Record; +}; + +export function emptyDirectThreadChatState(): DirectThreadChatState { + return { + subscriptionId: null, + lastCompletedItemId: null, + activeTurnId: null, + history: [], + live: [], + deltaText: {}, + }; +} + +/** 合并身份:工具条目的 app-server itemId 与原始 item 的 callId 是同一个值。 */ +export function directChatEntryIdentity(entry: DirectChatEntry): string { + return (entry.callId ?? '').trim() || entry.itemId; +} + +function mergeToolCall( + left: GameCreatorDirectToolCall, + right: GameCreatorDirectToolCall, +): GameCreatorDirectToolCall { + return { + ...left, + ...right, + title: right.title?.trim() ? right.title : left.title, + summary: right.summary?.trim() ? right.summary : left.summary, + kind: right.kind && right.kind !== 'other' ? right.kind : left.kind, + detail: { + command: right.detail?.command ?? left.detail?.command, + output: right.detail?.output ?? left.detail?.output, + changes: right.detail?.changes?.length + ? right.detail.changes + : left.detail?.changes, + }, + startedAt: Math.min( + left.startedAt > 0 ? left.startedAt : right.startedAt, + right.startedAt > 0 ? right.startedAt : left.startedAt, + ), + }; +} + +/** 后到的快照只补空字段:结果快照不能抹掉先到的命令与标题。 */ +export function mergeDirectChatEntry( + existing: DirectChatEntry, + incoming: DirectChatEntry, +): DirectChatEntry { + const merged: DirectChatEntry = { + ...existing, + ...incoming, + itemId: existing.itemId || incoming.itemId, + kind: + incoming.kind === 'tool' || existing.kind === 'tool' + ? 'tool' + : incoming.kind, + text: incoming.text ?? existing.text, + role: incoming.role ?? existing.role, + turnId: incoming.turnId ?? existing.turnId, + }; + const left = existing.toolCall ?? null; + const right = incoming.toolCall ?? null; + merged.toolCall = + left && right ? mergeToolCall(left, right) : (right ?? left); + return merged; +} + +function upsertLiveEntry( + state: DirectThreadChatState, + entry: DirectChatEntry, +): DirectThreadChatState { + const identity = directChatEntryIdentity(entry); + const index = state.live.findIndex( + (existing) => directChatEntryIdentity(existing) === identity, + ); + if (index < 0) { + return { ...state, live: [...state.live, entry] }; + } + const existing = state.live[index]; + if (!existing) { + return { ...state, live: [...state.live, entry] }; + } + const live = [...state.live]; + live[index] = mergeDirectChatEntry(existing, entry); + return { ...state, live }; +} + +/** 事件载荷就是聊天条目;不是聊天条目(例如纯工具输出之外的内部 item)返回 null。 */ +export function directChatEntryFromEventPayload( + payload: unknown, +): DirectChatEntry | null { + if (!payload || typeof payload !== 'object') return null; + const entry = payload as DirectChatEntry; + if (typeof entry.itemId !== 'string' || !entry.itemId.trim()) return null; + if (!entry.kind) return null; + return entry; +} + +function reduceDirectThreadEvent( + state: DirectThreadChatState, + event: DirectThreadRawEvent, +): DirectThreadChatState { + switch (event.type) { + case 'turn.started': + return { ...state, activeTurnId: event.turnId }; + case 'turn.completed': + return { + ...state, + activeTurnId: + state.activeTurnId === event.turnId ? null : state.activeTurnId, + }; + case 'item.delta': { + const itemId = event.itemId?.trim(); + if (!itemId) return state; + const payload = event.payload as { delta?: unknown; kind?: unknown }; + const delta = typeof payload?.delta === 'string' ? payload.delta : ''; + if (!delta) return state; + const text = `${state.deltaText[itemId] ?? ''}${delta}`; + const reasoning = payload?.kind === 'reasoning'; + return upsertLiveEntry( + { ...state, deltaText: { ...state.deltaText, [itemId]: text } }, + { + itemId, + kind: reasoning ? 'reasoning' : 'message', + role: reasoning ? null : 'assistant', + turnId: event.turnId, + text, + }, + ); + } + case 'item.started': + case 'item.completed': { + const entry = directChatEntryFromEventPayload(event.payload); + if (!entry) return state; + return upsertLiveEntry(state, { + ...entry, + turnId: entry.turnId ?? event.turnId, + }); + } + default: + return state; + } +} + +export function reduceDirectThreadEvents( + state: DirectThreadChatState, + events: readonly DirectThreadRawEvent[], +): DirectThreadChatState { + return events.reduce(reduceDirectThreadEvent, state); +} + +/** bootstrap 是运行态的唯一权威:原子替换运行态,历史窗口保留。 */ +export function resolveDirectThreadBootstrap( + state: DirectThreadChatState, + bootstrap: DirectThreadSubscriptionBootstrap, +): DirectThreadChatState { + return reduceDirectThreadEvents( + { + ...emptyDirectThreadChatState(), + history: state.history, + subscriptionId: bootstrap.subscriptionId, + lastCompletedItemId: bootstrap.lastCompletedItemId ?? null, + }, + bootstrap.events, + ); +} + +/** 历史切片并入:重叠条目按身份去重,新切片在前、已有历史在后。 */ +export function mergeDirectHistoryEntries( + state: DirectThreadChatState, + entries: readonly DirectChatEntry[], +): DirectThreadChatState { + const byIdentity = new Map(); + const order: string[] = []; + for (const entry of [...entries, ...state.history]) { + const identity = directChatEntryIdentity(entry); + const existing = byIdentity.get(identity); + if (existing) { + byIdentity.set(identity, mergeDirectChatEntry(existing, entry)); + continue; + } + byIdentity.set(identity, entry); + order.push(identity); + } + return { + ...state, + history: order + .map((identity) => byIdentity.get(identity)) + .filter((entry): entry is DirectChatEntry => Boolean(entry)), + }; +} + +/** 聊天投影输入:历史顺序 + 运行态覆盖;运行态独有条目排在最后。 */ +export function selectDirectChatEntries( + state: DirectThreadChatState, +): DirectChatEntry[] { + const liveByIdentity = new Map( + state.live.map((entry) => [directChatEntryIdentity(entry), entry]), + ); + const seen = new Set(); + const entries: DirectChatEntry[] = []; + for (const entry of state.history) { + const identity = directChatEntryIdentity(entry); + seen.add(identity); + const live = liveByIdentity.get(identity); + entries.push(live ? mergeDirectChatEntry(entry, live) : entry); + } + for (const entry of state.live) { + const identity = directChatEntryIdentity(entry); + if (seen.has(identity)) continue; + seen.add(identity); + entries.push(entry); + } + return entries; +} diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts new file mode 100644 index 000000000..912e645c7 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; + +import { + type DirectChatEntry, + directChatEntryIdentity, + emptyDirectThreadChatState, + mergeDirectHistoryEntries, + reduceDirectThreadEvents, + resolveDirectThreadBootstrap, + selectDirectChatEntries, +} from '../src/features/project-workspace/directThreadChat'; +import type { DirectThreadRawEvent } from '../src/features/project-workspace/directThreadEvents'; + +function event( + partial: Partial & + Pick, +): DirectThreadRawEvent { + return { seq: 1, payload: {}, ...partial }; +} + +function toolEntry( + overrides: Partial = {}, +): DirectChatEntry { + return { + itemId: 'call-1', + callId: 'call-1', + turnId: 'turn-1', + kind: 'tool', + toolCall: { + schemaVersion: 'agc-tool-call.v1', + id: 'call-1', + turnId: 'turn-1', + kind: 'command', + title: '执行命令', + summary: 'ls', + status: 'running', + detail: { command: 'ls', output: undefined, changes: [] }, + startedAt: 1000, + updatedAt: 1000, + }, + ...overrides, + }; +} + +describe('DirectProject 聊天 reducer', () => { + it('只有 turn.started 且未完成的回合才是活动回合', () => { + const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', turnId: 'turn-1' }), + ]); + expect(started.activeTurnId).toBe('turn-1'); + + const completed = reduceDirectThreadEvents(started, [ + event({ type: 'turn.completed', turnId: 'turn-1' }), + ]); + expect(completed.activeTurnId).toBeNull(); + }); + + it('bootstrap 原子替换运行态,没有生命周期事件就没有活动回合', () => { + const stale = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', turnId: 'turn-old' }), + ]); + const bootstrapped = resolveDirectThreadBootstrap(stale, { + subscriptionId: 'sub-1', + lastCompletedItemId: 'msg-9', + events: [], + }); + expect(bootstrapped.activeTurnId).toBeNull(); + expect(bootstrapped.subscriptionId).toBe('sub-1'); + expect(bootstrapped.lastCompletedItemId).toBe('msg-9'); + }); + + it('增量正文按条目累计,完成快照覆盖同一段', () => { + const streamed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.delta', + turnId: 'turn-1', + itemId: 'msg-1', + payload: { delta: '你', kind: 'message' }, + }), + event({ + type: 'item.delta', + turnId: 'turn-1', + itemId: 'msg-1', + payload: { delta: '好', kind: 'message' }, + }), + ]); + expect(selectDirectChatEntries(streamed)).toHaveLength(1); + expect(selectDirectChatEntries(streamed)[0]?.text).toBe('你好'); + + const done = reduceDirectThreadEvents(streamed, [ + event({ + type: 'item.completed', + turnId: 'turn-1', + itemId: 'msg-1', + payload: { + itemId: 'msg-1', + turnId: 'turn-1', + kind: 'message', + role: 'assistant', + text: '你好,我是陶泥儿。', + }, + }), + ]); + const entries = selectDirectChatEntries(done); + expect(entries).toHaveLength(1); + expect(entries[0]?.text).toBe('你好,我是陶泥儿。'); + }); + + it('工具条目 started/completed 归并成一张卡片', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.started', + turnId: 'turn-1', + itemId: 'call-1', + payload: toolEntry(), + }), + event({ + type: 'item.completed', + turnId: 'turn-1', + itemId: 'call-1', + payload: { + ...toolEntry(), + toolCall: { + ...toolEntry().toolCall!, + status: 'completed', + detail: { command: 'ls', output: 'assets\ngame', changes: [] }, + updatedAt: 2000, + }, + }, + }), + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(1); + expect(entries[0]?.toolCall?.status).toBe('completed'); + expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame'); + expect(entries[0]?.toolCall?.detail.command).toBe('ls'); + }); + + it('历史条目与运行态按 callId 合并,重复条目只出现一次', () => { + const historical = mergeDirectHistoryEntries(emptyDirectThreadChatState(), [ + toolEntry(), + { + itemId: 'msg-user', + kind: 'message', + role: 'user', + text: '做一个拼图游戏', + }, + ]); + const live = reduceDirectThreadEvents(historical, [ + event({ + type: 'item.completed', + turnId: 'turn-1', + itemId: 'call-1', + payload: { + ...toolEntry(), + itemId: 'call-1', + toolCall: { + ...toolEntry().toolCall!, + status: 'completed', + detail: { command: 'ls', output: 'assets', changes: [] }, + }, + }, + }), + ]); + const entries = selectDirectChatEntries(live); + expect(entries).toHaveLength(2); + expect(entries[0]?.toolCall?.status).toBe('completed'); + expect(directChatEntryIdentity(entries[0]!)).toBe('call-1'); + }); +}); -- 2.52.0 From 2938a49cacf8df71e6b9f080504af81f7178dfe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 18:05:30 +0800 Subject: [PATCH 39/92] =?UTF-8?q?=E5=9B=BA=E5=8C=96=20DirectProject=20?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E7=9C=9F=E7=9B=B8=E6=BA=90=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主规范新增首屏锚点、生命周期锚点必进 bootstrap、事件自足与不提供单点快照四条契约 - 新增 ADR 记录单一事实源取舍、删除清单与待验证的 id 空间风险 - 新增里程碑与实施计划,CONTEXT.md 补项目对话历史/运行态事件/聊天投影词条 - 目标文档合同与文档索引同步 --- CONTEXT.md | 16 +++++++ docs/README.md | 1 + ...irectProject对话历史单一事实源-2026-09-16.md | 42 +++++++++++++++++ ...划】DirectProject聊天真相源收敛-2026-09-16.md | 38 +++++++++++++++ ...碑】DirectProject聊天真相源收敛-2026-09-16.md | 47 +++++++++++++++++++ ...ectProject Codex原始历史与异常恢复-2026-09-04.md | 10 ++++ 6 files changed, 154 insertions(+) create mode 100644 docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md create mode 100644 docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md create mode 100644 docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md diff --git a/CONTEXT.md b/CONTEXT.md index 1351b1033..da2aca812 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -172,6 +172,20 @@ _Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器 Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。 _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI +## 项目开发对话(DirectProject) + +**项目对话历史**: +AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。 +_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本 + +**运行态事件**: +Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。 +_Avoid_: 进度通知、快照轮询、第二套历史 + +**聊天投影**: +把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。 +_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer + ## Relationships - 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。 @@ -206,3 +220,5 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI - “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。 - “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。 - “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。 +- “回合进度事件”曾同时指 Direct turn update 与 Thread Manager 运行态事件;已解析为 AGC 项目开发对话只保留 **运行态事件**。 +- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。 diff --git a/docs/README.md b/docs/README.md index 6a6bc67eb..e1c925fda 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,7 @@ - [AGC 客户端稳定版生命周期大切换](./【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md):统一 operation、认证/Runner、项目入口、本地恢复和 dev-stack 身份边界。 - [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):新单 Agent 策划会话、GDD 策略、未来 MCP/Skill 兼容插槽、阶段任务与退役验收合同。 - [DirectProject Codex 原始历史与异常恢复](<./technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md>):原始 Responses item 持久化、线程注入与异常回合收尾。 +- [DirectProject 对话历史单一事实源](./adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md):AGC 项目开发对话只以项目对话历史与运行态事件为真相源,聊天投影不落盘。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。 diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md new file mode 100644 index 000000000..3a65a47c1 --- /dev/null +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -0,0 +1,42 @@ +# 【ADR】DirectProject对话历史单一事实源-2026-09-16 + +状态:已接受 + +## 背景 + +AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(实时)、`turn-stream.jsonl`(文本段与工具交替顺序)、`tool-calls.jsonl`(已脱敏工具卡片),重进页面时还要额外接管活动回合快照。同一段文本和同一张工具卡片因此存在多个来源,实时与回读会互相覆盖,恢复路径也只能靠"哪个源先到"决定。 + +`.agent/conversations/project.jsonl` 里的 Codex 原始条目本身已经带着顺序(`function_call` 与 `function_call_output` 按写入顺序落行),顺序信息并不是协议缺陷,而是在投影层被丢弃。 + +## 决策 + +- AGC 项目开发对话的持久事实源只有 **项目对话历史**(`.agent/conversations/project.jsonl` 的原始条目);消息文本、工具卡片和它们的先后顺序都从它派生。 +- 运行期间的回合状态只来自 **运行态事件**(Thread Manager 的 subscribe / consume / notify);`notify` 只做唤醒,不携带状态。 +- **聊天投影** 在读取与渲染时生成,不落盘、不成为第二事实源;`turn-stream.jsonl` 与 `tool-calls.jsonl` 停止写入、停止读取,也不再提供读取命令。 +- 页面重进的运行态只由 `subscribe` 的 bootstrap 事件重建,删除活动回合快照接管路径。 +- 可见性判断留在前端聊天投影:后端历史分页只按原始条目切片,前端自己跳过不可显示条目并推进锚点。 +- 运行态事件与历史切片使用同形条目信封(`turnId` / `itemId` / `seq` / `payload`),Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「条目 → 视图」投影函数;Rust 做的是机械投影,不是可见性判断。 +- 活动回合的唯一判据是「出现过 `turn.started` 且未出现对应 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 +- 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 +- `notify` 仍是唯一唤醒意图,但前端在已判定存在活动回合期间叠加低频 `consume` tick(1–2 秒)兜底,因为唤醒本身允许丢失。 +- 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 +- 思考过程与工具活动同样从运行态事件与历史条目推断,界面展示保持不变。 +- 运行态事件必须自足:`item.started` / `item.completed` 携带与历史切片同形的完整条目(同一套脱敏、截断、路径归一),前端按 `itemId` 合并快照得到运行中与完成态;不提供按 `itemId` 单点取快照的接口。 +- 首屏历史由 `subscribe` 返回的 `lastCompletedItemId` 锚定,再取最近切片;删除返回整份对话的历史命令。 +- 生命周期锚点独立于 replay 队列保存,`subscribe` 必须返回最新的一条 `turn.started` / `turn.completed`,否则新订阅无法判定回合是否仍在运行。 +- 删除范围包含 `read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns`、`read_direct_project_history`(整份历史)以及 `game-creator-direct-turn-update` 事件与两个投影文件模块;保留分页用的历史切片读取。 +- 审批与提问事件本次只作为同一条事件流 pass-through,不并入聊天 reducer 驱动的状态机,迁移面收敛在历史与运行态一致性上。 + +## 备选方案与取舍 + +1. **保留 `tool-calls.jsonl` 作为"读侧已脱敏"缓存**:省一次脱敏与截断,但它成为与项目对话历史并行的第二事实源,卡片状态与顺序会和实时事件分叉。选择按读取期投影,必要时在进程内缓存。 +2. **保留 Direct 回合事件作为实时传输**:迁移量小,但同一段文本仍有两条实时链路,reducer 必须处理互相覆盖,正是本次要消除的问题。 +3. **让后端分页按"可显示消息数"切片**:界面能少写循环,代价是 Rust 需要理解 UI 可见性,界面规则一变就要同步改后端。 + +## 影响 + +- 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再读。 +- 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。 +- 回合结束语义务必由 `turn.completed` 判定;缺少该事件的残留回合不得被渲染成运行中。 +- 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl`。 +- 待实现前验证的风险:`item.started` 的条目 id 来自 app-server item,`item.completed` 的来自 raw response item,两者是否同一 id 空间必须先拿真实 app-server 会话核对;若不是同一 id 空间,活跃条目将永远收不到完成事件。 diff --git a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md new file mode 100644 index 000000000..035ff0cc6 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md @@ -0,0 +1,38 @@ +# 【实施计划】DirectProject 聊天真相源收敛 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md` | +| Status | ready | +| Owner | Codex | + +## 修改边界 + +- 允许修改:`agent/direct_thread_manager.rs`、`agent/codex_app_server/`、`agent/runtime_driver/entrypoints.rs`、`agent/direct_project_history.rs`、`main.rs` 命令注册、AGC 前端订阅与聊天投影、对应测试与 `docs/`。 +- 明确不修改:SpacetimeDB schema 与绑定、HTTP/OpenAPI、非 DirectProject Runtime、Codex durable thread 行为、审批弹层现有状态来源。 +- 保持 `.env` 未提交修改,不触碰个人配置。 + +## 实现顺序 + +1. 先用真实 app-server 会话核对 `item.started` 与 `item.completed` 的条目 id 是否同一 id 空间;不一致则先在适配层统一 id,再进入后续步骤。 +2. Thread Manager 事件自足化:`item.started` / `item.completed` 携带经同一套脱敏、截断、路径归一的完整条目,且与历史切片同形。 +3. 删除 Direct 回合事件发点、`direct_turn_stream.rs` / `direct_tool_calls.rs` 两个投影模块及其命令注册,保留历史切片读取与 `subscribe` / `consume` / notify。 +4. 前端收敛为单一 reducer:bootstrap 与 consume 走同一条事件流,notify 唤醒为主、活动回合期间低频 `consume` tick 兜底,过期后重新订阅并原子替换。 +5. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,锚点按原始条目 id 推进,切片无可见条目时自动连拉(上限 5 页)。 +6. 删除前端 `directTurnStream` / `directToolCalls` 状态、活动回合快照接管与流排序回退分支,把聊天投影收敛成"条目 → 视图"一条路径。 +7. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态。 + +## 验证命令 + +1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_thread_manager -- --nocapture` +2. `npx vitest run apps/ai-game-creator-shell/tests/directThreadEvents.test.ts apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts` +3. `npx vitest run apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` +4. TypeScript 类型检查与 ESLint(范围同前次 DirectProject 迁移)。 +5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` + +## 风险与回滚点 + +- 条目 id 空间不一致会让活跃条目永远收不到完成事件:这是第 1 步的显式前置验证,不通过不进入实现。 +- 事件 payload 变大(命令输出、文件变更明细):继续沿用既有截断上限,并观察 Thread Manager 单 thread 字节上限是否被提前触发。 +- 订阅过期与 notify 丢失:以重新 `subscribe` + 原子替换处理过期,以低频 tick 兜底丢失,两者都需要单测覆盖。 +- 回滚点:每一步都保持"新源可用即不依赖旧源"的中间态可回退;不允许出现新源未启用而旧源已删除的提交。 diff --git a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md new file mode 100644 index 000000000..f0b124e5a --- /dev/null +++ b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md @@ -0,0 +1,47 @@ +# 【里程碑】DirectProject 聊天真相源收敛 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | proposed | +| Date | 2026-09-16 | +| Parent Spec | `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` | + +## 目标 + +AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话历史**(`.agent/conversations/project.jsonl`)与 **运行态事件**(Thread Manager `subscribe` / `consume` / `notify`)。删除 Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl` 与活动回合快照接管这些并行真相源。 + +## 范围 + +- 运行态事件自足化:`item.started` / `item.completed` 携带与历史切片同形的完整脱敏条目,前端按条目身份合并运行中与完成态。 +- 历史读取以 `subscribe` 返回的 `lastCompletedItemId` 为首屏锚点,分页锚点取原始条目 id;不可显示条目由前端跳过并继续取页。 +- 前端收敛为单一事件 reducer 与单一聊天投影;活动回合只由 `turn.started` 与 `turn.completed` 判定。 +- 删除不再被使用的运行时事件、持久投影文件读写、命令与前端状态。 + +## 不在范围内 + +- 审批、提问与用户输入请求的状态机迁移;本次事件只作同一条流的 pass-through。 +- 跨进程回合账本、按回合统计与持久 turn ledger。 +- 旧项目磁盘上既有投影文件的清理、迁移或回填。 +- 非 DirectProject 运行时、Codex durable thread 语义、SpacetimeDB 与 HTTP 契约。 + +## 依赖与前置条件 + +- Thread Manager 深模块、app-server 事件适配与 Tauri 桥接已存在。 +- 主规范中的生命周期锚点、事件自足与首屏锚点条款已生效。 +- 实现前需用真实 app-server 会话核对 `item.started` 与 `item.completed` 是否同一 id 空间。 + +## 验收标准 + +- [ ] 一次回合内,实时渲染的文本段与工具卡片顺序,与回合结束后重进项目看到的顺序一致。 +- [ ] 回合进行中终止并重启进程后重进项目:已落盘的部分文本与工具卡片按原顺序出现,且界面不显示忙碌态。 +- [ ] 进程存活期间的页面重进(含切走再切回)能恢复运行中回合,并允许终止。 +- [ ] 历史分页在切片内全部是不可显示条目时仍能继续向前,不出现锚点停滞。 +- [ ] 文件系统不再新增 `turn-stream.jsonl` / `tool-calls.jsonl`;聊天内容只由项目对话历史与运行态事件重建。 +- [ ] 订阅过期后重新 `subscribe` 并原子替换状态,不重复渲染已完成的条目。 + +## 证据要求 + +- 自动化:Thread Manager 事件契约测试、聊天 reducer 单测(bootstrap / consume / 过期重订阅 / 残回合)、历史分页锚点测试、前端渲染测试(含此前跳过的工具卡片用例)。 +- 运行时:真实 app-server 会话下的新回合、杀进程重开、页面重进、分页与终止。 +- 边界:订阅过期、事件重复与乱序、不可显示切片、无 `turn.completed` 的残回合、工具输出超长截断与脱敏。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index a2b266ecb..0c6a66d8b 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -98,6 +98,10 @@ readHistory(threadId, { beforeItemId?, limit }) -> { `notify` 只负责唤醒,不携带事件、cursor 或持久化状态。前端收到通知后调用 `consume`;通知可合并、重复或丢失,事件完整性由 `consume` 保证。 +因为唤醒本身允许丢失,前端在「已判定存在活动回合」期间以低频 `consume` tick(1–2 秒)兜底;`consume` 自带 cursor,重复 tick 不会重复返回事件。 + +首屏历史不通过"读取整份对话"的命令获取:`subscribe` 返回的 `lastCompletedItemId` 就是首屏锚点,前端据此调用 `readHistory` 取最近的切片,再按滚动或按钮继续向前分页。系统不提供返回整份对话历史的命令。 + ### 事件和顺序 Thread 内所有公开事件共用一个单调递增 seq;seq 允许跳号,前端不要求连续。事件 envelope 至少包含: @@ -116,6 +120,12 @@ Thread 内所有公开事件共用一个单调递增 seq;seq 允许跳号, 一个 thread 同时最多有一个 active turn;一个 turn 内允许多个并发 item。`turn.completed` 必须在该 turn 的完成 item 均成功持久化后进入队列,前端据此结束运行态;不能用“不存在 unfinished item”猜测 turn 是否完成。 +前端 reducer 的活动回合判定只有一条:state 中存在「出现过 `turn.started` 且未出现对应 `turn.completed`」的 `turnId` 时,该 turn 才是活动回合,界面才允许显示忙碌态。`subscribe` bootstrap 中没有这样的 `turnId`,就表示当前没有活动回合;Thread Manager 队列随进程消失,因此进程重启后历史里留下的半截回合一律按已结束渲染,前端不发明中断态,也不从历史条目反推忙碌态。 + +生命周期锚点独立于 replay 队列保存:`turn.started` / `turn.completed` 事件即使已被队列前缀回收,`subscribe` 仍必须把最新的一条作为 bootstrap 事件返回。因此进程内任意时刻新建订阅,都能判定最新回合是运行中还是已结束,不依赖"未完成 item 恰好还在队列里"。 + +`item.started` 与 `item.completed` 必须携带与历史切片同形的完整条目(经同一套脱敏、截断、路径归一),不得只给 item 类型或空 payload。前端不得依赖"按 `itemId` 单点取快照"补齐正文:Rust 不提供 `getItemSnapshot(itemId)`,未完成条目的正文随事件下发,已完成条目一律通过历史读取。 + ### 队列、subscriber 和回收 每个 thread 一个 Vec-based append-only replay queue,使用逻辑 head 偏移清理前缀,不做中间删除。完成 item 的事件在持久化成功后才可进入普通 replay 回收流程;unfinished item 的事件必须保留到 item 完成,不能被普通上限截断。 -- 2.52.0 From 0ec1179bf1197fe2f3d3d277862560649d6dccdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 18:17:38 +0800 Subject: [PATCH 40/92] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=89=8D=E7=AB=AF=20co?= =?UTF-8?q?nsume=20=E8=BD=AE=E8=AF=A2=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 唤醒只由 notify 驱动,bootstrap 后立刻 consume 一次补齐竞态窗口 - 定时 tick 无法自举:判断活动回合本身依赖事件,唤醒丢失时定时器不会启动 - 主规范、ADR 与实施计划同步删除该条款 --- docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md | 2 +- .../【实施计划】DirectProject聊天真相源收敛-2026-09-16.md | 4 ++-- ...术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index 3a65a47c1..76e36bb03 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -18,7 +18,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 运行态事件与历史切片使用同形条目信封(`turnId` / `itemId` / `seq` / `payload`),Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「条目 → 视图」投影函数;Rust 做的是机械投影,不是可见性判断。 - 活动回合的唯一判据是「出现过 `turn.started` 且未出现对应 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 - 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 -- `notify` 仍是唯一唤醒意图,但前端在已判定存在活动回合期间叠加低频 `consume` tick(1–2 秒)兜底,因为唤醒本身允许丢失。 +- `notify` 是唯一唤醒来源:前端在 bootstrap 之后立刻 `consume` 一次补齐竞态窗口,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。 - 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 - 思考过程与工具活动同样从运行态事件与历史条目推断,界面展示保持不变。 - 运行态事件必须自足:`item.started` / `item.completed` 携带与历史切片同形的完整条目(同一套脱敏、截断、路径归一),前端按 `itemId` 合并快照得到运行中与完成态;不提供按 `itemId` 单点取快照的接口。 diff --git a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md index 035ff0cc6..9815096d9 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md @@ -17,7 +17,7 @@ 1. 先用真实 app-server 会话核对 `item.started` 与 `item.completed` 的条目 id 是否同一 id 空间;不一致则先在适配层统一 id,再进入后续步骤。 2. Thread Manager 事件自足化:`item.started` / `item.completed` 携带经同一套脱敏、截断、路径归一的完整条目,且与历史切片同形。 3. 删除 Direct 回合事件发点、`direct_turn_stream.rs` / `direct_tool_calls.rs` 两个投影模块及其命令注册,保留历史切片读取与 `subscribe` / `consume` / notify。 -4. 前端收敛为单一 reducer:bootstrap 与 consume 走同一条事件流,notify 唤醒为主、活动回合期间低频 `consume` tick 兜底,过期后重新订阅并原子替换。 +4. 前端收敛为单一 reducer:bootstrap 与 consume 走同一条事件流,bootstrap 后立刻 consume 一次补齐竞态窗口,此后只由 notify 唤醒,过期后重新订阅并原子替换。 5. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,锚点按原始条目 id 推进,切片无可见条目时自动连拉(上限 5 页)。 6. 删除前端 `directTurnStream` / `directToolCalls` 状态、活动回合快照接管与流排序回退分支,把聊天投影收敛成"条目 → 视图"一条路径。 7. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态。 @@ -34,5 +34,5 @@ - 条目 id 空间不一致会让活跃条目永远收不到完成事件:这是第 1 步的显式前置验证,不通过不进入实现。 - 事件 payload 变大(命令输出、文件变更明细):继续沿用既有截断上限,并观察 Thread Manager 单 thread 字节上限是否被提前触发。 -- 订阅过期与 notify 丢失:以重新 `subscribe` + 原子替换处理过期,以低频 tick 兜底丢失,两者都需要单测覆盖。 +- 订阅过期:以重新 `subscribe` + bootstrap 后立即 `consume` + 原子替换处理,需要单测覆盖;不引入定时轮询。 - 回滚点:每一步都保持"新源可用即不依赖旧源"的中间态可回退;不允许出现新源未启用而旧源已删除的提交。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index 0c6a66d8b..1e66add9c 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -98,7 +98,7 @@ readHistory(threadId, { beforeItemId?, limit }) -> { `notify` 只负责唤醒,不携带事件、cursor 或持久化状态。前端收到通知后调用 `consume`;通知可合并、重复或丢失,事件完整性由 `consume` 保证。 -因为唤醒本身允许丢失,前端在「已判定存在活动回合」期间以低频 `consume` tick(1–2 秒)兜底;`consume` 自带 cursor,重复 tick 不会重复返回事件。 +`subscribe` 把新 subscriber 的游标钉在当时的队尾,因此前端拿到 bootstrap 后必须**立刻 `consume` 一次**,补齐"订阅落定到首条事件"之间的竞态窗口;此后不再轮询,也不设任何定时 `consume`。用定时器兜底既自举不了(判断"有活动回合"本身依赖事件),也把唤醒机制变成两套。 首屏历史不通过"读取整份对话"的命令获取:`subscribe` 返回的 `lastCompletedItemId` 就是首屏锚点,前端据此调用 `readHistory` 取最近的切片,再按滚动或按钮继续向前分页。系统不提供返回整份对话历史的命令。 -- 2.52.0 From aa9076c95cb5f31d272d4e21de81520a82ce1dde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 18:29:24 +0800 Subject: [PATCH 41/92] =?UTF-8?q?=E5=88=A0=E9=99=A4=20DirectProject=20?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E4=B8=93=E7=94=A8=20content=20part?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 DirectCodexUserContentPart 的 agc_image_reference 分支与对应 ts-rs 绑定 - Rust 校验与 Codex wire 投影只保留 agc_attachment_reference - 更新附件顺序投影测试的用例名、输入与断言 - 同步聊天素材功能说明、用户 Response item 里程碑与实施计划 - 在决策日志记录上传文件统一走附件 part、不保留图片兼容分支 --- .../src/agent/direct_codex_user_item/model.rs | 2 -- .../src/agent/direct_codex_user_item/validation.rs | 3 +-- .../src/agent/direct_codex_user_item/wire.rs | 11 ++++------- .../generated/DirectCodexUserContentPart.ts | 3 +-- ...计划】DirectProject用户ResponseItem输入-2026-09-15.md | 1 + ...碑】DirectProject用户ResponseItem输入-2026-09-15.md | 6 +++--- docs/project-memory/shared-memory/decision-log.md | 6 ++++++ docs/【功能说明】AGC聊天素材引用-2026-09-08.md | 2 ++ 8 files changed, 18 insertions(+), 16 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs index e739b420b..dc20fb9e7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -39,8 +39,6 @@ pub(crate) enum DirectCodexUserContentPart { /// Uploaded project attachment kept inline in canonical content. #[serde(rename = "agc_attachment_reference")] AgcAttachmentReference(DirectCodexUserAttachmentReferencePart), - #[serde(rename = "agc_image_reference")] - AgcImageReference(DirectCodexUserAttachmentReferencePart), } #[derive(Clone, Debug, Deserialize, Serialize, TS)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs index 50c285729..dfe3dda4d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs @@ -40,8 +40,7 @@ pub(crate) fn validate_direct_codex_user_item( reference_count = reference_count.saturating_add(1); validate_runtime_region_reference(&manifest, reference)?; } - DirectCodexUserContentPart::AgcAttachmentReference(reference) - | DirectCodexUserContentPart::AgcImageReference(reference) => { + DirectCodexUserContentPart::AgcAttachmentReference(reference) => { if reference.name.trim().is_empty() { return Err("附件缺少文件名".to_string()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index c4352f0cf..9c43406e6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -100,8 +100,7 @@ pub(crate) fn direct_codex_user_item_to_wire_input( summary.push(']'); summary } - DirectCodexUserContentPart::AgcAttachmentReference(reference) - | DirectCodexUserContentPart::AgcImageReference(reference) => { + DirectCodexUserContentPart::AgcAttachmentReference(reference) => { let mut summary = format!( "[附件:名称={};类型={};大小={} 字节", reference.name.trim(), @@ -191,7 +190,7 @@ mod tests { } #[test] - fn attachment_and_image_parts_remain_in_canonical_order_when_projected() { + fn attachment_parts_remain_in_canonical_order_when_projected() { let root = tempfile::tempdir().expect("temp project"); crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") .expect("init project"); @@ -201,16 +200,14 @@ mod tests { "id": "turn-1:user", "content": [ {"type": "input_text", "text": "先看"}, - {"type": "agc_image_reference", "name": "hero.png", "mediaType": "image/png", "size": 12, "localPath": "assets/hero.png", "status": "imported"}, {"type": "agc_attachment_reference", "name": "notes.txt", "mediaType": "text/plain", "size": 4, "localPath": "assets/notes.txt", "status": "imported"} ] }); let projected = direct_codex_user_item_to_response_item(root.path(), &item) .expect("user response item should project"); let content = projected["content"].as_array().expect("content array"); - assert_eq!(content.len(), 3); + assert_eq!(content.len(), 2); assert!(content[0]["text"].as_str().unwrap().contains("先看")); - assert!(content[1]["text"].as_str().unwrap().contains("hero.png")); - assert!(content[2]["text"].as_str().unwrap().contains("notes.txt")); + assert!(content[1]["text"].as_str().unwrap().contains("notes.txt")); } } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts index c3b7b0ea1..da509125d 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts @@ -10,5 +10,4 @@ export type DirectCodexUserContentPart = } & DirectCodexUserRuntimeRegionPart) | ({ type: 'agc_attachment_reference'; - } & DirectCodexUserAttachmentReferencePart) - | ({ type: 'agc_image_reference' } & DirectCodexUserAttachmentReferencePart); + } & DirectCodexUserAttachmentReferencePart); diff --git a/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md b/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md index 51b9ea7b8..c18716d77 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md +++ b/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md @@ -10,6 +10,7 @@ - 允许修改:AGC 壳 Rust agent 输入合同、DirectProject 历史适配、前端聊天引用模型、ts-rs 生成配置、当前聊天素材文档。 - 明确不修改:assistant 返回协议、工具 activity、附件/图片上传 DTO、SpacetimeDB、HTTP API;上传 DTO 仅在入口转换为 canonical `content[]`,不再以 sidecar 文本追加。 +- 本切片明确:不根据 MIME 类型判断图片,不生成 `agc_image_reference`;所有上传文件统一映射为 `agc_attachment_reference`。 ## 实现顺序 diff --git a/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md b/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md index 963ad4791..7a36a3be7 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md +++ b/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md @@ -19,13 +19,13 @@ - `agc_runtime_region_reference` 保留运行区域语义摘要。 - Rust 在持久化前完成白名单、manifest 与路径校验。 - 现有标准 `response_item` 原样兼容;legacy conversation 行不提供 fallback。 -- 保持 assistant 返回、工具 activity 与上传 DTO 不变;但 DirectProject canonical user item 不再保存附件 sidecar,附件和图片必须作为 `content[]` 中按顺序排列的 `agc_attachment_reference` / `agc_image_reference`。 +- 保持 assistant 返回、工具 activity 与上传 DTO 不变;DirectProject canonical user item 不再保存附件 sidecar,所有本地上传文件(包括图片)统一作为 `content[]` 中按顺序排列的 `agc_attachment_reference`。 ## 不在范围内 - assistant item 前端投影或 Tauri 返回值改造。 - 工具 item、reasoning、file change、MCP item 的 UI 模型化。 -- 附件/图片 content part:本里程碑明确新增并生成绑定;上传 DTO 只作为输入适配,不作为历史事实源。 +- 不新增图片专用 content part;上传 DTO 只作为输入适配,不作为历史事实源。 - SpacetimeDB schema 或 HTTP API 变更。 ## 依赖与前置条件 @@ -42,7 +42,7 @@ - [x] 未知 part、失效资源或非法路径在持久化前失败关闭。 - [x] canonical item 以 `response_item` 写入历史,标准旧 item 原样可读。 - [x] Codex wire input 不含 AGC 私有 part,且顺序与 canonical content 一致。 -- [x] assistant、附件和工具链路行为无变化;附件与图片按 content 顺序内联,附件-only 输入也能进入 DirectProject prompt。 +- [x] assistant、附件和工具链路行为无变化;所有上传文件按 content 顺序内联,附件-only 输入也能进入 DirectProject prompt。 ## 证据要求 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d778bcade..6a444a5c2 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8769,3 +8769,9 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:采用后台继续运行语义。Direct 回合由进程内项目身份锁持有,页面离开不取消;重进项目通过活动回合只读快照与 Thread Manager bootstrap/consume 恢复忙碌态和进度。左上角面板复用同一快照列出正在运行的 Direct 项目并支持进入。 - 边界:快照不写项目文件、不进入公共 API、不跨应用重启恢复;读取失败保留上一份结果并单独提示,不改写成权限或审批失败。身份锁排他性、付费身份和项目写锁不变。 + +## 2026-09-16 DirectProject 上传文件统一使用附件 content part + +- 决策:DirectProject 本地上传不区分图片与其它文件。用户使用同一个文件入口,前端不按 `mediaType` 做图片判断,所有上传文件统一写成 `agc_attachment_reference`;项目资源 `@` 引用仍使用 `agc_resource_reference`。 +- 原因:原 `agc_image_reference` 与附件 payload 完全相同,Rust wire 投影也把两者合并成同一段文本,不能代表真实的多模态图片输入。保留该 discriminator 只会制造错误语义。 +- 边界:本次不新增 `input_image`,不保留图片类型兼容分支,不迁移旧历史;图片多模态能力未来单独设计独立 payload 与 wire 投影。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index ca8874954..53f9f46f3 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -15,6 +15,8 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。 提交时前端把 Lexical 草稿直接编码为受限 Response API user `message` item:`input_text` 与 AGC 引用 part 按编辑顺序内联在同一个 `content[]` 中。资源引用只携带稳定 `resourceId`;运行画面引用携带区域语义摘要及关联资源 ID。Rust 是唯一 schema source(通过 `ts-rs` 生成 TypeScript 绑定),在发起回合前完成 item 白名单、字段边界、manifest 归属和路径安全校验;校验失败时本轮不持久化、不发送。通过校验的 canonical item 以 `response_item` envelope 写入项目历史,随后由 Rust 将 AGC part 临时转换为 Codex 可接受的 `input_text`,保持原始 content 顺序。已有标准 `response_item` 原样读取与复用;旧 legacy conversation 行不再提供 fallback。 +本地上传入口不区分图片和其它文件:用户从同一个文件选择器提交任意附件,前端不做 MIME 类型分流,所有上传文件统一编码为 `agc_attachment_reference`。图片不会因为扩展名或 MIME 类型获得另一种 canonical part;只有未来真正支持 Codex 多模态 `input_image` 投影时,才另行设计图片协议。 + 当前已完成: - 三个聊天入口共用 `ResourceReferenceInput`; -- 2.52.0 From 656c89e4b2450b87826887b52d613672c3e8a9b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 18:45:13 +0800 Subject: [PATCH 42/92] =?UTF-8?q?=E6=8A=8A=20DirectProject=20=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E6=9D=A1=E7=9B=AE=E7=9A=84=E6=8A=95=E5=BD=B1=E4=BB=8E?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E6=94=B6=E5=8F=A3=EF=BC=8CRust=20=E5=8F=AA?= =?UTF-8?q?=E6=90=AC=E8=BF=90=E8=84=B1=E6=95=8F=E5=8E=9F=E5=A7=8B=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 agent/direct_thread_raw_item.rs:把 Codex 原始 response item 与 app-server item 收敛成同一形状的脱敏原始条目,只做挑字段、脱敏、截断,不再生成卡片的 kind / 标题 / 折叠摘要 删除 agent/direct_chat_entry.rs:工具卡片形状不再由 Rust 生产 历史切片改为返回脱敏原始条目列表,不再在 Rust 侧合并 function_call 与 function_call_output 新增前端 directThreadItemProjection.ts:工具卡片 kind / 标题 / 摘要 / 状态与可见性判定全部在前端完成 directThreadChat reducer 改用同一投影函数处理运行态事件,新增 mergeDirectHistoryItems 走同一份投影与合并规则 DirectThreadHistorySlice 去掉 entries 与 itemTimestamps,改为 items + firstItemId --- .../src-tauri/src/agent.rs | 4 +- .../src/agent/codex_app_server/mod.rs | 34 +- .../src-tauri/src/agent/direct_chat_entry.rs | 523 ---------------- .../src/agent/direct_thread_manager.rs | 8 +- .../src/agent/direct_thread_raw_item.rs | 581 ++++++++++++++++++ .../src-tauri/src/agent/direct_tool_calls.rs | 132 +--- .../src-tauri/src/commands.rs | 9 +- apps/ai-game-creator-shell/src/App.tsx | 39 +- .../project-workspace/directThreadChat.ts | 38 +- .../project-workspace/directThreadEvents.ts | 42 +- .../directThreadItemProjection.ts | 281 +++++++++ .../tests/directThreadChat.test.ts | 185 ++++-- .../tests/directThreadEvents.test.ts | 27 +- 13 files changed, 1108 insertions(+), 795 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_raw_item.rs create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index bf380a888..f3ca892e7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -14,7 +14,6 @@ mod codex_cli; mod codex_provider_proxy; mod design_runtime; mod design_tools; -mod direct_chat_entry; mod direct_codex_attachments; mod direct_codex_audit; mod direct_codex_user_item; @@ -22,6 +21,7 @@ mod direct_project_history; mod direct_project_turn_history; mod direct_runtime; mod direct_thread_manager; +mod direct_thread_raw_item; mod direct_tool_bridge; mod direct_tool_calls; mod direct_tools_mcp; @@ -49,7 +49,6 @@ pub(crate) use codex_cli::{ }; pub(crate) use codex_provider_proxy::*; pub(crate) use design_runtime::*; -pub(crate) use direct_chat_entry::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_audit::*; pub(crate) use direct_codex_user_item::*; @@ -57,6 +56,7 @@ pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_thread_manager::*; +pub(crate) use direct_thread_raw_item::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_calls::*; pub(crate) use direct_tools_mcp::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index a5db418f6..8b789f2e8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -744,16 +744,18 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat /// Project an app-server item into the small public payload carried by the /// DirectProject event queue. Full item contents are persisted in JSONL and /// must not be forwarded through the runtime event stream. -/// 运行态事件载荷:与历史切片同形的聊天条目;拿不到条目时给空对象。 -fn direct_chat_entry_payload( +/// 运行态事件载荷:与历史切片同形的脱敏原始条目;拿不到条目时给空对象。 +/// +/// 这里不生成工具卡片形状:标题、折叠摘要和可见性都是前端投影的职责。 +fn direct_thread_raw_item_payload( root: &std::path::Path, item: &serde_json::Value, turn_id: Option<&str>, completed: bool, now_ms: u64, ) -> serde_json::Value { - direct_chat_entry_from_item(root, item, turn_id, completed, now_ms) - .and_then(|entry| serde_json::to_value(entry).ok()) + direct_thread_raw_item_from_value(root, item, turn_id, completed, now_ms) + .and_then(|raw| serde_json::to_value(raw).ok()) .unwrap_or_else(|| serde_json::json!({})) } @@ -3062,7 +3064,7 @@ impl CodexAppServerConnection { "rawResponseItem/completed 缺少 item".to_string(), )); } - let entry_payload = direct_chat_entry_payload( + let entry_payload = direct_thread_raw_item_payload( history_root, &item, Some(turn_id.as_str()), @@ -3232,7 +3234,7 @@ impl CodexAppServerConnection { turn_id: turn_id.clone(), item_id, call_id: None, - payload: direct_chat_entry_payload( + payload: direct_thread_raw_item_payload( history_root, item, Some(turn_id.as_str()), @@ -4549,8 +4551,8 @@ mod tests { "result": { "content": "large output" } }); assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1")); - // 运行态事件必须自足:载荷是投影后的聊天条目,前端不需要再按 itemId 取快照。 - let payload = direct_chat_entry_payload( + // 运行态事件必须自足:载荷是脱敏原始条目,前端不需要再按 itemId 取快照。 + let payload = direct_thread_raw_item_payload( std::path::Path::new("."), &item, Some("turn-1"), @@ -4558,8 +4560,8 @@ mod tests { 1000, ); assert_eq!( - payload.get("kind").and_then(serde_json::Value::as_str), - Some("tool") + payload.get("itemType").and_then(serde_json::Value::as_str), + Some("mcpToolCall") ); assert_eq!( payload.get("itemId").and_then(serde_json::Value::as_str), @@ -4569,7 +4571,17 @@ mod tests { payload.get("turnId").and_then(serde_json::Value::as_str), Some("turn-1") ); - assert!(payload.get("toolCall").is_some(), "{payload}"); + // 卡片标题 / 折叠摘要 / kind 属于前端投影:载荷里不得出现这些 UI 语义。 + assert!(payload.get("toolCall").is_none(), "{payload}"); + assert!(payload.get("title").is_none(), "{payload}"); + assert!(payload.get("summary").is_none(), "{payload}"); + assert!(payload.get("kind").is_none(), "{payload}"); + // 参数里的密钥不得随载荷下发(脱敏占位符可以保留,明文不行)。 + let arguments = payload + .get("arguments") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + assert!(!arguments.contains("\"secret\""), "{payload}"); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs deleted file mode 100644 index a348cddbd..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs +++ /dev/null @@ -1,523 +0,0 @@ -//! DirectProject 聊天条目投影。 -//! -//! 前端只消费一种形状:`DirectChatEntry`。运行态事件与历史切片都由这里把 -//! Codex 原始 response item / app-server thread item 投影成同一形状,并使用 -//! 同一套脱敏、截断与项目路径归一,避免实时与回读两套口径分叉。 -//! -//! 这里只做机械投影:不判断"哪些条目要显示",也不决定顺序策略——可见性与 -//! 排列属于前端聊天投影。 - -use super::direct_tool_calls::{direct_tool_call_from_item, sanitize_detail_text}; -use crate::DirectToolCall; -use serde::Serialize; -use serde_json::Value; -use std::path::Path; - -pub(crate) const DIRECT_CHAT_ENTRY_KIND_MESSAGE: &str = "message"; -pub(crate) const DIRECT_CHAT_ENTRY_KIND_REASONING: &str = "reasoning"; -pub(crate) const DIRECT_CHAT_ENTRY_KIND_TOOL: &str = "tool"; - -/// 单条条目文本上限:与回合流同一口径,避免单条正文无界。 -const DIRECT_CHAT_ENTRY_TEXT_MAX_CHARS: usize = 8000; -/// 工具输入/输出上限:与卡片明细同口径。 -const DIRECT_CHAT_ENTRY_TOOL_TEXT_MAX_CHARS: usize = 4000; - -/// 前端唯一消费的聊天条目。 -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectChatEntry { - /// 稳定身份:`project.jsonl` 里的 response item id(分页锚点也用它)。 - pub(crate) item_id: String, - /// 工具条目的调用 id:app-server `item.started` 的 `itemId` 与原始 item 的 `call_id`。 - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) call_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) turn_id: Option, - /// `message` | `reasoning` | `tool` - pub(crate) kind: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) role: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) text: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) tool_call: Option, - /// 条目自带的毫秒时间;拿不到就是 0,由调用方用文件记录时间补齐。 - pub(crate) at: u64, -} - -impl DirectChatEntry { - /// 合并身份:前端按 `callId ?? itemId` 归并实时与历史里的同一条目。 - pub(crate) fn identity(&self) -> &str { - self.call_id.as_deref().unwrap_or(&self.item_id) - } -} - -fn bounded(value: &str, max_chars: usize) -> String { - if value.chars().count() <= max_chars { - return value.to_string(); - } - let mut truncated = value.chars().take(max_chars).collect::(); - truncated.push('…'); - truncated -} - -fn item_text(item: &Value) -> Option { - if let Some(text) = item.get("text").and_then(Value::as_str) { - if !text.trim().is_empty() { - return Some(text.to_string()); - } - } - for key in ["content", "summary"] { - let Some(parts) = item.get(key).and_then(Value::as_array) else { - continue; - }; - let joined = parts - .iter() - .filter_map(|part| part.get("text").and_then(Value::as_str)) - .collect::>() - .join(""); - if !joined.trim().is_empty() { - return Some(joined); - } - } - None -} - -fn item_turn_id(item: &Value) -> Option { - let from_metadata = item - .get("internal_chat_message_metadata_passthrough") - .and_then(|meta| meta.get("turn_id").or_else(|| meta.get("turnId"))) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - if from_metadata.is_some() { - return from_metadata; - } - // 用户条目没有元数据,但 id 里带回合:`direct-codex::user`。 - let rest = item - .get("id") - .and_then(Value::as_str)? - .trim() - .strip_prefix("direct-codex:")?; - let turn = rest.rsplit_once(':')?.0.trim(); - (!turn.is_empty()).then(|| turn.to_string()) -} - -fn item_at_ms(item: &Value) -> u64 { - item.get("internal_chat_message_metadata_passthrough") - .and_then(|meta| meta.get("create_time")) - .and_then(Value::as_f64) - .map(|seconds| (seconds * 1000.0).clamp(0.0, u64::MAX as f64) as u64) - .unwrap_or_default() -} - -fn item_identity(item: &Value) -> Option<(String, Option)> { - let call_id = item - .get("call_id") - .or_else(|| item.get("callId")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let id = item - .get("id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - match (id, call_id) { - (Some(id), call_id) => Some((id, call_id)), - (None, Some(call_id)) => Some((call_id.clone(), Some(call_id))), - (None, None) => None, - } -} - -fn raw_tool_kind(name: &str) -> &'static str { - match name { - "exec_command" | "shell" | "exec" => "command", - "apply_patch" | "write_file" | "edit_file" | "create_file" => "file_change", - "web_search" | "web_search_preview" => "web_search", - _ => "mcp_tool", - } -} - -fn raw_tool_title(kind: &str) -> &'static str { - match kind { - "command" => "执行命令", - "file_change" => "编辑文件", - "web_search" => "联网检索", - "mcp_tool" => "调用工具", - _ => "工具调用", - } -} - -fn stringified(value: &Value) -> Option { - match value { - Value::Null => None, - Value::String(text) => (!text.trim().is_empty()).then(|| text.clone()), - other => serde_json::to_string(other).ok(), - } -} - -fn first_line(value: &str, max_chars: usize) -> String { - bounded(value.lines().next().unwrap_or_default().trim(), max_chars) -} - -/// 原始 response item 词汇里的工具条目:`function_call` 带输入,`function_call_output` 带输出。 -/// 两者共用 `call_id`,前端与历史投影都按它归并成同一张卡片。 -fn raw_tool_entry( - root: &Path, - item: &Value, - item_id: String, - call_id: Option, - turn_id: Option, - at: u64, - completed: bool, - now_ms: u64, -) -> Option { - let item_type = item.get("type").and_then(Value::as_str)?; - let call_id = call_id?; - let (kind, title, summary, detail) = match item_type { - "function_call" => { - let name = item.get("name").and_then(Value::as_str).unwrap_or_default(); - let arguments = item - .get("arguments") - .and_then(Value::as_str) - .unwrap_or_default(); - let kind = raw_tool_kind(name); - let command = bounded( - &sanitize_detail_text(root, arguments), - DIRECT_CHAT_ENTRY_TOOL_TEXT_MAX_CHARS, - ); - let summary = first_line(&command, 120); - ( - kind, - raw_tool_title(kind).to_string(), - summary, - crate::DirectToolCallDetail { - command: Some(command), - output: None, - changes: Vec::new(), - }, - ) - } - "function_call_output" => { - let output = item.get("output").and_then(stringified).map(|output| { - bounded( - &sanitize_detail_text(root, &output), - DIRECT_CHAT_ENTRY_TOOL_TEXT_MAX_CHARS, - ) - }); - ( - "other", - String::new(), - String::new(), - crate::DirectToolCallDetail { - command: None, - output, - changes: Vec::new(), - }, - ) - } - _ => return None, - }; - let status = if completed { "completed" } else { "running" }; - let started_at = if at > 0 { at } else { now_ms }; - Some(DirectChatEntry { - item_id, - call_id: Some(call_id.clone()), - turn_id: turn_id.clone(), - kind: DIRECT_CHAT_ENTRY_KIND_TOOL.to_string(), - role: None, - text: None, - tool_call: Some(DirectToolCall { - schema_version: "agc-tool-call.v1".to_string(), - id: call_id, - turn_id: turn_id.unwrap_or_default(), - kind: kind.to_string(), - title, - summary, - status: status.to_string(), - detail, - started_at, - updated_at: if at > 0 { at } else { now_ms }, - }), - at, - }) -} - -/// 把一条 Codex 条目投影成聊天条目;不属于聊天内容的条目(如 `function_call_output`)返回 `None`。 -pub(crate) fn direct_chat_entry_from_item( - root: &Path, - item: &Value, - turn_id: Option<&str>, - completed: bool, - now_ms: u64, -) -> Option { - let (item_id, call_id) = item_identity(item)?; - let turn_id = turn_id - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .or_else(|| item_turn_id(item)); - let at = item_at_ms(item); - - if let Some(entry) = raw_tool_entry( - root, - item, - item_id.clone(), - call_id.clone(), - turn_id.clone(), - at, - completed, - now_ms, - ) { - return Some(entry); - } - - if let Some(tool_call) = direct_tool_call_from_item( - root, - item, - turn_id.as_deref().unwrap_or_default(), - completed, - now_ms, - ) { - let call_id = call_id.or_else(|| Some(tool_call.id.clone())); - return Some(DirectChatEntry { - item_id, - call_id, - turn_id, - kind: DIRECT_CHAT_ENTRY_KIND_TOOL.to_string(), - role: None, - text: None, - tool_call: Some(tool_call), - at, - }); - } - - let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default(); - let (kind, role) = if item_type == "reasoning" { - (DIRECT_CHAT_ENTRY_KIND_REASONING, None) - } else { - let role = item.get("role").and_then(Value::as_str)?; - if !matches!(role, "user" | "assistant") { - return None; - } - (DIRECT_CHAT_ENTRY_KIND_MESSAGE, Some(role.to_string())) - }; - let text = item_text(item)?; - let text = bounded( - &sanitize_detail_text(root, &text), - DIRECT_CHAT_ENTRY_TEXT_MAX_CHARS, - ); - Some(DirectChatEntry { - item_id, - call_id, - turn_id, - kind: kind.to_string(), - role, - text: Some(text), - tool_call: None, - at, - }) -} - -/// 历史切片投影:文件顺序就是条目顺序。 -/// 同一调用的输入(`function_call`)与输出(`function_call_output`)在这里并成一张卡片。 -pub(crate) fn direct_chat_entries_from_history( - root: &Path, - items: &[Value], -) -> Vec { - let now_ms = super::direct_tool_calls::direct_tool_call_now_ms(); - let mut entries: Vec = Vec::new(); - for item in items { - let Some(entry) = direct_chat_entry_from_item(root, item, None, true, now_ms) else { - continue; - }; - match entries - .iter_mut() - .find(|existing| existing.identity() == entry.identity()) - { - Some(existing) => merge_chat_entry(existing, entry), - None => entries.push(entry), - } - } - entries -} - -/// 同一条目的后续快照只补空字段:后到的输出不能抹掉先到的命令与标题。 -fn merge_chat_entry(existing: &mut DirectChatEntry, incoming: DirectChatEntry) { - if let (Some(existing_call), Some(incoming_call)) = - (existing.tool_call.as_mut(), incoming.tool_call.as_ref()) - { - existing_call.detail.command = existing_call - .detail - .command - .take() - .or_else(|| incoming_call.detail.command.clone()); - existing_call.detail.output = existing_call - .detail - .output - .take() - .or_else(|| incoming_call.detail.output.clone()); - if existing_call.detail.changes.is_empty() { - existing_call.detail.changes = incoming_call.detail.changes.clone(); - } - if existing_call.title.trim().is_empty() { - existing_call.title = incoming_call.title.clone(); - } - if existing_call.summary.trim().is_empty() { - existing_call.summary = incoming_call.summary.clone(); - } - if existing_call.kind == "other" && incoming_call.kind != "other" { - existing_call.kind = incoming_call.kind.clone(); - } - existing_call.updated_at = existing_call.updated_at.max(incoming_call.updated_at); - existing_call.status = incoming_call.status.clone(); - } - if existing.text.is_none() { - existing.text = incoming.text; - } - if existing.role.is_none() { - existing.role = incoming.role; - } - if existing.turn_id.is_none() { - existing.turn_id = incoming.turn_id; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use std::path::Path; - - fn root() -> &'static Path { - Path::new(".") - } - - #[test] - fn message_item_projects_role_and_text() { - let entry = direct_chat_entry_from_item( - root(), - &json!({ - "id": "direct-codex:turn-1:user", - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": "做一个拼图游戏"}], - }), - None, - true, - 0, - ) - .expect("user entry"); - assert_eq!(entry.kind, DIRECT_CHAT_ENTRY_KIND_MESSAGE); - assert_eq!(entry.role.as_deref(), Some("user")); - assert_eq!(entry.text.as_deref(), Some("做一个拼图游戏")); - assert_eq!(entry.turn_id.as_deref(), Some("turn-1")); - } - - #[test] - fn tool_item_carries_call_id_and_card() { - let entry = direct_chat_entry_from_item( - root(), - &json!({ - "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call", - "name": "exec_command", - "arguments": "{\"cmd\": \"ls\"}", - "internal_chat_message_metadata_passthrough": {"turn_id": "turn-1"}, - }), - None, - true, - 1000, - ) - .expect("tool entry"); - assert_eq!(entry.kind, DIRECT_CHAT_ENTRY_KIND_TOOL); - assert_eq!( - entry.call_id.as_deref(), - Some("call_00_Gpd0s0Ytm9YgIbwbEXva1473") - ); - assert_eq!(entry.identity(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); - assert_eq!(entry.item_id, "05dc0af1-8023-47fd-ad22-d54df2837b1b"); - assert!(entry.tool_call.is_some()); - } - - #[test] - fn reasoning_item_projects_text_without_role() { - let entry = direct_chat_entry_from_item( - root(), - &json!({ - "id": "reason-1", - "type": "reasoning", - "content": [{"type": "output_text", "text": "先看目录"}], - }), - Some("turn-1"), - true, - 0, - ) - .expect("reasoning entry"); - assert_eq!(entry.kind, DIRECT_CHAT_ENTRY_KIND_REASONING); - assert_eq!(entry.role, None); - assert_eq!(entry.text.as_deref(), Some("先看目录")); - } - - #[test] - fn non_chat_items_and_secrets_are_not_leaked() { - assert!(direct_chat_entry_from_item( - root(), - &json!({"id": "sys-1", "type": "message", "role": "system", "content": [{"type": "input_text", "text": "x"}]}), - Some("turn-1"), - true, - 0, - ) - .is_none()); - - let entry = direct_chat_entry_from_item( - root(), - &json!({ - "id": "msg-1", - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "key=sk-abcdefghijklmnop"}], - }), - Some("turn-1"), - true, - 0, - ) - .expect("assistant entry"); - let text = entry.text.unwrap_or_default(); - assert!( - !text.contains("sk-abcdefghijklmnop"), - "不得泄漏明文密钥:{text}" - ); - } - - #[test] - fn history_merges_call_input_and_output_into_one_card() { - let items = vec![ - json!({ - "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call", - "name": "exec_command", - "arguments": "{\"cmd\": \"ls\"}", - }), - json!({ - "id": "fco_01a06fa5-d636-7452-b337-a641c2e6bc76", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call_output", - "output": "assets\ngame\n", - }), - ]; - let entries = direct_chat_entries_from_history(root(), &items); - assert_eq!(entries.len(), 1, "同一调用只能有一张卡片"); - let card = entries[0].tool_call.as_ref().expect("card"); - assert_eq!(card.kind, "command"); - assert_eq!(card.title, "执行命令"); - assert_eq!(card.detail.command.as_deref(), Some("{\"cmd\": \"ls\"}")); - assert_eq!(card.detail.output.as_deref(), Some("assets\ngame")); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index 44a2b67ce..20c668a11 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -10,7 +10,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Mutex, OnceLock}; use uuid::Uuid; -use crate::agent::DirectChatEntry; +use crate::agent::DirectThreadRawItem; const DEFAULT_MAX_EVENTS: usize = 8_192; const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; @@ -70,11 +70,9 @@ pub(crate) struct DirectThreadConsumeResult { #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct DirectThreadHistorySlice { - pub(crate) items: Vec, + /// 脱敏原始条目,顺序即文件顺序。与运行态事件载荷同形,前端只有一套投影。 + pub(crate) items: Vec, pub(crate) has_more: bool, - pub(crate) item_timestamps: std::collections::BTreeMap, - /// 与运行态事件同形的聊天条目(顺序即文件顺序)。 - pub(crate) entries: Vec, /// 本次切片的原始条目锚点:无论切片里有没有可显示条目,分页都要靠它继续向前。 pub(crate) first_item_id: Option, } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_raw_item.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_raw_item.rs new file mode 100644 index 000000000..2dc96f3fe --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_raw_item.rs @@ -0,0 +1,581 @@ +//! DirectProject 「原始条目」搬运层。 +//! +//! Thread Manager 只做搬运:这里把 Codex 原始 response item 与 app-server thread item +//! 收敛成同一形状的**脱敏原始条目**,只做三件事——挑字段、脱敏、截断。 +//! +//! 这里不生成工具卡片,不推导标题 / 折叠摘要 / 可见性 / 排序:`title`、`summary`、 +//! `kind` 和"哪些条目要显示"全部是前端投影的职责。运行态事件与历史切片共用本形状, +//! 前端因此只需要一套投影与一套合并规则。 + +use crate::agent::redact_secret_tokens; +use crate::agent::sanitize_error_context; +use crate::redact_absolute_path_tokens; +use serde::Serialize; +use serde_json::Value; +use std::path::Path; + +/// 正文(消息 / 思考)上限。 +const DIRECT_THREAD_RAW_TEXT_MAX_CHARS: usize = 8000; +/// 工具明细(命令 / 参数 / 输出)上限。 +const DIRECT_THREAD_RAW_DETAIL_MAX_CHARS: usize = 4000; +/// 单条变更路径上限。 +const DIRECT_THREAD_RAW_PATH_MAX_CHARS: usize = 300; + +/// 原始条目里的一条文件变更。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectThreadRawChange { + pub(crate) path: String, + /// 原始 kind:`add` | `update` | `delete`(缺失时为 `update`)。 + pub(crate) kind: String, +} + +/// 前端唯一消费的条目形状:脱敏、截断后的 Codex 原始条目。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectThreadRawItem { + /// `project.jsonl` 里的 response item id;app-server `item/started` 时是 app-server item id。 + pub(crate) item_id: String, + /// 工具条目的调用 id:两个 id 空间唯一对齐点(原始 item 的 `call_id`)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) call_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) turn_id: Option, + /// Codex 原始 `type`:`message` / `reasoning` / `function_call` / `function_call_output` / + /// `commandExecution` / `fileChange` / `mcpToolCall` / `webSearch` / …(原样透传,不做映射)。 + pub(crate) item_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) arguments: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) command: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) tool: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) changes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) item_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) success: Option, + /// 协议事实:来自 `item/completed` / `rawResponseItem/completed` 为 true。 + pub(crate) completed: bool, + /// 毫秒时间;条目自带时间与记录时间都拿不到时为 0。 + pub(crate) at: u64, +} + +fn bounded(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_string(); + } + let mut truncated = value.chars().take(max_chars).collect::(); + truncated.push('…'); + truncated +} + +/// 原始值转文本:字符串原样,其它 JSON 值序列化。 +fn value_text(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(text) => (!text.trim().is_empty()).then(|| text.trim().to_string()), + other => serde_json::to_string_pretty(other).ok(), + } +} + +fn detail_text(root: &Path, value: &str) -> String { + bounded( + &sanitize_detail_text(root, value), + DIRECT_THREAD_RAW_DETAIL_MAX_CHARS, + ) +} + +fn item_text(root: &Path, item: &Value) -> Option { + let raw = item + .get("text") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + for key in ["content", "summary"] { + let Some(parts) = item.get(key).and_then(Value::as_array) else { + continue; + }; + let joined = parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join(""); + if !joined.trim().is_empty() { + return Some(joined); + } + } + None + }) + .filter(|text| !text.trim().is_empty())?; + Some(bounded( + &sanitize_detail_text(root, &raw), + DIRECT_THREAD_RAW_TEXT_MAX_CHARS, + )) +} + +fn item_turn_id(item: &Value) -> Option { + let from_metadata = item + .get("internal_chat_message_metadata_passthrough") + .and_then(|meta| meta.get("turn_id").or_else(|| meta.get("turnId"))) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if from_metadata.is_some() { + return from_metadata; + } + // 用户条目没有元数据,但 id 里带回合:`direct-codex::user`。 + let rest = item + .get("id") + .and_then(Value::as_str)? + .trim() + .strip_prefix("direct-codex:")?; + let turn = rest.rsplit_once(':')?.0.trim(); + (!turn.is_empty()).then(|| turn.to_string()) +} + +fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 { + let from_metadata = item + .get("internal_chat_message_metadata_passthrough") + .and_then(|meta| meta.get("create_time")) + .and_then(Value::as_f64) + .map(|seconds| (seconds * 1000.0).clamp(0.0, u64::MAX as f64) as u64) + .unwrap_or_default(); + if from_metadata > 0 { + return from_metadata; + } + for key in ["startedAtMs", "completedAtMs"] { + let value = item.get(key).and_then(Value::as_u64).unwrap_or_default(); + if value > 0 { + return value; + } + } + observed_at_ms +} + +fn item_identity(item: &Value) -> Option<(String, Option)> { + let call_id = item + .get("call_id") + .or_else(|| item.get("callId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let id = item + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + match (id, call_id) { + (Some(id), call_id) => Some((id, call_id)), + (None, Some(call_id)) => Some((call_id.clone(), Some(call_id))), + (None, None) => None, + } +} + +fn item_changes(root: &Path, item: &Value) -> Vec { + item.get("changes") + .and_then(Value::as_array) + .map(|changes| { + changes + .iter() + .filter_map(|change| { + let path = change + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|path| !path.is_empty())?; + Some(DirectThreadRawChange { + path: bounded( + &sanitize_detail_text(root, path), + DIRECT_THREAD_RAW_PATH_MAX_CHARS, + ), + kind: change + .get("kind") + .and_then(Value::as_str) + .unwrap_or("update") + .to_string(), + }) + }) + .collect::>() + }) + .unwrap_or_default() +} + +/// 把一条 Codex 条目搬运成脱敏原始条目;拿不到身份或类型时返回 `None`。 +/// +/// `observed_at_ms` 只作为条目自带时间缺失时的兜底(运行态用当前时间,历史用文件记录时间)。 +pub(crate) fn direct_thread_raw_item_from_value( + root: &Path, + item: &Value, + turn_id: Option<&str>, + completed: bool, + observed_at_ms: u64, +) -> Option { + if !item.is_object() { + return None; + } + let (item_id, call_id) = item_identity(item)?; + let item_type = item + .get("type") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(); + let turn_id = turn_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| item_turn_id(item)); + let at = item_at_ms(item, observed_at_ms); + + let text = match item_type.as_str() { + "message" | "agentMessage" | "userMessage" | "reasoning" => item_text(root, item), + _ => None, + }; + let arguments = item + .get("arguments") + .and_then(value_text) + .map(|value| detail_text(root, &value)); + let output = ["output", "aggregatedOutput", "result", "error"] + .iter() + .find_map(|key| item.get(key).and_then(value_text)) + .map(|value| detail_text(root, &value)); + let command = item + .get("command") + .and_then(Value::as_str) + .map(str::trim) + .filter(|command| !command.is_empty()) + .map(|command| detail_text(root, command)); + + Some(DirectThreadRawItem { + item_id, + call_id, + turn_id, + item_type, + role: item + .get("role") + .and_then(Value::as_str) + .map(str::trim) + .filter(|role| !role.is_empty()) + .map(str::to_string), + text, + name: item + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string), + arguments, + output, + command, + tool: item + .get("tool") + .and_then(Value::as_str) + .map(str::trim) + .filter(|tool| !tool.is_empty()) + .map(|tool| sanitize_detail_text(root, tool)), + changes: item_changes(root, item), + item_status: item + .get("status") + .and_then(Value::as_str) + .map(str::trim) + .filter(|status| !status.is_empty()) + .map(str::to_string), + exit_code: item.get("exitCode").and_then(Value::as_i64), + success: item.get("success").and_then(Value::as_bool), + completed, + at, + }) +} + +/// 历史切片搬运:保持文件顺序,不做任何合并。 +/// +/// 同一调用的 `function_call` 与 `function_call_output` 是两条原始条目,合并属于前端。 +/// `timestamp_of` 是文件记录时间,仅在条目自带时间缺失时兜底。 +pub(crate) fn direct_thread_raw_items_from_history( + root: &Path, + items: &[Value], + timestamp_of: impl Fn(&Value) -> u64, +) -> Vec { + items + .iter() + .filter_map(|item| { + direct_thread_raw_item_from_value(root, item, None, true, timestamp_of(item)) + }) + .collect() +} + +/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。 +fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) { + let mut index = start; + let mut relative = String::new(); + while index < value.len() { + let character = value[index..].chars().next().unwrap_or_default(); + if matches!(character, '/' | '\\') { + if !relative.is_empty() { + relative.push('/'); + } + index += character.len_utf8(); + continue; + } + if character.is_whitespace() + || matches!( + character, + '\'' | '"' + | '`' + | ',' + | ';' + | '|' + | '&' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | '<' + | '>' + | ':' + ) + { + break; + } + relative.push(character); + index += character.len_utf8(); + } + while relative.ends_with('/') { + relative.pop(); + } + (index, relative) +} + +/// 把项目根目录前缀换成**项目相对路径**(`/game/src/x.ts` → `game/src/x.ts`)。 +/// +/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成 +/// ``,之后就再也认不出哪些路径在项目内了。 +/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。 +fn relativize_project_root_paths(root: &Path, value: &str) -> String { + let root_text = root.to_string_lossy(); + let root_text = root_text.trim_end_matches(['/', '\\']); + if root_text.is_empty() { + return value.to_string(); + } + let mut needles = [ + root_text.to_string(), + root_text.replace('\\', "/"), + root_text.replace('/', "\\"), + ] + .into_iter() + .map(|needle| needle.to_ascii_lowercase()) + .filter(|needle| !needle.is_empty()) + .collect::>(); + needles.sort(); + needles.dedup(); + let lower = value.to_ascii_lowercase(); + + let mut output = String::with_capacity(value.len()); + let mut cursor = 0usize; + while cursor < value.len() { + let mut hit: Option<(usize, usize)> = None; + for needle in &needles { + let mut search = cursor; + while let Some(relative) = lower[search..].find(needle.as_str()) { + let start = search + relative; + let end = start + needle.len(); + let left_is_boundary = start == 0 + || lower[..start].chars().next_back().is_some_and(|character| { + !character.is_alphanumeric() && character != '_' && character != '-' + }); + if left_is_boundary && value[end..].starts_with(['/', '\\']) { + if hit.is_none_or(|(best_start, _)| start < best_start) { + hit = Some((start, end)); + } + break; + } + search = end; + } + } + let Some((start, end)) = hit else { + break; + }; + output.push_str(&value[cursor..start]); + let (consumed, relative) = project_relative_path_segment(value, end); + if relative.is_empty() { + // 只写了项目根目录本身(没有后续路径段):按占位形状处理。 + output.push_str(""); + } else { + output.push_str(&relative); + } + cursor = consumed; + } + output.push_str(&value[cursor..]); + output +} + +/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与 +/// 错误上下文脱敏。 +/// +/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录 +/// 仍然会留下;这里先归一化路径 token,再处理密钥。 +pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String { + let without_project_root = relativize_project_root_paths(root, value); + let without_absolute = redact_absolute_path_tokens(&without_project_root); + let without_secret = redact_secret_tokens(&without_absolute); + sanitize_error_context(&without_secret) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::path::Path; + + fn root() -> &'static Path { + Path::new(".") + } + + #[test] + fn message_item_carries_role_text_and_turn() { + let item = direct_thread_raw_item_from_value( + root(), + &json!({ + "id": "direct-codex:turn-1:user", + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "做一个拼图游戏"}], + }), + None, + true, + 0, + ) + .expect("user item"); + assert_eq!(item.item_type, "message"); + assert_eq!(item.role.as_deref(), Some("user")); + assert_eq!(item.text.as_deref(), Some("做一个拼图游戏")); + assert_eq!(item.turn_id.as_deref(), Some("turn-1")); + } + + #[test] + fn tool_item_keeps_call_id_and_raw_command() { + let item = direct_thread_raw_item_from_value( + root(), + &json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + "internal_chat_message_metadata_passthrough": {"turn_id": "turn-1"}, + }), + None, + true, + 1000, + ) + .expect("tool item"); + assert_eq!(item.item_type, "function_call"); + assert_eq!( + item.call_id.as_deref(), + Some("call_00_Gpd0s0Ytm9YgIbwbEXva1473") + ); + assert_eq!(item.name.as_deref(), Some("exec_command")); + assert_eq!(item.arguments.as_deref(), Some("{\"cmd\": \"ls\"}")); + assert_eq!(item.turn_id.as_deref(), Some("turn-1")); + } + + #[test] + fn reasoning_item_keeps_text_without_role() { + let item = direct_thread_raw_item_from_value( + root(), + &json!({ + "id": "reason-1", + "type": "reasoning", + "content": [{"type": "output_text", "text": "先看目录"}], + }), + Some("turn-1"), + true, + 0, + ) + .expect("reasoning item"); + assert_eq!(item.item_type, "reasoning"); + assert_eq!(item.role, None); + assert_eq!(item.text.as_deref(), Some("先看目录")); + } + + #[test] + fn secrets_and_absolute_paths_are_not_leaked() { + let item = direct_thread_raw_item_from_value( + root(), + &json!({ + "id": "msg-1", + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "key=sk-abcdefghijklmnop at /root/secret/x"}], + }), + Some("turn-1"), + true, + 0, + ) + .expect("assistant item"); + let text = item.text.unwrap_or_default(); + assert!( + !text.contains("sk-abcdefghijklmnop"), + "不得泄漏明文密钥:{text}" + ); + assert!(!text.contains("/root/secret"), "不得泄漏绝对路径:{text}"); + } + + #[test] + fn history_keeps_call_and_output_as_two_raw_items() { + let items = vec![ + json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + }), + json!({ + "id": "fco_01a06fa5-d636-7452-b337-a641c2e6bc76", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call_output", + "output": "assets\ngame\n", + }), + ]; + let projected = direct_thread_raw_items_from_history(root(), &items, |_| 0); + assert_eq!(projected.len(), 2, "搬运层不得替前端做合并"); + assert_eq!(projected[0].item_type, "function_call"); + assert_eq!(projected[1].item_type, "function_call_output"); + assert_eq!( + projected[0].call_id.as_deref(), + projected[1].call_id.as_deref() + ); + } + + #[test] + fn system_role_is_passed_through_for_frontend_to_filter() { + let item = direct_thread_raw_item_from_value( + root(), + &json!({"id": "sys-1", "type": "message", "role": "system", "content": [{"type": "input_text", "text": "x"}]}), + Some("turn-1"), + true, + 0, + ) + .expect("system item"); + assert_eq!(item.role.as_deref(), Some("system")); + assert_eq!(item.item_type, "message"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs index 82e9989e8..545b1fa11 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs @@ -8,11 +8,9 @@ //! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的 //! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。 -use crate::agent::redact_secret_tokens; -use crate::agent::sanitize_error_context; +use super::direct_thread_raw_item::sanitize_detail_text; use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file}; use crate::project::{enforce_project_permission_policy, project_append_lock_for}; -use crate::redact_absolute_path_tokens; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; @@ -86,134 +84,6 @@ fn tool_calls_path(root: &Path) -> PathBuf { root.join(".agent/conversations/tool-calls.jsonl") } -/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。 -fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) { - let mut index = start; - let mut relative = String::new(); - while index < value.len() { - let character = value[index..].chars().next().unwrap_or_default(); - if matches!(character, '/' | '\\') { - if !relative.is_empty() { - relative.push('/'); - } - index += character.len_utf8(); - continue; - } - if character.is_whitespace() - || matches!( - character, - '\'' | '"' - | '`' - | ',' - | ';' - | '|' - | '&' - | '(' - | ')' - | '[' - | ']' - | '{' - | '}' - | '<' - | '>' - | ':' - ) - { - break; - } - relative.push(character); - index += character.len_utf8(); - } - while relative.ends_with('/') { - relative.pop(); - } - (index, relative) -} - -/// 把项目根目录前缀换成**项目相对路径**(`/game/src/x.ts` → `game/src/x.ts`)。 -/// -/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成 -/// ``,之后就再也认不出哪些路径在项目内了。 -/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。 -fn relativize_project_root_paths(root: &Path, value: &str) -> String { - let root_text = root.to_string_lossy(); - let root_text = root_text.trim_end_matches(['/', '\\']); - if root_text.is_empty() { - return value.to_string(); - } - let mut needles = [ - root_text.to_string(), - root_text.replace('\\', "/"), - root_text.replace('/', "\\"), - ] - .into_iter() - .map(|needle| needle.to_ascii_lowercase()) - .filter(|needle| !needle.is_empty()) - .collect::>(); - needles.sort(); - needles.dedup(); - let lower = value.to_ascii_lowercase(); - - let mut output = String::with_capacity(value.len()); - let mut cursor = 0usize; - while cursor < value.len() { - let mut hit: Option<(usize, usize)> = None; - for needle in &needles { - let mut search = cursor; - while let Some(relative) = lower[search..].find(needle.as_str()) { - let start = search + relative; - let end = start + needle.len(); - let left_is_boundary = start == 0 - || lower[..start].chars().next_back().is_some_and(|character| { - !character.is_alphanumeric() && character != '_' && character != '-' - }); - if left_is_boundary && value[end..].starts_with(['/', '\\']) { - if hit.is_none_or(|(best_start, _)| start < best_start) { - hit = Some((start, end)); - } - break; - } - search = end; - } - } - let Some((start, end)) = hit else { - break; - }; - output.push_str(&value[cursor..start]); - let (consumed, relative) = project_relative_path_segment(value, end); - if relative.is_empty() { - // 只写了项目根目录本身(没有后续路径段):按占位形状处理。 - output.push_str(""); - } else { - output.push_str(&relative); - } - cursor = consumed; - } - output.push_str(&value[cursor..]); - output -} - -/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与 -/// 错误上下文脱敏。 -/// -/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录 -/// 仍然会留下;这里先归一化路径 token,再处理密钥。 -/// -/// 复用既有 `agent/generation/prompt_context.rs` 的脱敏组合:`sanitize_error_context` -/// 就是 `redact_secret_tokens` + `redact_error_sensitive_assignments` + -/// `redact_error_bearer_values` + `redact_error_config_names` 的既有组合用法,覆盖 -/// `Authorization: Bearer …`、`Cookie: …`、`api_key=…`、`client_secret=…` 这类键值凭据; -/// 含 `--password` / `--token` / `--secret` 这类敏感 CLI 标志的行按既有 fail-closed -/// 约定整行替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致)。 -/// -/// `pub(crate)`:回合流(`direct_turn_stream`)的文本段复用同一套脱敏,避免两处口径分叉。 -pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String { - let without_project_root = relativize_project_root_paths(root, value); - let without_absolute = redact_absolute_path_tokens(&without_project_root); - let without_secret = redact_secret_tokens(&without_absolute); - sanitize_error_context(&without_secret) -} - /// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。 fn bounded_chars(value: &str, max_chars: usize) -> String { if value.chars().count() <= max_chars { diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index d941f1c14..8dbdc185b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5383,17 +5383,20 @@ pub(crate) async fn read_direct_project_history_slice( before_item_id.as_deref(), limit.unwrap_or(20), )?; - let entries = direct_chat_entries_from_history(root, &items); let first_item_id = items .first() .and_then(|item| item.get("id")) .and_then(serde_json::Value::as_str) .map(str::to_string); + let items = direct_thread_raw_items_from_history(root, &items, |item| { + item.get("id") + .and_then(serde_json::Value::as_str) + .and_then(|id| item_timestamps.get(id).copied()) + .unwrap_or_default() + }); Ok(DirectThreadHistorySlice { items, has_more, - item_timestamps, - entries, first_item_id, }) }) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index ee075cfa5..5fc8d07e0 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -4101,6 +4101,7 @@ export function App({ : await readProjectSupervisorActiveSession(invoke, nextProjectPath); let runtimeError = ''; let loadedDirectHistoryHasMore = false; + let loadedDirectHistoryFirstItemId: string | null = null; const projectConversation = directCodexProductRuntime ? (() => { return invoke( @@ -4111,13 +4112,11 @@ export function App({ }, ).then((slice) => { loadedDirectHistoryHasMore = slice.hasMore; + loadedDirectHistoryFirstItemId = slice.firstItemId; return { path: nextProjectPath, agentId: null, - messages: directThreadHistoryItemsToMessages( - slice.items, - slice.itemTimestamps, - ), + messages: directThreadHistoryItemsToMessages(slice.items), } satisfies LocalConversationResult; }); })() @@ -4214,9 +4213,7 @@ export function App({ setProjectSupervisorRuntimeError(runtimeError || resumeError); if (directCodexProductRuntime) { setDirectHistoryHasMore(loadedDirectHistoryHasMore); - directHistoryOldestItemIdRef.current = - conversationMessages.find((message) => message.messageId) - ?.messageId ?? null; + directHistoryOldestItemIdRef.current = loadedDirectHistoryFirstItemId; } setMessages((current) => { // replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。 @@ -12493,25 +12490,23 @@ export function App({ if (localProjectPathRef.current !== projectPath) { return; } - const older = directThreadHistoryItemsToMessages( - slice.items, - slice.itemTimestamps, - ).map((message) => ({ - role: - message.role === 'user' - ? ('user' as const) - : ('assistant' as const), - text: message.content, - runtimeOwned: true, - messageId: message.messageId, - updatedAt: message.updatedAt, - })); + const older = directThreadHistoryItemsToMessages(slice.items).map( + (message) => ({ + role: + message.role === 'user' + ? ('user' as const) + : ('assistant' as const), + text: message.content, + runtimeOwned: true, + messageId: message.messageId, + updatedAt: message.updatedAt, + }), + ); setMessages((current) => [...older, ...current]); setConversationVisibleCount((current) => current + older.length); setDirectHistoryHasMore(slice.hasMore); directHistoryOldestItemIdRef.current = - older.find((message) => message.messageId)?.messageId ?? - directHistoryOldestItemIdRef.current; + slice.firstItemId ?? directHistoryOldestItemIdRef.current; } catch (error) { setWorkspaceStatus( `读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts index e2764e51e..ab5de6c00 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts @@ -10,6 +10,11 @@ import type { DirectThreadRawEvent, DirectThreadSubscriptionBootstrap, } from './directThreadEvents'; +import { + type DirectThreadRawItem, + isDirectThreadRawItem, + projectDirectThreadRawItem, +} from './directThreadItemProjection'; export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool'; @@ -123,17 +128,6 @@ function upsertLiveEntry( return { ...state, live }; } -/** 事件载荷就是聊天条目;不是聊天条目(例如纯工具输出之外的内部 item)返回 null。 */ -export function directChatEntryFromEventPayload( - payload: unknown, -): DirectChatEntry | null { - if (!payload || typeof payload !== 'object') return null; - const entry = payload as DirectChatEntry; - if (typeof entry.itemId !== 'string' || !entry.itemId.trim()) return null; - if (!entry.kind) return null; - return entry; -} - function reduceDirectThreadEvent( state: DirectThreadChatState, event: DirectThreadRawEvent, @@ -168,7 +162,9 @@ function reduceDirectThreadEvent( } case 'item.started': case 'item.completed': { - const entry = directChatEntryFromEventPayload(event.payload); + const entry = isDirectThreadRawItem(event.payload) + ? projectDirectThreadRawItem(event.payload) + : null; if (!entry) return state; return upsertLiveEntry(state, { ...entry, @@ -228,6 +224,24 @@ export function mergeDirectHistoryEntries( }; } +/** + * 历史切片并入:切片是脱敏原始条目,投影规则与运行态完全同一份。 + * + * `items` 必须按文件顺序给出;同一调用的 `function_call` 与 `function_call_output` + * 在这里按身份合并成一张卡片,而不是在 Rust 侧合并。 + */ +export function mergeDirectHistoryItems( + state: DirectThreadChatState, + items: readonly DirectThreadRawItem[], +): DirectThreadChatState { + return mergeDirectHistoryEntries( + state, + items + .map((item) => projectDirectThreadRawItem(item)) + .filter((entry): entry is DirectChatEntry => Boolean(entry)), + ); +} + /** 聊天投影输入:历史顺序 + 运行态覆盖;运行态独有条目排在最后。 */ export function selectDirectChatEntries( state: DirectThreadChatState, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts index 6c1cad229..78b8af25d 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts @@ -1,4 +1,5 @@ import type { LocalConversationMessageRecord } from '../../app/types'; +import type { DirectThreadRawItem } from './directThreadItemProjection'; export type DirectThreadRawEvent = { seq: number; @@ -19,33 +20,36 @@ export type DirectThreadConsumeResult = { }; export type DirectThreadHistorySlice = { - items: unknown[]; + /** 脱敏原始条目,顺序即文件顺序;与运行态事件载荷同形。 */ + items: DirectThreadRawItem[]; hasMore: boolean; - itemTimestamps?: Record; + /** 本次切片的原始条目锚点:分页继续向前靠它,而不是靠"有没有可显示条目"。 */ + firstItemId: string | null; }; +/** + * 历史条目转聊天消息。 + * + * 迁移期的过渡函数:只保留 `role ∈ {user, assistant}` 的文本条目,工具卡片与交替顺序 + * 由 `directThreadChat` 的 reducer 投影。App.tsx 切换到 reducer 后本函数删除。 + */ export function directThreadHistoryItemsToMessages( - items: unknown[], - itemTimestamps: Readonly> = {}, + items: readonly DirectThreadRawItem[], ): LocalConversationMessageRecord[] { - return items.flatMap((raw) => { - if (!raw || typeof raw !== 'object') return []; - const item = raw as Record; + return items.flatMap((item) => { + if ( + item.itemType !== 'message' && + item.itemType !== 'agentMessage' && + item.itemType !== 'userMessage' + ) { + return []; + } const role = item.role; if (role !== 'user' && role !== 'assistant') return []; const messageRole = role as 'user' | 'assistant'; - const content = Array.isArray(item.content) - ? item.content - .map((part) => - part && typeof part === 'object' && 'text' in part - ? (part as { text?: unknown }).text - : null, - ) - .filter((text): text is string => typeof text === 'string') - .join('') - : ''; + const content = typeof item.text === 'string' ? item.text : ''; if (!content) return []; - const messageId = typeof item.id === 'string' ? item.id : undefined; + const messageId = item.itemId; return [ { schemaVersion: 'agc-direct-project-context.v1', @@ -53,7 +57,7 @@ export function directThreadHistoryItemsToMessages( content, agentId: null, messageId, - updatedAt: messageId ? (itemTimestamps[messageId] ?? 0) : 0, + updatedAt: item.at ?? 0, }, ]; }); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts new file mode 100644 index 000000000..5ff47d33c --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts @@ -0,0 +1,281 @@ +/** + * DirectProject「原始条目 → 聊天条目」投影。 + * + * Rust 只搬运脱敏后的 Codex 原始条目(消息 / 思考 / 工具调用),工具卡片的 + * `kind`、标题、折叠摘要、状态判定和可见性全部在这里完成。运行态事件与历史切片 + * 走同一个函数,因此实时与回读不可能出现两套口径。 + */ + +import type { + GameCreatorDirectToolCall, + GameCreatorDirectToolCallChange, + GameCreatorDirectToolCallKind, + GameCreatorDirectToolCallStatus, +} from '../../app/types'; +import type { DirectChatEntry } from './directThreadChat'; + +/** 一个片段里的文件变更(与 Rust `DirectThreadRawChange` 同形)。 */ +export type DirectThreadRawChange = { + path: string; + kind: string; +}; + +/** Rust 搬运的脱敏原始条目:没有卡片语义,只有 Codex 原始字段。 */ +export type DirectThreadRawItem = { + itemId: string; + callId?: string | null; + turnId?: string | null; + itemType: string; + role?: string | null; + text?: string | null; + name?: string | null; + arguments?: string | null; + output?: string | null; + command?: string | null; + tool?: string | null; + changes?: DirectThreadRawChange[] | null; + itemStatus?: string | null; + exitCode?: number | null; + success?: boolean | null; + completed: boolean; + at?: number | null; +}; + +const MESSAGE_ITEM_TYPES = new Set(['message', 'agentMessage', 'userMessage']); +const REASONING_ITEM_TYPES = new Set(['reasoning']); +const TOOL_ITEM_TYPES = new Set([ + 'function_call', + 'function_call_output', + 'commandExecution', + 'fileChange', + 'mcpToolCall', + 'webSearch', + 'contextCompaction', +]); + +const FAILED_ITEM_STATUS = new Set([ + 'failed', + 'declined', + 'cancelled', + 'canceled', + 'aborted', +]); + +/** 折叠态摘要上限,与卡片契约一致。 */ +const TOOL_SUMMARY_MAX_CHARS = 120; + +function text(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function firstLine(value: string): string { + const [line = ''] = value.split('\n'); + const trimmed = line.trim(); + return trimmed.length > TOOL_SUMMARY_MAX_CHARS + ? `${trimmed.slice(0, TOOL_SUMMARY_MAX_CHARS)}…` + : trimmed; +} + +function toolKindFromFunctionName(name: string): GameCreatorDirectToolCallKind { + switch (name) { + case 'exec_command': + case 'shell': + case 'exec': + return 'command'; + case 'apply_patch': + case 'write_file': + case 'edit_file': + case 'create_file': + return 'file_change'; + case 'web_search': + case 'web_search_preview': + return 'web_search'; + default: + return 'mcp_tool'; + } +} + +function toolKind(raw: DirectThreadRawItem): GameCreatorDirectToolCallKind { + switch (raw.itemType) { + case 'function_call': + return toolKindFromFunctionName(text(raw.name)); + // 输出条目本身不携带工具语义:它只负责补上 `output`。 + case 'function_call_output': + return 'other'; + case 'commandExecution': + return 'command'; + case 'fileChange': + return 'file_change'; + case 'mcpToolCall': + return 'mcp_tool'; + case 'webSearch': + return 'web_search'; + case 'contextCompaction': + return 'context_compaction'; + default: + return 'other'; + } +} + +function toolStatus(raw: DirectThreadRawItem): GameCreatorDirectToolCallStatus { + const itemStatus = text(raw.itemStatus); + if (itemStatus === 'completed') return 'completed'; + if (FAILED_ITEM_STATUS.has(itemStatus)) return 'failed'; + // Codex 的退出码约定:非 0 即失败;缺席时按「已完成」处理。 + if (typeof raw.exitCode === 'number') { + return raw.exitCode === 0 ? 'completed' : 'failed'; + } + if (typeof raw.success === 'boolean') { + return raw.success ? 'completed' : 'failed'; + } + return raw.completed ? 'completed' : 'running'; +} + +function toolChanges( + raw: DirectThreadRawItem, +): GameCreatorDirectToolCallChange[] { + return (raw.changes ?? []) + .filter((change) => text(change?.path).trim().length > 0) + .map((change) => ({ + path: text(change.path), + kind: text(change.kind) || 'update', + })); +} + +function toolTitle( + kind: GameCreatorDirectToolCallKind, + changes: readonly GameCreatorDirectToolCallChange[], +): string { + switch (kind) { + case 'command': + return '执行命令'; + case 'file_change': { + const paths = new Set(changes.map((change) => change.path)); + return paths.size > 0 ? `编辑 ${paths.size} 个文件` : '编辑文件'; + } + case 'web_search': + return '联网检索'; + case 'context_compaction': + return '整理上下文'; + default: + return '调用工具'; + } +} + +/** + * 工具卡片投影。 + * + * `function_call_output` 只带输出,因此它的标题与摘要留空:后到的输出快照不允许 + * 抹掉先到的命令与标题(合并规则见 `mergeDirectChatEntry`)。 + */ +function projectToolCard( + raw: DirectThreadRawItem, +): GameCreatorDirectToolCall | null { + const kind = toolKind(raw); + const changes = toolChanges(raw); + const command = text(raw.command) || text(raw.arguments); + const tool = text(raw.tool); + const output = text(raw.output); + const isOutputOnly = raw.itemType === 'function_call_output'; + const detail: GameCreatorDirectToolCall['detail'] = {}; + if (command && !isOutputOnly) detail.command = command; + if (output) detail.output = output; + if (changes.length > 0) detail.changes = changes; + if (!detail.command && !detail.output && !detail.changes?.length) return null; + + const summarySource = + (kind === 'mcp_tool' ? tool : '') || + detail.command || + changes[0]?.path || + tool || + ''; + const at = typeof raw.at === 'number' && raw.at > 0 ? raw.at : 0; + return { + schemaVersion: 'agc-tool-call.v1', + id: text(raw.callId) || raw.itemId, + turnId: text(raw.turnId), + kind, + title: isOutputOnly ? '' : toolTitle(kind, changes), + summary: isOutputOnly ? '' : firstLine(summarySource), + status: toolStatus(raw), + detail, + startedAt: at, + updatedAt: at, + }; +} + +/** + * 原始条目投影成聊天条目;不属于聊天内容的条目返回 `null`。 + * + * 可见性判定只在这里:系统 / 开发者 message、无正文的空条目、未识别的 item 类型 + * 都不进聊天视图。 + */ +export function projectDirectThreadRawItem( + raw: DirectThreadRawItem | null | undefined, +): DirectChatEntry | null { + if (!raw) return null; + const itemId = text(raw.itemId).trim(); + const itemType = text(raw.itemType).trim(); + if (!itemId || !itemType) return null; + + if (REASONING_ITEM_TYPES.has(itemType)) { + const body = text(raw.text); + if (!body.trim()) return null; + return { + itemId, + callId: raw.callId ?? null, + turnId: raw.turnId ?? null, + kind: 'reasoning', + role: null, + text: body, + toolCall: null, + at: raw.at ?? 0, + }; + } + + if (MESSAGE_ITEM_TYPES.has(itemType)) { + const role = text(raw.role); + if (role !== 'user' && role !== 'assistant') return null; + const body = text(raw.text); + if (!body) return null; + return { + itemId, + callId: raw.callId ?? null, + turnId: raw.turnId ?? null, + kind: 'message', + role, + text: body, + toolCall: null, + at: raw.at ?? 0, + }; + } + + if (TOOL_ITEM_TYPES.has(itemType)) { + const toolCall = projectToolCard(raw); + if (!toolCall) return null; + return { + itemId, + callId: raw.callId ?? null, + turnId: raw.turnId ?? null, + kind: 'tool', + role: null, + text: null, + toolCall, + at: raw.at ?? 0, + }; + } + + return null; +} + +/** 事件 / 历史切片载荷是否像一条原始条目。 */ +export function isDirectThreadRawItem( + payload: unknown, +): payload is DirectThreadRawItem { + if (!payload || typeof payload !== 'object') return false; + const candidate = payload as Partial; + return ( + typeof candidate.itemId === 'string' && + typeof candidate.itemType === 'string' + ); +} diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index 912e645c7..feeffe84c 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from 'vitest'; import { - type DirectChatEntry, directChatEntryIdentity, emptyDirectThreadChatState, - mergeDirectHistoryEntries, + mergeDirectHistoryItems, reduceDirectThreadEvents, resolveDirectThreadBootstrap, selectDirectChatEntries, } from '../src/features/project-workspace/directThreadChat'; import type { DirectThreadRawEvent } from '../src/features/project-workspace/directThreadEvents'; +import type { DirectThreadRawItem } from '../src/features/project-workspace/directThreadItemProjection'; function event( partial: Partial & @@ -18,26 +18,34 @@ function event( return { seq: 1, payload: {}, ...partial }; } -function toolEntry( - overrides: Partial = {}, -): DirectChatEntry { +/** app-server `item/started`:工具真正开始执行,itemId 就是 call_id。 */ +function rawToolStarted( + overrides: Partial = {}, +): DirectThreadRawItem { return { - itemId: 'call-1', - callId: 'call-1', + itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', turnId: 'turn-1', - kind: 'tool', - toolCall: { - schemaVersion: 'agc-tool-call.v1', - id: 'call-1', - turnId: 'turn-1', - kind: 'command', - title: '执行命令', - summary: 'ls', - status: 'running', - detail: { command: 'ls', output: undefined, changes: [] }, - startedAt: 1000, - updatedAt: 1000, - }, + itemType: 'function_call', + name: 'exec_command', + arguments: '{"cmd": "ls"}', + completed: false, + at: 1000, + ...overrides, + }; +} + +/** 原始 response item 的 `function_call_output`:itemId 是另一个 id 空间,靠 callId 对齐。 */ +function rawToolOutput( + overrides: Partial = {}, +): DirectThreadRawItem { + return { + itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76', + callId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', + turnId: 'turn-1', + itemType: 'function_call_output', + output: 'assets\ngame', + completed: true, + at: 2000, ...overrides, }; } @@ -95,10 +103,12 @@ describe('DirectProject 聊天 reducer', () => { payload: { itemId: 'msg-1', turnId: 'turn-1', - kind: 'message', + itemType: 'message', role: 'assistant', text: '你好,我是陶泥儿。', - }, + completed: true, + at: 3000, + } satisfies DirectThreadRawItem, }), ]); const entries = selectDirectChatEntries(done); @@ -106,65 +116,124 @@ describe('DirectProject 聊天 reducer', () => { expect(entries[0]?.text).toBe('你好,我是陶泥儿。'); }); - it('工具条目 started/completed 归并成一张卡片', () => { + it('思考增量按 reasoning 条目累计,不混进助手文本', () => { const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ event({ - type: 'item.started', + type: 'item.delta', turnId: 'turn-1', - itemId: 'call-1', - payload: toolEntry(), - }), - event({ - type: 'item.completed', - turnId: 'turn-1', - itemId: 'call-1', - payload: { - ...toolEntry(), - toolCall: { - ...toolEntry().toolCall!, - status: 'completed', - detail: { command: 'ls', output: 'assets\ngame', changes: [] }, - updatedAt: 2000, - }, - }, + itemId: 'reason-1', + payload: { delta: '先看目录', kind: 'reasoning' }, }), ]); const entries = selectDirectChatEntries(state); expect(entries).toHaveLength(1); - expect(entries[0]?.toolCall?.status).toBe('completed'); - expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame'); - expect(entries[0]?.toolCall?.detail.command).toBe('ls'); + expect(entries[0]?.kind).toBe('reasoning'); + expect(entries[0]?.role).toBeNull(); + expect(entries[0]?.text).toBe('先看目录'); }); - it('历史条目与运行态按 callId 合并,重复条目只出现一次', () => { - const historical = mergeDirectHistoryEntries(emptyDirectThreadChatState(), [ - toolEntry(), + it('工具 started / output 跨 id 空间按 callId 归并成一张卡片', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.started', + turnId: 'turn-1', + itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', + payload: rawToolStarted(), + }), + event({ + type: 'item.completed', + turnId: 'turn-1', + itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76', + payload: rawToolOutput(), + }), + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(1); + expect(directChatEntryIdentity(entries[0]!)).toBe( + 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', + ); + expect(entries[0]?.toolCall?.kind).toBe('command'); + expect(entries[0]?.toolCall?.title).toBe('执行命令'); + expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); + expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame'); + expect(entries[0]?.toolCall?.status).toBe('completed'); + }); + + it('历史切片搬运层不合并,合并发生在前端投影', () => { + const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ + rawToolStarted(), + rawToolOutput(), { itemId: 'msg-user', - kind: 'message', + itemType: 'message', role: 'user', text: '做一个拼图游戏', + completed: true, + at: 0, + }, + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(2); + expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame'); + expect(entries[1]?.role).toBe('user'); + }); + + it('系统条目与未识别的 item 类型不进聊天视图', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.completed', + turnId: 'turn-1', + itemId: 'sys-1', + payload: { + itemId: 'sys-1', + itemType: 'message', + role: 'system', + text: '内部指令', + completed: true, + at: 0, + } satisfies DirectThreadRawItem, + }), + event({ + type: 'item.completed', + turnId: 'turn-1', + itemId: 'plan-1', + payload: { + itemId: 'plan-1', + itemType: 'plan', + completed: true, + at: 0, + } satisfies DirectThreadRawItem, + }), + ]); + expect(selectDirectChatEntries(state)).toHaveLength(0); + }); + + it('历史条目与运行态按身份合并,重复条目只出现一次', () => { + const historical = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ + rawToolStarted(), + { + itemId: 'msg-user', + itemType: 'message', + role: 'user', + text: '做一个拼图游戏', + completed: true, + at: 0, }, ]); const live = reduceDirectThreadEvents(historical, [ event({ type: 'item.completed', turnId: 'turn-1', - itemId: 'call-1', - payload: { - ...toolEntry(), - itemId: 'call-1', - toolCall: { - ...toolEntry().toolCall!, - status: 'completed', - detail: { command: 'ls', output: 'assets', changes: [] }, - }, - }, + itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76', + payload: rawToolOutput(), }), ]); const entries = selectDirectChatEntries(live); expect(entries).toHaveLength(2); expect(entries[0]?.toolCall?.status).toBe('completed'); - expect(directChatEntryIdentity(entries[0]!)).toBe('call-1'); + expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); + expect(directChatEntryIdentity(entries[0]!)).toBe( + 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', + ); }); }); diff --git a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts index 128cdf135..ccaebe645 100644 --- a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts @@ -20,20 +20,29 @@ describe('Direct 回合状态与历史时间', () => { expect(isDirectTurnInProgress(status)).toBe(true); } }); - it('按消息 id 读取信封时间,旧记录不使用当前时间补造', () => { + it('条目时间来自搬运后的 at,前端不自己补造时间', () => { const items = [ { - type: 'message', + itemId: 'direct-codex:turn:user', + itemType: 'message', role: 'user', - id: 'direct-codex:turn:user', - content: [{ type: 'input_text', text: '帮我修改游戏' }], + text: '帮我修改游戏', + completed: true, + at: 1_800_000_000_001, + }, + { + itemId: 'direct-codex:turn:assistant', + itemType: 'message', + role: 'assistant', + text: '好的', + completed: true, }, ]; - const timestamps = { 'direct-codex:turn:user': 1_800_000_000_001 }; - expect( - directThreadHistoryItemsToMessages(items, timestamps)[0]?.updatedAt, - ).toBe(1_800_000_000_001); - expect(directThreadHistoryItemsToMessages(items)[0]?.updatedAt).toBe(0); + expect(directThreadHistoryItemsToMessages(items)[0]?.updatedAt).toBe( + 1_800_000_000_001, + ); + // Rust 拿不到条目时间时给 0,前端不得用"当前时间"补造。 + expect(directThreadHistoryItemsToMessages(items)[1]?.updatedAt).toBe(0); expect(items[0]).not.toHaveProperty('recordedAt'); }); }); -- 2.52.0 From 423f2d71848fb124ec94750932e45d7bb39783d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 18:50:55 +0800 Subject: [PATCH 43/92] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E6=80=BB=E6=8E=A7=E8=81=8A=E5=A4=A9=E7=AA=97=E5=8F=A3=E6=9C=AA?= =?UTF-8?q?=E5=8F=91=E9=80=81=E8=8D=89=E7=A8=BF=E7=9A=84=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=20-=20App.tsx=20=E5=A2=9E=E5=8A=A0=20supervisorChatDraftHydrat?= =?UTF-8?q?edRef=20=E5=AE=88=E5=8D=AB=E7=9A=84=E6=8C=82=E8=BD=BD=E6=B0=B4?= =?UTF-8?q?=E5=90=88=EF=BC=9A=E7=8B=AC=E7=AB=8B=E6=80=BB=E6=8E=A7=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E7=AA=97=E5=8F=A3=E6=8C=82=E8=BD=BD=E8=B5=B0=20loadPr?= =?UTF-8?q?ojectConversation=EF=BC=8C=E4=B8=8D=E7=BB=8F=E8=BF=87=20openWor?= =?UTF-8?q?kspace=EF=BC=8C=E6=AD=A4=E5=89=8D=20sessionStorage=20=E9=87=8C?= =?UTF-8?q?=E7=9A=84=E8=8D=89=E7=A8=BF=E5=8F=AA=E5=86=99=E4=B8=8D=E8=AF=BB?= =?UTF-8?q?=EF=BC=8C=E9=87=8D=E8=BD=BD=E5=90=8E=E4=B8=BA=E7=A9=BA=20-=20?= =?UTF-8?q?=E4=BB=85=E5=9C=A8=20supervisorChatOnly=E3=80=81=E5=AD=98?= =?UTF-8?q?=E5=9C=A8=E6=8C=81=E4=B9=85=E5=8C=96=E8=8D=89=E7=A8=BF=E4=B8=94?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=A1=86=E5=B7=B2=E6=8C=82=E8=BD=BD=E6=97=B6?= =?UTF-8?q?=E5=9B=9E=E5=A1=AB=E4=B8=80=E6=AC=A1=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E7=94=A8=E6=88=B7=E5=90=8E=E7=BB=AD=E8=BE=93?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ai-game-creator-shell/src/App.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 77ea9afae..eed5fe807 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -2075,6 +2075,7 @@ export function App({ const agentRuntimeResumeProjectPathRef = useRef(null); const agentRunHistoryLoadingMoreRef = useRef(false); const initialProjectOpenedRef = useRef(false); + const supervisorChatDraftHydratedRef = useRef(false); const pendingUiConfirmationActionRef = useRef<(() => void) | null>(null); const queueRunLocalShortcutRef = useRef<() => void>(() => undefined); const executeRunLocalRef = useRef<(announceToChat: boolean) => void>( @@ -3078,6 +3079,16 @@ export function App({ if (!supervisorChatOnly) { return; } + // 独立总控聊天窗口的挂载路径直接走 loadProjectConversation,不经过 + // openWorkspace,所以草稿的读取要在这里补一次;只回填一次,之后的渲染不再 + // 覆盖用户正在输入的内容。输入框尚未挂载时不移除守卫,等它出现再水合。 + if (!supervisorChatDraftHydratedRef.current) { + const persistedDraft = readSupervisorChatDraft(initialProjectPath); + if (persistedDraft && chatComposerRef.current) { + supervisorChatDraftHydratedRef.current = true; + chatComposerRef.current.replaceText(persistedDraft); + } + } const draft = chatComposerRef.current?.getDraft(); if (!draft || !hasMeaningfulDirectCodexContent(draft.content)) { return; -- 2.52.0 From edfd1f9143c461f792fac47056fa23fd128044b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 18:51:08 +0800 Subject: [PATCH 44/92] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20Godot=20=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E5=9B=9E=E5=90=88=E7=94=A8=E4=BE=8B=E5=AF=B9=20canoni?= =?UTF-8?q?cal=20content=20=E7=9A=84=E6=96=AD=E8=A8=80=20-=20appSurface=20?= =?UTF-8?q?Godot=20=E7=94=A8=E4=BE=8B=E4=B8=8D=E5=86=8D=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E8=A2=AB=E8=A3=81=E5=89=AA=E8=BF=87=E7=9A=84=E7=BA=AF=E6=96=87?= =?UTF-8?q?=E6=9C=AC=EF=BC=8C=E6=94=B9=E4=B8=BA=E6=96=AD=E8=A8=80=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E5=AE=9E=E9=99=85=E6=8F=90=E4=BA=A4=E7=9A=84?= =?UTF-8?q?=20canonical=20content=EF=BC=88Shift+Enter=20=E4=B8=8E=E7=BB=84?= =?UTF-8?q?=E5=90=88=E6=80=81=20Enter=20=E6=8F=92=E5=85=A5=E7=9A=84?= =?UTF-8?q?=E6=8D=A2=E8=A1=8C=E5=8E=9F=E6=A0=B7=E4=BF=9D=E7=95=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/appSurface/project-development.suite.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 2438d7657..df819a4ab 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -9341,6 +9341,8 @@ export function registerProjectSupervisorSurfaceTests() { expect(fireEvent.keyDown(composer, { key: 'Enter' })).toBe(false); await waitFor(() => { + // canonical content 原样保留编辑器内容:上面的 Shift+Enter 与组合态 Enter + // 在编辑器里各插入一个真实换行,提交时不能被悄悄丢掉。 expect(invoke).toHaveBeenCalledWith( 'chat_with_game_creator_direct_codex', { @@ -9351,7 +9353,7 @@ export function registerProjectSupervisorSurfaceTests() { id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), type: 'message', role: 'user', - content: [{ type: 'input_text', text: '修改玩家移动脚本' }], + content: [{ type: 'input_text', text: '修改玩家移动脚本\n\n' }], }, }, ); -- 2.52.0 From 42b702d36209e31df3837f2df70b1dd6f2e0a585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 19:05:04 +0800 Subject: [PATCH 45/92] =?UTF-8?q?=E6=8C=89=20grill=20=E7=BB=93=E8=AE=BA?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20DirectProject=20=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E7=9C=9F=E7=9B=B8=E6=BA=90=E6=94=B6=E6=95=9B=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 里程碑与实施计划改定边界:Thread Manager 只搬运脱敏原始条目,不生成卡片形状与可见性 条目身份归一为 call_id ?? id,Thread Manager 与前端不再暴露第二个 id 概念,分页锚点仍取文件里的原始 item id 合并规则收敛为"先到定形、后到补空白",删除逐字段优先级表 主规范补充条目形状三条职责边界,并说明思考正文流式下发不放宽可见范围 ADR 同步:DirectRuntime 自己的进度事件与投影文件保留,前端删除的是 DirectProject 的读路径 失败与中止说明只在运行期显示;历史切片改为从文件尾反向扫描;未知 item 类型透传并加 TODO --- ...irectProject对话历史单一事实源-2026-09-16.md | 18 +++++++---- ...划】DirectProject聊天真相源收敛-2026-09-16.md | 26 ++++++++------- ...碑】DirectProject聊天真相源收敛-2026-09-16.md | 32 ++++++++++++++----- ...ectProject Codex原始历史与异常恢复-2026-09-04.md | 12 ++++++- 4 files changed, 61 insertions(+), 27 deletions(-) diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index 76e36bb03..e7b474cd4 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -12,19 +12,25 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - AGC 项目开发对话的持久事实源只有 **项目对话历史**(`.agent/conversations/project.jsonl` 的原始条目);消息文本、工具卡片和它们的先后顺序都从它派生。 - 运行期间的回合状态只来自 **运行态事件**(Thread Manager 的 subscribe / consume / notify);`notify` 只做唤醒,不携带状态。 -- **聊天投影** 在读取与渲染时生成,不落盘、不成为第二事实源;`turn-stream.jsonl` 与 `tool-calls.jsonl` 停止写入、停止读取,也不再提供读取命令。 +- **聊天投影** 在读取与渲染时生成,不落盘、不成为第二事实源;DirectProject 聊天框停止读取 `turn-stream.jsonl` 与 `tool-calls.jsonl`,也不再提供供前端读取的命令。DirectRuntime 自己那套进度事件与文件写入属于运行时账本,本轮保留不动。 - 页面重进的运行态只由 `subscribe` 的 bootstrap 事件重建,删除活动回合快照接管路径。 - 可见性判断留在前端聊天投影:后端历史分页只按原始条目切片,前端自己跳过不可显示条目并推进锚点。 -- 运行态事件与历史切片使用同形条目信封(`turnId` / `itemId` / `seq` / `payload`),Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「条目 → 视图」投影函数;Rust 做的是机械投影,不是可见性判断。 +- 运行态事件与历史切片使用同形条目信封(`turnId` / `itemId` / `seq` / `payload`),Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「原始条目 → 视图」投影函数。 +- 搬运层不生成展示形状:Thread Manager 只下发脱敏原始条目(`itemType` 原样透传),工具卡片的 `kind`、标题、折叠摘要都由前端生成。 +- 条目身份只有一套:`itemId = call_id ?? id`。工具 item 的 app-server id 就是 `call_id`,而 `project.jsonl` 落盘的是原始 response item(两个 id 不同),所以在进队列前归一,不向 Thread Manager 与前端暴露第二个 id 概念。 +- 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表。 - 活动回合的唯一判据是「出现过 `turn.started` 且未出现对应 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 - 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 - `notify` 是唯一唤醒来源:前端在 bootstrap 之后立刻 `consume` 一次补齐竞态窗口,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。 - 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 - 思考过程与工具活动同样从运行态事件与历史条目推断,界面展示保持不变。 -- 运行态事件必须自足:`item.started` / `item.completed` 携带与历史切片同形的完整条目(同一套脱敏、截断、路径归一),前端按 `itemId` 合并快照得到运行中与完成态;不提供按 `itemId` 单点取快照的接口。 +- 运行态事件必须自足:`item.started` / `item.completed` 携带与历史切片同形的完整**脱敏原始条目**,前端按归一后的 `itemId` 合并快照得到运行中与完成态;不提供按 `itemId` 单点取快照的接口。 +- 思考正文以 `item.delta{kind:"reasoning"}` 流式下发(`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta`)。这不放宽可见范围:同一段文本本来就已落进 `project.jsonl` 并在 `item.completed` 展示;plan 文本与命令输出仍只降级为活动状态。 - 首屏历史由 `subscribe` 返回的 `lastCompletedItemId` 锚定,再取最近切片;删除返回整份对话的历史命令。 - 生命周期锚点独立于 replay 队列保存,`subscribe` 必须返回最新的一条 `turn.started` / `turn.completed`,否则新订阅无法判定回合是否仍在运行。 -- 删除范围包含 `read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns`、`read_direct_project_history`(整份历史)以及 `game-creator-direct-turn-update` 事件与两个投影文件模块;保留分页用的历史切片读取。 +- 删除范围包含前端对 `read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns`、`read_direct_project_history`(整份历史)与 `game-creator-direct-turn-update` 事件的调用;保留分页用的历史切片读取,且该切片从文件尾反向扫描。 +- 失败与中止说明只在运行期显示,不写进 `project.jsonl`;页面重进后不再出现。 +- 历史切片的 `firstItemId` 是分页锚点,始终取 `project.jsonl` 里的原始 item id,与归一后的条目身份分开计算。 - 审批与提问事件本次只作为同一条事件流 pass-through,不并入聊天 reducer 驱动的状态机,迁移面收敛在历史与运行态一致性上。 ## 备选方案与取舍 @@ -35,8 +41,8 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( ## 影响 -- 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再读。 +- 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再由 DirectProject 聊天框读取。 - 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。 - 回合结束语义务必由 `turn.completed` 判定;缺少该事件的残留回合不得被渲染成运行中。 - 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl`。 -- 待实现前验证的风险:`item.started` 的条目 id 来自 app-server item,`item.completed` 的来自 raw response item,两者是否同一 id 空间必须先拿真实 app-server 会话核对;若不是同一 id 空间,活跃条目将永远收不到完成事件。 +- id 空间已用源码核对:codex-rs `app-server-protocol/src/protocol/thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`,而 `project.jsonl` 落盘的是原始 response item。真实 app-server 会话核对仍列为运行时验收项。 diff --git a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md index 9815096d9..f9040e238 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md @@ -8,31 +8,33 @@ ## 修改边界 -- 允许修改:`agent/direct_thread_manager.rs`、`agent/codex_app_server/`、`agent/runtime_driver/entrypoints.rs`、`agent/direct_project_history.rs`、`main.rs` 命令注册、AGC 前端订阅与聊天投影、对应测试与 `docs/`。 -- 明确不修改:SpacetimeDB schema 与绑定、HTTP/OpenAPI、非 DirectProject Runtime、Codex durable thread 行为、审批弹层现有状态来源。 +- 允许修改:`agent/direct_thread_manager.rs`、`agent/direct_thread_raw_item.rs`、`agent/codex_app_server/`、`agent/direct_project_history.rs`、`main.rs` 命令注册、AGC 前端订阅与聊天投影、对应测试与 `docs/`。 +- 明确不修改:SpacetimeDB schema 与绑定、HTTP/OpenAPI、DirectRuntime 自己的进度事件与 `turn-stream.jsonl` / `tool-calls.jsonl` 写入、Codex durable thread 行为、审批弹层现有状态来源。 - 保持 `.env` 未提交修改,不触碰个人配置。 ## 实现顺序 -1. 先用真实 app-server 会话核对 `item.started` 与 `item.completed` 的条目 id 是否同一 id 空间;不一致则先在适配层统一 id,再进入后续步骤。 -2. Thread Manager 事件自足化:`item.started` / `item.completed` 携带经同一套脱敏、截断、路径归一的完整条目,且与历史切片同形。 -3. 删除 Direct 回合事件发点、`direct_turn_stream.rs` / `direct_tool_calls.rs` 两个投影模块及其命令注册,保留历史切片读取与 `subscribe` / `consume` / notify。 -4. 前端收敛为单一 reducer:bootstrap 与 consume 走同一条事件流,bootstrap 后立刻 consume 一次补齐竞态窗口,此后只由 notify 唤醒,过期后重新订阅并原子替换。 -5. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,锚点按原始条目 id 推进,切片无可见条目时自动连拉(上限 5 页)。 -6. 删除前端 `directTurnStream` / `directToolCalls` 状态、活动回合快照接管与流排序回退分支,把聊天投影收敛成"条目 → 视图"一条路径。 -7. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态。 +1. Rust 只搬运:`agent/direct_thread_raw_item.rs` 把 Codex 原始条目挑字段、脱敏、截断后下发,事件载荷与历史切片同形,不生成卡片形状。 +2. 条目身份归一:`itemId = call_id ?? id`,同时从事件 envelope 与前端形状里删掉第二个 id 概念;历史切片的 `firstItemId` 继续取文件里的原始 item id。 +3. 思考正文流式:`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta` 产出 `item.delta{kind:"reasoning"}`;plan 文本与命令输出保持活动状态。 +4. 前端收敛为单一 reducer:bootstrap 与 consume 走同一条事件流,bootstrap 后立刻 consume 一次补齐竞态窗口,此后只由 notify 唤醒;合并规则只保留"先到定形、后到补空白",并删掉 `deltaText` 缓冲与回合结束后的运行态残留。 +5. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,历史读取改为从文件尾反向扫描;锚点按原始 item id 推进,切片无可见条目时自动连拉(上限 5 页)。 +6. App.tsx 接线:订阅 + 立即 consume + notify 唤醒,聊天视图改由 reducer 状态投影(含工具卡片),删除 Direct 回合事件订阅与 `directTurnStream` / `directToolCalls` 状态。 +7. 删除只服务旧读路径的命令与前端调用(`read_direct_project_history`、`read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns`),DirectRuntime 自己的写入保留。 +8. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态。 ## 验证命令 -1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_thread_manager -- --nocapture` -2. `npx vitest run apps/ai-game-creator-shell/tests/directThreadEvents.test.ts apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts` +1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_thread -- --nocapture` +2. `npx vitest run apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directThreadEvents.test.ts` 3. `npx vitest run apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` 4. TypeScript 类型检查与 ESLint(范围同前次 DirectProject 迁移)。 5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` ## 风险与回滚点 -- 条目 id 空间不一致会让活跃条目永远收不到完成事件:这是第 1 步的显式前置验证,不通过不进入实现。 +- 条目 id 空间不一致会让活跃条目永远收不到完成事件:第 2 步的归一必须在 Rust 出口完成;前端不得再拿到两个 id。 - 事件 payload 变大(命令输出、文件变更明细):继续沿用既有截断上限,并观察 Thread Manager 单 thread 字节上限是否被提前触发。 - 订阅过期:以重新 `subscribe` + bootstrap 后立即 `consume` + 原子替换处理,需要单测覆盖;不引入定时轮询。 +- 合并规则退化为"先到定形"后,若某类条目只有输出没有调用条目,该输出不显示;这是有意取舍,先观察再决定是否补规则。 - 回滚点:每一步都保持"新源可用即不依赖旧源"的中间态可回退;不允许出现新源未启用而旧源已删除的提交。 diff --git a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md index f0b124e5a..8009c1e3f 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md @@ -9,27 +9,41 @@ ## 目标 -AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话历史**(`.agent/conversations/project.jsonl`)与 **运行态事件**(Thread Manager `subscribe` / `consume` / `notify`)。删除 Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl` 与活动回合快照接管这些并行真相源。 +AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话历史**(`.agent/conversations/project.jsonl`)与 **运行态事件**(Thread Manager `subscribe` / `consume` / `notify`)。Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl` 与活动回合快照都不再是聊天视图的输入。 + +边界固定为:Thread Manager 只是**搬运层**——把 Codex 原始条目挑字段、脱敏、截断后下发;工具卡片的形状、可见性与合并全部由前端投影完成。条目身份只有一个:Rust 在进队列前归一到 `call_id ?? id`,不再暴露第二套 id 概念。 ## 范围 -- 运行态事件自足化:`item.started` / `item.completed` 携带与历史切片同形的完整脱敏条目,前端按条目身份合并运行中与完成态。 -- 历史读取以 `subscribe` 返回的 `lastCompletedItemId` 为首屏锚点,分页锚点取原始条目 id;不可显示条目由前端跳过并继续取页。 -- 前端收敛为单一事件 reducer 与单一聊天投影;活动回合只由 `turn.started` 与 `turn.completed` 判定。 -- 删除不再被使用的运行时事件、持久投影文件读写、命令与前端状态。 +- 运行态事件自足化:`item.started` / `item.completed` 携带与历史切片同形的脱敏**原始条目**,前端用同一个投影函数处理实时与回读。 +- 条目身份归一:`itemId = call_id ?? id`(工具 item 的 app-server id 就是 `call_id`);历史切片另给 `firstItemId` 作为分页锚点,锚点始终是文件里的原始 item id。 +- 思考正文流式:`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta` 以 `item.delta{kind:"reasoning"}` 下发正文;plan 文本与命令输出仍只降级为活动状态。 +- 历史读取以 `subscribe` 返回的 `lastCompletedItemId` 为首屏锚点,切片从**文件尾反向扫描**;不可显示条目由前端跳过并继续取页(上限 5 页)。 +- 前端收敛为单一事件 reducer 与单一聊天投影;活动回合只由 `turn.started` 与 `turn.completed` 判定;回合结束后回收该回合运行态条目。 +- 删除前端对 Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl`、活动回合快照的读取,以及只服务这些读取的命令与状态。 ## 不在范围内 - 审批、提问与用户输入请求的状态机迁移;本次事件只作同一条流的 pass-through。 - 跨进程回合账本、按回合统计与持久 turn ledger。 +- DirectRuntime 自己的进度事件与该运行时仍在使用的 `turn-stream.jsonl` / `tool-calls.jsonl` 写入:它们属于运行时的账本,本轮只切 DirectProject 聊天框的读路径。 - 旧项目磁盘上既有投影文件的清理、迁移或回填。 - 非 DirectProject 运行时、Codex durable thread 语义、SpacetimeDB 与 HTTP 契约。 +## 已确认的决策 + +- 投影在前端:Rust 不生成 `kind` / 标题 / 折叠摘要,也不做合并。 +- 合并只保留"先到定形、后到补空白":第一次见到的快照决定卡片形状,后续快照只补输出与状态;不做逐字段优先级表。 +- 条目 id 只有一套:`call_id ?? id`;前端与 Thread Manager 都不再出现第二个 id。 +- 思考正文流式下发不放宽可见范围:被下发的就是此前已在 `item.completed` 展示、并已落进 `project.jsonl` 的同一段文本。 +- 失败与中止说明只在运行期显示(不写 `project.jsonl`),页面重进后不再出现。 +- 未知 item 类型由 Rust 原样透传(只有类型与身份),当前由前端投影丢弃。 + ## 依赖与前置条件 - Thread Manager 深模块、app-server 事件适配与 Tauri 桥接已存在。 - 主规范中的生命周期锚点、事件自足与首屏锚点条款已生效。 -- 实现前需用真实 app-server 会话核对 `item.started` 与 `item.completed` 是否同一 id 空间。 +- id 空间已用源码核对:codex-rs `app-server-protocol/src/protocol/thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`,而 `project.jsonl` 落盘的是原始 response item(`id` 与 `call_id` 不同)。真实 app-server 会话核对仍作为运行时验收项。 ## 验收标准 @@ -37,11 +51,13 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 - [ ] 回合进行中终止并重启进程后重进项目:已落盘的部分文本与工具卡片按原顺序出现,且界面不显示忙碌态。 - [ ] 进程存活期间的页面重进(含切走再切回)能恢复运行中回合,并允许终止。 - [ ] 历史分页在切片内全部是不可显示条目时仍能继续向前,不出现锚点停滞。 -- [ ] 文件系统不再新增 `turn-stream.jsonl` / `tool-calls.jsonl`;聊天内容只由项目对话历史与运行态事件重建。 +- [ ] 聊天视图不再读取 `turn-stream.jsonl` / `tool-calls.jsonl` / Direct 回合事件 / 活动回合快照;`read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns` 不再被前端调用。 +- [ ] 同一工具调用在实时与回读各只出现一张卡片(跨 id 空间按归一身份对齐)。 +- [ ] 思考正文在回合进行中即可见,且不进入活动状态文本。 - [ ] 订阅过期后重新 `subscribe` 并原子替换状态,不重复渲染已完成的条目。 ## 证据要求 -- 自动化:Thread Manager 事件契约测试、聊天 reducer 单测(bootstrap / consume / 过期重订阅 / 残回合)、历史分页锚点测试、前端渲染测试(含此前跳过的工具卡片用例)。 +- 自动化:Thread Manager 事件契约测试、原始条目搬运与脱敏测试、聊天 reducer 单测(bootstrap / consume / 过期重订阅 / 残回合 / 先到优先合并)、历史分页锚点测试、前端渲染测试(含此前跳过的工具卡片用例)。 - 运行时:真实 app-server 会话下的新回合、杀进程重开、页面重进、分页与终止。 - 边界:订阅过期、事件重复与乱序、不可显示切片、无 `turn.completed` 的残回合、工具输出超长截断与脱敏。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index 1e66add9c..8ae5d836e 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -124,7 +124,17 @@ Thread 内所有公开事件共用一个单调递增 seq;seq 允许跳号, 生命周期锚点独立于 replay 队列保存:`turn.started` / `turn.completed` 事件即使已被队列前缀回收,`subscribe` 仍必须把最新的一条作为 bootstrap 事件返回。因此进程内任意时刻新建订阅,都能判定最新回合是运行中还是已结束,不依赖"未完成 item 恰好还在队列里"。 -`item.started` 与 `item.completed` 必须携带与历史切片同形的完整条目(经同一套脱敏、截断、路径归一),不得只给 item 类型或空 payload。前端不得依赖"按 `itemId` 单点取快照"补齐正文:Rust 不提供 `getItemSnapshot(itemId)`,未完成条目的正文随事件下发,已完成条目一律通过历史读取。 +`item.started` 与 `item.completed` 必须携带与历史切片同形的**脱敏原始条目**(经同一套挑字段、脱敏、截断、路径归一),不得只给 item 类型或空 payload。前端不得依赖"按 `itemId` 单点取快照"补齐正文:Rust 不提供 `getItemSnapshot(itemId)`,未完成条目的正文随事件下发,已完成条目一律通过历史读取。 + +条目形状的职责边界固定为三条: + +1. **搬运层不生成展示形状**。Thread Manager 只下发 Codex 原始条目(`itemType` 原样透传,正文与工具明细脱敏后带上限截断),不生成工具卡片的 `kind`、标题、折叠摘要,也不判断哪些条目要显示。 +2. **只有一个条目身份**。`itemId = call_id ?? id`:工具 item 的 app-server id 就是 `call_id`(codex-rs `thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`),而 `project.jsonl` 落盘的是原始 response item(`id` 与 `call_id` 不同),所以在进队列前归一。Thread Manager 与前端都不得再出现第二个 id 概念。 +3. **合并只在前端,且只保留"先到定形、后到补空白"**。第一次见到的快照决定卡片形状,后续快照只补输出与状态;同一调用只出现一张卡片。 + +历史切片的 `firstItemId` 不是上述归一身份:分页锚点必须是 `project.jsonl` 里的原始 item id,由 Rust 从文件扫描单独算出。 + +思考正文以 `item.delta{kind:"reasoning"}` 流式下发(来源是 app-server 的 `item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta`)。这不放宽可见文本范围:被下发的就是此前已在 `item.completed` 展示、并已落进 `project.jsonl` 的同一段文本;plan 文本与命令输出仍然只降级为活动状态,不下发正文。 ### 队列、subscriber 和回收 -- 2.52.0 From 09ad0073fe0905027c41a73e80947889f2115f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 19:06:05 +0800 Subject: [PATCH 46/92] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20DirectProject=20?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E8=AF=AD=E4=B9=89=EF=BC=9Abootstrap=20?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E5=B0=B1=E6=98=AF=E5=BD=93=E5=89=8D=E8=A6=81?= =?UTF-8?q?=E5=A4=84=E7=90=86=E7=9A=84=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit subscribe 已在同一边界把游标钉在队尾,因此前端直接 reduce bootstrap 事件,不再补一次 consume 主规范、ADR 与实施计划同步删除"竞态窗口"说法,notify 仍是唯一唤醒来源 --- docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md | 2 +- .../plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md | 2 +- ...技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index e7b474cd4..210db6135 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -21,7 +21,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表。 - 活动回合的唯一判据是「出现过 `turn.started` 且未出现对应 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 - 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 -- `notify` 是唯一唤醒来源:前端在 bootstrap 之后立刻 `consume` 一次补齐竞态窗口,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。 +- `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不额外补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。 - 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 - 思考过程与工具活动同样从运行态事件与历史条目推断,界面展示保持不变。 - 运行态事件必须自足:`item.started` / `item.completed` 携带与历史切片同形的完整**脱敏原始条目**,前端按归一后的 `itemId` 合并快照得到运行中与完成态;不提供按 `itemId` 单点取快照的接口。 diff --git a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md index f9040e238..1fc498481 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md @@ -17,7 +17,7 @@ 1. Rust 只搬运:`agent/direct_thread_raw_item.rs` 把 Codex 原始条目挑字段、脱敏、截断后下发,事件载荷与历史切片同形,不生成卡片形状。 2. 条目身份归一:`itemId = call_id ?? id`,同时从事件 envelope 与前端形状里删掉第二个 id 概念;历史切片的 `firstItemId` 继续取文件里的原始 item id。 3. 思考正文流式:`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta` 产出 `item.delta{kind:"reasoning"}`;plan 文本与命令输出保持活动状态。 -4. 前端收敛为单一 reducer:bootstrap 与 consume 走同一条事件流,bootstrap 后立刻 consume 一次补齐竞态窗口,此后只由 notify 唤醒;合并规则只保留"先到定形、后到补空白",并删掉 `deltaText` 缓冲与回合结束后的运行态残留。 +4. 前端收敛为单一 reducer:`subscribe` 返回的 bootstrap 事件就是已暂存的运行态,游标已经在队尾,前端直接 reduce 这批事件即可(不需要再补一次 `consume`);此后只由 notify 唤醒 `consume`。合并规则只保留"先到定形、后到补空白",并删掉 `deltaText` 缓冲与回合结束后的运行态残留。 5. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,历史读取改为从文件尾反向扫描;锚点按原始 item id 推进,切片无可见条目时自动连拉(上限 5 页)。 6. App.tsx 接线:订阅 + 立即 consume + notify 唤醒,聊天视图改由 reducer 状态投影(含工具卡片),删除 Direct 回合事件订阅与 `directTurnStream` / `directToolCalls` 状态。 7. 删除只服务旧读路径的命令与前端调用(`read_direct_project_history`、`read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns`),DirectRuntime 自己的写入保留。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index 8ae5d836e..b1d6888f7 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -98,7 +98,7 @@ readHistory(threadId, { beforeItemId?, limit }) -> { `notify` 只负责唤醒,不携带事件、cursor 或持久化状态。前端收到通知后调用 `consume`;通知可合并、重复或丢失,事件完整性由 `consume` 保证。 -`subscribe` 把新 subscriber 的游标钉在当时的队尾,因此前端拿到 bootstrap 后必须**立刻 `consume` 一次**,补齐"订阅落定到首条事件"之间的竞态窗口;此后不再轮询,也不设任何定时 `consume`。用定时器兜底既自举不了(判断"有活动回合"本身依赖事件),也把唤醒机制变成两套。 +`subscribe` 在同一个边界内先把新 subscriber 的游标钉在当时的队尾,再收集 bootstrap 的运行态事件,因此 bootstrap 返回的那批事件**就是**该 subscriber 此刻应处理的事件:前端直接 reduce 它们即可,不存在"再补一次 `consume` 才能补齐竞态窗口"的步骤。此后不再轮询,也不设任何定时 `consume`——唤醒只由 `notify` 负责。用定时器兜底既自举不了(判断"有活动回合"本身依赖事件),也把唤醒机制变成两套。 首屏历史不通过"读取整份对话"的命令获取:`subscribe` 返回的 `lastCompletedItemId` 就是首屏锚点,前端据此调用 `readHistory` 取最近的切片,再按滚动或按钮继续向前分页。系统不提供返回整份对话历史的命令。 -- 2.52.0 From 2748468d127a2c4c6455a535fe0358b26c603be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 19:38:31 +0800 Subject: [PATCH 47/92] =?UTF-8?q?DirectProject=20=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=94=B9=E7=94=A8=20ts-rs=20=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E7=9A=84=20tagged=20enum=20=E5=B9=B6=E5=88=A0?= =?UTF-8?q?=E6=8E=89=20turn=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 direct_thread_wire.rs:DirectThreadItem / DirectThreadEvent / 订阅与历史切片全部改成 ts-rs 导出的 tagged enum,取代原大而全的可空结构体 - 删除 direct_thread_raw_item.rs,模块注册与直通引用改到 direct_thread_wire - 条目身份只看一个 itemId:工具条目的第二个 id 在 Rust 边界归一,不再对外暴露 - 删除 DirectProject 聊天事件里的 turn id:生命周期用无载荷的 turn.started / turn.completed{status} 表示 - append 直接接收 DirectThreadEvent 并返回同一事件,队列内部自算 seq - 请求事件改为携带 DirectThreadRequestKind,去掉字符串中转 - 思考增量走 ReasoningDelta 通道,与正文增量共用 item.delta - at 用 #[ts(as = "f64")] 对齐 Tauri JSON 通道的 number - 用 cargo test export_bindings 重新生成 project-workspace/generated 绑定 --- .../src-tauri/src/agent.rs | 4 +- .../src/agent/codex_app_server/mod.rs | 224 ++--- .../src/agent/direct_thread_manager.rs | 448 ++++----- .../src/agent/direct_thread_raw_item.rs | 581 ------------ .../src-tauri/src/agent/direct_thread_wire.rs | 864 ++++++++++++++++++ .../src-tauri/src/agent/direct_tool_calls.rs | 2 +- .../src-tauri/src/commands.rs | 2 +- .../generated/DirectCodexUserContentPart.ts | 3 +- .../generated/DirectCodexUserItem.ts | 10 +- .../DirectCodexUserMessageEnvelope.ts | 4 + .../generated/DirectCodexUserMessageItem.ts | 6 +- .../generated/DirectCodexUserRole.ts | 2 +- .../DirectCodexUserRuntimeRegionPart.ts | 18 +- .../generated/DirectThreadConsumeResult.ts | 4 + .../generated/DirectThreadDeltaKind.ts | 6 + .../generated/DirectThreadEvent.ts | 30 + .../generated/DirectThreadFileChange.ts | 12 + .../generated/DirectThreadHistorySlice.ts | 14 + .../generated/DirectThreadItem.ts | 76 ++ .../generated/DirectThreadRequestKind.ts | 9 + .../DirectThreadSubscriptionBootstrap.ts | 14 + 21 files changed, 1315 insertions(+), 1018 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_raw_item.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadConsumeResult.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadDeltaKind.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadFileChange.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadHistorySlice.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadItem.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadSubscriptionBootstrap.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index f3ca892e7..5c8f7d58a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -21,7 +21,7 @@ mod direct_project_history; mod direct_project_turn_history; mod direct_runtime; mod direct_thread_manager; -mod direct_thread_raw_item; +mod direct_thread_wire; mod direct_tool_bridge; mod direct_tool_calls; mod direct_tools_mcp; @@ -56,7 +56,7 @@ pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_thread_manager::*; -pub(crate) use direct_thread_raw_item::*; +pub(crate) use direct_thread_wire::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_calls::*; pub(crate) use direct_tools_mcp::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 8b789f2e8..f47ec3b29 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -564,6 +564,17 @@ enum CodexTurnEvent { item_id: String, delta: String, }, + /// 思考正文增量:app-server `item/reasoning/summaryTextDelta` 的明文思考文本。 + /// + /// `item/reasoning/summaryTextDelta`(core `ReasoningContentDelta`)与 + /// `item/reasoning/textDelta`(core `ReasoningRawContentDelta`)都进这条通道:前者是 + /// reasoning item 的 `summary`,后者是它的 `content`,两段文本都随 `item/completed` + /// 落进 `project.jsonl`、此前也已经在完成时展示给用户。plan 文本与命令输出仍然只降级为 + /// 活动状态,不下发正文。 + ReasoningDelta { + item_id: String, + delta: String, + }, IntermediateText(String), Activity(&'static str), Item { @@ -571,7 +582,7 @@ enum CodexTurnEvent { params: serde_json::Value, }, Request { - event_type: &'static str, + kind: DirectThreadRequestKind, params: serde_json::Value, }, RawItem(serde_json::Value), @@ -741,40 +752,14 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat direct_codex_safe_activity_for_item(item_type) } -/// Project an app-server item into the small public payload carried by the -/// DirectProject event queue. Full item contents are persisted in JSONL and -/// must not be forwarded through the runtime event stream. -/// 运行态事件载荷:与历史切片同形的脱敏原始条目;拿不到条目时给空对象。 +/// 运行态事件载荷:与历史切片同形的脱敏原始条目;拿不到身份或类型就整条跳过。 /// /// 这里不生成工具卡片形状:标题、折叠摘要和可见性都是前端投影的职责。 -fn direct_thread_raw_item_payload( +fn direct_thread_event_item( root: &std::path::Path, item: &serde_json::Value, - turn_id: Option<&str>, - completed: bool, - now_ms: u64, -) -> serde_json::Value { - direct_thread_raw_item_from_value(root, item, turn_id, completed, now_ms) - .and_then(|raw| serde_json::to_value(raw).ok()) - .unwrap_or_else(|| serde_json::json!({})) -} - -fn direct_thread_item_id(item: &serde_json::Value) -> Option { - item.get("id") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - -/// 原始 response item 的调用 id:工具条目的 app-server `itemId` 就是这个值, -/// 所以它是两个 id 空间唯一的对齐点。 -fn direct_thread_item_call_id(item: &serde_json::Value) -> Option { - item.get("call_id") - .or_else(|| item.get("callId")) - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) +) -> Option { + direct_thread_item_from_value(root, item, direct_tool_call_now_ms()) } fn direct_codex_command_is_game_verification(command: &str) -> bool { @@ -991,19 +976,21 @@ fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static } } -fn direct_codex_request_event_type(method: &str) -> Option<&'static str> { +fn direct_codex_request_event_type(method: &str) -> Option { match method { "item/fileChange/requestApproval" | "item/commandExecution/requestApproval" - | "item/permissions/requestApproval" => Some("approval.requested"), - "item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => Some("ask.requested"), + | "item/permissions/requestApproval" => Some(DirectThreadRequestKind::ApprovalRequested), + "item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => { + Some(DirectThreadRequestKind::AskRequested) + } _ => None, } } -fn direct_codex_resolution_event_type(method: &str) -> Option<&'static str> { +fn direct_codex_resolution_event_type(method: &str) -> Option { match method { - "serverRequest/resolved" => Some("request.resolved"), + "serverRequest/resolved" => Some(DirectThreadRequestKind::RequestResolved), _ => None, } } @@ -1076,6 +1063,33 @@ fn direct_codex_notification_event( intermediate_text: Option, safe_activity: Option<&'static str>, ) -> Option { + // 思考正文走独立通道,交给 DirectProject 的运行态事件;它不因为 + // "preparing 活动" 的降级规则被丢掉,否则界面只能等 item/completed 才看到思考。 + // + // 两条通知都下发正文,不下发活动文本: + // - `item/reasoning/summaryTextDelta`(core `ReasoningContentDelta`)→ reasoning item 的 `summary`; + // - `item/reasoning/textDelta`(core `ReasoningRawContentDelta`)→ reasoning item 的 `content`, + // 正是 `project.jsonl` 里保存、并在此前 `item/completed` 已经展示给用户的同一段文本。 + // 因此这里只是把"完成时才看到"提前为"边生成边看到",没有放宽可见文本的范围; + // 未识别的 plan 文本与命令输出仍然只降级为活动状态,不下发正文。 + if matches!( + method, + "item/reasoning/summaryTextDelta" | "item/reasoning/textDelta" + ) { + return params + .get("delta") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(|delta| CodexTurnEvent::ReasoningDelta { + item_id: params + .get("itemId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| "direct-missing-item".to_string()), + delta: delta.to_string(), + }); + } let (activity, intermediate_text) = match (&intermediate_text, safe_activity) { (Some(_), Some(activity)) if activity == "preparing" => (Some(activity), None), _ => (safe_activity, intermediate_text), @@ -2943,19 +2957,7 @@ impl CodexAppServerConnection { turn_start_guard.armed = false; let direct_thread_id = history_root.to_string_lossy().into_owned(); if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { - append_direct_thread_event( - &direct_thread_id, - DirectThreadRawEventDraft { - event_type: "turn.started".to_string(), - turn_id: turn_id.clone(), - item_id: None, - call_id: None, - payload: serde_json::json!({ - "threadId": thread_id, - "turnId": turn_id, - }), - }, - ); + append_direct_thread_event(&direct_thread_id, DirectThreadEvent::turn_started()); } let mut receiver = self.register_turn(&turn_id).await; let mut direct_project_history = DirectProjectHistoryAccumulator::default(); @@ -3010,18 +3012,13 @@ impl CodexAppServerConnection { direct_project_history.observe_delta(&item_id, &delta); append_direct_thread_event( &direct_thread_id, - DirectThreadRawEventDraft { - event_type: "item.delta".to_string(), - turn_id: turn_id.clone(), - item_id: Some(item_id.clone()), - call_id: None, - // 事件自足:增量也要说明它是哪类条目的正文, - // 前端 reducer 不允许靠猜 itemId 的来源决定 kind。 - payload: serde_json::json!({ - "delta": delta.clone(), - "kind": "message", - }), - }, + // 事件自足:增量自带 item 身份与正文类别(正文 / 思考), + // 前端 reducer 不允许靠猜 itemId 的来源决定 kind。 + DirectThreadEvent::item_delta( + item_id.clone(), + DirectThreadDeltaKind::Message, + delta.clone(), + ), ); } streamed_text.push_str(&delta); @@ -3052,6 +3049,18 @@ impl CodexAppServerConnection { }); } } + Some(CodexTurnEvent::ReasoningDelta { item_id, delta }) => { + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::item_delta( + item_id, + DirectThreadDeltaKind::Reasoning, + delta, + ), + ); + } + } Some(CodexTurnEvent::IntermediateText(text)) => { if let Some(observer) = direct_observer.as_deref_mut() { observer(DirectCodexTurnObservation::IntermediateText(text)); @@ -3064,13 +3073,7 @@ impl CodexAppServerConnection { "rawResponseItem/completed 缺少 item".to_string(), )); } - let entry_payload = direct_thread_raw_item_payload( - history_root, - &item, - Some(turn_id.as_str()), - true, - direct_tool_call_now_ms(), - ); + let entry_item = direct_thread_event_item(history_root, &item); let history_root = history_root.to_path_buf(); let history_item = item.clone(); tokio::task::spawn_blocking(move || { @@ -3084,20 +3087,15 @@ impl CodexAppServerConnection { })? .map_err(platform_llm::LlmError::InvalidRequest)?; direct_project_history.complete_item(&item); - let item_id = direct_thread_item_id(&item); - append_direct_thread_event( - &direct_thread_id, - DirectThreadRawEventDraft { - event_type: "item.completed".to_string(), - turn_id: turn_id.clone(), - item_id, - call_id: direct_thread_item_call_id(&item), - payload: entry_payload, - }, - ); + if let Some(entry_item) = entry_item { + append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::item_completed(entry_item), + ); + } } } - Some(CodexTurnEvent::Request { event_type, params }) => { + Some(CodexTurnEvent::Request { kind, params }) => { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let request_id = params .get("requestId") @@ -3107,15 +3105,7 @@ impl CodexAppServerConnection { .map(str::to_string); append_direct_thread_event( &direct_thread_id, - DirectThreadRawEventDraft { - event_type: event_type.to_string(), - turn_id: turn_id.clone(), - item_id: None, - call_id: None, - payload: request_id - .map(|id| serde_json::json!({ "requestId": id })) - .unwrap_or_else(|| serde_json::json!({})), - }, + DirectThreadEvent::request(kind, request_id), ); } } @@ -3226,23 +3216,14 @@ impl CodexAppServerConnection { && self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { - let item_id = direct_thread_item_id(item); - append_direct_thread_event( - &direct_thread_id, - DirectThreadRawEventDraft { - event_type: "item.started".to_string(), - turn_id: turn_id.clone(), - item_id, - call_id: None, - payload: direct_thread_raw_item_payload( - history_root, - item, - Some(turn_id.as_str()), - completed, - direct_tool_call_now_ms(), - ), - }, - ); + if let Some(entry_item) = + direct_thread_event_item(history_root, item) + { + append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::item_started(entry_item), + ); + } } } } @@ -3284,13 +3265,7 @@ impl CodexAppServerConnection { { append_direct_thread_event( &direct_thread_id, - DirectThreadRawEventDraft { - event_type: "turn.completed".to_string(), - turn_id: turn_id.clone(), - item_id: None, - call_id: None, - payload: serde_json::json!({ "status": status }), - }, + DirectThreadEvent::turn_completed(status.to_string()), ); } match status { @@ -3985,8 +3960,8 @@ async fn read_game_creator_codex_app_server_stdout( continue; } } - let event = if let Some(event_type) = direct_codex_resolution_event_type(method) { - CodexTurnEvent::Request { event_type, params } + let event = if let Some(kind) = direct_codex_resolution_event_type(method) { + CodexTurnEvent::Request { kind, params } } else if let Some(activity) = safe_activity { // Preparing notifications may carry private plan/reasoning text; // expose only the safe activity category. Other categories may @@ -4038,8 +4013,8 @@ async fn read_game_creator_codex_app_server_stdout( ), method if direct_codex_request_event_type(method).is_some() => { CodexTurnEvent::Request { - event_type: direct_codex_request_event_type(method) - .expect("request event type checked above"), + kind: direct_codex_request_event_type(method) + .expect("request kind checked above"), params, } } @@ -4550,15 +4525,10 @@ mod tests { "arguments": { "path": "game/index.html", "token": "secret" }, "result": { "content": "large output" } }); - assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1")); // 运行态事件必须自足:载荷是脱敏原始条目,前端不需要再按 itemId 取快照。 - let payload = direct_thread_raw_item_payload( - std::path::Path::new("."), - &item, - Some("turn-1"), - false, - 1000, - ); + let projected = direct_thread_event_item(std::path::Path::new("."), &item).expect("item"); + assert_eq!(projected.item_id(), "item-1"); + let payload = serde_json::to_value(&projected).expect("payload"); assert_eq!( payload.get("itemType").and_then(serde_json::Value::as_str), Some("mcpToolCall") @@ -4567,10 +4537,6 @@ mod tests { payload.get("itemId").and_then(serde_json::Value::as_str), Some("item-1") ); - assert_eq!( - payload.get("turnId").and_then(serde_json::Value::as_str), - Some("turn-1") - ); // 卡片标题 / 折叠摘要 / kind 属于前端投影:载荷里不得出现这些 UI 语义。 assert!(payload.get("toolCall").is_none(), "{payload}"); assert!(payload.get("title").is_none(), "{payload}"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index 20c668a11..a30816d82 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -4,13 +4,13 @@ //! 每个 subscriber 一个受保护的消费游标。它不理解前端 reducer,也不负责 JSONL //! 持久化;调用方必须在完成 item 持久化成功后再追加对应完成事件。 -use serde::{Deserialize, Serialize}; -use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::sync::{Mutex, OnceLock}; use uuid::Uuid; -use crate::agent::DirectThreadRawItem; +use crate::agent::{ + DirectThreadConsumeResult, DirectThreadEvent, DirectThreadSubscriptionBootstrap, +}; const DEFAULT_MAX_EVENTS: usize = 8_192; const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; @@ -21,65 +21,11 @@ pub(crate) const DIRECT_THREAD_NOTIFY_EVENT: &str = "game-creator-direct-thread- static DIRECT_THREAD_MANAGER: OnceLock> = OnceLock::new(); static DIRECT_THREAD_MANAGER_APP_HANDLE: OnceLock = OnceLock::new(); -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectThreadRawEvent { - pub(crate) seq: u64, - #[serde(rename = "type")] - pub(crate) event_type: String, - pub(crate) turn_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) item_id: Option, - /// 合并身份的另一半:工具类条目的 `itemId` 是调用 id,原始 item 的 `id` 是 response item id, - /// 两者只在 `callId` 上对齐。前端按 `callId ?? itemId` 归并同一张卡片。 - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) call_id: Option, - pub(crate) payload: Value, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct DirectThreadRawEventDraft { - pub(crate) event_type: String, - pub(crate) turn_id: String, - pub(crate) item_id: Option, - pub(crate) call_id: Option, - pub(crate) payload: Value, -} - -impl DirectThreadRawEvent { - /// 条目合并身份:有 `callId` 就用它,否则用 `itemId`。 - fn item_identity(&self) -> Option<&str> { - self.call_id.as_deref().or(self.item_id.as_deref()) - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectThreadSubscriptionBootstrap { - pub(crate) subscription_id: String, - pub(crate) last_completed_item_id: Option, - pub(crate) events: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectThreadConsumeResult { - pub(crate) events: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectThreadHistorySlice { - /// 脱敏原始条目,顺序即文件顺序。与运行态事件载荷同形,前端只有一套投影。 - pub(crate) items: Vec, - pub(crate) has_more: bool, - /// 本次切片的原始条目锚点:无论切片里有没有可显示条目,分页都要靠它继续向前。 - pub(crate) first_item_id: Option, -} - +/// 队列里的一个事件。`seq` 只服务内部游标,不下发:前端按 `consume` 返回的数组顺序处理。 #[derive(Clone, Debug)] struct StoredEvent { - event: DirectThreadRawEvent, + event: DirectThreadEvent, + seq: u64, bytes: usize, cleanable: bool, } @@ -97,15 +43,14 @@ struct ThreadState { total_bytes: usize, active_items: HashSet, unresolved_requests: HashSet, - /// 最新一条 `turn.started` / `turn.completed` 的独立拷贝。 + /// 最近一条 `turn.started` / `turn.completed` 的独立拷贝。 /// /// TODO(thread-manager): 这里有意只保留"锚点",因为 replay 队列会回收可回收事件, /// 队列本身不是完美事件日志——被回收的 `turn.started` / `turn.completed` 不会回放, /// 只有这份拷贝保证新订阅仍能判定"最新回合是否还在跑"。若将来需要回放多个回合的 /// 生命周期(回合账本、跨进程恢复、按回合统计),必须另建持久 ledger, /// 不能靠扩大这份拷贝或放宽回收规则来模拟。 - lifecycle_anchor: Option, - last_completed_item_id: Option, + lifecycle_anchor: Option<(u64, DirectThreadEvent)>, subscribers: HashMap, } @@ -119,7 +64,6 @@ impl Default for ThreadState { active_items: HashSet::new(), unresolved_requests: HashSet::new(), lifecycle_anchor: None, - last_completed_item_id: None, subscribers: HashMap::new(), } } @@ -154,34 +98,29 @@ impl DirectThreadManager { pub(crate) fn append( &mut self, thread_id: &str, - draft: DirectThreadRawEventDraft, - ) -> DirectThreadRawEvent { + event: DirectThreadEvent, + ) -> DirectThreadEvent { let thread = self.threads.entry(thread_id.to_string()).or_default(); thread.next_seq = thread.next_seq.saturating_add(1); - let event = DirectThreadRawEvent { - seq: thread.next_seq, - event_type: draft.event_type, - turn_id: draft.turn_id, - item_id: draft.item_id, - call_id: draft.call_id, - payload: draft.payload, - }; - let cleanable = Self::observe_event(thread, &event); + let seq = thread.next_seq; + let cleanable = Self::observe_event(thread, seq, &event); let bytes = serde_json::to_vec(&event) .map(|value| value.len()) .unwrap_or_default(); thread.total_bytes = thread.total_bytes.saturating_add(bytes); thread.events.push(StoredEvent { event: event.clone(), + seq, bytes, cleanable, }); - Self::mark_item_events_cleanable(thread, event.item_identity()); - if matches!( - event.event_type.as_str(), - "approval.resolved" | "request.resolved" | "ask.resolved" - ) { - Self::mark_request_events_cleanable(thread, request_id(&event).as_deref()); + if let Some(item_id) = event.item_id() { + Self::mark_item_events_cleanable(thread, item_id, seq); + } + if let Some(kind) = event.request_kind() { + if kind.is_resolution() { + Self::mark_request_events_cleanable(thread, event.request_id(), seq); + } } self.evict(thread_id); event @@ -199,19 +138,20 @@ impl DirectThreadManager { .events .iter() .skip(thread.head) - .filter(|stored| Self::is_bootstrap_event(thread, &stored.event, stored.cleanable)) - .map(|stored| stored.event.clone()) + .filter(|stored| Self::is_bootstrap_event(thread, stored)) + .map(|stored| (stored.seq, stored.event.clone())) .collect::>(); - if let Some(anchor) = thread.lifecycle_anchor.as_ref() { - if !events.iter().any(|event| event.seq == anchor.seq) { - events.push(anchor.clone()); + // 生命周期锚点独立保存:即使队列里那条事件已被回收,也要作为 bootstrap 事件返回。 + if let Some((anchor_seq, anchor)) = thread.lifecycle_anchor.as_ref() { + if !events.iter().any(|(seq, _)| seq == anchor_seq) { + events.push((*anchor_seq, anchor.clone())); } } - events.sort_by_key(|event| event.seq); + events.sort_by_key(|(seq, _)| *seq); DirectThreadSubscriptionBootstrap { subscription_id, - last_completed_item_id: thread.last_completed_item_id.clone(), - events, + last_completed_item_id: None, + events: events.into_iter().map(|(_, event)| event).collect(), } } @@ -241,26 +181,28 @@ impl DirectThreadManager { let oldest_seq = thread .events .get(thread.head) - .map(|stored| stored.event.seq) + .map(|stored| stored.seq) .unwrap_or(thread.next_seq.saturating_add(1)); if cursor.saturating_add(1) < oldest_seq { thread.subscribers.remove(subscription_id); return Err(SUBSCRIPTION_EXPIRED.to_string()); } + let mut last_seq = cursor; let events = thread .events .iter() .skip(thread.head) - .filter(|stored| stored.event.seq > cursor) - .map(|stored| stored.event.clone()) + .filter(|stored| stored.seq > cursor) + .map(|stored| { + last_seq = stored.seq; + stored.event.clone() + }) .collect::>(); - if let Some(last) = events.last() { - thread - .subscribers - .get_mut(subscription_id) - .expect("subscriber remains registered") - .cursor = last.seq; - } + thread + .subscribers + .get_mut(subscription_id) + .expect("subscriber remains registered") + .cursor = last_seq; let result = DirectThreadConsumeResult { events }; Self::trim_prefix(thread); Ok(result) @@ -277,91 +219,76 @@ impl DirectThreadManager { }) } - fn observe_event(thread: &mut ThreadState, event: &DirectThreadRawEvent) -> bool { - match event.event_type.as_str() { - "item.started" => { - if let Some(identity) = event.item_identity() { - thread.active_items.insert(identity.to_string()); - } + /// 观察一条事件,返回它本身是否可回收。 + fn observe_event(thread: &mut ThreadState, seq: u64, event: &DirectThreadEvent) -> bool { + match event { + DirectThreadEvent::ItemStarted { item, .. } => { + thread.active_items.insert(item.item_id().to_string()); false } - "item.completed" => { - if let Some(identity) = event.item_identity() { - thread.active_items.remove(identity); - } - // 历史锚点必须是 project.jsonl 里的 response item id,不能用 call id。 - if let Some(item_id) = event.item_id.as_deref() { - thread.last_completed_item_id = Some(item_id.to_string()); - } + DirectThreadEvent::ItemCompleted { item, .. } => { + thread.active_items.remove(item.item_id()); true } - "turn.started" | "turn.completed" => { + DirectThreadEvent::TurnStarted { .. } | DirectThreadEvent::TurnCompleted { .. } => { // 队列只保留最新一条生命周期事件,更早的可能已被回收;见 // `ThreadState::lifecycle_anchor` 的 TODO:这不是完整事件日志。 - thread.lifecycle_anchor = Some(event.clone()); + thread.lifecycle_anchor = Some((seq, event.clone())); true } - "approval.requested" | "request.requested" | "ask.requested" => { - let request_id = request_id(event); + DirectThreadEvent::Request { + kind, request_id, .. + } => { + if kind.is_request() { + if let Some(request_id) = request_id.as_deref() { + thread.unresolved_requests.insert(request_id.to_string()); + } + // 没有 request id 的请求事件无法配对,直接视为可回收。 + return request_id.is_none(); + } if let Some(request_id) = request_id.as_deref() { - thread.unresolved_requests.insert(request_id.to_string()); - } - request_id.is_none() - } - "approval.resolved" | "request.resolved" | "ask.resolved" => { - if let Some(request_id) = request_id(event) { - thread.unresolved_requests.remove(&request_id); + thread.unresolved_requests.remove(request_id); } true } - _ => true, + DirectThreadEvent::ItemDelta { .. } => true, } } - fn is_bootstrap_event( - thread: &ThreadState, - event: &DirectThreadRawEvent, - cleanable: bool, - ) -> bool { - if thread - .lifecycle_anchor - .as_ref() - .is_some_and(|anchor| anchor.seq == event.seq) - { - return true; + /// 新订阅此刻需要补的事件:未完成 item 的完整事件、未解决请求、不可回收的事件。 + fn is_bootstrap_event(thread: &ThreadState, stored: &StoredEvent) -> bool { + if let Some(item_id) = stored.event.item_id() { + return thread.active_items.contains(item_id); } - if let Some(identity) = event.item_identity() { - return thread.active_items.contains(identity); + if let Some(request_id) = stored.event.request_id() { + return thread.unresolved_requests.contains(request_id); } - if let Some(request_id) = request_id(event) { - return thread.unresolved_requests.contains(&request_id); - } - !cleanable + // 生命周期锚点单独补,增量正文这类瞬时事件不回放。 + !stored.cleanable } - fn mark_item_events_cleanable(thread: &mut ThreadState, item_id: Option<&str>) { - let Some(item_id) = item_id else { - return; - }; + fn mark_item_events_cleanable(thread: &mut ThreadState, item_id: &str, seq: u64) { if thread.active_items.contains(item_id) { return; } for stored in &mut thread.events { - if stored.event.item_id.as_deref() == Some(item_id) { + if stored.seq <= seq && stored.event.item_id() == Some(item_id) { stored.cleanable = true; } } } - fn mark_request_events_cleanable(thread: &mut ThreadState, resolved_request_id: Option<&str>) { - let Some(resolved_request_id) = resolved_request_id else { + fn mark_request_events_cleanable(thread: &mut ThreadState, request_id: Option<&str>, seq: u64) { + let Some(resolved_request_id) = request_id else { return; }; for stored in &mut thread.events { - if matches!( - stored.event.event_type.as_str(), - "approval.requested" | "request.requested" | "ask.requested" - ) && request_id(&stored.event).as_deref() == Some(resolved_request_id) + if stored.seq <= seq + && stored + .event + .request_kind() + .is_some_and(|kind| kind.is_request()) + && stored.event.request_id() == Some(resolved_request_id) { stored.cleanable = true; } @@ -379,7 +306,7 @@ impl DirectThreadManager { let can_pop = thread .events .get(thread.head) - .is_some_and(|stored| stored.event.seq <= min_cursor && stored.cleanable); + .is_some_and(|stored| stored.seq <= min_cursor && stored.cleanable); if !can_pop { break; } @@ -419,7 +346,7 @@ impl DirectThreadManager { let oldest_seq = thread .events .get(thread.head) - .map(|stored| stored.event.seq) + .map(|stored| stored.seq) .unwrap_or(thread.next_seq.saturating_add(1)); let slowest = thread .subscribers @@ -448,13 +375,13 @@ pub(crate) fn set_direct_thread_manager_app_handle(app: tauri::AppHandle) { pub(crate) fn append_direct_thread_event( thread_id: &str, - draft: DirectThreadRawEventDraft, -) -> DirectThreadRawEvent { + event: DirectThreadEvent, +) -> DirectThreadEvent { let (event, subscriber_ids) = { let mut manager = global_direct_thread_manager() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - let event = manager.append(thread_id, draft); + let event = manager.append(thread_id, event); let subscriber_ids = manager.subscriber_ids(thread_id); (event, subscriber_ids) }; @@ -486,84 +413,48 @@ pub(crate) fn consume_direct_thread( .consume(subscription_id) } -fn request_id(event: &DirectThreadRawEvent) -> Option { - event - .payload - .get("requestId") - .and_then(Value::as_str) - .or_else(|| event.payload.get("id").and_then(Value::as_str)) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - #[cfg(test)] mod tests { use super::*; + use crate::agent::{DirectThreadDeltaKind, DirectThreadItem, DirectThreadRequestKind}; - fn draft(event_type: &str, turn_id: &str, item_id: Option<&str>) -> DirectThreadRawEventDraft { - DirectThreadRawEventDraft { - event_type: event_type.to_string(), - turn_id: turn_id.to_string(), - item_id: item_id.map(str::to_string), - call_id: None, - payload: serde_json::json!({}), + fn message(item_id: &str) -> DirectThreadItem { + DirectThreadItem::Message { + item_id: item_id.to_string(), + role: "assistant".to_string(), + text: "内容".to_string(), + at: 0, } } - /// 工具条目的两个 id 空间必须靠 `callId` 对齐:app-server `item.started` 的 itemId 是调用 id, - /// 原始 item 的 `item.completed` 的 itemId 是 response item id。 - fn draft_with_call_id( - event_type: &str, - turn_id: &str, - item_id: Option<&str>, - call_id: Option<&str>, - ) -> DirectThreadRawEventDraft { - DirectThreadRawEventDraft { - event_type: event_type.to_string(), - turn_id: turn_id.to_string(), - item_id: item_id.map(str::to_string), - call_id: call_id.map(str::to_string), - payload: serde_json::json!({}), - } + fn item_started(item_id: &str) -> DirectThreadEvent { + DirectThreadEvent::item_started(message(item_id)) } - #[test] - fn call_id_joins_started_and_completed_across_id_spaces() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append( - "thread-1", - draft_with_call_id("item.started", "turn-1", Some("call-1"), None), - ); - manager.append( - "thread-1", - draft_with_call_id("item.completed", "turn-1", Some("item-9"), Some("call-1")), - ); - // 活跃条目按合并身份清理,不留下永远收不到完成事件的幽灵条目。 - let bootstrap = manager.subscribe("thread-1"); - assert!( - bootstrap - .events - .iter() - .all(|event| event.event_type != "item.started"), - "已完成的条目不得再作为运行态事件回到 bootstrap" - ); - // 没有订阅者时,可回收事件全部被回收;历史锚点靠独立字段保留,不占队列。 - assert_eq!( - manager.thread_debug("thread-1").map(|debug| debug.0), - Some(0) - ); - // 历史锚点仍然是 response item id,前端才能拿它当分页锚点。 - assert_eq!(bootstrap.last_completed_item_id.as_deref(), Some("item-9")); + fn item_completed(item_id: &str) -> DirectThreadEvent { + DirectThreadEvent::item_completed(message(item_id)) + } + + fn item_delta(item_id: &str) -> DirectThreadEvent { + DirectThreadEvent::item_delta( + item_id.to_string(), + DirectThreadDeltaKind::Message, + "增量".to_string(), + ) + } + + fn request(kind: DirectThreadRequestKind, request_id: Option<&str>) -> DirectThreadEvent { + DirectThreadEvent::request(kind, request_id.map(str::to_string)) } #[test] fn subscribers_have_independent_cursors_on_one_global_queue() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", draft("turn.started", "turn-1", None)); + manager.append("thread-1", DirectThreadEvent::turn_started()); let first = manager.subscribe("thread-1"); let second = manager.subscribe("thread-1"); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); + manager.append("thread-1", item_started("item-1")); + manager.append("thread-1", item_delta("item-1")); let first_batch = manager .consume(&first.subscription_id) @@ -583,52 +474,49 @@ mod tests { #[test] fn bootstrap_contains_lifecycle_anchor_and_unfinished_events_only() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", draft("turn.started", "turn-1", None)); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); - manager.append( - "thread-1", - draft("item.completed", "turn-1", Some("item-1")), - ); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-2"))); + manager.append("thread-1", DirectThreadEvent::turn_started()); + manager.append("thread-1", item_started("item-1")); + manager.append("thread-1", item_delta("item-1")); + manager.append("thread-1", item_completed("item-1")); + manager.append("thread-1", item_started("item-2")); let bootstrap = manager.subscribe("thread-1"); - assert_eq!(bootstrap.last_completed_item_id.as_deref(), Some("item-1")); - assert_eq!( - bootstrap - .events - .iter() - .map(|event| event.event_type.as_str()) - .collect::>(), - vec!["turn.started", "item.started"] - ); + assert!(matches!( + bootstrap.events.as_slice(), + [ + DirectThreadEvent::TurnStarted {}, + DirectThreadEvent::ItemStarted { item, .. }, + ] if item.item_id() == "item-2" + )); } #[test] fn completion_releases_item_events_only_after_the_completion_event_is_appended() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); + manager.append("thread-1", item_started("item-1")); + manager.append("thread-1", item_delta("item-1")); let bootstrap = manager.subscribe("thread-1"); - manager.append( - "thread-1", - draft("item.completed", "turn-1", Some("item-1")), - ); + manager.append("thread-1", item_completed("item-1")); let events = manager .consume(&bootstrap.subscription_id) .expect("consume completion") .events; - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_type, "item.completed"); + assert!(matches!( + events.as_slice(), + [DirectThreadEvent::ItemCompleted { item, .. }] if item.item_id() == "item-1" + )); } #[test] fn slow_subscriber_is_expired_when_queue_limit_is_reached() { let mut manager = DirectThreadManager::with_limits(2, 100_000); let subscription = manager.subscribe("thread-1"); - manager.append("thread-1", draft("approval.resolved", "turn-1", None)); - manager.append("thread-1", draft("approval.resolved", "turn-1", None)); - manager.append("thread-1", draft("approval.resolved", "turn-1", None)); + for _ in 0..3 { + manager.append( + "thread-1", + request(DirectThreadRequestKind::RequestResolved, None), + ); + } assert_eq!( manager.consume(&subscription.subscription_id), Err(SUBSCRIPTION_EXPIRED.to_string()) @@ -638,11 +526,11 @@ mod tests { #[test] fn current_subscriber_is_not_expired_by_pinned_queue_head() { let mut manager = DirectThreadManager::with_limits(2, 100_000); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-1"))); + manager.append("thread-1", item_started("item-1")); let subscription = manager.subscribe("thread-1"); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); + for _ in 0..3 { + manager.append("thread-1", item_delta("item-1")); + } assert_ne!( manager.consume(&subscription.subscription_id), Err(SUBSCRIPTION_EXPIRED.to_string()) @@ -654,25 +542,16 @@ mod tests { let mut manager = DirectThreadManager::with_limits(100, 100_000); manager.append( "thread-1", - DirectThreadRawEventDraft { - event_type: "approval.requested".to_string(), - turn_id: "turn-1".to_string(), - item_id: None, - call_id: None, - payload: serde_json::json!({"requestId": "request-1"}), - }, + request( + DirectThreadRequestKind::ApprovalRequested, + Some("request-1"), + ), ); let bootstrap = manager.subscribe("thread-1"); assert_eq!(bootstrap.events.len(), 1); manager.append( "thread-1", - DirectThreadRawEventDraft { - event_type: "approval.resolved".to_string(), - turn_id: "turn-1".to_string(), - item_id: None, - call_id: None, - payload: serde_json::json!({"requestId": "request-1"}), - }, + request(DirectThreadRequestKind::RequestResolved, Some("request-1")), ); assert_eq!( manager @@ -689,24 +568,15 @@ mod tests { let mut manager = DirectThreadManager::with_limits(100, 100_000); manager.append( "thread-1", - DirectThreadRawEventDraft { - event_type: "approval.requested".to_string(), - turn_id: "turn-1".to_string(), - item_id: None, - call_id: None, - payload: serde_json::json!({"requestId": "request-1"}), - }, + request( + DirectThreadRequestKind::ApprovalRequested, + Some("request-1"), + ), ); let subscription = manager.subscribe("thread-1"); manager.append( "thread-1", - DirectThreadRawEventDraft { - event_type: "approval.resolved".to_string(), - turn_id: "turn-1".to_string(), - item_id: None, - call_id: None, - payload: serde_json::json!({"requestId": "request-1"}), - }, + request(DirectThreadRequestKind::RequestResolved, Some("request-1")), ); manager .consume(&subscription.subscription_id) @@ -717,17 +587,25 @@ mod tests { #[test] fn turn_completed_anchor_survives_empty_queue_for_new_subscriber() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", draft("turn.completed", "turn-1", None)); + manager.append( + "thread-1", + DirectThreadEvent::turn_completed("completed".to_string()), + ); let bootstrap = manager.subscribe("thread-1"); - assert_eq!(bootstrap.events.len(), 1); - assert_eq!(bootstrap.events[0].event_type, "turn.completed"); + assert!(matches!( + bootstrap.events.as_slice(), + [DirectThreadEvent::TurnCompleted { status }] if status == "completed" + )); } #[test] fn queue_cleanup_only_removes_a_cleanable_prefix() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("approval.resolved", "turn-1", None)); + manager.append("thread-1", item_started("item-1")); + manager.append( + "thread-1", + request(DirectThreadRequestKind::RequestResolved, None), + ); let subscription = manager.subscribe("thread-1"); manager .consume(&subscription.subscription_id) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_raw_item.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_raw_item.rs deleted file mode 100644 index 2dc96f3fe..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_raw_item.rs +++ /dev/null @@ -1,581 +0,0 @@ -//! DirectProject 「原始条目」搬运层。 -//! -//! Thread Manager 只做搬运:这里把 Codex 原始 response item 与 app-server thread item -//! 收敛成同一形状的**脱敏原始条目**,只做三件事——挑字段、脱敏、截断。 -//! -//! 这里不生成工具卡片,不推导标题 / 折叠摘要 / 可见性 / 排序:`title`、`summary`、 -//! `kind` 和"哪些条目要显示"全部是前端投影的职责。运行态事件与历史切片共用本形状, -//! 前端因此只需要一套投影与一套合并规则。 - -use crate::agent::redact_secret_tokens; -use crate::agent::sanitize_error_context; -use crate::redact_absolute_path_tokens; -use serde::Serialize; -use serde_json::Value; -use std::path::Path; - -/// 正文(消息 / 思考)上限。 -const DIRECT_THREAD_RAW_TEXT_MAX_CHARS: usize = 8000; -/// 工具明细(命令 / 参数 / 输出)上限。 -const DIRECT_THREAD_RAW_DETAIL_MAX_CHARS: usize = 4000; -/// 单条变更路径上限。 -const DIRECT_THREAD_RAW_PATH_MAX_CHARS: usize = 300; - -/// 原始条目里的一条文件变更。 -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectThreadRawChange { - pub(crate) path: String, - /// 原始 kind:`add` | `update` | `delete`(缺失时为 `update`)。 - pub(crate) kind: String, -} - -/// 前端唯一消费的条目形状:脱敏、截断后的 Codex 原始条目。 -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectThreadRawItem { - /// `project.jsonl` 里的 response item id;app-server `item/started` 时是 app-server item id。 - pub(crate) item_id: String, - /// 工具条目的调用 id:两个 id 空间唯一对齐点(原始 item 的 `call_id`)。 - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) call_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) turn_id: Option, - /// Codex 原始 `type`:`message` / `reasoning` / `function_call` / `function_call_output` / - /// `commandExecution` / `fileChange` / `mcpToolCall` / `webSearch` / …(原样透传,不做映射)。 - pub(crate) item_type: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) role: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) text: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) arguments: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) output: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) command: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) tool: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub(crate) changes: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) item_status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) exit_code: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) success: Option, - /// 协议事实:来自 `item/completed` / `rawResponseItem/completed` 为 true。 - pub(crate) completed: bool, - /// 毫秒时间;条目自带时间与记录时间都拿不到时为 0。 - pub(crate) at: u64, -} - -fn bounded(value: &str, max_chars: usize) -> String { - if value.chars().count() <= max_chars { - return value.to_string(); - } - let mut truncated = value.chars().take(max_chars).collect::(); - truncated.push('…'); - truncated -} - -/// 原始值转文本:字符串原样,其它 JSON 值序列化。 -fn value_text(value: &Value) -> Option { - match value { - Value::Null => None, - Value::String(text) => (!text.trim().is_empty()).then(|| text.trim().to_string()), - other => serde_json::to_string_pretty(other).ok(), - } -} - -fn detail_text(root: &Path, value: &str) -> String { - bounded( - &sanitize_detail_text(root, value), - DIRECT_THREAD_RAW_DETAIL_MAX_CHARS, - ) -} - -fn item_text(root: &Path, item: &Value) -> Option { - let raw = item - .get("text") - .and_then(Value::as_str) - .map(str::to_string) - .or_else(|| { - for key in ["content", "summary"] { - let Some(parts) = item.get(key).and_then(Value::as_array) else { - continue; - }; - let joined = parts - .iter() - .filter_map(|part| part.get("text").and_then(Value::as_str)) - .collect::>() - .join(""); - if !joined.trim().is_empty() { - return Some(joined); - } - } - None - }) - .filter(|text| !text.trim().is_empty())?; - Some(bounded( - &sanitize_detail_text(root, &raw), - DIRECT_THREAD_RAW_TEXT_MAX_CHARS, - )) -} - -fn item_turn_id(item: &Value) -> Option { - let from_metadata = item - .get("internal_chat_message_metadata_passthrough") - .and_then(|meta| meta.get("turn_id").or_else(|| meta.get("turnId"))) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - if from_metadata.is_some() { - return from_metadata; - } - // 用户条目没有元数据,但 id 里带回合:`direct-codex::user`。 - let rest = item - .get("id") - .and_then(Value::as_str)? - .trim() - .strip_prefix("direct-codex:")?; - let turn = rest.rsplit_once(':')?.0.trim(); - (!turn.is_empty()).then(|| turn.to_string()) -} - -fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 { - let from_metadata = item - .get("internal_chat_message_metadata_passthrough") - .and_then(|meta| meta.get("create_time")) - .and_then(Value::as_f64) - .map(|seconds| (seconds * 1000.0).clamp(0.0, u64::MAX as f64) as u64) - .unwrap_or_default(); - if from_metadata > 0 { - return from_metadata; - } - for key in ["startedAtMs", "completedAtMs"] { - let value = item.get(key).and_then(Value::as_u64).unwrap_or_default(); - if value > 0 { - return value; - } - } - observed_at_ms -} - -fn item_identity(item: &Value) -> Option<(String, Option)> { - let call_id = item - .get("call_id") - .or_else(|| item.get("callId")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let id = item - .get("id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - match (id, call_id) { - (Some(id), call_id) => Some((id, call_id)), - (None, Some(call_id)) => Some((call_id.clone(), Some(call_id))), - (None, None) => None, - } -} - -fn item_changes(root: &Path, item: &Value) -> Vec { - item.get("changes") - .and_then(Value::as_array) - .map(|changes| { - changes - .iter() - .filter_map(|change| { - let path = change - .get("path") - .and_then(Value::as_str) - .map(str::trim) - .filter(|path| !path.is_empty())?; - Some(DirectThreadRawChange { - path: bounded( - &sanitize_detail_text(root, path), - DIRECT_THREAD_RAW_PATH_MAX_CHARS, - ), - kind: change - .get("kind") - .and_then(Value::as_str) - .unwrap_or("update") - .to_string(), - }) - }) - .collect::>() - }) - .unwrap_or_default() -} - -/// 把一条 Codex 条目搬运成脱敏原始条目;拿不到身份或类型时返回 `None`。 -/// -/// `observed_at_ms` 只作为条目自带时间缺失时的兜底(运行态用当前时间,历史用文件记录时间)。 -pub(crate) fn direct_thread_raw_item_from_value( - root: &Path, - item: &Value, - turn_id: Option<&str>, - completed: bool, - observed_at_ms: u64, -) -> Option { - if !item.is_object() { - return None; - } - let (item_id, call_id) = item_identity(item)?; - let item_type = item - .get("type") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty())? - .to_string(); - let turn_id = turn_id - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .or_else(|| item_turn_id(item)); - let at = item_at_ms(item, observed_at_ms); - - let text = match item_type.as_str() { - "message" | "agentMessage" | "userMessage" | "reasoning" => item_text(root, item), - _ => None, - }; - let arguments = item - .get("arguments") - .and_then(value_text) - .map(|value| detail_text(root, &value)); - let output = ["output", "aggregatedOutput", "result", "error"] - .iter() - .find_map(|key| item.get(key).and_then(value_text)) - .map(|value| detail_text(root, &value)); - let command = item - .get("command") - .and_then(Value::as_str) - .map(str::trim) - .filter(|command| !command.is_empty()) - .map(|command| detail_text(root, command)); - - Some(DirectThreadRawItem { - item_id, - call_id, - turn_id, - item_type, - role: item - .get("role") - .and_then(Value::as_str) - .map(str::trim) - .filter(|role| !role.is_empty()) - .map(str::to_string), - text, - name: item - .get("name") - .and_then(Value::as_str) - .map(str::trim) - .filter(|name| !name.is_empty()) - .map(str::to_string), - arguments, - output, - command, - tool: item - .get("tool") - .and_then(Value::as_str) - .map(str::trim) - .filter(|tool| !tool.is_empty()) - .map(|tool| sanitize_detail_text(root, tool)), - changes: item_changes(root, item), - item_status: item - .get("status") - .and_then(Value::as_str) - .map(str::trim) - .filter(|status| !status.is_empty()) - .map(str::to_string), - exit_code: item.get("exitCode").and_then(Value::as_i64), - success: item.get("success").and_then(Value::as_bool), - completed, - at, - }) -} - -/// 历史切片搬运:保持文件顺序,不做任何合并。 -/// -/// 同一调用的 `function_call` 与 `function_call_output` 是两条原始条目,合并属于前端。 -/// `timestamp_of` 是文件记录时间,仅在条目自带时间缺失时兜底。 -pub(crate) fn direct_thread_raw_items_from_history( - root: &Path, - items: &[Value], - timestamp_of: impl Fn(&Value) -> u64, -) -> Vec { - items - .iter() - .filter_map(|item| { - direct_thread_raw_item_from_value(root, item, None, true, timestamp_of(item)) - }) - .collect() -} - -/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。 -fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) { - let mut index = start; - let mut relative = String::new(); - while index < value.len() { - let character = value[index..].chars().next().unwrap_or_default(); - if matches!(character, '/' | '\\') { - if !relative.is_empty() { - relative.push('/'); - } - index += character.len_utf8(); - continue; - } - if character.is_whitespace() - || matches!( - character, - '\'' | '"' - | '`' - | ',' - | ';' - | '|' - | '&' - | '(' - | ')' - | '[' - | ']' - | '{' - | '}' - | '<' - | '>' - | ':' - ) - { - break; - } - relative.push(character); - index += character.len_utf8(); - } - while relative.ends_with('/') { - relative.pop(); - } - (index, relative) -} - -/// 把项目根目录前缀换成**项目相对路径**(`/game/src/x.ts` → `game/src/x.ts`)。 -/// -/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成 -/// ``,之后就再也认不出哪些路径在项目内了。 -/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。 -fn relativize_project_root_paths(root: &Path, value: &str) -> String { - let root_text = root.to_string_lossy(); - let root_text = root_text.trim_end_matches(['/', '\\']); - if root_text.is_empty() { - return value.to_string(); - } - let mut needles = [ - root_text.to_string(), - root_text.replace('\\', "/"), - root_text.replace('/', "\\"), - ] - .into_iter() - .map(|needle| needle.to_ascii_lowercase()) - .filter(|needle| !needle.is_empty()) - .collect::>(); - needles.sort(); - needles.dedup(); - let lower = value.to_ascii_lowercase(); - - let mut output = String::with_capacity(value.len()); - let mut cursor = 0usize; - while cursor < value.len() { - let mut hit: Option<(usize, usize)> = None; - for needle in &needles { - let mut search = cursor; - while let Some(relative) = lower[search..].find(needle.as_str()) { - let start = search + relative; - let end = start + needle.len(); - let left_is_boundary = start == 0 - || lower[..start].chars().next_back().is_some_and(|character| { - !character.is_alphanumeric() && character != '_' && character != '-' - }); - if left_is_boundary && value[end..].starts_with(['/', '\\']) { - if hit.is_none_or(|(best_start, _)| start < best_start) { - hit = Some((start, end)); - } - break; - } - search = end; - } - } - let Some((start, end)) = hit else { - break; - }; - output.push_str(&value[cursor..start]); - let (consumed, relative) = project_relative_path_segment(value, end); - if relative.is_empty() { - // 只写了项目根目录本身(没有后续路径段):按占位形状处理。 - output.push_str(""); - } else { - output.push_str(&relative); - } - cursor = consumed; - } - output.push_str(&value[cursor..]); - output -} - -/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与 -/// 错误上下文脱敏。 -/// -/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录 -/// 仍然会留下;这里先归一化路径 token,再处理密钥。 -pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String { - let without_project_root = relativize_project_root_paths(root, value); - let without_absolute = redact_absolute_path_tokens(&without_project_root); - let without_secret = redact_secret_tokens(&without_absolute); - sanitize_error_context(&without_secret) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use std::path::Path; - - fn root() -> &'static Path { - Path::new(".") - } - - #[test] - fn message_item_carries_role_text_and_turn() { - let item = direct_thread_raw_item_from_value( - root(), - &json!({ - "id": "direct-codex:turn-1:user", - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": "做一个拼图游戏"}], - }), - None, - true, - 0, - ) - .expect("user item"); - assert_eq!(item.item_type, "message"); - assert_eq!(item.role.as_deref(), Some("user")); - assert_eq!(item.text.as_deref(), Some("做一个拼图游戏")); - assert_eq!(item.turn_id.as_deref(), Some("turn-1")); - } - - #[test] - fn tool_item_keeps_call_id_and_raw_command() { - let item = direct_thread_raw_item_from_value( - root(), - &json!({ - "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call", - "name": "exec_command", - "arguments": "{\"cmd\": \"ls\"}", - "internal_chat_message_metadata_passthrough": {"turn_id": "turn-1"}, - }), - None, - true, - 1000, - ) - .expect("tool item"); - assert_eq!(item.item_type, "function_call"); - assert_eq!( - item.call_id.as_deref(), - Some("call_00_Gpd0s0Ytm9YgIbwbEXva1473") - ); - assert_eq!(item.name.as_deref(), Some("exec_command")); - assert_eq!(item.arguments.as_deref(), Some("{\"cmd\": \"ls\"}")); - assert_eq!(item.turn_id.as_deref(), Some("turn-1")); - } - - #[test] - fn reasoning_item_keeps_text_without_role() { - let item = direct_thread_raw_item_from_value( - root(), - &json!({ - "id": "reason-1", - "type": "reasoning", - "content": [{"type": "output_text", "text": "先看目录"}], - }), - Some("turn-1"), - true, - 0, - ) - .expect("reasoning item"); - assert_eq!(item.item_type, "reasoning"); - assert_eq!(item.role, None); - assert_eq!(item.text.as_deref(), Some("先看目录")); - } - - #[test] - fn secrets_and_absolute_paths_are_not_leaked() { - let item = direct_thread_raw_item_from_value( - root(), - &json!({ - "id": "msg-1", - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "key=sk-abcdefghijklmnop at /root/secret/x"}], - }), - Some("turn-1"), - true, - 0, - ) - .expect("assistant item"); - let text = item.text.unwrap_or_default(); - assert!( - !text.contains("sk-abcdefghijklmnop"), - "不得泄漏明文密钥:{text}" - ); - assert!(!text.contains("/root/secret"), "不得泄漏绝对路径:{text}"); - } - - #[test] - fn history_keeps_call_and_output_as_two_raw_items() { - let items = vec![ - json!({ - "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call", - "name": "exec_command", - "arguments": "{\"cmd\": \"ls\"}", - }), - json!({ - "id": "fco_01a06fa5-d636-7452-b337-a641c2e6bc76", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call_output", - "output": "assets\ngame\n", - }), - ]; - let projected = direct_thread_raw_items_from_history(root(), &items, |_| 0); - assert_eq!(projected.len(), 2, "搬运层不得替前端做合并"); - assert_eq!(projected[0].item_type, "function_call"); - assert_eq!(projected[1].item_type, "function_call_output"); - assert_eq!( - projected[0].call_id.as_deref(), - projected[1].call_id.as_deref() - ); - } - - #[test] - fn system_role_is_passed_through_for_frontend_to_filter() { - let item = direct_thread_raw_item_from_value( - root(), - &json!({"id": "sys-1", "type": "message", "role": "system", "content": [{"type": "input_text", "text": "x"}]}), - Some("turn-1"), - true, - 0, - ) - .expect("system item"); - assert_eq!(item.role.as_deref(), Some("system")); - assert_eq!(item.item_type, "message"); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs new file mode 100644 index 000000000..d9374a3a0 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs @@ -0,0 +1,864 @@ +//! DirectProject 聊天事件的线上模型与投影。 +//! +//! 前端消费的类型由 ts-rs 导出到 `src/features/project-workspace/generated/`, +//! 与 Rust 定义同源:加一个字段不会只改一边。 +//! +//! 本模块只做三件事:挑字段、脱敏、截断。工具卡片的 `kind`、标题、折叠摘要、可见性与 +//! 合并规则全部属于前端投影,这里一概不出现。 +//! +//! 条目身份在进队列前就归一成**一个** `itemId`:原始文件里工具条目带两个 id(app-server +//! 的调用 id 与 response item id,调用与输出共用前者),归一只在 Rust 边界做一次, +//! Thread Manager 与前端都只认这一个,不暴露第二个 id 概念。 +//! 历史分页锚点是另一回事,那是文件里的原始 item id,单独取。 + +use crate::agent::redact_secret_tokens; +use crate::agent::sanitize_error_context; +use crate::redact_absolute_path_tokens; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::Path; +use ts_rs::TS; + +/// 正文(消息 / 思考)上限。 +const DIRECT_THREAD_TEXT_MAX_CHARS: usize = 8_000; +/// 工具明细(命令 / 参数 / 输出)上限。 +const DIRECT_THREAD_DETAIL_MAX_CHARS: usize = 4_000; +/// 单条变更路径上限。 +const DIRECT_THREAD_PATH_MAX_CHARS: usize = 300; + +/// 一条文件变更。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectThreadFileChange { + pub(crate) path: String, + /// `add` | `update` | `delete` + pub(crate) kind: String, +} + +/// 聊天视图的输入条目:一条 Codex 原始条目的脱敏投影。 +/// +/// `itemType` 就是 Codex 的原始类型,逐字透传;前端按它决定投影成消息、思考还是工具卡片。 +/// 未识别的类型走 [`DirectThreadItem::Other`],Rust 不替前端决定它是否可见。 +/// +/// 条目上的 `at` 是只用于显示的毫秒时间戳:ts-rs 默认把 `u64` 映射成 `bigint`, +/// 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "itemType", rename_all_fields = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectThreadItem { + #[serde(rename = "message")] + Message { + /// 归一身份:全链路只有这一个 id。 + item_id: String, + /// 原始 role(`user` / `assistant` / `system` / …);显示与否由前端判断。 + role: String, + text: String, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "reasoning")] + Reasoning { + item_id: String, + text: String, + #[ts(as = "f64")] + at: u64, + }, + /// 原始 response item 的工具调用:参数在 `arguments`,输出在后续的 + /// [`DirectThreadItem::FunctionCallOutput`](两者共用归一身份)。 + #[serde(rename = "function_call")] + FunctionCall { + item_id: String, + name: String, + arguments: String, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "function_call_output")] + FunctionCallOutput { + item_id: String, + output: String, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "commandExecution")] + CommandExecution { + item_id: String, + command: String, + #[serde(default)] + output: Option, + /// app-server 原始状态:`inProgress` / `completed` / `failed` / `declined` / … + #[serde(default)] + status: Option, + #[serde(default)] + #[ts(as = "Option")] + exit_code: Option, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "fileChange")] + FileChange { + item_id: String, + changes: Vec, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "mcpToolCall")] + McpToolCall { + item_id: String, + tool: String, + arguments: String, + #[serde(default)] + output: Option, + #[serde(default)] + status: Option, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "webSearch")] + WebSearch { + item_id: String, + #[serde(default)] + query: Option, + #[serde(default)] + output: Option, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "contextCompaction")] + ContextCompaction { + item_id: String, + #[ts(as = "f64")] + at: u64, + }, + /// 未识别的 Codex item 类型:原样透传身份与类型,不投影正文。 + #[serde(rename = "other")] + Other { + item_id: String, + raw_type: String, + #[ts(as = "f64")] + at: u64, + }, +} + +impl DirectThreadItem { + /// 归一身份:Thread Manager 用它登记与释放未完成条目,前端用它合并同一张卡片。 + pub(crate) fn item_id(&self) -> &str { + match self { + Self::Message { item_id, .. } + | Self::Reasoning { item_id, .. } + | Self::FunctionCall { item_id, .. } + | Self::FunctionCallOutput { item_id, .. } + | Self::CommandExecution { item_id, .. } + | Self::FileChange { item_id, .. } + | Self::McpToolCall { item_id, .. } + | Self::WebSearch { item_id, .. } + | Self::ContextCompaction { item_id, .. } + | Self::Other { item_id, .. } => item_id, + } + } +} + +/// 增量正文属于哪类条目。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectThreadDeltaKind { + /// assistant 正文。 + Message, + /// 思考正文。 + Reasoning, +} + +/// 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectThreadRequestKind { + #[serde(rename = "approval.requested")] + ApprovalRequested, + #[serde(rename = "ask.requested")] + AskRequested, + #[serde(rename = "request.resolved")] + RequestResolved, +} + +impl DirectThreadRequestKind { + /// 未解决的请求要留在 bootstrap 里,直到出现对应的解决事件。 + pub(crate) fn is_request(&self) -> bool { + matches!(self, Self::ApprovalRequested | Self::AskRequested) + } + + pub(crate) fn is_resolution(&self) -> bool { + !self.is_request() + } +} + +/// Thread Manager 下发的运行态事件。 +/// +/// 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`: +/// 游标是 Thread Manager 的内部事实,不下发。 +/// +/// 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,"当前回合是否还在跑"由 +/// 生命周期事件在序列中的位置给出,`turn_id` 对前端没有任何额外信息。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectThreadEvent { + #[serde(rename = "turn.started")] + TurnStarted, + #[serde(rename = "turn.completed")] + TurnCompleted { status: String }, + #[serde(rename = "item.started")] + ItemStarted { item: DirectThreadItem }, + #[serde(rename = "item.completed")] + ItemCompleted { item: DirectThreadItem }, + #[serde(rename = "item.delta")] + ItemDelta { + item_id: String, + kind: DirectThreadDeltaKind, + delta: String, + }, + #[serde(rename = "request")] + Request { + kind: DirectThreadRequestKind, + #[serde(default)] + request_id: Option, + }, +} + +impl DirectThreadEvent { + pub(crate) fn turn_started() -> Self { + Self::TurnStarted + } + + pub(crate) fn turn_completed(status: String) -> Self { + Self::TurnCompleted { status } + } + + pub(crate) fn item_started(item: DirectThreadItem) -> Self { + Self::ItemStarted { item } + } + + pub(crate) fn item_completed(item: DirectThreadItem) -> Self { + Self::ItemCompleted { item } + } + + pub(crate) fn item_delta(item_id: String, kind: DirectThreadDeltaKind, delta: String) -> Self { + Self::ItemDelta { + item_id, + kind, + delta, + } + } + + pub(crate) fn request(kind: DirectThreadRequestKind, request_id: Option) -> Self { + Self::Request { kind, request_id } + } + + /// 事件关联的条目身份:只有 item 事件有。 + pub(crate) fn item_id(&self) -> Option<&str> { + match self { + Self::ItemStarted { item, .. } | Self::ItemCompleted { item, .. } => { + Some(item.item_id()) + } + _ => None, + } + } + + pub(crate) fn request_id(&self) -> Option<&str> { + match self { + Self::Request { request_id, .. } => request_id.as_deref(), + _ => None, + } + } + + pub(crate) fn request_kind(&self) -> Option { + match self { + Self::Request { kind, .. } => Some(*kind), + _ => None, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectThreadSubscriptionBootstrap { + pub(crate) subscription_id: String, + /// 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。 + #[serde(default)] + pub(crate) last_completed_item_id: Option, + /// 该 subscriber 此刻应当处理的运行态事件(游标已经在队尾)。 + pub(crate) events: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectThreadConsumeResult { + pub(crate) events: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectThreadHistorySlice { + /// 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。 + pub(crate) items: Vec, + pub(crate) has_more: bool, + /// 本次切片的原始 item id 锚点:无论切片里有没有可显示条目,分页都靠它向前。 + #[serde(default)] + pub(crate) first_item_id: Option, +} + +fn bounded(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_string(); + } + let mut truncated = value.chars().take(max_chars).collect::(); + truncated.push('…'); + truncated +} + +fn detail_text(root: &Path, value: &str) -> String { + bounded( + &sanitize_detail_text(root, value), + DIRECT_THREAD_DETAIL_MAX_CHARS, + ) +} + +/// 原始值转文本:字符串原样,其它 JSON 值序列化(调用方随后脱敏)。 +fn value_text(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(text) => (!text.trim().is_empty()).then(|| text.trim().to_string()), + other => serde_json::to_string_pretty(other).ok(), + } +} + +fn item_text(root: &Path, item: &Value) -> Option { + let raw = item + .get("text") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + for key in ["content", "summary"] { + let Some(parts) = item.get(key).and_then(Value::as_array) else { + continue; + }; + let joined = parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join(""); + if !joined.trim().is_empty() { + return Some(joined); + } + } + None + }) + .filter(|text| !text.trim().is_empty())?; + Some(bounded( + &sanitize_detail_text(root, &raw), + DIRECT_THREAD_TEXT_MAX_CHARS, + )) +} + +fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 { + let from_metadata = item + .get("internal_chat_message_metadata_passthrough") + .and_then(|meta| meta.get("create_time")) + .and_then(Value::as_f64) + .map(|seconds| (seconds * 1000.0).clamp(0.0, u64::MAX as f64) as u64) + .unwrap_or_default(); + if from_metadata > 0 { + return from_metadata; + } + for key in ["startedAtMs", "completedAtMs"] { + let value = item.get(key).and_then(Value::as_u64).unwrap_or_default(); + if value > 0 { + return value; + } + } + observed_at_ms +} + +/// 归一身份:工具条目用工具调用 id,其它条目用自己的 `id`;只产出这一个值。 +pub(crate) fn direct_thread_item_identity(item: &Value) -> Option { + let call_id = item + .get("call_id") + .or_else(|| item.get("callId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let id = item + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + call_id.or(id) +} + +fn item_changes(root: &Path, item: &Value) -> Vec { + item.get("changes") + .and_then(Value::as_array) + .map(|changes| { + changes + .iter() + .filter_map(|change| { + let path = change + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|path| !path.is_empty())?; + Some(DirectThreadFileChange { + path: bounded( + &sanitize_detail_text(root, path), + DIRECT_THREAD_PATH_MAX_CHARS, + ), + kind: change + .get("kind") + .and_then(Value::as_str) + .unwrap_or("update") + .to_string(), + }) + }) + .collect::>() + }) + .unwrap_or_default() +} + +fn field_text(root: &Path, item: &Value, key: &str) -> Option { + item.get(key) + .and_then(value_text) + .map(|value| detail_text(root, &value)) +} + +/// 把一条 Codex 原始条目投影成线上条目;拿不到身份或类型时返回 `None`。 +/// +/// `observed_at_ms` 只在条目自带时间缺失时兜底(运行态用当前时间,历史用文件记录时间)。 +pub(crate) fn direct_thread_item_from_value( + root: &Path, + item: &Value, + observed_at_ms: u64, +) -> Option { + if !item.is_object() { + return None; + } + let item_id = direct_thread_item_identity(item)?; + let item_type = item + .get("type") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let at = item_at_ms(item, observed_at_ms); + let text = item_text(root, item); + let role = item + .get("role") + .and_then(Value::as_str) + .map(str::trim) + .filter(|role| !role.is_empty()) + .map(str::to_string); + + Some(match item_type { + "message" | "agentMessage" | "userMessage" => DirectThreadItem::Message { + item_id, + role: role.unwrap_or_else(|| { + if item_type == "userMessage" { + "user".to_string() + } else { + "assistant".to_string() + } + }), + text: text?, + at, + }, + "reasoning" => DirectThreadItem::Reasoning { + item_id, + text: text?, + at, + }, + "function_call" => DirectThreadItem::FunctionCall { + item_id, + name: item + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + arguments: field_text(root, item, "arguments").unwrap_or_default(), + at, + }, + "function_call_output" => DirectThreadItem::FunctionCallOutput { + item_id, + output: field_text(root, item, "output").unwrap_or_default(), + at, + }, + "commandExecution" => DirectThreadItem::CommandExecution { + item_id, + command: item + .get("command") + .and_then(Value::as_str) + .map(str::trim) + .filter(|command| !command.is_empty()) + .map(|command| detail_text(root, command)) + .unwrap_or_default(), + output: ["aggregatedOutput", "output", "error"] + .iter() + .find_map(|key| field_text(root, item, key)), + status: item + .get("status") + .and_then(Value::as_str) + .map(str::to_string), + exit_code: item.get("exitCode").and_then(Value::as_i64), + at, + }, + "fileChange" => DirectThreadItem::FileChange { + item_id, + changes: item_changes(root, item), + at, + }, + "mcpToolCall" => DirectThreadItem::McpToolCall { + item_id, + tool: item + .get("tool") + .and_then(Value::as_str) + .map(str::trim) + .filter(|tool| !tool.is_empty()) + .map(|tool| sanitize_detail_text(root, tool)) + .unwrap_or_default(), + arguments: field_text(root, item, "arguments").unwrap_or_default(), + output: ["result", "error"] + .iter() + .find_map(|key| field_text(root, item, key)), + status: item + .get("status") + .and_then(Value::as_str) + .map(str::to_string), + at, + }, + "webSearch" => DirectThreadItem::WebSearch { + item_id, + query: field_text(root, item, "query").or_else(|| { + item.get("action") + .and_then(|action| action.get("query")) + .and_then(value_text) + .map(|query| detail_text(root, &query)) + }), + output: field_text(root, item, "output"), + at, + }, + "contextCompaction" => DirectThreadItem::ContextCompaction { item_id, at }, + other => DirectThreadItem::Other { + item_id, + raw_type: other.to_string(), + at, + }, + }) +} + +/// 历史切片投影:保持文件顺序,不做任何合并(同一调用的调用与输出是两条条目)。 +/// +/// `timestamp_of` 是文件记录时间,仅在条目自带时间缺失时兜底。 +pub(crate) fn direct_thread_items_from_history( + root: &Path, + items: &[Value], + timestamp_of: impl Fn(&Value) -> u64, +) -> Vec { + items + .iter() + .filter_map(|item| direct_thread_item_from_value(root, item, timestamp_of(item))) + .collect() +} + +/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。 +fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) { + let mut index = start; + let mut relative = String::new(); + while index < value.len() { + let character = value[index..].chars().next().unwrap_or_default(); + if matches!(character, '/' | '\\') { + if !relative.is_empty() { + relative.push('/'); + } + index += character.len_utf8(); + continue; + } + if character.is_whitespace() + || matches!( + character, + '\'' | '"' + | '`' + | ',' + | ';' + | '|' + | '&' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | '<' + | '>' + | ':' + ) + { + break; + } + relative.push(character); + index += character.len_utf8(); + } + while relative.ends_with('/') { + relative.pop(); + } + (index, relative) +} + +/// 把项目根目录前缀换成**项目相对路径**(`/game/src/x.ts` → `game/src/x.ts`)。 +/// +/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成 +/// ``,之后就再也认不出哪些路径在项目内了。 +/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。 +fn relativize_project_root_paths(root: &Path, value: &str) -> String { + let root_text = root.to_string_lossy(); + let root_text = root_text.trim_end_matches(['/', '\\']); + if root_text.is_empty() { + return value.to_string(); + } + let mut needles = [ + root_text.to_string(), + root_text.replace('\\', "/"), + root_text.replace('/', "\\"), + ] + .into_iter() + .map(|needle| needle.to_ascii_lowercase()) + .filter(|needle| !needle.is_empty()) + .collect::>(); + needles.sort(); + needles.dedup(); + let lower = value.to_ascii_lowercase(); + + let mut output = String::with_capacity(value.len()); + let mut cursor = 0usize; + while cursor < value.len() { + let mut hit: Option<(usize, usize)> = None; + for needle in &needles { + let mut search = cursor; + while let Some(relative) = lower[search..].find(needle.as_str()) { + let start = search + relative; + let end = start + needle.len(); + let left_is_boundary = start == 0 + || lower[..start].chars().next_back().is_some_and(|character| { + !character.is_alphanumeric() && character != '_' && character != '-' + }); + if left_is_boundary && value[end..].starts_with(['/', '\\']) { + if hit.is_none_or(|(best_start, _)| start < best_start) { + hit = Some((start, end)); + } + break; + } + search = end; + } + } + let Some((start, end)) = hit else { + break; + }; + output.push_str(&value[cursor..start]); + let (consumed, relative) = project_relative_path_segment(value, end); + if relative.is_empty() { + // 只写了项目根目录本身(没有后续路径段):按占位形状处理。 + output.push_str(""); + } else { + output.push_str(&relative); + } + cursor = consumed; + } + output.push_str(&value[cursor..]); + output +} + +/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与 +/// 错误上下文脱敏。 +/// +/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录 +/// 仍然会留下;这里先归一化路径 token,再处理密钥。 +pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String { + let without_project_root = relativize_project_root_paths(root, value); + let without_absolute = redact_absolute_path_tokens(&without_project_root); + let without_secret = redact_secret_tokens(&without_absolute); + sanitize_error_context(&without_secret) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::path::Path; + + fn root() -> &'static Path { + Path::new(".") + } + + #[test] + fn message_item_carries_role_text_and_turn() { + let item = direct_thread_item_from_value( + root(), + &json!({ + "id": "direct-codex:turn-1:user", + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "做一个拼图游戏"}], + }), + 0, + ) + .expect("user item"); + assert_eq!( + item, + DirectThreadItem::Message { + item_id: "direct-codex:turn-1:user".to_string(), + role: "user".to_string(), + text: "做一个拼图游戏".to_string(), + at: 0, + } + ); + } + + #[test] + fn app_server_agent_message_defaults_to_assistant_role() { + let item = direct_thread_item_from_value( + root(), + &json!({"id": "msg-1", "type": "agentMessage", "text": "已执行"}), + 1000, + ) + .expect("agent message"); + assert!(matches!( + item, + DirectThreadItem::Message { role, .. } if role == "assistant" + )); + } + + #[test] + fn tool_item_identity_is_normalized_to_one_id() { + let item = direct_thread_item_from_value( + root(), + &json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + "internal_chat_message_metadata_passthrough": {"turn_id": "turn-1"}, + }), + 1000, + ) + .expect("tool item"); + // 只有唯一身份:工具条目在文件里的另一个 id 不再对外暴露。 + assert_eq!(item.item_id(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); + assert!(matches!( + item, + DirectThreadItem::FunctionCall { name, arguments, .. } + if name == "exec_command" && arguments == "{\"cmd\": \"ls\"}" + )); + } + + #[test] + fn command_execution_keeps_raw_status_and_exit_code() { + let item = direct_thread_item_from_value( + root(), + &json!({ + "id": "call-1", + "type": "commandExecution", + "command": "ls", + "status": "failed", + "exitCode": 2, + "aggregatedOutput": "boom", + }), + 0, + ) + .expect("command item"); + assert!(matches!( + item, + DirectThreadItem::CommandExecution { + command, + output: Some(output), + status: Some(status), + exit_code: Some(2), + .. + } if command == "ls" && output == "boom" && status == "failed" + )); + } + + #[test] + fn secrets_and_absolute_paths_are_not_leaked() { + let item = direct_thread_item_from_value( + root(), + &json!({ + "id": "msg-1", + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "key=sk-abcdefghijklmnop at /root/secret/x"}], + }), + 0, + ) + .expect("assistant item"); + let DirectThreadItem::Message { text, .. } = item else { + panic!("message item"); + }; + assert!( + !text.contains("sk-abcdefghijklmnop"), + "不得泄漏明文密钥:{text}" + ); + assert!(!text.contains("/root/secret"), "不得泄漏绝对路径:{text}"); + } + + #[test] + fn history_keeps_call_and_output_as_two_items_with_one_identity() { + let items = vec![ + json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + }), + json!({ + "id": "fco_01a06fa5-d636-7452-b337-a641c2e6bc76", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call_output", + "output": "assets\ngame\n", + }), + ]; + let projected = direct_thread_items_from_history(root(), &items, |_| 0); + assert_eq!(projected.len(), 2, "搬运层不得替前端做合并"); + assert!(matches!( + projected[0], + DirectThreadItem::FunctionCall { .. } + )); + assert!(matches!( + projected[1], + DirectThreadItem::FunctionCallOutput { .. } + )); + // 调用与输出共享同一个归一身份,前端才能把它们并成一张卡片。 + assert_eq!(projected[0].item_id(), projected[1].item_id()); + assert_eq!(projected[0].item_id(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); + } + + #[test] + fn unknown_item_types_are_passed_through_without_body() { + let item = direct_thread_item_from_value( + root(), + &json!({"id": "plan-1", "type": "plan", "text": "内部计划"}), + 0, + ) + .expect("unknown item"); + assert!(matches!( + item, + // TODO(direct-thread): 未知类型目前只带类型与身份,前端投影会丢弃它。 + // 哪些类型要显示属于前端可见性决策,需要时改前端,不要在这里加白名单。 + DirectThreadItem::Other { ref raw_type, .. } if raw_type == "plan" + )); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs index 545b1fa11..f73471a56 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs @@ -8,7 +8,7 @@ //! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的 //! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。 -use super::direct_thread_raw_item::sanitize_detail_text; +use super::direct_thread_wire::sanitize_detail_text; use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file}; use crate::project::{enforce_project_permission_policy, project_append_lock_for}; use serde::{Deserialize, Serialize}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 8dbdc185b..696aff810 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5388,7 +5388,7 @@ pub(crate) async fn read_direct_project_history_slice( .and_then(|item| item.get("id")) .and_then(serde_json::Value::as_str) .map(str::to_string); - let items = direct_thread_raw_items_from_history(root, &items, |item| { + let items = direct_thread_items_from_history(root, &items, |item| { item.get("id") .and_then(serde_json::Value::as_str) .and_then(|id| item_timestamps.get(id).copied()) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts index 10c4e0f5a..eec5662e2 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts @@ -1,5 +1,4 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; export type DirectCodexUserContentPart = diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts index 4c9ea8ca3..aa1ee88dd 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts @@ -1,5 +1,9 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; -export type DirectCodexUserItem = DirectCodexUserMessageItem; +/** + * DirectProject 本轮 user input 的唯一结构化入口。 + */ +export type DirectCodexUserItem = { + type: 'message'; +} & DirectCodexUserMessageItem; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts new file mode 100644 index 000000000..97e0b0dae --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectCodexUserItem } from './DirectCodexUserItem'; + +export type DirectCodexUserMessageEnvelope = { item: DirectCodexUserItem }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts index f7447dc5d..a1101c8cb 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts @@ -1,11 +1,9 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; import type { DirectCodexUserRole } from './DirectCodexUserRole'; export type DirectCodexUserMessageItem = { - type: 'message'; role: DirectCodexUserRole; - content: DirectCodexUserContentPart[]; + content: Array; id: string; }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts index e1dc59c59..0c2540122 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts @@ -1,3 +1,3 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type DirectCodexUserRole = 'user'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts index 296c0d19a..b134fb7bc 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts @@ -1,13 +1,13 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type DirectCodexUserRuntimeRegionPart = { label: string; - runId?: string; - versionId?: string; - elementTag?: string; - elementRole?: string; - text?: string; - width?: number; - height?: number; - resourceIds: string[]; + runId: string | null; + versionId: string | null; + elementTag: string | null; + elementRole: string | null; + text: string | null; + width: number | null; + height: number | null; + resourceIds: Array; }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadConsumeResult.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadConsumeResult.ts new file mode 100644 index 000000000..ea7e6e112 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadConsumeResult.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectThreadEvent } from './DirectThreadEvent'; + +export type DirectThreadConsumeResult = { events: Array }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadDeltaKind.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadDeltaKind.ts new file mode 100644 index 000000000..215819e1a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadDeltaKind.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 增量正文属于哪类条目。 + */ +export type DirectThreadDeltaKind = 'message' | 'reasoning'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts new file mode 100644 index 000000000..a4b0d7688 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts @@ -0,0 +1,30 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectThreadDeltaKind } from './DirectThreadDeltaKind'; +import type { DirectThreadItem } from './DirectThreadItem'; +import type { DirectThreadRequestKind } from './DirectThreadRequestKind'; + +/** + * Thread Manager 下发的运行态事件。 + * + * 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`: + * 游标是 Thread Manager 的内部事实,不下发。 + * + * 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,"当前回合是否还在跑"由 + * 生命周期事件在序列中的位置给出,`turn_id` 对前端没有任何额外信息。 + */ +export type DirectThreadEvent = + | { type: 'turn.started' } + | { type: 'turn.completed'; status: string } + | { type: 'item.started'; item: DirectThreadItem } + | { type: 'item.completed'; item: DirectThreadItem } + | { + type: 'item.delta'; + itemId: string; + kind: DirectThreadDeltaKind; + delta: string; + } + | { + type: 'request'; + kind: DirectThreadRequestKind; + requestId: string | null; + }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadFileChange.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadFileChange.ts new file mode 100644 index 000000000..a510ccb68 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadFileChange.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 一条文件变更。 + */ +export type DirectThreadFileChange = { + path: string; + /** + * `add` | `update` | `delete` + */ + kind: string; +}; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadHistorySlice.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadHistorySlice.ts new file mode 100644 index 000000000..9ac30f139 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadHistorySlice.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectThreadItem } from './DirectThreadItem'; + +export type DirectThreadHistorySlice = { + /** + * 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。 + */ + items: Array; + hasMore: boolean; + /** + * 本次切片的原始 item id 锚点:无论切片里有没有可显示条目,分页都靠它向前。 + */ + firstItemId: string | null; +}; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadItem.ts new file mode 100644 index 000000000..f1257ddcc --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadItem.ts @@ -0,0 +1,76 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectThreadFileChange } from './DirectThreadFileChange'; + +/** + * 聊天视图的输入条目:一条 Codex 原始条目的脱敏投影。 + * + * `itemType` 就是 Codex 的原始类型,逐字透传;前端按它决定投影成消息、思考还是工具卡片。 + * 未识别的类型走 [`DirectThreadItem::Other`],Rust 不替前端决定它是否可见。 + * + * 条目上的 `at` 是只用于显示的毫秒时间戳:ts-rs 默认把 `u64` 映射成 `bigint`, + * 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。 + */ +export type DirectThreadItem = + | { + itemType: 'message'; + /** + * 归一身份:全链路只有这一个 id。 + */ + itemId: string; + /** + * 原始 role(`user` / `assistant` / `system` / …);显示与否由前端判断。 + */ + role: string; + text: string; + at: number; + } + | { itemType: 'reasoning'; itemId: string; text: string; at: number } + | { + itemType: 'function_call'; + itemId: string; + name: string; + arguments: string; + at: number; + } + | { + itemType: 'function_call_output'; + itemId: string; + output: string; + at: number; + } + | { + itemType: 'commandExecution'; + itemId: string; + command: string; + output: string | null; + /** + * app-server 原始状态:`inProgress` / `completed` / `failed` / `declined` / … + */ + status: string | null; + exitCode: number | null; + at: number; + } + | { + itemType: 'fileChange'; + itemId: string; + changes: Array; + at: number; + } + | { + itemType: 'mcpToolCall'; + itemId: string; + tool: string; + arguments: string; + output: string | null; + status: string | null; + at: number; + } + | { + itemType: 'webSearch'; + itemId: string; + query: string | null; + output: string | null; + at: number; + } + | { itemType: 'contextCompaction'; itemId: string; at: number } + | { itemType: 'other'; itemId: string; rawType: string; at: number }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts new file mode 100644 index 000000000..13ee401f3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。 + */ +export type DirectThreadRequestKind = + | 'approval.requested' + | 'ask.requested' + | 'request.resolved'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadSubscriptionBootstrap.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadSubscriptionBootstrap.ts new file mode 100644 index 000000000..45efbf4b4 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadSubscriptionBootstrap.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectThreadEvent } from './DirectThreadEvent'; + +export type DirectThreadSubscriptionBootstrap = { + subscriptionId: string; + /** + * 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。 + */ + lastCompletedItemId: string | null; + /** + * 该 subscriber 此刻应当处理的运行态事件(游标已经在队尾)。 + */ + events: Array; +}; -- 2.52.0 From fdc48fe72516badabf7b6d88dc9098363c3716c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 19:49:26 +0800 Subject: [PATCH 48/92] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E6=8A=95=E5=BD=B1=E6=8C=89=20ts-rs=20=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E6=94=B6=E5=8F=A3=EF=BC=8C=E5=88=A0=E6=8E=89?= =?UTF-8?q?=20callId=20=E4=B8=8E=E5=9B=9E=E5=90=88=E8=BA=AB=E4=BB=BD=20-?= =?UTF-8?q?=20directThreadItemProjection.ts=20=E7=9B=B4=E6=8E=A5=E6=B6=88?= =?UTF-8?q?=E8=B4=B9=E7=94=9F=E6=88=90=E7=9A=84=20DirectThreadItem?= =?UTF-8?q?=EF=BC=8C=E5=B7=A5=E5=85=B7=E5=8D=A1=E7=89=87=20kind=20/=20?= =?UTF-8?q?=E6=A0=87=E9=A2=98=20/=20=E6=91=98=E8=A6=81=20/=20=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E4=B8=8E=E5=8F=AF=E8=A7=81=E6=80=A7=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E9=83=BD=E5=9C=A8=E8=BF=99=E9=87=8C=20-=20directThreadChat.ts?= =?UTF-8?q?=20=E8=81=8A=E5=A4=A9=E6=9D=A1=E7=9B=AE=E5=8F=AA=E7=95=99?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=20itemId=EF=BC=9A=E5=8E=BB=E6=8E=89=20callId?= =?UTF-8?q?=20=E6=A6=82=E5=BF=B5=EF=BC=8C=E5=8E=BB=E6=8E=89=20deltaText=20?= =?UTF-8?q?=E7=BC=93=E5=86=B2=EF=BC=8C=E5=A2=9E=E9=87=8F=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E5=88=B0=E8=BF=90=E8=A1=8C=E6=80=81=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE=E6=AD=A3=E6=96=87=20-=20=E5=90=88=E5=B9=B6=E8=A7=84?= =?UTF-8?q?=E5=88=99=E6=94=B6=E6=88=90=E5=85=88=E5=88=B0=E8=80=85=E8=B5=A2?= =?UTF-8?q?=E3=80=81=E5=90=8E=E5=88=B0=E5=8F=AA=E8=A1=A5=E7=A9=BA=EF=BC=9A?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E5=8F=AA=E5=A2=9E=E4=B8=8D=E5=87=8F=E3=80=81?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=8A=B6=E6=80=81=E5=85=81=E8=AE=B8=E4=BB=8E?= =?UTF-8?q?=20running=20=E5=8D=87=E7=BA=A7=E5=88=B0=E7=BB=88=E6=80=81=20-?= =?UTF-8?q?=20=E5=9B=9E=E5=90=88=E7=BB=93=E6=9D=9F=E6=8A=8A=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=80=81=E6=9D=A1=E7=9B=AE=E5=B9=B6=E5=85=A5=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=B9=B6=E6=B8=85=E7=A9=BA=20live=EF=BC=8C=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE=E6=97=A2=E4=B8=8D=E6=B6=88=E5=A4=B1=E4=B9=9F=E4=B8=8D?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=20-=20directThreadEvents.ts=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E8=BD=AC=E5=8F=91=20ts-rs=20=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=EF=BC=8C=E5=8F=AA=E4=BF=9D=E7=95=99=E5=8E=86=E5=8F=B2=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE=E8=BD=AC=E6=B6=88=E6=81=AF=E7=9A=84=E8=BF=87=E6=B8=A1?= =?UTF-8?q?=E5=87=BD=E6=95=B0=20-=20ToolCallGroup=20=E4=B8=8E=20toolCallGr?= =?UTF-8?q?oupPresentation=20=E6=94=B9=E7=94=A8=E5=8E=BB=E6=8E=89=E5=9B=9E?= =?UTF-8?q?=E5=90=88=E8=BA=AB=E4=BB=BD=E7=9A=84=20DirectChatToolCard=20-?= =?UTF-8?q?=20generated/index.ts=20=E8=A1=A5=E4=B8=8A=20DirectThread*=20?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E5=AF=BC=E5=87=BA=20-=20=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E6=9C=AC=E4=BB=93=E5=BA=93=E5=85=B6=E5=AE=83=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E4=BB=8D=E5=9C=A8=E6=94=B9=E7=9A=84=20DirectCodexUser*=20?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E9=87=8D=E6=96=B0=E7=94=9F=E6=88=90=EF=BC=88?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E8=AF=AF=E6=94=B9=E4=BB=96=E4=BA=BA=E8=BF=9B?= =?UTF-8?q?=E8=A1=8C=E4=B8=AD=E7=9A=84=E5=A5=91=E7=BA=A6=EF=BC=89=20-=20?= =?UTF-8?q?=E4=B8=A4=E4=B8=AA=20vitest=20=E7=94=A8=E4=BE=8B=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=8C=89=E6=96=B0=20API=20=E9=87=8D=E5=86=99=EF=BC=8C?= =?UTF-8?q?11=20=E6=9D=A1=E7=94=A8=E4=BE=8B=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project-workspace/ToolCallGroup.tsx | 6 +- .../project-workspace/directThreadChat.ts | 280 +++++++------- .../project-workspace/directThreadEvents.ts | 72 ++-- .../directThreadItemProjection.ts | 365 +++++++++--------- .../generated/DirectCodexUserContentPart.ts | 3 +- .../generated/DirectCodexUserItem.ts | 10 +- .../DirectCodexUserMessageEnvelope.ts | 4 - .../generated/DirectCodexUserMessageItem.ts | 6 +- .../generated/DirectCodexUserRole.ts | 2 +- .../DirectCodexUserRuntimeRegionPart.ts | 18 +- .../project-workspace/generated/index.ts | 8 + .../toolCallGroupPresentation.ts | 22 +- .../tests/directThreadChat.test.ts | 200 +++++----- .../tests/directThreadEvents.test.ts | 6 +- 14 files changed, 475 insertions(+), 527 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx index d5e904f51..683e5c2c0 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx @@ -9,7 +9,7 @@ import { import { useEffect, useId, useState } from 'react'; import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent'; -import type { GameCreatorDirectToolCall } from '../../app/types'; +import type { DirectChatToolCard } from './directThreadChat'; import { formatToolCallDuration, formatTurnDuration, @@ -38,7 +38,7 @@ export function ToolCallGroup({ active = false, className, }: { - calls: GameCreatorDirectToolCall[]; + calls: DirectChatToolCard[]; /** 同一回合用户消息的 `updatedAt`;拿不到就传 0,只显示结束时间。 */ userSentAt?: number | null; /** @@ -169,7 +169,7 @@ function ToolCallRow({ call, active, }: { - call: GameCreatorDirectToolCall; + call: DirectChatToolCard; active: boolean; }) { const [expanded, setExpanded] = useState(false); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts index ab5de6c00..37adb0c7e 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts @@ -1,120 +1,133 @@ /** - * DirectProject 聊天 reducer:把运行态 raw event 与历史切片归并成同一份聊天条目。 + * DirectProject 聊天 reducer:把运行态事件与历史切片归并成同一份聊天条目。 * - * 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。这里不判断哪些条目 - * 要显示,也不决定顺序策略之外的任何东西:顺序 = 历史文件顺序 + 运行态独有条目。 + * 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。顺序 = 历史文件顺序 + + * 运行态独有条目。这里不做可见性判断(那是投影的事),也不认任何回合身份:DirectProject + * 同一时刻只有一个回合在跑,`turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔。 */ import type { GameCreatorDirectToolCall } from '../../app/types'; import type { - DirectThreadRawEvent, + DirectThreadConsumeResult, + DirectThreadEvent, + DirectThreadHistorySlice, + DirectThreadItem, DirectThreadSubscriptionBootstrap, } from './directThreadEvents'; -import { - type DirectThreadRawItem, - isDirectThreadRawItem, - projectDirectThreadRawItem, -} from './directThreadItemProjection'; +import { projectDirectThreadItem } from './directThreadItemProjection'; export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool'; -/** 与 Rust `DirectChatEntry` 同形:运行态事件与历史切片共用的唯一形状。 */ +/** 聊天卡片里的工具形状:持久化卡片去掉回合身份(Rust 侧已经不下发 turn id)。 */ +export type DirectChatToolCard = Omit; + +/** 聊天视图里的一条条目;运行态事件与历史切片共用的唯一形状。 */ export type DirectChatEntry = { itemId: string; - callId?: string | null; - turnId?: string | null; kind: DirectChatEntryKind; role?: 'user' | 'assistant' | null; text?: string | null; - toolCall?: GameCreatorDirectToolCall | null; + toolCall?: DirectChatToolCard | null; at?: number; }; export type DirectThreadChatState = { subscriptionId: string | null; - /** 首屏历史锚点:`subscribe` 给出的最后一个完成条目 id。 */ + /** 首屏历史锚点:`subscribe` 给出的最后一条完成条目 id。 */ lastCompletedItemId: string | null; - /** 活动回合:只有出现 `turn.started` 且没有对应 `turn.completed` 才有值。 */ - activeTurnId: string | null; + /** 最新回合是否还在跑;只由生命周期事件的先后决定。 */ + turnRunning: boolean; /** 历史切片条目,保持文件顺序。 */ history: DirectChatEntry[]; - /** 运行态条目,保持到达顺序。 */ + /** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */ live: DirectChatEntry[]; - /** 流式正文累计(itemId → text)。 */ - deltaText: Record; }; export function emptyDirectThreadChatState(): DirectThreadChatState { return { subscriptionId: null, lastCompletedItemId: null, - activeTurnId: null, + turnRunning: false, history: [], live: [], - deltaText: {}, }; } -/** 合并身份:工具条目的 app-server itemId 与原始 item 的 callId 是同一个值。 */ -export function directChatEntryIdentity(entry: DirectChatEntry): string { - return (entry.callId ?? '').trim() || entry.itemId; +function longerText( + left: string | null | undefined, + right: string | null | undefined, +): string | null { + const a = typeof left === 'string' ? left : ''; + const b = typeof right === 'string' ? right : ''; + // 正文只增不减:增量往同一段落追加,完成快照可能比累计更长(漏过几条 delta)。 + return b.length > a.length ? b : a; } -function mergeToolCall( - left: GameCreatorDirectToolCall, - right: GameCreatorDirectToolCall, -): GameCreatorDirectToolCall { +function mergeToolStatus( + left: DirectChatToolCard['status'] | null | undefined, + right: DirectChatToolCard['status'] | null | undefined, +): DirectChatToolCard['status'] { + // 只有终态才算数:先到的 `running` 允许被后到的完成 / 失败覆盖,反过来不行。 + if (left === 'running' || !left) return right ?? left ?? 'running'; + return left; +} + +function mergeToolCard( + left: DirectChatToolCard | null, + right: DirectChatToolCard | null, +): DirectChatToolCard | null { + if (!left) return right; + if (!right) return left; return { ...left, - ...right, - title: right.title?.trim() ? right.title : left.title, - summary: right.summary?.trim() ? right.summary : left.summary, - kind: right.kind && right.kind !== 'other' ? right.kind : left.kind, + kind: left.kind && left.kind !== 'other' ? left.kind : right.kind, + title: left.title?.trim() ? left.title : right.title, + summary: left.summary?.trim() ? left.summary : right.summary, + status: mergeToolStatus(left.status, right.status), detail: { - command: right.detail?.command ?? left.detail?.command, - output: right.detail?.output ?? left.detail?.output, - changes: right.detail?.changes?.length - ? right.detail.changes - : left.detail?.changes, + command: left.detail.command ?? right.detail.command, + output: left.detail.output ?? right.detail.output, + changes: left.detail.changes?.length + ? left.detail.changes + : right.detail.changes, }, - startedAt: Math.min( - left.startedAt > 0 ? left.startedAt : right.startedAt, - right.startedAt > 0 ? right.startedAt : left.startedAt, - ), + startedAt: left.startedAt > 0 ? left.startedAt : right.startedAt, + updatedAt: Math.max(left.updatedAt, right.updatedAt), }; } -/** 后到的快照只补空字段:结果快照不能抹掉先到的命令与标题。 */ +/** + * 先到的快照赢,后到的只补空字段。 + * + * 三个例外只有"后到信息一定更全"时才成立:正文取更长的一份、工具状态允许从 `running` + * 升级到终态、`updatedAt` 取较新的时间。其余字段一律先到先用,后到的空值不得抹掉它。 + */ export function mergeDirectChatEntry( existing: DirectChatEntry, incoming: DirectChatEntry, ): DirectChatEntry { - const merged: DirectChatEntry = { - ...existing, - ...incoming, + return { itemId: existing.itemId || incoming.itemId, kind: - incoming.kind === 'tool' || existing.kind === 'tool' + existing.kind === 'tool' || incoming.kind === 'tool' ? 'tool' - : incoming.kind, - text: incoming.text ?? existing.text, - role: incoming.role ?? existing.role, - turnId: incoming.turnId ?? existing.turnId, + : existing.kind, + role: existing.role ?? incoming.role ?? null, + text: longerText(existing.text, incoming.text), + toolCall: mergeToolCard( + existing.toolCall ?? null, + incoming.toolCall ?? null, + ), + at: existing.at || incoming.at, }; - const left = existing.toolCall ?? null; - const right = incoming.toolCall ?? null; - merged.toolCall = - left && right ? mergeToolCall(left, right) : (right ?? left); - return merged; } function upsertLiveEntry( state: DirectThreadChatState, entry: DirectChatEntry, ): DirectThreadChatState { - const identity = directChatEntryIdentity(entry); const index = state.live.findIndex( - (existing) => directChatEntryIdentity(existing) === identity, + (existing) => existing.itemId === entry.itemId, ); if (index < 0) { return { ...state, live: [...state.live, entry] }; @@ -128,49 +141,47 @@ function upsertLiveEntry( return { ...state, live }; } -function reduceDirectThreadEvent( +function appendLiveText( state: DirectThreadChatState, - event: DirectThreadRawEvent, + event: Extract, +): DirectThreadChatState { + const itemId = event.itemId.trim(); + if (!itemId || !event.delta) return state; + const reasoning = event.kind === 'reasoning'; + const existing = state.live.find((entry) => entry.itemId === itemId); + return upsertLiveEntry(state, { + itemId, + kind: reasoning ? 'reasoning' : 'message', + role: reasoning ? null : 'assistant', + text: `${existing?.text ?? ''}${event.delta}`, + }); +} + +export function reduceDirectThreadEvent( + state: DirectThreadChatState, + event: DirectThreadEvent, ): DirectThreadChatState { switch (event.type) { case 'turn.started': - return { ...state, activeTurnId: event.turnId }; + return { ...state, turnRunning: true }; case 'turn.completed': + // 回合结束:条目已经落盘,运行态并入历史后清空,避免同一条目渲染两次。 return { ...state, - activeTurnId: - state.activeTurnId === event.turnId ? null : state.activeTurnId, + turnRunning: false, + history: mergeHistoryEntries(state.history, state.live), + live: [], }; - case 'item.delta': { - const itemId = event.itemId?.trim(); - if (!itemId) return state; - const payload = event.payload as { delta?: unknown; kind?: unknown }; - const delta = typeof payload?.delta === 'string' ? payload.delta : ''; - if (!delta) return state; - const text = `${state.deltaText[itemId] ?? ''}${delta}`; - const reasoning = payload?.kind === 'reasoning'; - return upsertLiveEntry( - { ...state, deltaText: { ...state.deltaText, [itemId]: text } }, - { - itemId, - kind: reasoning ? 'reasoning' : 'message', - role: reasoning ? null : 'assistant', - turnId: event.turnId, - text, - }, - ); - } + case 'item.delta': + return appendLiveText(state, event); case 'item.started': case 'item.completed': { - const entry = isDirectThreadRawItem(event.payload) - ? projectDirectThreadRawItem(event.payload) - : null; - if (!entry) return state; - return upsertLiveEntry(state, { - ...entry, - turnId: entry.turnId ?? event.turnId, - }); + const entry = projectDirectThreadItem(event.item); + return entry ? upsertLiveEntry(state, entry) : state; } + case 'request': + // 审批 / 提问只影响面板交互,不并入聊天条目。 + return state; default: return state; } @@ -178,20 +189,23 @@ function reduceDirectThreadEvent( export function reduceDirectThreadEvents( state: DirectThreadChatState, - events: readonly DirectThreadRawEvent[], + events: readonly DirectThreadEvent[], ): DirectThreadChatState { return events.reduce(reduceDirectThreadEvent, state); } -/** bootstrap 是运行态的唯一权威:原子替换运行态,历史窗口保留。 */ +/** + * bootstrap 是运行态的唯一权威:游标已在队尾,返回的事件就是此刻要处理的事件。 + * + * 历史窗口保留:bootstrap 不重新回读历史切片,那是 `lastCompletedItemId` 的职责。 + */ export function resolveDirectThreadBootstrap( state: DirectThreadChatState, bootstrap: DirectThreadSubscriptionBootstrap, ): DirectThreadChatState { return reduceDirectThreadEvents( { - ...emptyDirectThreadChatState(), - history: state.history, + ...state, subscriptionId: bootstrap.subscriptionId, lastCompletedItemId: bootstrap.lastCompletedItemId ?? null, }, @@ -199,69 +213,59 @@ export function resolveDirectThreadBootstrap( ); } -/** 历史切片并入:重叠条目按身份去重,新切片在前、已有历史在后。 */ -export function mergeDirectHistoryEntries( +/** 事件顺序 = 游标顺序;调用方只需要把 `consume` 的结果喂进来。 */ +export function applyDirectThreadConsumeResult( state: DirectThreadChatState, - entries: readonly DirectChatEntry[], + result: DirectThreadConsumeResult, ): DirectThreadChatState { - const byIdentity = new Map(); - const order: string[] = []; - for (const entry of [...entries, ...state.history]) { - const identity = directChatEntryIdentity(entry); - const existing = byIdentity.get(identity); - if (existing) { - byIdentity.set(identity, mergeDirectChatEntry(existing, entry)); + return reduceDirectThreadEvents(state, result.events); +} + +/** 同一身份的条目合并,先到者在前:历史在前、运行态在后,运行态只补空。 */ +export function mergeHistoryEntries( + leading: readonly DirectChatEntry[], + trailing: readonly DirectChatEntry[], +): DirectChatEntry[] { + const byId = new Map(); + const entries: DirectChatEntry[] = []; + for (const entry of [...leading, ...trailing]) { + const index = byId.get(entry.itemId); + if (index === undefined) { + byId.set(entry.itemId, entries.length); + entries.push(entry); continue; } - byIdentity.set(identity, entry); - order.push(identity); + const existing = entries[index]; + if (existing) entries[index] = mergeDirectChatEntry(existing, entry); } - return { - ...state, - history: order - .map((identity) => byIdentity.get(identity)) - .filter((entry): entry is DirectChatEntry => Boolean(entry)), - }; + return entries; } /** * 历史切片并入:切片是脱敏原始条目,投影规则与运行态完全同一份。 * - * `items` 必须按文件顺序给出;同一调用的 `function_call` 与 `function_call_output` - * 在这里按身份合并成一张卡片,而不是在 Rust 侧合并。 + * 同一调用的调用与输出在这里按身份合并成一张卡片,而不是在 Rust 侧合并。 */ export function mergeDirectHistoryItems( state: DirectThreadChatState, - items: readonly DirectThreadRawItem[], + items: readonly DirectThreadItem[], ): DirectThreadChatState { - return mergeDirectHistoryEntries( - state, - items - .map((item) => projectDirectThreadRawItem(item)) - .filter((entry): entry is DirectChatEntry => Boolean(entry)), - ); + const entries = items + .map((item) => projectDirectThreadItem(item)) + .filter((entry): entry is DirectChatEntry => Boolean(entry)); + return { ...state, history: mergeHistoryEntries(entries, state.history) }; +} + +export function mergeDirectThreadHistorySlice( + state: DirectThreadChatState, + slice: DirectThreadHistorySlice, +): DirectThreadChatState { + return mergeDirectHistoryItems(state, slice.items); } /** 聊天投影输入:历史顺序 + 运行态覆盖;运行态独有条目排在最后。 */ export function selectDirectChatEntries( state: DirectThreadChatState, ): DirectChatEntry[] { - const liveByIdentity = new Map( - state.live.map((entry) => [directChatEntryIdentity(entry), entry]), - ); - const seen = new Set(); - const entries: DirectChatEntry[] = []; - for (const entry of state.history) { - const identity = directChatEntryIdentity(entry); - seen.add(identity); - const live = liveByIdentity.get(identity); - entries.push(live ? mergeDirectChatEntry(entry, live) : entry); - } - for (const entry of state.live) { - const identity = directChatEntryIdentity(entry); - if (seen.has(identity)) continue; - seen.add(identity); - entries.push(entry); - } - return entries; + return mergeHistoryEntries(state.history, state.live); } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts index 78b8af25d..e52462b56 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts @@ -1,62 +1,44 @@ +/** + * DirectProject 运行态事件的线上类型。 + * + * 类型由 Rust 侧 ts-rs 导出(改完 Rust 模型后跑 `cargo test export_bindings`),这里只做 + * 入口转发:前端不再自己抄一份形状,字段增删必须改 Rust。 + */ + import type { LocalConversationMessageRecord } from '../../app/types'; -import type { DirectThreadRawItem } from './directThreadItemProjection'; +import type { DirectThreadItem } from './generated'; -export type DirectThreadRawEvent = { - seq: number; - type: string; - turnId: string; - itemId?: string; - payload: Record; -}; - -export type DirectThreadSubscriptionBootstrap = { - subscriptionId: string; - lastCompletedItemId: string | null; - events: DirectThreadRawEvent[]; -}; - -export type DirectThreadConsumeResult = { - events: DirectThreadRawEvent[]; -}; - -export type DirectThreadHistorySlice = { - /** 脱敏原始条目,顺序即文件顺序;与运行态事件载荷同形。 */ - items: DirectThreadRawItem[]; - hasMore: boolean; - /** 本次切片的原始条目锚点:分页继续向前靠它,而不是靠"有没有可显示条目"。 */ - firstItemId: string | null; -}; +export type { + DirectThreadConsumeResult, + DirectThreadDeltaKind, + DirectThreadEvent, + DirectThreadFileChange, + DirectThreadHistorySlice, + DirectThreadItem, + DirectThreadRequestKind, + DirectThreadSubscriptionBootstrap, +} from './generated'; /** * 历史条目转聊天消息。 * - * 迁移期的过渡函数:只保留 `role ∈ {user, assistant}` 的文本条目,工具卡片与交替顺序 - * 由 `directThreadChat` 的 reducer 投影。App.tsx 切换到 reducer 后本函数删除。 + * 过渡函数:`App.tsx` 仍按 `LocalConversationMessageRecord` 渲染,切换成 reducer 之后删除。 + * 只保留 `role ∈ {user, assistant}` 且有正文的条目;工具卡片与交替顺序由 reducer 投影。 */ export function directThreadHistoryItemsToMessages( - items: readonly DirectThreadRawItem[], + items: readonly DirectThreadItem[], ): LocalConversationMessageRecord[] { return items.flatMap((item) => { - if ( - item.itemType !== 'message' && - item.itemType !== 'agentMessage' && - item.itemType !== 'userMessage' - ) { - return []; - } - const role = item.role; - if (role !== 'user' && role !== 'assistant') return []; - const messageRole = role as 'user' | 'assistant'; - const content = typeof item.text === 'string' ? item.text : ''; - if (!content) return []; - const messageId = item.itemId; + if (item.itemType !== 'message') return []; + if (item.role !== 'user' && item.role !== 'assistant') return []; + if (!item.text) return []; return [ { schemaVersion: 'agc-direct-project-context.v1', - role: messageRole, - content, + role: item.role, + content: item.text, agentId: null, - messageId, + messageId: item.itemId, updatedAt: item.at ?? 0, }, ]; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts index 5ff47d33c..f1982c80f 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts @@ -1,57 +1,22 @@ /** * DirectProject「原始条目 → 聊天条目」投影。 * - * Rust 只搬运脱敏后的 Codex 原始条目(消息 / 思考 / 工具调用),工具卡片的 - * `kind`、标题、折叠摘要、状态判定和可见性全部在这里完成。运行态事件与历史切片 - * 走同一个函数,因此实时与回读不可能出现两套口径。 + * 输入是 Rust 侧 ts-rs 导出的 `DirectThreadItem`(脱敏后的 Codex 原始条目),工具卡片的 + * `kind`、标题、折叠摘要、状态判定和可见性全部在这里完成。运行态事件与历史切片走同一个 + * 函数,因此实时与回读不可能出现两套口径。 */ import type { - GameCreatorDirectToolCall, GameCreatorDirectToolCallChange, + GameCreatorDirectToolCallDetail, GameCreatorDirectToolCallKind, GameCreatorDirectToolCallStatus, } from '../../app/types'; -import type { DirectChatEntry } from './directThreadChat'; +import type { DirectChatEntry, DirectChatToolCard } from './directThreadChat'; +import type { DirectThreadItem } from './directThreadEvents'; -/** 一个片段里的文件变更(与 Rust `DirectThreadRawChange` 同形)。 */ -export type DirectThreadRawChange = { - path: string; - kind: string; -}; - -/** Rust 搬运的脱敏原始条目:没有卡片语义,只有 Codex 原始字段。 */ -export type DirectThreadRawItem = { - itemId: string; - callId?: string | null; - turnId?: string | null; - itemType: string; - role?: string | null; - text?: string | null; - name?: string | null; - arguments?: string | null; - output?: string | null; - command?: string | null; - tool?: string | null; - changes?: DirectThreadRawChange[] | null; - itemStatus?: string | null; - exitCode?: number | null; - success?: boolean | null; - completed: boolean; - at?: number | null; -}; - -const MESSAGE_ITEM_TYPES = new Set(['message', 'agentMessage', 'userMessage']); -const REASONING_ITEM_TYPES = new Set(['reasoning']); -const TOOL_ITEM_TYPES = new Set([ - 'function_call', - 'function_call_output', - 'commandExecution', - 'fileChange', - 'mcpToolCall', - 'webSearch', - 'contextCompaction', -]); +/** 折叠态摘要上限,与卡片契约一致。 */ +const TOOL_SUMMARY_MAX_CHARS = 120; const FAILED_ITEM_STATUS = new Set([ 'failed', @@ -61,13 +26,6 @@ const FAILED_ITEM_STATUS = new Set([ 'aborted', ]); -/** 折叠态摘要上限,与卡片契约一致。 */ -const TOOL_SUMMARY_MAX_CHARS = 120; - -function text(value: unknown): string { - return typeof value === 'string' ? value : ''; -} - function firstLine(value: string): string { const [line = ''] = value.split('\n'); const trimmed = line.trim(); @@ -95,51 +53,17 @@ function toolKindFromFunctionName(name: string): GameCreatorDirectToolCallKind { } } -function toolKind(raw: DirectThreadRawItem): GameCreatorDirectToolCallKind { - switch (raw.itemType) { - case 'function_call': - return toolKindFromFunctionName(text(raw.name)); - // 输出条目本身不携带工具语义:它只负责补上 `output`。 - case 'function_call_output': - return 'other'; - case 'commandExecution': - return 'command'; - case 'fileChange': - return 'file_change'; - case 'mcpToolCall': - return 'mcp_tool'; - case 'webSearch': - return 'web_search'; - case 'contextCompaction': - return 'context_compaction'; - default: - return 'other'; - } -} - -function toolStatus(raw: DirectThreadRawItem): GameCreatorDirectToolCallStatus { - const itemStatus = text(raw.itemStatus); - if (itemStatus === 'completed') return 'completed'; - if (FAILED_ITEM_STATUS.has(itemStatus)) return 'failed'; +function toolStatus( + status: string | null, + exitCode: number | null, +): GameCreatorDirectToolCallStatus { + if (status === 'completed') return 'completed'; + if (status && FAILED_ITEM_STATUS.has(status)) return 'failed'; // Codex 的退出码约定:非 0 即失败;缺席时按「已完成」处理。 - if (typeof raw.exitCode === 'number') { - return raw.exitCode === 0 ? 'completed' : 'failed'; + if (typeof exitCode === 'number') { + return exitCode === 0 ? 'completed' : 'failed'; } - if (typeof raw.success === 'boolean') { - return raw.success ? 'completed' : 'failed'; - } - return raw.completed ? 'completed' : 'running'; -} - -function toolChanges( - raw: DirectThreadRawItem, -): GameCreatorDirectToolCallChange[] { - return (raw.changes ?? []) - .filter((change) => text(change?.path).trim().length > 0) - .map((change) => ({ - path: text(change.path), - kind: text(change.kind) || 'update', - })); + return status ? 'running' : 'completed'; } function toolTitle( @@ -162,120 +86,183 @@ function toolTitle( } } -/** - * 工具卡片投影。 - * - * `function_call_output` 只带输出,因此它的标题与摘要留空:后到的输出快照不允许 - * 抹掉先到的命令与标题(合并规则见 `mergeDirectChatEntry`)。 - */ -function projectToolCard( - raw: DirectThreadRawItem, -): GameCreatorDirectToolCall | null { - const kind = toolKind(raw); - const changes = toolChanges(raw); - const command = text(raw.command) || text(raw.arguments); - const tool = text(raw.tool); - const output = text(raw.output); - const isOutputOnly = raw.itemType === 'function_call_output'; - const detail: GameCreatorDirectToolCall['detail'] = {}; - if (command && !isOutputOnly) detail.command = command; - if (output) detail.output = output; +function fileChanges( + item: Extract, +): GameCreatorDirectToolCallChange[] { + return item.changes + .filter((change) => change.path.trim().length > 0) + .map((change) => ({ + path: change.path, + kind: change.kind || 'update', + })); +} + +type ToolCardInput = { + itemId: string; + at: number; + kind: GameCreatorDirectToolCallKind; + /** 输出条目只带输出:标题与摘要留空,交给先到的调用快照。 */ + outputOnly?: boolean; + tool?: string; + command?: string; + output?: string; + status: GameCreatorDirectToolCallStatus; + changes?: GameCreatorDirectToolCallChange[]; +}; + +function buildToolCard(input: ToolCardInput): DirectChatToolCard | null { + const changes = input.changes ?? []; + const detail: GameCreatorDirectToolCallDetail = {}; + if (input.command && !input.outputOnly) detail.command = input.command; + if (input.output) detail.output = input.output; if (changes.length > 0) detail.changes = changes; + // 没有命令 / 输出 / 文件明细的条目不渲染成卡片:一张空卡片对用户没有信息量。 if (!detail.command && !detail.output && !detail.changes?.length) return null; const summarySource = - (kind === 'mcp_tool' ? tool : '') || + (input.kind === 'mcp_tool' ? (input.tool ?? '') : '') || detail.command || changes[0]?.path || - tool || + input.tool || ''; - const at = typeof raw.at === 'number' && raw.at > 0 ? raw.at : 0; return { schemaVersion: 'agc-tool-call.v1', - id: text(raw.callId) || raw.itemId, - turnId: text(raw.turnId), - kind, - title: isOutputOnly ? '' : toolTitle(kind, changes), - summary: isOutputOnly ? '' : firstLine(summarySource), - status: toolStatus(raw), + id: input.itemId, + kind: input.kind, + title: input.outputOnly ? '' : toolTitle(input.kind, changes), + summary: input.outputOnly ? '' : firstLine(summarySource), + status: input.status, detail, - startedAt: at, - updatedAt: at, + startedAt: input.at, + updatedAt: input.at, }; } +function toolCardFromItem(item: DirectThreadItem): DirectChatToolCard | null { + switch (item.itemType) { + case 'function_call': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: toolKindFromFunctionName(item.name), + tool: item.name, + command: item.arguments, + status: 'running', + }); + case 'function_call_output': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'other', + outputOnly: true, + output: item.output, + status: 'completed', + }); + case 'commandExecution': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'command', + command: item.command, + output: item.output ?? '', + status: toolStatus(item.status, item.exitCode), + }); + case 'fileChange': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'file_change', + changes: fileChanges(item), + status: 'completed', + }); + case 'mcpToolCall': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'mcp_tool', + tool: item.tool, + command: item.arguments, + output: item.output ?? '', + status: toolStatus(item.status, null), + }); + case 'webSearch': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'web_search', + command: item.query ?? '', + output: item.output ?? '', + status: 'completed', + }); + case 'contextCompaction': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'context_compaction', + command: '整理上下文', + status: 'completed', + }); + default: + return null; + } +} + /** * 原始条目投影成聊天条目;不属于聊天内容的条目返回 `null`。 * - * 可见性判定只在这里:系统 / 开发者 message、无正文的空条目、未识别的 item 类型 - * 都不进聊天视图。 + * 可见性判定只在这里:系统 / 开发者 message、无正文的空条目、未识别的 item 类型都不进 + * 聊天视图。`other` 是 Rust 原样透传的未知类型,要不要显示属于前端可见性决策,当前不显示。 */ -export function projectDirectThreadRawItem( - raw: DirectThreadRawItem | null | undefined, +export function projectDirectThreadItem( + item: DirectThreadItem | null | undefined, ): DirectChatEntry | null { - if (!raw) return null; - const itemId = text(raw.itemId).trim(); - const itemType = text(raw.itemType).trim(); - if (!itemId || !itemType) return null; + if (!item) return null; + const itemId = item.itemId.trim(); + if (!itemId) return null; - if (REASONING_ITEM_TYPES.has(itemType)) { - const body = text(raw.text); - if (!body.trim()) return null; - return { - itemId, - callId: raw.callId ?? null, - turnId: raw.turnId ?? null, - kind: 'reasoning', - role: null, - text: body, - toolCall: null, - at: raw.at ?? 0, - }; + switch (item.itemType) { + case 'message': { + const role = + item.role === 'user' + ? 'user' + : item.role === 'assistant' + ? 'assistant' + : null; + if (!role || !item.text.trim()) return null; + return { + itemId, + kind: 'message', + role, + text: item.text, + toolCall: null, + at: item.at, + }; + } + case 'reasoning': { + if (!item.text.trim()) return null; + return { + itemId, + kind: 'reasoning', + role: null, + text: item.text, + toolCall: null, + at: item.at, + }; + } + case 'other': + // TODO(direct-thread): 未识别类型目前不显示;要让它们出现只改这里,别回 Rust 加白名单。 + return null; + default: { + const toolCall = toolCardFromItem(item); + if (!toolCall) return null; + return { + itemId, + kind: 'tool', + role: null, + text: null, + toolCall, + at: item.at, + }; + } } - - if (MESSAGE_ITEM_TYPES.has(itemType)) { - const role = text(raw.role); - if (role !== 'user' && role !== 'assistant') return null; - const body = text(raw.text); - if (!body) return null; - return { - itemId, - callId: raw.callId ?? null, - turnId: raw.turnId ?? null, - kind: 'message', - role, - text: body, - toolCall: null, - at: raw.at ?? 0, - }; - } - - if (TOOL_ITEM_TYPES.has(itemType)) { - const toolCall = projectToolCard(raw); - if (!toolCall) return null; - return { - itemId, - callId: raw.callId ?? null, - turnId: raw.turnId ?? null, - kind: 'tool', - role: null, - text: null, - toolCall, - at: raw.at ?? 0, - }; - } - - return null; -} - -/** 事件 / 历史切片载荷是否像一条原始条目。 */ -export function isDirectThreadRawItem( - payload: unknown, -): payload is DirectThreadRawItem { - if (!payload || typeof payload !== 'object') return false; - const candidate = payload as Partial; - return ( - typeof candidate.itemId === 'string' && - typeof candidate.itemType === 'string' - ); } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts index eec5662e2..10c4e0f5a 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts @@ -1,4 +1,5 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; export type DirectCodexUserContentPart = diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts index aa1ee88dd..4c9ea8ca3 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts @@ -1,9 +1,5 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; -/** - * DirectProject 本轮 user input 的唯一结构化入口。 - */ -export type DirectCodexUserItem = { - type: 'message'; -} & DirectCodexUserMessageItem; +export type DirectCodexUserItem = DirectCodexUserMessageItem; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts deleted file mode 100644 index 97e0b0dae..000000000 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DirectCodexUserItem } from './DirectCodexUserItem'; - -export type DirectCodexUserMessageEnvelope = { item: DirectCodexUserItem }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts index a1101c8cb..f7447dc5d 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts @@ -1,9 +1,11 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; import type { DirectCodexUserRole } from './DirectCodexUserRole'; export type DirectCodexUserMessageItem = { + type: 'message'; role: DirectCodexUserRole; - content: Array; + content: DirectCodexUserContentPart[]; id: string; }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts index 0c2540122..e1dc59c59 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts @@ -1,3 +1,3 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type DirectCodexUserRole = 'user'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts index b134fb7bc..296c0d19a 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts @@ -1,13 +1,13 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type DirectCodexUserRuntimeRegionPart = { label: string; - runId: string | null; - versionId: string | null; - elementTag: string | null; - elementRole: string | null; - text: string | null; - width: number | null; - height: number | null; - resourceIds: Array; + runId?: string; + versionId?: string; + elementTag?: string; + elementRole?: string; + text?: string; + width?: number; + height?: number; + resourceIds: string[]; }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts index f1012a4c5..aeeebc8f8 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts @@ -3,3 +3,11 @@ export type { DirectCodexUserItem } from './DirectCodexUserItem'; export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; export type { DirectCodexUserRole } from './DirectCodexUserRole'; export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; +export type { DirectThreadConsumeResult } from './DirectThreadConsumeResult'; +export type { DirectThreadDeltaKind } from './DirectThreadDeltaKind'; +export type { DirectThreadEvent } from './DirectThreadEvent'; +export type { DirectThreadFileChange } from './DirectThreadFileChange'; +export type { DirectThreadHistorySlice } from './DirectThreadHistorySlice'; +export type { DirectThreadItem } from './DirectThreadItem'; +export type { DirectThreadRequestKind } from './DirectThreadRequestKind'; +export type { DirectThreadSubscriptionBootstrap } from './DirectThreadSubscriptionBootstrap'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts b/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts index a48a125bd..e41ca0354 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts @@ -1,7 +1,5 @@ -import type { - GameCreatorDirectToolCall, - GameCreatorDirectToolCallKind, -} from '../../app/types'; +import type { GameCreatorDirectToolCallKind } from '../../app/types'; +import type { DirectChatToolCard } from './directThreadChat'; /** * 工具调用折叠块的纯文案计算:汇总 / 行文案。 @@ -43,7 +41,7 @@ export const TOOL_CALL_ROW_VERBS: Partial< /** 汇总文案:按 kind 计数、固定顺序拼成 `已执行 5 个命令、2 个文件变更`;空集合返回空串。 */ export function toolCallGroupSummary( - calls: GameCreatorDirectToolCall[], + calls: DirectChatToolCard[], running = false, ) { const counts = new Map(); @@ -77,7 +75,7 @@ export function toolCallGroupSummary( } /** 一行工具的文案:只用工具本身的摘要(不带"已运行"这类动词前缀),状态由行尾状态列表达。 */ -export function toolCallRowText(call: GameCreatorDirectToolCall) { +export function toolCallRowText(call: DirectChatToolCard) { // 行首不再写"已运行/已编辑"这类动词前缀:命令状态由行尾的状态列表达 // (执行中 / 已执行 / 失败),前缀会和它重复。 if (call.kind === 'context_compaction') { @@ -86,7 +84,7 @@ export function toolCallRowText(call: GameCreatorDirectToolCall) { return toolCallRowSummary(call); } -function toolCallRowSummary(call: GameCreatorDirectToolCall) { +function toolCallRowSummary(call: DirectChatToolCard) { if (call.kind === 'command') { // 历史摘要可能已被可执行文件路径占满;优先从完整、已脱敏的输入提取正文。 const script = windowsPowerShellCommandBody( @@ -114,7 +112,7 @@ function toolCallRowSummary(call: GameCreatorDirectToolCall) { } /** 仅格式化卡片输入,不修改执行参数、历史记录或工具输出。 */ -export function toolCallInputText(call: GameCreatorDirectToolCall) { +export function toolCallInputText(call: DirectChatToolCard) { const input = call.detail.command?.trim() ?? ''; return call.kind === 'command' ? (windowsPowerShellCommandBody(input) ?? input) @@ -179,7 +177,7 @@ function unwrapDisplayArgument(argument: string) { * 这两种情况不显示耗时,不显示 `0s` / 负数。 */ export function toolCallDurationMs( - call: Pick, + call: Pick, ): number | null { const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0; const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0; @@ -212,7 +210,7 @@ export function formatToolCallDuration(ms: number | null | undefined) { /** 一回合总用时:该回合所有工具的 `min(startedAt)` → `max(updatedAt)`;取不到返回 `null`。 */ export function turnToolCallDurationMs( - calls: Array>, + calls: Array>, ): number | null { let minStartedAt = Number.POSITIVE_INFINITY; let maxUpdatedAt = Number.NEGATIVE_INFINITY; @@ -253,7 +251,7 @@ export function formatTurnDuration(ms: number | null | undefined) { /** 该回合的结束时间:`max(updatedAt)`;取不到返回 0。 */ export function turnToolCallEndedAt( - calls: Array>, + calls: Array>, ) { let maxUpdatedAt = 0; for (const call of calls) { @@ -287,7 +285,7 @@ export function formatClockTime(timestamp: number | null | undefined) { * 能拿到同回合用户消息时间(`updatedAt > 0`)时显示「发送 → 结束」,取不到就只显示结束时间。 */ export function turnToolCallTimeLabel( - calls: Array>, + calls: Array>, userSentAt: number | null | undefined, ) { const endLabel = formatClockTime(turnToolCallEndedAt(calls)); diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index feeffe84c..e381805dd 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -1,95 +1,104 @@ import { describe, expect, it } from 'vitest'; import { - directChatEntryIdentity, emptyDirectThreadChatState, mergeDirectHistoryItems, reduceDirectThreadEvents, resolveDirectThreadBootstrap, selectDirectChatEntries, } from '../src/features/project-workspace/directThreadChat'; -import type { DirectThreadRawEvent } from '../src/features/project-workspace/directThreadEvents'; -import type { DirectThreadRawItem } from '../src/features/project-workspace/directThreadItemProjection'; +import type { DirectThreadEvent } from '../src/features/project-workspace/directThreadEvents'; +import type { DirectThreadItem } from '../src/features/project-workspace/directThreadItemProjection'; function event( - partial: Partial & - Pick, -): DirectThreadRawEvent { - return { seq: 1, payload: {}, ...partial }; + partial: Pick & Partial, +): DirectThreadEvent { + return partial as DirectThreadEvent; } -/** app-server `item/started`:工具真正开始执行,itemId 就是 call_id。 */ -function rawToolStarted( - overrides: Partial = {}, -): DirectThreadRawItem { +/** app-server `item/started`:工具真正开始执行,itemId 就是归一后的唯一身份。 */ +function toolStarted( + overrides: Partial< + Extract + > = {}, +): DirectThreadItem { return { - itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', - turnId: 'turn-1', itemType: 'function_call', + itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', name: 'exec_command', arguments: '{"cmd": "ls"}', - completed: false, at: 1000, ...overrides, }; } -/** 原始 response item 的 `function_call_output`:itemId 是另一个 id 空间,靠 callId 对齐。 */ -function rawToolOutput( - overrides: Partial = {}, -): DirectThreadRawItem { +/** 原始 response item 的输出条目:Rust 已经把它归一成同一个 itemId。 */ +function toolOutput(): DirectThreadItem { return { - itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76', - callId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', - turnId: 'turn-1', itemType: 'function_call_output', + itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', output: 'assets\ngame', - completed: true, at: 2000, + }; +} + +function messageItem( + overrides: Partial> = {}, +): DirectThreadItem { + return { + itemType: 'message', + itemId: 'msg-1', + role: 'assistant', + text: '你好', + at: 3000, ...overrides, }; } describe('DirectProject 聊天 reducer', () => { - it('只有 turn.started 且未完成的回合才是活动回合', () => { + it('生命周期事件只切换"是否还在跑",不带回合身份', () => { const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ - event({ type: 'turn.started', turnId: 'turn-1' }), + event({ type: 'turn.started' }), ]); - expect(started.activeTurnId).toBe('turn-1'); + expect(started.turnRunning).toBe(true); const completed = reduceDirectThreadEvents(started, [ - event({ type: 'turn.completed', turnId: 'turn-1' }), + event({ type: 'turn.completed', status: 'completed' }), ]); - expect(completed.activeTurnId).toBeNull(); + expect(completed.turnRunning).toBe(false); }); - it('bootstrap 原子替换运行态,没有生命周期事件就没有活动回合', () => { - const stale = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ - event({ type: 'turn.started', turnId: 'turn-old' }), - ]); - const bootstrapped = resolveDirectThreadBootstrap(stale, { - subscriptionId: 'sub-1', - lastCompletedItemId: 'msg-9', - events: [], - }); - expect(bootstrapped.activeTurnId).toBeNull(); + it('bootstrap 事件就是要处理的事件:未完成条目直接进运行态', () => { + const bootstrapped = resolveDirectThreadBootstrap( + emptyDirectThreadChatState(), + { + subscriptionId: 'sub-1', + lastCompletedItemId: 'msg-9', + events: [ + event({ type: 'turn.started' }), + event({ type: 'item.started', item: toolStarted() }), + ], + }, + ); expect(bootstrapped.subscriptionId).toBe('sub-1'); expect(bootstrapped.lastCompletedItemId).toBe('msg-9'); + expect(bootstrapped.turnRunning).toBe(true); + expect(selectDirectChatEntries(bootstrapped)).toHaveLength(1); }); - it('增量正文按条目累计,完成快照覆盖同一段', () => { + it('增量正文按条目累计,完成快照更长时覆盖同一段', () => { const streamed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ event({ type: 'item.delta', - turnId: 'turn-1', itemId: 'msg-1', - payload: { delta: '你', kind: 'message' }, + kind: 'message', + delta: '你', }), event({ type: 'item.delta', - turnId: 'turn-1', itemId: 'msg-1', - payload: { delta: '好', kind: 'message' }, + kind: 'message', + delta: '好', }), ]); expect(selectDirectChatEntries(streamed)).toHaveLength(1); @@ -98,17 +107,7 @@ describe('DirectProject 聊天 reducer', () => { const done = reduceDirectThreadEvents(streamed, [ event({ type: 'item.completed', - turnId: 'turn-1', - itemId: 'msg-1', - payload: { - itemId: 'msg-1', - turnId: 'turn-1', - itemType: 'message', - role: 'assistant', - text: '你好,我是陶泥儿。', - completed: true, - at: 3000, - } satisfies DirectThreadRawItem, + item: messageItem({ text: '你好,我是陶泥儿。' }), }), ]); const entries = selectDirectChatEntries(done); @@ -120,9 +119,9 @@ describe('DirectProject 聊天 reducer', () => { const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ event({ type: 'item.delta', - turnId: 'turn-1', itemId: 'reason-1', - payload: { delta: '先看目录', kind: 'reasoning' }, + kind: 'reasoning', + delta: '先看目录', }), ]); const entries = selectDirectChatEntries(state); @@ -132,26 +131,14 @@ describe('DirectProject 聊天 reducer', () => { expect(entries[0]?.text).toBe('先看目录'); }); - it('工具 started / output 跨 id 空间按 callId 归并成一张卡片', () => { + it('工具调用与输出共用唯一 itemId,归并成一张卡片', () => { const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ - event({ - type: 'item.started', - turnId: 'turn-1', - itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', - payload: rawToolStarted(), - }), - event({ - type: 'item.completed', - turnId: 'turn-1', - itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76', - payload: rawToolOutput(), - }), + event({ type: 'item.started', item: toolStarted() }), + event({ type: 'item.completed', item: toolOutput() }), ]); const entries = selectDirectChatEntries(state); expect(entries).toHaveLength(1); - expect(directChatEntryIdentity(entries[0]!)).toBe( - 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', - ); + expect(entries[0]?.itemId).toBe('call_00_Gpd0s0Ytm9YgIbwbEXva1473'); expect(entries[0]?.toolCall?.kind).toBe('command'); expect(entries[0]?.toolCall?.title).toBe('执行命令'); expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); @@ -159,18 +146,27 @@ describe('DirectProject 聊天 reducer', () => { expect(entries[0]?.toolCall?.status).toBe('completed'); }); + it('回合结束把运行态并入历史并清空,条目不会消失也不会重复', () => { + const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started' }), + event({ type: 'item.completed', item: messageItem() }), + event({ type: 'item.started', item: toolStarted() }), + ]); + expect(running.live).toHaveLength(2); + + const done = reduceDirectThreadEvents(running, [ + event({ type: 'turn.completed', status: 'completed' }), + ]); + expect(done.live).toHaveLength(0); + expect(done.history).toHaveLength(2); + expect(selectDirectChatEntries(done)).toHaveLength(2); + }); + it('历史切片搬运层不合并,合并发生在前端投影', () => { const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ - rawToolStarted(), - rawToolOutput(), - { - itemId: 'msg-user', - itemType: 'message', - role: 'user', - text: '做一个拼图游戏', - completed: true, - at: 0, - }, + toolStarted(), + toolOutput(), + messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }), ]); const entries = selectDirectChatEntries(state); expect(entries).toHaveLength(2); @@ -182,58 +178,36 @@ describe('DirectProject 聊天 reducer', () => { const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ event({ type: 'item.completed', - turnId: 'turn-1', - itemId: 'sys-1', - payload: { + item: messageItem({ itemId: 'sys-1', - itemType: 'message', role: 'system', text: '内部指令', - completed: true, - at: 0, - } satisfies DirectThreadRawItem, + }), }), event({ type: 'item.completed', - turnId: 'turn-1', - itemId: 'plan-1', - payload: { + item: { + itemType: 'other', itemId: 'plan-1', - itemType: 'plan', - completed: true, + rawType: 'plan', at: 0, - } satisfies DirectThreadRawItem, + } satisfies DirectThreadItem, }), ]); expect(selectDirectChatEntries(state)).toHaveLength(0); }); - it('历史条目与运行态按身份合并,重复条目只出现一次', () => { + it('历史条目与运行态按唯一 id 合并,重复条目只出现一次', () => { const historical = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ - rawToolStarted(), - { - itemId: 'msg-user', - itemType: 'message', - role: 'user', - text: '做一个拼图游戏', - completed: true, - at: 0, - }, + toolStarted(), + messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }), ]); const live = reduceDirectThreadEvents(historical, [ - event({ - type: 'item.completed', - turnId: 'turn-1', - itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76', - payload: rawToolOutput(), - }), + event({ type: 'item.completed', item: toolOutput() }), ]); const entries = selectDirectChatEntries(live); expect(entries).toHaveLength(2); expect(entries[0]?.toolCall?.status).toBe('completed'); expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); - expect(directChatEntryIdentity(entries[0]!)).toBe( - 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', - ); }); }); diff --git a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts index ccaebe645..6229b26b5 100644 --- a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts @@ -4,6 +4,7 @@ import { directThreadHistoryItemsToMessages, isDirectTurnInProgress, } from '../src/features/project-workspace/directThreadEvents'; +import type { DirectThreadItem } from '../src/features/project-workspace/directThreadItemProjection'; describe('Direct 回合状态与历史时间', () => { it('终态和空状态不恢复为活动回合', () => { @@ -27,7 +28,6 @@ describe('Direct 回合状态与历史时间', () => { itemType: 'message', role: 'user', text: '帮我修改游戏', - completed: true, at: 1_800_000_000_001, }, { @@ -35,9 +35,9 @@ describe('Direct 回合状态与历史时间', () => { itemType: 'message', role: 'assistant', text: '好的', - completed: true, + at: 0, }, - ]; + ] satisfies DirectThreadItem[]; expect(directThreadHistoryItemsToMessages(items)[0]?.updatedAt).toBe( 1_800_000_000_001, ); -- 2.52.0 From ed3369a494a1540ddc279ee2a68e357c14f71446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 19:54:05 +0800 Subject: [PATCH 49/92] =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=85=88=E8=A1=8C?= =?UTF-8?q?=EF=BC=9A=E6=8A=8A=20DirectProject=20=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E7=9C=9F=E7=9B=B8=E6=BA=90=E6=94=B6=E6=95=9B=E7=9A=84=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E6=94=B9=E5=88=B0=E5=BD=93=E5=89=8D=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主规范把事件模型写成 ts-rs 导出的 tagged enum,说明 seq 不下发、事件不带回合身份,并补上 ts 绑定生成与 f64 时间戳约定 - ADR 补上 tagged enum 绑定、turn id 删除、正文只增不减与回合回收、前端卡片去掉 turnId、未知类型前端丢弃 - 里程碑与实施计划同步:文件改名到 direct_thread_wire.rs,新增删回合身份与生成绑定两步,验证命令加上 export_bindings - 工具卡片技术方案在文首加修订段:数据来源层作废,表现层契约仍然有效,DirectRuntime 的 tool-calls.jsonl 写入保留 - 对话回合唯一投影里程碑改为 superseded,列出仍然有效与已作废的分界 --- ...irectProject对话历史单一事实源-2026-09-16.md | 18 ++++++--- ...划】DirectProject聊天真相源收敛-2026-09-16.md | 28 +++++++------- ...碑】DirectProject聊天真相源收敛-2026-09-16.md | 18 +++++---- .../【里程碑】对话回合唯一投影-2026-09-16.md | 37 ++++++++++--------- ...ectProject Codex原始历史与异常恢复-2026-09-04.md | 32 ++++++++++------ ...方案】GameAgent对话工具调用卡片-2026-09-14.md | 11 +++++- 6 files changed, 88 insertions(+), 56 deletions(-) diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index 210db6135..8e8a43ea8 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -15,11 +15,14 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - **聊天投影** 在读取与渲染时生成,不落盘、不成为第二事实源;DirectProject 聊天框停止读取 `turn-stream.jsonl` 与 `tool-calls.jsonl`,也不再提供供前端读取的命令。DirectRuntime 自己那套进度事件与文件写入属于运行时账本,本轮保留不动。 - 页面重进的运行态只由 `subscribe` 的 bootstrap 事件重建,删除活动回合快照接管路径。 - 可见性判断留在前端聊天投影:后端历史分页只按原始条目切片,前端自己跳过不可显示条目并推进锚点。 -- 运行态事件与历史切片使用同形条目信封(`turnId` / `itemId` / `seq` / `payload`),Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「原始条目 → 视图」投影函数。 +- 线上模型是 **ts-rs 导出的 tagged enum**(`agent/direct_thread_wire.rs`),不是"一个大结构体加一堆可空字段":`DirectThreadItem` 用 `itemType` 区分条目,`DirectThreadEvent` 用 `type` 区分事件,前端直接消费生成的 TS 类型(改完 Rust 模型跑 `cargo test export_bindings`)。条目上的毫秒时间戳标 `#[ts(as = "f64")]`,因为 ts-rs 默认把 `u64` 映射成 `bigint`,而 Tauri 的 JSON 通道传的是 `number`。 +- 运行态事件与历史切片使用同形条目,Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「原始条目 → 视图」投影函数。 - 搬运层不生成展示形状:Thread Manager 只下发脱敏原始条目(`itemType` 原样透传),工具卡片的 `kind`、标题、折叠摘要都由前端生成。 -- 条目身份只有一套:`itemId = call_id ?? id`。工具 item 的 app-server id 就是 `call_id`,而 `project.jsonl` 落盘的是原始 response item(两个 id 不同),所以在进队列前归一,不向 Thread Manager 与前端暴露第二个 id 概念。 -- 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表。 -- 活动回合的唯一判据是「出现过 `turn.started` 且未出现对应 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 +- 条目身份只有一套:进队列前归一成一个 `itemId`。工具条目在 `project.jsonl` 里带两个 id(调用 id 与 response item id,调用与输出共用前者),归一只在 Rust 边界做一次,Thread Manager 与前端都不暴露第二个 id 概念。 +- 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;前端 state 里只有一个 `turnRunning` 布尔,没有 `turnId`。`subscribe` 返回的条目、增量、请求与队列锚点都不带 turn id。 +- 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表。只有"后到信息一定更全"时才例外:正文取更长的一份、工具状态允许从 `running` 升级到终态、`updatedAt` 取较新的时间。 +- 前端不保留增量缓冲:`item.delta` 直接追加到运行态条目的正文(正文只增不减)。`turn.completed` 把当前回合的运行态条目并入历史再清空,条目既不消失也不重复。 +- 活动回合的唯一判据是「出现过 `turn.started` 且未出现 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 - 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 - `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不额外补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。 - 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 @@ -27,8 +30,11 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 运行态事件必须自足:`item.started` / `item.completed` 携带与历史切片同形的完整**脱敏原始条目**,前端按归一后的 `itemId` 合并快照得到运行中与完成态;不提供按 `itemId` 单点取快照的接口。 - 思考正文以 `item.delta{kind:"reasoning"}` 流式下发(`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta`)。这不放宽可见范围:同一段文本本来就已落进 `project.jsonl` 并在 `item.completed` 展示;plan 文本与命令输出仍只降级为活动状态。 - 首屏历史由 `subscribe` 返回的 `lastCompletedItemId` 锚定,再取最近切片;删除返回整份对话的历史命令。 -- 生命周期锚点独立于 replay 队列保存,`subscribe` 必须返回最新的一条 `turn.started` / `turn.completed`,否则新订阅无法判定回合是否仍在运行。 -- 删除范围包含前端对 `read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns`、`read_direct_project_history`(整份历史)与 `game-creator-direct-turn-update` 事件的调用;保留分页用的历史切片读取,且该切片从文件尾反向扫描。 +- 生命周期锚点独立于 replay 队列保存(队列会回收 `cleanable` 事件,新订阅的游标又在队尾,回收后无法反推"最新回合是 started 还是 completed"),`subscribe` 必须返回最新的一条 `turn.started` / `turn.completed`,否则新订阅无法判定回合是否仍在运行。 +- 前端工具卡片形状是 `Omit`:聊天卡片不再有回合身份,`tool-calls.jsonl` 的持久化形状仍保留 `turnId`(DirectRuntime 的账本没动)。 +- 未识别 item 类型由 Rust 原样透传(只带类型与身份,Rust 侧留 TODO),当前由前端投影丢弃:哪些类型可见属于前端决策,不回 Rust 加白名单。 +- 删除范围包含前端对 `read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns`、`read_direct_project_history`(整份历史)与 `game-creator-direct-turn-update` 事件的调用;保留分页用的历史切片读取(`read_direct_project_history_slice`),且该切片从文件尾反向扫描。 +- 前端删掉 `directTurnStream` / `directToolCalls` / 活动回合快照接管 / 瞬时应答文本这些并行状态,聊天视图只由 reducer 状态投影(含工具卡片)。 - 失败与中止说明只在运行期显示,不写进 `project.jsonl`;页面重进后不再出现。 - 历史切片的 `firstItemId` 是分页锚点,始终取 `project.jsonl` 里的原始 item id,与归一后的条目身份分开计算。 - 审批与提问事件本次只作为同一条事件流 pass-through,不并入聊天 reducer 驱动的状态机,迁移面收敛在历史与运行态一致性上。 diff --git a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md index 1fc498481..37e4fbf57 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md @@ -8,28 +8,30 @@ ## 修改边界 -- 允许修改:`agent/direct_thread_manager.rs`、`agent/direct_thread_raw_item.rs`、`agent/codex_app_server/`、`agent/direct_project_history.rs`、`main.rs` 命令注册、AGC 前端订阅与聊天投影、对应测试与 `docs/`。 +- 允许修改:`agent/direct_thread_wire.rs`、`agent/direct_thread_manager.rs`、`agent/codex_app_server/`、`agent/direct_project_history.rs`、`main.rs` 命令注册、`src/features/project-workspace/generated/`(ts-rs 生成目录)、AGC 前端订阅与聊天投影、对应测试与 `docs/`。 - 明确不修改:SpacetimeDB schema 与绑定、HTTP/OpenAPI、DirectRuntime 自己的进度事件与 `turn-stream.jsonl` / `tool-calls.jsonl` 写入、Codex durable thread 行为、审批弹层现有状态来源。 - 保持 `.env` 未提交修改,不触碰个人配置。 ## 实现顺序 -1. Rust 只搬运:`agent/direct_thread_raw_item.rs` 把 Codex 原始条目挑字段、脱敏、截断后下发,事件载荷与历史切片同形,不生成卡片形状。 -2. 条目身份归一:`itemId = call_id ?? id`,同时从事件 envelope 与前端形状里删掉第二个 id 概念;历史切片的 `firstItemId` 继续取文件里的原始 item id。 -3. 思考正文流式:`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta` 产出 `item.delta{kind:"reasoning"}`;plan 文本与命令输出保持活动状态。 -4. 前端收敛为单一 reducer:`subscribe` 返回的 bootstrap 事件就是已暂存的运行态,游标已经在队尾,前端直接 reduce 这批事件即可(不需要再补一次 `consume`);此后只由 notify 唤醒 `consume`。合并规则只保留"先到定形、后到补空白",并删掉 `deltaText` 缓冲与回合结束后的运行态残留。 -5. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,历史读取改为从文件尾反向扫描;锚点按原始 item id 推进,切片无可见条目时自动连拉(上限 5 页)。 -6. App.tsx 接线:订阅 + 立即 consume + notify 唤醒,聊天视图改由 reducer 状态投影(含工具卡片),删除 Direct 回合事件订阅与 `directTurnStream` / `directToolCalls` 状态。 -7. 删除只服务旧读路径的命令与前端调用(`read_direct_project_history`、`read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns`),DirectRuntime 自己的写入保留。 -8. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态。 +1. Rust 只搬运:`agent/direct_thread_wire.rs` 把 Codex 原始条目挑字段、脱敏、截断后下发,事件载荷与历史切片同形,不生成卡片形状;线上模型是 ts-rs 导出的 tagged enum(`DirectThreadItem` / `DirectThreadEvent` / bootstrap / consume / history slice),`at` 标 `#[ts(as = "f64")]`,改完模型跑 `cargo test export_bindings` 生成前端绑定。 +2. 条目身份归一:进队列前收敛成一个 `itemId`,事件 envelope 与前端形状里都不出现第二个 id 概念;历史切片的 `firstItemId` 继续取文件里的原始 item id。 +3. 删掉回合身份:`turn.started` 无载荷、`turn.completed{status}`,条目 / 增量 / 请求 / 生命周期锚点都不带 turn id;队列 `append` 直接收 `DirectThreadEvent`,`seq` 内部自算。 +4. 思考正文流式:`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta` 产出 `item.delta{kind:"reasoning"}`;plan 文本与命令输出保持活动状态。 +5. 前端收敛为单一 reducer:`subscribe` 返回的 bootstrap 事件就是已暂存的运行态,游标已经在队尾,前端直接 reduce 这批事件即可(不需要再补一次 `consume`);此后只由 notify 唤醒 `consume`。合并规则只保留"先到定形、后到补空白"(正文只增不减、工具状态允许从 running 升级到终态),`item.delta` 直接追加到运行态条目正文,删掉 `deltaText` 缓冲,`turn.completed` 把运行态条目并入历史再清空。 +6. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,历史读取改为从文件尾反向扫描;锚点按原始 item id 推进,切片无可见条目时自动连拉(上限 5 页)。 +7. App.tsx 接线:订阅 + 立即 reduce bootstrap + notify 唤醒 consume,聊天视图改由 reducer 状态投影(含工具卡片),删除 Direct 回合事件订阅与 `directTurnStream` / `directToolCalls` 状态。 +8. 删除只服务旧读路径的命令与前端调用(`read_direct_project_history`、`read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns`),DirectRuntime 自己的写入保留。 +9. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态。 ## 验证命令 1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_thread -- --nocapture` -2. `npx vitest run apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directThreadEvents.test.ts` -3. `npx vitest run apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` -4. TypeScript 类型检查与 ESLint(范围同前次 DirectProject 迁移)。 -5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` +2. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`(生成 `src/features/project-workspace/generated/`) +3. `npx vitest run apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directThreadEvents.test.ts` +4. `npx vitest run apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` +5. TypeScript 类型检查与 ESLint(范围同前次 DirectProject 迁移)。 +6. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` ## 风险与回滚点 diff --git a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md index 8009c1e3f..ba4ed5368 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md @@ -11,15 +11,16 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话历史**(`.agent/conversations/project.jsonl`)与 **运行态事件**(Thread Manager `subscribe` / `consume` / `notify`)。Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl` 与活动回合快照都不再是聊天视图的输入。 -边界固定为:Thread Manager 只是**搬运层**——把 Codex 原始条目挑字段、脱敏、截断后下发;工具卡片的形状、可见性与合并全部由前端投影完成。条目身份只有一个:Rust 在进队列前归一到 `call_id ?? id`,不再暴露第二套 id 概念。 +边界固定为:Thread Manager 只是**搬运层**——把 Codex 原始条目挑字段、脱敏、截断后下发;工具卡片的形状、可见性与合并全部由前端投影完成。线上模型是 ts-rs 导出的 tagged enum(`agent/direct_thread_wire.rs`),条目身份只有一个:Rust 在进队列前归一成一个 `itemId`,不再暴露第二个 id 概念,也不带任何回合身份。 ## 范围 - 运行态事件自足化:`item.started` / `item.completed` 携带与历史切片同形的脱敏**原始条目**,前端用同一个投影函数处理实时与回读。 -- 条目身份归一:`itemId = call_id ?? id`(工具 item 的 app-server id 就是 `call_id`);历史切片另给 `firstItemId` 作为分页锚点,锚点始终是文件里的原始 item id。 +- 线上模型与提示词同源:条目与事件是 ts-rs 导出的 tagged enum,前端消费生成绑定(改 Rust 模型后跑 `cargo test export_bindings`);毫秒时间戳用 `#[ts(as = "f64")]` 对齐 Tauri JSON 通道的 `number`。 +- 条目身份归一:进队列前收敛成一个 `itemId`(工具条目在 `project.jsonl` 里带调用 id 与 response item id 两个值);历史切片另给 `firstItemId` 作为分页锚点,锚点始终是文件里的原始 item id。 - 思考正文流式:`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta` 以 `item.delta{kind:"reasoning"}` 下发正文;plan 文本与命令输出仍只降级为活动状态。 - 历史读取以 `subscribe` 返回的 `lastCompletedItemId` 为首屏锚点,切片从**文件尾反向扫描**;不可显示条目由前端跳过并继续取页(上限 5 页)。 -- 前端收敛为单一事件 reducer 与单一聊天投影;活动回合只由 `turn.started` 与 `turn.completed` 判定;回合结束后回收该回合运行态条目。 +- 前端收敛为单一事件 reducer 与单一聊天投影;活动回合只由 `turn.started` 与 `turn.completed` 判定(事件不带 turn id,前端只有一个 `turnRunning` 布尔);`item.delta` 直接追加到运行态条目正文,不保留增量缓冲;`turn.completed` 把该回合运行态条目并入历史再清空。 - 删除前端对 Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl`、活动回合快照的读取,以及只服务这些读取的命令与状态。 ## 不在范围内 @@ -34,10 +35,12 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 - 投影在前端:Rust 不生成 `kind` / 标题 / 折叠摘要,也不做合并。 - 合并只保留"先到定形、后到补空白":第一次见到的快照决定卡片形状,后续快照只补输出与状态;不做逐字段优先级表。 -- 条目 id 只有一套:`call_id ?? id`;前端与 Thread Manager 都不再出现第二个 id。 +- 条目 id 只有一套:归一到 `itemId`;前端与 Thread Manager 都不再出现第二个 id。 +- 事件不带回合身份:`turn.started` 无载荷、`turn.completed{status}`;Thread Manager 的生命周期锚点、条目、增量与请求都不带 turn id。 - 思考正文流式下发不放宽可见范围:被下发的就是此前已在 `item.completed` 展示、并已落进 `project.jsonl` 的同一段文本。 - 失败与中止说明只在运行期显示(不写 `project.jsonl`),页面重进后不再出现。 -- 未知 item 类型由 Rust 原样透传(只有类型与身份),当前由前端投影丢弃。 +- 未知 item 类型由 Rust 原样透传(只有类型与身份,Rust 侧留 TODO),当前由前端投影丢弃。 +- 前端聊天卡片的工具形状是 `Omit`;`tool-calls.jsonl` 的持久化形状与 DirectRuntime 的写入保持不变。 ## 依赖与前置条件 @@ -52,12 +55,13 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 - [ ] 进程存活期间的页面重进(含切走再切回)能恢复运行中回合,并允许终止。 - [ ] 历史分页在切片内全部是不可显示条目时仍能继续向前,不出现锚点停滞。 - [ ] 聊天视图不再读取 `turn-stream.jsonl` / `tool-calls.jsonl` / Direct 回合事件 / 活动回合快照;`read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns` 不再被前端调用。 -- [ ] 同一工具调用在实时与回读各只出现一张卡片(跨 id 空间按归一身份对齐)。 +- [ ] 同一工具调用在实时与回读各只出现一张卡片(两个 id 空间按归一后的 `itemId` 对齐)。 +- [ ] 前端聊天状态里不再出现第二个 id 概念与任何回合身份字段;事件解析统一来自 ts-rs 生成绑定。 - [ ] 思考正文在回合进行中即可见,且不进入活动状态文本。 - [ ] 订阅过期后重新 `subscribe` 并原子替换状态,不重复渲染已完成的条目。 ## 证据要求 -- 自动化:Thread Manager 事件契约测试、原始条目搬运与脱敏测试、聊天 reducer 单测(bootstrap / consume / 过期重订阅 / 残回合 / 先到优先合并)、历史分页锚点测试、前端渲染测试(含此前跳过的工具卡片用例)。 +- 自动化:Thread Manager 事件契约测试、原始条目搬运与脱敏测试、ts-rs 绑定生成(`cargo test export_bindings`)、聊天 reducer 单测(bootstrap / consume / 过期重订阅 / 残回合 / 先到优先合并 / 回合回收)、历史分页锚点测试、前端渲染测试(含此前跳过的工具卡片用例)。 - 运行时:真实 app-server 会话下的新回合、杀进程重开、页面重进、分页与终止。 - 边界:订阅过期、事件重复与乱序、不可显示切片、无 `turn.completed` 的残回合、工具输出超长截断与脱敏。 diff --git a/docs/project-memory/plans/【里程碑】对话回合唯一投影-2026-09-16.md b/docs/project-memory/plans/【里程碑】对话回合唯一投影-2026-09-16.md index 191a6f606..c7e01f50a 100644 --- a/docs/project-memory/plans/【里程碑】对话回合唯一投影-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】对话回合唯一投影-2026-09-16.md @@ -1,29 +1,32 @@ # 对话回合唯一投影 -- Version: 2 -- Status: implemented-awaiting-runtime-acceptance +- Version: 3 +- Status: superseded - Date: 2026-09-16 +- Superseded by: `../【里程碑】DirectProject聊天真相源收敛-2026-09-16.md` - Parent Spec: ../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md -## 范围与评审 +## 结论 -单里程碑修复回合展示和流写入的一致性;补充终态重进恢复、用户发送时间和完成后的过程折叠。评审确认:活动快照及 Direct 事件拥有生命周期,Provider 回放不创建 client 回合;JSONL 信封可选时间字段不污染原始 item,无须数据库或旧数据迁移;最终回复沿用 Runtime 的最后 assistant item 合同,失败提示不折叠。没有身份的旧记录不得做位置猜配。 +本里程碑原先把「回合唯一投影」落在 Direct 回合事件 + `turn-stream.jsonl` + `tool-calls.jsonl` + 活动回合快照这条读路径上,方向已被推翻:聊天视图的输入只剩**项目对话历史**与**运行态事件**两项,Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl`、活动回合快照都不再是聊天视图的输入。后续实现与验收一律以 `../【里程碑】DirectProject聊天真相源收敛-2026-09-16.md` 与 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 为准。 -## 验收 +## 仍然有效的部分 -1. 一个 turn 只有一个呈现入口,用户消息不丢失。 -2. item 增量、完成、持久化和回读保持相同身份与固定顺序。 -3. 工具输入输出保留,重复快照不重复渲染。 -4. TypeScript、最小 Cargo 检查、编码和 diff 检查通过;用户要求不运行测试,实机新回合/重开/分页验收待确认。 -5. 已结束回合重进不显示提交中,真实运行回合可恢复;跨项目/新回合迟到快照无效。 -6. 用户消息时间可刷新恢复,旧无时间记录不造值;完成后中间正文和工具统一折叠,最终回复及失败提示保持可见。 +1. 一个回合只有一个呈现入口,用户消息不丢失;历史里带不了身份(没有 id)的旧记录不得做位置猜配。 +2. 同一 item 的增量、完成、持久化与回读使用同一个身份、同一个顺序;重复快照不重复渲染。 +3. 工具调用的输入与输出都保留,展开后仍显示「输入 / 输出」。 +4. 已结束回合重进不显示提交中;真实运行回合可恢复。 +5. 用户消息时间只来自条目自己的时间戳,旧无时间记录不造值;完成后中间正文与工具统一折叠,最终回复与失败提示保持可见。 +6. 失败与中止说明只在运行期显示,不写进 `project.jsonl`;页面重进后不再出现。 -依赖:既有项目历史和 v1 turn-stream / tool-calls DTO。未完成真实 UI 验收前不进入其它里程碑。 +## 已作废的部分 + +- 由活动回合快照接管运行中回合:改为由 `subscribe` bootstrap 事件判定(事件序列里 `turn.started` 之后没有 `turn.completed` 即运行中)。 +- 按 `turnId` 归并回合并把工具卡片挂在回合上:事件与条目都不再带回合身份,前端只有一个 `turnRunning` 布尔,卡片形状去掉 `turnId`。 +- 用 `turn-stream.jsonl` 的 `seq` 决定文本与工具的交替顺序:改为按运行态事件顺序 + 历史文件顺序投影。 +- 前端订阅 `game-creator-direct-turn-update`:改为 `subscribe` + `notify` 唤醒 `consume`(bootstrap 的事件直接 reduce,不再补一次 `consume`)。 ## 当前证据 -- 定向 TypeScript 类型检查、`cargo check --locked --bin genarrative-ai-game-creator-shell`、编码检查、文档索引检查、`git diff --check` 通过。 -- 已补充回合归属、分页、重复快照、无流回退及 writer 完成/切段、持久快照单调性/跨回合裁剪用例;按用户要求未执行测试,不能作为已通过凭证。 -- 静态自审确认视图只剩统一回合列表,不再存在 mapped/unmapped/live 三个回合流出口;失败提示使用稳定 failure 身份。 -- 真实新回合、历史重开、分页、失败/中断、工具展开输入输出仍待重启原生客户端后验收;仅本地提交,不推送。 -- 本次增量已完成生命周期来源收敛、信封发送时间和完成过程折叠;定向 TypeScript、ESLint、Cargo check、文档索引通过。新增时间幂等/旧记录、终态分类及内容分区用例但未运行;主页进入项目、重新发送/切项目竞态和自动折叠仍待原生实机验收,仅本地提交,不推送。 +- 里程碑版本的静态检查结论(TypeScript、Cargo、编码、文档索引、`git diff --check`)仍然成立;该增量按当时授权未运行测试。 +- 新路径的证据要求见 `../【里程碑】DirectProject聊天真相源收敛-2026-09-16.md` 的「验收标准」与「证据要求」,其中包含解锁此前跳过的工具卡片渲染用例。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index b1d6888f7..14ce22931 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -104,23 +104,29 @@ readHistory(threadId, { beforeItemId?, limit }) -> { ### 事件和顺序 -Thread 内所有公开事件共用一个单调递增 seq;seq 允许跳号,前端不要求连续。事件 envelope 至少包含: +Thread 内所有公开事件共用一个单调递增 seq,但 **seq 只是 Thread Manager 的内部游标事实,不下发**:同一个 subscriber 的 `consume` 按队列顺序返回事件数组,数组顺序就是前端要处理的顺序,前端因此不需要 item 级 cursor 或第二套 reducer。 + +线上模型是 ts-rs 导出的 tagged enum(`agent/direct_thread_wire.rs`),前端消费 `src/features/project-workspace/generated/` 里的生成绑定,改 Rust 模型后跑 `cargo test export_bindings` 重新生成;毫秒时间戳标 `#[ts(as = "f64")]`,因为 ts-rs 默认把 `u64` 映射成 `bigint`,而 Tauri 的 JSON 通道传的是 `number`。 + +事件按 `type` 区分,条目按 `itemType` 区分: ```ts -{ - seq: number, - type: string, - turnId: string, - itemId?: string, - payload: unknown, -} +type DirectThreadEvent = + | { type: 'turn.started' } + | { type: 'turn.completed'; status: string } + | { type: 'item.started'; item: DirectThreadItem } + | { type: 'item.completed'; item: DirectThreadItem } + | { type: 'item.delta'; itemId: string; kind: 'message' | 'reasoning'; delta: string } + | { type: 'request'; kind: 'approval.requested' | 'ask.requested' | 'request.resolved'; requestId: string | null }; ``` -进入 Thread Manager 的是已经完成安全过滤和协议标准化的公开 raw event,不是未经审查的 app-server JSON。事件可交错包含多个并发 item:`item.started`、`item.delta`、`item.completed`、approval/request/resolved 事件,以及 `turn.started`、`turn.completed` 生命周期事件。前端按 `turnId` / `itemId` 分发并 reduce,不需要 item 级 cursor 或第二套 reducer。 +进入 Thread Manager 的是已经完成安全过滤和协议标准化的公开 raw event,不是未经审查的 app-server JSON。事件可交错包含多个并发 item:`item.started`、`item.delta`、`item.completed`、approval/request/resolved 事件,以及 `turn.started`、`turn.completed` 生命周期事件。前端按事件顺序 reduce,只用一个 reducer。 + +**事件不带回合身份。** DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;条目、增量、请求与生命周期锚点都不带 turn id。前端 state 里只有一个 `turnRunning` 布尔,历史条目也不记录回合身份。 一个 thread 同时最多有一个 active turn;一个 turn 内允许多个并发 item。`turn.completed` 必须在该 turn 的完成 item 均成功持久化后进入队列,前端据此结束运行态;不能用“不存在 unfinished item”猜测 turn 是否完成。 -前端 reducer 的活动回合判定只有一条:state 中存在「出现过 `turn.started` 且未出现对应 `turn.completed`」的 `turnId` 时,该 turn 才是活动回合,界面才允许显示忙碌态。`subscribe` bootstrap 中没有这样的 `turnId`,就表示当前没有活动回合;Thread Manager 队列随进程消失,因此进程重启后历史里留下的半截回合一律按已结束渲染,前端不发明中断态,也不从历史条目反推忙碌态。 +前端 reducer 的活动回合判定只有一条:事件序列中出现 `turn.started` 且其后没有 `turn.completed` 时才是活动回合,界面才允许显示忙碌态。`subscribe` bootstrap 里没有这样的序列,就表示当前没有活动回合;Thread Manager 队列随进程消失,因此进程重启后历史里留下的半截回合一律按已结束渲染,前端不发明中断态,也不从历史条目反推忙碌态。 生命周期锚点独立于 replay 队列保存:`turn.started` / `turn.completed` 事件即使已被队列前缀回收,`subscribe` 仍必须把最新的一条作为 bootstrap 事件返回。因此进程内任意时刻新建订阅,都能判定最新回合是运行中还是已结束,不依赖"未完成 item 恰好还在队列里"。 @@ -129,8 +135,10 @@ Thread 内所有公开事件共用一个单调递增 seq;seq 允许跳号, 条目形状的职责边界固定为三条: 1. **搬运层不生成展示形状**。Thread Manager 只下发 Codex 原始条目(`itemType` 原样透传,正文与工具明细脱敏后带上限截断),不生成工具卡片的 `kind`、标题、折叠摘要,也不判断哪些条目要显示。 -2. **只有一个条目身份**。`itemId = call_id ?? id`:工具 item 的 app-server id 就是 `call_id`(codex-rs `thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`),而 `project.jsonl` 落盘的是原始 response item(`id` 与 `call_id` 不同),所以在进队列前归一。Thread Manager 与前端都不得再出现第二个 id 概念。 -3. **合并只在前端,且只保留"先到定形、后到补空白"**。第一次见到的快照决定卡片形状,后续快照只补输出与状态;同一调用只出现一张卡片。 +2. **只有一个条目身份**。工具条目在 `project.jsonl` 里带两个 id(调用 id 与 response item id,同一调用的调用与输出共用前者;codex-rs `thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`),所以在进队列前归一成一个 `itemId`。Thread Manager 与前端都不得再出现第二个 id 概念。 +3. **合并只在前端,且只保留"先到定形、后到补空白"**。第一次见到的快照决定卡片形状,后续快照只补输出与状态;同一调用只出现一张卡片。只有"后到信息一定更全"时才例外:正文取更长的一份、工具状态允许从 `running` 升级到终态、`updatedAt` 取较新的时间。 + +前端不保留增量缓冲:`item.delta` 直接追加到运行态条目的正文(正文只增不减)。`turn.completed` 把当前回合的运行态条目并入历史再清空,条目既不消失也不重复;失败与中止说明只在运行期显示,不写进 `project.jsonl`。 历史切片的 `firstItemId` 不是上述归一身份:分页锚点必须是 `project.jsonl` 里的原始 item id,由 Rust 从文件扫描单独算出。 diff --git a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md index 11ce76401..ddb07e8b6 100644 --- a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md +++ b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md @@ -1,5 +1,14 @@ # 【技术方案】GameAgent 对话工具调用卡片(Codex 风格)-2026-09-14 +## 2026-09-16 修订(当前状态) + +本方案的**卡片表现层**(折叠 / 展开、标题与摘要文案、耗时与时间显示、脱敏、无障碍、样式)仍然是有效契约;**数据来源层**已被 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 取代,边界改为: + +- DirectProject 聊天框的工具卡片由**运行态事件 + 项目对话历史**在前端投影生成(`features/project-workspace/directThreadItemProjection.ts`),不再读取 `tool-calls.jsonl`,`read_direct_tool_calls` 不再是聊天视图的输入。 +- 报文中不再有 `toolCalls` 增量字段与 `GameCreatorDirectTurnUpdateEvent` 这条实时链路:卡片形状由前端从脱敏原始条目生成,事件里只有 `item.started` / `item.completed` / `item.delta`(线上模型见 `agent/direct_thread_wire.rs`,由 ts-rs 导出绑定)。 +- 卡片身份只有一个 `itemId`(工具条目在 `project.jsonl` 里带的两个 id 已在 Rust 边界归一),前端卡片形状是 `Omit`:聊天卡片不再有回合身份。 +- 下面「### 1. 工具调用条目」「### 2. 实时事件」「### 3. 回读命令」三节描述的是 DirectRuntime 自己的账本(`tool-calls.jsonl` 的写入形状与脱敏规则仍然有效,DirectRuntime 保留),**不再是 DirectProject 聊天框的读路径**;「### 4. 前端合并与渲染」中按 `turnId` 归并、按 `turn-stream.jsonl` 的 `seq` 交替的规则已作废,改为按事件顺序 + 历史文件顺序投影。 + ## 一句话交付 把 GameAgent 右侧对话面板里的「执行命令 / 写文件 / 调工具」从一行中文进度文本,改成 Codex 桌面客户端那样的**可折叠卡片**(折叠态一行摘要,展开态看命令与文件明细),并且在**刷新页面、重开项目后仍然存在**。 @@ -55,7 +64,7 @@ toolCalls?: DirectTurnToolCall[] | null; - 历史文件缺失 → 返回空数组,不报错。 - 单行损坏 → 逐行读字节并逐行解码,跳过该行继续,不整体失败;只有损坏字节与下一行黏成一行(例如写入被截断、缺失换行)时,被丢掉的也只是那**一行**,其后的合法记录必须继续读回(与 Codex item 流一样是"尽力而为"的展示数据,不是业务真相)。 -### 4. 前端合并与渲染(回合唯一归属,连续工具成块) +### 4. 前端合并与渲染(回合唯一归属,连续工具成块)——已作废,见文首修订 - 加载对话时按 `turnId` 归并为唯一回合容器,用户消息保留在该回合前部。有 `turn-stream.jsonl` 时,文本与工具按 item `seq` 交替,连续工具合为一块,遇到文本另起一块;没有流的历史回合才采用“工具块 + 历史正文”。 - 回合完成后,中间文本及所有工具块统一收进默认关闭的“执行过程”;最终回复及失败提示留在外面。展开后仍按原顺序查看中间输出和工具详情;运行中不使用外层折叠区。用户消息的发送时间从消息自身的历史时间读取,不能拿工具起点补造。 -- 2.52.0 From 3c6e084a413b66c358d472cb3c4dafafe2e4f200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 20:17:15 +0800 Subject: [PATCH 50/92] =?UTF-8?q?DirectProject=20=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E5=88=87=E7=89=87=E6=94=B9=E4=B8=BA=E4=BB=8E=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=B0=BE=E5=9B=9E=E6=89=AB=E5=B9=B6=E5=88=A0=E6=8E=89=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E6=A1=86=E5=8F=AA=E8=AF=BB=E5=91=BD=E4=BB=A4=20-=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20DirectProjectHistoryReverseLines=EF=BC=9A?= =?UTF-8?q?=E6=8C=89=E5=9D=97=E4=BB=8E=E6=96=87=E4=BB=B6=E5=B0=BE=E9=80=90?= =?UTF-8?q?=E8=A1=8C=E5=9B=9E=E8=AF=BB=20project.jsonl=20-=20=E5=B9=82?= =?UTF-8?q?=E7=AD=89=E5=9B=9E=E6=89=AB=E4=B8=8E"=E8=AF=BB=E4=B8=80?= =?UTF-8?q?=E5=B1=8F"=E5=85=B1=E7=94=A8=E8=BF=99=E4=B8=80=E5=A5=97?= =?UTF-8?q?=E5=B0=BE=E9=83=A8=E5=9B=9E=E8=AF=BB=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=90=84=E8=87=AA=E4=BB=8E=E6=96=87=E4=BB=B6=E5=A4=B4=E8=AF=BB?= =?UTF-8?q?=E5=88=B0=E5=B0=BE=20-=20read=5Fdirect=5Fproject=5Fhistory=5Fit?= =?UTF-8?q?ems=5Fslice=5Fat=20=E8=AF=AD=E4=B9=89=E4=B8=8D=E5=8F=98?= =?UTF-8?q?=E4=BB=8D=E8=BF=94=E5=9B=9E=E5=85=83=E7=BB=84=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=AC=AC=E5=9B=9B=E9=A1=B9=E4=B8=BA=E6=9C=AC=E5=B1=8F?= =?UTF-8?q?=E6=9C=80=E8=80=81=E7=9A=84=20itemId=20=E4=BD=9C=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E9=94=9A=E7=82=B9=20-=20=E5=88=86=E9=A1=B5=E9=94=9A?= =?UTF-8?q?=E7=82=B9=E5=8F=AA=E6=8C=89=E5=BD=92=E4=B8=80=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=20itemId=20=E5=8C=B9=E9=85=8D=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=8E=A5=E5=8F=97=E7=AC=AC=E4=BA=8C=E4=B8=AA=20id=20-=20read?= =?UTF-8?q?=5Fdirect=5Fproject=5Flast=5Fitem=5Fid=5Fat=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=B0=BE=E9=83=A8=E5=9B=9E=E6=89=AB=E8=AF=BB=E4=B8=80?= =?UTF-8?q?=E8=A1=8C=EF=BC=8C=E4=B8=8D=E5=86=8D=E8=AF=BB=E6=95=B4=E4=BB=BD?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=20-=20=E5=88=A0=E9=99=A4=E5=8F=AA=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E8=81=8A=E5=A4=A9=E6=A1=86=E5=9B=9E=E8=AF=BB=E7=9A=84?= =?UTF-8?q?=20read=5Fdirect=5Ftool=5Fcalls=20/=20read=5Fdirect=5Fturn=5Fst?= =?UTF-8?q?ream=20=E5=91=BD=E4=BB=A4=E4=B8=8E=E6=B3=A8=E5=86=8C=20-=20?= =?UTF-8?q?=E4=BF=9D=E7=95=99=20list=5Fgame=5Fcreator=5Fdirect=5Factive=5F?= =?UTF-8?q?turns=EF=BC=9A=E5=AE=83=E6=9C=8D=E5=8A=A1=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E8=B7=A8=E9=A1=B5=E9=9D=A2"=E8=BF=90=E8=A1=8C=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E9=A1=B9=E7=9B=AE"=EF=BC=8C=E4=B8=8D=E6=98=AF?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E6=A1=86=E8=AF=BB=E8=B7=AF=E5=BE=84=20-=20?= =?UTF-8?q?=E8=A1=A5=E5=8E=86=E5=8F=B2=E5=88=87=E7=89=87=E5=B0=BE=E9=83=A8?= =?UTF-8?q?=E5=9B=9E=E6=89=AB=E4=B8=8E=E5=88=86=E9=A1=B5=E9=94=9A=E7=82=B9?= =?UTF-8?q?=E4=B8=A4=E6=9D=A1=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/agent/direct_project_history.rs | 322 +++++++++++++----- .../src-tauri/src/commands.rs | 48 +-- .../src-tauri/src/main.rs | 2 - 3 files changed, 252 insertions(+), 120 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 130c01549..133409a7d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -1,3 +1,4 @@ +use super::direct_thread_item_identity; use super::runtime_actions::acquire_game_creator_agent_runtime_project_write_lock_with_wait; use crate::config::prepare_game_creator_private_path_for_read; use crate::project::{ @@ -102,63 +103,114 @@ fn record(item: &Value) -> Result { const DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES: usize = 16 * 1024; +/// 从文件尾向前回读到的一行。 +/// +/// `terminated` 表示这行后面确实有换行符:文件里只有末行可能没有换行,那种行是 append +/// 侧承诺会修复的截断尾,解析失败时按"历史到此结束"处理,而不是报错。 +struct DirectProjectHistoryReverseLine { + bytes: Vec, + terminated: bool, +} + +/// 从文件尾向前逐行产出 `project.jsonl`。 +/// +/// 历史会随项目长到 MB 级,而"最近一屏"和"按 id 回扫"都只关心尾部若干行:两者共用这一套 +/// 按块回读,"读一屏"不必再从文件头逐行读到尾。 +struct DirectProjectHistoryReverseLines { + path: PathBuf, + file: File, + /// 已读进内存、但还没被换行切出来的更早字节。 + pending: Vec, + chunk: Vec, + /// 下一次回读的起始偏移;0 表示文件头已经读完。 + position: u64, +} + +impl DirectProjectHistoryReverseLines { + fn open(path: &Path) -> Result { + let file = File::open(path) + .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; + let position = file + .metadata() + .map_err(|error| { + format!( + "读取 DirectProject 历史元数据失败:{}: {error}", + path.display() + ) + })? + .len(); + Ok(Self { + path: path.to_path_buf(), + file, + pending: Vec::new(), + chunk: vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES], + position, + }) + } + + fn next_line(&mut self) -> Result, String> { + loop { + if let Some(newline) = self.pending.iter().rposition(|byte| *byte == b'\n') { + let bytes = self.pending[newline + 1..].to_vec(); + self.pending.truncate(newline); + return Ok(Some(DirectProjectHistoryReverseLine { + bytes, + terminated: true, + })); + } + if self.position == 0 { + if self.pending.is_empty() { + return Ok(None); + } + return Ok(Some(DirectProjectHistoryReverseLine { + bytes: std::mem::take(&mut self.pending), + terminated: false, + })); + } + let read_len = usize::try_from(self.position) + .unwrap_or(usize::MAX) + .min(self.chunk.len()); + self.position -= read_len as u64; + self.file + .seek(SeekFrom::Start(self.position)) + .map_err(|error| { + format!( + "定位 DirectProject 历史失败:{}: {error}", + self.path.display() + ) + })?; + self.file + .read_exact(&mut self.chunk[..read_len]) + .map_err(|error| { + format!( + "读取 DirectProject 历史失败:{}: {error}", + self.path.display() + ) + })?; + let mut combined = self.chunk[..read_len].to_vec(); + combined.extend_from_slice(&self.pending); + self.pending = combined; + } + } +} + fn find_direct_project_history_item_by_id_at( path: &Path, item_id: &str, ) -> Result, String> { fire_direct_project_history_scan_probe(DIRECT_PROJECT_HISTORY_SCAN_PROBE_STARTED); - let mut file = File::open(path) - .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; - let mut position = file - .metadata() - .map_err(|error| { - format!( - "读取 DirectProject 历史元数据失败:{}: {error}", - path.display() - ) - })? - .len(); - let mut pending = Vec::new(); - let mut chunk = vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES]; - - loop { - if position == 0 { - break; - } - let read_len = usize::try_from(position) - .unwrap_or(usize::MAX) - .min(chunk.len()); - position -= read_len as u64; - file.seek(SeekFrom::Start(position)) - .map_err(|error| format!("定位 DirectProject 历史失败:{}: {error}", path.display()))?; - file.read_exact(&mut chunk[..read_len]) - .map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?; - - let mut combined = Vec::with_capacity(read_len + pending.len()); - combined.extend_from_slice(&chunk[..read_len]); - combined.extend_from_slice(&pending); - let mut line_end = combined.len(); - while let Some(newline) = combined[..line_end].iter().rposition(|byte| *byte == b'\n') { - let line = &combined[newline + 1..line_end]; - if !line.is_empty() { - if let Some(item) = direct_project_history_item_from_line(path, line)? { - if item.get("id").and_then(Value::as_str) == Some(item_id) { - return Ok(Some(item)); - } + let mut lines = DirectProjectHistoryReverseLines::open(path)?; + while let Some(line) = lines.next_line()? { + match direct_project_history_item_from_line(path, &line.bytes) { + Ok(Some(item)) => { + if item.get("id").and_then(Value::as_str) == Some(item_id) { + return Ok(Some(item)); } } - line_end = newline; - } - pending = combined[..line_end].to_vec(); - } - - if !pending.is_empty() { - // The append path repairs an unterminated final JSONL record before - // writing. A duplicate scan must not reject that repairable tail. - if let Ok(Some(item)) = direct_project_history_item_from_line(path, &pending) { - if item.get("id").and_then(Value::as_str) == Some(item_id) { - return Ok(Some(item)); - } + Ok(None) => {} + // 末行没有换行符:append 侧会修复这条截断尾,幂等回扫不能因此失败。 + Err(_) if !line.terminated => break, + Err(error) => return Err(error), } } Ok(None) @@ -530,46 +582,93 @@ fn read_direct_project_history_entries_at(root: &Path) -> Result, limit: usize, -) -> Result<(Vec, bool, BTreeMap), String> { - let items = read_direct_project_history_entries_at(root)?; - let end = match before_item_id { - Some(item_id) => items - .iter() - .position(|(item, _)| item.get("id").and_then(Value::as_str) == Some(item_id)) - .ok_or_else(|| format!("DirectProject 历史中不存在 item:{item_id}"))?, - None => items.len(), - }; +) -> Result<(Vec, bool, BTreeMap, Option), String> { + let path = history_path(root); + if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? { + return Ok((Vec::new(), false, BTreeMap::new(), None)); + } let bounded_limit = limit.clamp(1, 200); - let start = end.saturating_sub(bounded_limit); - let slice = &items[start..end]; - let timestamps = slice + let mut lines = DirectProjectHistoryReverseLines::open(&path)?; + let mut newest_first: Vec<(Value, u64)> = Vec::new(); + // 锚点所在行本身不进窗口:它是"下一屏"的边界。 + let mut anchor_seen = before_item_id.is_none(); + let mut has_more = false; + while let Some(line) = lines.next_line()? { + if line.bytes.iter().all(u8::is_ascii_whitespace) { + continue; + } + let parsed: Value = match serde_json::from_slice(&line.bytes) { + Ok(value) => value, + // 与顺序读取一致:只有末行没有换行符时,解析失败才按"可修复的截断尾"结束。 + Err(_) if !line.terminated => break, + Err(error) => { + return Err(format!( + "解析 DirectProject 历史失败:{}: {error}", + path.display() + )) + } + }; + let Some(item) = direct_project_history_item_from_parsed_line(&path, &parsed)? else { + continue; + }; + if !anchor_seen { + anchor_seen = before_item_id.is_some_and(|anchor| { + direct_thread_item_identity(&item).as_deref() == Some(anchor) + }); + continue; + } + if is_direct_project_internal_context_item(&item) { + continue; + } + if newest_first.len() == bounded_limit { + // 收满一屏之后再见一条可显示条目,就足以说明还有更早的历史。 + has_more = true; + break; + } + let recorded_at = parsed + .get("recordedAt") + .and_then(Value::as_u64) + .unwrap_or(0); + newest_first.push((item, recorded_at)); + } + if !anchor_seen { + return Err(format!( + "DirectProject 历史中不存在 item:{}", + before_item_id.unwrap_or_default() + )); + } + newest_first.reverse(); + let recorded_at_ms = newest_first .iter() .filter_map(|(item, at)| { - let id = item.get("id").and_then(Value::as_str)?; - (*at > 0).then(|| (id.to_string(), *at)) + let identity = direct_thread_item_identity(item)?; + (*at > 0).then_some((identity, *at)) }) .collect(); + let first_item_id = newest_first + .first() + .and_then(|(item, _)| direct_thread_item_identity(item)); Ok(( - slice.iter().map(|(item, _)| item.clone()).collect(), - start > 0, - timestamps, + newest_first.into_iter().map(|(item, _)| item).collect(), + has_more, + recorded_at_ms, + first_item_id, )) } +/// 最新一条可显示条目的 itemId:首屏历史锚点。 +/// +/// 与"最近一屏"共用尾部回扫,读一行就能返回,不回读整份历史。 pub(crate) fn read_direct_project_last_item_id_at(root: &Path) -> Result, String> { - Ok(read_direct_project_history_items_at(root)? - .into_iter() - .rev() - .find_map(|item| { - item.get("id") - .and_then(Value::as_str) - .filter(|id| !id.is_empty()) - .map(str::to_string) - })) + Ok(read_direct_project_history_items_slice_at(root, None, 1)?.3) } pub(crate) fn read_direct_project_chat_history_at( @@ -648,12 +747,12 @@ mod tests { "content": [{"type": "input_text", "text": "修改游戏"}], }); append_direct_project_user_message_at(root.path(), &item).unwrap(); - let (items, _, timestamps) = + let (items, _, timestamps, _) = super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); assert_eq!(items, vec![item.clone()]); assert!(timestamps["sent-message"] > 0); append_direct_project_user_message_at(root.path(), &item).unwrap(); - let (_, _, reloaded) = + let (_, _, reloaded, _) = super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); assert_eq!(timestamps, reloaded); } @@ -662,7 +761,7 @@ mod tests { fn old_history_without_envelope_time_stays_unknown() { let root = init_history_project("history-unknown-time"); write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); - let (_, _, timestamps) = + let (_, _, timestamps, _) = super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); assert!(timestamps.is_empty()); assert_eq!( @@ -674,6 +773,71 @@ mod tests { ); } + /// 判据:读一屏只回扫文件尾,不碰文件头。 + /// + /// 文件头放一条坏行(JSON 不合法),只要读取只覆盖"尾部一屏 + 一条"的范围就必须成功; + /// 一旦实现退回"从文件头逐行读到尾",这条用例会因为坏行直接失败。 + #[test] + fn history_window_reads_from_the_tail_without_parsing_the_head() { + let root = init_history_project("history-tail-scan"); + let head = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"head-broken","content":[{"type":"input_text","text":"坏行"}"#; + write_history_lines( + root.path(), + &[ + head, + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"), + RESPONSE_ITEM_ROW, + ], + ); + let (items, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at(root.path(), None, 1).unwrap(); + let ids = items + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-2"]); + assert!(has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-2")); + } + + /// 判据:分页锚点是"本窗口最老一条的归一身份",逐屏向前不重不漏。 + #[test] + fn history_window_paginates_upwards_by_identity_anchor() { + let root = init_history_project("history-pagination"); + write_history_lines( + root.path(), + &[ + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"), + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-2"), + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-3"), + ], + ); + let (newest, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at(root.path(), None, 2).unwrap(); + let ids = newest + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-2", "codex-item-3"]); + assert!(has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-2")); + + let (older, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at( + root.path(), + first_item_id.as_deref(), + 2, + ) + .unwrap(); + let ids = older + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-1"]); + assert!(!has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-1")); + } + /// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。 /// /// 注入标记是"让接下来 N 次单次尝试返回争用失败";退避表只补一次重试,所以注入 1 次 diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 696aff810..eff140194 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5306,31 +5306,6 @@ pub(crate) async fn read_agent_runtime_error_detail( .await .map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))? } -#[tauri::command] -pub(crate) async fn read_direct_tool_calls( - project_path: String, -) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - read_direct_tool_calls_at(root) - }) - .await - .map_err(|error| format!("读取工具调用历史后台任务失败:{error}"))? -} - -#[tauri::command] -pub(crate) async fn read_direct_turn_stream( - project_path: String, -) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - read_direct_turn_stream_at(root) - }) - .await - .map_err(|error| format!("读取回合流历史后台任务失败:{error}"))? -} #[tauri::command] pub(crate) fn list_game_creator_direct_active_turns( @@ -5378,20 +5353,15 @@ pub(crate) async fn read_direct_project_history_slice( tauri::async_runtime::spawn_blocking(move || { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; - let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at( - root, - before_item_id.as_deref(), - limit.unwrap_or(20), - )?; - let first_item_id = items - .first() - .and_then(|item| item.get("id")) - .and_then(serde_json::Value::as_str) - .map(str::to_string); - let items = direct_thread_items_from_history(root, &items, |item| { - item.get("id") - .and_then(serde_json::Value::as_str) - .and_then(|id| item_timestamps.get(id).copied()) + let (raw_items, has_more, recorded_at_ms, first_item_id) = + read_direct_project_history_items_slice_at( + root, + before_item_id.as_deref(), + limit.unwrap_or(20), + )?; + let items = direct_thread_items_from_history(root, &raw_items, |item| { + direct_thread_item_identity(item) + .and_then(|identity| recorded_at_ms.get(&identity).copied()) .unwrap_or_default() }); Ok(DirectThreadHistorySlice { diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index bec55a981..c240a21fc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2782,8 +2782,6 @@ fn main() { archive_game_creator_agent_session, read_local_conversation, read_direct_project_conversation, - read_direct_tool_calls, - read_direct_turn_stream, read_agent_runtime_error_detail, list_game_creator_direct_active_turns, subscribe_direct_project_thread, -- 2.52.0 From 280a2dc5d1f5b09a7c8e67fd256c762eef25034d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 21:24:41 +0800 Subject: [PATCH 51/92] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20DirectProject=20?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E8=8D=89=E7=A8=BF=E4=B8=A2=E6=8E=89=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E5=90=8E=E7=9A=84=E6=8D=A2=E8=A1=8C=EF=BC=88review=20?= =?UTF-8?q?=E7=AC=AC=201=20=E6=9D=A1=EF=BC=89=20-=20ResourceReferenceInput?= =?UTF-8?q?=20=E7=9A=84=E8=8D=89=E7=A8=BF=E6=8A=95=E5=BD=B1=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E3=80=8C=E5=90=91=E5=89=8D=E5=90=88=E5=B9=B6=E3=80=8D?= =?UTF-8?q?=EF=BC=9A=E4=B8=8A=E4=B8=80=20part=20=E6=98=AF=E5=BC=95?= =?UTF-8?q?=E7=94=A8=20chip=20=E6=97=B6=E5=85=88=E6=8A=8A=E5=BE=85?= =?UTF-8?q?=E5=86=99=E6=96=87=E6=9C=AC=E6=94=92=E5=9C=A8=20pendingText=20?= =?UTF-8?q?=E4=B8=8A=EF=BC=8C=E7=AD=89=E4=B8=8B=E4=B8=80=E4=B8=AA=E6=96=87?= =?UTF-8?q?=E6=9C=AC=20part=20=E5=88=B0=E6=9D=A5=E5=86=8D=E5=90=88?= =?UTF-8?q?=E5=B9=B6=EF=BC=8C=E6=AE=B5=E8=90=BD=E5=88=86=E9=9A=94=E7=AC=A6?= =?UTF-8?q?=E4=B8=8E=E8=BD=AF=E6=8D=A2=E8=A1=8C=E4=B8=8D=E5=86=8D=E8=A2=AB?= =?UTF-8?q?=20trim()=20=E4=B8=A2=E6=8E=89=20-=20=E6=94=92=E5=88=B0?= =?UTF-8?q?=E6=9C=80=E5=90=8E=E4=BB=8D=E5=8F=AA=E6=9C=89=E7=A9=BA=E7=99=BD?= =?UTF-8?q?=E7=9A=84=E6=96=87=E6=9C=AC=E4=B8=8D=E5=85=A5=20content?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E4=BA=A7=E5=87=BA=20Rust=20validate?= =?UTF-8?q?=5Fdirect=5Fcodex=5Fuser=5Fitem=20=E4=BC=9A=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E7=9A=84=E7=A9=BA=20input=5Ftext=20-=20=E6=96=B0=E5=A2=9E=20re?= =?UTF-8?q?sourceReferenceInput=20=E7=94=A8=E4=BE=8B=E3=80=8C=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E5=90=8E=E9=9D=A2=E7=9A=84=E6=AE=B5=E8=90=BD=E5=88=86?= =?UTF-8?q?=E9=9A=94=E4=B8=8D=E4=BC=9A=E8=A2=AB=E5=90=9E=E6=8E=89=E3=80=8D?= =?UTF-8?q?=EF=BC=8C=E9=92=89=E4=BD=8F=20chip=20=E5=90=8E=E4=B8=80?= =?UTF-8?q?=E6=AE=B5=E7=9A=84=20content=20part=E3=80=81=E6=B4=BE=E7=94=9F?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E4=B8=8E=E3=80=8C=E4=B8=8D=E4=BA=A7=E7=94=9F?= =?UTF-8?q?=E7=BA=AF=E7=A9=BA=E7=99=BD=20part=E3=80=8D=20-=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=9B=B4=E6=96=B0=20canonical=20content=20=E4=B8=A5?= =?UTF-8?q?=E6=A0=BC=E8=BE=B9=E7=95=8C=E9=87=8C=E7=A8=8B=E7=A2=91=E7=9A=84?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=BB=93=E8=AE=BA=E4=B8=8E=E8=AF=81=E6=8D=AE?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E5=9C=A8=20shared-memory/pitfalls=20?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E8=AF=A5=E7=BC=BA=E9=99=B7=E7=9A=84=E7=8E=B0?= =?UTF-8?q?=E8=B1=A1=E3=80=81=E5=8E=9F=E5=9B=A0=E3=80=81=E5=A4=84=E7=90=86?= =?UTF-8?q?=E4=B8=8E=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ResourceReferenceInput.tsx | 47 +++++++++++++----- .../tests/resourceReferenceInput.test.tsx | 49 +++++++++++++++++++ ...Project canonical content严格边界-2026-09-16.md | 5 +- docs/project-memory/shared-memory/pitfalls.md | 8 +++ 4 files changed, 94 insertions(+), 15 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index da7eda167..240489cda 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -174,41 +174,62 @@ class ResourceMentionOption extends MenuOption { } } -function appendInputText(content: DirectCodexUserContentPart[], text: string) { +/** + * 收集过程中的待写文本。 + * + * 只有紧挨着上一段文本时才能直接追加;上一 part 是引用(chip)时先把文本攒起来,等 + * 下一个文本到来再合并成同一个 part。否则段落分隔符(root 子节点之间补的 `\n`)和软 + * 换行会被整段丢掉,chip 后面的文字会粘成 `@素材下一段`,并原样进 agent 与队列文案。 + * 攒到最后仍然只有空白的(以换行结尾、两个 chip 之间只隔一个换行)不落成 part: + * Rust `validate_direct_codex_user_item` 会拒绝空 `input_text`。 + */ +type DraftContentCollector = { + content: DirectCodexUserContentPart[]; + pendingText: string; +}; + +function appendInputText(collector: DraftContentCollector, text: string) { + if (!text) { + return; + } + const { content } = collector; const previous = content[content.length - 1]; - if (previous?.type === 'input_text') { + if (collector.pendingText === '' && previous?.type === 'input_text') { previous.text += text; return; } - if (text.trim()) { - content.push({ type: 'input_text', text }); + collector.pendingText += text; + if (!collector.pendingText.trim()) { + return; } + content.push({ type: 'input_text', text: collector.pendingText }); + collector.pendingText = ''; } function collectDraftParts( node: LexicalNode, references: ChatReference[], - content: DirectCodexUserContentPart[], + collector: DraftContentCollector, ) { if ($isTextNode(node)) { - appendInputText(content, node.getTextContent()); + appendInputText(collector, node.getTextContent()); return; } if ($isLineBreakNode(node)) { - appendInputText(content, '\n'); + appendInputText(collector, '\n'); return; } if ($isResourceReferenceNode(node)) { references.push(node.__reference); - content.push(chatReferenceToContentPart(node.__reference)); + collector.content.push(chatReferenceToContentPart(node.__reference)); return; } if ($isElementNode(node)) { node.getChildren().forEach((child, index) => { if (index > 0 && node.getType() === 'root') { - appendInputText(content, '\n'); + appendInputText(collector, '\n'); } - collectDraftParts(child, references, content); + collectDraftParts(child, references, collector); }); } } @@ -221,11 +242,11 @@ type DraftProjection = { /** 仅供编辑器内部派生引用(重建文本草稿时用);对外只暴露 canonical content。 */ function readDraftProjectionFromNodes(): DraftProjection { const references: ChatReference[] = []; - const content: DirectCodexUserContentPart[] = []; - collectDraftParts($getRoot(), references, content); + const collector: DraftContentCollector = { content: [], pendingText: '' }; + collectDraftParts($getRoot(), references, collector); return { references: dedupeChatReferences(references), - content, + content: collector.content, }; } diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index 68ace0833..953d3d391 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -251,6 +251,55 @@ describe('ResourceReferenceInput', () => { expect(screen.getByRole('button', { name: '模拟润色' })).not.toBeNull(); }); + test('引用后面的段落分隔不会被吞掉:读回的 prompt 与编辑器分段逐字一致', async () => { + const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); + function Controlled() { + const composerRef = createRef(); + return ( + <> + + + + ); + } + render(); + await settleComposer(); + + fireEvent.click(screen.getByRole('button', { name: '模拟润色' })); + await settleComposer(); + await settleComposer(); + + const draft = onChange.mock.calls.at(-1)?.[0]; + // chip 与后一段各自成段:段落分隔必须留在 canonical content 里。丢掉它读回的 + // prompt 会粘成 `@hero把这一版改成夜景`,并原样进 agent、队列文案与润色判据。 + expect(draft?.content).toEqual([ + chatReferenceToContentPart(reference), + { type: 'input_text', text: '\n把这一版改成夜景' }, + ]); + expect(draftText(draft)).toBe('@hero\n把这一版改成夜景'); + // 换行并进后面那段文本,而不是单独落一个纯空白 part:Rust 会拒绝空 input_text。 + expect( + draft?.content.filter( + (part) => part.type === 'input_text' && !part.text.trim(), + ), + ).toEqual([]); + }); + test('引用浮层打开时 Enter 不提交表单,关掉后恢复提交', async () => { const onSubmit = vi.fn(); const user = userEvent.setup(); diff --git a/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md index 80b8b6a13..38815e6c1 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md @@ -38,12 +38,13 @@ ## 实现结论 -- 唯一的前端空白过滤留在 Lexical 投影层:`agc_attachment`/`input_text` 之外的纯空白文本不作为 content part,因为 Rust `validate_direct_codex_user_item` 会拒绝空 `input_text`。转换函数不再重复过滤。 +- 唯一的前端空白过滤留在 Lexical 投影层:投影只产出有意义的 `input_text`,因为 Rust `validate_direct_codex_user_item` 会拒绝空 `input_text`。转换函数不再重复过滤。 +- 投影层的过滤口径是「向前合并」而不是「直接丢弃」:上一 part 是引用(chip)时先把待写文本攒住,等下一个文本 part 到来再合并成同一个 part。段落分隔符(root 子节点之间补的 `\n`)与软换行因此不会丢,`@素材` 后面的那一段不会被粘成 `@素材下一段`;攒到最后仍只有空白的(以换行结尾、两个 chip 之间只隔一个换行)不入 content。 - 显示文本、队列 chip 文案、草稿持久化和润色判据统一由 `directCodexContentToPromptText(content, assets)` 从 content 派生,不再维护并行的 `text` 字段。 - 需要文本草稿的旧入口(`replaceText`、快速编辑)仍由编辑器把文本 + 引用重建为 content,方向是「文本 → content」,不存在「legacy 字段 → content」的回退。 ## 证据 - `apps/ai-game-creator-shell/tests/resourceReferences.test.ts`:content 原样传递、空白 part 保留、有效性判断、文本派生。 -- `apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx`、`chatPromptPolish.test.tsx`、`tests/appSurface/*.suite.ts`:草稿读取、提醒判据、队列与 caller 迁移到 content-only。 +- `apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx`(含「引用后面的段落分隔不会被吞掉」)、`chatPromptPolish.test.tsx`、`tests/appSurface/*.suite.ts`:草稿读取、提醒判据、队列与 caller 迁移到 content-only。 - AGC shell 类型检查、定向 Vitest、`npm run check:encoding`、`git diff --check` 通过。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 444798e15..26eacc4ae 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5616,3 +5616,11 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - fixture 子进程统一清除 `GIT_*` 环境,并用一次性外层 linked worktree 验证引用、索引、配置不变;原有工程检查规则保持完整,不能用逐项关闭规则修复 fixture 污染。 - Vitest 的 `toHaveBeenCalledWith` 匹配任意一次调用,失败输出会列出其它命令;应先定位相同命令的真实参数差异,不能由其它调用的序号推断时序故障。 - 存在后台轮询的 IPC mock 不应要求目标命令占据全局最后一次调用。验证刷新时先记录调用边界,再筛选该边界之后的目标命令,严格核对其最后一次参数,避免后台查询影响断言,也避免旧调用掩盖刷新未执行。 + +## 2026-09-16 Lexical 投影丢掉引用后的换行:`@素材` 和下一段粘成一个词 + +- **现象**:聊天输入区里先 `@` 一个素材、回车换段再写文字,提交出去的 canonical content 里没有任何分隔,直接读成 `@hero把这一版改成夜景`;同一个字符串还会进 agent 输入、队列 chip 文案与润色判据。 +- **原因**:`ResourceReferenceInput` 的 `appendInputText` 只认「上一 part 是 `input_text`」这一种可追加情形,其余一律 `if (text.trim())` 才落 part。root 子节点之间补的段落分隔符与 `LineBreakNode` 传进来的都是 `'\n'`,`trim()` 为空 ⇒ 整段丢掉;chip 后那一段文字随后另起一个 part,派生文本用 `''` 直接拼接,于是粘在一起。 +- **处理**:投影层改成「向前合并」——待写文本先攒在 `pendingText` 上,下一个文本到来时合并成同一个 `input_text`(`\n` 因此落在 `\n把这一版改成夜景` 里);攒到最后仍只有空白的(以换行结尾、两个 chip 之间只隔一个换行)不入 content,因为 Rust `validate_direct_codex_user_item` 会拒绝空 `input_text`,直接按 review 的 `text.includes('\n')` 落 part 会让「chip 换行 chip」这种输入整轮发不出去。 +- **验证**:`apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx` 新增「引用后面的段落分隔不会被吞掉」,断言 chip 后一段的 part 为 `{ type: 'input_text', text: '\n把这一版改成夜景' }`、派生文本逐字一致、且不出现纯空白 part;去掉修复后该用例变红(比对失败在 content 相等那一行)。 +- **关联**:`apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx`(`DraftContentCollector` / `appendInputText`)、`docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md`。 -- 2.52.0 From 869abad879685f48fc3c6f99f514e28edbfa4834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 21:25:38 +0800 Subject: [PATCH 52/92] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8E=92=E9=98=9F?= =?UTF-8?q?=E6=B6=88=E6=81=AF=20chip=20=E7=9A=84=20@=20=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E9=9C=B2=E5=87=BA=E5=86=85=E9=83=A8=20resourceId=EF=BC=88revie?= =?UTF-8?q?w=20=E7=AC=AC=202=20=E6=9D=A1=EF=BC=89=20-=20queuedChatTurnLabe?= =?UTF-8?q?l=20=E5=A2=9E=E5=8A=A0=20assets=20=E5=85=A5=E5=8F=82=E5=B9=B6?= =?UTF-8?q?=E9=80=8F=E4=BC=A0=E7=BB=99=20directCodexContentToPromptText?= =?UTF-8?q?=EF=BC=8C@=20=E5=BC=95=E7=94=A8=E6=8C=89=20manifest=20=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=90=8D=E5=B1=95=E5=BC=80=EF=BC=8C=E4=B8=8E=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E8=BE=93=E5=85=A5=E5=8C=BA=E6=96=87=E6=A1=88=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E4=B8=80=E8=87=B4=20-=20ComposerTurnQueue=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=20assets=20=E5=B1=9E=E6=80=A7=EF=BC=8CProjectSupervis?= =?UTF-8?q?orView=20=E6=8A=8A=20chatProjectAssets=20=E4=BC=A0=E4=B8=8B?= =?UTF-8?q?=E5=8E=BB=20-=20chat-composer=20=E7=94=A8=E4=BE=8B=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E3=80=8C=E9=98=9F=E5=88=97=20chip=20=E7=9A=84=20@=20?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E6=8C=89=20manifest=20=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=90=8D=E5=B1=95=E5=BC=80=E3=80=8D=EF=BC=8C=E9=92=89=E4=BD=8F?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=90=8D=E6=96=87=E6=A1=88=E4=B8=8E=E4=B8=8D?= =?UTF-8?q?=E4=BC=A0=E6=B8=85=E5=8D=95=E6=97=B6=E7=9A=84=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=20id=20=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project-workspace/ComposerControls.tsx | 8 +++-- .../ProjectSupervisorView.tsx | 1 + .../project-workspace/chatComposerQueue.ts | 15 +++++++-- .../tests/appSurface/chat-composer.suite.ts | 31 +++++++++++++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx index 0aa574dbc..410ee2da7 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx @@ -19,6 +19,7 @@ import { import type { RefObject } from 'react'; import { useEffect, useRef, useState } from 'react'; +import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { resolveTauriInvoke } from '../../app/tauri'; import type { GameCreatorAppConfigView, @@ -192,9 +193,12 @@ export function ComposerPendingAttachments({ /** 队列 chip:回合运行中入队的消息,按 FIFO 顺序展示,可单条取消。 */ export function ComposerTurnQueue({ turns, + assets, onCancel, }: { turns: readonly QueuedChatTurn[]; + /** 与聊天输入区同源的素材清单:chip 文案里的 `@` 引用按它展开成显示名。 */ + assets?: readonly GameCreationAppAssetManifestEntry[]; onCancel: (id: string) => void; }) { if (turns.length === 0) { @@ -211,11 +215,11 @@ export function ComposerTurnQueue({ {index + 1} - {queuedChatTurnLabel(turn)} + {queuedChatTurnLabel(turn, assets)} - ) : null} -
- ) : null} ) : null} { - if (item.itemType !== 'message') return []; - if (item.role !== 'user' && item.role !== 'assistant') return []; - if (!item.text) return []; - return [ - { - schemaVersion: 'agc-direct-project-context.v1', - role: item.role, - content: item.text, - agentId: null, - messageId: item.itemId, - updatedAt: item.at ?? 0, - }, - ]; - }); -} - -/** 只有这些状态表示仍持有活动回合;Provider 回放的终态不是活动快照。 */ -export function isDirectTurnInProgress( - status: string | null | undefined, -): status is 'accepted' | 'running' | 'streaming' | 'finalizing' { - return ( - status === 'accepted' || - status === 'running' || - status === 'streaming' || - status === 'finalizing' - ); -} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts index f1982c80f..1f44ddda0 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts @@ -217,7 +217,8 @@ export function projectDirectThreadItem( item: DirectThreadItem | null | undefined, ): DirectChatEntry | null { if (!item) return null; - const itemId = item.itemId.trim(); + // 身份是条目唯一的主键:拿不到身份的载荷既不能渲染也不能合并,只丢弃这一条。 + const itemId = typeof item.itemId === 'string' ? item.itemId.trim() : ''; if (!itemId) return null; switch (item.itemType) { diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts index cd5327ef5..20e17ca0e 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts @@ -1,30 +1,12 @@ -import type { - ChatMessage, - GameCreatorDirectToolCall, - TurnStreamItem, -} from '../../app/types'; -import { projectSupervisorChatMessageText } from '../agent-runtime'; +/** + * DirectProject 聊天呈现:把聊天条目切成"用户气泡 / 过程 / 最终回复"三段。 + * + * 输入是唯一一份聊天条目(历史切片 + 运行态事件归并的结果),顺序就是条目顺序; + * 这里只做分区与合并(连续工具合成一块),不认回合身份,也不再从文本长度 / 标点猜切点。 + */ -export function directConversationTurnId(messageId: string | null | undefined) { - const match = /^direct-codex:(.+):(user|assistant|failure)$/.exec( - messageId ?? '', - ); - return match?.[1] ?? null; -} - -export type DirectTurnPresentation = { - key: string; - turnId: string | null; - messages: ChatMessage[]; - notices: ChatMessage[]; - items: TurnStreamItem[]; - calls: GameCreatorDirectToolCall[]; - source: 'stream' | 'messages'; - active: boolean; - transientReply: string; - startedAt: number; - endedAt: number; -}; +import type { ChatMessage } from '../../app/types'; +import type { DirectChatEntry, DirectChatToolCard } from './directThreadChat'; /** 统一 Direct 时间戳为 Unix 毫秒;历史/异常 payload 可能传 Unix 秒。 */ export function normalizeDirectTimestamp(value: number | undefined) { @@ -42,236 +24,251 @@ export function normalizeDirectTimestamp(value: number | undefined) { export const directMessageTimestamp = normalizeDirectTimestamp; -/** 从唯一呈现来源分区,完成后只把最终回复及失败提示留在过程折叠区之外。 */ -export function splitDirectTurnContent(turn: DirectTurnPresentation) { - const assistants = turn.messages.filter( - (message) => message.role === 'assistant', - ); - const isFailureMessage = (message: ChatMessage) => - Boolean( - message.messageId?.endsWith(':failure') && - directConversationTurnId(message.messageId), - ); - const isFailureItem = (item: TurnStreamItem) => - item.id === `text:${turn.turnId}:failure`; - const failed = - assistants.some(isFailureMessage) || turn.items.some(isFailureItem); - const lastText = - !turn.active && !failed - ? turn.items - .filter((item) => item.kind === 'text' && item.text.trim()) - .at(-1) - : undefined; - const lastMessage = !turn.active && !failed ? assistants.at(-1) : undefined; - return { - processItems: turn.items.filter( - (item) => item !== lastText && !isFailureItem(item), - ), - finalItems: turn.items.filter( - (item) => item === lastText || isFailureItem(item), - ), - processMessages: assistants.filter( - (message) => message !== lastMessage && !isFailureMessage(message), - ), - finalMessages: assistants.filter( - (message) => message === lastMessage || isFailureMessage(message), - ), - }; +export type DirectChatBlock = + | { kind: 'user'; key: string; text: string; at: number } + | { + kind: 'assistant'; + key: string; + text: string; + at: number; + notice?: boolean; + } + | { kind: 'reasoning'; key: string; text: string } + | { kind: 'tools'; key: string; calls: DirectChatToolCard[] }; + +export type DirectChatTurn = { + key: string; + /** 用户气泡:顺序即发出顺序。 */ + users: DirectChatBlock[]; + /** 过程块:工具与中间正文按条目顺序,连续工具合成一块。 */ + process: DirectChatBlock[]; + /** 最终回复,以及失败 / 终止这类只存在于运行期的说明。 */ + finals: DirectChatBlock[]; + active: boolean; + startedAt: number; + endedAt: number; +}; + +type DirectChatTurnEntries = { + key: string; + entries: DirectChatEntry[]; + /** 本地乐观用户气泡:还没有任何落盘条目时的用户消息。 */ + localUsers: DirectChatBlock[]; + notices: ChatMessage[]; + active: boolean; +}; + +function blockFromEntry( + entry: DirectChatEntry, + key: string, +): DirectChatBlock | null { + if (entry.kind === 'tool') { + return entry.toolCall + ? { kind: 'tools', key, calls: [entry.toolCall] } + : null; + } + const text = entry.text ?? ''; + if (!text.trim()) return null; + if (entry.kind === 'reasoning') { + return { kind: 'reasoning', key, text }; + } + return entry.role === 'user' + ? { kind: 'user', key, text, at: normalizeDirectTimestamp(entry.at) } + : { + kind: 'assistant', + key, + text, + at: normalizeDirectTimestamp(entry.at), + }; } -/** 完整历史先按身份归属,再决定可见回合;渲染层只消费这一个列表。 */ -export function buildDirectTurnPresentations({ - messages, - visibleMessages, - items, - calls, - activeTurnId, - transientReply, - hasUnloadedHistory = false, -}: { - messages: readonly ChatMessage[]; - visibleMessages: readonly ChatMessage[]; - items: readonly TurnStreamItem[]; - calls: readonly GameCreatorDirectToolCall[]; - activeTurnId?: string | null; - transientReply: string; - hasUnloadedHistory?: boolean; -}): DirectTurnPresentation[] { - const rows = new Map(); - const itemTurnIds = new Map(); - for (const item of items) { - const prefix = `text:${item.turnId}:`; - if (item.kind === 'text' && item.id.startsWith(prefix)) { - itemTurnIds.set(item.id.slice(prefix.length), item.turnId); +/** 连续的工具条目合成一块;中间夹了正文就分块。 */ +function mergeToolBlocks(blocks: DirectChatBlock[]): DirectChatBlock[] { + const merged: DirectChatBlock[] = []; + for (const block of blocks) { + const last = merged[merged.length - 1]; + if (block.kind === 'tools' && last?.kind === 'tools') { + last.calls.push(...block.calls); + continue; } + merged.push( + block.kind === 'tools' ? { ...block, calls: [...block.calls] } : block, + ); } - const ensure = (turnId: string | null, key: string) => { - const existing = rows.get(key); - if (existing) return existing; - const row: DirectTurnPresentation = { + return merged; +} + +function blockFromLocalMessage( + message: ChatMessage, + index: number, +): DirectChatBlock | null { + const text = message.text.trim(); + if (!text) return null; + const key = message.messageId ?? `local:${index}`; + if (message.role === 'user') { + return { + kind: 'user', key, - turnId, - messages: [], - notices: [], - items: [], - calls: [], - source: 'messages', - active: Boolean(turnId && turnId === activeTurnId), - transientReply: '', - startedAt: 0, - endedAt: 0, + text: message.text, + at: normalizeDirectTimestamp(message.updatedAt), }; - rows.set(key, row); - return row; + } + return { + kind: 'assistant', + key, + text: message.text, + at: normalizeDirectTimestamp(message.updatedAt), + notice: true, }; - - // 原始 assistant id 可通过流 item 精确反查。旧用户记录无 turn id 时, - // 只用同一用户记录区间内的这种精确证据关联,不与第 N 个工具回合配对。 - const boundaryTurnIds = new Map(); - let boundary = -1; - messages.forEach((message, index) => { - if (message.role === 'user') boundary = index; - const id = - directConversationTurnId(message.messageId) ?? - itemTurnIds.get(message.messageId ?? ''); - if (id && boundary >= 0 && !boundaryTurnIds.has(boundary)) { - boundaryTurnIds.set(boundary, id); - } - }); - const visible = new Set(visibleMessages); - const visibleKeys = new Set(); - const seenMessages = new Set(); - boundary = -1; - messages.forEach((message, index) => { - if (message.role === 'user') boundary = index; - const turnId = - directConversationTurnId(message.messageId) ?? - itemTurnIds.get(message.messageId ?? '') ?? - boundaryTurnIds.get(boundary) ?? - null; - const key = turnId - ? `turn:${turnId}` - : `history:${boundary < 0 ? index : boundary}`; - const row = ensure(turnId, key); - if (visible.has(message)) visibleKeys.add(key); - if (message.messageId && seenMessages.has(message.messageId)) return; - if (message.messageId) seenMessages.add(message.messageId); - row.messages.push(message); - }); - - const itemsByKey = new Map(); - for (const item of items) { - const key = `${item.turnId}\0${item.id}`; - const previous = itemsByKey.get(key); - if (!previous || item.updatedAt >= previous.updatedAt) - itemsByKey.set(key, item); - } - for (const item of itemsByKey.values()) { - ensure(item.turnId, `turn:${item.turnId}`).items.push(item); - } - const callsByKey = new Map(); - for (const call of calls) { - const key = `${call.turnId}\0${call.id}`; - const previous = callsByKey.get(key); - if (!previous || call.updatedAt >= previous.updatedAt) - callsByKey.set(key, call); - } - for (const call of callsByKey.values()) { - ensure(call.turnId, `turn:${call.turnId}`).calls.push(call); - } - if (activeTurnId) ensure(activeTurnId, `turn:${activeTurnId}`); - - for (const row of rows.values()) { - row.items.sort((a, b) => a.seq - b.seq || a.id.localeCompare(b.id)); - row.calls.sort( - (a, b) => a.startedAt - b.startedAt || a.id.localeCompare(b.id), - ); - const assistants = row.messages.filter( - (message) => message.role === 'assistant', - ); - const rawAssistants = assistants.filter( - (message) => - message.messageId && !directConversationTurnId(message.messageId), - ); - const textIds = new Set( - row.items.filter((item) => item.kind === 'text').map((item) => item.id), - ); - // 精确 item id 才能补齐旧快照正文;无法证明流覆盖完整历史时,整轮使用历史, - // 不同时渲染“部分流 + 全文”,也不按长度比例猜测是否重复。 - const historyCovered = assistants.every( - (message) => - Boolean(directConversationTurnId(message.messageId)) || - Boolean( - message.messageId && - textIds.has(`text:${row.turnId}:${message.messageId}`), - ), - ); - row.source = - row.items.length > 0 && - historyCovered && - (textIds.size > 0 || assistants.length === 0) - ? 'stream' - : 'messages'; - if (row.source === 'stream') { - row.notices = assistants.filter( - (message) => - message.messageId === `direct-codex:${row.turnId}:failure` && - !textIds.has(`text:${row.turnId}:failure`), - ); - const historyById = new Map( - rawAssistants.map((message) => [ - `text:${row.turnId}:${message.messageId}`, - message, - ]), - ); - row.items = row.items.map((item) => { - const history = historyById.get(item.id); - return item.kind === 'text' && history - ? { ...item, text: projectSupervisorChatMessageText(history) } - : item; - }); - } - if (row.source === 'messages' && rawAssistants.length > 0) { - const lastText = row.items.filter((item) => item.kind === 'text').at(-1); - const lastRaw = rawAssistants.at(-1); - // 最后一个原始 item 与流尾身份一致时,GUI 整轮回执不是另一条回复。 - if ( - lastText && - lastRaw && - lastText.id === `text:${row.turnId}:${lastRaw.messageId}` - ) { - row.messages = row.messages.filter( - (message) => - message.messageId !== `direct-codex:${row.turnId}:assistant`, - ); - } - } - if (row.active && row.source === 'messages' && assistants.length === 0) { - row.transientReply = transientReply; - } - const starts = [ - ...row.messages - .filter((message) => message.role === 'user') - .map((message) => directMessageTimestamp(message.updatedAt)), - ...row.items.map((item) => directMessageTimestamp(item.at)), - ...row.calls.map((call) => directMessageTimestamp(call.startedAt)), - ].filter((at) => at > 0); - row.startedAt = starts.length ? Math.min(...starts) : 0; - row.endedAt = Math.max( - 0, - ...row.messages.map((message) => - directMessageTimestamp(message.updatedAt), - ), - ...row.items.map((item) => directMessageTimestamp(item.updatedAt)), - ...row.calls.map((call) => directMessageTimestamp(call.updatedAt)), - ); - } - return [...rows.values()].filter( - (row) => - row.active || - visibleKeys.has(row.key) || - (!hasUnloadedHistory && - row.messages.length === 0 && - (row.items.length > 0 || row.calls.length > 0)), - ); +} + +function newTurn(key: string): DirectChatTurnEntries { + return { + key, + entries: [], + localUsers: [], + notices: [], + active: false, + }; +} + +/** + * 条目 + 运行期本地消息 → 回合列表。 + * + * 每个用户条目开一个新回合;本地用户气泡(乐观发送)也算开新回合;本地 assistant 消息 + * (失败 / 终止说明)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。 + */ +export function buildDirectChatTurns({ + entries, + localMessages = [], + turnRunning = false, +}: { + entries: readonly DirectChatEntry[]; + localMessages?: readonly ChatMessage[]; + turnRunning?: boolean; +}): DirectChatTurn[] { + const turns: DirectChatTurnEntries[] = []; + let current: DirectChatTurnEntries | null = null; + for (const entry of entries) { + if (entry.kind === 'message' && entry.role === 'user') { + current = newTurn(entry.itemId); + turns.push(current); + } + if (!current) { + current = newTurn(`history:${turns.length}`); + turns.push(current); + } + current.entries.push(entry); + } + + const entryIds = new Set(entries.map((entry) => entry.itemId)); + localMessages.forEach((message, index) => { + if (message.messageId && entryIds.has(message.messageId)) return; + if (message.role === 'user') { + const block = blockFromLocalMessage(message, index); + current = newTurn(message.messageId ?? `local:${index}`); + turns.push(current); + if (block) current.localUsers.push(block); + return; + } + if (!current) { + current = newTurn(`history:${turns.length}`); + turns.push(current); + } + current.notices.push(message); + }); + if (current) current.active = turnRunning; + + return turns.map((turn) => { + const lastAssistant = turn.active + ? -1 + : turn.entries.reduce( + (found, entry, index) => + entry.kind === 'message' && entry.role === 'assistant' + ? index + : found, + -1, + ); + const users: DirectChatBlock[] = []; + const process: DirectChatBlock[] = []; + const finals: DirectChatBlock[] = []; + turn.entries.forEach((entry, index) => { + const block = blockFromEntry(entry, `${turn.key}:${entry.itemId}`); + if (!block) return; + if (block.kind === 'user') { + users.push(block); + return; + } + if (index === lastAssistant) { + finals.push(block); + return; + } + process.push(block); + }); + users.push(...turn.localUsers); + turn.notices.forEach((message, index) => { + const block = blockFromLocalMessage(message, index); + if (block) finals.push(block); + }); + const times = [ + ...turn.entries.map((entry) => normalizeDirectTimestamp(entry.at)), + ...turn.notices.map((message) => + normalizeDirectTimestamp(message.updatedAt), + ), + ...users.map((block) => (block.kind === 'user' ? block.at : 0)), + ].filter((at) => at > 0); + const startedAt = times.length ? Math.min(...times) : 0; + const endedAt = [ + ...turn.entries.map((entry) => normalizeDirectTimestamp(entry.at)), + ...turn.notices.map((message) => + normalizeDirectTimestamp(message.updatedAt), + ), + ].reduce((latest, at) => Math.max(latest, at), 0); + return { + key: turn.key, + users, + process: mergeToolBlocks(process), + finals, + active: turn.active, + startedAt, + endedAt, + } satisfies DirectChatTurn; + }); +} + +/** 条目里的用户 / 助手消息转聊天消息(独立窗口只渲染气泡,不渲染工具卡片)。 */ +export function directChatEntriesToMessages( + entries: readonly DirectChatEntry[], +): ChatMessage[] { + return entries.flatMap((entry): ChatMessage[] => { + if (entry.kind !== 'message') return []; + const text = entry.text ?? ''; + if (!text.trim()) return []; + return [ + { + role: entry.role === 'user' ? 'user' : 'assistant', + text, + runtimeOwned: true, + messageId: entry.itemId, + updatedAt: normalizeDirectTimestamp(entry.at), + }, + ]; + }); +} + +/** 同一 messageId 只留一条:条目版本在前,本地运行期消息只补空位。 */ +export function mergeDirectChatMessages( + leading: readonly ChatMessage[], + trailing: readonly ChatMessage[], +): ChatMessage[] { + const seen = new Set(); + const merged: ChatMessage[] = []; + for (const message of [...leading, ...trailing]) { + if (message.messageId) { + if (seen.has(message.messageId)) continue; + seen.add(message.messageId); + } + merged.push(message); + } + return merged; } diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 45f59dcd4..57bb32690 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -110,7 +110,12 @@ async function openDirectCodexSurface(overrides: InvokeOverrides = {}) { renderLauncherProjectsAt('/?launcher'); pickProjectFromLauncher(DYNAMIC_GAME_PROJECT_PATH); const surface = await screen.findByLabelText('陶泥儿项目对话'); - return { invoke, path: DYNAMIC_GAME_PROJECT_PATH, surface }; + return { + invoke, + path: DYNAMIC_GAME_PROJECT_PATH, + surface, + harness: supervisorHarness, + }; } async function submitDirectTurn( @@ -479,9 +484,11 @@ export function registerChatComposerControlTests() { resolve: (value: string) => void; reject: (error: Error) => void; }> = []; - const { invoke, path, surface } = await openDirectCodexSurface({ + const { invoke, path, surface, harness } = await openDirectCodexSurface({ chat_with_game_creator_direct_codex: () => new Promise((resolve, reject) => { + // 回合真正开跑:生命周期事件由订阅下发,界面据此进入"可终止"。 + harness.emitDirectThreadEvents({ type: 'turn.started' }); pending.push({ resolve, reject }); }), cancel_direct_codex_turn: async () => undefined, @@ -502,7 +509,6 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith('cancel_direct_codex_turn', { projectPath: path, - clientTurnId: expect.any(String), }); }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 5dbedafbf..d0e923feb 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -744,6 +744,16 @@ function createProjectSupervisorRuntimeHarness({ let designAgentUpdateHandler: | ((event: { payload: Record }) => void) | null = null; + let directThreadNotifyHandler: + | ((event: { payload: { subscriptionId: string } }) => void) + | null = null; + let directThreadSubscriptionId: string | null = null; + let directThreadSubscriptionSequence = 0; + // 未消费的运行态事件队列:`subscribe` 的 bootstrap 与 `consume` 都从这里取, + // 与 Rust 侧"游标在队尾、事件按序下发"的语义一致。 + let pendingDirectThreadEvents: Array> = []; + let directThreadHistoryItems: Array> = []; + let directThreadLastCompletedItemId: string | null = null; const conversationRecord = ( role: 'user' | 'assistant', @@ -938,6 +948,32 @@ function createProjectSupervisorRuntimeHarness({ const states = runtimeMapLoader ? await runtimeMapLoader() : []; return states.map((state) => runtimeResult(state)); } + if (command === 'subscribe_direct_project_thread') { + directThreadSubscriptionSequence += 1; + directThreadSubscriptionId = `direct-thread-${directThreadSubscriptionSequence}`; + const events = pendingDirectThreadEvents; + pendingDirectThreadEvents = []; + return { + subscriptionId: directThreadSubscriptionId, + lastCompletedItemId: directThreadLastCompletedItemId, + events, + }; + } + if (command === 'consume_direct_project_thread') { + if (String(args?.subscriptionId ?? '') !== directThreadSubscriptionId) { + throw new Error('SUBSCRIPTION_EXPIRED'); + } + const events = pendingDirectThreadEvents; + pendingDirectThreadEvents = []; + return { events }; + } + if (command === 'read_direct_project_history_slice') { + return { + items: [...directThreadHistoryItems], + hasMore: false, + firstItemId: null, + }; + } if (command === 'start_game_creator_supervisor_runtime_task') { if (args?.runProfile !== expectedRunProfile) { throw new Error('unexpected Project Supervisor run profile'); @@ -1120,6 +1156,10 @@ function createProjectSupervisorRuntimeHarness({ designAgentUpdateHandler = handler as unknown as typeof designAgentUpdateHandler; } + if (eventName === 'game-creator-direct-thread-notify') { + directThreadNotifyHandler = + handler as unknown as typeof directThreadNotifyHandler; + } return () => { if (runtimeUpdateHandler === handler) { runtimeUpdateHandler = null; @@ -1133,10 +1173,25 @@ function createProjectSupervisorRuntimeHarness({ if (designAgentUpdateHandler === handler) { designAgentUpdateHandler = null; } + if (directThreadNotifyHandler === handler) { + directThreadNotifyHandler = null; + } }; }, ); + /** 追加运行态事件并唤醒订阅者:bootstrap / consume 共用同一份队列。 */ + const emitDirectThreadEvents = ( + ...events: Array> + ) => { + pendingDirectThreadEvents.push(...events); + if (directThreadSubscriptionId) { + directThreadNotifyHandler?.({ + payload: { subscriptionId: directThreadSubscriptionId }, + }); + } + }; + return { invoke, listen, @@ -1229,6 +1284,55 @@ function createProjectSupervisorRuntimeHarness({ }, }); }, + emitDirectThreadEvents, + /** + * 一轮 Direct 回合的标准事件序列:生命周期 → 落盘用户条目 → 助手正文 → 终态。 + * + * 与 Rust 侧一致:消息身份是 `direct-codex:{turnId}:{role}`,工具条目另配 itemId。 + */ + completeDirectThreadTurn({ + turnId, + reply, + prompt = '', + at = 9_000, + status = 'completed', + }: { + turnId: string; + reply: string; + prompt?: string; + at?: number; + status?: string; + }) { + const events: Array> = [{ type: 'turn.started' }]; + if (prompt.trim()) { + events.push({ + type: 'item.completed', + item: { + itemType: 'message', + itemId: `direct-codex:${turnId}:user`, + role: 'user', + text: prompt, + at, + }, + }); + } + events.push({ + type: 'item.completed', + item: { + itemType: 'message', + itemId: `direct-codex:${turnId}:assistant`, + role: 'assistant', + text: reply, + at: at + 1, + }, + }); + directThreadLastCompletedItemId = `direct-codex:${turnId}:assistant`; + events.push({ type: 'turn.completed', status }); + emitDirectThreadEvents(...events); + }, + setDirectThreadHistory(items: Array>) { + directThreadHistoryItems = [...items]; + }, }; } diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 01e5f217d..98706a90e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1483,6 +1483,11 @@ export function registerHomeProjectCreationTests() { }; } if (command === 'chat_with_game_creator_direct_codex') { + supervisorHarness.completeDirectThreadTurn({ + turnId: String(args?.clientTurnId ?? ''), + prompt: '你好,今天多少号', + reply: '别这么骂自己,具体发生什么了?', + }); return '别这么骂自己,具体发生什么了?'; } return supervisorHarness.invoke(command, args); @@ -1620,6 +1625,11 @@ export function registerHomeProjectCreationTests() { return '角色参考游戏'; } if (command === 'chat_with_game_creator_direct_codex') { + supervisorHarness.completeDirectThreadTurn({ + turnId: String(args?.clientTurnId ?? ''), + prompt: '按这个角色做游戏', + reply: '附件已经进入当前项目。', + }); return '附件已经进入当前项目。'; } return supervisorHarness.invoke(command, args); @@ -2451,7 +2461,11 @@ export function registerHomeProjectCreationTests() { } if (command === 'read_direct_project_history_slice') { expect(args).toEqual({ projectPath, limit: 20 }); - return { items: [...persistedMessages], hasMore: false }; + return { + items: [...persistedMessages], + hasMore: false, + firstItemId: null, + }; } if (command === 'append_local_conversation_message') { throw new Error( @@ -2460,14 +2474,22 @@ export function registerHomeProjectCreationTests() { } if (command === 'chat_with_game_creator_direct_codex') { const clientTurnId = String(args?.clientTurnId ?? ''); - persistedMessages.push(args?.userItem as Record, { - role: 'assistant', - type: 'message', - content: [ - { type: 'output_text', text: 'DIRECT_EXISTING_PROJECT_OK' }, - ], - id: `direct-codex:${clientTurnId}:assistant`, - }); + persistedMessages.push( + { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '继续修改已有项目', + at: 9_000, + }, + { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:assistant`, + role: 'assistant', + text: 'DIRECT_EXISTING_PROJECT_OK', + at: 9_001, + }, + ); return 'DIRECT_EXISTING_PROJECT_OK'; } throw new Error(`unexpected invoke ${command}`); @@ -2537,7 +2559,11 @@ export function registerHomeProjectCreationTests() { } if (command === 'read_direct_project_history_slice') { expect(args).toEqual({ projectPath, limit: 20 }); - return { items: [...persistedMessages], hasMore: false }; + return { + items: [...persistedMessages], + hasMore: false, + firstItemId: null, + }; } if (command === 'append_local_permission_log') { return {}; @@ -2561,17 +2587,23 @@ export function registerHomeProjectCreationTests() { content: [{ type: 'input_text', text: '生成一个游戏' }], }, }); - persistedMessages.push(args?.userItem as Record, { - id: `direct-codex:${clientTurnId}:assistant`, - type: 'message', - role: 'assistant', - content: [ - { - type: 'output_text', - text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', - }, - ], - }); + // Rust 落盘的原始条目在回读时已经是投影后的形状:身份只有一个 itemId。 + persistedMessages.push( + { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '生成一个游戏', + at: 9_000, + }, + { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:assistant`, + role: 'assistant', + text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', + at: 9_001, + }, + ); throw new Error('codex-app-server-error:unauthorized'); } throw new Error(`unexpected invoke ${command}`); @@ -2596,21 +2628,18 @@ export function registerHomeProjectCreationTests() { }); expect(persistedMessages).toEqual([ { - id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), - type: 'message', + itemType: 'message', + itemId: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), role: 'user', - content: [{ type: 'input_text', text: '生成一个游戏' }], + text: '生成一个游戏', + at: 9_000, }, { - id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:assistant$/), - type: 'message', + itemType: 'message', + itemId: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:assistant$/), role: 'assistant', - content: [ - { - type: 'output_text', - text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', - }, - ], + text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', + at: 9_001, }, ]); expect(JSON.stringify(persistedMessages)).not.toContain( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 2438d7657..70c2d799c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -7295,6 +7295,11 @@ export function registerProjectSupervisorSurfaceTests() { return manifest; } if (command === 'chat_with_game_creator_direct_codex') { + supervisorHarness.completeDirectThreadTurn({ + turnId: String(args?.clientTurnId ?? ''), + prompt: '从旧任务状态继续生成,但使用 direct Codex', + reply: 'DIRECT_AFTER_RECONCILIATION_OK', + }); return 'DIRECT_AFTER_RECONCILIATION_OK'; } if (command === 'get_local_game_preview_status') { @@ -7689,7 +7694,7 @@ export function registerProjectSupervisorSurfaceTests() { }); }); - it.skip('opens an existing project without hydrating legacy Supervisor history, then sends consecutive direct Codex turns', async () => { + it('opens an existing project without hydrating legacy Supervisor history, then sends consecutive direct Codex turns', async () => { const projectPath = '/tmp/launcher-supervisor-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -7698,28 +7703,9 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); - let directTurnUpdateHandler: - | ((event: { payload: Record }) => void) - | null = null; - const listen = vi.fn( - async ( - eventName: string, - handler: (event: { payload: Record }) => void, - ) => { - if (eventName === 'game-creator-direct-turn-update') { - directTurnUpdateHandler = handler; - return () => { - if (directTurnUpdateHandler === handler) { - directTurnUpdateHandler = null; - } - }; - } - return supervisorHarness.listen( - eventName, - handler as Parameters[1], - ); - }, - ); + const firstDirectReply = createDeferred(); + const secondDirectReply = createDeferred(); + let directReplyCount = 0; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_design_agent_runtime_mode') return null; @@ -7738,20 +7724,28 @@ export function registerProjectSupervisorSurfaceTests() { return manifest; } if (command === 'chat_with_game_creator_direct_codex') { - return `DIRECT_REPLY:${String(args?.prompt ?? '')}`; + directReplyCount += 1; + return directReplyCount === 1 + ? firstDirectReply.promise + : secondDirectReply.promise; + } + if (command === 'get_local_game_preview_status') { + // 进入项目时按内存 registry 核对一次预览活体(有活体才作为会话预览)。 + return { status: 'stopped', url: null, port: null, root: null }; } return supervisorHarness.invoke(command, args); }, ); window.__TAURI__ = { core: { invoke }, - event: { listen }, + event: { listen: supervisorHarness.listen }, }; renderLauncherProjectsAt('/?launcher'); pickProjectFromLauncher(projectPath); const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + // DirectProject 拥有自己的会话身份:打开项目不得触碰老 Supervisor 会话与运行态。 expect( within(supervisorSurface).queryByText('已恢复的项目总控历史'), ).toBeNull(); @@ -7768,10 +7762,8 @@ export function registerProjectSupervisorSurfaceTests() { ), ).toBe(false); expect(screen.queryByLabelText('专业 Agent 协作状态')).toBeNull(); - expect(screen.queryByRole('status', { name: '自动执行' })).toBeNull(); expect(screen.queryByLabelText('子 Agent 状态栏')).toBeNull(); expect(screen.queryByText('严格审批')).toBeNull(); - expect(screen.queryByRole('button', { name: /审批配置/ })).toBeNull(); expect(screen.queryByLabelText('选择 Agent')).toBeNull(); expect(screen.queryByRole('dialog', { name: 'Agent 对话' })).toBeNull(); expect(supervisorHarness.listen).not.toHaveBeenCalledWith( @@ -7782,53 +7774,31 @@ export function registerProjectSupervisorSurfaceTests() { ([command]) => command === 'read_project_permission_policy', ).length; - const firstDirectReply = createDeferred(); - const secondDirectReply = createDeferred(); - let directReplyCount = 0; - invoke.mockImplementation( - async (command: string, args?: Record) => { - if (command === 'chat_with_game_creator_direct_codex') { - directReplyCount += 1; - return directReplyCount === 1 - ? firstDirectReply.promise - : secondDirectReply.promise; - } - return supervisorHarness.invoke(command, args); - }, - ); - await setComposerText( screen.getByLabelText('陶泥儿对话内容'), '先完成正式客户端玩法拆解', ); - fireEvent.click( - within(supervisorSurface).getByRole('button', { name: '发送' }), + fireEvent.submit( + screen + .getByLabelText('陶泥儿对话内容') + .closest('form') as HTMLFormElement, ); - await waitFor(() => - expect( - within( - within(supervisorSurface).getByTestId('agent-tool-call-group'), - ).getByText('需求已接收'), - ).not.toBeNull(), - ); - const directMessageList = - within(supervisorSurface).getByLabelText('陶泥儿消息'); - Object.defineProperties(directMessageList, { - clientHeight: { configurable: true, value: 180 }, - scrollHeight: { configurable: true, value: 640 }, - scrollTop: { configurable: true, value: 0, writable: true }, + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + { + projectPath, + prompt: '先完成正式客户端玩法拆解', + clientTurnId: expect.any(String), + userItem: { + id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: '先完成正式客户端玩法拆解' }], + }, + }, + ); }); - const waitingProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect(waitingProcessCard.parentElement).toBe(directMessageList); - expect( - within(waitingProcessCard).getByText('正在等待陶泥儿开始'), - ).not.toBeNull(); - expect( - within(waitingProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在等待陶泥儿开始'); const firstDirectCall = invoke.mock.calls.find( ([command]) => command === 'chat_with_game_creator_direct_codex', ); @@ -7837,223 +7807,16 @@ export function registerProjectSupervisorSurfaceTests() { ?.clientTurnId ?? '', ); expect(firstTurnId).not.toBe(''); - expect(firstDirectCall?.[1]).toEqual({ - projectPath, - prompt: '先完成正式客户端玩法拆解', - clientTurnId: firstTurnId, - userItem: { - id: `direct-codex:${firstTurnId}:user`, - type: 'message', - role: 'user', - content: [{ type: 'input_text', text: '先完成正式客户端玩法拆解' }], - }, - }); - await act(async () => { - supervisorHarness.emitProgress('direct', '正在准备安全阶段'); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 0, - status: 'accepted', - activity: 'request-accepted', - updatedAt: 1000, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 1, - status: 'running', - activity: 'preparing', - updatedAt: 1200, - }, - }); - }); - const thinkingProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect(within(thinkingProcessCard).getByText('任务执行中')).not.toBeNull(); - expect( - within(thinkingProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在思考中'); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 2, - status: 'running', - activity: 'file-read', - accumulatedText: `正在读取 game/index.html,${'这是一段非常长的执行内容。'.repeat(12)}用于验证执行详情展开状态不会因为后续事件更新而被重置。`, - updatedAt: 1500, - }, - }); - }); - const runningProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect(within(runningProcessCard).getByText('任务执行中')).not.toBeNull(); - expect( - within(runningProcessCard).getByRole('button', { name: '展开' }), - ).not.toBeNull(); - fireEvent.click( - within(runningProcessCard).getByRole('button', { name: '展开' }), - ); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 3, - status: 'streaming', - activity: 'controlled-tool', - accumulatedText: 'DIRECT_STREAM:先完成正式客户端玩法拆解', - updatedAt: 2000, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 1, - status: 'streaming', - activity: 'file-write', - accumulatedText: '乱序事件不能回退正文', - updatedAt: 1500, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: 'old-direct-turn', - sequence: 99, - status: 'streaming', - activity: 'validation', - accumulatedText: '旧回合不能覆盖正文', - updatedAt: 3000, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath: '/tmp/wrong-direct-project', - turnId: firstTurnId, - sequence: 100, - status: 'streaming', - activity: 'response-finalization', - accumulatedText: '错误项目不能覆盖正文', - updatedAt: 4000, - }, - }); - supervisorHarness.emitProgress('direct', '旧 fallback 不能覆盖精确事件'); - }); - const streamingProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect(within(streamingProcessCard).getByText('回复生成中')).not.toBeNull(); - expect( - within(streamingProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在生成回复'); - expect( - within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, - ).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解'); - expect(directMessageList.scrollTop).toBe(640); - expect(within(supervisorSurface).queryByText(/不能覆盖正文/u)).toBeNull(); - expect( - within(supervisorSurface).queryByText('旧 fallback 不能覆盖精确事件'), - ).toBeNull(); - directMessageList.scrollTop = 100; - fireEvent.scroll(directMessageList); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 4, - status: 'running', - accumulatedText: `正在执行 npm test,${'工具输出很长时需要保持展开状态。'.repeat(10)}`, - updatedAt: 4500, - }, - }); - }); - expect(directMessageList.scrollTop).toBe(100); - expect(within(streamingProcessCard).getByText('任务执行中')).not.toBeNull(); - expect( - within(streamingProcessCard).getByText(/正在执行 npm test/u), - ).not.toBeNull(); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 5, - status: 'running', - activity: 'command-exec', - accumulatedText: `正在执行命令:npm run smoke,${'heartbeat 不得覆盖具体命令。'.repeat(12)}`, - updatedAt: 5000, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 6, - status: 'running', - activity: 'command-exec', - updatedAt: 5100, - }, - }); - }); - expect( - within(streamingProcessCard).getByText(/heartbeat 不得覆盖具体命令/u), - ).not.toBeNull(); - expect(within(streamingProcessCard).queryByText('正在执行命令')).toBeNull(); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 7, - status: 'running', - activity: 'controlled-tool', - accumulatedText: `正在写入文件:game/index.html,${'写入详情需要保持展开状态。'.repeat(12)}`, - updatedAt: 5200, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 8, - status: 'running', - activity: 'controlled-tool', - updatedAt: 5300, - }, - }); - }); - expect( - within(streamingProcessCard).getByText( - /正在写入文件:game\/index\.html/u, - ), - ).not.toBeNull(); - expect(within(streamingProcessCard).queryByText('正在调用工具')).toBeNull(); - expect( - within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, - ).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解'); - expect( - ( - within(streamingProcessCard).getByRole('button', { - name: '收起', - }) as HTMLButtonElement - ).getAttribute('aria-expanded'), - ).toBe('true'); await act(async () => { firstDirectReply.resolve('DIRECT_REPLY:先完成正式客户端玩法拆解'); + supervisorHarness.completeDirectThreadTurn({ + turnId: firstTurnId, + prompt: '先完成正式客户端玩法拆解', + reply: 'DIRECT_REPLY:先完成正式客户端玩法拆解', + }); }); + // 用户条目由落盘事件回填:乐观气泡与事件条目同身份,只渲染一次。 await waitFor(() => { expect( within(supervisorSurface).getAllByText( @@ -8061,16 +7824,8 @@ export function registerProjectSupervisorSurfaceTests() { ), ).toHaveLength(1); expect( - within(supervisorSurface).queryByLabelText('陶泥儿正在执行的内容'), - ).toBeNull(); - expect( - within(supervisorSurface).queryByText( - 'DIRECT_STREAM:先完成正式客户端玩法拆解', - ), - ).toBeNull(); - expect( - within(directMessageList).queryByTestId('agent-tool-call-group'), - ).toBeNull(); + within(supervisorSurface).getAllByText('先完成正式客户端玩法拆解'), + ).toHaveLength(1); }); await waitFor(() => { expect( @@ -8086,8 +7841,10 @@ export function registerProjectSupervisorSurfaceTests() { screen.getByLabelText('陶泥儿对话内容'), '补充:优先复用现有素材', ); - fireEvent.click( - within(supervisorSurface).getByRole('button', { name: '发送' }), + fireEvent.submit( + screen + .getByLabelText('陶泥儿对话内容') + .closest('form') as HTMLFormElement, ); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( @@ -8113,68 +7870,12 @@ export function registerProjectSupervisorSurfaceTests() { ?.clientTurnId ?? '', ); expect(secondTurnId).not.toBe(firstTurnId); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: secondTurnId, - sequence: 0, - status: 'streaming', - activity: 'not-a-public-activity', - accumulatedText: '即将失败的临时正文', - updatedAt: 5000, - }, - }); - }); - const failedTurnProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect( - within(failedTurnProcessCard).getByText('回复生成中'), - ).not.toBeNull(); - expect( - within(failedTurnProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在生成回复'); - expect( - within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, - ).toBe('即将失败的临时正文'); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: secondTurnId, - sequence: 1, - status: 'failed', - activity: 'none', - accumulatedText: '失败事件不得保留这段正文', - updatedAt: 5100, - }, - }); - }); - expect( - within(supervisorSurface).queryByLabelText('陶泥儿实时回复'), - ).toBeNull(); - const failedStatusProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect( - within(failedStatusProcessCard).getByText('处理失败'), - ).not.toBeNull(); - expect( - within(failedStatusProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在记录失败原因'); + + // 失败回合:运行期说明只留在界面,不进历史;输入盒必须回到可用态。 await act(async () => { secondDirectReply.reject(new Error('模拟 direct 失败')); }); await waitFor(() => { - expect( - within(supervisorSurface).queryByLabelText('陶泥儿实时回复'), - ).toBeNull(); - expect( - within(directMessageList).queryByTestId('agent-tool-call-group'), - ).toBeNull(); expect( ( within(supervisorSurface).getByRole('button', { @@ -8182,6 +7883,9 @@ export function registerProjectSupervisorSurfaceTests() { }) as HTMLButtonElement ).disabled, ).toBe(false); + expect( + within(supervisorSurface).queryByRole('button', { name: '终止' }), + ).toBeNull(); }); expect( invoke.mock.calls.filter( @@ -8198,8 +7902,7 @@ export function registerProjectSupervisorSurfaceTests() { ([command]) => command === 'chat_with_game_creator_direct_codex', ), ).toHaveLength(2); - // 进入项目时按内存 registry 核对一次预览活体(有活体才作为会话预览)。 - // 这里只核验、不启动:开始预览仍然只能由用户动作或 Agent 触发。 + // 首次进入项目时核验一次预览活体;开始预览仍然只能由用户动作或 Agent 触发。 expect( invoke.mock.calls.filter( ([command]) => command === 'get_local_game_preview_status', @@ -8217,7 +7920,7 @@ export function registerProjectSupervisorSurfaceTests() { ).toHaveLength(policyReadCountBeforeChat + 1); }); - it.skip('renders one collapsed tool-call group per turn from the direct turn event and keeps it after the turn completes', async () => { + it('renders one collapsed tool-call group per turn from thread events and keeps it after the turn completes', async () => { const projectPath = '/tmp/launcher-tool-call-card-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -8226,28 +7929,23 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); - let directTurnUpdateHandler: - | ((event: { payload: Record }) => void) - | null = null; - const listen = vi.fn( - async ( - eventName: string, - handler: (event: { payload: Record }) => void, - ) => { - if (eventName === 'game-creator-direct-turn-update') { - directTurnUpdateHandler = handler; - return () => { - if (directTurnUpdateHandler === handler) { - directTurnUpdateHandler = null; - } - }; - } - return supervisorHarness.listen( - eventName, - handler as Parameters[1], - ); + // 打开项目时已有上一轮对话:这一轮的块必须落在消息流末尾,而不是被锚到别人头上。 + supervisorHarness.setDirectThreadHistory([ + { + itemType: 'message', + itemId: 'direct-codex:turn-existing:user', + role: 'user', + text: '先做一个主菜单', + at: 900, }, - ); + { + itemType: 'message', + itemId: 'direct-codex:turn-existing:assistant', + role: 'assistant', + text: '上一轮已完成', + at: 1000, + }, + ]); const directReply = createDeferred(); const invoke = vi.fn( async (command: string, args?: Record) => { @@ -8269,35 +7967,6 @@ export function registerProjectSupervisorSurfaceTests() { if (command === 'chat_with_game_creator_direct_codex') { return directReply.promise; } - if (command === 'read_direct_tool_calls') { - return []; - } - if (command === 'read_direct_project_conversation') { - // 打开项目时有一轮已落盘的对话:工具调用卡要挂在它的 assistant 消息之后。 - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [ - { - schemaVersion: 'agc-direct-project-context.v1', - role: 'user', - content: '做一个跑酷游戏', - agentId: null, - messageId: 'direct-codex:turn-existing:user', - updatedAt: 900, - }, - { - schemaVersion: 'agc-direct-project-context.v1', - role: 'assistant', - content: '上一轮已完成', - agentId: null, - messageId: 'direct-codex:turn-existing:assistant', - updatedAt: 1000, - }, - ], - }; - } if (command === 'get_local_game_preview_status') { return { status: 'stopped', url: null, port: null, root: null }; } @@ -8306,7 +7975,7 @@ export function registerProjectSupervisorSurfaceTests() { ); window.__TAURI__ = { core: { invoke }, - event: { listen }, + event: { listen: supervisorHarness.listen }, }; renderLauncherProjectsAt('/?launcher'); @@ -8342,31 +8011,33 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(clientTurnId).not.toBe(''); - // 实时增量:同一条 `item-command` 从 running 走到 completed,`item-file` 由下一条事件带出。 + // 运行中:命令开始执行。同一 itemId 的后续事件就地更新,不新起一张卡。 await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: clientTurnId, - sequence: 1, - status: 'running', - activity: 'command-exec', - updatedAt: 1000, - toolCalls: [ - { - schemaVersion: 'agc-tool-call.v1', - id: 'item-command', - kind: 'command', - title: '执行命令', - summary: 'npm run build', - status: 'running', - detail: { command: 'npm run build' }, - startedAt: 1000, - updatedAt: 1000, - }, - ], + supervisorHarness.emitDirectThreadEvents( + { type: 'turn.started' }, + { + type: 'item.completed', + item: { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '做一个跑酷游戏', + at: 995, + }, }, - }); + { + type: 'item.started', + item: { + itemType: 'commandExecution', + itemId: 'item-command', + command: 'npm run build', + output: null, + status: 'inProgress', + exitCode: null, + at: 1000, + }, + }, + ); }); // 一回合一个折叠块(默认折叠):块头是按钮,正文用 `hidden` 收起。 const runningGroups = within(supervisorSurface).getAllByTestId( @@ -8385,52 +8056,49 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(runningBody).not.toBeNull(); expect(runningBody?.hasAttribute('hidden')).toBe(true); - // 此刻只应看到本回合的那一条命令:不在对话里、也不属于当前回合的 turnId - // 会在 App 侧被过滤掉,不会漂在消息流里。 expect(runningHead.textContent).toContain('1 个命令'); + // 命令完成 + 文件变更:同一回合两块合成一个块,顺序按 startedAt。 await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: clientTurnId, - sequence: 2, - status: 'running', - activity: 'file-write', - updatedAt: 1100, - toolCalls: [ - { - schemaVersion: 'agc-tool-call.v1', - id: 'item-command', - kind: 'command', - title: '执行命令', - summary: 'npm run build', - status: 'completed', - detail: { command: 'npm run build', output: 'build ok' }, - startedAt: 1000, - updatedAt: 1100, - }, - { - schemaVersion: 'agc-tool-call.v1', - id: 'item-file', - kind: 'file_change', - title: '编辑 1 个文件', - summary: 'game/src/hero.ts', - status: 'failed', - detail: { - changes: [ - { path: 'game/src/hero.ts', kind: 'update' }, - { path: 'game/src/hero.ts', kind: 'delete' }, - ], - }, - startedAt: 1050, - updatedAt: 1100, - }, - ], + supervisorHarness.emitDirectThreadEvents( + { + type: 'item.completed', + item: { + itemType: 'commandExecution', + itemId: 'item-command', + command: 'npm run build', + output: 'build ok', + status: 'completed', + exitCode: 0, + at: 1100, + }, }, - }); + { + type: 'item.started', + item: { + itemType: 'fileChange', + itemId: 'item-file', + changes: [ + { path: 'game/src/hero.ts', kind: 'update' }, + { path: 'game/src/hero.ts', kind: 'delete' }, + ], + at: 1050, + }, + }, + { + type: 'item.completed', + item: { + itemType: 'fileChange', + itemId: 'item-file', + changes: [ + { path: 'game/src/hero.ts', kind: 'update' }, + { path: 'game/src/hero.ts', kind: 'delete' }, + ], + at: 1100, + }, + }, + ); }); - // 同一回合的两条工具只产生一个块;同回合内按 startedAt 升序。 await waitFor(() => { expect( within(supervisorSurface).getAllByTestId('agent-tool-call-group'), @@ -8439,18 +8107,11 @@ export function registerProjectSupervisorSurfaceTests() { const group = within(supervisorSurface).getAllByTestId( 'agent-tool-call-group', )[0] as HTMLElement; - expect(group.getAttribute('data-status')).toBe('failed'); const groupHead = within(group).getByTestId('agent-tool-call-group-head'); expect(groupHead.textContent).toContain('已执行 1 个命令、1 个文件变更'); // 块头右侧是总用时(min(startedAt) → max(updatedAt)),并在 data-* 上暴露原始毫秒。 expect(group.getAttribute('data-duration-ms')).toBe('100'); - expect(groupHead.textContent).toContain('用时 1秒'); - // 同一回合的用户消息时间(App 提交时写入)→ 显示「发送 → 结束」。 - expect( - groupHead.querySelector('.agent-tool-call-group-time')?.textContent, - ).toMatch(/^\d{2}:\d{2}:\d{2} → \d{2}:\d{2}:\d{2}$/); - // 实时回合的块落在消息流末尾:该回合 assistant 消息还没落盘, - // 所以它在上一条 assistant 消息之后,而不是被锚到别人头上。 + // 实时回合的块落在消息流末尾:该回合还没有正文,不依赖任何锚点消息。 const liveChildren = Array.from((messageList as HTMLElement).children); expect( liveChildren.findIndex((node) => node === group), @@ -8461,29 +8122,23 @@ export function registerProjectSupervisorSurfaceTests() { await waitFor(() => { expect(groupHead.getAttribute('aria-expanded')).toBe('true'); }); - const groupBody = group.querySelector( - `#${groupHead.getAttribute('aria-controls')}`, - ); - expect(groupBody?.hasAttribute('hidden')).toBe(false); const rows = within(group).getAllByTestId('agent-tool-call-row'); expect(rows).toHaveLength(2); expect(rows.map((row) => row.getAttribute('data-kind'))).toEqual([ 'command', 'file_change', ]); - // 同一 id 只渲染一次,状态由 completed 覆盖;failed 行标「失败」。 - expect(rows[0]?.getAttribute('data-status')).toBe('completed'); - expect(rows[1]?.getAttribute('data-status')).toBe('failed'); const commandRow = rows[0] as HTMLElement; const fileRow = rows[1] as HTMLElement; - expect(within(commandRow).getByText('npm run build')).not.toBeNull(); - expect(within(fileRow).getByText('game/src/hero.ts')).not.toBeNull(); - expect(within(fileRow).getByText('失败')).not.toBeNull(); + expect( + within(commandRow).getAllByText('npm run build').length, + ).toBeGreaterThan(0); + expect( + within(fileRow).getAllByText('game/src/hero.ts').length, + ).toBeGreaterThan(0); // 每行右侧显示该工具自己的耗时(`startedAt` → `updatedAt`)。 expect(commandRow.getAttribute('data-duration-ms')).toBe('100'); - expect(within(commandRow).getByText('0.1s')).not.toBeNull(); expect(fileRow.getAttribute('data-duration-ms')).toBe('50'); - expect(within(fileRow).getByText('0.1s')).not.toBeNull(); // 行可二级展开:默认折叠,展开后看到命令 / 路径 + 变更类型 / 输出。 const commandRowHead = within(commandRow).getByRole('button'); @@ -8517,10 +8172,14 @@ export function registerProjectSupervisorSurfaceTests() { expect(within(fileDetail as HTMLElement).getByText('修改')).not.toBeNull(); expect(within(fileDetail as HTMLElement).getByText('删除')).not.toBeNull(); - // 回合结束:assistant 消息落盘后,块移到该回合 assistant 消息**之前**(工具在上、答复在下), - // 不重复、不消失。 + // 回合结束:正文进历史,工具块收进「执行过程」折叠区,不重复、不消失。 await act(async () => { directReply.resolve('DIRECT_REPLY:做一个跑酷游戏'); + supervisorHarness.completeDirectThreadTurn({ + turnId: clientTurnId, + reply: 'DIRECT_REPLY:做一个跑酷游戏', + at: 1500, + }); }); await waitFor(() => { expect( @@ -8536,24 +8195,12 @@ export function registerProjectSupervisorSurfaceTests() { const settledGroups = within(supervisorSurface).getAllByTestId( 'agent-tool-call-group', ); - const children = Array.from((messageList as HTMLElement).children); - const assistantIndex = children.findIndex( - (node) => - node.classList.contains('message--assistant') && - node.textContent?.includes('DIRECT_REPLY:做一个跑酷游戏'), + const processSection = + within(supervisorSurface).getByTestId('turn-process'); + expect(processSection.contains(settledGroups[0] ?? null)).toBe(true); + expect(processSection.textContent).not.toContain( + 'DIRECT_REPLY:做一个跑酷游戏', ); - expect(assistantIndex).toBeGreaterThanOrEqual(0); - const settledGroupIndex = children.findIndex( - (node) => node === settledGroups[0], - ); - expect(settledGroupIndex).toBeGreaterThanOrEqual(0); - // 块在该回合的 user 消息与 assistant 消息之间:紧邻 assistant 消息之前。 - expect(settledGroupIndex).toBeLessThan(assistantIndex); - expect(settledGroupIndex).toBe(assistantIndex - 1); - expect( - children[settledGroupIndex - 1]?.classList.contains('message--user'), - ).toBe(true); - // 重新挂载后的块回到默认折叠;键盘可达:块头就是按钮(Tab 可达), // Enter/Space 触发的 click 同步 aria-expanded 与 hidden。 const settledGroup = settledGroups[0] as HTMLElement; @@ -8582,8 +8229,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(settledBody?.hasAttribute('hidden')).toBe(true); }); }); - - it.skip('reads the persisted tool-call history whenever a project is opened', async () => { + it('renders persisted tool cards from the history slice whenever a project is opened', async () => { const projectPath = '/tmp/launcher-tool-call-persisted-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -8592,7 +8238,42 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); - let readToolCallCount = 0; + // 历史切片的条目已经是投影形状:工具的调用与输出共用同一个 itemId。 + supervisorHarness.setDirectThreadHistory([ + { + itemType: 'message', + itemId: 'direct-codex:turn-persisted:user', + role: 'user', + text: '做一个小球弹跳游戏', + at: 1000, + }, + { + itemType: 'function_call', + itemId: 'persisted-command', + name: 'exec_command', + arguments: 'npm run build', + at: 1000, + }, + { + itemType: 'function_call_output', + itemId: 'persisted-command', + output: 'built in 500ms', + at: 1500, + }, + { + itemType: 'fileChange', + itemId: 'persisted-file', + changes: [{ path: 'game/src/hero.ts', kind: 'add' }], + at: 1500, + }, + { + itemType: 'message', + itemId: 'direct-codex:turn-persisted:assistant', + role: 'assistant', + text: '上一轮的答复', + at: 2000, + }, + ]); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_design_agent_runtime_mode') return null; @@ -8610,62 +8291,6 @@ export function registerProjectSupervisorSurfaceTests() { if (command === 'get_local_game_manifest') { return manifest; } - if (command === 'read_direct_project_conversation') { - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [ - { - schemaVersion: 'agc-direct-project-context.v1', - role: 'user', - content: '做一个小球弹跳游戏', - agentId: null, - messageId: 'direct-codex:turn-persisted:user', - updatedAt: 1000, - }, - { - schemaVersion: 'agc-direct-project-context.v1', - role: 'assistant', - content: '上一轮的答复', - agentId: null, - messageId: 'direct-codex:turn-persisted:assistant', - updatedAt: 2000, - }, - ], - }; - } - if (command === 'read_direct_tool_calls') { - readToolCallCount += 1; - return [ - { - schemaVersion: 'agc-tool-call.v1', - id: 'persisted-command', - turnId: 'turn-persisted', - kind: 'command', - title: '执行命令', - summary: 'npm run build', - status: 'completed', - detail: { command: 'npm run build' }, - startedAt: 1000, - updatedAt: 1500, - }, - { - schemaVersion: 'agc-tool-call.v1', - id: 'persisted-file', - turnId: 'turn-persisted', - kind: 'file_change', - title: '编辑 1 个文件', - summary: 'game/src/hero.ts', - status: 'completed', - detail: { - changes: [{ path: 'game/src/hero.ts', kind: 'add' }], - }, - startedAt: 1500, - updatedAt: 2000, - }, - ]; - } if (command === 'get_local_game_preview_status') { return { status: 'stopped', url: null, port: null, root: null }; } @@ -8681,15 +8306,15 @@ export function registerProjectSupervisorSurfaceTests() { pickProjectFromLauncher(projectPath); const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); - // 打开项目时必须回读一次工具调用历史:这是「刷新后卡片仍在」的数据来源。 - await waitFor(() => { - expect(readToolCallCount).toBeGreaterThanOrEqual(1); - }); - expect(invoke).toHaveBeenCalledWith('read_direct_tool_calls', { + // 打开项目时读一屏历史:这是「刷新后卡片仍在」的数据来源,工具卡片由前端投影。 + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { projectPath, + limit: 20, }); - // 回读到的工具调用挂在自己回合的 assistant 消息**之前**(工具在上、答复在下), - // 默认折叠、展开后行数 = 回读到的工具数。 + expect( + await within(supervisorSurface).findByText('做一个小球弹跳游戏'), + ).not.toBeNull(); + // 工具在「执行过程」折叠区里,最终回复在折叠区外:默认折叠、展开后行数 = 工具数。 const persistedGroups = await within(supervisorSurface).findAllByTestId( 'agent-tool-call-group', ); @@ -8702,38 +8327,115 @@ export function registerProjectSupervisorSurfaceTests() { expect(persistedHead.textContent).toContain( '已执行 1 个命令、1 个文件变更', ); - // 回读回来的时间戳一样能算总用时与「发送 → 结束」。 - expect(persistedGroup.getAttribute('data-duration-ms')).toBe('1000'); - expect(persistedHead.textContent).toContain('用时 1秒'); - expect( - persistedHead.querySelector('.agent-tool-call-group-time')?.textContent, - ).toMatch(/^\d{2}:\d{2} → \d{2}:\d{2}$/); - const messageList = supervisorSurface.querySelector( - '.project-supervisor-message-list', - ) as HTMLElement; - const children = Array.from(messageList.children); - const assistantIndex = children.findIndex((node) => - node.classList.contains('message--assistant'), - ); - const groupIndex = children.findIndex((node) => node === persistedGroup); - expect(assistantIndex).toBeGreaterThanOrEqual(0); - expect(groupIndex).toBe(assistantIndex - 1); + const processSection = + within(supervisorSurface).getByTestId('turn-process'); + expect(processSection.contains(persistedGroup)).toBe(true); + expect(processSection.textContent).not.toContain('上一轮的答复'); fireEvent.click(persistedHead); const persistedRows = within(persistedGroup).getAllByTestId( 'agent-tool-call-row', ); expect(persistedRows).toHaveLength(2); - // 每行右侧是该工具自己的耗时(0.5s / 0.5s)。 - expect(persistedRows[0]?.getAttribute('data-duration-ms')).toBe('500'); + // 行标题与展开后的命令详情都写着这条命令,这里只要求它确实出现在这一行里。 expect( - within(persistedRows[0] as HTMLElement).getByText('0.5s'), - ).not.toBeNull(); + within(persistedRows[0] as HTMLElement).getAllByText('npm run build'), + ).toHaveLength(2); }); - it('renders the running turn tool-call group in a fresh chat that has no anchored assistant message', async () => { + it('drains a notify that lands before the subscribe receipt so the running turn is not stranded', async () => { + // 真实竞态:`subscribe` 在 Rust 侧已经注册好 subscriber 并开始通知,但前端还没拿到 + // subscriptionId。此时的通知不能白丢——拿到回执后必须补一次 consume,否则整个回合会卡在队列里。 + const projectPath = '/tmp/launcher-notify-before-subscribe-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-notify-before-subscribe-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + // 订阅回执被挂住:Rust 侧已经建好订阅(因此事件会带通知),前端还在等回执。 + const subscribeReceipt = createDeferred(); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-notify-before-subscribe-game', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'subscribe_direct_project_thread') { + const result = await supervisorHarness.invoke(command, args); + await subscribeReceipt.promise; + return result; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + pickProjectFromLauncher(projectPath); + + const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('subscribe_direct_project_thread', { + projectPath, + }); + }); + + // 回执还没回到前端,整轮事件已经到了:通知此刻拿不到 subscriptionId,只能记账。 + await act(async () => { + supervisorHarness.emitDirectThreadEvents( + { type: 'turn.started' }, + { + type: 'item.completed', + item: { + itemType: 'message', + itemId: 'direct-codex:notify-window:assistant', + role: 'assistant', + text: '回执前就到达的回复', + at: 900, + }, + }, + { type: 'turn.completed', status: 'completed' }, + ); + }); + expect( + within(supervisorSurface).queryByText('回执前就到达的回复'), + ).toBeNull(); + + // 回执到达:前端补一次 consume,把队列里的事件取回来。 + await act(async () => { + subscribeReceipt.resolve(); + }); + expect( + await within(supervisorSurface).findByText('回执前就到达的回复'), + ).not.toBeNull(); + // 补取之后不再有残留的忙碌态。 + expect( + within(supervisorSurface).queryByRole('button', { name: '终止' }), + ).toBeNull(); + }); + + it('renders the running turn tool-call group in a fresh chat from the thread subscription', async () => { // 空对话首轮:历史为空,消息列表里只有 App 落的默认问候(`描述你的想法,或 @ 引用素材`,没有 messageId)。 - // 这种回合没有任何「可锚」的 assistant 消息,块必须兜底落在消息列表末尾, - // 而不是等到回合结束、assistant 消息带上 messageId 之后才出现。 + // 运行中的回合没有任何落盘正文,工具块必须靠订阅事件直接出现在消息列表末尾, + // 而不是等到回合结束、历史切片回来之后才出现。 const projectPath = '/tmp/launcher-tool-call-fresh-turn-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -8742,25 +8444,6 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); - let directTurnUpdateHandler: - | ((event: { payload: Record }) => void) - | null = null; - const listen = vi.fn( - async ( - eventName: string, - handler: (event: { payload: Record }) => void, - ) => { - if (eventName === 'game-creator-direct-turn-update') { - directTurnUpdateHandler = handler; - return () => { - if (directTurnUpdateHandler === handler) { - directTurnUpdateHandler = null; - } - }; - } - return supervisorHarness.listen(eventName, handler); - }, - ); const directReply = createDeferred(); const invoke = vi.fn( async (command: string, args?: Record) => { @@ -8782,18 +8465,6 @@ export function registerProjectSupervisorSurfaceTests() { if (command === 'chat_with_game_creator_direct_codex') { return directReply.promise; } - if (command === 'read_direct_tool_calls') { - return []; - } - if (command === 'read_direct_project_conversation') { - // 真正的空对话:没有任何历史消息,App 会落一条没有 messageId 的默认问候。 - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [], - }; - } if (command === 'get_local_game_preview_status') { return { status: 'stopped', url: null, port: null, root: null }; } @@ -8802,7 +8473,7 @@ export function registerProjectSupervisorSurfaceTests() { ); window.__TAURI__ = { core: { invoke }, - event: { listen }, + event: { listen: supervisorHarness.listen }, }; renderLauncherProjectsAt('/?launcher'); @@ -8842,29 +8513,32 @@ export function registerProjectSupervisorSurfaceTests() { expect(clientTurnId).not.toBe(''); await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: clientTurnId, - sequence: 1, - status: 'running', - activity: 'command-exec', - updatedAt: 1400, - toolCalls: [ - { - schemaVersion: 'agc-tool-call.v1', - id: 'fresh-turn-command', - kind: 'command', - title: '执行命令', - summary: 'npm run build', - status: 'running', - detail: { command: 'npm run build' }, - startedAt: 1000, - updatedAt: 1400, - }, - ], + // 订阅事件就是运行态:生命周期 + 用户条目 + 命令开始执行。 + supervisorHarness.emitDirectThreadEvents( + { type: 'turn.started' }, + { + type: 'item.completed', + item: { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '做一个跑酷游戏', + at: 900, + }, }, - }); + { + type: 'item.started', + item: { + itemType: 'commandExecution', + itemId: 'fresh-turn-command', + command: 'npm run build', + output: null, + status: 'inProgress', + exitCode: null, + at: 1000, + }, + }, + ); }); // 回合进行中:块已经可见,且落在消息列表末尾(默认问候之后),不依赖任何带 messageId 的 assistant 消息。 @@ -8874,7 +8548,6 @@ export function registerProjectSupervisorSurfaceTests() { expect(runningGroups).toHaveLength(1); const runningGroup = runningGroups[0] as HTMLElement; expect(runningGroup.getAttribute('data-status')).toBe('running'); - expect(runningGroup.getAttribute('data-duration-ms')).toBe('400'); const runningHead = within(runningGroup).getByTestId( 'agent-tool-call-group-head', ); @@ -8890,14 +8563,51 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(runningRows).toHaveLength(1); expect(runningRows[0]?.getAttribute('data-kind')).toBe('command'); - expect(runningRows[0]?.getAttribute('data-duration-ms')).toBe('400'); + + // 同一条命令完成:同一个 itemId 就地更新,不新起一张卡,耗时按 startedAt → updatedAt 计算。 + await act(async () => { + supervisorHarness.emitDirectThreadEvents({ + type: 'item.completed', + item: { + itemType: 'commandExecution', + itemId: 'fresh-turn-command', + command: 'npm run build', + output: 'built in 400ms', + status: 'completed', + exitCode: 0, + at: 1400, + }, + }); + }); + await waitFor(() => { + expect( + within(supervisorSurface).getAllByTestId('agent-tool-call-group'), + ).toHaveLength(1); + }); + const settledRunningGroup = within(supervisorSurface).getAllByTestId( + 'agent-tool-call-group', + )[0] as HTMLElement; + expect(settledRunningGroup.getAttribute('data-duration-ms')).toBe('400'); + fireEvent.click( + within(settledRunningGroup).getByTestId('agent-tool-call-group-head'), + ); + const settledRunningRows = within(settledRunningGroup).getAllByTestId( + 'agent-tool-call-row', + ); + expect(settledRunningRows).toHaveLength(1); + expect(settledRunningRows[0]?.getAttribute('data-duration-ms')).toBe('400'); expect( - within(runningRows[0] as HTMLElement).getByText('0.4s'), + within(settledRunningRows[0] as HTMLElement).getByText('0.4s'), ).not.toBeNull(); - // 回合结束:assistant 消息落盘后块回到它之前,且**只有一份**(末尾兜底不留下重复块)。 + // 回合结束:assistant 正文进历史,工具块收进「执行过程」折叠区,且**只有一份**。 await act(async () => { directReply.resolve('DIRECT_REPLY:空对话首轮'); + supervisorHarness.completeDirectThreadTurn({ + turnId: clientTurnId, + reply: 'DIRECT_REPLY:空对话首轮', + at: 1500, + }); }); await waitFor(() => { expect( @@ -8928,9 +8638,8 @@ export function registerProjectSupervisorSurfaceTests() { chatProjectAssets: [], composerRef: createRef(), directCodex: true, - directStatus: null, - directProcessDetail: '', - directProcessKey: '', + directEntries: [], + directTurnRunning: false, hiddenConversationCount: 0, messagesRef: createRef(), needsUserInput: false, diff --git a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts deleted file mode 100644 index 6229b26b5..000000000 --- a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - directThreadHistoryItemsToMessages, - isDirectTurnInProgress, -} from '../src/features/project-workspace/directThreadEvents'; -import type { DirectThreadItem } from '../src/features/project-workspace/directThreadItemProjection'; - -describe('Direct 回合状态与历史时间', () => { - it('终态和空状态不恢复为活动回合', () => { - for (const status of [ - 'completed', - 'failed', - 'interrupted', - null, - undefined, - ]) { - expect(isDirectTurnInProgress(status)).toBe(false); - } - for (const status of ['accepted', 'running', 'streaming', 'finalizing']) { - expect(isDirectTurnInProgress(status)).toBe(true); - } - }); - it('条目时间来自搬运后的 at,前端不自己补造时间', () => { - const items = [ - { - itemId: 'direct-codex:turn:user', - itemType: 'message', - role: 'user', - text: '帮我修改游戏', - at: 1_800_000_000_001, - }, - { - itemId: 'direct-codex:turn:assistant', - itemType: 'message', - role: 'assistant', - text: '好的', - at: 0, - }, - ] satisfies DirectThreadItem[]; - expect(directThreadHistoryItemsToMessages(items)[0]?.updatedAt).toBe( - 1_800_000_000_001, - ); - // Rust 拿不到条目时间时给 0,前端不得用"当前时间"补造。 - expect(directThreadHistoryItemsToMessages(items)[1]?.updatedAt).toBe(0); - expect(items[0]).not.toHaveProperty('recordedAt'); - }); -}); diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts index f47f263a2..a887f02c2 100644 --- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts @@ -1,236 +1,220 @@ import { describe, expect, it } from 'vitest'; -import type { - ChatMessage, - GameCreatorDirectToolCall, - TurnStreamItem, -} from '../src/app/types'; +import type { ChatMessage } from '../src/app/types'; +import type { DirectChatEntry } from '../src/features/project-workspace/directThreadChat'; import { - buildDirectTurnPresentations, + buildDirectChatTurns, + directChatEntriesToMessages, directMessageTimestamp, + mergeDirectChatMessages, normalizeDirectTimestamp, - splitDirectTurnContent, } from '../src/features/project-workspace/directTurnPresentation'; -const user = (turn: string): ChatMessage => ({ +const userEntry = ( + itemId: string, + at = 1_800_000_000_000, +): DirectChatEntry => ({ + itemId, + kind: 'message', role: 'user', - text: '只读检查', - messageId: `direct-codex:${turn}:user`, + text: `问题 ${itemId}`, + at, }); -const assistant = (id: string, text = '完整回复'): ChatMessage => ({ + +const assistantEntry = ( + itemId: string, + text: string, + at = 1_800_000_001_000, +): DirectChatEntry => ({ + itemId, + kind: 'message', role: 'assistant', text, - messageId: id, + at, }); -const text = (turnId: string, id: string, seq = 1): TurnStreamItem => ({ - schemaVersion: 'agc-turn-stream.v1', - kind: 'text', - turnId, - id: `text:${turnId}:${id}`, - text: '前缀', - seq, - at: 1_800_000_000_000, - updatedAt: 1_800_000_000_001, -}); -const tool = (turnId: string): GameCreatorDirectToolCall => ({ - schemaVersion: 'agc-tool-call.v1', - id: 'call', - turnId, - kind: 'mcp_tool', - title: '调用工具', - summary: '读取', - status: 'completed', - detail: { command: '{"path":"file"}', output: '内容', changes: [] }, - startedAt: 1_800_000_000_000, - updatedAt: 1_800_000_000_001, -}); -const build = ( - messages: ChatMessage[], - items: TurnStreamItem[], - options: Partial[0]> = {}, -) => - buildDirectTurnPresentations({ - messages, - visibleMessages: messages, - items, - calls: [], - transientReply: '', - ...options, - }); -describe('DirectProject 回合唯一呈现', () => { +const toolEntry = ( + itemId: string, + at = 1_800_000_000_500, +): DirectChatEntry => ({ + itemId, + kind: 'tool', + role: null, + text: null, + at, + toolCall: { + schemaVersion: 'agc-tool-call.v1', + id: itemId, + kind: 'command', + title: '执行命令', + summary: 'npm run build', + status: 'completed', + detail: { command: 'npm run build' }, + startedAt: at, + updatedAt: at, + }, +}); + +const reasoningEntry = ( + itemId: string, + text = '先看目录', + at = 1_800_000_000_200, +): DirectChatEntry => ({ + itemId, + kind: 'reasoning', + role: null, + text, + at, +}); + +const localUser = (text: string, messageId?: string): ChatMessage => ({ + role: 'user', + text, + ...(messageId ? { messageId } : {}), + updatedAt: 1_800_000_002_000, +}); + +const localNotice = (text: string, messageId?: string): ChatMessage => ({ + role: 'assistant', + text, + ...(messageId ? { messageId } : {}), + updatedAt: 1_800_000_002_500, +}); + +describe('DirectProject 聊天分区', () => { it('把 Unix 秒时间戳归一化为毫秒,已有毫秒值保持不变', () => { expect(normalizeDirectTimestamp(1_800_000_000)).toBe(1_800_000_000_000); expect(normalizeDirectTimestamp(1_800_000_000_000)).toBe(1_800_000_000_000); - expect(directMessageTimestamp(1_800_000_000)).toBe(1_800_000_000_000); - }); - - it('历史仍有未加载切片时,不把那些回合的工具流追加到当前页末尾', () => { - const rows = build( - [user('one')], - [text('old', 'raw-old'), text('one', 'raw')], - { - hasUnloadedHistory: true, - }, - ); - expect(rows.map((row) => row.turnId)).toEqual(['one']); - const active = build([], [text('live', 'raw')], { - hasUnloadedHistory: true, - activeTurnId: 'live', - }); - expect(active.map((row) => row.turnId)).toEqual(['live']); - }); - it('尚未落盘用户的实时回合也只产生一个 owner,不另建 live 与 unmapped 出口', () => { - const rows = build([], [text('one', 'raw')], { - activeTurnId: 'one', - transientReply: '同一份累计回复', - calls: [tool('one')], - }); - expect(rows).toHaveLength(1); - expect(rows[0].source).toBe('stream'); - expect(rows[0].transientReply).toBe(''); - expect(rows[0].calls).toHaveLength(1); - }); - it('同一身份用户重复快照不增加回合,不丢用户正文', () => { - const rows = build([user('one'), user('one')], [text('one', 'raw')]); - expect(rows).toHaveLength(1); - expect(rows[0].messages).toHaveLength(1); - expect(rows[0].messages[0].role).toBe('user'); - }); - it('用原始 item 身份补齐旧前缀,最终合成消息不另占正文出口', () => { - const rows = build( - [user('one'), assistant('raw'), assistant('direct-codex:one:assistant')], - [text('one', 'raw')], - ); - expect(rows).toHaveLength(1); - expect(rows[0].source).toBe('stream'); - expect(rows[0].items).toHaveLength(1); - expect(rows[0].items[0].text).toBe('完整回复'); - }); - it('先关联完整历史再分页,首条可见 assistant 不会失去用户归属', () => { - const messages = [ - user('old'), - assistant('old-raw'), - user('one'), - assistant('raw'), - ]; - const rows = build(messages, [text('old', 'old-raw'), text('one', 'raw')], { - visibleMessages: messages.slice(3), - }); - expect(rows.map((row) => row.turnId)).toEqual(['one']); - expect(rows[0].messages[0].messageId).toBe('direct-codex:one:user'); - }); - it('纯文本旧回合不挤占之后有工具的回合身份', () => { - const rows = build( - [user('old'), assistant('plain'), user('one'), assistant('raw')], - [text('one', 'raw')], - { calls: [tool('one')] }, - ); - expect(rows.map((row) => row.turnId)).toEqual(['old', 'one']); - expect(rows[0].source).toBe('messages'); - expect(rows[0].calls).toEqual([]); - expect(rows[1].source).toBe('stream'); - }); - it('没有流的原始 assistant 与工具仍归属一个回合,输入输出保留', () => { - const rows = build([user('one'), assistant('raw')], [], { - calls: [tool('one')], - }); - expect(rows).toHaveLength(1); - expect(rows[0].source).toBe('messages'); - expect(rows[0].calls[0].detail.output).toBe('内容'); - }); - it('无法证明流覆盖历史时整轮回退,不能混画部分流与全文', () => { - const rows = build( - [user('one'), assistant('raw-a'), assistant('raw-b')], - [text('one', 'raw-b')], - ); - expect(rows[0].source).toBe('messages'); - expect( - rows[0].messages.filter((message) => message.role === 'assistant'), - ).toHaveLength(2); - }); - it('不同回合的相同文字不是重复消息,不做文本去重', () => { - const rows = build( - [user('one'), assistant('raw-one'), user('two'), assistant('raw-two')], - [text('one', 'raw-one'), text('two', 'raw-two')], - ); - expect(rows).toHaveLength(2); - expect(rows.every((row) => row.source === 'stream')).toBe(true); - }); - it('乱序重复快照按 seq 排列且不增加 item', () => { - const first = text('one', 'a'); - const last = text('one', 'b', 3); - const marker: TurnStreamItem = { - ...text('one', 'unused', 2), - kind: 'tool', - id: 'tool:one:call', - callId: 'call', - }; - const rows = build([user('one')], [last, marker, first, first]); - expect(rows[0].items.map((item) => item.seq)).toEqual([1, 2, 3]); - }); - it('完成后中间文本和工具进入过程,最终回复独立且分区不重复', () => { - const marker: TurnStreamItem = { - ...text('one', 'unused', 2), - kind: 'tool', - id: 'tool:one:call', - callId: 'call', - }; - const row = build( - [user('one')], - [text('one', 'start'), marker, text('one', 'final', 3)], - )[0]; - const parts = splitDirectTurnContent(row); - expect(parts.processItems.map((item) => item.id)).toEqual([ - 'text:one:start', - 'tool:one:call', - ]); - expect(parts.finalItems.map((item) => item.id)).toEqual(['text:one:final']); - const active = splitDirectTurnContent({ ...row, active: true }); - expect(active.processItems).toHaveLength(3); - expect(active.finalItems).toEqual([]); - }); - it('失败回合保留失败提示,不把末尾过程文本提升为最终回复', () => { - const row = build( - [user('one'), assistant('direct-codex:one:failure', '失败')], - [text('one', 'progress'), { ...text('one', 'failure', 2), text: '失败' }], - )[0]; - const parts = splitDirectTurnContent(row); - expect(parts.processItems.map((item) => item.id)).toEqual([ - 'text:one:progress', - ]); - expect(parts.finalItems.map((item) => item.id)).toEqual([ - 'text:one:failure', - ]); - }); - it('无流历史只保留最后一条回复,发送时间不从工具推断', () => { - const row = build( - [user('one'), assistant('progress'), assistant('final')], - [], - )[0]; - const parts = splitDirectTurnContent(row); - expect(parts.processMessages.map((message) => message.messageId)).toEqual([ - 'progress', - ]); - expect(parts.finalMessages.map((message) => message.messageId)).toEqual([ - 'final', - ]); expect(directMessageTimestamp(undefined)).toBe(0); expect(directMessageTimestamp(Number.MAX_VALUE)).toBe(0); expect(directMessageTimestamp(1_800_000_000_001)).toBe(1_800_000_000_001); }); - it('无流活动回合的累计文本只属于该回合,持久 assistant 到达即接管', () => { + + it('每个用户条目开一个新回合,顺序就是条目顺序', () => { + const turns = buildDirectChatTurns({ + entries: [ + userEntry('u1'), + assistantEntry('a1', '第一轮答复'), + userEntry('u2', 1_800_000_010_000), + assistantEntry('a2', '第二轮答复', 1_800_000_011_000), + ], + }); + expect(turns.map((turn) => turn.key)).toEqual(['u1', 'u2']); + expect(turns[0]?.users[0]).toMatchObject({ text: '问题 u1' }); + expect(turns[0]?.finals[0]).toMatchObject({ text: '第一轮答复' }); + expect(turns[1]?.finals[0]).toMatchObject({ text: '第二轮答复' }); + expect(turns[0]?.startedAt).toBe(1_800_000_000_000); + expect(turns[0]?.endedAt).toBe(1_800_000_001_000); + }); + + it('已结束的回合只把最后一条助手正文当最终回复,中间正文与工具进过程', () => { + const turns = buildDirectChatTurns({ + entries: [ + userEntry('u1'), + reasoningEntry('r1'), + assistantEntry('a-mid', '我先看一下项目'), + toolEntry('t1'), + assistantEntry('a-final', '改好了', 1_800_000_002_000), + ], + }); + expect(turns[0]?.process.map((block) => block.key)).toEqual([ + 'u1:r1', + 'u1:a-mid', + 'u1:t1', + ]); + expect(turns[0]?.finals.map((block) => block.key)).toEqual(['u1:a-final']); + }); + + it('连续工具合成一块,夹了正文就另起一块', () => { + const turns = buildDirectChatTurns({ + entries: [ + userEntry('u1'), + toolEntry('t1'), + toolEntry('t2', 1_800_000_000_600), + assistantEntry('a-mid', '继续', 1_800_000_000_700), + toolEntry('t3', 1_800_000_000_800), + ], + turnRunning: true, + }); + const process = turns[0]?.process ?? []; + expect(process.map((block) => block.kind)).toEqual([ + 'tools', + 'assistant', + 'tools', + ]); + expect(process[0]).toMatchObject({ + kind: 'tools', + calls: [{ id: 't1' }, { id: 't2' }], + }); + expect(process[2]).toMatchObject({ kind: 'tools', calls: [{ id: 't3' }] }); + }); + + it('运行中的回合:过程不折叠,最后的助手正文仍在过程里流式显示', () => { + const turns = buildDirectChatTurns({ + entries: [ + userEntry('u1'), + assistantEntry('a-live', '正在写'), + toolEntry('t1', 1_800_000_001_100), + ], + turnRunning: true, + }); + expect(turns[0]?.active).toBe(true); + expect(turns[0]?.finals).toEqual([]); + expect(turns[0]?.process.map((block) => block.kind)).toEqual([ + 'assistant', + 'tools', + ]); + }); + + it('运行期失败说明挂到当前回合末尾,不当成最终回复', () => { + const turns = buildDirectChatTurns({ + entries: [userEntry('u1'), assistantEntry('a1', '正文')], + localMessages: [localNotice('后台任务失败:端口被占用')], + }); + expect(turns).toHaveLength(1); + expect(turns[0]?.finals.map((block) => block.kind)).toEqual([ + 'assistant', + 'assistant', + ]); + expect(turns[0]?.finals[1]).toMatchObject({ + notice: true, + text: '后台任务失败:端口被占用', + }); + }); + + it('乐观用户气泡自成回合,已落盘的同一身份不重复渲染', () => { + const turns = buildDirectChatTurns({ + entries: [userEntry('u1'), assistantEntry('a1', '答复')], + localMessages: [localUser('问题 u1', 'u1'), localUser('第二条')], + turnRunning: true, + }); + expect(turns.map((turn) => turn.key)).toEqual(['u1', 'local:1']); + expect(turns[1]?.users).toHaveLength(1); + expect(turns[1]?.active).toBe(true); + }); + + it('历史无用户条目时也保留一个回合承载正文', () => { + const turns = buildDirectChatTurns({ + entries: [assistantEntry('a1', '只有回复')], + }); + expect(turns).toHaveLength(1); + expect(turns[0]?.users).toEqual([]); + expect(turns[0]?.finals[0]).toMatchObject({ text: '只有回复' }); + }); + + it('条目转消息只保留用户 / 助手正文,并按身份去重', () => { + const messages = directChatEntriesToMessages([ + userEntry('u1'), + reasoningEntry('r1'), + toolEntry('t1'), + assistantEntry('a1', '答复'), + ]); + expect(messages.map((message) => message.messageId)).toEqual(['u1', 'a1']); expect( - build([user('one')], [], { - activeTurnId: 'one', - transientReply: '回复', - })[0].transientReply, - ).toBe('回复'); - expect( - build([user('one'), assistant('direct-codex:one:assistant')], [], { - activeTurnId: 'one', - transientReply: '回复', - })[0].transientReply, - ).toBe(''); + mergeDirectChatMessages(messages, [ + { role: 'assistant', text: '答复', messageId: 'a1' }, + localNotice('只在运行期的失败说明', 'local-1'), + ]).map((message) => message.messageId), + ).toEqual(['u1', 'a1', 'local-1']); }); }); -- 2.52.0 From 5eba53a5ccf0b034139ced5c1c237d242bb4d383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 22:16:24 +0800 Subject: [PATCH 56/92] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20DirectProject=20?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E7=9C=9F=E7=9B=B8=E6=BA=90=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E7=9A=84=E6=96=87=E6=A1=A3=E7=BB=93=E8=AE=BA=20-=20=E4=B8=BB?= =?UTF-8?q?=E8=A7=84=E8=8C=83=EF=BC=9A=E8=BF=90=E8=A1=8C=E6=80=81=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E6=94=B9=E4=B8=BA=E4=B8=8B=E5=8F=91=E4=B8=8E=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=88=87=E7=89=87=E5=90=8C=E5=BD=A2=E7=9A=84=E8=84=B1?= =?UTF-8?q?=E6=95=8F=E5=8E=9F=E5=A7=8B=E6=9D=A1=E7=9B=AE=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=8F=AA=E7=BB=99=E7=B1=BB=E5=9E=8B=E4=B8=8E=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=20-=20=E4=B8=BB=E8=A7=84=E8=8C=83=E4=B8=8E=20ADR=20?= =?UTF-8?q?=E5=86=99=E6=B8=85=E5=9B=9E=E6=89=A7=E7=AB=9E=E6=80=81=EF=BC=9A?= =?UTF-8?q?=E5=9B=9E=E6=89=A7=E5=89=8D=E5=88=B0=E8=BE=BE=E7=9A=84=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E5=8F=AA=E8=83=BD=E8=AE=B0=E6=AC=A0=E8=B4=A6=EF=BC=8C?= =?UTF-8?q?=E5=9B=9E=E6=89=A7=E5=88=B0=E8=BE=BE=E5=90=8E=E5=BF=85=E9=A1=BB?= =?UTF-8?q?=E8=A1=A5=E4=B8=80=E6=AC=A1=20consume=20-=20ADR=20=E4=B8=8E?= =?UTF-8?q?=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=E8=AF=B4=E6=98=8E=20list?= =?UTF-8?q?=5Fgame=5Fcreator=5Fdirect=5Factive=5Fturns=20=E6=9C=89?= =?UTF-8?q?=E6=84=8F=E4=BF=9D=E7=95=99=EF=BC=8C=E6=9C=8D=E5=8A=A1=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E8=BF=90=E8=A1=8C=E4=B8=AD=E9=A1=B9=E7=9B=AE=E5=88=97?= =?UTF-8?q?=E8=A1=A8=20-=20=E9=87=8C=E7=A8=8B=E7=A2=91=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E6=A0=87=E5=87=86=E6=8C=89=E8=87=AA=E5=8A=A8=E5=8C=96=E8=AF=81?= =?UTF-8?q?=E6=8D=AE=E5=8B=BE=E9=80=89=EF=BC=8C=E8=BF=90=E8=A1=8C=E6=97=B6?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E4=B8=8E=E5=B7=B2=E7=9F=A5=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E7=BC=BA=E5=8F=A3=E5=8D=95=E7=8B=AC=E6=A0=87=E6=B3=A8=20-=20?= =?UTF-8?q?=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=E8=A1=A5=20export=5Fbindin?= =?UTF-8?q?gs=20=E5=90=8E=E9=9C=80=20prettier=20=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E3=80=81=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=9B=B4=E5=90=8D=E4=B8=8E=E7=AB=9E=E6=80=81=E9=A3=8E=E9=99=A9?= =?UTF-8?q?=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...irectProject对话历史单一事实源-2026-09-16.md | 4 +-- ...划】DirectProject聊天真相源收敛-2026-09-16.md | 13 +++++---- ...碑】DirectProject聊天真相源收敛-2026-09-16.md | 28 ++++++++++--------- ...ectProject Codex原始历史与异常恢复-2026-09-04.md | 8 +++--- 4 files changed, 28 insertions(+), 25 deletions(-) diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index 8e8a43ea8..afde4e259 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -24,7 +24,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 前端不保留增量缓冲:`item.delta` 直接追加到运行态条目的正文(正文只增不减)。`turn.completed` 把当前回合的运行态条目并入历史再清空,条目既不消失也不重复。 - 活动回合的唯一判据是「出现过 `turn.started` 且未出现 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 - 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 -- `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不额外补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。 +- `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不需要为了取这批事件再补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。唯一例外是回执竞态:Rust 侧一注册完 subscriber 就开始 `notify`,前端却要等回执才知道自己的 `subscriptionId`,这段窗口内的通知只能记成欠账,回执到达后立刻补一次 `consume` 取回,否则该回合的尾部事件会卡在队列里等一个可能永不出现的下一次通知。 - 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 - 思考过程与工具活动同样从运行态事件与历史条目推断,界面展示保持不变。 - 运行态事件必须自足:`item.started` / `item.completed` 携带与历史切片同形的完整**脱敏原始条目**,前端按归一后的 `itemId` 合并快照得到运行中与完成态;不提供按 `itemId` 单点取快照的接口。 @@ -33,7 +33,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 生命周期锚点独立于 replay 队列保存(队列会回收 `cleanable` 事件,新订阅的游标又在队尾,回收后无法反推"最新回合是 started 还是 completed"),`subscribe` 必须返回最新的一条 `turn.started` / `turn.completed`,否则新订阅无法判定回合是否仍在运行。 - 前端工具卡片形状是 `Omit`:聊天卡片不再有回合身份,`tool-calls.jsonl` 的持久化形状仍保留 `turnId`(DirectRuntime 的账本没动)。 - 未识别 item 类型由 Rust 原样透传(只带类型与身份,Rust 侧留 TODO),当前由前端投影丢弃:哪些类型可见属于前端决策,不回 Rust 加白名单。 -- 删除范围包含前端对 `read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns`、`read_direct_project_history`(整份历史)与 `game-creator-direct-turn-update` 事件的调用;保留分页用的历史切片读取(`read_direct_project_history_slice`),且该切片从文件尾反向扫描。 +- 删除范围包含前端对 `read_direct_turn_stream`、`read_direct_tool_calls`、`read_direct_project_history`(整份历史)与 `game-creator-direct-turn-update` 事件的调用;保留分页用的历史切片读取(`read_direct_project_history_slice`),且该切片从文件尾反向扫描。`list_game_creator_direct_active_turns` 有意保留:它服务首页跨页面的「运行中的项目」列表,不是聊天框读路径。 - 前端删掉 `directTurnStream` / `directToolCalls` / 活动回合快照接管 / 瞬时应答文本这些并行状态,聊天视图只由 reducer 状态投影(含工具卡片)。 - 失败与中止说明只在运行期显示,不写进 `project.jsonl`;页面重进后不再出现。 - 历史切片的 `firstItemId` 是分页锚点,始终取 `project.jsonl` 里的原始 item id,与归一后的条目身份分开计算。 diff --git a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md index 37e4fbf57..ab8c8f5c5 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md @@ -3,7 +3,7 @@ | 字段 | 值 | | --- | --- | | Milestone | `docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md` | -| Status | ready | +| Status | implemented | | Owner | Codex | ## 修改边界 @@ -18,17 +18,17 @@ 2. 条目身份归一:进队列前收敛成一个 `itemId`,事件 envelope 与前端形状里都不出现第二个 id 概念;历史切片的 `firstItemId` 继续取文件里的原始 item id。 3. 删掉回合身份:`turn.started` 无载荷、`turn.completed{status}`,条目 / 增量 / 请求 / 生命周期锚点都不带 turn id;队列 `append` 直接收 `DirectThreadEvent`,`seq` 内部自算。 4. 思考正文流式:`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta` 产出 `item.delta{kind:"reasoning"}`;plan 文本与命令输出保持活动状态。 -5. 前端收敛为单一 reducer:`subscribe` 返回的 bootstrap 事件就是已暂存的运行态,游标已经在队尾,前端直接 reduce 这批事件即可(不需要再补一次 `consume`);此后只由 notify 唤醒 `consume`。合并规则只保留"先到定形、后到补空白"(正文只增不减、工具状态允许从 running 升级到终态),`item.delta` 直接追加到运行态条目正文,删掉 `deltaText` 缓冲,`turn.completed` 把运行态条目并入历史再清空。 +5. 前端收敛为单一 reducer:`subscribe` 返回的 bootstrap 事件就是已暂存的运行态,游标已经在队尾,前端直接 reduce 这批事件即可(不需要为了拿这批事件再补一次 `consume`);此后只由 notify 唤醒 `consume`。唯一例外是回执竞态:Rust 注册完 subscriber 就开始通知,而前端要等回执才知道 `subscriptionId`,这段时间到达的通知只能记欠账,回执到达后立刻补一次 `consume`(否则整轮最后一个事件之后可能再无通知,事件会卡死在队列里)。合并规则只保留"先到定形、后到补空白"(正文只增不减、工具状态允许从 running 升级到终态),`item.delta` 直接追加到运行态条目正文,删掉 `deltaText` 缓冲,`turn.completed` 把运行态条目并入历史再清空。 6. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,历史读取改为从文件尾反向扫描;锚点按原始 item id 推进,切片无可见条目时自动连拉(上限 5 页)。 7. App.tsx 接线:订阅 + 立即 reduce bootstrap + notify 唤醒 consume,聊天视图改由 reducer 状态投影(含工具卡片),删除 Direct 回合事件订阅与 `directTurnStream` / `directToolCalls` 状态。 -8. 删除只服务旧读路径的命令与前端调用(`read_direct_project_history`、`read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns`),DirectRuntime 自己的写入保留。 +8. 删除只服务旧读路径的命令与前端调用(`read_direct_project_history`、`read_direct_turn_stream`、`read_direct_tool_calls`),DirectRuntime 自己的写入保留。`list_game_creator_direct_active_turns` 是唯一的例外并有意保留:它服务首页跨页面的「运行中的项目」列表(`WorkspaceLauncher` / `directActiveTurns.ts`),不是聊天框读路径。 9. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态。 ## 验证命令 1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_thread -- --nocapture` -2. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`(生成 `src/features/project-workspace/generated/`) -3. `npx vitest run apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directThreadEvents.test.ts` +2. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`(生成 `src/features/project-workspace/generated/`),随后用 `prettier --write` 格式化生成目录,避免未格式化的 ts-rs 输出混进提交 +3. `npx vitest run apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts` 4. `npx vitest run apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` 5. TypeScript 类型检查与 ESLint(范围同前次 DirectProject 迁移)。 6. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` @@ -37,6 +37,7 @@ - 条目 id 空间不一致会让活跃条目永远收不到完成事件:第 2 步的归一必须在 Rust 出口完成;前端不得再拿到两个 id。 - 事件 payload 变大(命令输出、文件变更明细):继续沿用既有截断上限,并观察 Thread Manager 单 thread 字节上限是否被提前触发。 -- 订阅过期:以重新 `subscribe` + bootstrap 后立即 `consume` + 原子替换处理,需要单测覆盖;不引入定时轮询。 +- 订阅过期:以重新 `subscribe` + 原子替换处理,需要单测覆盖;不引入定时轮询。 +- 回执竞态:`subscribe` 回执到达前产生的 `notify` 拿不到订阅身份,必须记欠账并在回执到达后补一次 `consume`;已有专门用例 `drains a notify that lands before the subscribe receipt` 钉住,改坏会让整轮事件卡死。 - 合并规则退化为"先到定形"后,若某类条目只有输出没有调用条目,该输出不显示;这是有意取舍,先观察再决定是否补规则。 - 回滚点:每一步都保持"新源可用即不依赖旧源"的中间态可回退;不允许出现新源未启用而旧源已删除的提交。 diff --git a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md index ba4ed5368..560d1fb8d 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md @@ -3,7 +3,7 @@ | 字段 | 值 | | --- | --- | | Version | 1.0 | -| Status | proposed | +| Status | implemented(自动化验收通过,运行时验收待补) | | Date | 2026-09-16 | | Parent Spec | `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` | @@ -50,18 +50,20 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 ## 验收标准 -- [ ] 一次回合内,实时渲染的文本段与工具卡片顺序,与回合结束后重进项目看到的顺序一致。 -- [ ] 回合进行中终止并重启进程后重进项目:已落盘的部分文本与工具卡片按原顺序出现,且界面不显示忙碌态。 -- [ ] 进程存活期间的页面重进(含切走再切回)能恢复运行中回合,并允许终止。 -- [ ] 历史分页在切片内全部是不可显示条目时仍能继续向前,不出现锚点停滞。 -- [ ] 聊天视图不再读取 `turn-stream.jsonl` / `tool-calls.jsonl` / Direct 回合事件 / 活动回合快照;`read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns` 不再被前端调用。 -- [ ] 同一工具调用在实时与回读各只出现一张卡片(两个 id 空间按归一后的 `itemId` 对齐)。 -- [ ] 前端聊天状态里不再出现第二个 id 概念与任何回合身份字段;事件解析统一来自 ts-rs 生成绑定。 -- [ ] 思考正文在回合进行中即可见,且不进入活动状态文本。 -- [ ] 订阅过期后重新 `subscribe` 并原子替换状态,不重复渲染已完成的条目。 +- [x] 一次回合内,实时渲染的文本段与工具卡片顺序,与回合结束后重进项目看到的顺序一致。【自动化:`project-development.suite.ts` 空对话首轮 + 历史切片工具卡片用例】 +- [ ] 回合进行中终止并重启进程后重进项目:已落盘的部分文本与工具卡片按原顺序出现,且界面不显示忙碌态。【待真实 app-server 运行时验收】 +- [ ] 进程存活期间的页面重进(含切走再切回)能恢复运行中回合,并允许终止。【待真实 app-server 运行时验收】 +- [x] 历史分页在切片内全部是不可显示条目时仍能继续向前,不出现锚点停滞。【自动化:`direct_project_history` 尾部回扫与分页锚点用例】 +- [x] 聊天视图不再读取 `turn-stream.jsonl` / `tool-calls.jsonl` / Direct 回合事件 / 活动回合快照;`read_direct_turn_stream` 与 `read_direct_tool_calls` 命令已删除。`list_game_creator_direct_active_turns` **有意保留**:它服务首页跨页面的「运行中的项目」列表(`WorkspaceLauncher` / `directActiveTurns.ts`),不属于聊天框读路径。 +- [x] 同一工具调用在实时与回读各只出现一张卡片(两个 id 空间按归一后的 `itemId` 对齐)。【自动化:reducer「先到定形、后到补空」合并单测 + 工具卡片渲染用例】 +- [x] 前端聊天状态里不再出现第二个 id 概念与任何回合身份字段;事件解析统一来自 ts-rs 生成绑定。【自动化:`directThreadChat` / `directTurnPresentation` 单测 + `cargo test export_bindings` 生成绑定无差异】 +- [x] 思考正文在回合进行中即可见,且不进入活动状态文本。【自动化:`directThreadChat` 的 `item.delta{kind:"reasoning"}` 单测】 +- [x] 订阅过期后重新 `subscribe` 并原子替换状态,不重复渲染已完成的条目。【自动化:reducer 过期重订阅单测】 +- [x] 通知先于 `subscribe` 回执到达时不丢事件:前端记欠账,回执到达后立刻补一次 `consume`。【自动化:`drains a notify that lands before the subscribe receipt` 用例】 ## 证据要求 -- 自动化:Thread Manager 事件契约测试、原始条目搬运与脱敏测试、ts-rs 绑定生成(`cargo test export_bindings`)、聊天 reducer 单测(bootstrap / consume / 过期重订阅 / 残回合 / 先到优先合并 / 回合回收)、历史分页锚点测试、前端渲染测试(含此前跳过的工具卡片用例)。 -- 运行时:真实 app-server 会话下的新回合、杀进程重开、页面重进、分页与终止。 -- 边界:订阅过期、事件重复与乱序、不可显示切片、无 `turn.completed` 的残回合、工具输出超长截断与脱敏。 +- 自动化(已跑):`cargo test direct_thread`(25 条)、`cargo test direct_project_history`(20 条)、`cargo test export_bindings`(生成绑定与工作区无差异)、`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(468 passed / 17 skipped)、`directThreadChat.test.ts` + `directTurnPresentation.test.ts` 单测、TypeScript 类型检查、ESLint 与 `prettier`。 +- 运行时(待补):真实 app-server 会话下的新回合、杀进程重开、页面重进、分页与终止。 +- 边界:订阅过期、回执竞态、不可显示切片、无 `turn.completed` 的残回合、工具输出超长截断与脱敏。 +- 已知环境缺口:本容器内 `rehype-highlight` 未安装且远端拉包被策略禁用,`ChatMarkdownMessage` / `AgentMessageContent` 与 `chatPromptPolish` / `clientApi` 的环境型失败与本里程碑改动无关。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index 14ce22931..29e7165c6 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -1,6 +1,6 @@ # DirectProject Codex 原始历史与异常恢复 -更新时间:`2026-09-15` +更新时间:`2026-09-16` ## 目标 @@ -18,7 +18,7 @@ DirectProject 只使用 `.agent/conversations/project.jsonl` 作为对话历史 `project.jsonl` 的 `payload` 必须是未经改写的 Responses item。AGC 前端 user input 先以 canonical user message item 形式写入;发送给 app-server 前由 Rust 投影为 Codex 可接受的 `message` item,AGC 私有 content part 不会穿透到 wire。Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存运行态 delta/started 事件。 -Thread Manager 的运行态事件是另一份内存协议:app-server 通知先经过安全投影,再只发送 item 类型、item ID、delta 文本和 turn 终态等必要字段;不得把完整 item、工具参数或调用结果转发到前端。完整 item 仍只通过上述 JSONL 历史读取。 +Thread Manager 的运行态事件是另一份内存协议:app-server 通知先经过安全投影(挑字段、脱敏、截断、路径归一),再按与历史切片同形的脱敏原始条目(`itemType`、唯一 `itemId`、正文或工具明细)加 delta 文本与 turn 终态下发;搬运层不生成卡片形状,也不把未经脱敏的完整 item 转发到前端。注入 Codex 用的完整 item 仍只从上述 JSONL 历史读取。 DirectProject 自己的写侧只写新格式:格式切换(#282)时仍会写旧行的路径已收口——显式 Codex 返回只落在自己的 journal `.agent/conversations/codex-responses.jsonl`,不再投影进 `project.jsonl`。 @@ -96,9 +96,9 @@ readHistory(threadId, { beforeItemId?, limit }) -> { `consume` 不接收或返回 cursor。每个 subscriber 在 Rust 内部持有自己的 cursor,并在加锁的临界区内完成过期判断、读取和 cursor 前进。前端只持有 `subscriptionId` 与 reducer state。并发 `consume` 不重复返回同一批事件。 -`notify` 只负责唤醒,不携带事件、cursor 或持久化状态。前端收到通知后调用 `consume`;通知可合并、重复或丢失,事件完整性由 `consume` 保证。 +`notify` 只负责唤醒,不携带事件、cursor 或持久化状态。前端收到通知后调用 `consume`;通知可合并、重复或丢失,事件完整性由 `consume` 保证——但前提是前端确实唤醒了 `consume`,见下条的回执竞态。 -`subscribe` 在同一个边界内先把新 subscriber 的游标钉在当时的队尾,再收集 bootstrap 的运行态事件,因此 bootstrap 返回的那批事件**就是**该 subscriber 此刻应处理的事件:前端直接 reduce 它们即可,不存在"再补一次 `consume` 才能补齐竞态窗口"的步骤。此后不再轮询,也不设任何定时 `consume`——唤醒只由 `notify` 负责。用定时器兜底既自举不了(判断"有活动回合"本身依赖事件),也把唤醒机制变成两套。 +`subscribe` 在同一个边界内先把新 subscriber 的游标钉在当时的队尾,再收集 bootstrap 的运行态事件,因此 bootstrap 返回的那批事件**就是**该 subscriber 此刻应处理的事件:前端直接 reduce 它们即可,不存在"先补一次 `consume` 才能拿到已暂存事件"的步骤。第一个例外只有回执竞态:Rust 侧一注册完 subscriber 就开始 `notify`,而前端要等回执到达才知道自己的 `subscriptionId`,这段窗口内的通知拿不到订阅身份。前端因此必须记一笔欠账,回执到达后立刻补一次 `consume` 取回那批事件;否则事件会卡在队列里等下一次通知,而一次回合的最后一个事件之后可能再也没有通知。除此之外不轮询,也不设任何定时 `consume`——唤醒只由 `notify` 负责。用定时器兜底既自举不了(判断"有活动回合"本身依赖事件),也把唤醒机制变成两套。 首屏历史不通过"读取整份对话"的命令获取:`subscribe` 返回的 `lastCompletedItemId` 就是首屏锚点,前端据此调用 `readHistory` 取最近的切片,再按滚动或按钮继续向前分页。系统不提供返回整份对话历史的命令。 -- 2.52.0 From d874a13a925454877aef34a54c258d9e2c1ef030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 22:41:54 +0800 Subject: [PATCH 57/92] =?UTF-8?q?=E8=A1=A5=E8=AE=B0=20Node=2026=20?= =?UTF-8?q?=E4=B8=8B=20vitest=20jsdom=20=E7=94=A8=E4=BE=8B=E7=9A=84=20loca?= =?UTF-8?q?lStorage=20=E5=9D=91=20-=20pitfalls=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=EF=BC=9ANode=2026=20=E7=9A=84=E5=AE=9E=E9=AA=8C=E6=80=A7=20glo?= =?UTF-8?q?balThis.localStorage=20=E4=BC=9A=E9=A1=B6=E6=8E=89=20jsdom=20?= =?UTF-8?q?=E7=9A=84=20Storage=EF=BC=8C=E5=AF=BC=E8=87=B4=E5=8F=AA?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=20jsdom=20=E7=9A=84=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E8=AF=BB=E5=88=B0=20undefined=20-=20=E7=BB=99=E5=87=BA?= =?UTF-8?q?=E8=B7=91=E6=B3=95=EF=BC=9ANODE=5FOPTIONS=3D--localstorage-file?= =?UTF-8?q?=3D=E2=80=A6=20=E5=90=8E=20clientApi=20=E4=B8=8E=20chatPromptPo?= =?UTF-8?q?lish=20=E5=85=B1=2026=20=E9=A1=B9=E8=BD=AC=E7=BB=BF=20-=20?= =?UTF-8?q?=E9=87=8C=E7=A8=8B=E7=A2=91=E8=AF=81=E6=8D=AE=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=B8=BA=E5=85=A8=E9=87=8F=E7=BB=93=E6=9E=9C=EF=BC=9A125=20fil?= =?UTF-8?q?es=20/=201676=20passed=20/=2017=20skipped=EF=BC=8Ctsc=20exit=20?= =?UTF-8?q?0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../【里程碑】DirectProject聊天真相源收敛-2026-09-16.md | 4 ++-- docs/project-memory/shared-memory/pitfalls.md | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md index 560d1fb8d..e04466315 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md @@ -63,7 +63,7 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 ## 证据要求 -- 自动化(已跑):`cargo test direct_thread`(25 条)、`cargo test direct_project_history`(20 条)、`cargo test export_bindings`(生成绑定与工作区无差异)、`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(468 passed / 17 skipped)、`directThreadChat.test.ts` + `directTurnPresentation.test.ts` 单测、TypeScript 类型检查、ESLint 与 `prettier`。 +- 自动化(已跑):`cargo test direct_thread`(25 条)、`cargo test direct_project_history`(20 条)、`cargo test export_bindings`(生成绑定与工作区无差异)、`NODE_OPTIONS=--localstorage-file=… npx vitest run apps/ai-game-creator-shell/tests`(125 files / 1676 passed / 17 skipped)、`npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`(exit 0)、ESLint 与 `prettier`。 - 运行时(待补):真实 app-server 会话下的新回合、杀进程重开、页面重进、分页与终止。 - 边界:订阅过期、回执竞态、不可显示切片、无 `turn.completed` 的残回合、工具输出超长截断与脱敏。 -- 已知环境缺口:本容器内 `rehype-highlight` 未安装且远端拉包被策略禁用,`ChatMarkdownMessage` / `AgentMessageContent` 与 `chatPromptPolish` / `clientApi` 的环境型失败与本里程碑改动无关。 +- 环境注意:`rehype-highlight` 已装齐后 `ChatMarkdownMessage` / `AgentMessageContent` 转绿;Node 26 下 vitest 的 jsdom 用例需要 `--localstorage-file` 才能拿到 `window.localStorage`(`clientApi.test.ts` / `chatPromptPolish.test.tsx`),已记入 `docs/project-memory/shared-memory/pitfalls.md`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 444798e15..1458cb070 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5616,3 +5616,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - fixture 子进程统一清除 `GIT_*` 环境,并用一次性外层 linked worktree 验证引用、索引、配置不变;原有工程检查规则保持完整,不能用逐项关闭规则修复 fixture 污染。 - Vitest 的 `toHaveBeenCalledWith` 匹配任意一次调用,失败输出会列出其它命令;应先定位相同命令的真实参数差异,不能由其它调用的序号推断时序故障。 - 存在后台轮询的 IPC mock 不应要求目标命令占据全局最后一次调用。验证刷新时先记录调用边界,再筛选该边界之后的目标命令,严格核对其最后一次参数,避免后台查询影响断言,也避免旧调用掩盖刷新未执行。 + +## 2026-09-16 Node 26 下 vitest 的 jsdom 用例拿不到 window.localStorage + +- **现象**:只声明 `@vitest-environment jsdom` 的用例里 `window.localStorage` 是 `undefined`(典型报错 `Cannot read properties of undefined (reading 'clear')`);而 `appSurface.test.ts` 里同样访问 `window.localStorage` 却一切正常。 +- **原因(已核对,不是推断)**:Node 26 在 `globalThis` 上定义了实验性 `localStorage` 访问器,不传 `--localstorage-file` 时它返回 `undefined`。vitest 0.34 的 jsdom 环境把 jsdom window 的描述符复制到全局时,不会覆盖 `globalThis` 上已存在的键,于是 jsdom 真正的 `Storage` 被 Node 的 `undefined` 顶掉。`appSurface` 之所以不受影响,是因为 `apps/ai-game-creator-shell/tests/appSurface/harness.ts` 自己用 `Object.defineProperty(window, 'localStorage', …)` 挂了内存实现。 +- **处理**:跑这类用例时给 Node 加 `--localstorage-file`,例如 `NODE_OPTIONS="--localstorage-file=/tmp/vitest-node-localstorage" npx vitest run `;`apps/ai-game-creator-shell/tests/clientApi.test.ts` 与 `chatPromptPolish.test.tsx` 加这个开关后 26 项全绿。不要改业务代码或往测试里塞假 storage 来绕过。 +- **关联**:`vitest.config.ts`(`environment: 'node'` + 用例内 docblock 切 jsdom)、`apps/ai-game-creator-shell/tests/appSurface/harness.ts` 的内存 localStorage 垫片。 -- 2.52.0 From ff8e517c8e69417a75e7b11c318a2e0f0abf135c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 23:42:58 +0800 Subject: [PATCH 58/92] =?UTF-8?q?=E6=B8=85=E7=90=86=20DirectProject=20?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E7=BB=93=E6=9D=9F=E5=90=8E=E4=B8=A2=E5=BC=83?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E6=AD=A3=E6=96=87=E7=9A=84=E6=AD=BB=E8=AF=AD?= =?UTF-8?q?=E5=8F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App.tsx 不再绑定 chat_with_game_creator_direct_codex 的返回值,删掉 void reply - 保留说明:回复正文按条目身份从线程事件 / 历史切片进聊天,本地不再补第二条 --- apps/ai-game-creator-shell/src/App.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 7cdff8576..72285aa78 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -6541,7 +6541,9 @@ export function App({ directTurnInput.attachments = attachments; } directTurnInput.userItem = effectiveUserItem; - const reply = await withDirectCodexSessionRefresh(() => { + // 回复正文按条目身份从线程事件 / 历史切片进聊天,本地不再补一条, + // 因此这里只等回合跑完,不接返回值。 + await withDirectCodexSessionRefresh(() => { return directInvoke( 'chat_with_game_creator_direct_codex', directTurnInput, @@ -6553,8 +6555,6 @@ export function App({ projectSupervisorHistoryLoadVersionRef.current += 1; } if (localProjectPathRef.current === directProjectPath) { - // 回复正文按条目身份从线程事件 / 历史切片进聊天,本地不再补一条。 - void reply; await refreshDirectProjectManifest(directProjectPath); } } catch (error) { -- 2.52.0 From 1b3a3b003fb1ec69d9b35a54e12096cc79756dfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 23:45:19 +0800 Subject: [PATCH 59/92] =?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=AA=A8=E6=9E=B6?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E9=87=8D=E5=A4=8D=E4=B8=8B=E5=8F=91=20turn.s?= =?UTF-8?q?tarted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - harness.ts 记录运行态"最新回合是否在跑",emitDirectThreadEvents 只在生命周期事件上更新 - completeDirectThreadTurn 仅在回合没在跑时补 turn.started,避免与用例先发的事件重叠 --- .../tests/appSurface/harness.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index d0e923feb..dc18ea287 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -754,6 +754,9 @@ function createProjectSupervisorRuntimeHarness({ let pendingDirectThreadEvents: Array> = []; let directThreadHistoryItems: Array> = []; let directThreadLastCompletedItemId: string | null = null; + // 运行态事件里只有一个"最新回合是否在跑"的布尔:与 Thread Manager 只保留一条生命周期 + // 锚点一致,重复 `turn.started` 在生产里不会出现。 + let directThreadTurnRunning = false; const conversationRecord = ( role: 'user' | 'assistant', @@ -1184,6 +1187,10 @@ function createProjectSupervisorRuntimeHarness({ const emitDirectThreadEvents = ( ...events: Array> ) => { + for (const event of events) { + if (event.type === 'turn.started') directThreadTurnRunning = true; + if (event.type === 'turn.completed') directThreadTurnRunning = false; + } pendingDirectThreadEvents.push(...events); if (directThreadSubscriptionId) { directThreadNotifyHandler?.({ @@ -1303,7 +1310,10 @@ function createProjectSupervisorRuntimeHarness({ at?: number; status?: string; }) { - const events: Array> = [{ type: 'turn.started' }]; + // 生命周期锚点只有一份:回合已经在跑时不再补 `turn.started`。 + const events: Array> = directThreadTurnRunning + ? [] + : [{ type: 'turn.started' }]; if (prompt.trim()) { events.push({ type: 'item.completed', -- 2.52.0 From 6a4cb2bc18b775606ddbbd5ff63bf3782099f9ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 23:48:46 +0800 Subject: [PATCH 60/92] =?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=AA=A8=E6=9E=B6?= =?UTF-8?q?=E7=9A=84=20subscribe=20bootstrap=20=E6=8C=89=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - harness.ts 记录未完成条目集合、事件序号与最新生命周期锚点 - subscribe 只回放未完成条目与最新生命周期锚点,已完成条目与增量正文不再补发 - 去掉已下发事件的序号记录,避免序号表无限增长 --- .../tests/appSurface/harness.ts | 72 ++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index dc18ea287..043c814d8 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -627,6 +627,23 @@ function createPlanGddStateView( }; } +/** + * 运行态事件里的条目身份:只有 `item.started` / `item.completed` 带条目。 + * + * `item.delta` 只带 itemId 不带条目,属于瞬时事件,不参与 bootstrap 回放。 + */ +function directThreadEventItemId( + event: Record, +): string | null { + if (event.type !== 'item.started' && event.type !== 'item.completed') { + return null; + } + const item = event.item; + if (!item || typeof item !== 'object') return null; + const itemId = (item as Record).itemId; + return typeof itemId === 'string' && itemId ? itemId : null; +} + function createProjectSupervisorRuntimeHarness({ projectPath = '/tmp/authorized-game', sessionId = 'supervisor-session-active', @@ -757,6 +774,18 @@ function createProjectSupervisorRuntimeHarness({ // 运行态事件里只有一个"最新回合是否在跑"的布尔:与 Thread Manager 只保留一条生命周期 // 锚点一致,重复 `turn.started` 在生产里不会出现。 let directThreadTurnRunning = false; + // bootstrap 的回放规则与 Rust 侧 `is_bootstrap_event` 对齐:只补"这一刻还没结束的条目" + // 与最新一条生命周期锚点;已完成条目、增量正文这类瞬时事件都不回放。 + const directThreadActiveItemIds = new Set(); + const directThreadPendingEventSeqs = new Map< + Record, + number + >(); + let directThreadEventSequence = 0; + let directThreadLifecycleAnchor: { + seq: number; + event: Record; + } | null = null; const conversationRecord = ( role: 'user' | 'assistant', @@ -954,12 +983,35 @@ function createProjectSupervisorRuntimeHarness({ if (command === 'subscribe_direct_project_thread') { directThreadSubscriptionSequence += 1; directThreadSubscriptionId = `direct-thread-${directThreadSubscriptionSequence}`; - const events = pendingDirectThreadEvents; + const pending = pendingDirectThreadEvents; pendingDirectThreadEvents = []; + // 游标落在队尾:只有"还没结束的条目"与最新生命周期锚点作为 bootstrap 回放, + // 已完成条目与增量正文不再补发(Rust 侧 `is_bootstrap_event` 的同一口径)。 + const replay = pending + .filter((event) => { + const itemId = directThreadEventItemId(event); + return Boolean(itemId && directThreadActiveItemIds.has(itemId)); + }) + .map((event) => ({ + seq: directThreadPendingEventSeqs.get(event) ?? 0, + event, + })); + for (const event of pending) { + directThreadPendingEventSeqs.delete(event); + } + if ( + directThreadLifecycleAnchor && + !replay.some( + (entry) => entry.event === directThreadLifecycleAnchor?.event, + ) + ) { + replay.push(directThreadLifecycleAnchor); + } + replay.sort((left, right) => left.seq - right.seq); return { subscriptionId: directThreadSubscriptionId, lastCompletedItemId: directThreadLastCompletedItemId, - events, + events: replay.map((entry) => entry.event), }; } if (command === 'consume_direct_project_thread') { @@ -1188,8 +1240,24 @@ function createProjectSupervisorRuntimeHarness({ ...events: Array> ) => { for (const event of events) { + directThreadEventSequence += 1; + directThreadPendingEventSeqs.set(event, directThreadEventSequence); if (event.type === 'turn.started') directThreadTurnRunning = true; if (event.type === 'turn.completed') directThreadTurnRunning = false; + if (event.type === 'turn.started' || event.type === 'turn.completed') { + // 生命周期锚点只留最新一条,与 Thread Manager 的 `lifecycle_anchor` 一致。 + directThreadLifecycleAnchor = { + seq: directThreadEventSequence, + event, + }; + } + const itemId = directThreadEventItemId(event); + if (itemId && event.type === 'item.started') { + directThreadActiveItemIds.add(itemId); + } + if (itemId && event.type === 'item.completed') { + directThreadActiveItemIds.delete(itemId); + } } pendingDirectThreadEvents.push(...events); if (directThreadSubscriptionId) { -- 2.52.0 From 64bd006981d5c72b4eb8ff3144fcbaaecae393b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 23:52:02 +0800 Subject: [PATCH 61/92] =?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=AA=A8=E6=9E=B6?= =?UTF-8?q?=E7=9A=84=E5=8E=86=E5=8F=B2=E5=88=87=E7=89=87=E6=8C=89=20limit?= =?UTF-8?q?=20=E4=B8=8E=E9=94=9A=E7=82=B9=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - harness.ts 的 read_direct_project_history_slice 按生产口径从队尾取一屏、锚点条目不进窗口 - 按本屏最老一条算 firstItemId,收满一屏后再有条目才算 hasMore - project-development.suite.ts 补一条用例:26 条历史只显示尾部 20 条,点「显示更早的对话」按锚点取回前 6 条后按钮消失 --- .../tests/appSurface/harness.ts | 29 ++++++- .../appSurface/project-development.suite.ts | 81 +++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 043c814d8..2eebd7640 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -1023,10 +1023,33 @@ function createProjectSupervisorRuntimeHarness({ return { events }; } if (command === 'read_direct_project_history_slice') { + // 生产口径:从文件尾反向取一屏可显示条目,锚点条目本身不进窗口;收满一屏之后 + // 再见一条才算 `hasMore`,`firstItemId` 是本屏最老一条的 itemId。 + const requestedLimit = Number(args?.limit ?? 20); + const limit = Math.min( + Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 20, 1), + 200, + ); + const beforeItemId = + typeof args?.beforeItemId === 'string' && args.beforeItemId + ? args.beforeItemId + : null; + let end = directThreadHistoryItems.length; + if (beforeItemId) { + const anchorIndex = directThreadHistoryItems.findIndex( + (item) => String(item.itemId ?? '') === beforeItemId, + ); + end = + anchorIndex >= 0 ? anchorIndex : directThreadHistoryItems.length; + } + const start = Math.max(0, end - limit); + const window = directThreadHistoryItems.slice(start, end); + const oldest = window[0]; return { - items: [...directThreadHistoryItems], - hasMore: false, - firstItemId: null, + items: [...window], + hasMore: start > 0, + firstItemId: + oldest && typeof oldest.itemId === 'string' ? oldest.itemId : null, }; } if (command === 'start_game_creator_supervisor_runtime_task') { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 70c2d799c..6aa05e4bc 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -8342,6 +8342,87 @@ export function registerProjectSupervisorSurfaceTests() { ).toHaveLength(2); }); + it('loads the earlier direct history page from the first-item anchor', async () => { + // 分页锚点走的是首屏切片给出的 `firstItemId`:切片本身从队尾取,老的一屏靠锚点继续向前。 + const projectPath = '/tmp/launcher-direct-history-pagination-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-direct-history-pagination-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + // 26 条 > 首屏 20 条:首屏只显示最后 20 条,前面 6 条要靠"显示更早"翻页取回。 + supervisorHarness.setDirectThreadHistory( + Array.from({ length: 26 }, (_, index) => { + const label = `历史对话 ${String(index + 1).padStart(2, '0')}`; + return { + itemType: 'message', + itemId: `direct-codex:turn-${label}:user`, + role: 'user', + text: label, + at: 1000 + index, + }; + }), + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-direct-history-pagination-game', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + pickProjectFromLauncher(projectPath); + + const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + expect( + await within(supervisorSurface).findByText('历史对话 26'), + ).not.toBeNull(); + // 首屏是尾部的 20 条:第 7 条起可见,更早的 6 条还没读进来。 + expect(within(supervisorSurface).getByText('历史对话 07')).not.toBeNull(); + expect(within(supervisorSurface).queryByText('历史对话 06')).toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + limit: 20, + }); + + fireEvent.click(screen.getByRole('button', { name: '显示更早的对话' })); + + // 翻页按首屏最老一条的锚点向前取;取到文件头后按钮消失。 + expect( + await within(supervisorSurface).findByText('历史对话 01'), + ).not.toBeNull(); + expect(within(supervisorSurface).getByText('历史对话 06')).not.toBeNull(); + expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + beforeItemId: 'direct-codex:turn-历史对话 07:user', + limit: 20, + }); + }); + it('drains a notify that lands before the subscribe receipt so the running turn is not stranded', async () => { // 真实竞态:`subscribe` 在 Rust 侧已经注册好 subscriber 并开始通知,但前端还没拿到 // subscriptionId。此时的通知不能白丢——拿到回执后必须补一次 consume,否则整个回合会卡在队列里。 -- 2.52.0 From e17af3008e80e828ca8b92d15c6634278ba12590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 23:59:29 +0800 Subject: [PATCH 62/92] =?UTF-8?q?=E9=A6=96=E5=B1=8F=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E5=88=87=E7=89=87=E6=94=B9=E5=88=B0=20staleness=20=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=E4=B9=8B=E5=90=8E=E5=86=8D=E5=B9=B6=E5=85=A5=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=20reducer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loadProjectConversation 不再在 read_direct_project_history_slice 的 then 回调里直接写 directThreadChat - 切片先暂存,等 loadVersion / localProjectPathRef 守卫通过后再与 hasMore、锚点一起落到状态 - 现有 launcher 按项目路径 key 重挂载 App,跨项目污染暂不可复现;本次只是让这条写入与其它状态写入的时序一致 --- apps/ai-game-creator-shell/src/App.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 72285aa78..2abc16df0 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -3491,6 +3491,9 @@ export function App({ let runtimeError = ''; let loadedDirectHistoryHasMore = false; let loadedDirectHistoryFirstItemId: string | null = null; + // 首屏切片先暂存,待下方 staleness 守卫通过后再并入聊天 reducer: + // 迟到的切片属于已经切走的项目,不能在守卫之前就写进全局聊天状态。 + let loadedDirectHistorySlice: DirectThreadHistorySlice | null = null; const projectConversation = directCodexProductRuntime ? (() => { return invoke( @@ -3502,10 +3505,7 @@ export function App({ ).then((slice) => { loadedDirectHistoryHasMore = slice.hasMore; loadedDirectHistoryFirstItemId = slice.firstItemId; - // 首屏历史进聊天 reducer;`messages` 在 DirectProject 下只剩运行期本地消息。 - setDirectThreadChat((state) => - mergeDirectThreadHistorySlice(state, slice), - ); + loadedDirectHistorySlice = slice; return { path: nextProjectPath, agentId: null, @@ -3579,6 +3579,13 @@ export function App({ if (directCodexProductRuntime) { setDirectHistoryHasMore(loadedDirectHistoryHasMore); directHistoryOldestItemIdRef.current = loadedDirectHistoryFirstItemId; + if (loadedDirectHistorySlice) { + const slice = loadedDirectHistorySlice; + // 首屏历史进聊天 reducer;`messages` 在 DirectProject 下只剩运行期本地消息。 + setDirectThreadChat((state) => + mergeDirectThreadHistorySlice(state, slice), + ); + } } setMessages((current) => { // replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。 -- 2.52.0 From 098693636aa3e48c8eb8855a4683efa7ddf24ae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 00:02:11 +0800 Subject: [PATCH 63/92] =?UTF-8?q?=E5=88=86=E9=A1=B5=E5=88=87=E7=89=87?= =?UTF-8?q?=E5=BC=80=E5=A4=B4=E7=9A=84=E5=89=8D=E5=AF=BC=E6=9D=A1=E7=9B=AE?= =?UTF-8?q?=E5=B9=B6=E8=BF=9B=E5=90=8E=E9=9D=A2=E9=82=A3=E4=B8=AA=E5=9B=9E?= =?UTF-8?q?=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildDirectChatTurns 先攒住第一个用户条目之前的过程条目,交给后面那个回合 - 不再渲染没有用户气泡的孤儿回合;整份历史都没有用户条目时仍保留一个回合 - directTurnPresentation 单测补一条分页切片从半截回合开头的情形 --- .../directTurnPresentation.ts | 17 +++++++++++++-- .../tests/directTurnPresentation.test.ts | 21 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts index 20e17ca0e..a481c03ed 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts @@ -149,17 +149,30 @@ export function buildDirectChatTurns({ }): DirectChatTurn[] { const turns: DirectChatTurnEntries[] = []; let current: DirectChatTurnEntries | null = null; + // 分页切片的开头可能落在半截回合里(那一条用户条目还在更早的一屏):这些前导条目先攒着, + // 交给后面第一个用户条目开的回合,避免渲染出一个没有用户气泡的孤儿回合。 + const leadingEntries: DirectChatEntry[] = []; for (const entry of entries) { if (entry.kind === 'message' && entry.role === 'user') { current = newTurn(entry.itemId); turns.push(current); + if (leadingEntries.length > 0) { + current.entries.push(...leadingEntries); + leadingEntries.length = 0; + } } if (!current) { - current = newTurn(`history:${turns.length}`); - turns.push(current); + leadingEntries.push(entry); + continue; } current.entries.push(entry); } + if (!current && leadingEntries.length > 0) { + // 整份历史都没有用户条目:仍然保留一个回合承载正文。 + current = newTurn(`history:${turns.length}`); + current.entries.push(...leadingEntries); + turns.push(current); + } const entryIds = new Set(entries.map((entry) => entry.itemId)); localMessages.forEach((message, index) => { diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts index a887f02c2..809041ff9 100644 --- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts @@ -202,6 +202,27 @@ describe('DirectProject 聊天分区', () => { expect(turns[0]?.finals[0]).toMatchObject({ text: '只有回复' }); }); + it('分页切片开头落在半截回合里时并进后面那个回合,不生成没有用户气泡的孤儿回合', () => { + // 更早的一屏把上一条用户条目切在外面:这三条前导条目属于上一轮,但不能自成回合。 + const turns = buildDirectChatTurns({ + entries: [ + reasoningEntry('r-prev'), + toolEntry('t-prev'), + assistantEntry('a-prev', '上一轮的答复', 1_800_000_000_900), + userEntry('u2', 1_800_000_010_000), + assistantEntry('a2', '这一轮的答复', 1_800_000_011_000), + ], + }); + expect(turns.map((turn) => turn.key)).toEqual(['u2']); + expect(turns[0]?.users.map((block) => block.key)).toEqual(['u2:u2']); + expect(turns[0]?.process.map((block) => block.key)).toEqual([ + 'u2:r-prev', + 'u2:t-prev', + 'u2:a-prev', + ]); + expect(turns[0]?.finals.map((block) => block.key)).toEqual(['u2:a2']); + }); + it('条目转消息只保留用户 / 助手正文,并按身份去重', () => { const messages = directChatEntriesToMessages([ userEntry('u1'), -- 2.52.0 From 1b40f030e7d1965f43c3c9dabe15b7245379dc04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 00:05:57 +0800 Subject: [PATCH 64/92] =?UTF-8?q?=E5=88=A0=E6=8E=89=E8=81=8A=E5=A4=A9=20re?= =?UTF-8?q?ducer=20=E9=87=8C=E6=B2=A1=E4=BA=BA=E8=AF=BB=E7=9A=84=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E8=BA=AB=E4=BB=BD=E4=B8=8E=E9=A6=96=E5=B1=8F=E9=94=9A?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DirectThreadChatState 去掉 subscriptionId 与 lastCompletedItemId 两个只写不读的字段 - resolveDirectThreadBootstrap 只把 bootstrap 事件 reduce 进状态,不再顺带写入这两个字段 - directThreadChat 单测改成断言聊天状态里不再出现这两个字段(bootstrap 载荷本身不变) --- .../project-workspace/directThreadChat.ts | 17 +++-------------- .../tests/directThreadChat.test.ts | 5 +++-- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts index 37adb0c7e..d9b978db5 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts @@ -32,9 +32,6 @@ export type DirectChatEntry = { }; export type DirectThreadChatState = { - subscriptionId: string | null; - /** 首屏历史锚点:`subscribe` 给出的最后一条完成条目 id。 */ - lastCompletedItemId: string | null; /** 最新回合是否还在跑;只由生命周期事件的先后决定。 */ turnRunning: boolean; /** 历史切片条目,保持文件顺序。 */ @@ -45,8 +42,6 @@ export type DirectThreadChatState = { export function emptyDirectThreadChatState(): DirectThreadChatState { return { - subscriptionId: null, - lastCompletedItemId: null, turnRunning: false, history: [], live: [], @@ -197,20 +192,14 @@ export function reduceDirectThreadEvents( /** * bootstrap 是运行态的唯一权威:游标已在队尾,返回的事件就是此刻要处理的事件。 * - * 历史窗口保留:bootstrap 不重新回读历史切片,那是 `lastCompletedItemId` 的职责。 + * 订阅身份与首屏历史锚点(`subscriptionId` / `lastCompletedItemId`)是订阅循环自己的局部 + * 事实,不进聊天状态:这里只把 bootstrap 事件 reduce 进现有状态。 */ export function resolveDirectThreadBootstrap( state: DirectThreadChatState, bootstrap: DirectThreadSubscriptionBootstrap, ): DirectThreadChatState { - return reduceDirectThreadEvents( - { - ...state, - subscriptionId: bootstrap.subscriptionId, - lastCompletedItemId: bootstrap.lastCompletedItemId ?? null, - }, - bootstrap.events, - ); + return reduceDirectThreadEvents(state, bootstrap.events); } /** 事件顺序 = 游标顺序;调用方只需要把 `consume` 的结果喂进来。 */ diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index e381805dd..d9bb2b82f 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -80,8 +80,9 @@ describe('DirectProject 聊天 reducer', () => { ], }, ); - expect(bootstrapped.subscriptionId).toBe('sub-1'); - expect(bootstrapped.lastCompletedItemId).toBe('msg-9'); + // 订阅身份与首屏锚点由订阅循环自己持有,不写进聊天 reducer 状态。 + expect(bootstrapped).not.toHaveProperty('subscriptionId'); + expect(bootstrapped).not.toHaveProperty('lastCompletedItemId'); expect(bootstrapped.turnRunning).toBe(true); expect(selectDirectChatEntries(bootstrapped)).toHaveLength(1); }); -- 2.52.0 From 975a2e577cae87fcb4a973676cd38258578a0ec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 00:10:20 +0800 Subject: [PATCH 65/92] =?UTF-8?q?=E7=BB=88=E6=AD=A2=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E6=94=B9=E6=8C=89=E7=BB=BC=E5=90=88=E7=B9=81=E5=BF=99=E4=BF=A1?= =?UTF-8?q?=E5=8F=B7=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleCancelDirectCodexTurn 的守卫改用 supervisorChatBusy,与「终止」按钮的可见条件对齐 - 刚提交、turn.started 还没到达的窗口里点击终止不再回"当前没有正在运行的回合" - chat-composer 用例补一条:生命周期事件缺席时也能发出 cancel_direct_codex_turn --- apps/ai-game-creator-shell/src/App.tsx | 4 +- .../tests/appSurface/chat-composer.suite.ts | 37 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 2abc16df0..e2a652ee9 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -12051,7 +12051,9 @@ export function App({ } const invoke = resolveTauriInvoke(); const directProjectPath = resolveChatProjectPath(localProject); - if (!invoke || !directProjectPath || !directTurnRunning) { + // 繁忙判据与「终止」按钮的可见条件一致:本地 invoke 正在跑,或订阅说还有一条回合没结束。 + // 只认 `turn.started` 推导出来的 `directTurnRunning` 会漏掉"刚提交、事件还没到"的窗口。 + if (!invoke || !directProjectPath || !supervisorChatBusy) { setProjectSupervisorRuntimeError('当前没有正在运行的回合,无法终止。'); return; } diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 57bb32690..ccaae80aa 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -524,6 +524,43 @@ export function registerChatComposerControlTests() { expect(within(surface).getByText('已终止本次回合。')).not.toBeNull(); }); + it('terminates a turn that was submitted before its lifecycle event arrived', async () => { + // 刚提交、`turn.started` 还没下发:按钮已经变成「终止」,点击不能回"没有正在运行的回合"。 + const pending: Array<{ resolve: (value: string) => void }> = []; + const { invoke, path, surface } = await openDirectCodexSurface({ + chat_with_game_creator_direct_codex: () => + new Promise((resolve) => { + pending.push({ resolve }); + }), + cancel_direct_codex_turn: async () => undefined, + }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '做一个小游戏'); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expect.objectContaining({ prompt: '做一个小游戏' }), + ); + }); + + const stopButton = await within(surface).findByRole('button', { + name: '终止', + }); + fireEvent.click(stopButton); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('cancel_direct_codex_turn', { + projectPath: path, + }); + }); + expect( + within(surface).queryByText('当前没有正在运行的回合,无法终止。'), + ).toBeNull(); + + await act(async () => { + pending[0]?.resolve('回合回复'); + }); + }); + it('moves the reasoning effort control next to the model selector and persists only for later turns', async () => { let stored = 'high'; const { invoke, surface } = await openDirectCodexSurface({ -- 2.52.0 From ad19c9475e5dbf77a2e30ce9b830fdda0ab0777d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 00:18:49 +0800 Subject: [PATCH 66/92] =?UTF-8?q?=E5=9B=9E=E6=89=AB=E7=9A=84=20terminated?= =?UTF-8?q?=20=E6=94=B9=E6=88=90=E6=8C=89=E7=9C=9F=E5=AE=9E=E6=8D=A2?= =?UTF-8?q?=E8=A1=8C=E7=BB=93=E5=B0=BE=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DirectProjectHistoryReverseLines 增记 saw_delimiter,第一段按"文件是否以换行结尾"判定,之后的行按已切掉的分隔符判定 - 尾部残行(上一行完整、这一行没有换行)解析失败时改为跳过继续回扫,不再报错、也不再就地结束丢掉更早的完整历史 - 换行结尾的坏行(含文件第一段)与顺序读取保持一致地失败关闭 - 补两条回扫用例:尾部残行不丢更早条目、坏掉的第一段必须失败关闭(都按旧实现变异验证过) --- .../src/agent/direct_project_history.rs | 88 ++++++++++++++++--- 1 file changed, 75 insertions(+), 13 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 133409a7d..039942a3f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -105,8 +105,9 @@ const DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES: usize = 16 * 1024; /// 从文件尾向前回读到的一行。 /// -/// `terminated` 表示这行后面确实有换行符:文件里只有末行可能没有换行,那种行是 append -/// 侧承诺会修复的截断尾,解析失败时按"历史到此结束"处理,而不是报错。 +/// `terminated` 表示这行后面确实有换行符:整份历史里只有末尾那一段可能没有换行,那一段是 +/// append 侧承诺会修复的截断尾。解析失败时跳过这一行继续往前回扫,而不是报错——它是回扫 +/// 见到的第一行,一旦就地结束就会把后面完整的更早历史全部丢掉。 struct DirectProjectHistoryReverseLine { bytes: Vec, terminated: bool, @@ -124,6 +125,8 @@ struct DirectProjectHistoryReverseLines { chunk: Vec, /// 下一次回读的起始偏移;0 表示文件头已经读完。 position: u64, + /// 是否已经切过一次换行:切过之后,接下来产出的行前面一定还有换行分隔。 + saw_delimiter: bool, } impl DirectProjectHistoryReverseLines { @@ -145,6 +148,7 @@ impl DirectProjectHistoryReverseLines { pending: Vec::new(), chunk: vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES], position, + saw_delimiter: false, }) } @@ -152,19 +156,27 @@ impl DirectProjectHistoryReverseLines { loop { if let Some(newline) = self.pending.iter().rposition(|byte| *byte == b'\n') { let bytes = self.pending[newline + 1..].to_vec(); + // `pending` 的尾部始终是文件尾,所以第一次切出来的这一段后面有没有换行, + // 就是"整个文件是否以换行结尾";之后切出来的行都被刚切掉的那个换行分隔。 + let terminated = if self.saw_delimiter { + true + } else { + self.pending.last() == Some(&b'\n') + }; self.pending.truncate(newline); - return Ok(Some(DirectProjectHistoryReverseLine { - bytes, - terminated: true, - })); + self.saw_delimiter = true; + return Ok(Some(DirectProjectHistoryReverseLine { bytes, terminated })); } if self.position == 0 { if self.pending.is_empty() { return Ok(None); } + // 走到这里说明剩下的字节里没有换行:只有文件根本没被换行分隔过, + // 或者这就是第一行。前者是唯一没有换行结尾的行。 + let terminated = self.saw_delimiter; return Ok(Some(DirectProjectHistoryReverseLine { bytes: std::mem::take(&mut self.pending), - terminated: false, + terminated, })); } let read_len = usize::try_from(self.position) @@ -208,8 +220,9 @@ fn find_direct_project_history_item_by_id_at( } } Ok(None) => {} - // 末行没有换行符:append 侧会修复这条截断尾,幂等回扫不能因此失败。 - Err(_) if !line.terminated => break, + // 末行没有换行符:这是 append 侧会修复的截断尾,跳过它继续往前找, + // 不能因为尾部半行就让整次幂等回扫失败。 + Err(_) if !line.terminated => continue, Err(error) => return Err(error), } } @@ -607,8 +620,9 @@ pub(crate) fn read_direct_project_history_items_slice_at( } let parsed: Value = match serde_json::from_slice(&line.bytes) { Ok(value) => value, - // 与顺序读取一致:只有末行没有换行符时,解析失败才按"可修复的截断尾"结束。 - Err(_) if !line.terminated => break, + // 与顺序读取一致:只有末行没有换行符时才是"可修复的截断尾"。回扫是从文件尾向前的, + // 这一行是最新的一条,跳过它继续读更早的完整行,不能因此把整屏历史判成空的。 + Err(_) if !line.terminated => continue, Err(error) => { return Err(format!( "解析 DirectProject 历史失败:{}: {error}", @@ -838,6 +852,54 @@ mod tests { assert_eq!(first_item_id.as_deref(), Some("codex-item-1")); } + /// 判据:尾部残行(上一行完整、这一行没有换行结尾)跳过继续回扫,不报错也不就地结束。 + /// + /// 变异验证:`terminated` 按分支硬编码(旧实现)会把这条残行当成完整行,"读一屏"直接 + /// 报解析失败;就地结束则返回空窗口,把更早的完整历史全部丢掉。 + #[test] + fn truncated_history_tail_is_skipped_without_losing_earlier_items() { + let root = init_history_project("history-truncated-tail"); + let path = history_path(root.path()); + std::fs::create_dir_all(path.parent().expect("history parent")).expect("history dir"); + let first_row = RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"); + let partial_tail = r#"{"type":"response_item","payload":{"type":"mess"#; + std::fs::write( + &path, + format!("{first_row}\n{RESPONSE_ITEM_ROW}\n{partial_tail}"), + ) + .expect("write truncated history tail"); + + let (items, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + let ids = items + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-1", "codex-item-2"]); + assert!(!has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-1")); + } + + /// 判据:只有"末尾没有换行"那一段放宽;换行结尾的坏行一律失败关闭,第一段也不例外。 + /// + /// 变异验证:把第一段也当可修复尾行(旧实现)时,这条用例会静默返回空历史而不是报错。 + #[test] + fn corrupt_first_line_fails_closed_like_any_newline_terminated_line() { + let root = init_history_project("history-corrupt-head"); + let first_row = RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"); + write_history_lines( + root.path(), + &[ + r#"{"type":"response_item","payload":{"type":"mess"#, + &first_row, + ], + ); + + let error = super::read_direct_project_history_items_slice_at(root.path(), None, 20) + .expect_err("换行结尾的坏行必须失败关闭"); + assert!(error.starts_with("解析 DirectProject 历史失败"), "{error}"); + } + /// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。 /// /// 注入标记是"让接下来 N 次单次尝试返回争用失败";退避表只补一次重试,所以注入 1 次 @@ -912,8 +974,8 @@ mod tests { /// 判据:格式类失败**不**触发重试。同一份历史每次读都是同一个结论,重试只是白等 /// (而且会把"收尾失败"再拖一轮)。注入次数因此必须原样留着。 /// - /// fixture 形状说明:回扫只对"后面还有换行的完整行"做严格解析,文件里第一段(以及末尾 - /// 没有换行的那一段)按可修复尾行放宽。所以损坏行必须夹在两条完整行中间才会失败关闭。 + /// fixture 形状说明:回扫只对"末尾没有换行的那一段"按可修复尾行放宽,其余每一行(包括 + /// 文件第一段)都必须有换行结尾并被严格解析。所以损坏行夹在两条完整行中间必然失败关闭。 #[test] fn shape_failure_is_not_retried() { let root = init_history_project("shape-no-retry"); -- 2.52.0 From da2ad83c3b15ef5fd30e2148ef43d46c1f579807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 00:24:01 +0800 Subject: [PATCH 67/92] =?UTF-8?q?=E5=88=86=E9=A1=B5=E9=94=9A=E7=82=B9?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E6=96=87=E4=BB=B6=E9=87=8C=E7=9A=84=E5=8E=9F?= =?UTF-8?q?=E5=A7=8B=20item=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 direct_project_history_anchor_id:优先取 project.jsonl 原始 id,缺 id 时退回归一身份兜底 - read_direct_project_history_items_slice_at 的锚点匹配与 first_item_id 都改用原始 id - 工具条目的调用与输出共用 call_id,按归一身份当锚点会先跳过 output 再把上一屏的 function_call 带回来 - 原分页用例更名 history_window_paginates_upwards_by_item_id_anchor - 新增 pagination_anchor_uses_raw_item_id_for_tool_call_pairs,旧实现下会返回 call-1 而非 fc-1(已做变异验证) --- .../src/agent/direct_project_history.rs | 65 +++++++++++++++++-- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 039942a3f..5a59918d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -595,9 +595,22 @@ fn read_direct_project_history_entries_at(root: &Path) -> Result Option { + item.get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| direct_thread_item_identity(item)) +} + +/// 从文件尾向前回扫一屏历史,返回 `(条目, 还有更早的条目, 记录时间, 本屏最老一条的原始 item id)`。 +/// +/// 分页锚点是原始 item id,收满 `limit` 条可显示条目后再多看一眼"还有没有更早的 /// 条目"就停,不回读整份历史。 pub(crate) fn read_direct_project_history_items_slice_at( root: &Path, @@ -635,7 +648,7 @@ pub(crate) fn read_direct_project_history_items_slice_at( }; if !anchor_seen { anchor_seen = before_item_id.is_some_and(|anchor| { - direct_thread_item_identity(&item).as_deref() == Some(anchor) + direct_project_history_anchor_id(&item).as_deref() == Some(anchor) }); continue; } @@ -669,7 +682,7 @@ pub(crate) fn read_direct_project_history_items_slice_at( .collect(); let first_item_id = newest_first .first() - .and_then(|(item, _)| direct_thread_item_identity(item)); + .and_then(|(item, _)| direct_project_history_anchor_id(item)); Ok(( newest_first.into_iter().map(|(item, _)| item).collect(), has_more, @@ -814,9 +827,9 @@ mod tests { assert_eq!(first_item_id.as_deref(), Some("codex-item-2")); } - /// 判据:分页锚点是"本窗口最老一条的归一身份",逐屏向前不重不漏。 + /// 判据:分页锚点是"本窗口最老一条的原始 item id",逐屏向前不重不漏。 #[test] - fn history_window_paginates_upwards_by_identity_anchor() { + fn history_window_paginates_upwards_by_item_id_anchor() { let root = init_history_project("history-pagination"); write_history_lines( root.path(), @@ -900,6 +913,46 @@ mod tests { assert!(error.starts_with("解析 DirectProject 历史失败"), "{error}"); } + /// 判据:分页锚点取文件里的原始 item id。工具条目的调用与输出共用调用 id,归一身份不唯一。 + /// + /// 变异验证:锚点按归一身份算(旧实现)时,翻页会先跳过同身份的 output,再把上一屏已经 + /// 显示过的 function_call 重新带进这一屏。 + #[test] + fn pagination_anchor_uses_raw_item_id_for_tool_call_pairs() { + let root = init_history_project("history-tool-call-anchor"); + let call_row = r#"{"type":"response_item","payload":{"type":"function_call","id":"fc-1","call_id":"call-1","name":"exec_command","arguments":"{\"cmd\":\"ls\"}"}}"#; + let output_row = r#"{"type":"response_item","payload":{"type":"function_call_output","id":"fc-2","call_id":"call-1","output":"ok"}}"#; + let earlier_row = RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"); + write_history_lines(root.path(), &[&earlier_row, call_row, output_row]); + + // 首屏取 2 条:本屏最老是调用条目,锚点必须是它在文件里的原始 id,不是 call-1。 + let (newest, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at(root.path(), None, 2).unwrap(); + let ids = newest + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["fc-1", "fc-2"]); + assert!(has_more); + assert_eq!(first_item_id.as_deref(), Some("fc-1")); + + // 用锚点翻上一屏:不能再把 fc-1 带一遍,也不能原地返回同一屏。 + let (older, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at( + root.path(), + first_item_id.as_deref(), + 2, + ) + .unwrap(); + let ids = older + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-1"]); + assert!(!has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-1")); + } + /// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。 /// /// 注入标记是"让接下来 N 次单次尝试返回争用失败";退避表只补一次重试,所以注入 1 次 -- 2.52.0 From e640219243244f9eadc5a2a7a28553eddb0285c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 13:22:36 +0800 Subject: [PATCH 68/92] =?UTF-8?q?=E6=80=9D=E8=80=83=E6=AD=A3=E6=96=87?= =?UTF-8?q?=E6=94=B9=E8=B5=B0=E7=94=9F=E4=BA=A7=E8=B7=AF=E5=BE=84=E7=9A=84?= =?UTF-8?q?=20ReasoningDelta=20=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 direct_codex_reasoning_delta_event,两条 reasoning 增量通知只在这一处分类 - 生产 stdout 读取器改用该函数,并跳过「preparing 活动」的降级与活动节流(活动节流会按类别吃掉逐段正文) - 旧分类函数 direct_codex_notification_event 同步改用它,避免两份实现再次分叉 - 单测 direct_reasoning_deltas_stream_text_while_plan_and_command_output_stay_activity 取代原先断言 Activity 的旧用例 - 新增 E2E codex_app_server_streams_reasoning_deltas_without_activity_fallback:把读取器的路由关掉即转红(已做变异验证) --- .../src/agent/codex_app_server/mod.rs | 206 ++++++++++++++---- 1 file changed, 163 insertions(+), 43 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 3cb7bc305..83c7de9f9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -1060,38 +1060,46 @@ fn should_emit_direct_codex_activity( true } +/// 思考正文增量事件:`item/reasoning/summaryTextDelta`(reasoning item 的 `summary`)与 +/// `item/reasoning/textDelta`(它的 `content`)都下发正文,不降级成"preparing 活动文本"。 +/// +/// 这里只是把"完成时才看到"提前为"边生成边看到":两段文本本来就随 `item/completed` +/// 落进 `project.jsonl` 并展示给用户,可见范围没有放宽;未识别的 plan 文本与命令输出 +/// 仍然只降级为活动状态。运行态读取器和 `direct_codex_notification_event` 共用这一处, +/// 避免两份实现再次分叉(分叉时就出现过"生产路径从不产生 ReasoningDelta")。 +fn direct_codex_reasoning_delta_event( + method: &str, + params: &serde_json::Value, +) -> Option { + if !matches!( + method, + "item/reasoning/summaryTextDelta" | "item/reasoning/textDelta" + ) { + return None; + } + params + .get("delta") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(|delta| CodexTurnEvent::ReasoningDelta { + item_id: params + .get("itemId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| "direct-missing-item".to_string()), + delta: delta.to_string(), + }) +} + fn direct_codex_notification_event( method: &str, params: &serde_json::Value, intermediate_text: Option, safe_activity: Option<&'static str>, ) -> Option { - // 思考正文走独立通道,交给 DirectProject 的运行态事件;它不因为 - // "preparing 活动" 的降级规则被丢掉,否则界面只能等 item/completed 才看到思考。 - // - // 两条通知都下发正文,不下发活动文本: - // - `item/reasoning/summaryTextDelta`(core `ReasoningContentDelta`)→ reasoning item 的 `summary`; - // - `item/reasoning/textDelta`(core `ReasoningRawContentDelta`)→ reasoning item 的 `content`, - // 正是 `project.jsonl` 里保存、并在此前 `item/completed` 已经展示给用户的同一段文本。 - // 因此这里只是把"完成时才看到"提前为"边生成边看到",没有放宽可见文本的范围; - // 未识别的 plan 文本与命令输出仍然只降级为活动状态,不下发正文。 - if matches!( - method, - "item/reasoning/summaryTextDelta" | "item/reasoning/textDelta" - ) { - return params - .get("delta") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - .map(|delta| CodexTurnEvent::ReasoningDelta { - item_id: params - .get("itemId") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| "direct-missing-item".to_string()), - delta: delta.to_string(), - }); + if let Some(event) = direct_codex_reasoning_delta_event(method, params) { + return Some(event); } let (activity, intermediate_text) = match (&intermediate_text, safe_activity) { (Some(_), Some(activity)) if activity == "preparing" => (Some(activity), None), @@ -3988,12 +3996,18 @@ async fn read_game_creator_codex_app_server_stdout( let Some(turn_id) = turn_id else { continue; }; - if let Some(activity) = safe_activity { - let last_activity = last_direct_activity_by_turn - .entry(turn_id.clone()) - .or_default(); - if !should_emit_direct_codex_activity(last_activity, activity) { - continue; + // 思考正文走正文通道,不参与 `preparing` 活动的降级与节流:活动节流按类别抑制 + // 连续的 preparing,逐段思考正文会被整段吃掉——这正是生产路径此前从不产生 + // `ReasoningDelta` 的原因。正文仍沿用下面的正文节流,避免重复 chunk 反复入队。 + let reasoning_delta = direct_codex_reasoning_delta_event(method, ¶ms); + if reasoning_delta.is_none() { + if let Some(activity) = safe_activity { + let last_activity = last_direct_activity_by_turn + .entry(turn_id.clone()) + .or_default(); + if !should_emit_direct_codex_activity(last_activity, activity) { + continue; + } } } if let Some(text) = intermediate_text.as_deref() { @@ -4004,7 +4018,9 @@ async fn read_game_creator_codex_app_server_stdout( continue; } } - let event = if let Some(kind) = direct_codex_resolution_event_type(method) { + let event = if let Some(event) = reasoning_delta { + event + } else if let Some(kind) = direct_codex_resolution_event_type(method) { CodexTurnEvent::Request { kind, params } } else if let Some(activity) = safe_activity { // Preparing notifications may carry private plan/reasoning text; @@ -4783,18 +4799,49 @@ mod tests { ); } + /// 判据:思考正文走 `ReasoningDelta` 正文通道(与 ADR 一致),不再被 `preparing` + /// 活动降级吃掉;plan 文本与命令输出仍然只降级成活动类别,不带正文。 #[test] - fn direct_preparing_notifications_emit_thinking_activity_without_raw_text() { - let reasoning = serde_json::json!({ "delta": "hidden reasoning must not leak" }); - assert!(matches!( - direct_codex_notification_event( + fn direct_reasoning_deltas_stream_text_while_plan_and_command_output_stay_activity() { + let reasoning = serde_json::json!({ "itemId": "reasoning-1", "delta": "思考正文" }); + for method in [ + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + ] { + assert!( + matches!( + direct_codex_reasoning_delta_event(method, &reasoning), + Some(CodexTurnEvent::ReasoningDelta { item_id, delta }) + if item_id == "reasoning-1" && delta == "思考正文" + ), + "{method} 必须下发明文思考增量" + ); + // 运行态读取器用的就是这一个分类函数,不能再走「preparing 活动」降级。 + assert!( + matches!( + direct_codex_notification_event( + method, + &reasoning, + Some("思考正文".to_string()), + Some("preparing"), + ), + Some(CodexTurnEvent::ReasoningDelta { .. }) + ), + "{method} 在通知分类里不能降级成活动" + ); + } + assert!( + direct_codex_reasoning_delta_event( "item/reasoning/textDelta", - &reasoning, - Some("hidden reasoning must not leak".to_string()), - Some("preparing"), - ), - Some(CodexTurnEvent::Activity("preparing")) - )); + &serde_json::json!({ "delta": "" }), + ) + .is_none(), + "空增量不产生正文事件" + ); + assert!( + direct_codex_reasoning_delta_event("turn/plan/updated", &reasoning).is_none(), + "非 reasoning 通知不进正文通道" + ); let plan = serde_json::json!({ "explanation": "private plan text must not leak" }); assert!(matches!( @@ -6288,6 +6335,79 @@ while IFS= read -r line; do :; done assert_eq!(connection.inner.threads.lock().await.len(), 1); } + /// 判据:生产读取器不再把思考正文降级成 `preparing` 活动。 + /// + /// fixture 刻意不发 `turn/started` 等先导活动,所以"活动节流"这条退路不存在: + /// 一旦读取器把 `item/reasoning/*Delta` 归回活动通道,observations 里就会出现 + /// `Activity("preparing")`。同时明文思考只走 DirectProject 正文通道, + /// 不得漏进旧的运行态 observation。 + #[cfg(unix)] + #[tokio::test] + async fn codex_app_server_streams_reasoning_deltas_without_activity_fallback() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp.path().join("fake-codex-app-server-reasoning-delta"); + std::fs::write( + &executable, + r#"#!/bin/sh +IFS= read -r initialize +case "$initialize" in *'"method":"initialize"'*) ;; *) exit 51 ;; esac +printf '%s\n' '{"id":1,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}' +IFS= read -r initialized +case "$initialized" in *'"method":"initialized"'*) ;; *) exit 52 ;; esac +IFS= read -r thread_start +case "$thread_start" in *'"method":"thread/start"'*) ;; *) exit 53 ;; esac +printf '%s\n' '{"id":2,"result":{"thread":{"id":"thread-1"}}}' +IFS= read -r turn_start +case "$turn_start" in *'"method":"turn/start"'*) ;; *) exit 54 ;; esac +printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-1","items":[],"status":"inProgress"}}}' +printf '%s\n' '{"method":"item/reasoning/textDelta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"reasoning-1","delta":"SECRET_REASONING_TEXT"}}' +printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","delta":"{\"toolCalls\":[]}"}}' +printf '%s\n' '{"method":"item/completed","params":{"completedAtMs":1,"threadId":"thread-1","turnId":"turn-1","item":{"id":"item-1","type":"agentMessage","text":"{\"toolCalls\":[]}"}}}' +printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thread-1","turn":{"id":"turn-1","items":[],"status":"completed"}}}' +while IFS= read -r line; do :; done +"#, + ) + .expect("write fake app-server"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake app-server"); + + let llm = test_llm(); + let connection = + CodexAppServerConnection::spawn_with_executable(&llm, executable.as_os_str()) + .await + .expect("spawn fake app-server"); + let mut observations = Vec::new(); + let mut observer = |observation| observations.push(observation); + connection + .run_turn_with_direct_observer( + &test_snapshot(), + &llm, + tool_request(), + None, + Some(&mut observer), + None, + ) + .await + .expect("run fake app-server turn"); + drop(observer); + assert!( + !observations.iter().any(|observation| matches!( + observation, + DirectCodexTurnObservation::Activity("preparing") + )), + "思考增量必须走 ReasoningDelta 正文通道,不能降级成 preparing 活动" + ); + assert!( + !format!("{observations:?}").contains("SECRET_REASONING_TEXT"), + "明文思考不得漏进运行态 observation" + ); + } + #[cfg(unix)] #[tokio::test] async fn direct_home_app_server_uses_read_only_protocol_and_rejects_file_change_items() { -- 2.52.0 From d348cf9064429e872a5ac293f0b6106e054c3a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 13:42:35 +0800 Subject: [PATCH 69/92] =?UTF-8?q?=E7=BA=BF=E7=A8=8B=20id=20=E5=8F=AA?= =?UTF-8?q?=E6=8C=89=E8=B7=AF=E5=BE=84=E5=BD=92=E4=B8=80=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=8F=97=20manifest=20=E5=8F=AF=E8=AF=BB=E6=80=A7?= =?UTF-8?q?=E5=BD=B1=E5=93=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - direct_thread_id_for_project 改用 resolve_direct_codex_project_authority(存在/是目录/canonicalize) - 不再读 .agent/manifest.json:那次读取是连接池权威身份的要求,线程 id 只是路径 key - 否则 manifest 在订阅时读失败、跑回合时读成功,会让符号链接等非 canonical 调用方路径算出两个线程 id,订阅绑到空线程 - 新增 direct_project_thread_id_ignores_manifest_readability,旧实现下会退回调用方原始字符串(已做变异验证) --- .../direct_project_identity.rs | 11 +++-- .../src/agent/codex_app_server/mod.rs | 44 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs index fd2068ae9..98f347d30 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs @@ -23,10 +23,15 @@ pub(crate) fn direct_codex_canonical_project_identity( /// 项目根目录在 Thread Manager 里的线程身份。 /// /// 订阅入口、回合事件写入和"回合被兜底释放"三处必须算出同一个字符串,否则前端会订阅到 -/// 一个永不产生事件的空线程;因此这里统一做一次权威路径归一。归一失败(例如 manifest -/// 暂时读不到)时退回调用方给的路径,至少保持同一入口同一结果。 +/// 一个永不产生事件的空线程。这个字符串**只取决于路径**:能归一就用 canonical 路径,只有 +/// 归一本身失败(路径不存在 / 不是目录 / 无法安全解析)才退回调用方给的字符串。 +/// +/// 这里刻意不读 `.agent/manifest.json`:那次读取是"项目权威身份"(连接池摘要,见 +/// `direct_codex_canonical_project_identity`)的要求,而线程 id 只是一个路径 key。把 +/// manifest 的瞬时抖动混进线程 id,会让同一项目在"订阅那一刻"与"跑回合那一刻"算出两个 +/// 字符串(例如调用方给的是符号链接路径),订阅就绑到一条永远不会有事件的空线程上。 pub(crate) fn direct_thread_id_for_project(root: &std::path::Path) -> String { - let Ok((canonical_root, _)) = direct_codex_canonical_project_identity(root) else { + let Ok((canonical_root, _)) = resolve_direct_codex_project_authority(root) else { return root.to_string_lossy().into_owned(); }; canonical_root diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 83c7de9f9..06569e388 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -5225,6 +5225,50 @@ mod tests { assert_ne!(first_identity, second_identity); } + /// 判据:线程 id 只由路径决定,不受 manifest 可读性影响。 + /// + /// 变异验证:线程 id 走 `direct_codex_canonical_project_identity`(旧实现)时,manifest + /// 读不到会让非 canonical 的调用方路径退回"原始字符串",订阅与回合事件因此落在两条线程上。 + #[cfg(unix)] + #[test] + fn direct_project_thread_id_ignores_manifest_readability() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("temp dir"); + let project = temp.path().join("thread-id-project"); + init_local_game_project_at(&project, "thread-id-project", "线程身份项目") + .expect("initialize project"); + let stable_link = temp.path().join("current-project"); + symlink(&project, &stable_link).expect("project symlink"); + + let canonical = direct_thread_id_for_project(&project); + assert_eq!( + canonical, + project + .canonicalize() + .expect("canonical project") + .to_string_lossy(), + "线程 id 就是 canonical 路径" + ); + assert_eq!( + direct_thread_id_for_project(&stable_link), + canonical, + "符号链接必须归一到同一个线程 id" + ); + + // manifest 暂时读不到(项目刚创建 / 正被替换)不能让线程 id 变样。 + std::fs::remove_file(project.join(".agent/manifest.json")).expect("remove manifest"); + assert!( + direct_codex_canonical_project_identity(&project).is_err(), + "前提:manifest 读不到时权威身份确实会失败" + ); + assert_eq!( + direct_thread_id_for_project(&stable_link), + canonical, + "manifest 读失败不能把线程 id 退回调用方原始字符串" + ); + } + #[test] fn direct_project_pool_identity_changes_when_manifest_identity_is_replaced_in_place() { let temp = tempfile::tempdir().expect("temp dir"); -- 2.52.0 From 9993b2feb5bb4f19e5d6762fd9f466b3ea9df775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 13:47:32 +0800 Subject: [PATCH 70/92] =?UTF-8?q?=E9=80=9A=E7=9F=A5=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E6=94=B6=E6=95=9B=E6=88=90=E7=94=9F=E4=BA=A7=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E5=99=A8=E4=B8=8E=E5=8D=95=E6=B5=8B=E5=85=B1=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - direct_codex_notification_event 收编 resolution / request / item / rawItem / Terminal 全部分支,并接收 turnId - 读取器删掉自己那份内联分类,只保留「必须有 turnId 才处理」与活动/正文节流 - agentMessage 缺 itemId 的告警与 direct-missing-item:{turnId} 兜底身份原样保留,避免行为变化 - 新增 direct_notification_classification_covers_the_reader_branches 钉住各分支;E2E 假 app-server 用例继续覆盖读取器 --- .../src/agent/codex_app_server/mod.rs | 183 +++++++++++------- 1 file changed, 117 insertions(+), 66 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 06569e388..9be967bcd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -1092,15 +1092,29 @@ fn direct_codex_reasoning_delta_event( }) } +/// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。 +/// +/// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外 +/// 再做一遍——两份实现分叉过一次,结果是生产路径从不产生 `ReasoningDelta`。 +/// +/// Preparing notifications may carry private plan/reasoning text; expose only the safe +/// activity category. Other categories may retain their bounded, redacted intermediate text. fn direct_codex_notification_event( method: &str, params: &serde_json::Value, intermediate_text: Option, safe_activity: Option<&'static str>, + turn_id: &str, ) -> Option { if let Some(event) = direct_codex_reasoning_delta_event(method, params) { return Some(event); } + if let Some(kind) = direct_codex_resolution_event_type(method) { + return Some(CodexTurnEvent::Request { + kind, + params: params.clone(), + }); + } let (activity, intermediate_text) = match (&intermediate_text, safe_activity) { (Some(_), Some(activity)) if activity == "preparing" => (Some(activity), None), _ => (safe_activity, intermediate_text), @@ -1115,14 +1129,19 @@ fn direct_codex_notification_event( "item/agentMessage/delta" => params .get("delta") .and_then(serde_json::Value::as_str) - .filter(|value| !value.trim().is_empty()) + .filter(|value| !value.is_empty()) .map(|delta| { let item_id = params .get("itemId") .and_then(serde_json::Value::as_str) .filter(|value| !value.is_empty()) .map(str::to_string) - .unwrap_or_else(|| "direct-missing-item".to_string()); + .unwrap_or_else(|| { + eprintln!( + "agent.direct_codex.protocol_warning event=item/agentMessage/delta missing_item_id" + ); + format!("direct-missing-item:{turn_id}") + }); CodexTurnEvent::AgentMessageDelta { item_id, delta: delta.to_string(), @@ -1138,6 +1157,13 @@ fn direct_codex_notification_event( .cloned() .unwrap_or(serde_json::Value::Null), )), + method if direct_codex_request_event_type(method).is_some() => { + Some(CodexTurnEvent::Request { + kind: direct_codex_request_event_type(method) + .expect("request kind checked above"), + params: params.clone(), + }) + } _ => Some(CodexTurnEvent::Terminal(params.clone())), } } @@ -3999,8 +4025,8 @@ async fn read_game_creator_codex_app_server_stdout( // 思考正文走正文通道,不参与 `preparing` 活动的降级与节流:活动节流按类别抑制 // 连续的 preparing,逐段思考正文会被整段吃掉——这正是生产路径此前从不产生 // `ReasoningDelta` 的原因。正文仍沿用下面的正文节流,避免重复 chunk 反复入队。 - let reasoning_delta = direct_codex_reasoning_delta_event(method, ¶ms); - if reasoning_delta.is_none() { + let is_reasoning_delta = direct_codex_reasoning_delta_event(method, ¶ms).is_some(); + if !is_reasoning_delta { if let Some(activity) = safe_activity { let last_activity = last_direct_activity_by_turn .entry(turn_id.clone()) @@ -4018,68 +4044,14 @@ async fn read_game_creator_codex_app_server_stdout( continue; } } - let event = if let Some(event) = reasoning_delta { - event - } else if let Some(kind) = direct_codex_resolution_event_type(method) { - CodexTurnEvent::Request { kind, params } - } else if let Some(activity) = safe_activity { - // Preparing notifications may carry private plan/reasoning text; - // expose only the safe activity category. Other categories may - // retain their bounded, redacted intermediate text below. - if activity == "preparing" { - CodexTurnEvent::Activity(activity) - } else if let Some(text) = intermediate_text { - CodexTurnEvent::IntermediateText(text) - } else { - CodexTurnEvent::Activity(activity) - } - } else if let Some(text) = intermediate_text { - CodexTurnEvent::IntermediateText(text) - } else { - match method { - "item/agentMessage/delta" => { - let Some(delta) = params - .get("delta") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - else { - continue; - }; - let item_id = params - .get("itemId") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| { - eprintln!( - "agent.direct_codex.protocol_warning event=item/agentMessage/delta missing_item_id" - ); - format!("direct-missing-item:{turn_id}") - }); - CodexTurnEvent::AgentMessageDelta { - item_id, - delta: delta.to_string(), - } - } - "item/started" | "item/completed" => CodexTurnEvent::Item { - completed: method == "item/completed", - params, - }, - "rawResponseItem/completed" => CodexTurnEvent::RawItem( - params - .get("item") - .cloned() - .unwrap_or(serde_json::Value::Null), - ), - method if direct_codex_request_event_type(method).is_some() => { - CodexTurnEvent::Request { - kind: direct_codex_request_event_type(method) - .expect("request kind checked above"), - params, - } - } - _ => CodexTurnEvent::Terminal(params), - } + let Some(event) = direct_codex_notification_event( + method, + ¶ms, + intermediate_text, + safe_activity, + &turn_id, + ) else { + continue; }; let sender = if method == "turn/completed" { last_direct_activity_by_turn.remove(&turn_id); @@ -4824,6 +4796,7 @@ mod tests { &reasoning, Some("思考正文".to_string()), Some("preparing"), + "turn-1", ), Some(CodexTurnEvent::ReasoningDelta { .. }) ), @@ -4850,6 +4823,7 @@ mod tests { &plan, Some("private plan text must not leak".to_string()), Some("preparing"), + "turn-1", ), Some(CodexTurnEvent::Activity("preparing")) )); @@ -4861,11 +4835,88 @@ mod tests { &command_output, None, Some("command-exec"), + "turn-1", ), Some(CodexTurnEvent::Activity("command-exec")) )); } + /// 判据:读取器与单测共用同一个分类函数,这些分支的行为被钉在这里。 + /// + /// 参数:method / params / 正文候选 / 安全活动类别 / turnId。 + #[test] + fn direct_notification_classification_covers_the_reader_branches() { + let empty = serde_json::json!({}); + let resolved = serde_json::json!({ "requestId": "request-1" }); + assert!(matches!( + direct_codex_notification_event( + "serverRequest/resolved", + &resolved, + None, + None, + "turn-1" + ), + Some(CodexTurnEvent::Request { + kind: DirectThreadRequestKind::RequestResolved, + .. + }) + )); + let approval = serde_json::json!({ "requestId": "request-2" }); + assert!(matches!( + direct_codex_notification_event( + "item/fileChange/requestApproval", + &approval, + None, + None, + "turn-1", + ), + Some(CodexTurnEvent::Request { + kind: DirectThreadRequestKind::ApprovalRequested, + .. + }) + )); + assert!(matches!( + direct_codex_notification_event("item/started", &empty, None, None, "turn-1"), + Some(CodexTurnEvent::Item { + completed: false, + .. + }) + )); + assert!(matches!( + direct_codex_notification_event( + "rawResponseItem/completed", + &serde_json::json!({ "item": { "type": "reasoning" } }), + None, + None, + "turn-1", + ), + Some(CodexTurnEvent::RawItem(_)) + )); + // 空正文的 assistant 增量既不产事件,也不产 Terminal 兜底。 + assert!(direct_codex_notification_event( + "item/agentMessage/delta", + &serde_json::json!({ "delta": "" }), + None, + None, + "turn-1", + ) + .is_none()); + assert!(matches!( + direct_codex_notification_event( + "item/agentMessage/delta", + &serde_json::json!({ "delta": "正文" }), + None, + None, + "turn-1", + ), + Some(CodexTurnEvent::AgentMessageDelta { delta, .. }) if delta == "正文" + )); + assert!(matches!( + direct_codex_notification_event("turn/completed", &empty, None, None, "turn-1"), + Some(CodexTurnEvent::Terminal(_)) + )); + } + fn test_llm() -> GameCreatorLlmConfig { GameCreatorLlmConfig { api_key: "fixture-secret".to_string(), -- 2.52.0 From b543a060b75ab17be89a91d025de93eb09b03164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 14:01:41 +0800 Subject: [PATCH 71/92] =?UTF-8?q?=E6=94=BE=E5=AE=BD=20DirectProject=20cont?= =?UTF-8?q?ent=20=E6=A0=A1=E9=AA=8C=E4=B8=BA=E3=80=8C=E6=95=B4=E6=9D=A1?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E3=80=8D=E5=B9=B6=E6=8C=89=E9=80=90=E5=AD=97?= =?UTF-8?q?=E6=8A=95=E5=BD=B1=EF=BC=88review=20=E7=AC=AC=201=20=E6=9D=A1?= =?UTF-8?q?=EF=BC=8C=E2=91=A0=20=E5=8F=A3=E5=BE=84=EF=BC=89=20-=20Rust=20v?= =?UTF-8?q?alidate=5Fdirect=5Fcodex=5Fuser=5Fitem=20=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=8F=AA=E5=88=A4=E6=95=B4=E6=9D=A1=20content=EF=BC=9A?= =?UTF-8?q?=E6=9C=89=E4=B8=80=E6=AE=B5=E9=9D=9E=E7=A9=BA=E7=99=BD=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E6=88=96=E4=BB=BB=E6=84=8F=E9=9D=9E=E6=96=87=E6=9C=AC?= =?UTF-8?q?=20part=20=E5=8D=B3=E6=9C=89=E6=95=88=EF=BC=8C=E5=8D=95?= =?UTF-8?q?=E4=B8=AA=E7=BA=AF=E7=A9=BA=E7=99=BD=20input=5Ftext=EF=BC=88?= =?UTF-8?q?=E6=AE=B5=E8=90=BD=E5=88=86=E9=9A=94=20/=20=E8=BD=AF=E6=8D=A2?= =?UTF-8?q?=E8=A1=8C=20/=20chip=20=E5=90=8E=E7=9A=84=E5=88=86=E9=9A=94?= =?UTF-8?q?=E7=A9=BA=E6=A0=BC=EF=BC=89=E5=90=88=E6=B3=95=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E9=80=90=E4=B8=AA=20part=20=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E7=A9=BA=E6=96=87=E6=9C=AC=20-=20=E6=96=B0=E5=A2=9E=20content?= =?UTF-8?q?=5Fhas=5Fmeaningful=5Finput=EF=BC=8C=E5=B9=B6=E4=B8=8E=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=20hasMeaningfulDirectCodexContent=20=E5=90=8C?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=EF=BC=9Bwire.rs=20=E7=9A=84=E3=80=8C?= =?UTF-8?q?=E4=B8=8D=E8=83=BD=E8=BD=AC=E6=8D=A2=E4=B8=BA=E7=A9=BA=20prompt?= =?UTF-8?q?=E3=80=8D=E5=8F=AA=E4=BD=9C=E5=85=9C=E5=BA=95=20-=20ResourceRef?= =?UTF-8?q?erenceInput=20=E7=9A=84=E6=8A=95=E5=BD=B1=E5=B1=82=E5=8E=9F?= =?UTF-8?q?=E6=A0=B7=E9=80=8F=E4=BC=A0=E7=BC=96=E8=BE=91=E5=99=A8=E8=8A=82?= =?UTF-8?q?=E7=82=B9=EF=BC=9A=E4=B8=8D=E5=81=9A=E7=A9=BA=E7=99=BD=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E3=80=81=E4=B9=9F=E4=B8=8D=E4=B8=8E=E7=9B=B8=E9=82=BB?= =?UTF-8?q?=20part=20=E5=90=88=E5=B9=B6=EF=BC=8C=E5=88=A0=E6=8E=89=20appen?= =?UTF-8?q?dInputText=20=E4=B8=8E=E4=B8=AD=E9=97=B4=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E7=9A=84=E3=80=8C=E5=90=91=E5=89=8D=E5=90=88=E5=B9=B6=E3=80=8D?= =?UTF-8?q?=E6=94=B6=E9=9B=86=E5=99=A8=20-=20=E6=8C=89=E9=80=90=E5=AD=97?= =?UTF-8?q?=E6=8A=95=E5=BD=B1=E6=9B=B4=E6=96=B0=E8=B5=84=E6=BA=90=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E3=80=81Godot=20=E5=9B=9E=E5=90=88=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E7=9A=84=20canonical=20content=20=E6=96=AD=E8=A8=80=20-=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E9=87=8C=E7=A8=8B=E7=A2=91=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=BB=93=E8=AE=BA=E4=B8=8E=E8=AF=81=E6=8D=AE=E3=80=81pitfalls?= =?UTF-8?q?=20=E6=9D=A1=E7=9B=AE=EF=BC=9BRust=20validation/wire=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E3=80=8C=E5=8D=95=E4=B8=AA=E7=BA=AF=E7=A9=BA?= =?UTF-8?q?=E7=99=BD=20part=20=E9=80=9A=E8=BF=87=E3=80=81=E6=95=B4?= =?UTF-8?q?=E6=9D=A1=E5=85=A8=E7=A9=BA=E7=99=BD=E6=8B=92=E7=BB=9D=E3=80=8D?= =?UTF-8?q?=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../direct_codex_user_item/validation.rs | 63 +++++++++++++++++-- .../src/agent/direct_codex_user_item/wire.rs | 47 +++++++++++++- .../ResourceReferenceInput.tsx | 56 +++++------------ .../appSurface/project-development.suite.ts | 8 ++- .../tests/resourceReferenceInput.test.tsx | 49 +++++++++------ ...Project canonical content严格边界-2026-09-16.md | 9 +-- docs/project-memory/shared-memory/pitfalls.md | 8 +-- 7 files changed, 165 insertions(+), 75 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs index dfe3dda4d..8de0f9440 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs @@ -20,18 +20,16 @@ pub(crate) fn validate_direct_codex_user_item( if message.id.trim().is_empty() { return Err("DirectProject user item 缺少稳定 id".to_string()); } - if message.content.is_empty() { + // 有效输入只判一整条 content:单个纯空白 `input_text` 是合法 part —— 编辑器里的段落 + // 分隔、软换行与 chip 后的分隔空格就是这样落进 canonical content 的,前端不为它过滤。 + if !content_has_meaningful_input(&message.content) { return Err("DirectProject user item content 不能为空".to_string()); } let manifest = read_manifest_for_project(root)?; let mut reference_count = 0usize; for part in &message.content { match part { - DirectCodexUserContentPart::InputText { text } => { - if text.trim().is_empty() { - return Err("DirectProject input_text 不能为空".to_string()); - } - } + DirectCodexUserContentPart::InputText { .. } => {} DirectCodexUserContentPart::AgcResourceReference { resource_id } => { reference_count = reference_count.saturating_add(1); validate_resource_id_and_manifest(&manifest, resource_id)?; @@ -60,6 +58,14 @@ pub(crate) fn validate_direct_codex_user_item( Ok(()) } +/// 整条 content 是否还有有效输入:任何一段非空白文本、或任何一个非文本 part 都算。 +pub(crate) fn content_has_meaningful_input(content: &[DirectCodexUserContentPart]) -> bool { + content.iter().any(|part| match part { + DirectCodexUserContentPart::InputText { text } => !text.trim().is_empty(), + _ => true, + }) +} + pub(crate) fn validate_resource_id_and_manifest( manifest: &GameCreationAppManifest, resource_id: &str, @@ -98,3 +104,48 @@ fn validate_runtime_region_reference( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::content_has_meaningful_input; + use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart; + + fn input_text(text: &str) -> DirectCodexUserContentPart { + DirectCodexUserContentPart::InputText { + text: text.to_string(), + } + } + + #[test] + fn only_all_blank_content_counts_as_empty_input() { + // 空数组与「整条只有空白」是同一种空输入。 + assert!(!content_has_meaningful_input(&[])); + assert!(!content_has_meaningful_input(&[input_text(" \n ")])); + assert!(!content_has_meaningful_input(&[ + input_text("\n"), + input_text(" "), + ])); + } + + #[test] + fn whitespace_parts_are_valid_next_to_meaningful_input() { + // 段落分隔 / 软换行 / chip 后的分隔空格都是合法的单个 part。 + assert!(content_has_meaningful_input(&[ + input_text("\n"), + input_text("看"), + ])); + assert!(content_has_meaningful_input(&[ + input_text("看"), + input_text("\n\n"), + ])); + } + + #[test] + fn non_text_parts_always_count_as_input() { + assert!(content_has_meaningful_input(&[ + DirectCodexUserContentPart::AgcResourceReference { + resource_id: "asset-hero".to_string(), + }, + ])); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index 9c43406e6..a05daef41 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -144,7 +144,8 @@ pub(crate) fn direct_codex_user_item_to_prompt( #[cfg(test)] mod tests { - use super::direct_codex_user_item_to_response_item; + use super::{direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input}; + use crate::agent::direct_codex_user_item::model::DirectCodexUserItem; use serde_json::json; use std::path::Path; @@ -210,4 +211,48 @@ mod tests { assert!(content[0]["text"].as_str().unwrap().contains("先看")); assert!(content[1]["text"].as_str().unwrap().contains("notes.txt")); } + + #[test] + fn whitespace_only_text_parts_survive_validation() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") + .expect("init project"); + let item: DirectCodexUserItem = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [ + {"type": "input_text", "text": "先看"}, + {"type": "input_text", "text": "\n"}, + {"type": "input_text", "text": " "} + ] + })) + .expect("deserialize user item"); + let wire = direct_codex_user_item_to_wire_input(root.path(), &item) + .expect("whitespace-only part next to real text must pass"); + let parts = wire.as_array().expect("wire input array"); + assert_eq!(parts.len(), 3); + assert_eq!(parts[1]["text"].as_str(), Some("\n")); + assert_eq!(parts[2]["text"].as_str(), Some(" ")); + } + + #[test] + fn all_blank_content_is_rejected() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") + .expect("init project"); + let item: DirectCodexUserItem = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [ + {"type": "input_text", "text": "\n"}, + {"type": "input_text", "text": " "} + ] + })) + .expect("deserialize user item"); + let error = direct_codex_user_item_to_wire_input(root.path(), &item) + .expect_err("all-blank content must fail closed"); + assert!(error.contains("不能为空"), "{error}"); + } } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 240489cda..351f68d0e 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -175,61 +175,39 @@ class ResourceMentionOption extends MenuOption { } /** - * 收集过程中的待写文本。 - * - * 只有紧挨着上一段文本时才能直接追加;上一 part 是引用(chip)时先把文本攒起来,等 - * 下一个文本到来再合并成同一个 part。否则段落分隔符(root 子节点之间补的 `\n`)和软 - * 换行会被整段丢掉,chip 后面的文字会粘成 `@素材下一段`,并原样进 agent 与队列文案。 - * 攒到最后仍然只有空白的(以换行结尾、两个 chip 之间只隔一个换行)不落成 part: - * Rust `validate_direct_codex_user_item` 会拒绝空 `input_text`。 + * 编辑器节点 → canonical content part,**原样透传**:段落分隔(root 子节点之间补的 + * `\n`)、软换行、chip 后的分隔空格都各自成 part,不做空白过滤、不与相邻 part 合并。 + * 有效输入只在整条 content 上判定(`hasMeaningfulDirectCodexContent` 与 Rust + * `validate_direct_codex_user_item` 同口径),前端不替用户改写他输入了什么。 */ -type DraftContentCollector = { - content: DirectCodexUserContentPart[]; - pendingText: string; -}; - -function appendInputText(collector: DraftContentCollector, text: string) { - if (!text) { - return; - } - const { content } = collector; - const previous = content[content.length - 1]; - if (collector.pendingText === '' && previous?.type === 'input_text') { - previous.text += text; - return; - } - collector.pendingText += text; - if (!collector.pendingText.trim()) { - return; - } - content.push({ type: 'input_text', text: collector.pendingText }); - collector.pendingText = ''; -} - function collectDraftParts( node: LexicalNode, references: ChatReference[], - collector: DraftContentCollector, + content: DirectCodexUserContentPart[], ) { if ($isTextNode(node)) { - appendInputText(collector, node.getTextContent()); + const text = node.getTextContent(); + // Lexical 不会留下空 TextNode;这只防「空串 part」落进 app-server 输入。 + if (text) { + content.push({ type: 'input_text', text }); + } return; } if ($isLineBreakNode(node)) { - appendInputText(collector, '\n'); + content.push({ type: 'input_text', text: '\n' }); return; } if ($isResourceReferenceNode(node)) { references.push(node.__reference); - collector.content.push(chatReferenceToContentPart(node.__reference)); + content.push(chatReferenceToContentPart(node.__reference)); return; } if ($isElementNode(node)) { node.getChildren().forEach((child, index) => { if (index > 0 && node.getType() === 'root') { - appendInputText(collector, '\n'); + content.push({ type: 'input_text', text: '\n' }); } - collectDraftParts(child, references, collector); + collectDraftParts(child, references, content); }); } } @@ -242,11 +220,11 @@ type DraftProjection = { /** 仅供编辑器内部派生引用(重建文本草稿时用);对外只暴露 canonical content。 */ function readDraftProjectionFromNodes(): DraftProjection { const references: ChatReference[] = []; - const collector: DraftContentCollector = { content: [], pendingText: '' }; - collectDraftParts($getRoot(), references, collector); + const content: DirectCodexUserContentPart[] = []; + collectDraftParts($getRoot(), references, content); return { references: dedupeChatReferences(references), - content: collector.content, + content, }; } diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index df819a4ab..5f02558a9 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -9342,7 +9342,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(fireEvent.keyDown(composer, { key: 'Enter' })).toBe(false); await waitFor(() => { // canonical content 原样保留编辑器内容:上面的 Shift+Enter 与组合态 Enter - // 在编辑器里各插入一个真实换行,提交时不能被悄悄丢掉。 + // 在编辑器里各插入一个真实换行,提交时不能被悄悄丢掉,也不与相邻文本合并。 expect(invoke).toHaveBeenCalledWith( 'chat_with_game_creator_direct_codex', { @@ -9353,7 +9353,11 @@ export function registerProjectSupervisorSurfaceTests() { id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), type: 'message', role: 'user', - content: [{ type: 'input_text', text: '修改玩家移动脚本\n\n' }], + content: [ + { type: 'input_text', text: '修改玩家移动脚本' }, + { type: 'input_text', text: '\n' }, + { type: 'input_text', text: '\n' }, + ], }, }, ); diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index 953d3d391..b80e18a42 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -85,9 +85,9 @@ async function settleComposer() { }); } -/** 草稿展示文本:canonical content 是唯一真相,引用按稳定 id 展开成 `@id`。 */ +/** 草稿展示文本:canonical content 是唯一真相;这里刻意不传 manifest,引用展开成 `@id`。 */ function draftText(draft: ChatComposerDraft | undefined) { - return directCodexContentToPromptText(draft?.content ?? []); + return directCodexContentToPromptText(draft?.content ?? [], []); } /** 草稿里的资源引用 id,按 content 顺序。 */ @@ -251,7 +251,7 @@ describe('ResourceReferenceInput', () => { expect(screen.getByRole('button', { name: '模拟润色' })).not.toBeNull(); }); - test('引用后面的段落分隔不会被吞掉:读回的 prompt 与编辑器分段逐字一致', async () => { + test('引用后面的段落分隔原样落进 content:读回的 prompt 与编辑器分段逐字一致', async () => { const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); function Controlled() { @@ -285,19 +285,15 @@ describe('ResourceReferenceInput', () => { await settleComposer(); const draft = onChange.mock.calls.at(-1)?.[0]; - // chip 与后一段各自成段:段落分隔必须留在 canonical content 里。丢掉它读回的 - // prompt 会粘成 `@hero把这一版改成夜景`,并原样进 agent、队列文案与润色判据。 + // chip 与后一段各自成段:段落分隔原样留在 canonical content 里(投影不做空白过滤, + // 也不与相邻 part 合并)。丢掉它读回的 prompt 会粘成 `@hero把这一版改成夜景`, + // 并原样进 agent、队列文案与润色判据。 expect(draft?.content).toEqual([ chatReferenceToContentPart(reference), - { type: 'input_text', text: '\n把这一版改成夜景' }, + { type: 'input_text', text: '\n' }, + { type: 'input_text', text: '把这一版改成夜景' }, ]); expect(draftText(draft)).toBe('@hero\n把这一版改成夜景'); - // 换行并进后面那段文本,而不是单独落一个纯空白 part:Rust 会拒绝空 input_text。 - expect( - draft?.content.filter( - (part) => part.type === 'input_text' && !part.text.trim(), - ), - ).toEqual([]); }); test('引用浮层打开时 Enter 不提交表单,关掉后恢复提交', async () => { @@ -365,9 +361,21 @@ describe('ResourceReferenceInput', () => { expect(onChange).toHaveBeenCalled(); }); const draft = onChange.mock.calls.at(-1)?.[0]; - // canonical content 只承载有意义的 part:chip 之间的分隔空格是纯 UI 排版, - // 引用本身由稳定 resourceId 表达。空格也不进内容——Rust 会拒绝纯空白 input_text。 - expect(draftText(draft)).toBe('@hero@enemy'); + // canonical content 是编辑器内容的逐字投影:picker 在每个 chip 后插入的分隔空格 + // 也原样进内容,所以派生文本是 `@hero @enemy`(引用本身仍由稳定 resourceId 表达)。 + const heroPart = chatReferenceToContentPart( + resourceReferenceFromAsset(assets[0]!, 'asset-picker'), + ); + const enemyPart = chatReferenceToContentPart( + resourceReferenceFromAsset(assets[1]!, 'asset-picker'), + ); + expect(draft?.content).toEqual([ + heroPart, + { type: 'input_text', text: ' ' }, + enemyPart, + { type: 'input_text', text: ' ' }, + ]); + expect(draftText(draft)).toBe('@hero @enemy'); expect(draftResourceIds(draft)).toEqual(['hero', 'enemy']); expect( document.querySelector('[data-resource-reference-id="hero"]'), @@ -828,13 +836,15 @@ describe('ResourceReferenceInput', () => { await user.click(screen.getByRole('button', { name: '插入引用' })); await settleComposer(); + // 恢复的草稿文本 + picker 插入的 chip 与它后面的分隔空格,逐字就是编辑器里的内容。 expect( onChange.mock.calls .at(-1)?.[0] - .content.filter((part) => part.type === 'input_text') - .map((part) => (part.type === 'input_text' ? part.text : '')) - .join(''), - ).toBe('恢复出来的草稿'); + .content.filter((part) => part.type === 'input_text'), + ).toEqual([ + { type: 'input_text', text: '恢复出来的草稿' }, + { type: 'input_text', text: ' ' }, + ]); }); test('标签库按 manifest 标签派生:计数只算候选、排序稳定、多标签取交集', () => { @@ -994,6 +1004,7 @@ describe('ResourceReferenceInput', () => { // 「快速编辑」不允许出现第二种引用格式。 expect(chatDraft?.content).toEqual([ { type: 'agc_resource_reference', resourceId: 'hero' }, + { type: 'input_text', text: ' ' }, ]); expect(quickEditDraft).toEqual(chatDraft); expect( diff --git a/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md index 38815e6c1..ffd7767f8 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md @@ -38,13 +38,14 @@ ## 实现结论 -- 唯一的前端空白过滤留在 Lexical 投影层:投影只产出有意义的 `input_text`,因为 Rust `validate_direct_codex_user_item` 会拒绝空 `input_text`。转换函数不再重复过滤。 -- 投影层的过滤口径是「向前合并」而不是「直接丢弃」:上一 part 是引用(chip)时先把待写文本攒住,等下一个文本 part 到来再合并成同一个 part。段落分隔符(root 子节点之间补的 `\n`)与软换行因此不会丢,`@素材` 后面的那一段不会被粘成 `@素材下一段`;攒到最后仍只有空白的(以换行结尾、两个 chip 之间只隔一个换行)不入 content。 -- 显示文本、队列 chip 文案、草稿持久化和润色判据统一由 `directCodexContentToPromptText(content, assets)` 从 content 派生,不再维护并行的 `text` 字段。 +- 前端不再做任何空白过滤:Lexical 投影层原样透传编辑器节点,段落分隔符(root 子节点之间补的 `\n`)、软换行、chip 后的分隔空格都各自成 part,既不丢弃也不与相邻 part 合并 —— 前端不替用户改写他输入的内容。 +- 有效输入只判整条 content:有一段非空白文本或任何一个非文本 part 就算有效输入,单个纯空白 `input_text` 合法。前端 `hasMeaningfulDirectCodexContent` 与 Rust `validate_direct_codex_user_item`(`content_has_meaningful_input`)同口径,Rust 侧不再逐个 part 拒绝空文本;`wire.rs` 的「不能转换为空 prompt」只作兜底。 +- 显示文本、队列 chip 文案、草稿持久化和润色判据统一由 `directCodexContentToPromptText(content, assets)` 从 content 派生,不再维护并行的 `text` 字段;`assets`(当前项目 manifest)必填,`agc_resource_reference` 按 `@显示名` 展开,消息正文与队列 chip 因此逐字一致。 - 需要文本草稿的旧入口(`replaceText`、快速编辑)仍由编辑器把文本 + 引用重建为 content,方向是「文本 → content」,不存在「legacy 字段 → content」的回退。 ## 证据 - `apps/ai-game-creator-shell/tests/resourceReferences.test.ts`:content 原样传递、空白 part 保留、有效性判断、文本派生。 -- `apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx`(含「引用后面的段落分隔不会被吞掉」)、`chatPromptPolish.test.tsx`、`tests/appSurface/*.suite.ts`:草稿读取、提醒判据、队列与 caller 迁移到 content-only。 +- `apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx`(含「引用后面的段落分隔原样落进 content」)、`chatPromptPolish.test.tsx`、`tests/appSurface/*.suite.ts`:草稿读取、提醒判据、队列与 caller 迁移到 content-only,并按逐字投影断言 content。 +- `apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/`:`validation.rs` 的「只有整条 content 全空白才算空输入」与 `wire.rs` 的「单个纯空白 part 通过校验、整条全空白拒绝」用例。 - AGC shell 类型检查、定向 Vitest、`npm run check:encoding`、`git diff --check` 通过。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a2fa70792..a12abf8b1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5620,10 +5620,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` ## 2026-09-16 Lexical 投影丢掉引用后的换行:`@素材` 和下一段粘成一个词 - **现象**:聊天输入区里先 `@` 一个素材、回车换段再写文字,提交出去的 canonical content 里没有任何分隔,直接读成 `@hero把这一版改成夜景`;同一个字符串还会进 agent 输入、队列 chip 文案与润色判据。 -- **原因**:`ResourceReferenceInput` 的 `appendInputText` 只认「上一 part 是 `input_text`」这一种可追加情形,其余一律 `if (text.trim())` 才落 part。root 子节点之间补的段落分隔符与 `LineBreakNode` 传进来的都是 `'\n'`,`trim()` 为空 ⇒ 整段丢掉;chip 后那一段文字随后另起一个 part,派生文本用 `''` 直接拼接,于是粘在一起。 -- **处理**:投影层改成「向前合并」——待写文本先攒在 `pendingText` 上,下一个文本到来时合并成同一个 `input_text`(`\n` 因此落在 `\n把这一版改成夜景` 里);攒到最后仍只有空白的(以换行结尾、两个 chip 之间只隔一个换行)不入 content,因为 Rust `validate_direct_codex_user_item` 会拒绝空 `input_text`,直接按 review 的 `text.includes('\n')` 落 part 会让「chip 换行 chip」这种输入整轮发不出去。 -- **验证**:`apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx` 新增「引用后面的段落分隔不会被吞掉」,断言 chip 后一段的 part 为 `{ type: 'input_text', text: '\n把这一版改成夜景' }`、派生文本逐字一致、且不出现纯空白 part;去掉修复后该用例变红(比对失败在 content 相等那一行)。 -- **关联**:`apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx`(`DraftContentCollector` / `appendInputText`)、`docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md`。 +- **原因**:`3c7b02b9f`(2026-09-15)为了让 content 通过 Rust 的「空 `input_text`」校验,在投影层加了 `appendInputText`(`if (text.trim())` 才落 part,并与相邻文本合并)。root 子节点之间补的段落分隔符与 `LineBreakNode` 传进来的都是 `'\n'`,`trim()` 为空 ⇒ 整段丢掉;chip 后那一段文字随后另起一个 part,派生文本用 `''` 直接拼接,于是粘成 `@hero把这一版改成夜景`。`41366dd71` 又把消息正文 / 队列 chip / 快速编辑的文本派生切到这条投影上,缺陷扩散到界面与出站 prompt。 +- **处理(最终口径)**:不保留任何前端过滤,而是去掉规则和它的成因——Rust `validate_direct_codex_user_item` 改成只判整条 content(`content_has_meaningful_input`:有一段非空白文本或任意非文本 part 即有效),单个纯空白 `input_text` 合法;`ResourceReferenceInput` 的投影原样透传编辑器节点,既不丢空白也不与相邻 part 合并。中间版本(把待写文本「向前合并」到下一个 part)已随之删除:它仍会丢掉尾随换行与「两个 chip 之间只隔一个换行」的分隔,也仍要让前端替用户改写内容。 +- **验证**:Rust `validation.rs` / `wire.rs` 新增「单个纯空白 part 通过校验、整条全空白拒绝」用例;`apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx`「引用后面的段落分隔原样落进 content」断言 `[ref, { type: 'input_text', text: '\n' }, { type: 'input_text', text: '…' }]` 与派生文本逐字一致;`tests/appSurface/project-development.suite.ts` 的 Godot 用例断言 Shift+Enter 的两个换行各自成 part。 +- **关联**:`apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx`(`collectDraftParts`)、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs`、`docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md`。 ## 2026-09-16 派生文本漏传 `manifest.assets`:`@引用` 从显示名退化成内部 id -- 2.52.0 From 6eed6bd13f0d98404442dd93844d40995166f859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 14:02:21 +0800 Subject: [PATCH 72/92] =?UTF-8?q?=E6=94=B6=E6=95=9B=20content=E2=86=92?= =?UTF-8?q?=E5=8F=AF=E8=AF=BB=E6=96=87=E6=9C=AC=E4=B8=BA=E5=8D=95=E4=B8=80?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=E5=B9=B6=E5=BC=BA=E5=88=B6=E4=BC=A0=20manife?= =?UTF-8?q?st=EF=BC=88review=20=E7=AC=AC=202=E3=80=813=20=E6=9D=A1?= =?UTF-8?q?=EF=BC=89=20-=20directCodexContentToPromptText=20=E7=9A=84=20as?= =?UTF-8?q?sets=20=E6=94=B9=E4=B8=BA=E5=BF=85=E5=A1=AB=EF=BC=8C=E5=8E=BB?= =?UTF-8?q?=E6=8E=89=20=3D=20[]=20=E9=BB=98=E8=AE=A4=E5=80=BC=EF=BC=9A?= =?UTF-8?q?=E6=BC=8F=E4=BC=A0=E7=B4=A0=E6=9D=90=E6=B8=85=E5=8D=95=E4=BB=8E?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=9C=9F=E6=96=87=E6=A1=88=E9=80=80=E5=8C=96?= =?UTF-8?q?=E5=8F=98=E5=9B=9E=E7=BC=96=E8=AF=91=E6=9C=9F=E9=94=99=E8=AF=AF?= =?UTF-8?q?=20-=20queuedChatTurnLabel=20=E7=9A=84=20assets=20=E4=B8=8E=20C?= =?UTF-8?q?omposerTurnQueue=20=E7=9A=84=20assets=20=E5=B1=9E=E6=80=A7?= =?UTF-8?q?=E5=90=8C=E6=A0=B7=E5=BF=85=E5=A1=AB=EF=BC=8C=E9=98=9F=E5=88=97?= =?UTF-8?q?=20chip=20=E4=B8=8E=E6=B6=88=E6=81=AF=E6=AD=A3=E6=96=87?= =?UTF-8?q?=E5=85=B1=E7=94=A8=E5=90=8C=E4=B8=80=E4=B8=AA=E6=B4=BE=E7=94=9F?= =?UTF-8?q?=EF=BC=88chip=20=E5=8F=AA=E5=A4=9A=E5=81=9A=E5=8E=8B=E5=8D=95?= =?UTF-8?q?=E8=A1=8C=E4=B8=8E=E9=99=90=E9=95=BF=EF=BC=89=20-=20=E6=96=AD?= =?UTF-8?q?=E8=A8=80=E6=94=B9=E4=B8=BA=E6=98=BE=E5=BC=8F=E4=BC=A0=20[]=20?= =?UTF-8?q?=E6=88=96=20manifest=EF=BC=9AresourceReferences=E3=80=81chat-co?= =?UTF-8?q?mposer=20=E7=94=A8=E4=BE=8B=E6=8C=89=E6=96=B0=E7=AD=BE=E5=90=8D?= =?UTF-8?q?=E6=94=B6=E6=95=9B=20-=20=E6=9B=B4=E6=96=B0=20pitfalls=20?= =?UTF-8?q?=E6=9D=A1=E7=9B=AE=E4=B8=8E=20decision-log=EF=BC=88canonical=20?= =?UTF-8?q?content=20=E6=9C=89=E6=95=88=E6=80=A7=E5=8F=AA=E5=88=A4?= =?UTF-8?q?=E6=95=B4=E6=9D=A1=20content=E3=80=81=E6=8A=95=E5=BD=B1?= =?UTF-8?q?=E9=80=90=E5=AD=97=E9=80=8F=E4=BC=A0=E3=80=81=E5=8F=AF=E8=AF=BB?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E5=8D=95=E4=B8=80=E5=8F=A3=E5=BE=84=E5=BF=85?= =?UTF-8?q?=E5=A1=AB=20assets=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/project-workspace/ComposerControls.tsx | 4 ++-- .../features/project-workspace/chatComposerQueue.ts | 9 +++++---- .../project-workspace/resourceReferences.ts | 10 +++++++++- .../tests/appSurface/chat-composer.suite.ts | 10 +++++----- .../tests/resourceReferences.test.ts | 13 ++++++++----- docs/project-memory/shared-memory/decision-log.md | 10 ++++++++++ docs/project-memory/shared-memory/pitfalls.md | 1 + 7 files changed, 40 insertions(+), 17 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx index 410ee2da7..292ddd245 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx @@ -197,8 +197,8 @@ export function ComposerTurnQueue({ onCancel, }: { turns: readonly QueuedChatTurn[]; - /** 与聊天输入区同源的素材清单:chip 文案里的 `@` 引用按它展开成显示名。 */ - assets?: readonly GameCreationAppAssetManifestEntry[]; + /** 与聊天消息渲染同源的素材清单:chip 文案里的 `@` 引用按它展开成显示名。 */ + assets: readonly GameCreationAppAssetManifestEntry[]; onCancel: (id: string) => void; }) { if (turns.length === 0) { diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts b/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts index 2d88fa4ec..3132e0602 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts @@ -75,14 +75,15 @@ export function chatQueueFullNotice(): string { } /** - * 队列 chip 上显示的文字:单行、有长度上限。 + * 队列 chip 上显示的文字:与真实消息同一个派生(`directCodexContentToPromptText`), + * 再压成单行并限长。 * - * `assets` 必须与聊天输入区同源(`manifest.assets`):`@` 引用要按显示名展开,否则 - * chip 上会露出 `@asset:…` 这样的内部 id,与用户输入时看到的 `@显示名` 对不上。 + * `assets` 与聊天消息渲染同源(`manifest.assets`)且必填:`@` 引用按显示名展开,chip + * 与消息正文逐字一致,不会露出 `@asset:…` 这种内部 id。 */ export function queuedChatTurnLabel( turn: QueuedChatTurn, - assets: readonly GameCreationAppAssetManifestEntry[] = [], + assets: readonly GameCreationAppAssetManifestEntry[], ): string { const text = directCodexContentToPromptText(turn.userItem.content, assets) .trim() diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index f6a8f6712..614c3f651 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -89,9 +89,17 @@ export const EMPTY_CHAT_COMPOSER_DRAFT: ChatComposerDraft = { content: [], }; +/** + * canonical content → 可读文本:**消息正文、队列 chip、润色判据、出站 prompt 只有这一个口径**。 + * + * `assets` 是当前项目的 manifest 且必填:`agc_resource_reference` 按 `@显示名` 展开,与用户 + * 在编辑器里看到、以及聊天消息里渲染出来的字面量逐字一致。没有默认值 —— 漏传素材清单会 + * 静默退化成 `@内部 id`(用户看到的文案与真正落盘的 prompt 分叉)。只有素材已不在清单里时 + * 才回落到 `resourceId`,那是「引用指向已消失的素材」的兜底而不是常态。 + */ export function directCodexContentToPromptText( content: readonly DirectCodexUserContentPart[], - assets: readonly GameCreationAppAssetManifestEntry[] = [], + assets: readonly GameCreationAppAssetManifestEntry[], ) { const labels = new Map( assets.map((asset) => [asset.id, resourceDisplayName(asset)]), diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 53f8df2bd..ba027ccfa 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -223,8 +223,6 @@ export function registerChatComposerControlTests() { expect(queuedChatTurnLabel(turn, manifestAssets)).toBe( '用这张图改一下@hero', ); - // 素材清单不传(老调用方)时退回稳定 id,不抛错。 - expect(queuedChatTurnLabel(turn)).toBe('用这张图改一下@asset:hero'); }); it('keeps queued chat turns in FIFO order and drops only the cancelled one', () => { @@ -241,8 +239,10 @@ export function registerChatComposerControlTests() { // FIFO:先入先出,不丢、不乱序。 const firstOut = dequeueChatTurn(queue); expect(firstOut.next?.clientTurnId).toBe('client-1'); - expect(firstOut.next && queuedChatTurnLabel(firstOut.next)).toBe('第一条'); - expect(firstOut.rest.map((turn) => queuedChatTurnLabel(turn))).toEqual([ + expect(firstOut.next && queuedChatTurnLabel(firstOut.next, [])).toBe( + '第一条', + ); + expect(firstOut.rest.map((turn) => queuedChatTurnLabel(turn, []))).toEqual([ '第二条', '第三条', ]); @@ -250,7 +250,7 @@ export function registerChatComposerControlTests() { // 单条取消只移除那一条,顺序不变。 expect( removeQueuedChatTurn(firstOut.rest, 'turn-2').map((turn) => - queuedChatTurnLabel(turn), + queuedChatTurnLabel(turn, []), ), ).toEqual(['第三条']); expect(removeQueuedChatTurn(firstOut.rest, 'turn-missing')).toHaveLength(2); diff --git a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts index 1e7048cfc..90f987e9b 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts @@ -96,11 +96,14 @@ describe('DirectProject user Response item', () => { it('展示用文本由 content 派生,引用按 @ 显示名展开', () => { expect( - directCodexContentToPromptText([ - { type: 'input_text', text: '用 ' }, - { type: 'agc_resource_reference', resourceId: 'asset-hero' }, - { type: 'input_text', text: ' 做主视觉' }, - ]), + directCodexContentToPromptText( + [ + { type: 'input_text', text: '用 ' }, + { type: 'agc_resource_reference', resourceId: 'asset-hero' }, + { type: 'input_text', text: ' 做主视觉' }, + ], + [], + ), ).toBe('用 @asset-hero 做主视觉'); }); }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6a444a5c2..8bb3685fe 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8775,3 +8775,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:DirectProject 本地上传不区分图片与其它文件。用户使用同一个文件入口,前端不按 `mediaType` 做图片判断,所有上传文件统一写成 `agc_attachment_reference`;项目资源 `@` 引用仍使用 `agc_resource_reference`。 - 原因:原 `agc_image_reference` 与附件 payload 完全相同,Rust wire 投影也把两者合并成同一段文本,不能代表真实的多模态图片输入。保留该 discriminator 只会制造错误语义。 - 边界:本次不新增 `input_image`,不保留图片类型兼容分支,不迁移旧历史;图片多模态能力未来单独设计独立 payload 与 wire 投影。 + +## 2026-09-17 DirectProject canonical content 的有效性只判整条 content + +- 决策:canonical user item 的有效输入判据只落在**整条 content** 上——只要有一段非空白文本、或任何一个非文本 part 就算有效输入;单个纯空白 `input_text`(段落分隔、软换行、chip 后的分隔空格)是合法 part。Rust `validate_direct_codex_user_item` 的 `content_has_meaningful_input` 与前端 `hasMeaningfulDirectCodexContent` 同口径,`wire.rs` 的「不能转换为空 prompt」只作兜底。 +- 决策:编辑器投影层(`ResourceReferenceInput` 的 `collectDraftParts`)原样透传编辑器节点:不做空白过滤,也不与相邻 part 合并。前端不替用户改写他输入的内容,canonical content 与编辑器内容逐字对应。 +- 决策:content → 可读文本只有 `directCodexContentToPromptText(content, assets)` 一个口径,`assets`(当前项目 manifest)必填:消息正文、队列 chip 文案、润色判据、草稿持久化与出站 prompt 全部由它派生,`agc_resource_reference` 按 `@显示名` 展开,只有素材已不在清单里时才回落 `resourceId`。 +- 原因:`3c7b02b9f` 为了让 content 通过「空 `input_text`」校验而在投影层丢空白 part,把引用后的段落分隔一起丢了(`@素材` 与下一段粘成一个词);`41366dd71` 又把消息 / 队列 / 快速编辑的文本派生切到这条投影上,缺陷扩散到界面与出站 prompt。 +- 边界:不新增 content part 类型,不迁移历史(历史 content 原样回放),不为旧口径保留兼容分支;前端仍不发整条全空白的一轮,app-server 输入里出现纯空白 text item 由本决定接受。 +- 验证:Rust `validation.rs` / `wire.rs` 用例「单个纯空白 part 通过校验、整条全空白拒绝」;AGC 侧 `resourceReferenceInput.test.tsx`、`resourceReferences.test.ts`、`appSurface/project-development.suite.ts`(Godot 回合)、`projectResourceLiveIntegration.test.tsx` 改为按逐字投影断言,`ai-game-creator-shell:typecheck` 与定向 vitest 通过。 +- 关联规范:`docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a12abf8b1..6e19018f1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5630,5 +5630,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **现象**:快速编辑面板里 `@` 插了素材再点「修改」,出站 `derive_local_project_resource` 的 `prompt` 是 `把夜色改成星空@source-rules`,而界面 chip 与聊天输入区显示的是 `@rules`;仓库自带的 `projectResourceLiveIntegration` 用例因此长期是红的(`expected '把夜色改成星空@rules' to be '把夜色改成星空@source-rules'`)。同一根因还让排队消息 chip 显示 `@asset:…`。 - **原因**:`directCodexContentToPromptText(content, assets)` 的 `assets` 有默认值 `[]`,漏传不报错、只是把 `agc_resource_reference` 退化成 `${resourceId}`。`ResourceReferenceInput` 自己读草稿的四处都带了 `manifest.assets`,而它的三个消费方漏传:`chatComposerQueue.queuedChatTurnLabel`(宿主 `ComposerTurnQueue` 也没接素材清单)、`project-development/index.tsx` 的 `applyResourceQuickEditPrompt` 与 `applyResourceQuickEditDraft`;后两个的 `useCallback` 依赖里同样没有 `manifest.assets`,改完还会读到旧清单。 - **处理**:三个消费方全部补上素材清单并进依赖数组——`queuedChatTurnLabel(turn, assets)` + `ComposerTurnQueue` 新增 `assets` 属性(由 `ProjectSupervisorView` 传 `chatProjectAssets`)、快速编辑的两处改用 `manifest.assets`。改「比较用的草稿文本」与「落进面板的文本」必须同一个口径,否则 `replaceText` 会每次输入都重跑一遍。 +- **加固**:`directCodexContentToPromptText(content, assets)` 与 `queuedChatTurnLabel(turn, assets)` 的 `assets` 改为**必填**(删掉 `= []` 默认值),测试里刻意不传 manifest 的地方显式写 `[]`。理由:默认值把「漏传素材清单」从编译期错误降级成运行期文案退化,正是本条缺陷的入口;队列 chip 与消息正文从此共用同一个派生(`queuedChatTurnLabel` 只多做「压成单行 + 限长」)。 - **验证**:`apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx` 的「快速编辑提示词里能 @ 出资源选择器」由红转绿(该文件 29 passed);`tests/appSurface/chat-composer.suite.ts` 新增「队列 chip 的 @ 引用按 manifest 显示名展开」;`appSurface.test.ts` 467 passed、定向 51 passed、`npm run ai-game-creator-shell:typecheck`、`npm run check:encoding` 通过。 - **关联**:`apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts`、`ComposerControls.tsx`、`ProjectSupervisorView.tsx`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx`。 -- 2.52.0 From 0caf998227db172abab233b65633cdca2332a15f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 14:03:05 +0800 Subject: [PATCH 73/92] =?UTF-8?q?=E9=87=8D=E6=96=B0=E7=94=9F=E6=88=90=20ts?= =?UTF-8?q?-rs=20=E5=89=8D=E7=AB=AF=E7=BB=91=E5=AE=9A=E5=B9=B6=E8=AE=A9?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E6=8C=89=20null=20=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E6=9E=84=E9=80=A0=E8=BF=90=E8=A1=8C=E6=97=B6=E5=8C=BA=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 跑 export_bindings 后 prettier 格式化:DirectCodexUser* 5 个文件内容更新,新增 DirectCodexUserMessageEnvelope.ts 与 ui-editor 的 BindingChange/BindingDTO - DirectCodexUserRuntimeRegionPart 的可选字段由 `?:` 变成 `T | null`(Rust Option 会显式序列化成 null,不是省略键) - chatReferenceToContentPart 改用 `?? null`,消息条目的 satisfies 目标改为 DirectCodexUserItem(type:'message' 归到信封类型) - ResourceReferenceInput 不再复制一份区段字面量,改为复用 chatReferenceToContentPart - 验证:tsc --noEmit 通过;resourceReferences 等 5 个用例集 81 passed;appSurface 452 passed / 17 skipped(连跑两次) --- .../ResourceReferenceInput.tsx | 21 ++----------------- .../generated/DirectCodexUserContentPart.ts | 3 +-- .../generated/DirectCodexUserItem.ts | 10 ++++++--- .../DirectCodexUserMessageEnvelope.ts | 4 ++++ .../generated/DirectCodexUserMessageItem.ts | 6 ++---- .../generated/DirectCodexUserRole.ts | 2 +- .../DirectCodexUserRuntimeRegionPart.ts | 18 ++++++++-------- .../project-workspace/resourceReferences.ts | 18 ++++++++-------- .../features/ui-editor/types/BindingChange.ts | 6 ++++++ .../features/ui-editor/types/BindingDTO.ts | 4 ++++ 10 files changed, 45 insertions(+), 47 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/BindingChange.ts create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/types/BindingDTO.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 10ac003cc..2b24cc7a6 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -80,6 +80,7 @@ import { type ChatComposerDraft, type ChatReference, chatReferenceListKey, + chatReferenceToContentPart, currentIterationVersionAssets, dedupeChatReferences, refreshResourceReference, @@ -203,25 +204,7 @@ function collectDraftParts( if ($isResourceReferenceNode(node)) { textParts.push(`@${node.__reference.label}`); references.push(node.__reference); - content.push( - node.__reference.type === 'resource' - ? { - type: 'agc_resource_reference', - resourceId: node.__reference.resourceId, - } - : { - type: 'agc_runtime_region_reference', - label: node.__reference.label, - runId: node.__reference.runId, - versionId: node.__reference.versionId, - elementTag: node.__reference.elementTag, - elementRole: node.__reference.elementRole, - text: node.__reference.text, - width: node.__reference.width, - height: node.__reference.height, - resourceIds: node.__reference.resourceIds, - }, - ); + content.push(chatReferenceToContentPart(node.__reference)); return; } if ($isElementNode(node)) { diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts index 10c4e0f5a..eec5662e2 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts @@ -1,5 +1,4 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; export type DirectCodexUserContentPart = diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts index 4c9ea8ca3..aa1ee88dd 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts @@ -1,5 +1,9 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; -export type DirectCodexUserItem = DirectCodexUserMessageItem; +/** + * DirectProject 本轮 user input 的唯一结构化入口。 + */ +export type DirectCodexUserItem = { + type: 'message'; +} & DirectCodexUserMessageItem; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts new file mode 100644 index 000000000..97e0b0dae --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectCodexUserItem } from './DirectCodexUserItem'; + +export type DirectCodexUserMessageEnvelope = { item: DirectCodexUserItem }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts index f7447dc5d..a1101c8cb 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts @@ -1,11 +1,9 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; import type { DirectCodexUserRole } from './DirectCodexUserRole'; export type DirectCodexUserMessageItem = { - type: 'message'; role: DirectCodexUserRole; - content: DirectCodexUserContentPart[]; + content: Array; id: string; }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts index e1dc59c59..0c2540122 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts @@ -1,3 +1,3 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type DirectCodexUserRole = 'user'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts index 296c0d19a..b134fb7bc 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts @@ -1,13 +1,13 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type DirectCodexUserRuntimeRegionPart = { label: string; - runId?: string; - versionId?: string; - elementTag?: string; - elementRole?: string; - text?: string; - width?: number; - height?: number; - resourceIds: string[]; + runId: string | null; + versionId: string | null; + elementTag: string | null; + elementRole: string | null; + text: string | null; + width: number | null; + height: number | null; + resourceIds: Array; }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index c08fba49a..8b163d45a 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -13,7 +13,6 @@ import { import type { DirectCodexUserContentPart, DirectCodexUserItem, - DirectCodexUserMessageItem, } from './generated'; export type ResourceReferenceSource = @@ -100,16 +99,17 @@ export function chatReferenceToContentPart( if (reference.type === 'resource') { return { type: 'agc_resource_reference', resourceId: reference.resourceId }; } + // 生成绑定里可选字段是 `T | null`(Rust `Option` 会显式序列化成 null,不是省略键)。 return { type: 'agc_runtime_region_reference', label: reference.label, - runId: reference.runId, - versionId: reference.versionId, - elementTag: reference.elementTag, - elementRole: reference.elementRole, - text: reference.text, - width: reference.width, - height: reference.height, + runId: reference.runId ?? null, + versionId: reference.versionId ?? null, + elementTag: reference.elementTag ?? null, + elementRole: reference.elementRole ?? null, + text: reference.text ?? null, + width: reference.width ?? null, + height: reference.height ?? null, resourceIds: reference.resourceIds, }; } @@ -135,7 +135,7 @@ export function chatComposerDraftToDirectCodexUserItem( role: 'user', content, id, - } satisfies DirectCodexUserMessageItem; + } satisfies DirectCodexUserItem; } export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) { diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingChange.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingChange.ts new file mode 100644 index 000000000..3bb2c5c4d --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingChange.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NodeComponent } from "./NodeComponent"; +import type { NodeId } from "./NodeId"; +import type { StageStatus } from "./StageStatus"; + +export type BindingChange = { node_id: NodeId, component: NodeComponent, component_status: StageStatus, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingDTO.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingDTO.ts new file mode 100644 index 000000000..beb340894 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingDTO.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BindingChange } from "./BindingChange"; + +export type BindingDTO = { changes: Array, }; -- 2.52.0 From dd339638ccfca29b70ffa2dfa5606d3682fc2741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 14:38:22 +0800 Subject: [PATCH 74/92] =?UTF-8?q?Revert=20"=E4=BF=AE=E5=A4=8D=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E9=87=8D=E8=AF=BB=E5=90=8E=E6=97=A7=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E5=88=B0=E6=9C=AB=E5=B0=BE=E7=9A=84=E4=B9=B1?= =?UTF-8?q?=E5=BA=8F"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 036395c6c979dad4091031c6942c95219d125b2c. --- apps/ai-game-creator-shell/src/App.tsx | 7 ++-- apps/ai-game-creator-shell/src/app/types.ts | 2 - .../tests/directHistoryPagination.test.tsx | 37 ------------------- ...实施计划】AGC对话历史分页恢复-2026-09-16.md | 1 - ...里程碑】AGC对话历史分页恢复-2026-09-16.md | 1 - ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 - 6 files changed, 3 insertions(+), 46 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index be33bfe93..ac1ae9a8b 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -626,7 +626,7 @@ function claimInitialSupervisorMessageForPage(projectPath: string, scope = '') { * 初始需求是**乐观插入**到 messages 的(latch 命中后先插一条 user 消息,再发起回合), * 而历史回读在 replace 分支里是无条件整体替换 —— 只要回读晚于乐观插入,那条用户消息 * 就会被冲掉(界面上看不到初始需求,但回合其实已经跑起来了)。 - * 这里只把当前 messages 里"非历史来源、运行时拥有、且回读结果里没有"的消息保留在末尾。 + * 这里把当前 messages 里"运行时拥有、且回读结果里没有"的消息保留在末尾(它们是最新的)。 */ function mergeLoadedConversationWithPendingRuntimeMessages( loaded: ChatMessage[], @@ -644,7 +644,7 @@ function mergeLoadedConversationWithPendingRuntimeMessages( loaded.map((message) => `${message.role}\u0000${message.text}`), ); const pending = current.filter((message) => { - if (!message.runtimeOwned || message.fromHistory) { + if (!message.runtimeOwned) { return false; } if (message.messageId) { @@ -4202,7 +4202,7 @@ export function App({ const conversationMessages = mergeProjectSupervisorConversation( resolvedProjectConversation.messages, supervisorConversation?.messages ?? [], - ).map((message) => ({ ...message, fromHistory: true })); + ); if ( conversationContainsProjectSupervisorResponseStream( supervisorConversation?.messages ?? [], @@ -12528,7 +12528,6 @@ export function App({ : ('assistant' as const), text: message.content, runtimeOwned: true, - fromHistory: true, messageId: message.messageId, updatedAt: message.updatedAt, })); diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 6fb26adc6..88594678d 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -999,8 +999,6 @@ export interface ChatMessage { agentId?: string | null; updatedAt?: number; runtimeOwned?: boolean; - /** 来自历史回读,不作为尚未落盘的实时消息追加到新历史页末尾。 */ - fromHistory?: boolean; } export type DesignAgentInput = diff --git a/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx b/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx index 8c0f00a19..9b7b410a0 100644 --- a/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx +++ b/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx @@ -97,43 +97,6 @@ afterEach(() => { }); describe('Direct 聊天历史分页集成', () => { - it('加载旧页后重读历史回到最新页,再翻页仍按原顺序且不重复', async () => { - const invoke = install((args) => - args.beforeItemId - ? page(messages.slice(0, 6), false) - : page(messages.slice(6), true), - ); - mount(); - await screen.findByText('历史正文 25'); - fireEvent.click(earlier()); - await screen.findByText('历史正文 0'); - await setComposerText(screen.getByLabelText('陶泥儿对话内容'), '/history'); - fireEvent.click(screen.getByRole('button', { name: '发送' })); - await act(async () => { - await Promise.resolve(); - }); - expect(screen.queryByText('历史正文 0')).toBeNull(); - expect(screen.getByText('历史正文 25')).not.toBeNull(); - fireEvent.click(earlier()); - await screen.findByText('历史正文 0'); - for (let index = 0; index < messages.length; index += 1) { - expect(screen.getAllByText(`历史正文 ${index}`)).toHaveLength(1); - if (index > 0) { - expect( - screen - .getByText(`历史正文 ${index - 1}`) - .compareDocumentPosition(screen.getByText(`历史正文 ${index}`)) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).not.toBe(0); - } - } - expect( - invoke.mock.calls.filter( - ([command]) => command === 'read_direct_project_history_slice', - ), - ).toHaveLength(4); - }); - it('首屏按消息加载20条,更早消息使用原生游标,原始工具记录不占页', async () => { const invoke = install((args) => args.beforeItemId diff --git a/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md b/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md index db90eebeb..97f4d2433 100644 --- a/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md @@ -10,7 +10,6 @@ 2. 工作台首屏与更早消息读取显式请求消息模式,消费游标;加载代次隔离、单飞与 ID 去重。 3. 合成记录测试复现原始工具页卡住的形状,覆盖旧无 ID、坏行、时间、失败/重复/切项目;临时目录只读重放用户日志。 4. 前端/原生定向测试、类型、Lint、编码、文档和差异检查通过后,更新问题表及 PR 草稿并本地提交。 -5. 在临时消息投影中标记历史来源,保留 Runtime 所有权语义;刷新合并时只保留非历史来源的待回读消息。补齐「先加载旧页,再 /history,再翻页」的顺序与去重回归,保持尚未落盘用户输入的保留逻辑。 ## 边界与停止条件 diff --git a/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md b/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md index 4b8e244c4..a247be381 100644 --- a/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md @@ -17,4 +17,3 @@ 2. 消息正文、原始 ID、时间和顺序保持不变,翻页能到达早期用户提问及最终回答,不重复。 3. 连点、请求失败重试、项目切换和同项目重新加载不会污染消息或游标。 4. 原始切片默认模式回归通过,用户日志只读重放可以取回全部现存消息;不把真实日志或对话正文提交到仓库。 -5. 加载更早消息后重新读取历史,旧页不出现在最新回复之后;再次翻页保持顺序且不重复。未落盘的实时用户输入不因历史刷新被丢弃。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 4c39954dc..792e58a6c 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -35,7 +35,6 @@ - 聊天历史使用 `read_direct_project_history_slice` 的 `messagesOnly: true` 模式,按有正文的 user/assistant 消息分页,默认 20 条;工具/推理原始记录不占聊天页名额、不进入聊天分页响应,也不从磁盘删除。接口省略该选项时维持原始 item 切片语义。响应给出明确的 `oldestItemId` 游标;消息投影不能重新发明分页位置。 - 消息模式读取逐行过滤原始记录,不在内存中积累整份工具输出;正文、原始 ID 和信封时间原样保留。旧的无 ID 消息不能凭空生成身份,必要时向前扩展到已有消息 ID 边界;没有更早消息时结束分页。 - 首屏和加载更早消息共用分页解析;重复点击只发一个请求,重叠消息按原始 ID 去重并保留当前显示版本。切项目、同项目重新加载及 A→B→A 的迟到响应不得覆盖当前消息、游标或加载状态;失败保留已有消息与分页位置并允许重试。 -- 历史读取来源与 Runtime 所有权分开记录;从磁盘分页读出的消息不能当作未落盘实时消息保留到新页末尾。重新读取历史时回到最新页,旧页仍可从原生游标再次向前加载;真正尚未回读到的实时消息继续保留,不改原始日志、时间或内容。 - 交付合同:实时消息、历史回读、工具详情与最终回复先归一为按 `clientTurnId` 唯一的回合,再渲染一次。用户消息始终保留;同一回合的正文、工具和耗时不能从消息、实时尾部、未归属尾部等多个出口重复展示。 - 归属来自 `direct-codex:{clientTurnId}:{role}`、文本流中保留的原始 item ID,以及项目历史内明确用户记录之后的 assistant 记录。先在已加载的完整消息集合中关联,再做可见分页;持久历史继续通过 canonical item 切片懒加载,`hasMore` 为真时,未加载回合的工具流不得漂到当前页尾部。禁止将第 N 个有工具回合配给第 N 条用户消息,禁止按文本长度、标点或时间窗猜测归属。缺身份的旧记录保留,不猜造其与其它回合的关联。 - 有回合流时正文与工具位置仅来自 item 边界与 `seq`,工具详情按该回合的 `callId` 关联;没有流时同一个回合容器显示历史消息与工具。整轮累计文本仅在活动回合尚无流和持久 assistant 时作兜底,不另建实时消息出口。 -- 2.52.0 From b37d91882c1c31ca44ff825a37c7cdc6bc73d642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 14:38:39 +0800 Subject: [PATCH 75/92] =?UTF-8?q?Revert=20"=E4=BF=AE=E5=A4=8DDirectProject?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E5=8E=86=E5=8F=B2=E5=88=86=E9=A1=B5=E5=8D=A1?= =?UTF-8?q?=E9=A1=B5"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 1b010fb1f89f7470a0fa99c8ac7dbe5e500077f7. --- .../src/agent/direct_project_history.rs | 236 +----------------- .../src/agent/direct_thread_manager.rs | 1 - .../src-tauri/src/commands.rs | 20 +- apps/ai-game-creator-shell/src/App.tsx | 72 ++---- .../project-workspace/directThreadEvents.ts | 51 +--- .../tests/appSurface/home.suite.ts | 4 +- .../appSurface/project-development.suite.ts | 1 - .../tests/directHistoryPagination.test.tsx | 232 ----------------- .../tests/directThreadEvents.test.ts | 49 ---- ...实施计划】AGC对话历史分页恢复-2026-09-16.md | 25 -- ...施计划】AGC画布交互稳定性修复-2026-09-16.md | 4 +- ...里程碑】AGC对话历史分页恢复-2026-09-16.md | 19 -- docs/project-memory/shared-memory/pitfalls.md | 4 - ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 3 - 14 files changed, 36 insertions(+), 685 deletions(-) delete mode 100644 apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx delete mode 100644 docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md delete mode 100644 docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index ed9e5e576..130c01549 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -482,38 +482,9 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result Result, String> { - read_direct_project_history_entries_filtered_at(root, None, false) -} - -fn is_direct_project_chat_message(item: &Value) -> bool { - matches!( - item.get("role").and_then(Value::as_str), - Some("user" | "assistant") - ) && item - .get("content") - .and_then(Value::as_array) - .is_some_and(|parts| { - parts.iter().any(|part| { - part.get("text") - .and_then(Value::as_str) - .is_some_and(|text| !text.is_empty()) - }) - }) -} - -/// 消息模式逐行丢弃工具输出,只保留聊天正文,避免 40 MiB 工具日志被整表积累或发给 UI。 -fn read_direct_project_history_entries_filtered_at( - root: &Path, - before_item_id: Option<&str>, - messages_only: bool, -) -> Result, String> { let path = history_path(root); if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? { - return if before_item_id.is_some() { - Err("DirectProject 历史游标对应的文件已不存在".to_string()) - } else { - Ok(Vec::new()) - }; + return Ok(Vec::new()); } let file = File::open(&path) .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; @@ -548,12 +519,6 @@ fn read_direct_project_history_entries_filtered_at( if is_direct_project_internal_context_item(&item) { continue; } - if before_item_id.is_some_and(|id| item.get("id").and_then(Value::as_str) == Some(id)) { - return Ok(items); - } - if messages_only && !is_direct_project_chat_message(&item) { - continue; - } items.push(( item, parsed @@ -562,45 +527,7 @@ fn read_direct_project_history_entries_filtered_at( .unwrap_or(0), )); } - match before_item_id { - Some(item_id) => Err(format!("DirectProject 历史中不存在 item:{item_id}")), - None => Ok(items), - } -} - -pub(crate) fn read_direct_project_chat_items_slice_at( - root: &Path, - before_item_id: Option<&str>, - limit: usize, -) -> Result<(Vec, bool, BTreeMap), String> { - let entries = read_direct_project_history_entries_filtered_at(root, before_item_id, true)?; - let mut start = entries.len().saturating_sub(limit.clamp(1, 200)); - // 旧消息可能没有 ID:保留原文,并向前扩到可寻址的已有 ID,不能制造原始消息身份。 - while start > 0 - && entries[start] - .0 - .get("id") - .and_then(Value::as_str) - .is_none_or(str::is_empty) - { - start -= 1; - } - let timestamps = entries[start..] - .iter() - .filter_map(|(item, at)| { - let id = item.get("id").and_then(Value::as_str)?; - (*at > 0).then(|| (id.to_string(), *at)) - }) - .collect(); - Ok(( - entries - .into_iter() - .skip(start) - .map(|(item, _)| item) - .collect(), - start > 0, - timestamps, - )) + Ok(items) } pub(crate) fn read_direct_project_history_items_slice_at( @@ -713,165 +640,6 @@ mod tests { const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#; const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#; - fn write_items(root: &std::path::Path, items: &[Value]) { - let lines = items - .iter() - .enumerate() - .map(|(index, item)| { - json!({"type": "response_item", "payload": item, "recordedAt": 1000 + index}) - .to_string() - }) - .collect::>(); - write_history_lines(root, &lines.iter().map(String::as_str).collect::>()); - } - - #[test] - fn chat_pages_skip_tool_only_tail_and_gaps_without_losing_messages_or_times() { - let root = init_history_project("message-pages"); - let mut raw = Vec::new(); - let mut expected = Vec::new(); - for n in 0..44 { - let item = json!({ - "id": format!("message-{n}"), "type": "message", - "role": if n == 0 || n == 38 { "user" } else { "assistant" }, - "content": [{"type": "output_text", "text": format!("消息 {n}")}], - }); - expected.push(item.clone()); - raw.push(item); - for tool in 0..25 { - raw.push(json!({ - "id": format!("tool-{n}-{tool}"), "type": "function_call_output", - "output": "工具结果不应占聊天页名额", - })); - } - } - write_items(root.path(), &raw); - let path = history_path(root.path()); - let before = std::fs::read(&path).unwrap(); - let (old_page, _, _) = - super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); - assert!(old_page - .iter() - .all(|item| item["type"] == "function_call_output")); - let mut cursor = None; - let mut all = Vec::new(); - let mut sizes = Vec::new(); - loop { - let (mut page, more, timestamps) = - super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20) - .unwrap(); - sizes.push(page.len()); - for item in &page { - let index = raw.iter().position(|raw| raw["id"] == item["id"]).unwrap(); - assert_eq!( - timestamps[item["id"].as_str().unwrap()], - 1000 + index as u64 - ); - } - let next = page - .first() - .and_then(|item| item["id"].as_str()) - .map(str::to_string); - page.append(&mut all); - all = page; - if !more { - break; - } - assert_ne!(next, cursor); - cursor = next; - assert!(sizes.len() < 10); - } - assert_eq!(sizes, vec![20, 20, 4]); - assert_eq!(all, expected); - assert_eq!(std::fs::read(&path).unwrap(), before); - } - - #[test] - fn chat_pages_handle_empty_content_internal_context_and_missing_ids() { - let root = init_history_project("message-page-boundary"); - write_items( - root.path(), - &[ - json!({"id":"u", "role":"user", "content":[{"text":"第一条"}]}), - json!({"role":"assistant", "content":[{"text":"无ID的旧消息"}]}), - json!({"id":"a", "role":"assistant", "content":[{"text":"最后一条"}]}), - json!({"id":"empty", "role":"assistant", "content":[{"text":""}]}), - json!({"id":"internal", "role":"user", "content":[{"text":"内部"}]}), - json!({"id":"reason", "type":"reasoning", "content":[{"text":"推理"}]}), - ], - ); - let (page, more, _) = - super::read_direct_project_chat_items_slice_at(root.path(), None, 1).unwrap(); - assert_eq!(page[0]["id"], "a"); - assert!(more); - let (page, more, _) = - super::read_direct_project_chat_items_slice_at(root.path(), Some("a"), 1).unwrap(); - assert_eq!(page.len(), 2); - assert_eq!(page[0]["id"], "u"); - assert!(page[1].get("id").is_none()); - assert!(!more); - assert!( - super::read_direct_project_chat_items_slice_at(root.path(), Some("missing"), 20) - .is_err() - ); - write_items( - root.path(), - &[json!({"id":"tool", "type":"function_call", "arguments":"{}"})], - ); - let (page, more, _) = - super::read_direct_project_chat_items_slice_at(root.path(), None, 20).unwrap(); - assert!(page.is_empty()); - assert!(!more); - } - - #[test] - #[ignore = "人工只读诊断:通过 AGC_HISTORY_REPLAY_SOURCE 提供原始历史文件"] - fn replay_external_chat_history_pages_without_mutating_source() { - let source = std::env::var_os("AGC_HISTORY_REPLAY_SOURCE").expect("provide replay source"); - let before = std::fs::read(&source).expect("read source"); - let root = init_history_project("external-history-replay"); - let path = history_path(root.path()); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, &before).unwrap(); - let expected = - super::read_direct_project_history_entries_filtered_at(root.path(), None, true) - .expect("read messages"); - let mut cursor = None; - let mut all = Vec::new(); - let mut pages = 0; - loop { - let (mut items, more, _) = - super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20) - .expect("read page"); - let next = items - .first() - .and_then(|item| item["id"].as_str()) - .map(str::to_string); - items.append(&mut all); - all = items; - pages += 1; - if !more { - break; - } - assert!(next.is_some() && next != cursor, "cursor must advance"); - assert!(pages <= expected.len() + 1, "pagination must terminate"); - cursor = next; - } - assert!( - all.iter().eq(expected.iter().map(|(item, _)| item)), - "message order and content must match" - ); - assert!( - std::fs::read(&source).unwrap() == before, - "source must remain unchanged" - ); - eprintln!( - "history replay: messages={}, pages={pages}, users={}", - all.len(), - all.iter().filter(|item| item["role"] == "user").count() - ); - } - #[test] fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() { let root = init_history_project("history-time"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index 1bec45d0a..e3bc497ea 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -59,7 +59,6 @@ pub(crate) struct DirectThreadHistorySlice { pub(crate) items: Vec, pub(crate) has_more: bool, pub(crate) item_timestamps: std::collections::BTreeMap, - pub(crate) oldest_item_id: Option, } #[derive(Clone, Debug)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index f5724810b..273d193ad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5415,29 +5415,19 @@ pub(crate) async fn read_direct_project_history_slice( project_path: String, before_item_id: Option, limit: Option, - messages_only: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; - let read_slice = if messages_only.unwrap_or(false) { - read_direct_project_chat_items_slice_at - } else { - read_direct_project_history_items_slice_at - }; - let (items, has_more, item_timestamps) = - read_slice(root, before_item_id.as_deref(), limit.unwrap_or(20))?; - let oldest_item_id = items - .first() - .and_then(|item| item.get("id")) - .and_then(serde_json::Value::as_str) - .filter(|id| !id.is_empty()) - .map(str::to_string); + let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at( + root, + before_item_id.as_deref(), + limit.unwrap_or(20), + )?; Ok(DirectThreadHistorySlice { items, has_more, item_timestamps, - oldest_item_id, }) }) .await diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index ac1ae9a8b..9523363ac 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -239,11 +239,10 @@ import { DeveloperProjectPanels } from './features/project-workspace/DeveloperPr import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels'; import { type DirectThreadConsumeResult, - directThreadHistoryPage, + directThreadHistoryItemsToMessages, type DirectThreadHistorySlice, type DirectThreadSubscriptionBootstrap, isDirectTurnInProgress, - prependDirectHistoryMessages, } from './features/project-workspace/directThreadEvents'; import { normalizeDirectTimestamp } from './features/project-workspace/directTurnPresentation'; import type { DirectCodexUserContentPart } from './features/project-workspace/generated'; @@ -1959,7 +1958,7 @@ export function App({ ); const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false); const directHistoryOldestItemIdRef = useRef(null); - const directHistoryLoadingRef = useRef(null); + const directHistoryLoadingRef = useRef(false); const [pendingCommand, setPendingCommand] = useState( null, ); @@ -2152,7 +2151,6 @@ export function App({ function resetProjectSupervisorState() { projectSupervisorHistoryLoadVersionRef.current += 1; - directHistoryLoadingRef.current = null; projectSupervisorRuntimeResumeProjectPathRef.current = null; projectSupervisorSessionIdRef.current = null; projectSupervisorRuntimeRef.current = null; @@ -4011,8 +4009,6 @@ export function App({ } const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1; projectSupervisorHistoryLoadVersionRef.current = loadVersion; - if (directCodexProductRuntime) - directHistoryLoadingRef.current = loadVersion; try { // V2 projects do not have a Supervisor run or legacy conversation. Probe the // V2 authority first; a missing V2 session returns null and preserves the @@ -4109,7 +4105,6 @@ export function App({ : await readProjectSupervisorActiveSession(invoke, nextProjectPath); let runtimeError = ''; let loadedDirectHistoryHasMore = false; - let loadedDirectHistoryCursor: string | null = null; const projectConversation = directCodexProductRuntime ? (() => { return invoke( @@ -4117,16 +4112,16 @@ export function App({ { projectPath: nextProjectPath, limit: CONVERSATION_INITIAL_VISIBLE_COUNT, - messagesOnly: true, }, ).then((slice) => { - const page = directThreadHistoryPage(slice); - loadedDirectHistoryHasMore = page.hasMore; - loadedDirectHistoryCursor = page.cursor; + loadedDirectHistoryHasMore = slice.hasMore; return { path: nextProjectPath, agentId: null, - messages: page.messages, + messages: directThreadHistoryItemsToMessages( + slice.items, + slice.itemTimestamps, + ), } satisfies LocalConversationResult; }); })() @@ -4223,7 +4218,9 @@ export function App({ setProjectSupervisorRuntimeError(runtimeError || resumeError); if (directCodexProductRuntime) { setDirectHistoryHasMore(loadedDirectHistoryHasMore); - directHistoryOldestItemIdRef.current = loadedDirectHistoryCursor; + directHistoryOldestItemIdRef.current = + conversationMessages.find((message) => message.messageId) + ?.messageId ?? null; } setMessages((current) => { // replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。 @@ -4284,10 +4281,6 @@ export function App({ : workspaceStatus, ); // Keep the default greeting when history is missing or blocked. - } finally { - if (directHistoryLoadingRef.current === loadVersion) { - directHistoryLoadingRef.current = null; - } } } @@ -4403,9 +4396,6 @@ export function App({ setAgentRunHistoryFiles([]); setAgentRuntimeById({}); setMessages(conversationMessages); - // 只有新项目确实打开后才丢弃旧分页位置;打开失败时旧会话仍可继续翻页。 - directHistoryOldestItemIdRef.current = null; - setDirectHistoryHasMore(false); setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); savedConversationProjectPathRef.current = openedProject.projectPath; savedConversationCountRef.current = conversationMessages.length; @@ -12490,38 +12480,27 @@ export function App({ : null; async function showEarlierConversationMessages() { - if (hiddenConversationCount > 0) { - setConversationVisibleCount((current) => - Math.min(messages.length, current + CONVERSATION_VISIBLE_STEP), - ); - return; - } if (directCodexProductRuntime && directHistoryHasMore) { const invoke = resolveTauriInvoke(); const projectPath = localProject?.projectPath; - if (invoke && projectPath && directHistoryLoadingRef.current === null) { - const loadVersion = projectSupervisorHistoryLoadVersionRef.current; - const beforeItemId = directHistoryOldestItemIdRef.current; - directHistoryLoadingRef.current = loadVersion; - const isCurrentLoad = () => - manifestRefreshMountedRef.current && - localProjectPathRef.current === projectPath && - projectSupervisorHistoryLoadVersionRef.current === loadVersion; + if (invoke && projectPath && !directHistoryLoadingRef.current) { + directHistoryLoadingRef.current = true; try { const slice = await invoke( 'read_direct_project_history_slice', { projectPath, - beforeItemId, + beforeItemId: directHistoryOldestItemIdRef.current, limit: CONVERSATION_VISIBLE_STEP, - messagesOnly: true, }, ); - if (!isCurrentLoad()) { + if (localProjectPathRef.current !== projectPath) { return; } - const page = directThreadHistoryPage(slice, beforeItemId); - const older = page.messages.map((message) => ({ + const older = directThreadHistoryItemsToMessages( + slice.items, + slice.itemTimestamps, + ).map((message) => ({ role: message.role === 'user' ? ('user' as const) @@ -12531,21 +12510,18 @@ export function App({ messageId: message.messageId, updatedAt: message.updatedAt, })); - setMessages((current) => - prependDirectHistoryMessages(current, older), - ); + setMessages((current) => [...older, ...current]); setConversationVisibleCount((current) => current + older.length); - setDirectHistoryHasMore(page.hasMore); - directHistoryOldestItemIdRef.current = page.cursor; + setDirectHistoryHasMore(slice.hasMore); + directHistoryOldestItemIdRef.current = + older.find((message) => message.messageId)?.messageId ?? + directHistoryOldestItemIdRef.current; } catch (error) { - if (!isCurrentLoad()) return; setWorkspaceStatus( `读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`, ); } finally { - if (directHistoryLoadingRef.current === loadVersion) { - directHistoryLoadingRef.current = null; - } + directHistoryLoadingRef.current = false; } } return; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts index 32180fcff..6c1cad229 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts @@ -1,7 +1,4 @@ -import type { - ChatMessage, - LocalConversationMessageRecord, -} from '../../app/types'; +import type { LocalConversationMessageRecord } from '../../app/types'; export type DirectThreadRawEvent = { seq: number; @@ -25,54 +22,8 @@ export type DirectThreadHistorySlice = { items: unknown[]; hasMore: boolean; itemTimestamps?: Record; - oldestItemId?: string | null; }; -/** 游标取原始响应,而非过滤后的聊天消息;拒绝不能前进的页,避免静默反复回读。 */ -export function directThreadHistoryPage( - slice: DirectThreadHistorySlice, - previousCursor: string | null = null, -) { - const first = slice.items[0]; - const firstId = - first && typeof first === 'object' && 'id' in first - ? (first as { id?: unknown }).id - : null; - const cursor = - slice.oldestItemId ?? - (typeof firstId === 'string' && firstId ? firstId : null); - if (slice.hasMore && (!cursor || cursor === previousCursor)) { - throw new Error('对话历史分页游标未前进,请重新读取项目历史'); - } - return { - messages: directThreadHistoryItemsToMessages( - slice.items, - slice.itemTimestamps, - ), - hasMore: slice.hasMore, - cursor, - }; -} - -/** 保留当前实时/已显示版本;原始身份相同的回读消息不能插入第二次。 */ -export function prependDirectHistoryMessages( - current: readonly ChatMessage[], - older: readonly ChatMessage[], -): ChatMessage[] { - const ids = new Set( - current.flatMap((message) => - message.messageId ? [message.messageId] : [], - ), - ); - const additions = older.filter((message) => { - if (!message.messageId) return true; - if (ids.has(message.messageId)) return false; - ids.add(message.messageId); - return true; - }); - return [...additions, ...current]; -} - export function directThreadHistoryItemsToMessages( items: unknown[], itemTimestamps: Readonly> = {}, diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index a1ab75aad..01e5f217d 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2450,7 +2450,7 @@ export function registerHomeProjectCreationTests() { }; } if (command === 'read_direct_project_history_slice') { - expect(args).toEqual({ projectPath, limit: 20, messagesOnly: true }); + expect(args).toEqual({ projectPath, limit: 20 }); return { items: [...persistedMessages], hasMore: false }; } if (command === 'append_local_conversation_message') { @@ -2536,7 +2536,7 @@ export function registerHomeProjectCreationTests() { return manifest; } if (command === 'read_direct_project_history_slice') { - expect(args).toEqual({ projectPath, limit: 20, messagesOnly: true }); + expect(args).toEqual({ projectPath, limit: 20 }); return { items: [...persistedMessages], hasMore: false }; } if (command === 'append_local_permission_log') { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 21c3f481c..49a0d67d6 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -9139,7 +9139,6 @@ export function registerProjectSupervisorSurfaceTests() { expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { projectPath, limit: 20, - messagesOnly: true, }), ); // 默认任务占位行也不能触发专业 Agent 历史的批量读取。 diff --git a/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx b/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx deleted file mode 100644 index 9b7b410a0..000000000 --- a/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx +++ /dev/null @@ -1,232 +0,0 @@ -/** @vitest-environment jsdom */ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import type { DirectThreadHistorySlice } from '../src/features/project-workspace/directThreadEvents'; -import { - act, - App, - createGameCreationAppManifest, - fireEvent, - React, - render, - screen, - setComposerText, -} from './appSurface/harness'; - -const projectPath = '/tmp/direct-message-pages'; -const manifest = createGameCreationAppManifest( - 'direct-message-pages', - '历史分页', -); -const messages = Array.from({ length: 26 }, (_, index) => ({ - id: `direct-codex:turn-${Math.floor(index / 2)}:${index % 2 ? 'assistant' : 'user'}`, - type: 'message', - role: index % 2 ? 'assistant' : 'user', - content: [ - { - type: index % 2 ? 'output_text' : 'input_text', - text: `历史正文 ${index}`, - }, - ], -})); -function page( - items: typeof messages, - hasMore: boolean, -): DirectThreadHistorySlice { - return { items, hasMore, oldestItemId: items[0]?.id ?? null }; -} -function deferred() { - let resolve!: (value: DirectThreadHistorySlice) => void; - let reject!: (error: Error) => void; - const promise = new Promise((yes, no) => { - resolve = yes; - reject = no; - }); - return { promise, resolve, reject }; -} -function install( - read: ( - args: Record, - ) => DirectThreadHistorySlice | Promise, -) { - const invoke = vi.fn( - async (command: string, args?: Record) => { - if (command === 'read_direct_project_history_slice') { - expect(args?.messagesOnly).toBe(true); - return read(args!); - } - if (command === 'read_project_permission_policy') - return { - path: '.agent/policy.json', - policy: { deniedCommands: [], confirmCommands: [] }, - }; - if (command === 'get_local_game_manifest') return manifest; - if (command === 'read_game_creator_app_config') - return { - config: { - selectedModelId: 'quality', - selectedModelIsDefault: true, - llm: { customEnabled: false }, - }, - }; - if ( - command === 'read_direct_tool_calls' || - command === 'read_direct_turn_stream' || - command === 'list_game_creator_direct_active_turns' - ) - return []; - return null; - }, - ); - window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; - return invoke; -} -function mount(path = projectPath) { - return render( - , - ); -} -const earlier = () => screen.getByRole('button', { name: /显示更早/ }); -afterEach(() => { - delete window.__TAURI__; -}); - -describe('Direct 聊天历史分页集成', () => { - it('首屏按消息加载20条,更早消息使用原生游标,原始工具记录不占页', async () => { - const invoke = install((args) => - args.beforeItemId - ? page(messages.slice(0, 6), false) - : page(messages.slice(6), true), - ); - mount(); - await screen.findByText('历史正文 25'); - expect(screen.queryByText('历史正文 0')).toBeNull(); - fireEvent.click(earlier()); - await screen.findByText('历史正文 0'); - expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull(); - expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { - projectPath, - beforeItemId: messages[6]!.id, - limit: 20, - messagesOnly: true, - }); - }); - - it('重复点击单飞,失败保留游标可重试,重叠消息不重复', async () => { - const pending = deferred(); - let attempts = 0; - const invoke = install((args) => { - if (!args.beforeItemId) return page(messages.slice(6), true); - attempts += 1; - return attempts === 1 - ? pending.promise - : page(messages.slice(0, 8), false); - }); - mount(); - await screen.findByText('历史正文 25'); - fireEvent.click(earlier()); - fireEvent.click(earlier()); - expect(attempts).toBe(1); - await act(async () => pending.reject(new Error('模拟读取失败'))); - expect(screen.getByText('历史正文 25')).not.toBeNull(); - fireEvent.click(earlier()); - await screen.findByText('历史正文 0'); - expect(screen.getAllByText('历史正文 6')).toHaveLength(1); - expect(screen.getAllByText('历史正文 7')).toHaveLength(1); - const loads = invoke.mock.calls.filter( - ([command, args]) => - command === 'read_direct_project_history_slice' && args?.beforeItemId, - ); - expect(loads.map(([, args]) => args?.beforeItemId)).toEqual([ - messages[6]!.id, - messages[6]!.id, - ]); - }); - - it('同项目重新加载使旧翻页失效,旧 finally 不解除新请求的单飞', async () => { - const old = deferred(); - const fresh = deferred(); - let fullLoads = 0; - let olderLoads = 0; - const recent = messages.slice(6).map((item) => ({ - ...item, - content: [{ type: 'output_text', text: `重读 ${item.content[0]!.text}` }], - })); - install((args) => { - if (!args.beforeItemId) { - fullLoads += 1; - return page(fullLoads === 1 ? messages.slice(6) : recent, true); - } - olderLoads += 1; - return olderLoads === 1 ? old.promise : fresh.promise; - }); - mount(); - await screen.findByText('历史正文 25'); - fireEvent.click(earlier()); - await setComposerText(screen.getByLabelText('陶泥儿对话内容'), '/history'); - fireEvent.click(screen.getByRole('button', { name: '发送' })); - await screen.findByText('重读 历史正文 25'); - fireEvent.click(earlier()); - await act(async () => - old.resolve( - page( - [ - { - ...messages[0]!, - id: 'stale', - content: [{ type: 'input_text', text: '过期消息' }], - }, - ], - false, - ), - ), - ); - expect(screen.queryByText('过期消息')).toBeNull(); - fireEvent.click(earlier()); - expect(olderLoads).toBe(2); - await act(async () => fresh.resolve(page(messages.slice(0, 6), false))); - await screen.findByText('历史正文 0'); - }); - - it('离开项目并重进后,旧请求不能污染新实例', async () => { - const old = deferred(); - const invoke = install((args) => - args.beforeItemId ? old.promise : page(messages.slice(6), true), - ); - const first = mount(); - await screen.findByText('历史正文 25'); - fireEvent.click(earlier()); - first.unmount(); - const other = mount('/tmp/another-project'); - await screen.findByText('历史正文 25'); - other.unmount(); - mount(); - await screen.findByText('历史正文 25'); - await act(async () => - old.resolve( - page( - [ - { - ...messages[0]!, - id: 'stale', - content: [{ type: 'input_text', text: '旧项目迟到消息' }], - }, - ], - false, - ), - ), - ); - expect(screen.queryByText('旧项目迟到消息')).toBeNull(); - expect(earlier()).not.toBeNull(); - expect( - invoke.mock.calls.filter( - ([command]) => command === 'read_direct_project_history_slice', - ), - ).toHaveLength(4); - }); -}); diff --git a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts index ff7849dfc..128cdf135 100644 --- a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts @@ -2,59 +2,10 @@ import { describe, expect, it } from 'vitest'; import { directThreadHistoryItemsToMessages, - directThreadHistoryPage, isDirectTurnInProgress, - prependDirectHistoryMessages, } from '../src/features/project-workspace/directThreadEvents'; describe('Direct 回合状态与历史时间', () => { - it('游标来自原始切片,不从没有聊天消息的工具页倒推', () => { - const page = directThreadHistoryPage( - { - items: [{ id: 'tool-older', type: 'function_call_output' }], - hasMore: true, - oldestItemId: 'tool-older', - }, - 'message-newer', - ); - expect(page.messages).toEqual([]); - expect(page.cursor).toBe('tool-older'); - }); - it('空历史结束,无 ID 或不前进的非终页明确失败而不循环', () => { - expect(directThreadHistoryPage({ items: [], hasMore: false })).toEqual({ - messages: [], - cursor: null, - hasMore: false, - }); - expect(() => directThreadHistoryPage({ items: [], hasMore: true })).toThrow( - '游标未前进', - ); - expect(() => - directThreadHistoryPage( - { - items: [{ id: 'same' }], - hasMore: true, - oldestItemId: 'same', - }, - 'same', - ), - ).toThrow('游标未前进'); - }); - it('重叠页按原始 ID 去重,保留当前正文,旧无 ID 消息不删除', () => { - const current = [ - { role: 'assistant' as const, text: '完整正文', messageId: 'a' }, - ]; - const old = { role: 'user' as const, text: '用户输入', messageId: 'u' }; - expect( - prependDirectHistoryMessages(current, [ - old, - old, - { role: 'assistant', text: '旧快照', messageId: 'a' }, - { role: 'assistant', text: '无身份旧消息' }, - ]), - ).toEqual([old, { role: 'assistant', text: '无身份旧消息' }, ...current]); - }); - it('终态和空状态不恢复为活动回合', () => { for (const status of [ 'completed', diff --git a/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md b/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md deleted file mode 100644 index 97f4d2433..000000000 --- a/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md +++ /dev/null @@ -1,25 +0,0 @@ -# AGC 对话历史分页恢复实施计划 - -- Date: 2026-09-16 -- Status: awaiting-runtime-acceptance -- Milestone: [AGC 对话历史分页恢复](./【里程碑】AGC对话历史分页恢复-2026-09-16.md) - -## 实施 - -1. 原生历史读取复用逐行解析,增加消息模式,过滤后分页并返回已有原始消息 ID 游标;保持原始接口默认行为和路径权限。 -2. 工作台首屏与更早消息读取显式请求消息模式,消费游标;加载代次隔离、单飞与 ID 去重。 -3. 合成记录测试复现原始工具页卡住的形状,覆盖旧无 ID、坏行、时间、失败/重复/切项目;临时目录只读重放用户日志。 -4. 前端/原生定向测试、类型、Lint、编码、文档和差异检查通过后,更新问题表及 PR 草稿并本地提交。 - -## 边界与停止条件 - -不调整 Direct 消息呈现归属、不修未证明的写入丢失、不上传日志、不触碰用户项目。必要 API 变化仅为本地 IPC 可选参数和游标字段;无 OpenAPI、SpacetimeDB 或持久化迁移。远程推送/PR/WIP 操作仍待额外确认。 - -## 验收证据 - -- 历史消息模型、回合呈现及分页集成共 24 个前端测试通过;包含真实 App 的首屏/更早页、单飞、失败重试、重叠消息、同项目重新加载及离开再进入的迟到响应。 -- 16 个历史原生测试通过;人工日志重放用例在 CI 默认忽略,已在本地单独执行通过。 -- 使用用户提供的原始日志运行修复后的原生读取:44 条现存聊天消息(含 2 条用户消息)分 3 页取回,逐项内容与顺序一致,原文件字节未变;未在仓库保存原始日志。 -- 工作台/Direct 恢复与画布导航的 8 个定向回归通过。真实客户端重新进入与向上翻页尚待用户验收;本次涉及 Rust IPC,需重新构建并启动原生端。 -- 与前面画布/JSON 修复联合复验:119 个前端定向测试、30 个原生测试通过;AGC TypeScript、修改文件 ESLint、编码、文档索引和差异检查通过。 -- 状态只覆盖日志中已经证明的分页卡页,不据此宣称其它可能的未落盘消息也已恢复。 diff --git a/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md b/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md index 9902abd0b..24f16aefa 100644 --- a/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md @@ -32,7 +32,7 @@ | B02 | 初次进入双指平移无效,整理后恢复 | 已优化;用户在本轮反馈未再复现,按用户要求更新状态。隔离组件连续平移通过。 | | B03 | 快速平移触发更新深度错误 | 已修复已确认的窗口 Context 反馈循环,回归验证收敛;真实操作继续观察。 | | B04 | 资源选中后运行不可用提示消失 | 已修复,提示与选择解耦,自动化验证通过。 | -| B05 | 对话记录偶发丢失 | 已修复日志复现的历史分页卡点:消息模式过滤后分页、原生游标与读取代次隔离。原生只读重放分 3 页取回全部 44 条现存消息,原文件未变;真实客户端待验收,不扩大为其它未落盘记录已恢复。 | +| B05 | 对话记录偶发丢失 | 已定位历史分页卡点,尚未修改。只读复验用户提供的历史:558 条合法原始记录中有 44 条聊天消息;首屏原始 20 条只投影出一条助手消息,下一页原始 20 条没有聊天消息,按消息计算的游标不推进。已存用户提问和最终回答因此无法继续翻出;不能凭该文件排除其它未落盘记录。 | | B06 | JSON 文档未正确识别展示 | 已按用户确认完成本地修复:合法 UI State 由原生完整校验,卡片显示 UI 设计并进入现有编辑器;普通 JSON 显示 JSON 并可代码预览。自动化验证通过,待重建原生客户端验收;详见 JSON 语义识别实施计划。 | | C01 | 右键平移,保留左键框选 | 已实现,卡片左键拖动、框选、指针取消/失焦/捕获丢失及控件边界测试通过。 | @@ -41,4 +41,4 @@ - 修复前新增回归测试能检出外壳重复发布、运行提示消失和右键无效;修复后窗口/画布定向测试通过,现有导航、框选、指针点击/取消、UI 编辑器返回平移和素材定位用例通过。 - AGC TypeScript、修改文件 ESLint、编码、文档索引及差异空白检查通过。 - 测试仍有既有 React 列表 key、旧用例 act/IPC 桩告警,未作为本批功能修复扩大范围。 -- 用户反馈 B01/B02 本轮未再复现,记为已优化;右键手感与其它真实客户端细节继续观察。对话历史分页与 JSON 双路径均已本地修复并通过定向验证,待重建原生端后真实客户端验收。本计划保持开放。 +- 用户反馈 B01/B02 本轮未再复现,记为已优化;右键手感与其它真实客户端细节继续观察。对话历史分页尚未修复;JSON 双路径已本地修复并通过自动化验证,待真实客户端验收。本计划保持开放。 diff --git a/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md b/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md deleted file mode 100644 index a247be381..000000000 --- a/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md +++ /dev/null @@ -1,19 +0,0 @@ -# AGC 对话历史分页恢复 - -- Version: 1 -- Status: reviewed -- Date: 2026-09-16 -- Parent Spec: [AGC 实施计划:DirectProject 回合展示唯一归属](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) - -## 范围与评审 - -用户已要求修复 B05。已用用户提供的原始日志只读复验:原始切片被工具/推理填满时,聊天投影为空,消息游标不推进。修复聊天读取与分页,不改历史写入、不删除记录、不修改模型上下文,不扩大到其它尚无证据的对话丢失原因。 - -采用已有切片接口的显式消息模式与原生游标。原始模式缺省行为保持不变,旧无 ID 消息保留;前端以项目和加载代次隔离结果。本轮不包含远程写入。 - -## 验收 - -1. 工具/推理密集、末尾无消息、纯工具历史均不产生空页死循环。 -2. 消息正文、原始 ID、时间和顺序保持不变,翻页能到达早期用户提问及最终回答,不重复。 -3. 连点、请求失败重试、项目切换和同项目重新加载不会污染消息或游标。 -4. 原始切片默认模式回归通过,用户日志只读重放可以取回全部现存消息;不把真实日志或对话正文提交到仓库。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 24a21eea8..ac8fbca0c 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,9 +1,5 @@ # 踩坑与排障记录 -## DirectProject 历史不能按工具条目切页再按消息推进游标 - -原始 `response_item` 历史同时含用户/助手消息、推理与工具输出。若原生每次取 20 个原始条目、前端过滤聊天消息后再找最旧 ID,纯工具页会让消息集合为空且游标不动,看起来历史丢失。聊天读取固定显式请求 `messagesOnly: true`,原生逐行过滤后按消息分页并返回 `oldestItemId`;默认原始模式留给原始条目消费者。无 ID 旧消息保留并扩展到可寻址边界,不能造 ID。前端保留项目与读取代次、单飞及 ID 去重,旧请求的成功、失败与 finally 都不能覆盖新读取;真实日志只在临时目录只读重放,不能提交正文夹具。 - ## JSON 卡片显示与 UI 编辑能力必须同源 JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡片、缩略图及编辑器入口共同消费受控文本预览的 `uiDesignAssetId`;只有原生复用 UI 持久化合同校验 schema、完整 State 和项目/资产身份后才设置它。普通 JSON 保留 JSON 代码预览,不按 `kind: UI/ui` 或 schema 字符串片段猜测编辑能力。已有合法 UI State 的加载/保存不依赖 kind 精确大小写,但新建初始化仍保留正式 UI 资产门禁;缓存与项目切换须保留现有身份隔离。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 792e58a6c..d69549ea0 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -32,9 +32,6 @@ ## 2026-09-16 DirectProject 回合展示唯一归属 -- 聊天历史使用 `read_direct_project_history_slice` 的 `messagesOnly: true` 模式,按有正文的 user/assistant 消息分页,默认 20 条;工具/推理原始记录不占聊天页名额、不进入聊天分页响应,也不从磁盘删除。接口省略该选项时维持原始 item 切片语义。响应给出明确的 `oldestItemId` 游标;消息投影不能重新发明分页位置。 -- 消息模式读取逐行过滤原始记录,不在内存中积累整份工具输出;正文、原始 ID 和信封时间原样保留。旧的无 ID 消息不能凭空生成身份,必要时向前扩展到已有消息 ID 边界;没有更早消息时结束分页。 -- 首屏和加载更早消息共用分页解析;重复点击只发一个请求,重叠消息按原始 ID 去重并保留当前显示版本。切项目、同项目重新加载及 A→B→A 的迟到响应不得覆盖当前消息、游标或加载状态;失败保留已有消息与分页位置并允许重试。 - 交付合同:实时消息、历史回读、工具详情与最终回复先归一为按 `clientTurnId` 唯一的回合,再渲染一次。用户消息始终保留;同一回合的正文、工具和耗时不能从消息、实时尾部、未归属尾部等多个出口重复展示。 - 归属来自 `direct-codex:{clientTurnId}:{role}`、文本流中保留的原始 item ID,以及项目历史内明确用户记录之后的 assistant 记录。先在已加载的完整消息集合中关联,再做可见分页;持久历史继续通过 canonical item 切片懒加载,`hasMore` 为真时,未加载回合的工具流不得漂到当前页尾部。禁止将第 N 个有工具回合配给第 N 条用户消息,禁止按文本长度、标点或时间窗猜测归属。缺身份的旧记录保留,不猜造其与其它回合的关联。 - 有回合流时正文与工具位置仅来自 item 边界与 `seq`,工具详情按该回合的 `callId` 关联;没有流时同一个回合容器显示历史消息与工具。整轮累计文本仅在活动回合尚无流和持久 assistant 时作兜底,不另建实时消息出口。 -- 2.52.0 From 63c495dab02132c86096cef6662b2a79af50fcfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 14:40:36 +0800 Subject: [PATCH 76/92] =?UTF-8?q?DirectProject=20=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=8A=8A=20Codex=20=E5=9B=9E=E6=98=BE=E7=9A=84=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=B6=88=E6=81=AF=E4=B8=8B=E5=8F=91=E7=BB=99=E8=81=8A?= =?UTF-8?q?=E5=A4=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 运行态条目投影新增回显过滤 direct_thread_visible_item,item/started 与 rawResponseItem/completed 两条路径共用 - is_direct_project_codex_user_item 提升为 pub(crate),落盘侧与运行态侧共用同一判据 - 新增 E2E direct_project_turn_does_not_forward_codex_user_echo_as_chat_items:修复前事件流会多出两条同文本用户条目(已做变异验证) - 同步 ADR 与 shared-memory decision-log:回显过滤必须同时覆盖落盘与运行态两侧 --- .../src/agent/codex_app_server/mod.rs | 172 +++++++++++++++++- .../src/agent/direct_project_history.rs | 7 +- ...irectProject对话历史单一事实源-2026-09-16.md | 1 + .../shared-memory/decision-log.md | 1 + 4 files changed, 178 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 9be967bcd..ca96f6a03 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -765,6 +765,25 @@ fn direct_thread_event_item( direct_thread_item_from_value(root, item, direct_tool_call_now_ms()) } +/// 运行态条目投影:Codex 回显的用户消息整条跳过。 +/// +/// AGC 是 DirectProject 用户消息的唯一来源——回合入口在 `turn/start` 之前就把 +/// `direct-codex:{clientTurnId}:user` 落盘,并下发同一条运行态条目。app-server 之后 +/// 回显的 `userMessage`(`item/started`)和原始 `role=user` item +/// (`rawResponseItem/completed`)只用于观察和关联:落盘侧已由 +/// `append_direct_project_history_item_at` 过滤,运行态必须用同一口径过滤,否则前端会多 +/// 渲染出两条没有历史对应的孤儿用户气泡,各自开出一个耗时 0 秒的假回合,直到重进页面才 +/// 恢复(那时读的是同一份已过滤的 `project.jsonl`)。 +fn direct_thread_visible_item( + root: &std::path::Path, + item: &serde_json::Value, +) -> Option { + if is_direct_project_codex_user_item(item) { + return None; + } + direct_thread_event_item(root, item) +} + fn direct_codex_command_is_game_verification(command: &str) -> bool { let command = command.to_ascii_lowercase(); command.contains("game.static_smoke") @@ -3125,7 +3144,7 @@ impl CodexAppServerConnection { "rawResponseItem/completed 缺少 item".to_string(), )); } - let entry_item = direct_thread_event_item(history_root, &item); + let entry_item = direct_thread_visible_item(history_root, &item); let history_root = history_root.to_path_buf(); let history_item = item.clone(); tokio::task::spawn_blocking(move || { @@ -3269,7 +3288,7 @@ impl CodexAppServerConnection { == CodexAppServerWorkspaceMode::DirectProject { if let Some(entry_item) = - direct_thread_event_item(history_root, item) + direct_thread_visible_item(history_root, item) { append_direct_thread_event( &direct_thread_id, @@ -6503,6 +6522,155 @@ while IFS= read -r line; do :; done ); } + #[cfg(unix)] + #[tokio::test] + async fn direct_project_turn_does_not_forward_codex_user_echo_as_chat_items() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let project = temp.path().join("direct-user-echo-project"); + crate::init_local_game_project_at(&project, "direct-user-echo", "回显过滤") + .expect("init project"); + let executable = temp.path().join("fake-codex-app-server-direct-user-echo"); + std::fs::write( + &executable, + r#"#!/bin/sh +while IFS= read -r line; do + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') + case "$line" in + *'"method":"initialize"'*) printf '{"id":%s,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}\n' "$id" ;; + *'"method":"skills/extraRoots/set"'*) printf '{"id":%s,"result":{}}\n' "$id" ;; + *'"method":"skills/list"'*) printf '{"id":%s,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}\n' "$id" ;; + *'"method":"thread/start"'*) printf '{"id":%s,"result":{"thread":{"id":"thread-echo"}}}\n' "$id" ;; + *'"method":"thread/inject_items"'*) printf '{"id":%s,"result":{}}\n' "$id" ;; + *'"method":"turn/start"'*) + printf '{"id":%s,"result":{"turn":{"id":"turn-echo","items":[],"status":"inProgress"}}}\n' "$id" + printf '%s\n' '{"method":"turn/started","params":{"threadId":"thread-echo","turn":{"id":"turn-echo","items":[],"status":"inProgress"}}}' + printf '%s\n' '{"method":"item/started","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"codex-user-echo-1","type":"userMessage","clientId":"turn-0001","content":[{"type":"text","text":"请创建菜单"}]}}}' + printf '%s\n' '{"method":"rawResponseItem/completed","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"codex-raw-user-echo-1","type":"message","role":"user","content":[{"type":"input_text","text":"请创建菜单"}]}}}' + printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thread-echo","turnId":"turn-echo","itemId":"item-echo-1","delta":"好的"}}' + printf '%s\n' '{"method":"item/completed","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"item-echo-1","type":"agentMessage","text":"好的"}}}' + printf '%s\n' '{"method":"rawResponseItem/completed","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"item-echo-1","type":"message","role":"assistant","content":[{"type":"output_text","text":"好的"}]}}}' + printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thread-echo","turn":{"id":"turn-echo","items":[],"status":"completed"}}}' + ;; + esac +done +"#, + ) + .expect("write fake app-server"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake app-server"); + + let llm = test_llm(); + let credential = CodexAppServerCredential::AppDataKey { + fingerprint: "fixture-credential".to_string(), + }; + let connection = + CodexAppServerConnection::spawn_with_executable_and_credential_at_workspace( + &llm, + &credential, + executable.as_os_str(), + Some(&project), + CodexAppServerWorkspaceMode::DirectProject, + ) + .await + .expect("spawn direct-project app-server"); + + let user_item = serde_json::json!({ + "type": "message", + "role": "user", + "id": "direct-codex:turn-0001:user", + "content": [{"type": "input_text", "text": "请创建菜单"}] + }); + let thread_id = direct_thread_id_for_project(&project); + let bootstrap = crate::agent::subscribe_direct_thread(&thread_id); + assert!( + bootstrap.events.is_empty(), + "订阅发生在回合之前,bootstrap 必须为空" + ); + // 生产入口(`chat_with_game_creator_direct_codex`)在发起回合前登记本客户端的 + // 付费生成身份;工具桥在这一轮里按它绑定付费调用,这里补上同一步。 + let _active_invocation = + crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") + .expect("enter direct invocation"); + let mut observer = |_observation| {}; + connection + .run_turn_with_direct_observer_and_history( + &test_snapshot(), + &llm, + LlmRunRequest::single_turn("系统", "请创建菜单"), + Some(&project), + Some("turn-0001"), + Some(&user_item), + None, + Some(&mut observer), + None, + ) + .await + .expect("run direct-project turn"); + drop(observer); + + let consumed = crate::agent::consume_direct_thread(&bootstrap.subscription_id) + .expect("consume events"); + let mut user_items = Vec::new(); + let mut assistant_items = Vec::new(); + for event in &consumed.events { + let item = match event { + DirectThreadEvent::ItemStarted { item } + | DirectThreadEvent::ItemCompleted { item } => item, + _ => continue, + }; + match item { + DirectThreadItem::Message { + item_id, + role, + text, + .. + } if role.as_str() == "user" => { + user_items.push((item_id.clone(), text.clone())); + } + DirectThreadItem::Message { text, .. } if !text.trim().is_empty() => { + assistant_items.push(text.clone()); + } + _ => {} + } + } + assert_eq!( + user_items, + vec![( + "direct-codex:turn-0001:user".to_string(), + "请创建菜单".to_string() + )], + "Codex 回显的用户消息不得再作为聊天条目下发,否则前端会渲染出多余的孤儿用户气泡" + ); + assert_eq!( + assistant_items, + vec!["好的".to_string()], + "assistant 正文仍必须按同一 itemId 下发一次" + ); + let history = read_direct_project_history_items_at(&project).expect("read history"); + let history_user_items = history + .iter() + .filter(|item| item.get("role").and_then(serde_json::Value::as_str) == Some("user")) + .cloned() + .collect::>(); + assert!( + history + .iter() + .any(|item| item.get("id").and_then(serde_json::Value::as_str) + == Some("item-echo-1")), + "assistant 与工具条目仍必须落盘:{history:?}" + ); + assert_eq!( + history_user_items, + vec![user_item], + "回显的用户消息也不得落盘,历史里只能有 AGC 自己那条" + ); + } + #[cfg(unix)] #[tokio::test] async fn direct_home_app_server_uses_read_only_protocol_and_rejects_file_change_items() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 5a59918d8..f7bf03745 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -492,7 +492,12 @@ fn append_direct_project_history_item_once(root: &Path, item: &Value) -> Result< append_jsonl_line_unlocked(&path, &line, "DirectProject 历史") } -fn is_direct_project_codex_user_item(item: &Value) -> bool { +/// Codex app-server 回显的用户条目(`userMessage` / 非 AGC 的 `role=user` message)。 +/// +/// AGC 自己预写的用户条目 id 固定是 `direct-codex:{clientTurnId}:user`,因此这里必须 +/// 把两者区分开:落盘侧(本模块)与运行态事件侧(`codex_app_server`)共用同一口径, +/// 任何一侧漏判都会让同一条用户消息出现第二个身份。 +pub(crate) fn is_direct_project_codex_user_item(item: &Value) -> bool { if item.get("type").and_then(Value::as_str) == Some("userMessage") { return true; } diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index afde4e259..1f2e51478 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -17,6 +17,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 可见性判断留在前端聊天投影:后端历史分页只按原始条目切片,前端自己跳过不可显示条目并推进锚点。 - 线上模型是 **ts-rs 导出的 tagged enum**(`agent/direct_thread_wire.rs`),不是"一个大结构体加一堆可空字段":`DirectThreadItem` 用 `itemType` 区分条目,`DirectThreadEvent` 用 `type` 区分事件,前端直接消费生成的 TS 类型(改完 Rust 模型跑 `cargo test export_bindings`)。条目上的毫秒时间戳标 `#[ts(as = "f64")]`,因为 ts-rs 默认把 `u64` 映射成 `bigint`,而 Tauri 的 JSON 通道传的是 `number`。 - 运行态事件与历史切片使用同形条目,Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「原始条目 → 视图」投影函数。 +- 两侧的过滤口径必须完全一致,包含「哪些条目根本不是本项目的聊天条目」:Codex app-server 回显的用户消息(`userMessage` / 非 AGC 的 `role=user`)在落盘侧被过滤,在运行态事件侧也必须被过滤(`direct_thread_visible_item`)。少一侧就会出现「实时比历史多出两条同文本用户条目、各自开出一个耗时 0 秒的假回合,重进页面又正常」这类只有其中一侧的事实源缺陷。 - 搬运层不生成展示形状:Thread Manager 只下发脱敏原始条目(`itemType` 原样透传),工具卡片的 `kind`、标题、折叠摘要都由前端生成。 - 条目身份只有一套:进队列前归一成一个 `itemId`。工具条目在 `project.jsonl` 里带两个 id(调用 id 与 response item id,调用与输出共用前者),归一只在 Rust 边界做一次,Thread Manager 与前端都不暴露第二个 id 概念。 - 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;前端 state 里只有一个 `turnRunning` 布尔,没有 `turnId`。`subscribe` 返回的条目、增量、请求与队列锚点都不带 turn id。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d778bcade..2b0554396 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8251,6 +8251,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - `.agent/conversations/project.jsonl` 中的 DirectProject 用户消息由 AGC 在 `turn/start` 前以 `direct-codex:{clientTurnId}:user` 幂等追加;写入失败时禁止发起 Codex turn,失败或中断也保留该 user item。 - Codex app-server 回显的 `userMessage` / `role=user` item 不是第二个历史来源。AGC 只处理其观察和关联,不再把该 echo 追加到项目历史;Codex 的 assistant、tool 和其它有效 response item 仍按现有 append-only 规则落盘。 +- 2026-09-17 追加:回显过滤必须同时覆盖**运行态事件侧**(`direct_thread_visible_item`,`item/started` 与 `rawResponseItem/completed` 两条路径),不能只过滤落盘。只过滤落盘时实时聊天会多出两条没有历史对应的孤儿用户气泡,各自开出一个「耗时 0 秒」的假回合,重进页面读同一份已过滤的 `project.jsonl` 又恢复正常;判据是 `direct_project_turn_does_not_forward_codex_user_echo_as_chat_items`(回合事件里只能有一条 `direct-codex:{clientTurnId}:user` 用户条目)。 - 本地 AGC user-item 写入必须使用允许 user item 的内部入口,Codex raw item 写入使用过滤入口,避免“过滤回显”反过来阻断预写。相同 `clientTurnId` 只能复用相同规范化 prompt,内容冲突必须失败关闭。 ## 2026-08-31 AGC 错误报告与诊断上传 -- 2.52.0 From 459f8a60e5a0aa6236fa8075c933952215dda8b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 16:18:51 +0800 Subject: [PATCH 77/92] =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=85=88=E8=A1=8C?= =?UTF-8?q?=EF=BC=9A=E5=AE=9A=E6=AD=BB=20DirectProject=20=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=88=86=E9=A1=B5=E8=BF=9E=E6=8B=89=E7=9A=84=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E6=80=A7=E5=8F=A3=E5=BE=84=20=E6=8A=8A=E3=80=8C?= =?UTF-8?q?=E5=8F=AF=E6=98=BE=E7=A4=BA=E3=80=8D=E5=88=A4=E6=8D=AE=E6=98=8E?= =?UTF-8?q?=E7=A1=AE=E4=B8=BA=E5=90=88=E5=B9=B6=E5=90=8E=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E6=8A=95=E5=BD=B1=E5=87=BA=E7=8E=B0=E6=96=B0=E5=9B=9E=E5=90=88?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E5=86=99=E6=B8=85=E6=AF=8F=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=205=20=E9=A1=B5=E4=B8=8A=E9=99=90=E4=B8=8E=E9=94=9A=E7=82=B9?= =?UTF-8?q?=E4=B8=8D=E5=89=8D=E8=BF=9B=E5=8D=B3=E5=81=9C=20=E9=87=8C?= =?UTF-8?q?=E7=A8=8B=E7=A2=91=E6=8A=8A=E5=8E=9F=E7=AC=AC=2056=20=E6=9D=A1?= =?UTF-8?q?=E6=8C=89=E5=90=8E=E7=AB=AF=E9=94=9A=E7=82=B9=E5=9B=9E=E6=89=AB?= =?UTF-8?q?=E4=B8=8E=E5=89=8D=E7=AB=AF=E8=BF=9E=E6=8B=89=E6=8B=86=E6=88=90?= =?UTF-8?q?=E4=B8=A4=E6=9D=A1=E7=8B=AC=E7=AB=8B=E9=AA=8C=E6=94=B6=E9=A1=B9?= =?UTF-8?q?=EF=BC=8C=E5=89=8D=E7=AB=AF=E9=82=A3=E6=9D=A1=E5=85=88=E7=95=99?= =?UTF-8?q?=E5=BE=85=E8=A1=A5=E8=AF=81=E6=8D=AE=20=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E7=AC=AC=206=20=E6=AD=A5=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=EF=BC=8C=E5=B9=B6=E6=8A=8A=E6=96=B0=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E5=8D=95=E6=B5=8B=E5=8A=A0=E5=85=A5=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=20=E5=86=B3=E7=AD=96=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=202026-09-17=20=E6=9D=A1=E7=9B=AE=EF=BC=8C?= =?UTF-8?q?=E8=AE=B0=E4=B8=8B=E8=90=BD=E7=82=B9=E3=80=81=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E7=B2=92=E5=BA=A6=E3=80=81=E5=A4=B1=E8=B4=A5=E4=B8=8E=E7=AB=9E?= =?UTF-8?q?=E6=80=81=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...实施计划】DirectProject聊天真相源收敛-2026-09-16.md | 4 ++-- ...【里程碑】DirectProject聊天真相源收敛-2026-09-16.md | 6 ++++-- docs/project-memory/shared-memory/decision-log.md | 10 ++++++++++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md index ab8c8f5c5..21bd38c00 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md @@ -19,7 +19,7 @@ 3. 删掉回合身份:`turn.started` 无载荷、`turn.completed{status}`,条目 / 增量 / 请求 / 生命周期锚点都不带 turn id;队列 `append` 直接收 `DirectThreadEvent`,`seq` 内部自算。 4. 思考正文流式:`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta` 产出 `item.delta{kind:"reasoning"}`;plan 文本与命令输出保持活动状态。 5. 前端收敛为单一 reducer:`subscribe` 返回的 bootstrap 事件就是已暂存的运行态,游标已经在队尾,前端直接 reduce 这批事件即可(不需要为了拿这批事件再补一次 `consume`);此后只由 notify 唤醒 `consume`。唯一例外是回执竞态:Rust 注册完 subscriber 就开始通知,而前端要等回执才知道 `subscriptionId`,这段时间到达的通知只能记欠账,回执到达后立刻补一次 `consume`(否则整轮最后一个事件之后可能再无通知,事件会卡死在队列里)。合并规则只保留"先到定形、后到补空白"(正文只增不减、工具状态允许从 running 升级到终态),`item.delta` 直接追加到运行态条目正文,删掉 `deltaText` 缓冲,`turn.completed` 把运行态条目并入历史再清空。 -6. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,历史读取改为从文件尾反向扫描;锚点按原始 item id 推进,切片无可见条目时自动连拉(上限 5 页)。 +6. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,历史读取改为从文件尾反向扫描;锚点按原始 item id 推进;一次翻页操作在前端连拉,直到合并后聊天投影出现新回合(新的用户气泡)或 `hasMore=false`,每个操作上限 5 页;锚点未推进(`items` 为空 / `firstItemId` 为 null / 与请求锚点相同)时立即停止。可见性口径只在 `features/project-workspace/directHistoryPaging.ts` 实现一份,首屏与「显示更早」共用。 7. App.tsx 接线:订阅 + 立即 reduce bootstrap + notify 唤醒 consume,聊天视图改由 reducer 状态投影(含工具卡片),删除 Direct 回合事件订阅与 `directTurnStream` / `directToolCalls` 状态。 8. 删除只服务旧读路径的命令与前端调用(`read_direct_project_history`、`read_direct_turn_stream`、`read_direct_tool_calls`),DirectRuntime 自己的写入保留。`list_game_creator_direct_active_turns` 是唯一的例外并有意保留:它服务首页跨页面的「运行中的项目」列表(`WorkspaceLauncher` / `directActiveTurns.ts`),不是聊天框读路径。 9. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态。 @@ -28,7 +28,7 @@ 1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_thread -- --nocapture` 2. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`(生成 `src/features/project-workspace/generated/`),随后用 `prettier --write` 格式化生成目录,避免未格式化的 ts-rs 输出混进提交 -3. `npx vitest run apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts` +3. `npx vitest run apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts` 4. `npx vitest run apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` 5. TypeScript 类型检查与 ESLint(范围同前次 DirectProject 迁移)。 6. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` diff --git a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md index e04466315..d1c3af086 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md @@ -19,7 +19,7 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 - 线上模型与提示词同源:条目与事件是 ts-rs 导出的 tagged enum,前端消费生成绑定(改 Rust 模型后跑 `cargo test export_bindings`);毫秒时间戳用 `#[ts(as = "f64")]` 对齐 Tauri JSON 通道的 `number`。 - 条目身份归一:进队列前收敛成一个 `itemId`(工具条目在 `project.jsonl` 里带调用 id 与 response item id 两个值);历史切片另给 `firstItemId` 作为分页锚点,锚点始终是文件里的原始 item id。 - 思考正文流式:`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta` 以 `item.delta{kind:"reasoning"}` 下发正文;plan 文本与命令输出仍只降级为活动状态。 -- 历史读取以 `subscribe` 返回的 `lastCompletedItemId` 为首屏锚点,切片从**文件尾反向扫描**;不可显示条目由前端跳过并继续取页(上限 5 页)。 +- 历史读取以 `subscribe` 返回的 `lastCompletedItemId` 为首屏锚点,切片从**文件尾反向扫描**;后端切片只按原始条目切片,可见性判断留在前端聊天投影;一次翻页操作在前端连拉取页,直到合并后聊天投影出现新回合或 `hasMore=false`,每个操作上限 5 页。 - 前端收敛为单一事件 reducer 与单一聊天投影;活动回合只由 `turn.started` 与 `turn.completed` 判定(事件不带 turn id,前端只有一个 `turnRunning` 布尔);`item.delta` 直接追加到运行态条目正文,不保留增量缓冲;`turn.completed` 把该回合运行态条目并入历史再清空。 - 删除前端对 Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl`、活动回合快照的读取,以及只服务这些读取的命令与状态。 @@ -41,6 +41,7 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 - 失败与中止说明只在运行期显示(不写 `project.jsonl`),页面重进后不再出现。 - 未知 item 类型由 Rust 原样透传(只有类型与身份,Rust 侧留 TODO),当前由前端投影丢弃。 - 前端聊天卡片的工具形状是 `Omit`;`tool-calls.jsonl` 的持久化形状与 DirectRuntime 的写入保持不变。 +- 「可显示」的判据取**前端回合反馈**:一次翻页操作连拉到「合并后聊天投影的回合数增加」为止。工具卡片与思考文本虽然能通过 `projectDirectThreadItem`,但可能整页落进已渲染回合的折叠「执行过程」,不构成用户可见反馈;口径只在 `directHistoryPaging.ts` 里实现一份,首屏与「显示更早」共用。 ## 依赖与前置条件 @@ -53,7 +54,8 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 - [x] 一次回合内,实时渲染的文本段与工具卡片顺序,与回合结束后重进项目看到的顺序一致。【自动化:`project-development.suite.ts` 空对话首轮 + 历史切片工具卡片用例】 - [ ] 回合进行中终止并重启进程后重进项目:已落盘的部分文本与工具卡片按原顺序出现,且界面不显示忙碌态。【待真实 app-server 运行时验收】 - [ ] 进程存活期间的页面重进(含切走再切回)能恢复运行中回合,并允许终止。【待真实 app-server 运行时验收】 -- [x] 历史分页在切片内全部是不可显示条目时仍能继续向前,不出现锚点停滞。【自动化:`direct_project_history` 尾部回扫与分页锚点用例】 +- [x] 历史切片从文件尾反向回扫,锚点始终是文件里的原始 item id;切片内全部是不可显示条目时仍能继续向前,不出现锚点停滞。【自动化:`direct_project_history` 尾部回扫与分页锚点用例】 +- [ ] 前端一次翻页操作自动连拉,直到合并后聊天投影出现新回合或 `hasMore=false`,每个操作上限 5 页;锚点不前进时立即停止,不空转。【待补:`directHistoryPaging` 单测 + `project-development.suite.ts` 跨页同回合用例】 - [x] 聊天视图不再读取 `turn-stream.jsonl` / `tool-calls.jsonl` / Direct 回合事件 / 活动回合快照;`read_direct_turn_stream` 与 `read_direct_tool_calls` 命令已删除。`list_game_creator_direct_active_turns` **有意保留**:它服务首页跨页面的「运行中的项目」列表(`WorkspaceLauncher` / `directActiveTurns.ts`),不属于聊天框读路径。 - [x] 同一工具调用在实时与回读各只出现一张卡片(两个 id 空间按归一后的 `itemId` 对齐)。【自动化:reducer「先到定形、后到补空」合并单测 + 工具卡片渲染用例】 - [x] 前端聊天状态里不再出现第二个 id 概念与任何回合身份字段;事件解析统一来自 ts-rs 生成绑定。【自动化:`directThreadChat` / `directTurnPresentation` 单测 + `cargo test export_bindings` 生成绑定无差异】 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2b0554396..855eac7d3 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2,6 +2,16 @@ > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 +## 2026-09-17 DirectProject 历史分页的「可显示」口径定为前端回合反馈,一次翻页连拉上限 5 页 + +- 背景:ADR 与里程碑要求「一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页」,但实现只落地了后端锚点与文件尾回扫,前端仍是单发一页。历史切片的 `limit` 按原始条目计,一整页全是工具卡片 / 思考文本且落进同一个已渲染回合的折叠「执行过程」时,用户点「显示更早的对话」看不到任何变化。 +- 决策(可显示 = 出现新回合):一次翻页操作连续取页,停止判据是**合并后聊天投影的回合数增加**(出现新的用户气泡)。工具卡片与思考文本虽然能通过 `projectDirectThreadItem`,但它们可能整页落进已渲染回合的折叠过程区,不构成用户可见反馈。 +- 决策(粒度和上限):每个用户操作最多 5 次请求,首屏那次算第 1 页、之后每次点击重新计数;每页仍是 `limit = CONVERSATION_VISIBLE_STEP`,上限只约束请求次数,不改页大小契约。 +- 决策(硬性终止):`items` 为空、`firstItemId` 为 null 或与请求锚点相同 → 立即停止,不靠 5 页上限兜底。 +- 决策(落点):口径与循环只在 `features/project-workspace/directHistoryPaging.ts` 一份实现里,首屏与「显示更早」共用;`DIRECT_HISTORY_MAX_PAGES_PER_ACTION` 放 `app/constants.ts`。 +- 边界:循环内只累积、结束后一次性并入聊天 state;某页失败时保留已成功页并沿用现有报错文案;切换项目时丢弃整批;不新增 loading / 禁用态与新文案,不做滚动锚定补偿;运行中回合允许连拉历史。 +- 验证:`directHistoryPaging` 单测覆盖口径、上限、`hasMore=false` 早停、锚点不前进、单页失败;`project-development.suite.ts` 新增「跨页同回合」集成用例作为真实回归网。 + ## 2026-09-16 图标图集自动拆图上限提高到 256 - 背景:AGC 图标图集自动连通域识别在一次生成中识别出 86 个区域,原有 64 片上限在后处理阶段阻断了请求;该上限同时影响 api-server 自动 / 手动切片、SpacetimeDB 批量落库和统一生成结果 item 数量。 -- 2.52.0 From 1ae6c6cd0633d32fe68a775959450ca44cc861bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 16:27:09 +0800 Subject: [PATCH 78/92] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20DirectProject=20?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E5=88=86=E9=A1=B5=E8=BF=9E=E6=8B=89=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E4=B8=8E=E5=8D=95=E6=B5=8B=20directHistoryPaging=20?= =?UTF-8?q?=E6=8C=89=E3=80=8C=E5=90=88=E5=B9=B6=E5=90=8E=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E6=8A=95=E5=BD=B1=E5=9B=9E=E5=90=88=E6=95=B0=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E3=80=8D=E5=88=A4=E6=96=AD=E5=8F=AF=E8=A7=81=E5=8F=8D=E9=A6=88?= =?UTF-8?q?=EF=BC=8C=E5=87=BA=E7=8E=B0=E6=96=B0=E5=9B=9E=E5=90=88=E6=88=96?= =?UTF-8?q?=20hasMore=3Dfalse=20=E5=8D=B3=E5=81=9C=20=E8=BF=9E=E6=8B=89?= =?UTF-8?q?=E4=B8=8A=E9=99=90=20DIRECT=5FHISTORY=5FMAX=5FPAGES=5FPER=5FACT?= =?UTF-8?q?ION=20=E6=94=BE=E8=BF=9B=20app/constants=EF=BC=8C=E6=AF=8F?= =?UTF-8?q?=E4=B8=AA=E6=93=8D=E4=BD=9C=E6=9C=80=E5=A4=9A=205=20=E6=AC=A1?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=20=E9=94=9A=E7=82=B9=E6=9C=AA=E6=8E=A8?= =?UTF-8?q?=E8=BF=9B=EF=BC=88items=20=E4=B8=BA=E7=A9=BA=20/=20firstItemId?= =?UTF-8?q?=20=E4=B8=BA=20null=20=E6=88=96=E5=8E=9F=E5=9C=B0=E6=89=93?= =?UTF-8?q?=E8=BD=AC=EF=BC=89=E6=97=B6=E7=AB=8B=E5=8D=B3=E5=81=9C=E6=89=8B?= =?UTF-8?q?=E5=B9=B6=E6=94=B6=E5=8F=A3=20hasMore=20=E6=9F=90=E9=A1=B5?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E5=A4=B1=E8=B4=A5=E4=B8=8D=E4=B8=A2=E5=B7=B2?= =?UTF-8?q?=E5=8F=96=E5=88=B0=E7=9A=84=E9=A1=B5=EF=BC=8C=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=20error=20=E4=BA=A4=E7=BB=99=E8=B0=83=E7=94=A8=E6=96=B9?= =?UTF-8?q?=E6=8A=A5=E9=94=99=20directThreadChat=20=E6=8A=BD=E5=87=BA=20pr?= =?UTF-8?q?ojectDirectHistoryItems=EF=BC=8C=E8=AE=A9=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=E4=B8=8E=E8=A7=86=E5=9B=BE=E5=85=B1=E7=94=A8?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E4=BB=BD=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/constants.ts | 2 + .../project-workspace/directHistoryPaging.ts | 84 +++++++ .../project-workspace/directThreadChat.ts | 20 +- .../tests/directHistoryPaging.test.ts | 234 ++++++++++++++++++ 4 files changed, 336 insertions(+), 4 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/directHistoryPaging.ts create mode 100644 apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts diff --git a/apps/ai-game-creator-shell/src/app/constants.ts b/apps/ai-game-creator-shell/src/app/constants.ts index 0a936c1fa..91bb2b6f1 100644 --- a/apps/ai-game-creator-shell/src/app/constants.ts +++ b/apps/ai-game-creator-shell/src/app/constants.ts @@ -14,6 +14,8 @@ export const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20; export const AGENT_RUN_HISTORY_VISIBLE_STEP = 20; export const CONVERSATION_INITIAL_VISIBLE_COUNT = 20; export const CONVERSATION_VISIBLE_STEP = 20; +/** 一次翻页操作最多连拉几页:见 ADR「分页锚点取原始条目 id」。 */ +export const DIRECT_HISTORY_MAX_PAGES_PER_ACTION = 5; export const AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD = 48; export const PROJECT_SUPERVISOR_AGENT_ID = 'project-supervisor'; /** diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryPaging.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryPaging.ts new file mode 100644 index 000000000..3065848b0 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryPaging.ts @@ -0,0 +1,84 @@ +/** + * DirectProject 历史分页连拉:一次翻页操作连续取页,直到出现用户看得见的变化。 + * + * 可见性判断留在前端聊天投影(后端切片只按原始条目切片):工具卡片与思考文本同样占满一屏, + * 所以一整页完全可能都落进已经渲染的回合里,对用户就是"点了没变化"。判据因此取**合并后 + * 聊天投影的回合数增加**(出现新的用户气泡),而不是"这一页里有没有能渲染的条目"。 + * + * 这里是该判据的唯一出处:首屏与「显示更早」共用同一条循环,别在调用点各写一份。 + */ + +import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../../app/constants'; +import { + type DirectChatEntry, + mergeHistoryEntries, + projectDirectHistoryItems, +} from './directThreadChat'; +import type { + DirectThreadHistorySlice, + DirectThreadItem, +} from './directThreadEvents'; +import { buildDirectChatTurns } from './directTurnPresentation'; + +export type DirectHistoryPages = { + /** 累积到的条目,保持文件顺序(旧 → 新)。 */ + items: DirectThreadItem[]; + /** 后端声明的"还有更早的历史";取不动时收口为 false。 */ + hasMore: boolean; + /** 下一屏的锚点;取不动或失败时停在最后一个可用的锚点上。 */ + firstItemId: string | null; + /** 中断原因;`null` 表示正常停止。 */ + error: unknown; +}; + +function visibleTurnCount(entries: readonly DirectChatEntry[]): number { + return buildDirectChatTurns({ entries }).length; +} + +/** 取页是"从新往旧"的,拼回文件顺序要整个翻过来。 */ +function inFileOrder(pages: readonly (readonly DirectThreadItem[])[]) { + return [...pages].reverse().flat(); +} + +export async function readDirectHistoryPages({ + existingEntries, + beforeItemId, + readSlice, +}: { + /** 当前聊天视图里的条目:判据必须和用户看到的是同一份。 */ + existingEntries: readonly DirectChatEntry[]; + beforeItemId: string | null; + readSlice: (beforeItemId: string | null) => Promise; +}): Promise { + const baseTurnCount = visibleTurnCount(existingEntries); + const pages: DirectThreadItem[][] = []; + let anchor = beforeItemId; + let hasMore = false; + let error: unknown = null; + for (let page = 0; page < DIRECT_HISTORY_MAX_PAGES_PER_ACTION; page += 1) { + let slice: DirectThreadHistorySlice; + try { + slice = await readSlice(anchor); + } catch (thrown) { + // 已经取到的页照常交给调用方:第 N 页失败不该丢掉前 N-1 页。 + error = thrown; + break; + } + pages.push([...slice.items]); + hasMore = slice.hasMore; + const nextAnchor = slice.firstItemId; + if (slice.items.length === 0 || !nextAnchor || nextAnchor === anchor) { + // 锚点不前进就再也取不到更早的页:继续只会重复拿回同一个窗口。按"取不动了"收口。 + hasMore = false; + break; + } + anchor = nextAnchor; + if (!hasMore) break; + const merged = mergeHistoryEntries( + projectDirectHistoryItems(inFileOrder(pages)), + existingEntries, + ); + if (visibleTurnCount(merged) > baseTurnCount) break; + } + return { items: inFileOrder(pages), hasMore, firstItemId: anchor, error }; +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts index d9b978db5..76e4a5fa3 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts @@ -230,6 +230,15 @@ export function mergeHistoryEntries( return entries; } +/** 历史切片条目 → 聊天条目:可见性判定的唯一入口,分页判据也读这一份。 */ +export function projectDirectHistoryItems( + items: readonly DirectThreadItem[], +): DirectChatEntry[] { + return items + .map((item) => projectDirectThreadItem(item)) + .filter((entry): entry is DirectChatEntry => Boolean(entry)); +} + /** * 历史切片并入:切片是脱敏原始条目,投影规则与运行态完全同一份。 * @@ -239,10 +248,13 @@ export function mergeDirectHistoryItems( state: DirectThreadChatState, items: readonly DirectThreadItem[], ): DirectThreadChatState { - const entries = items - .map((item) => projectDirectThreadItem(item)) - .filter((entry): entry is DirectChatEntry => Boolean(entry)); - return { ...state, history: mergeHistoryEntries(entries, state.history) }; + return { + ...state, + history: mergeHistoryEntries( + projectDirectHistoryItems(items), + state.history, + ), + }; } export function mergeDirectThreadHistorySlice( diff --git a/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts b/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts new file mode 100644 index 000000000..50bfe55bb --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../src/app/constants'; +import { readDirectHistoryPages } from '../src/features/project-workspace/directHistoryPaging'; +import type { DirectChatEntry } from '../src/features/project-workspace/directThreadChat'; +import { + emptyDirectThreadChatState, + mergeDirectHistoryItems, + selectDirectChatEntries, +} from '../src/features/project-workspace/directThreadChat'; +import type { + DirectThreadHistorySlice, + DirectThreadItem, +} from '../src/features/project-workspace/directThreadEvents'; + +function userItem(itemId: string): DirectThreadItem { + return { itemType: 'message', itemId, role: 'user', text: itemId, at: 1000 }; +} + +function assistantItem(itemId: string): DirectThreadItem { + return { + itemType: 'message', + itemId, + role: 'assistant', + text: `答复 ${itemId}`, + at: 2000, + }; +} + +function toolItem(itemId: string): DirectThreadItem { + return { + itemType: 'function_call', + itemId, + name: 'exec_command', + arguments: '{"cmd": "ls"}', + at: 1500, + }; +} + +function reasoningItem(itemId: string): DirectThreadItem { + return { itemType: 'reasoning', itemId, text: '想想', at: 1400 }; +} + +function slice( + items: DirectThreadItem[], + hasMore: boolean, + firstItemId: string | null, +): DirectThreadHistorySlice { + return { items, hasMore, firstItemId }; +} + +/** 用生产投影把条目拉成聊天条目:判据必须和视图看到的是同一份。 */ +function entriesOf(items: DirectThreadItem[]): DirectChatEntry[] { + return selectDirectChatEntries( + mergeDirectHistoryItems(emptyDirectThreadChatState(), items), + ); +} + +function readerOf(pages: DirectThreadHistorySlice[]) { + const remaining = [...pages]; + return vi.fn(async (_beforeItemId: string | null) => { + const next = remaining.shift(); + if (!next) throw new Error('测试桩:不该再取页'); + return next; + }); +} + +describe('DirectProject 历史分页连拉', () => { + it('整页工具与思考条目落进已渲染回合时继续取页,直到出现新的用户气泡', async () => { + // 已渲染的回合是「turn-2-user」,后面跟着它的折叠执行过程。 + const existing = entriesOf([userItem('turn-2-user'), toolItem('call-2')]); + const readSlice = readerOf([ + slice([toolItem('call-3'), reasoningItem('reason-3')], true, 'reason-3'), + slice( + [userItem('turn-1-user'), assistantItem('turn-1-assistant')], + false, + 'turn-1-user', + ), + ]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + // 第二页才带来新回合:锚点按上一页最老一条推进,条目按文件顺序拼回去。 + expect(readSlice.mock.calls).toEqual([['turn-2-user'], ['reason-3']]); + expect(result.items.map((item) => item.itemId)).toEqual([ + 'turn-1-user', + 'turn-1-assistant', + 'call-3', + 'reason-3', + ]); + expect(result.hasMore).toBe(false); + expect(result.firstItemId).toBe('turn-1-user'); + expect(result.error).toBeNull(); + const merged = selectDirectChatEntries( + mergeDirectHistoryItems( + { turnRunning: false, history: existing, live: [] }, + result.items, + ), + ); + expect(merged.some((entry) => entry.itemId === 'turn-1-user')).toBe(true); + }); + + it('第一页就出现新的用户气泡时只取一页', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([ + slice([userItem('turn-1-user')], true, 'turn-1-user'), + ]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + // 后端说还有更早的:按钮继续留在界面上,用户可以接着往前翻。 + expect(result.hasMore).toBe(true); + expect(result.firstItemId).toBe('turn-1-user'); + }); + + it('首屏没有已渲染回合时,第一页本身就构成可见反馈', async () => { + const readSlice = readerOf([slice([toolItem('call-1')], true, 'call-1')]); + + const result = await readDirectHistoryPages({ + existingEntries: [], + beforeItemId: null, + readSlice, + }); + + expect(readSlice.mock.calls).toEqual([[null]]); + expect(result.items.map((item) => item.itemId)).toEqual(['call-1']); + }); + + it('hasMore=false 时即使没有新回合也立即停止', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([slice([toolItem('call-3')], false, 'call-3')]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.hasMore).toBe(false); + }); + + it('一直没有新回合时最多连拉上限页数就停手', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf( + Array.from( + { length: DIRECT_HISTORY_MAX_PAGES_PER_ACTION + 2 }, + (_, index) => + slice([toolItem(`call-${index + 3}`)], true, `call-${index + 3}`), + ), + ); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes( + DIRECT_HISTORY_MAX_PAGES_PER_ACTION, + ); + // 用满上限但文件里还有更早的历史:按钮留在界面上,用户可再点一次。 + expect(result.hasMore).toBe(true); + expect(result.items).toHaveLength(DIRECT_HISTORY_MAX_PAGES_PER_ACTION); + expect(result.firstItemId).toBe( + `call-${DIRECT_HISTORY_MAX_PAGES_PER_ACTION + 2}`, + ); + }); + + it('锚点不前进时立即停止,不空转也不留按钮', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([slice([], true, null)]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.items).toEqual([]); + expect(result.hasMore).toBe(false); + expect(result.firstItemId).toBe('turn-2-user'); + }); + + it('锚点原地打转时立即停止,不重复取同一页', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([ + slice([toolItem('call-3')], true, 'turn-2-user'), + ]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.hasMore).toBe(false); + }); + + it('某一页读取失败时保留已经取到的页', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const failure = new Error('读取失败'); + const readSlice = vi.fn(async (beforeItemId: string | null) => { + if (beforeItemId === 'turn-2-user') { + return slice([toolItem('call-3')], true, 'call-3'); + } + throw failure; + }); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(2); + expect(result.items.map((item) => item.itemId)).toEqual(['call-3']); + // 失败的页没有推进锚点:下一次点击会重取这一页。 + expect(result.firstItemId).toBe('call-3'); + expect(result.hasMore).toBe(true); + expect(result.error).toBe(failure); + }); +}); -- 2.52.0 From 37ae7508b8f0582a2ac73d06b6062756a831614f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 16:39:24 +0800 Subject: [PATCH 79/92] =?UTF-8?q?App=20=E8=81=8A=E5=A4=A9=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=88=86=E9=A1=B5=E6=94=B9=E8=B5=B0=E8=BF=9E=E6=8B=89?= =?UTF-8?q?=E5=BE=AA=E7=8E=AF=20=E9=A6=96=E5=B1=8F=E4=B8=8E=E3=80=8C?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E6=9B=B4=E6=97=A9=E3=80=8D=E5=85=B1=E7=94=A8?= =?UTF-8?q?=20readDirectHistoryPages=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=90=84?= =?UTF-8?q?=E8=87=AA=E5=8D=95=E5=8F=91=E4=B8=80=E9=A1=B5=20=E9=A6=96?= =?UTF-8?q?=E5=B1=8F=E5=9F=BA=E7=BA=BF=E5=8F=96=E7=A9=BA=E5=B9=B6=E5=9C=A8?= =?UTF-8?q?=E5=AE=88=E5=8D=AB=E5=90=8E=E4=B8=80=E6=AC=A1=E6=80=A7=E5=B9=B6?= =?UTF-8?q?=E5=85=A5=E8=81=8A=E5=A4=A9=20reducer=EF=BC=8C=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E4=BB=8D=E6=8C=89=E5=8E=9F=E6=9C=89=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E6=8A=9B=E5=87=BA=20=E3=80=8C=E6=98=BE=E7=A4=BA=E6=9B=B4?= =?UTF-8?q?=E6=97=A9=E3=80=8D=E6=8C=89=E5=BD=93=E5=89=8D=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E6=9D=A1=E7=9B=AE=E5=88=A4=E6=96=AD=E6=96=B0=E5=9B=9E=E5=90=88?= =?UTF-8?q?=EF=BC=8C=E5=8F=96=E5=88=B0=E5=A4=9A=E9=A1=B5=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E6=80=A7=E5=B9=B6=E5=85=A5=E5=B9=B6=E6=8E=A8=E8=BF=9B=E9=94=9A?= =?UTF-8?q?=E7=82=B9=20=E8=BF=9E=E6=8B=89=E4=B8=AD=E9=80=94=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E4=BF=9D=E7=95=99=E5=B7=B2=E5=8F=96=E5=88=B0=E7=9A=84?= =?UTF-8?q?=E9=A1=B5=EF=BC=8C=E6=8A=A5=E9=94=99=E7=BB=9F=E4=B8=80=E8=B5=B0?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E4=B8=AA=E5=87=BA=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ai-game-creator-shell/src/App.tsx | 90 +++++++++++++++++--------- 1 file changed, 60 insertions(+), 30 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index e2a652ee9..5a4ae6b75 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -232,17 +232,19 @@ import { } from './features/project-workspace/chatComposerQueue'; import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels'; import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels'; +import { readDirectHistoryPages } from './features/project-workspace/directHistoryPaging'; import { applyDirectThreadConsumeResult, type DirectThreadChatState, emptyDirectThreadChatState, - mergeDirectThreadHistorySlice, + mergeDirectHistoryItems, resolveDirectThreadBootstrap, selectDirectChatEntries, } from './features/project-workspace/directThreadChat'; import { type DirectThreadConsumeResult, type DirectThreadHistorySlice, + type DirectThreadItem, type DirectThreadSubscriptionBootstrap, } from './features/project-workspace/directThreadEvents'; import type { DirectCodexUserContentPart } from './features/project-workspace/generated'; @@ -3491,21 +3493,38 @@ export function App({ let runtimeError = ''; let loadedDirectHistoryHasMore = false; let loadedDirectHistoryFirstItemId: string | null = null; - // 首屏切片先暂存,待下方 staleness 守卫通过后再并入聊天 reducer: + // 首屏连拉到的条目先暂存,待下方 staleness 守卫通过后再并入聊天 reducer: // 迟到的切片属于已经切走的项目,不能在守卫之前就写进全局聊天状态。 - let loadedDirectHistorySlice: DirectThreadHistorySlice | null = null; + let loadedDirectHistoryItems: DirectThreadItem[] = []; const projectConversation = directCodexProductRuntime ? (() => { - return invoke( - 'read_direct_project_history_slice', - { - projectPath: nextProjectPath, - limit: CONVERSATION_INITIAL_VISIBLE_COUNT, - }, - ).then((slice) => { - loadedDirectHistoryHasMore = slice.hasMore; - loadedDirectHistoryFirstItemId = slice.firstItemId; - loadedDirectHistorySlice = slice; + // 首屏铺的就是这份视图的基线,"新回合"比较没有意义:判据退化为"这一页得有能渲染 + // 的条目",所以基线取空,免得拿上一份对话的残留状态去比。首屏失败仍按原有语义 + // 抛出,交给调用方的 catch 处理。 + return readDirectHistoryPages({ + existingEntries: [], + beforeItemId: null, + readSlice: (beforeItemId) => + invoke( + 'read_direct_project_history_slice', + beforeItemId + ? { + projectPath: nextProjectPath, + beforeItemId, + limit: CONVERSATION_INITIAL_VISIBLE_COUNT, + } + : { + projectPath: nextProjectPath, + limit: CONVERSATION_INITIAL_VISIBLE_COUNT, + }, + ), + }).then((pages) => { + if (pages.error) { + throw pages.error; + } + loadedDirectHistoryHasMore = pages.hasMore; + loadedDirectHistoryFirstItemId = pages.firstItemId; + loadedDirectHistoryItems = pages.items; return { path: nextProjectPath, agentId: null, @@ -3579,12 +3598,10 @@ export function App({ if (directCodexProductRuntime) { setDirectHistoryHasMore(loadedDirectHistoryHasMore); directHistoryOldestItemIdRef.current = loadedDirectHistoryFirstItemId; - if (loadedDirectHistorySlice) { - const slice = loadedDirectHistorySlice; + if (loadedDirectHistoryItems.length > 0) { + const items = loadedDirectHistoryItems; // 首屏历史进聊天 reducer;`messages` 在 DirectProject 下只剩运行期本地消息。 - setDirectThreadChat((state) => - mergeDirectThreadHistorySlice(state, slice), - ); + setDirectThreadChat((state) => mergeDirectHistoryItems(state, items)); } } setMessages((current) => { @@ -11807,24 +11824,37 @@ export function App({ if (invoke && projectPath && !directHistoryLoadingRef.current) { directHistoryLoadingRef.current = true; try { - const slice = await invoke( - 'read_direct_project_history_slice', - { - projectPath, - beforeItemId: directHistoryOldestItemIdRef.current, - limit: CONVERSATION_VISIBLE_STEP, - }, - ); + // 一次点击连拉,直到出现新回合或取不动为止:一屏全是工具卡片 / 思考文本时, + // 单发一页会让用户点了"显示更早"却看不到任何变化。 + const pages = await readDirectHistoryPages({ + existingEntries: selectDirectChatEntries(directThreadChat), + beforeItemId: directHistoryOldestItemIdRef.current, + readSlice: (beforeItemId) => + invoke( + 'read_direct_project_history_slice', + beforeItemId + ? { + projectPath, + beforeItemId, + limit: CONVERSATION_VISIBLE_STEP, + } + : { projectPath, limit: CONVERSATION_VISIBLE_STEP }, + ), + }); if (localProjectPathRef.current !== projectPath) { return; } - // 更早的一屏历史进聊天 reducer:与运行态同形、同身份,重复读取只补不重。 + // 连拉到的页一次性进聊天 reducer:与运行态同形、同身份,重复读取只补不重。 setDirectThreadChat((state) => - mergeDirectThreadHistorySlice(state, slice), + mergeDirectHistoryItems(state, pages.items), ); - setDirectHistoryHasMore(slice.hasMore); + setDirectHistoryHasMore(pages.hasMore); directHistoryOldestItemIdRef.current = - slice.firstItemId ?? directHistoryOldestItemIdRef.current; + pages.firstItemId ?? directHistoryOldestItemIdRef.current; + if (pages.error) { + // 已经取到的页照常进视图,失败只走下面同一个报错出口。 + throw pages.error; + } } catch (error) { setWorkspaceStatus( `读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`, -- 2.52.0 From e382d23892d1060cbf367225eeff81ba86481f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 16:49:08 +0800 Subject: [PATCH 80/92] =?UTF-8?q?=E8=A1=A5=E8=B7=A8=E9=A1=B5=E5=90=8C?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E7=9A=84=E5=8E=86=E5=8F=B2=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E7=94=A8=E4=BE=8B=20=E7=94=A8=2051=20?= =?UTF-8?q?=E6=9D=A1=E5=8E=86=E5=8F=B2=E5=A4=8D=E7=8E=B0=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E6=8A=A5=E7=9A=84=E7=8E=B0=E8=B1=A1=EF=BC=9A20=20=E6=9D=A1?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=9D=A1=E7=9B=AE=E6=95=B4=E5=B1=8F=E8=90=BD?= =?UTF-8?q?=E8=BF=9B=E5=90=8C=E4=B8=80=E4=B8=AA=E6=8A=98=E5=8F=A0=E7=9A=84?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E8=BF=87=E7=A8=8B=20=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E7=82=B9=E5=87=BB=E8=BF=9E=E6=8B=89=E4=B8=A4?= =?UTF-8?q?=E9=A1=B5=E6=8B=BF=E5=88=B0=E6=96=B0=E7=94=A8=E6=88=B7=E6=B0=94?= =?UTF-8?q?=E6=B3=A1=EF=BC=8C=E5=B9=B6=E6=8C=89=20process-31=20/=20process?= =?UTF-8?q?-11=20=E4=B8=A4=E6=AC=A1=E9=94=9A=E7=82=B9=E6=8E=A8=E8=BF=9B=20?= =?UTF-8?q?=E6=96=AD=E8=A8=80=E8=BF=9E=E6=8B=89=E6=8F=92=E5=85=A5=E6=97=A7?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E5=90=8E=E8=A7=86=E5=8F=A3=E4=BB=8D=E5=81=9C?= =?UTF-8?q?=E5=9C=A8=E5=8E=9F=E5=A4=84=EF=BC=8C=E4=B8=8D=E8=A2=AB=E6=8B=89?= =?UTF-8?q?=E5=9B=9E=E5=88=97=E8=A1=A8=E5=BA=95=E9=83=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../appSurface/project-development.suite.ts | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 6aa05e4bc..638d0b31f 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -8423,6 +8423,114 @@ export function registerProjectSupervisorSurfaceTests() { }); }); + it('keeps pulling earlier pages while the page only deepens the rendered turn', async () => { + // 用户报的现象:一屏 20 条全是同一个回合的工具卡片,落在折叠的「执行过程」里, + // 点一次「显示更早」看不到任何变化。连拉必须越过这一屏,直到出现新的用户气泡。 + const projectPath = '/tmp/launcher-direct-history-cross-page-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-direct-history-cross-page-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + // 51 条:用户气泡 + 50 条工具条目。首屏取尾 20 条(process-31..process-50), + // 第一次翻页再取 20 条(process-11..process-30),都还在同一个回合里。 + supervisorHarness.setDirectThreadHistory([ + { + itemType: 'message', + itemId: 'direct-codex:turn-更早:user', + role: 'user', + text: '更早的用户提问', + at: 1000, + }, + ...Array.from({ length: 50 }, (_, index) => ({ + itemType: 'function_call', + itemId: `process-${index + 1}`, + name: 'exec_command', + arguments: `echo ${index + 1}`, + at: 1100 + index, + })), + ]); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-direct-history-cross-page-game', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + pickProjectFromLauncher(projectPath); + + const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + limit: 20, + }), + ); + // 首屏只有这个回合的执行过程:用户气泡还在更早的页里。 + expect(within(supervisorSurface).queryByText('更早的用户提问')).toBeNull(); + + // 视口停在列表中部(既不在顶部触发自动翻页,也不在底部跟随最新)。 + const messageList = screen.getByLabelText('陶泥儿消息') as HTMLDivElement; + let messageScrollHeight = 600; + Object.defineProperty(messageList, 'scrollHeight', { + configurable: true, + get: () => messageScrollHeight, + }); + Object.defineProperty(messageList, 'clientHeight', { + configurable: true, + get: () => 100, + }); + messageList.scrollTop = 100; + fireEvent.scroll(messageList); + // 插入后列表会变长:跟随最新的话视口会被拉到这个高度。 + messageScrollHeight = 2000; + + fireEvent.click(screen.getByRole('button', { name: '显示更早的对话' })); + + // 一次点击连拉两页:第一页仍然只有执行过程,第二页才带回用户气泡。 + expect( + await within(supervisorSurface).findByText('更早的用户提问'), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + beforeItemId: 'process-31', + limit: 20, + }); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + beforeItemId: 'process-11', + limit: 20, + }); + // 取到文件头后按钮收起。 + expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull(); + // 连拉插入的旧内容不得把视口弹到列表底部。 + expect(messageList.scrollTop).toBe(100); + }); + it('drains a notify that lands before the subscribe receipt so the running turn is not stranded', async () => { // 真实竞态:`subscribe` 在 Rust 侧已经注册好 subscriber 并开始通知,但前端还没拿到 // subscriptionId。此时的通知不能白丢——拿到回执后必须补一次 consume,否则整个回合会卡在队列里。 -- 2.52.0 From 0a9cdf6395c0787002307800f0252731d7633c76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 16:51:30 +0800 Subject: [PATCH 81/92] =?UTF-8?q?=E8=A1=A5=20DirectProject=20=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=88=86=E9=A1=B5=E8=BF=9E=E6=8B=89=E7=9A=84=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E8=AF=81=E6=8D=AE=20=E9=87=8C=E7=A8=8B=E7=A2=91?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E8=BF=9E=E6=8B=89=E9=82=A3=E6=9D=A1=E8=BD=AC?= =?UTF-8?q?=E6=88=90=E5=B7=B2=E9=AA=8C=E6=94=B6=EF=BC=8C=E8=AF=81=E6=8D=AE?= =?UTF-8?q?=E6=8C=87=E5=90=91=E6=96=B0=E5=8D=95=E6=B5=8B=E4=B8=8E=E8=B7=A8?= =?UTF-8?q?=E9=A1=B5=E5=90=8C=E5=9B=9E=E5=90=88=E9=9B=86=E6=88=90=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=20=E8=AF=81=E6=8D=AE=E8=A6=81=E6=B1=82=E8=A1=A5?= =?UTF-8?q?=E4=B8=8A=E6=9C=AC=E8=BD=AE=E5=AE=9E=E8=B7=91=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E4=B8=8E=E5=8F=98=E5=BC=82=E9=AA=8C=E8=AF=81=E7=8E=B0=E8=B1=A1?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E5=86=8D=E5=87=BA=E7=8E=B0=E6=89=93?= =?UTF-8?q?=E5=8B=BE=E4=BD=86=E5=8F=AA=E8=A6=86=E7=9B=96=E4=B8=80=E5=8D=8A?= =?UTF-8?q?=20=E5=86=B3=E7=AD=96=E8=AE=B0=E5=BD=95=E6=8A=8A=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E5=8F=A3=E5=BE=84=E5=86=99=E6=88=90=E5=85=B7=E4=BD=93?= =?UTF-8?q?=E6=9D=A1=E6=95=B0=E4=B8=8E=E5=8F=98=E5=BC=82=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=BB=93=E8=AE=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md | 3 ++- docs/project-memory/shared-memory/decision-log.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md index d1c3af086..358c74846 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md @@ -55,7 +55,7 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 - [ ] 回合进行中终止并重启进程后重进项目:已落盘的部分文本与工具卡片按原顺序出现,且界面不显示忙碌态。【待真实 app-server 运行时验收】 - [ ] 进程存活期间的页面重进(含切走再切回)能恢复运行中回合,并允许终止。【待真实 app-server 运行时验收】 - [x] 历史切片从文件尾反向回扫,锚点始终是文件里的原始 item id;切片内全部是不可显示条目时仍能继续向前,不出现锚点停滞。【自动化:`direct_project_history` 尾部回扫与分页锚点用例】 -- [ ] 前端一次翻页操作自动连拉,直到合并后聊天投影出现新回合或 `hasMore=false`,每个操作上限 5 页;锚点不前进时立即停止,不空转。【待补:`directHistoryPaging` 单测 + `project-development.suite.ts` 跨页同回合用例】 +- [x] 前端一次翻页操作自动连拉,直到合并后聊天投影出现新回合或 `hasMore=false`,每个操作上限 5 页;锚点不前进时立即停止,不空转。【自动化:`directHistoryPaging.test.ts` 8 条 + `project-development.suite.ts` 跨页同回合用例(变异验证见下)】 - [x] 聊天视图不再读取 `turn-stream.jsonl` / `tool-calls.jsonl` / Direct 回合事件 / 活动回合快照;`read_direct_turn_stream` 与 `read_direct_tool_calls` 命令已删除。`list_game_creator_direct_active_turns` **有意保留**:它服务首页跨页面的「运行中的项目」列表(`WorkspaceLauncher` / `directActiveTurns.ts`),不属于聊天框读路径。 - [x] 同一工具调用在实时与回读各只出现一张卡片(两个 id 空间按归一后的 `itemId` 对齐)。【自动化:reducer「先到定形、后到补空」合并单测 + 工具卡片渲染用例】 - [x] 前端聊天状态里不再出现第二个 id 概念与任何回合身份字段;事件解析统一来自 ts-rs 生成绑定。【自动化:`directThreadChat` / `directTurnPresentation` 单测 + `cargo test export_bindings` 生成绑定无差异】 @@ -66,6 +66,7 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 ## 证据要求 - 自动化(已跑):`cargo test direct_thread`(25 条)、`cargo test direct_project_history`(20 条)、`cargo test export_bindings`(生成绑定与工作区无差异)、`NODE_OPTIONS=--localstorage-file=… npx vitest run apps/ai-game-creator-shell/tests`(125 files / 1676 passed / 17 skipped)、`npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`(exit 0)、ESLint 与 `prettier`。 +- 自动化(分页连拉,已跑):`npx vitest run apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts`(8 passed)、`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(470 tests / 453 passed / 17 skipped,含新增的 `keeps pulling earlier pages while the page only deepens the rendered turn`)、`npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`(exit 0)。变异验证:把停止判据退化成「这一页有可渲染条目就停」后,跨页用例以 `Unable to find an element with the text: 更早的用户提问` 变红,即用户报的「点了没变化」。 - 运行时(待补):真实 app-server 会话下的新回合、杀进程重开、页面重进、分页与终止。 - 边界:订阅过期、回执竞态、不可显示切片、无 `turn.completed` 的残回合、工具输出超长截断与脱敏。 - 环境注意:`rehype-highlight` 已装齐后 `ChatMarkdownMessage` / `AgentMessageContent` 转绿;Node 26 下 vitest 的 jsdom 用例需要 `--localstorage-file` 才能拿到 `window.localStorage`(`clientApi.test.ts` / `chatPromptPolish.test.tsx`),已记入 `docs/project-memory/shared-memory/pitfalls.md`。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 855eac7d3..fd06e1408 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -10,7 +10,7 @@ - 决策(硬性终止):`items` 为空、`firstItemId` 为 null 或与请求锚点相同 → 立即停止,不靠 5 页上限兜底。 - 决策(落点):口径与循环只在 `features/project-workspace/directHistoryPaging.ts` 一份实现里,首屏与「显示更早」共用;`DIRECT_HISTORY_MAX_PAGES_PER_ACTION` 放 `app/constants.ts`。 - 边界:循环内只累积、结束后一次性并入聊天 state;某页失败时保留已成功页并沿用现有报错文案;切换项目时丢弃整批;不新增 loading / 禁用态与新文案,不做滚动锚定补偿;运行中回合允许连拉历史。 -- 验证:`directHistoryPaging` 单测覆盖口径、上限、`hasMore=false` 早停、锚点不前进、单页失败;`project-development.suite.ts` 新增「跨页同回合」集成用例作为真实回归网。 +- 验证:`directHistoryPaging` 单测 8 条覆盖口径、上限、`hasMore=false` 早停、锚点不前进、单页失败;`project-development.suite.ts` 新增「跨页同回合」集成用例作为真实回归网(变异验证:把判据退化成「有可渲染条目就停」后该用例变红);`tsc` 与 `appSurface.test.ts`(470 tests / 17 skipped)全绿。 ## 2026-09-16 图标图集自动拆图上限提高到 256 -- 2.52.0 From 06004bfeeadecf242edbee4a33ee75a7ffe5e382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 11:34:35 +0800 Subject: [PATCH 82/92] =?UTF-8?q?=E6=B8=85=E7=90=86=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E6=A0=87=E7=AD=BE=E7=9A=84=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除相同结果的死三元分支。 保持资源与运行区域引用均使用统一的 @标签 文本。 --- .../src/features/project-workspace/ResourceReferenceInput.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index cc54a46cc..b061cf091 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -236,9 +236,7 @@ function readDraftFromNodes(): ChatComposerDraft { const labels = new Map( projection.references.map((reference) => [ reference.type === 'resource' ? reference.resourceId : reference.label, - reference.type === 'resource' - ? `@${reference.label}` - : `@${reference.label}`, + `@${reference.label}`, ]), ); const text = projection.content -- 2.52.0 From b73a329b2cdcf5b575b1465e0eecdf603b98d659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 11:35:18 +0800 Subject: [PATCH 83/92] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E5=9C=BA=E6=99=AF=E4=B8=8B=E5=8E=86=E5=8F=B2=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用不含附件的文本输入判断 /history 命令。 避免待发送附件把 canonical prompt 拼成长文本后绕过历史记录加载。 --- apps/ai-game-creator-shell/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 1d5dc6195..9ca76cfcc 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -12303,7 +12303,7 @@ export function App({ } return; } - if (directCodexProductRuntime && canonicalPrompt === '/history') { + if (directCodexProductRuntime && prompt === '/history') { const nextProjectPath = requireChatProjectForUserAction(); if (!nextProjectPath) { return; -- 2.52.0 From 4ae5ab14fa76d821fc0787a5d6a47e018dfa02fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 11:36:10 +0800 Subject: [PATCH 84/92] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E9=98=9F=E5=88=97?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E4=B8=8D=E5=8F=AF=E8=BE=BE=E5=85=9C=E5=BA=95?= =?UTF-8?q?=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 队列标签统一复用 canonical content 的文本派生。 删除不会被现有引用类型触发的附件和素材兜底文案。 --- .../project-workspace/chatComposerQueue.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts b/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts index 3132e0602..74601fa2e 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts @@ -88,17 +88,9 @@ export function queuedChatTurnLabel( const text = directCodexContentToPromptText(turn.userItem.content, assets) .trim() .replace(/\s+/gu, ' '); - if (text) { - return text.length > 24 ? `${text.slice(0, 24)}…` : text; - } - const attachment = turn.userItem.content.find( - (part) => part.type === 'agc_attachment_reference', - ); - if (attachment?.type === 'agc_attachment_reference') { - return `附件 · ${attachment.name || '未命名'}`; - } - if (turn.userItem.content.some((part) => part.type !== 'input_text')) { - return '素材引用'; - } - return '未命名消息'; + return text + ? text.length > 24 + ? `${text.slice(0, 24)}…` + : text + : '未命名消息'; } -- 2.52.0 From 6e63a118e0aa3f9e63a58ea94c3bc46e52ac44c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 12:53:44 +0800 Subject: [PATCH 85/92] =?UTF-8?q?=E5=BB=B6=E5=90=8E=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E7=9B=B4=E5=88=B0=E7=B4=A0=E6=9D=90=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=E5=8F=AF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免初始编辑器挂载时 assetsById 为空导致资源引用永久丢失。 保留 Lexical 节点自行解析资产名称的后续 TODO。 --- .../project-workspace/ResourceReferenceInput.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index b061cf091..033931eb8 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -726,6 +726,20 @@ function ResourceReferenceEditor({ if (initialDraftAppliedRef.current || !initialContent) { return; } + // Manifest assets can arrive after the initial editor mount. Do not mark the + // draft as applied while resource references still have no lookup table; + // otherwise the later assetsById update cannot restore those references. + const hasUnresolvedResourceReference = initialContent.some( + (part) => + part.type === 'agc_resource_reference' && + !assetsById.has(part.resourceId), + ); + if (hasUnresolvedResourceReference && assetsById.size === 0) { + return; + } + // TODO: let the Lexical resource node resolve its asset label from the + // manifest itself, so unresolved references can remain as nodes while the + // manifest loads instead of waiting in this effect. initialDraftAppliedRef.current = true; editor.update(() => { applyContentToRoot(initialContent, assetsById); -- 2.52.0 From faed0ec251764cf4255da855c09f624d9cb643d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 12:54:22 +0800 Subject: [PATCH 86/92] =?UTF-8?q?=E7=A7=BB=E9=99=A4=20DirectProject=20?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E7=9A=84=E9=87=8D=E5=A4=8D=E5=9B=9E=E5=90=88?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DirectProject 继续以 project.jsonl 作为完整回合记录。 入口不再创建 DirectCodexTurnAudit,保留底层可选审计接口供独立测试和其它路径使用。 --- .../src/agent/direct_runtime/user_input.rs | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index 1e631cd1f..506c9f070 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -42,17 +42,9 @@ 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 mut audit = DirectCodexTurnAudit::start(root, &turn_id, &prompt, &[]); - validate_direct_codex_user_item(root, &user_item).map_err(|error| { - audit.finish(false); - error - })?; - let user_prompt = direct_codex_user_item_to_prompt(root, &user_item).map_err(|error| { - audit.finish(false); - error - })?; + validate_direct_codex_user_item(root, &user_item)?; + let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)?; if user_prompt.trim().is_empty() { - audit.finish(false); return Err("聊天内容不能为空".to_string()); } let canonical_user_item = @@ -62,18 +54,15 @@ pub(crate) async fn chat_with_game_creator_direct_codex( &user_prompt, creation_type.as_deref(), Some(&turn_emitter), - Some(&mut audit), + // DirectProject 的完整回合权威已经落在 project.jsonl;不再创建平行审计日志。 + None, canonical_user_item, ) .await { Ok(reply) => reply, - Err(error) => { - audit.finish(false); - return Err(error); - } + Err(error) => return Err(error), }; - audit.finish(true); turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None); Ok(reply) } -- 2.52.0 From d8af0fa882ffb7ccc5c8b53bf140b5753fc7b777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 13:17:31 +0800 Subject: [PATCH 87/92] =?UTF-8?q?=E4=BF=9D=E7=95=99=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E4=B8=AD=E7=9A=84=E9=99=84=E4=BB=B6=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E8=8A=82=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 agc_attachment_reference 增加 Lexical 附件节点与可移除 chip。 恢复初始 canonical content 时不再静默丢弃附件,并让编辑器读回完整附件 part。 --- .../AttachmentReferenceChip.tsx | 40 +++++++ .../AttachmentReferenceNode.tsx | 107 ++++++++++++++++++ .../ResourceReferenceInput.tsx | 15 ++- 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceChip.tsx create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceNode.tsx diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceChip.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceChip.tsx new file mode 100644 index 000000000..e533d6220 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceChip.tsx @@ -0,0 +1,40 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $getNodeByKey, type NodeKey } from 'lexical'; +import { Paperclip, X } from 'lucide-react'; + +import type { DirectCodexUserAttachmentReferencePart } from './generated'; + +export function AttachmentReferenceChip({ + attachment, + nodeKey, +}: { + attachment: DirectCodexUserAttachmentReferencePart; + nodeKey: NodeKey; +}) { + const [editor] = useLexicalComposerContext(); + return ( + + + ); +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceNode.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceNode.tsx new file mode 100644 index 000000000..71b77dc85 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceNode.tsx @@ -0,0 +1,107 @@ +import { + $applyNodeReplacement, + DecoratorNode, + type EditorConfig, + type LexicalNode, + type NodeKey, + type SerializedLexicalNode, + type Spread, +} from 'lexical'; +import type { ReactNode } from 'react'; + +import { AttachmentReferenceChip } from './AttachmentReferenceChip'; +import type { DirectCodexUserAttachmentReferencePart } from './generated'; + +export type SerializedAttachmentReferenceNode = Spread< + { + attachment: DirectCodexUserAttachmentReferencePart; + type: 'attachment-reference'; + version: 1; + }, + SerializedLexicalNode +>; + +/** 附件在纯文本里的唯一占位字符:一个附件 chip 就当一个字符。 */ +const OBJECT_REPLACEMENT = '\uFFFC'; + +/** + * Inline canonical attachment part rendered inside the Lexical composer. + * The node stores the complete part so read-back preserves the project path and status. + */ +export class AttachmentReferenceNode extends DecoratorNode { + __attachment: DirectCodexUserAttachmentReferencePart; + + static getType() { + return 'attachment-reference'; + } + + static clone(node: AttachmentReferenceNode) { + return new AttachmentReferenceNode(node.__attachment, node.__key); + } + + static importJSON(serializedNode: SerializedAttachmentReferenceNode) { + return $createAttachmentReferenceNode(serializedNode.attachment); + } + + constructor( + attachment: DirectCodexUserAttachmentReferencePart, + key?: NodeKey, + ) { + super(key); + this.__attachment = attachment; + } + + exportJSON(): SerializedAttachmentReferenceNode { + return { + ...super.exportJSON(), + attachment: this.__attachment, + type: 'attachment-reference', + version: 1, + }; + } + + createDOM(_config: EditorConfig) { + return document.createElement('span'); + } + + updateDOM() { + return false; + } + + getTextContent() { + return OBJECT_REPLACEMENT; + } + + getTextContentSize() { + return OBJECT_REPLACEMENT.length; + } + + isInline() { + return true; + } + + isKeyboardSelectable() { + return true; + } + + decorate() { + return ( + + ); + } +} + +export function $createAttachmentReferenceNode( + attachment: DirectCodexUserAttachmentReferencePart, +) { + return $applyNodeReplacement(new AttachmentReferenceNode(attachment)); +} + +export function $isAttachmentReferenceNode( + node: LexicalNode | null | undefined, +): node is AttachmentReferenceNode { + return node instanceof AttachmentReferenceNode; +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 033931eb8..7a8759cb9 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -61,6 +61,11 @@ import { createProjectResourcePreviewRequestId, createProjectResourcePreviewScopeId, } from '../../services/projectResourcePreviewTransport'; +import { + $createAttachmentReferenceNode, + $isAttachmentReferenceNode, + AttachmentReferenceNode, +} from './AttachmentReferenceNode'; import { chatPromptDraftKey, readChatPromptPolishReminderDisabled, @@ -199,6 +204,10 @@ function collectDraftParts( content.push({ type: 'input_text', text: '\n' }); return; } + if ($isAttachmentReferenceNode(node)) { + content.push({ type: 'agc_attachment_reference', ...node.__attachment }); + return; + } if ($isResourceReferenceNode(node)) { references.push(node.__reference); content.push(chatReferenceToContentPart(node.__reference)); @@ -422,6 +431,10 @@ function applyContentToRoot( }); return; } + if (part.type === 'agc_attachment_reference') { + paragraph.append($createAttachmentReferenceNode(part)); + return; + } const reference = referenceFromContentPart(part, assetsById); if (reference) { paragraph.append($createResourceReferenceNode(reference)); @@ -1442,7 +1455,7 @@ export const ResourceReferenceInput = forwardRef< return ( Date: Fri, 18 Sep 2026 13:19:01 +0800 Subject: [PATCH 88/92] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E8=8D=89=E7=A8=BF?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2=E7=9A=84=E6=96=87=E6=9C=AC=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 ChatComposerDraft.content 设为必填的 canonical 唯一来源。 删除 text 与 references 重建 DirectCodexUserItem 的兼容分支及其无效空值兜底。 --- apps/ai-game-creator-shell/src/App.tsx | 2 +- .../ResourceReferenceInput.tsx | 18 +++++++----------- .../project-workspace/resourceReferences.ts | 18 +++--------------- .../src/view/project-development/index.tsx | 4 ++-- 4 files changed, 13 insertions(+), 29 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 9ca76cfcc..21c09ca58 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -4073,7 +4073,7 @@ export function App({ } function handleChatComposerChange(draft: ChatComposerDraft) { - const content = draft.content ?? []; + const content = draft.content; setChatContent(content); setChatInput(directCodexContentToPromptText(content, manifest.assets)); setChatReferences( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 7a8759cb9..cec28710a 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -916,11 +916,7 @@ function ResourceReferenceEditor({ ); const readPromptText = useCallback( - () => - directCodexContentToPromptText( - liveDraftRef.current.content ?? [], - assets, - ), + () => directCodexContentToPromptText(liveDraftRef.current.content, assets), [assets], ); const resolvePolishContext = useCallback( @@ -957,7 +953,7 @@ function ResourceReferenceEditor({ const submitCurrentDraft = useCallback(() => { setReminderOpen(false); acknowledgedDraftKeyRef.current = chatPromptDraftKey( - liveDraftRef.current.content ?? [], + liveDraftRef.current.content, ); rootRef.current?.closest('form')?.requestSubmit(); }, [rootRef]); @@ -995,9 +991,9 @@ function ResourceReferenceEditor({ const handleFormSubmit = (event: Event) => { if ( !shouldRemindChatPromptPolish({ - content: liveDraftRef.current.content ?? [], + content: liveDraftRef.current.content, prompt: directCodexContentToPromptText( - liveDraftRef.current.content ?? [], + liveDraftRef.current.content, assetsRef.current, ), acknowledgedDraftKey: acknowledgedDraftKeyRef.current, @@ -1016,7 +1012,7 @@ function ResourceReferenceEditor({ // 草稿发出去或被清空后重新开始一轮:清掉润色结果与「本轮已确认」标记。 useEffect(() => { - if ((liveDraftRef.current.content ?? []).length > 0) return; + if (liveDraftRef.current.content.length > 0) return; resetPromptPolish(); acknowledgedDraftKeyRef.current = null; }, [resetPromptPolish]); @@ -1365,9 +1361,9 @@ function ResourceReferenceEditor({ const nextDraft = readResourceReferenceDraft(editorState); liveDraftRef.current = nextDraft; setDraftText( - directCodexContentToPromptText(nextDraft.content ?? [], assets), + directCodexContentToPromptText(nextDraft.content, assets), ); - if ((nextDraft.content ?? []).length === 0) { + if (nextDraft.content.length === 0) { resetPromptPolish(); acknowledgedDraftKeyRef.current = null; } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index e111106a5..1be2d478e 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -51,8 +51,8 @@ export type ChatReference = ResourceReference | RuntimeRegionReference; export type ChatComposerDraft = { text: string; references: ChatReference[]; - /** Lexical 顺序对应的 canonical user content;仅由编辑器读回时提供。 */ - content?: DirectCodexUserContentPart[]; + /** Lexical 顺序对应的 canonical user content,是草稿的唯一结构化来源。 */ + content: DirectCodexUserContentPart[]; }; export const RESOURCE_REFERENCE_INSERT_EVENT = 'agc-resource-reference-insert'; @@ -153,22 +153,10 @@ export function chatComposerDraftToDirectCodexUserItem( draft: ChatComposerDraft, id: string, ): DirectCodexUserItem { - const content = - draft.content ?? - (draft.references.length > 0 - ? [ - ...(draft.text - ? [{ type: 'input_text' as const, text: draft.text }] - : []), - ...draft.references.map(chatReferenceToContentPart), - ] - : draft.text - ? [{ type: 'input_text' as const, text: draft.text }] - : []); return { type: 'message', role: 'user', - content, + content: draft.content, id, } satisfies DirectCodexUserItem; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 3359be08e..d81dd7083 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -6712,7 +6712,7 @@ export default function ProjectDevelopmentView({ if ( currentDraft && directCodexContentToPromptText( - currentDraft.content ?? [], + currentDraft.content, manifest.assets, ) !== text ) { @@ -6743,7 +6743,7 @@ export default function ProjectDevelopmentView({ const applyResourceQuickEditDraft = useCallback( (draft: ChatComposerDraft) => { applyResourceQuickEditPrompt( - directCodexContentToPromptText(draft.content ?? [], manifest.assets), + directCodexContentToPromptText(draft.content, manifest.assets), ); }, [applyResourceQuickEditPrompt, manifest.assets], -- 2.52.0 From 6b13d1d61333a52ba8a3dd9cb2bceaabf1c19862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 14:00:00 +0800 Subject: [PATCH 89/92] =?UTF-8?q?=E6=B8=85=E6=B4=97=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E6=91=98=E8=A6=81=E5=85=83=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wire 投影复用附件名称与媒体类型的安全清洗。 附件项目路径输出归一化结果,避免原始控制字符进入模型提示。 --- .../src/agent/direct_codex_user_item/wire.rs | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index a05daef41..ca26742f5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -1,6 +1,9 @@ use super::model::{DirectCodexUserContentPart, DirectCodexUserItem}; use super::validation::validate_direct_codex_user_item; -use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path}; +use crate::agent::{ + read_manifest_for_project, sanitize_attachment_local_path, sanitize_attachment_media_type, + sanitize_attachment_name, +}; use serde_json::Value; use std::path::Path; @@ -101,14 +104,15 @@ pub(crate) fn direct_codex_user_item_to_wire_input( summary } DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + let name = sanitize_attachment_name(&reference.name); + let media_type = sanitize_attachment_media_type(&reference.media_type); + let local_path = sanitize_attachment_local_path(&reference.local_path); let mut summary = format!( "[附件:名称={};类型={};大小={} 字节", - reference.name.trim(), - reference.media_type.trim(), - reference.size + name, media_type, reference.size ); - if !reference.local_path.trim().is_empty() { - summary.push_str(&format!(";项目路径={}", reference.local_path.trim())); + if let Some(local_path) = local_path { + summary.push_str(&format!(";项目路径={local_path}")); } summary.push_str(&format!(";状态={}", reference.status.trim())); summary.push(']'); @@ -212,6 +216,36 @@ mod tests { assert!(content[1]["text"].as_str().unwrap().contains("notes.txt")); } + #[test] + fn attachment_metadata_is_sanitized_before_prompt_projection() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") + .expect("init project"); + let item = json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [{ + "type": "agc_attachment_reference", + "name": "C:\\tmp\\notes\nsecret.md", + "mediaType": "text/plain\nBearer secret", + "size": 4, + "localPath": "assets\\.\\notes.txt", + "status": "imported" + }] + }); + let wire = super::direct_codex_user_item_to_wire_input( + root.path(), + &serde_json::from_value(item).expect("deserialize user item"), + ) + .expect("attachment metadata should project"); + let text = wire[0]["text"].as_str().expect("wire text"); + assert!(text.contains("名称=notessecret.md"), "{text}"); + assert!(text.contains("类型=application/octet-stream"), "{text}"); + assert!(text.contains("项目路径=assets/notes.txt"), "{text}"); + assert!(!text.contains("Bearer secret"), "{text}"); + } + #[test] fn whitespace_only_text_parts_survive_validation() { let root = tempfile::tempdir().expect("temp project"); -- 2.52.0 From 37d45d2de8068e58c01570de030043d7f8184185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 14:03:30 +0800 Subject: [PATCH 90/92] =?UTF-8?q?=E9=99=90=E5=88=B6=20DirectProject=20inli?= =?UTF-8?q?ne=20=E9=99=84=E4=BB=B6=E6=95=B0=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 canonical 附件增加独立的八个条目上限。 补充超限附件拒绝测试,避免绕过素材引用数量门禁。 --- .../direct_codex_user_item/validation.rs | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs index 8de0f9440..12bab6797 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs @@ -4,6 +4,7 @@ use super::model::{ }; use crate::agent::{ read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest, + MAX_DIRECT_CODEX_ATTACHMENTS, }; use std::path::Path; @@ -27,6 +28,7 @@ pub(crate) fn validate_direct_codex_user_item( } let manifest = read_manifest_for_project(root)?; let mut reference_count = 0usize; + let mut attachment_count = 0usize; for part in &message.content { match part { DirectCodexUserContentPart::InputText { .. } => {} @@ -39,6 +41,12 @@ pub(crate) fn validate_direct_codex_user_item( validate_runtime_region_reference(&manifest, reference)?; } DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + attachment_count = attachment_count.saturating_add(1); + if attachment_count > MAX_DIRECT_CODEX_ATTACHMENTS { + return Err(format!( + "一次最多携带 {MAX_DIRECT_CODEX_ATTACHMENTS} 个附件" + )); + } if reference.name.trim().is_empty() { return Err("附件缺少文件名".to_string()); } @@ -107,8 +115,9 @@ fn validate_runtime_region_reference( #[cfg(test)] mod tests { - use super::content_has_meaningful_input; + use super::{content_has_meaningful_input, validate_direct_codex_user_item}; use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart; + use serde_json::json; fn input_text(text: &str) -> DirectCodexUserContentPart { DirectCodexUserContentPart::InputText { @@ -148,4 +157,33 @@ mod tests { }, ])); } + + #[test] + fn inline_attachment_count_is_bounded_independently() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "validation-test", "校验测试") + .expect("init project"); + let content = (0..=crate::agent::MAX_DIRECT_CODEX_ATTACHMENTS) + .map(|index| { + json!({ + "type": "agc_attachment_reference", + "name": format!("attachment-{index}.txt"), + "mediaType": "text/plain", + "size": 1, + "localPath": "", + "status": "failed" + }) + }) + .collect::>(); + let item = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "content": content, + "id": "turn-1:user" + })) + .expect("deserialize user item"); + let error = validate_direct_codex_user_item(root.path(), &item) + .expect_err("too many inline attachments must be rejected"); + assert!(error.contains("最多携带"), "{error}"); + } } -- 2.52.0 From 709ef6256c9a02ace75eef6769bc972a73e67fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 14:07:07 +0800 Subject: [PATCH 91/92] =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=B7=B2=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E9=99=84=E4=BB=B6=E7=9A=84=E9=A1=B9=E7=9B=AE=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit imported 附件必须携带有效非空的项目相对路径。 failed 附件仍允许无路径,并补充状态路径组合测试。 --- .../direct_codex_user_item/validation.rs | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs index 12bab6797..f0ff6e7be 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs @@ -50,11 +50,15 @@ pub(crate) fn validate_direct_codex_user_item( if reference.name.trim().is_empty() { return Err("附件缺少文件名".to_string()); } + let status = reference.status.trim(); + if status == "imported" && reference.local_path.trim().is_empty() { + return Err("已导入附件缺少项目路径".to_string()); + } if !reference.local_path.trim().is_empty() { sanitize_attachment_local_path(&reference.local_path) .ok_or_else(|| "附件项目路径无效".to_string())?; } - if !matches!(reference.status.trim(), "imported" | "failed") { + if !matches!(status, "imported" | "failed") { return Err("附件状态无效".to_string()); } } @@ -186,4 +190,28 @@ mod tests { .expect_err("too many inline attachments must be rejected"); assert!(error.contains("最多携带"), "{error}"); } + + #[test] + fn imported_attachment_requires_a_project_path() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "validation-test", "校验测试") + .expect("init project"); + let item = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "agc_attachment_reference", + "name": "attachment.txt", + "mediaType": "text/plain", + "size": 1, + "localPath": "", + "status": "imported" + }], + "id": "turn-1:user" + })) + .expect("deserialize user item"); + let error = validate_direct_codex_user_item(root.path(), &item) + .expect_err("imported attachment without a project path must fail"); + assert!(error.contains("缺少项目路径"), "{error}"); + } } -- 2.52.0 From 9fa05eccaf197144843a163a7af5a2446e0208bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 14:12:47 +0800 Subject: [PATCH 92/92] =?UTF-8?q?=E9=99=90=E5=88=B6=E9=99=84=E4=BB=B6?= =?UTF-8?q?=E5=85=83=E6=95=B0=E6=8D=AE=E6=A0=BC=E5=BC=8F=E4=B8=8E=E9=95=BF?= =?UTF-8?q?=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 校验 inline 附件名称和媒体类型的控制字符、格式与长度。 共享附件清洗上限常量并补充异常元数据拒绝测试。 --- .../src/agent/direct_codex_attachments.rs | 6 +- .../direct_codex_user_item/validation.rs | 67 ++++++++++++++++++- .../src/agent/direct_codex_user_item/wire.rs | 9 ++- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs index 8cf49e017..2f93f775f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs @@ -2,9 +2,9 @@ //! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。 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; +pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160; +pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96; +pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512; const HOME_ATTACHMENT_HEADER: &str = "[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs index f0ff6e7be..acbbdb4f0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs @@ -4,7 +4,8 @@ use super::model::{ }; use crate::agent::{ read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest, - MAX_DIRECT_CODEX_ATTACHMENTS, + MAX_DIRECT_CODEX_ATTACHMENTS, MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS, + MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS, }; use std::path::Path; @@ -50,6 +51,22 @@ pub(crate) fn validate_direct_codex_user_item( if reference.name.trim().is_empty() { return Err("附件缺少文件名".to_string()); } + let name = reference.name.trim(); + if name.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS + || name.chars().any(char::is_control) + { + return Err("附件文件名无效或过长".to_string()); + } + let media_type = reference.media_type.trim(); + if media_type.is_empty() + || media_type.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS + || media_type.chars().any(|character| { + !(character.is_ascii_alphanumeric() + || matches!(character, '/' | '+' | '-' | '.' | '_')) + }) + { + return Err("附件媒体类型无效或过长".to_string()); + } let status = reference.status.trim(); if status == "imported" && reference.local_path.trim().is_empty() { return Err("已导入附件缺少项目路径".to_string()); @@ -214,4 +231,52 @@ mod tests { .expect_err("imported attachment without a project path must fail"); assert!(error.contains("缺少项目路径"), "{error}"); } + + #[test] + fn attachment_name_and_media_type_are_bounded_and_well_formed() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "validation-test", "校验测试") + .expect("init project"); + let long_name = "a".repeat(crate::agent::MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS + 1); + let cases = [ + ( + json!({ + "name": "bad\nname.txt", + "mediaType": "text/plain" + }), + "文件名", + ), + ( + json!({ + "name": "ok.txt", + "mediaType": "text/plain\nsecret" + }), + "媒体类型", + ), + ( + json!({ + "name": long_name, + "mediaType": "text/plain" + }), + "文件名", + ), + ]; + for (metadata, expected) in cases { + let mut value = metadata; + value["type"] = json!("agc_attachment_reference"); + value["size"] = json!(1); + value["localPath"] = json!(""); + value["status"] = json!("failed"); + let item = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "content": [value], + "id": "turn-1:user" + })) + .expect("deserialize user item"); + let error = validate_direct_codex_user_item(root.path(), &item) + .expect_err("invalid attachment metadata must fail"); + assert!(error.contains(expected), "{error}"); + } + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index ca26742f5..d9b365dd8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -227,8 +227,8 @@ mod tests { "id": "turn-1:user", "content": [{ "type": "agc_attachment_reference", - "name": "C:\\tmp\\notes\nsecret.md", - "mediaType": "text/plain\nBearer secret", + "name": "C:\\tmp\\notes.md", + "mediaType": "text/plain", "size": 4, "localPath": "assets\\.\\notes.txt", "status": "imported" @@ -240,10 +240,9 @@ mod tests { ) .expect("attachment metadata should project"); let text = wire[0]["text"].as_str().expect("wire text"); - assert!(text.contains("名称=notessecret.md"), "{text}"); - assert!(text.contains("类型=application/octet-stream"), "{text}"); + assert!(text.contains("名称=notes.md"), "{text}"); + assert!(text.contains("类型=text/plain"), "{text}"); assert!(text.contains("项目路径=assets/notes.txt"), "{text}"); - assert!(!text.contains("Bearer secret"), "{text}"); } #[test] -- 2.52.0