From 180a249a68cf477f1f895a0c767bcde82472c5f9 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 18:08:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=A0=E6=8E=89=E8=81=8A=E5=A4=A9=E5=9B=9E?= =?UTF-8?q?=E8=AF=BB=E8=B7=AF=E5=BE=84=E9=87=8C=E5=B7=B2=E6=97=A0=E4=BA=BA?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E7=9A=84=E5=9B=9E=E8=AF=BB=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E4=B8=8E=E5=89=8D=E7=AB=AF=E6=AD=BB=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除 Rust 侧 read_direct_tool_calls_at 与 read_direct_turn_stream_at:前端已改走项目对话历史,这两个回读函数不再有调用方 direct_tool_calls / direct_turn_stream 的模块注释与上限常量口径改成写侧,回读命令退役后不再提「回读」 direct_tool_calls 单测改为直接读盘断言落盘内容,删掉只服务已删回读的跳过损坏行 / 合并重复行 / 回读上限三条用例 写前读取的 UTF-8 容错用例改名为 tool_call_pre_read_skips_invalid_utf8_line 并补缺文件返回空表断言 app/types.ts 删除 GameCreatorDirectTurnUpdateEvent 与 TurnStream* 五个只服务旧事件链路的类型 工具卡片方案 §3 由「回读命令」改写为「落盘契约(写侧)」,文首修订说明同步 --- .../src-tauri/src/agent/direct_tool_calls.rs | 161 ++++-------------- .../src-tauri/src/agent/direct_turn_stream.rs | 13 +- apps/ai-game-creator-shell/src/app/types.ts | 91 +--------- ...方案】GameAgent对话工具调用卡片-2026-09-14.md | 6 +- 4 files changed, 43 insertions(+), 228 deletions(-) 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 f73471a56..718ed37a8 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 @@ -1,4 +1,4 @@ -//! GameAgent 对话「工具调用卡片」的采集、持久化与回读。 +//! GameAgent 对话「工具调用卡片」的采集与持久化。 //! //! 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`: //! Codex app-server 的 `item/started` / `item/completed` 里带着完整的命令 / 文件变更 @@ -9,7 +9,7 @@ //! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。 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::config::write_game_creator_private_file; use crate::project::{enforce_project_permission_policy, project_append_lock_for}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -22,7 +22,7 @@ use std::path::{Path, PathBuf}; pub(crate) const DIRECT_TOOL_CALL_RECORD_TYPE: &str = "tool_call_item"; /// 条目 schema 版本。 pub(crate) const DIRECT_TOOL_CALL_SCHEMA_VERSION: &str = "agc-tool-call.v1"; -/// 回读上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。 +/// 落盘上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。 pub(crate) const DIRECT_TOOL_CALL_LIMIT: usize = 200; /// `detail.command` / `detail.output` 的字符上限。 const DIRECT_TOOL_CALL_DETAIL_MAX_CHARS: usize = 4000; @@ -397,15 +397,6 @@ fn normalize_tool_calls(calls: Vec) -> Vec { normalized } -/// 回读:文件缺失返回空数组;单行损坏跳过;按时间正序,最多最近 200 条。 -pub(crate) fn read_direct_tool_calls_at(root: &Path) -> Result, String> { - let path = tool_calls_path(root); - if !prepare_game_creator_private_path_for_read(&path, false, "工具调用历史")? { - return Ok(Vec::new()); - } - Ok(normalize_tool_calls(read_tool_call_lines(&path))) -} - /// 状态的「确定性」排序:终态(`completed` / `failed`)优先于 `running`。 fn status_certainty(status: &str) -> u8 { match status { @@ -554,10 +545,22 @@ mod tests { use super::{ direct_tool_call_from_item, direct_tool_call_now_ms, direct_tool_call_status, direct_tool_call_status_changed, persist_direct_tool_call_at, persist_direct_tool_calls_at, - read_direct_tool_calls_at, sanitize_detail_text, tool_calls_path, DirectToolCall, - DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT, DIRECT_TOOL_CALL_SCHEMA_VERSION, + read_tool_call_lines, sanitize_detail_text, tool_call_from_line, tool_calls_path, + DirectToolCall, DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT, + DIRECT_TOOL_CALL_SCHEMA_VERSION, }; use serde_json::json; + use std::path::Path; + + /// 写侧用例直接读文件:回读命令退役后不再经过 `normalize_tool_calls` 的合并与裁剪, + /// 断言因此落在「磁盘上到底写了什么」这一层。 + fn persisted_tool_calls(root: &Path) -> Vec { + std::fs::read_to_string(tool_calls_path(root)) + .unwrap_or_default() + .lines() + .filter_map(tool_call_from_line) + .collect() + } /// 一行合法的落盘信封(回读用例的夹具)。 fn tool_call_row(id: &str, started_at: u64, updated_at: u64) -> String { @@ -682,7 +685,7 @@ mod tests { .expect("completed tool call"); persist_direct_tool_call_at(root.path(), &completed).expect("persist completed"); - let calls = read_direct_tool_calls_at(root.path()).expect("read tool calls"); + let calls = persisted_tool_calls(root.path()); assert_eq!(calls.len(), 1, "同一 id 只能有一行"); assert_eq!(calls[0].status, "completed"); assert_eq!(calls[0].started_at, 1000, "startedAt 不被 completed 覆盖"); @@ -782,85 +785,6 @@ mod tests { ); } - /// 判据:单行损坏只跳过该行,不整体失败;缺文件返回空数组。 - #[test] - fn tool_call_read_skips_corrupted_lines() { - let root = init_tool_call_project("tool-call-corrupt"); - let path = tool_calls_path(root.path()); - std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); - let good = serde_json::to_string(&json!({ - "type": "tool_call_item", - "payload": { - "schemaVersion": "agc-tool-call.v1", - "id": "item-good", - "turnId": "turn-1", - "kind": "command", - "title": "执行命令", - "summary": "npm run build", - "status": "completed", - "detail": {"command": "npm run build"}, - "startedAt": 1, - "updatedAt": 2 - } - })) - .expect("serialize good row"); - std::fs::write( - &path, - format!("{good}\n{{ not json\n{{\"type\":\"other\",\"payload\":{{}}}}\n{good}\n"), - ) - .expect("write fixture"); - - let missing = tempfile::tempdir().expect("missing dir"); - assert!( - read_direct_tool_calls_at(missing.path()) - .expect("missing file is empty") - .is_empty(), - "历史文件缺失必须返回空数组" - ); - - let calls = read_direct_tool_calls_at(root.path()).expect("read with corrupted lines"); - assert_eq!(calls.len(), 1, "坏行被跳过,同 id 归并成一条"); - assert_eq!(calls[0].id, "item-good"); - } - - /// 判据:回读按时间正序,且超出上限时保留最新。 - #[test] - fn tool_call_read_is_ordered_and_capped() { - let root = init_tool_call_project("tool-call-cap"); - let total = DIRECT_TOOL_CALL_LIMIT + 5; - let calls = (0..total) - .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-1", - false, - 1000 + index as u64, - ) - .expect("tool call") - }) - .collect::>(); - persist_direct_tool_calls_at(root.path(), &calls).expect("persist batch"); - - let read = read_direct_tool_calls_at(root.path()).expect("read capped"); - assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "超出上限保留最新 N 条"); - assert_eq!( - read.first().expect("first").id, - format!("item-{:04}", total - DIRECT_TOOL_CALL_LIMIT), - "最早被裁掉的是最旧的条目" - ); - assert!( - read.windows(2) - .all(|pair| pair[0].timestamp() <= pair[1].timestamp()), - "回读必须按时间正序" - ); - } - /// 判据:fileChange 的标题按去重后的变更数量,摘要取首个变更路径。 #[test] fn tool_call_file_change_title_counts_unique_paths() { @@ -1059,7 +983,7 @@ mod tests { persist_direct_tool_call_at(root.path(), &completed).expect("persist completed first"); persist_direct_tool_call_at(root.path(), &running).expect("persist stale running"); - let calls = read_direct_tool_calls_at(root.path()).expect("read after stale single write"); + let calls = persisted_tool_calls(root.path()); assert_eq!(calls.len(), 1, "同一 id 只能有一行"); assert_eq!( calls[0].status, "completed", @@ -1071,7 +995,7 @@ mod tests { // 回合末整批落盘那条路径同样不得回退。 persist_direct_tool_calls_at(root.path(), std::slice::from_ref(&running)) .expect("persist stale running batch"); - let calls = read_direct_tool_calls_at(root.path()).expect("read after stale batch write"); + let calls = persisted_tool_calls(root.path()); assert_eq!( calls[0].status, "completed", "整批落盘路径同样不得把 completed 打回 running" @@ -1079,28 +1003,6 @@ mod tests { assert_eq!(calls[0].updated_at, 2000, "整批落盘不得回退 updatedAt"); } - /// 判据:读回时同 id 的重复行也按 `updatedAt` 单调合并(磁盘上留有旧快照不得回退状态)。 - #[test] - fn tool_call_read_merges_duplicate_rows_monotonically() { - let root = init_tool_call_project("tool-call-read-monotonic"); - let path = tool_calls_path(root.path()); - std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); - let completed = tool_call_row("item-1", 1000, 2000); - let stale_running = tool_call_row("item-1", 1000, 1000) - .replace("\"status\":\"completed\"", "\"status\":\"running\""); - assert!(stale_running.contains("\"status\":\"running\"")); - std::fs::write(&path, format!("{completed}\n{stale_running}\n")).expect("write fixture"); - - let calls = read_direct_tool_calls_at(root.path()).expect("read duplicate rows"); - assert_eq!(calls.len(), 1, "同 id 归并成一条"); - assert_eq!( - calls[0].status, "completed", - "磁盘上更旧的快照不得把状态打回 running" - ); - assert_eq!(calls[0].updated_at, 2000, "归并保留更新的 updatedAt"); - assert_eq!(calls[0].started_at, 1000); - } - /// 判据:项目内绝对路径落成项目相对路径,项目外绝对路径保持既有占位形状。 #[test] fn tool_call_paths_become_project_relative() { @@ -1153,9 +1055,10 @@ mod tests { ); } - /// 判据:单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回。 + /// 判据:写前读取时单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回, + /// 否则一次截断写入会把整份工具卡片从后续重写里抹掉。 #[test] - fn tool_call_read_skips_invalid_utf8_line() { + fn tool_call_pre_read_skips_invalid_utf8_line() { let root = init_tool_call_project("tool-call-invalid-utf8"); let path = tool_calls_path(root.path()); std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); @@ -1168,7 +1071,7 @@ mod tests { bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes()); bytes.push(b'\n'); std::fs::write(&path, &bytes).expect("write invalid utf8 fixture"); - let calls = read_direct_tool_calls_at(root.path()).expect("read with invalid utf8"); + let calls = read_tool_call_lines(&path); assert_eq!( calls.len(), 2, @@ -1188,7 +1091,7 @@ mod tests { bytes.extend_from_slice(tool_call_row("item-c", 3000, 3000).as_bytes()); bytes.push(b'\n'); std::fs::write(&path, &bytes).expect("write truncated utf8 fixture"); - let calls = read_direct_tool_calls_at(root.path()).expect("read with truncated line"); + let calls = read_tool_call_lines(&path); assert_eq!( calls.len(), 2, @@ -1196,10 +1099,16 @@ mod tests { ); assert_eq!(calls[0].id, "item-a"); assert_eq!(calls[1].id, "item-c"); + + let missing = tempfile::tempdir().expect("missing dir"); + assert!( + read_tool_call_lines(&tool_calls_path(missing.path())).is_empty(), + "历史文件缺失时写前读取必须返回空表" + ); } - /// 判据:200 条上限是「按时间保留最新 200 条」,超出时更早回合的卡片会被静默丢弃 - /// (契约内行为,不是缺陷)。本用例只钉住现状与时间正序。 + /// 判据:落到磁盘上的行同样受 200 条上限约束——「按时间保留最新 200 条」,更早回合的 + /// 卡片会被静默丢弃(契约内行为,不是缺陷)。本用例只钉住现状与时间正序。 #[test] fn tool_call_cap_drops_oldest_turn_cards() { let root = init_tool_call_project("tool-call-cap-oldest"); @@ -1236,7 +1145,7 @@ mod tests { .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"); + let read = persisted_tool_calls(root.path()); assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "上限仍是 200 条"); assert_eq!( read.last().expect("last").id, @@ -1251,7 +1160,7 @@ mod tests { assert!( read.windows(2) .all(|pair| pair[0].timestamp() <= pair[1].timestamp()), - "回读必须按时间正序" + "落盘顺序必须按时间正序" ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs index a2c8f1b30..dcdc5829b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs @@ -1,4 +1,4 @@ -//! GameAgent 对话「回合流」的采集、持久化与回读。 +//! GameAgent 对话「回合流」的采集与持久化。 //! //! 顺序真相放在一处:`/.agent/conversations/turn-stream.jsonl` 按**出现顺序** //! 记录一个回合里的文本段与工具调用。工具条目只记位置标记(`callId`),工具本身的正文 @@ -11,7 +11,7 @@ //! `project.jsonl` 保留原始消息;本流补充文本与工具交替的 item 顺序,不能重复展示两份正文。 use crate::agent::sanitize_detail_text; -use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file}; +use crate::config::write_game_creator_private_file; use crate::project::{enforce_project_permission_policy, project_append_lock_for}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -339,15 +339,6 @@ pub(crate) fn upsert_direct_turn_stream_item_at( }) } -/// 回读:文件缺失返回空数组;单行损坏跳过;按 `seq` 正序,最多最后 400 条。 -pub(crate) fn read_direct_turn_stream_at(root: &Path) -> Result, String> { - let path = turn_stream_path(root); - if !prepare_game_creator_private_path_for_read(&path, false, "回合流历史")? { - return Ok(Vec::new()); - } - Ok(normalize_stream_items(read_stream_lines(&path))) -} - /// 追加一段固定身份的文本段(失败说明等):位置排在当前流末尾。 /// /// 幂等:同一 `(turnId, itemId)` 已经存在时只更新文本与 `updatedAt`(回合重放 / 重复收尾 diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 88594678d..dd40ab154 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -1077,27 +1077,6 @@ export interface AgentProgressEvent { message: string; } -export type GameCreatorDirectTurnUpdateStatus = - | 'accepted' - | 'running' - | 'streaming' - | 'finalizing' - | 'completed' - | 'failed'; - -export type GameCreatorDirectTurnActivity = - | 'request-accepted' - | 'preparing' - | 'file-read' - | 'file-write' - | 'game-verify' - | 'command-exec' - | 'controlled-tool' - | 'web-search' - | 'context-compaction' - | 'response-finalization' - | 'none'; - export type GameCreatorDirectToolCallKind = | 'command' | 'file_change' @@ -1125,10 +1104,9 @@ export interface GameCreatorDirectToolCallDetail { /** * 一条工具调用(Codex item 的结构化投影)。 * - * 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`: - * 字段形状与 Rust 侧 `DirectToolCall`、独立历史文件 - * `.agent/conversations/tool-calls.jsonl` 的 payload 一致(这里少 `turnId` 的变体用于 - * 事件增量,见下面 `GameCreatorDirectTurnToolCall`)。 + * 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`:字段形状与 + * Rust 侧 `DirectToolCall`、独立历史文件 `.agent/conversations/tool-calls.jsonl` 的 payload + * 一致(DirectProject 聊天卡片用 `Omit` 这一变体)。 */ export interface GameCreatorDirectToolCall { schemaVersion: string; @@ -1143,69 +1121,6 @@ export interface GameCreatorDirectToolCall { updatedAt: number; } -/** 事件里下发的增量条目:与持久化同形,去掉 `turnId`(回合 id 在事件顶层)。 */ -export type GameCreatorDirectTurnToolCall = Omit< - GameCreatorDirectToolCall, - 'turnId' ->; - -export interface GameCreatorDirectTurnUpdateEvent { - projectPath: string; - turnId: string; - sequence: number; - status: GameCreatorDirectTurnUpdateStatus; - activity?: GameCreatorDirectTurnActivity | null; - accumulatedText?: string | null; - /** - * 本回合内**发生变化**的结构化工具调用(只有变化时才带,不是每个 heartbeat 都带全量)。 - * 可选:老版本事件没有这个字段,前端拿到 `undefined` 时必须与改造前行为一致。 - */ - toolCalls?: GameCreatorDirectTurnToolCall[] | null; - /** - * 本回合当前累计的思考过程(流式,整段替换);拿不到时字段缺席。 - */ - reasoningText?: string | null; - /** - * 「文本段 + 工具」的**顺序真相**里本次发生变化的那几条。 - * - * 顺序由 `seq`(条目首次出现时钉死)决定,与落盘 `turn-stream.jsonl` 完全同一份数据, - * 前端不再自己猜切点。可选:老版本事件没有这个字段。 - */ - streamItems?: TurnStreamItem[] | null; - updatedAt: number; -} - -/** 回合流里的一个 `text` 段;`text` 是该段当前累计全文(会随 delta 增长)。 */ -export interface TurnStreamTextItem extends TurnStreamItemBase { - kind: 'text'; - text: string; -} - -/** 回合流里的一个 `tool` 位置标记;工具正文在 `tool-calls.jsonl`(按 `callId` 关联)。 */ -export interface TurnStreamToolItem extends TurnStreamItemBase { - kind: 'tool'; - callId: string; -} - -interface TurnStreamItemBase { - schemaVersion: string; - /** 幂等身份:文本段 `text::`、工具 `tool::`。 */ - id: string; - turnId: string; - /** 首次出现的写入序号:**顺序真相**,按它升序渲染。 */ - seq: number; - /** 条目首次出现的时刻(Unix 毫秒),同 `seq` 时用它排序。 */ - at: number; - updatedAt: number; -} - -/** - * 回合流条目(`read_direct_turn_stream` 的返回元素)。 - * - * 与 Rust `DirectTurnStreamItem` 同形:`text` 段 ↔ `tool` 位置标记。 - */ -export type TurnStreamItem = TurnStreamTextItem | TurnStreamToolItem; - /** `cancel_direct_codex_turn` 的返回值。 */ export interface DirectTurnCancelView { /** diff --git a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md index ddb07e8b6..886772b12 100644 --- a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md +++ b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md @@ -4,7 +4,7 @@ 本方案的**卡片表现层**(折叠 / 展开、标题与摘要文案、耗时与时间显示、脱敏、无障碍、样式)仍然是有效契约;**数据来源层**已被 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 取代,边界改为: -- DirectProject 聊天框的工具卡片由**运行态事件 + 项目对话历史**在前端投影生成(`features/project-workspace/directThreadItemProjection.ts`),不再读取 `tool-calls.jsonl`,`read_direct_tool_calls` 不再是聊天视图的输入。 +- DirectProject 聊天框的工具卡片由**运行态事件 + 项目对话历史**在前端投影生成(`features/project-workspace/directThreadItemProjection.ts`),不再读取 `tool-calls.jsonl`;`read_direct_tool_calls` 命令与 Rust 侧 `read_direct_tool_calls_at` 回读函数已删除,该文件现在只有 DirectRuntime 的写入。 - 报文中不再有 `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` 交替的规则已作废,改为按事件顺序 + 历史文件顺序投影。 @@ -56,9 +56,9 @@ toolCalls?: DirectTurnToolCall[] | null; - 字段**可选**:老版本事件解析路径必须保持兼容(前端拿到 `undefined` 时行为与现在一致)。 - `DirectTurnToolCall` 与上面 payload 同形(去掉 `turnId`)。 -### 3. 回读命令 +### 3. 落盘契约(写侧) -新增 Tauri 命令 `read_direct_tool_calls(projectPath)`,返回按时间正序的 `DirectTurnToolCall[]`。 +DirectRuntime 写 `/.agent/conversations/tool-calls.jsonl`;回读命令与 Rust 侧回读函数已随聊天读路径退役删除,下面的语义约束的是**写进文件的行**。 - **上限语义**:200 条是「按时间保留最新 200 条」。超出时更早回合的卡片会被**静默丢弃**(老回合卡片会消失),不做分页、不做历史回填;同一 `id` 的多条记录先按 `updatedAt` 合并,再按时间正序裁剪。 - 历史文件缺失 → 返回空数组,不报错。