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 fedf2cc12..72bd90dfb 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 @@ -1221,4 +1221,60 @@ mod tests { assert_eq!(calls[1].id, "item-c"); } + /// 判据:200 条上限是「按时间保留最新 200 条」,超出时更早回合的卡片会被静默丢弃 + /// (契约内行为,不是缺陷)。本用例只钉住现状与时间正序。 + #[test] + fn tool_call_cap_drops_oldest_turn_cards() { + let root = init_tool_call_project("tool-call-cap-oldest"); + let old_turn = (0..DIRECT_TOOL_CALL_LIMIT) + .map(|index| { + direct_tool_call_from_item( + root.path(), + &json!({ + "id": format!("item-{index:04}"), + "type": "commandExecution", + "command": format!("run {index}"), + "startedAtMs": 1000 + index as u64, + }), + "turn-old", + false, + 1000 + index as u64, + ) + .expect("old turn tool call") + }) + .collect::>(); + persist_direct_tool_calls_at(root.path(), &old_turn).expect("persist old turn"); + let newest = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-newest", + "type": "commandExecution", + "command": "run newest", + "startedAtMs": 90_000, + }), + "turn-new", + false, + 90_000, + ) + .expect("newest tool call"); + persist_direct_tool_call_at(root.path(), &newest).expect("persist newest"); + + let read = read_direct_tool_calls_at(root.path()).expect("read capped"); + assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "上限仍是 200 条"); + assert_eq!( + read.last().expect("last").id, + "item-newest", + "最新回合的卡片必须在" + ); + assert_eq!( + read.first().expect("first").id, + "item-0001", + "最旧回合的卡片被静默丢弃(老回合卡片会消失)" + ); + assert!( + read.windows(2) + .all(|pair| pair[0].timestamp() <= pair[1].timestamp()), + "回读必须按时间正序" + ); + } } diff --git a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md index 7cf7c832e..d330a3948 100644 --- a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md +++ b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md @@ -24,10 +24,16 @@ ``` - `id`:Codex item 的 id;同一 item 的 `started` 与 `completed` 必须落成**同一条**(按 id 幂等 upsert,不允许写两行)。 +- **状态单调**:同一 `id` 的每条快照按 `updatedAt` 合并落盘——`updatedAt` 更旧的快照不得覆盖更新的 `status` 与 `updatedAt`。逐条快照落盘与回合末整批落盘两条路径会并发竞争,后到的旧快照不能把已经 `completed` / `failed` 的卡片打回 `running`;`updatedAt` 相同时终态优先;`startedAt` 取最早的非零值(`item/completed` 不一定带 `startedAtMs`)。 - `title` 是折叠态的一行标题,按 kind 固定:`command` → `执行命令`、`file_change` → `编辑 N 个文件`(N = changes 去重后数量)、其余见 kind 枚举。 -- `summary` 是折叠态标题后面的短摘要:命令取命令首行(截断 120 字符),`file_change` 取首个变更路径。 -- `detail.command` / `detail.output` 各截断到 4000 字符;`detail.changes[].path` 用项目相对路径。 -- **必须脱敏**:沿用既有 `codex_app_server.rs` 里对 command/tool 参数的安全处理,不得把 API Key、Token、Cookie、绝对用户目录写进 `detail`。 +- `summary` 是折叠态标题后面的短摘要:命令取命令首行(截断 120 字符),`file_change` 取首个变更路径。`summary` 的每个来源(命令、变更路径、`tool`)都必须先脱敏再落盘。 +- `detail.command` / `detail.output` 各截断到 4000 字符。 +- **路径形状**:`detail.changes[].path` 用**项目相对路径**(如 `game/src/x.ts`,分隔符统一成 `/`);项目外的绝对路径落成 `` 占位。任何情况下都不得写出项目根目录本身、用户家目录或绝对路径的原始值。 +- **必须脱敏**(落盘前统一走 `agent/direct_tool_calls.rs` 的 `sanitize_detail_text`,顺序:项目路径归一化 → `redact_absolute_path_tokens` → `redact_secret_tokens` → `sanitize_error_context`): + - 前缀型密钥沿用 `redact_secret_tokens`(`sk-…`、`tnr_sk_…`、`ghp_…`、`AKIA…`、`eyJ…` 等); + - 键值型凭据沿用 `sanitize_error_context`(= `redact_secret_tokens` + `redact_error_sensitive_assignments` + `redact_error_bearer_values` + `redact_error_config_names` 的既有组合),覆盖 `Authorization: Bearer …`、`Cookie: session=…`、`api_key=…`、`client_secret=…`、`token=…` 等形状; + - 含 `--password` / `--token` / `--secret` / `--api-key` 这类敏感 CLI 标志的行按既有 fail-closed 约定**整行**替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致;即使标志后面只是 `$VAR` 占位符也整行替换,占位符本身不会保留); + - 脱敏必须幂等(同一段文本连跑两次结果一致),且不得把未脱敏文本写进 `detail` / `summary`。 ### 2. 实时事件(新增字段,不改既有字段语义) @@ -43,10 +49,11 @@ toolCalls?: DirectTurnToolCall[] | null; ### 3. 回读命令 -新增 Tauri 命令 `read_direct_tool_calls(projectPath)`,返回按时间正序的 `DirectTurnToolCall[]`,最多最近 200 条(超出截断,保留最新)。 +新增 Tauri 命令 `read_direct_tool_calls(projectPath)`,返回按时间正序的 `DirectTurnToolCall[]`。 +- **上限语义**:200 条是「按时间保留最新 200 条」。超出时更早回合的卡片会被**静默丢弃**(老回合卡片会消失),不做分页、不做历史回填;同一 `id` 的多条记录先按 `updatedAt` 合并,再按时间正序裁剪。 - 历史文件缺失 → 返回空数组,不报错。 -- 单行损坏 → 跳过该行继续,不整体失败(与 Codex item 流一样是"尽力而为"的展示数据,不是业务真相)。 +- 单行损坏 → 逐行读字节并逐行解码,跳过该行继续,不整体失败;只有损坏字节与下一行黏成一行(例如写入被截断、缺失换行)时,被丢掉的也只是那**一行**,其后的合法记录必须继续读回(与 Codex item 流一样是"尽力而为"的展示数据,不是业务真相)。 ### 4. 前端合并与渲染 @@ -97,7 +104,7 @@ toolCalls?: DirectTurnToolCall[] | null; - Rust(`apps/ai-game-creator-shell/src-tauri/src/`):`agent/codex_app_server.rs`(item → 结构化采集)、`agent/direct_runtime.rs`(随事件下发 + 回合结束持久化)、`agent/direct_project_history.rs` 或新增 `agent/direct_tool_calls.rs`(upsert 与回读)、`commands.rs`(新增命令注册)。 - 前端(`apps/ai-game-creator-shell/src/`):`app/types.ts`(新增字段与类型)、`App.tsx`(订阅、累加、加载时合并)、`features/agent-runtime/` 或新增 `features/project-workspace/ToolCallCard.tsx`(卡片组件)、`styles.css`(卡片样式,新样式集中在文件末尾中文注释区块)。 -- 测试:Rust 侧单元测试(upsert 幂等、截断、脱敏、损坏行跳过)、前端 `tests/`(卡片渲染、折叠交互、重开项目后合并、老事件兼容)。 +- 测试:Rust 侧单元测试(upsert 幂等、状态按 `updatedAt` 单调合并、截断、脱敏覆盖与幂等性、项目相对路径形状、非法 UTF-8 损坏行跳过、200 条上限保留最新)、前端 `tests/`(卡片渲染、折叠交互、重开项目后合并、老事件兼容)。 ## 实施顺序(每步都要能独立验证)