补记工具调用卡片契约(脱敏范围、路径形状、状态单调、200 条上限语义)

- docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md:
  写明同一 id 的快照按 updatedAt 单调合并(旧快照不得把 completed / failed 打回 running,
  并发来源是逐条快照落盘与回合末整批落盘两条路径)
- 写明 detail.changes[].path 用项目相对路径(分隔符统一成 /)、项目外绝对路径用 <absolute-path> 占位
- 写明必须脱敏的具体函数链路(sanitize_detail_text:路径归一化 → redact_absolute_path_tokens →
  redact_secret_tokens → sanitize_error_context),含敏感 CLI 标志的行整行 fail-closed
  (--password 后面即使只是 $VAR 占位符也整行替换),且脱敏必须幂等
- 写明 200 条上限语义是"按时间保留最新 200 条",超出时更早回合的卡片会被静默丢弃(老回合卡片会消失),
  不做分页与历史回填(行为不变,只补文档)
- 写明单行损坏(含非法 UTF-8)逐行读取跳过继续,黏连形态只丢那一行
- 新增单测:tool_call_cap_drops_oldest_turn_cards(超出后保留最新 N 条 + 按时间正序)
This commit is contained in:
2026-09-15 18:22:53 +08:00
parent 885f2f10f3
commit b395185580
2 changed files with 69 additions and 6 deletions
@@ -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::<Vec<_>>();
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()),
"回读必须按时间正序"
);
}
}
@@ -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`,分隔符统一成 `/`);项目外的绝对路径落成 `<absolute-path>` 占位。任何情况下都不得写出项目根目录本身、用户目录或绝对路径的原始值
- **必须脱敏**(落盘前统一走 `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/`(卡片渲染、折叠交互、重开项目后合并、老事件兼容)。
## 实施顺序(每步都要能独立验证)