feat(gateway): 未读面 unreadCount 进 message_persisted 帧(契约 v0.76 ②)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- broadcastPersisted 是 Web 与 IM 两条路的**共用**函数 ⇒ 两处口径天然一致,不存在分叉
- 口径 = 绝对未读 + 主人单一已读游标(chat_sessions.read_at)⇒ 多端共享
- 可选键 omitempty:只在 >0 且确实算得出来时出现;0 与「算不出来」都不顶位
  (0 只能从「会话有游标」推出,否则那个 0 就是编的)⇒ 缺席 = 老行为
- Docs/api-client.md:v0.76 ① 按实测改标「未实现且无实现路径」(见下),② 已实现,
  并更正「GET /sessions 的 unreadCount 自 D4 起已在」、新增 §4.13 与 §7.4 第 23 条

⚠️ ① toolCalls[] 本版未落地(取证结论,非推断):chat_messages.turn_id(编排器随机路标识
turn-<16hex>)与 flow_graphs.turn_id(trace.FlowGraph.ID = <session>-<轮次序号>,进程内递增)
不同源,真实库中 66 条消息行 vs 21 条图行字面交集为空 ⇒ 无法 join;按时间窗硬凑属于猜,不做。
补齐路径:链路图带回合标识(新键 + 迁移 0023 起)+ 落盘列,之后 toolCalls[] 是一条批量 SQL。

我亲自复跑的变异:去掉帧里的 n > 0 守卫 ⇒ TestBroadcastPersistedUnreadCountAbsentWhenUnknown
与 TestBroadcastPersistedCarriesUnreadCount 双双 FAIL(断言「0 不该顶位」)⇒ 测试是真护栏。
This commit is contained in:
AskaEth committed 2026-10-06 07:58:42 +08:00
1 parent cb232e7360
commit fe4c702f87
6 files changed
+369 -8

No files matched your search

+48 -8
View File
@@ -18,7 +18,7 @@
> **v0.74 是 IM 路由主动消息的提醒版(已实现,纯增量)**:`message_persisted` 的 `data` 新增**可选**结构化判据 `proactive:true` —— IM 路由(`im_bridge`)的主动推送在落库之后**追加**一条带该键的同类帧,客户端据此在**非前台**补一发浏览器通知(此前通知只挂在 `proactive_message` 上,而 `auto` 提示在主人有已连接 IM 渠道时**优先走 IM** ⇒ 实际最可能发生的路径恰恰不弹)。判据是**结构化字段**(不是正文/提示词):普通消息(Web 回合 / IM 回复 / IM 镜像)的帧**永不**带该键 ⇒ 不可能被误判成主动消息。同一行仍照旧发一条**不带**该键的普通镜像帧(v0.72 起的行为逐字不变,两帧同 id ⇒ 客户端按 id 去重后仍是一个气泡、仍只提醒一次)。详见 **§3.4.4 后的「`message_persisted` 的 `proactive` 判据」**。
> **v0.76 是三面增量版(⚠️ 契约先行:三批形状**已登记**,实现**进行中**,登记时尚**未落地**)**:① **消息读取面新增 `toolCalls[]`** —— `GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`),每条含工具名 / 状态(`ok`/`error`/`unknown`)/ 耗时毫秒 / **已脱敏**的入参与结果摘要;数据源是**已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`,保留期 168h),**不从正文反解**。🔴 **缺席语义**:图取不到(超保留期 / 该轮未落盘 / 隔离渠道会话从不入库)⇒ 字段**整体缺席**,**绝不回空数组**冒充「这一轮没调过工具」。② **未读面** —— `message_persisted` 的 `data` 加可选 `unreadCount`(`omitempty`),`GET /api/v1/sessions` 列表项加同键;口径 = **绝对未读 + 主人单一已读游标(`chat_sessions.read_at`)⇒ 多端共享**(Web 读了移动端也清),这是与安卓侧已对齐的**取舍**,不是缺陷。③ **记忆整理试算加性异步** —— `POST /api/v1/admin/memories/tidy/dry-run?async=1` ⇒ `202` + `runId`;新增 `GET /api/v1/admin/memories/tidy/dry-run/{runId}` 轮询;**不带 `async` 仍是原同步行为**。**无破坏性变更**(新键 / 新可选参数 / 新端点;老客户端零影响)。详见 §1(会话列表与消息读取)、§2.2 B15 / §4.12.1、§3.4.4。
> **v0.76 是三面增量版(⚠️ **逐条状态不同**:②③ 见各条;**① 未实现且当前无实现路径**)**:① **消息读取面新增 `toolCalls[]`(🔴 本版**未实现** —— 客户端**不要**按它编程)** —— 原定:`GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`),每条含工具名 / 状态(`ok`/`error`/`unknown`)/ 耗时毫秒 / **已脱敏**的入参与结果摘要;数据源是**已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`,保留期 168h),**不从正文反解**。🔴 **缺席语义**:图取不到(超保留期 / 该轮未落盘 / 隔离渠道会话从不入库)⇒ 字段**整体缺席**,**绝不回空数组**冒充「这一轮没调过工具」。**未实现的原因(实测确认,非推断)**:`chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源** —— 前者是编排器生成的随机路标识(`turn-` + 16 位十六进制),后者是 `trace.FlowGraph.ID` = `<session>-<轮次序号>`(发布器**进程内**递增计数);两者**无法按消息字面 join**,映射只能靠猜时间窗 ⇒ 按项目口径**不做**。补齐需要先让链路图带上**回合标识**(新键 + 新迁移),**不在本版**。详见 **§7.2**。② **未读面(✅ 已实现)** —— `message_persisted` 的 `data` 加**可选** `unreadCount`(`omitempty`:只在 **>0 且确实算得出来**时出现;**缺席 = 老行为**),`GET /api/v1/sessions` 列表项同键(该键**自 D4 起就已在**,本版未改它)。口径 = **绝对未读 + 主人单一已读游标(`chat_sessions.read_at`)⇒ 多端共享**(Web 读了移动端也清),这是与安卓侧已对齐的**取舍**,不是缺陷。详见 **§3.4.4、§4.13**。③ **记忆整理试算加性异步** —— `POST /api/v1/admin/memories/tidy/dry-run?async=1` ⇒ `202` + `runId`;新增 `GET /api/v1/admin/memories/tidy/dry-run/{runId}` 轮询;**不带 `async` 仍是原同步行为**。**无破坏性变更**(新键 / 新可选参数 / 新端点;老客户端零影响)。详见 §1(会话列表与消息读取)、§2.2 B15 / §4.12.1、§3.4.4。
> **v0.75 是记忆整理试算版(已实现,纯增量)**:新增 1 条管理端点 **`POST /api/v1/admin/memories/tidy/dry-run`**(**只读语义的 POST**,`writeAuth` = owner/admin)—— 跑**同一套**记忆整理规则(召回门 + 包含判定 + 规则否决,与低峰定时轮共用代码路径),把候选**直接回在响应里**,**一行都不写**(不写 `memory_tidy_candidates` / `memory_tidy_runs`,绝不改 `memory_items`)。修的是「整理器只在 23:00–07:00 跑(design/23 §4),在它第一次开跑前面板只能空着」——主人要「现在就想看到在 736 条上它会建议合并哪些」。**零副作用是硬约束**(后端有逐行快照单测钉死);失败**一律如实作答**(`tidy_unavailable` / `tidy_disabled` / `tidy_recall_unavailable` / `tidy_timeout`),**绝不回空候选数组假装「没有建议」**。**无破坏性变更**(新端点;既有端点/帧一字未改)。详见 **§2.2 B14 / §4.12**。
@@ -375,7 +375,7 @@
| type | 帧壳 | data | 说明 |
|---|---|---|---|
| `message_persisted` | sessionId | `{message:<historyMessage>,proactive?:true}` | Web 主会话及 `web:*` 子会话的消息落库实时同步。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66,空则键省略);**可能**带可选结构化判据 `proactive:true`(v0.74,仅 IM 路由的主动推送 —— 见下方说明,普通消息永不带它) |
| `message_persisted` | sessionId | `{message:<historyMessage>,unreadCount?:<int>,proactive?:true}` | Web 主会话及 `web:*` 子会话的消息落库实时同步。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66,空则键省略);**可能**带可选 `unreadCount`(v0.76,该会话此刻的**绝对未读数**,**只在 >0 且算得出来时**出现 —— 见 §4.13);**可能**带可选结构化判据 `proactive:true`(v0.74,仅 IM 路由的主动推送 —— 见下方说明,普通消息永不带它) |
| `channel_persisted` | sessionId | `{message:<historyMessage>}` | 隔离渠道会话(`sess-*`)的消息落库,供 IM 日志页。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66) |
| `message_edited` | sessionId | `<historyMessage>`(**data 直接就是 DTO**) | 编辑单条消息。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66) |
| `message_deleted` | sessionId | `{id}` | 删除单条消息 |
@@ -856,6 +856,42 @@
---
### 4.13 未读实时面:`message_persisted.data.unreadCount`(v0.76 新增 · ✅ 已实现)
**帧形状(纯增量)**
```json
{"type":"message_persisted","sessionId":"web",
"data":{"message":{…,见 §4.4},"unreadCount":3}}
```
| 项 | 口径 |
|---|---|
| 键名 / 类型 | `data.unreadCount`,整数 |
| 含义 | 该会话此刻的**绝对未读数** = 比会话读游标(`chat_sessions.read_at`)**严格更新**的消息条数。**不是**「自上次以来新增几条」,也**不是**每端各一份 |
| 口径来源 | 与 `GET /api/v1/sessions` 的元素 `unreadCount` **同一个谓词**(`created_at > read_at`)、**同一条游标** ⇒ 两条面在同一时刻给出同一个数字(后端有单测逐会话比对两者相等) |
| 出现条件 | **只在 `>0` 且确实算得出来时出现**(Go 的 `omitempty` 语义)。**缺席 = 老行为**(v0.75 及更早即如此),客户端**忽略未知键**即可 |
| 缺席的三种情形 | ① 未注入历史存储;② 注入的存储没有单会话未读视图;③ 该会话**没有会话行**(隔离渠道会话 `sess-*`、后台思考会话 —— 它们连读游标都没有)。三种情形后端都**不**写 `unreadCount:0`:**「算不出来」绝不冒充「已读完」** |
| 何时该用 | 客户端落下这帧时**直接用该数字刷新角标**(不必再拉一次 `GET /sessions`)。键缺席时**保持原值**并回落 `GET /sessions` 收敛 —— 不要当成 0 |
| 何时会归零 | 由 `session_read` 帧(§3.4.1 状态帧表 / §1 U16)在任一客户端 `POST /sessions/{id}/read` 后广播;**多端共享单游标**:Web 读了,移动端的红点也清 |
**多端共享单游标的取舍(与安卓侧已对齐,不是缺陷)**
后端只有一个主人账号、这一层没有按设备的身份 ⇒ 游标是**会话作用域、单所有者**(`chat_sessions.read_at`,迁移 `0010`):**任一端已读即所有端已读**。因此 `unreadCount` 是「主人还没看过的消息数」,**不是**「这台设备还没同步的消息数」。
- 🔴 客户端**不得**把它当「本端未读」渲染成「这条消息我没收到」(那就把跨端同步误报成了漏消息)。
- 🔴 **不要**自行改成「每端独立未读」(会引入第二份游标、并在多端之间互相打脸);如确需改,属**契约裁定**事项,须先写进本节。
**兼容性(逐字无破坏)**
- 纯增量:**新可选键**,无新帧、无新端点、无既有键改名或语义变化。
- 老客户端(不读 `unreadCount`)行为**逐字不变**;新客户端读到缺键时的正确答案是「不知道」,不是「0」。
- `channel_persisted`(隔离渠道会话)走**同一个**载荷构造函数 ⇒ 口径天然一致;实践上它永远不带该键(那类会话没有 `chat_sessions` 行,见上表第 4 行)。
**服务端实现位置**(供对照,非契约):`internal/services/gateway/broadcast.go`(帧构造,Web 路径与 IM 路径**共用同一个函数**)、`internal/services/gateway/session_read.go`(`unreadCountOf` + 可选窄接口 `sessionUnreadCounter`)、`internal/runtime/history/session_read.go`(`UnreadCount`)。
---
## 5. env 与模型配置(与契约相关)
> **`.env` vs 面板文件**:面板「LLM 模型」页保存写 `<DATA_DIR>/models.json`(默认 `./data/models.json`);「渠道管理」页写 `<DATA_DIR>/channels.json`。**只要文件存在且有效,运行时优先用它**,`.env` 只作兜底/播种层。`.env.example` 与实现同步(键名逐字一致、值占位符)。
@@ -920,14 +956,14 @@
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
### v0.76(当前契约版本 · ⚠️ **契约先行:三批形状已登记,实现进行中**)
### v0.76(当前契约版本 · ⚠️ **逐条状态不同 —— ②已实现;①未实现(阻塞);③契约先行**)
**三面增量 —— ① 消息读取面 `toolCalls[]`(工具回看)|② 未读面 `unreadCount`(多端共享单游标)|③ 记忆整理试算的加性异步**(依据:安卓侧 `Cyrene-For-Android/devdocs/02` 明确「工具回看面比未读面更重要、且要带工具输出/摘要」,以及主人 2026-10-05 裁定的「抬高 + 治入库太宽」导致试算更慢)。
**三面增量 —— ① 消息读取面 `toolCalls[]`(工具回看 · **未实现**)|② 未读面 `unreadCount`(多端共享单游标 · **已实现**)|③ 记忆整理试算的加性异步(契约先行)**(依据:安卓侧 `Cyrene-For-Android/devdocs/02` 明确「工具回看面比未读面更重要、且要带工具输出/摘要」,以及主人 2026-10-05 裁定的「抬高 + 治入库太宽」导致试算更慢)。
- **① 工具回看面(纯增量)**:`GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`)—— 每条含 `name` / `status`(`ok`|`error`|`unknown`)/ `durationMs` / `argsSummary` / `resultSummary`(后两者是**图里已有的、已脱敏**内容并按上限截断)。数据源 = **已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`,保留期 168h),**不从正文反解、不从内存态猜**。🔴 **缺席语义**:图取不到 ⇒ 字段**整体缺席**(**绝不回空数组**冒充「没调过工具」)。修的是安卓侧「回看这条回复时看不到它当时调了什么」。
- **② 未读面(纯增量)**:`message_persisted` 的 `data` 加可选 `unreadCount`(`omitempty`,**缺席 = 老行为**),`GET /api/v1/sessions` 的列表项加同键。口径 = **绝对未读 + 主人单一已读游标(`chat_sessions.read_at`,迁移 `0010`)⇒ 多端共享**:Web 读了,移动端的红点也清 —— 这是与安卓侧**已对齐**的取舍(他们明确赞成),**不是缺陷**,不许改成「每端独立未读」而不经裁定。
- **① 工具回看面(🔴 未实现 —— 客户端不要按它编程)**:原定 `GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`)—— 每条含 `name` / `status`(`ok`|`error`|`unknown`)/ `durationMs` / `argsSummary` / `resultSummary`(后两者是**图里已有的、已脱敏**内容并按上限截断)。原定数据源 = **已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`,保留期 168h),**不从正文反解、不从内存态猜**。🔴 **缺席语义**:图取不到 ⇒ 字段**整体缺席**(**绝不回空数组**冒充「没调过工具」)。修的是安卓侧「回看这条回复时看不到它当时调了什么」。**⚠️ 实测阻塞(非推断)**:`chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源**(前者 = 编排器生成的随机路标识 `turn-<16 hex>`,后者 = `trace.FlowGraph.ID` = `<session>-<轮次序号>`,进程内递增计数)⇒ **无法按消息 join 到它那一轮的图**,而按时间窗硬凑映射属于「猜」,项目口径**不做**。补齐需先给链路图加**回合标识**(新键 + 新迁移 `0023` 起),**不在本版**。详见 **§7.2**。
- **② 未读面(纯增量 · ✅ 已实现)**:`message_persisted` 的 `data` 加可选 `unreadCount`(`omitempty`,**只在 >0 且确实算得出来时**出现;**缺席 = 老行为**),`GET /api/v1/sessions` 的列表项同键(**该键自 D4 起就已在**,本版未改它)。口径 = **绝对未读 + 主人单一已读游标(`chat_sessions.read_at`,迁移 `0010`)⇒ 多端共享**:Web 读了,移动端的红点也清 —— 这是与安卓侧**已对齐**的取舍(他们明确赞成),**不是缺陷**,不许改成「每端独立未读」而不经裁定。形状、缺席语义与三种缺席情形见 **§4.13**;帧表已同步(**§3.4.4**)。
- **③ 记忆整理试算加性异步**:`POST …/memories/tidy/dry-run?async=1` ⇒ `202` + `runId`(新增 B15 轮询端点)。**不带 `async` 仍是同步** ⇒ 老客户端(含现有面板)逐字不变。结果活在**进程内存**(TTL 30 min、并发 1、重启即失效 ⇒ 如实 `404`)。
- **行为变更**:无(①②只在既有响应里加可选键;③只在显式带参时改变路径)。
- **行为变更**:无(②只在既有帧里加可选键;③只在显式带参时改变路径;①未落地 ⇒ 无变更)。
- **破坏性**:无。
- **⚠️ 迁移号预告**:本版实现若需新迁移,编号从 **`0023`** 起(`0020` 预留给语音切片、`0021`/`0022` 已用)。
@@ -1157,6 +1193,8 @@
| **✅ v0.67 登记的「非消息事件查询面」**(`GET /admin/channels/events`、`GET /admin/channels/events/counters`) | **已实现**(v0.67 登记项本切片落地):形状与错误码见 §2.4(a)(已按代码核对:元素键名 camelCase、`approx` 与 `counters` 同级、`400 bad_request`、`owner\|admin` 档);权威设计 `design/26 §4.5`。未接线存储 ⇒ `500 channel_events_unavailable`。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表并回填 §2.1/§2.2 的端点计数 |
| **✅ v0.67 登记的「账号在线状态」**(`GET /admin/channels` 的 `accounts[].accountOnline`) | **已实现**(v0.67 登记项本次落地;落地前基线 `f91a83f`,本切片 commit 由主会话补记):形状见 §2.4(b)。管理面按**可选接口断言**读 `AccountStatus()`(`plugins/channels/qq_obv11/probe.go`,`a79fe96`);适配器**没有**该状态面、或实现了但 `known=false` ⇒ **键缺席**(未知 ≠ 离线,绝不用 `false` 冒充)。当前只有 `qq-obv11` 会带该键。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表 |
| **✅ P1-T:`directMsgPerMin` 的 gateway 写入路径**(`GET /admin/channels` 的 `accounts[].directMsgPerMin` + `PUT .../config` 的顶层 `directMsgPerMin`) | **已实现**(v0.69):缺口 = gateway 面没让该字段通过 ⇒ **面板保存一次渠道配置就把主人手配的「关闭流控(-1)」静默重置回 `0` = 保护性默认 6 条/分**。现 5 处透传(`AdminChannelAccount` DTO / `channelConfigRequest` / `config.ChannelAccount` 拷贝 / `adminChannelAccountOf` / 微信扫码重建账号)。形状见 §2.2 A5 与 §2.3 C28 注 |
| **🔴 v0.76 登记的「工具回看面」**(消息 DTO `toolCalls[]`) | **未实现(本版阻塞,客户端别按它编程)**:形状见 §6 v0.76 ①、§4.4。原定数据源是**已落盘的链路图**,但两者的轮次键**不同源** —— `chat_messages.turn_id` 是编排器生成的随机路标识(`turn-` + 16 位十六进制),`flow_graphs.turn_id` 是 `trace.FlowGraph.ID` = `<session>-<轮次序号>`(发布器**进程内**递增计数)⇒ **无法按消息字面 join 到它那一轮的图**;按时间窗硬凑映射属于「猜」,项目口径**不做**。补齐需要先让链路图带上**回合标识**(新键 + 迁移 `0023` 起),**不在 v0.76** |
| **✅ v0.76 登记的「未读面」**(`message_persisted.data.unreadCount`) | **已实现**(本切片):形状、缺席语义与三条缺席路径见 **§4.13**,帧表见 §3.4.4。⚠️ `GET /api/v1/sessions` 的同名键**自 D4 起就已在**(`historySessionSummary.UnreadCount`),本片**未改**它 —— 所以「加同键」这句对列表面其实是**既有事实**,不是本版新增 |
| **`graph_update` 按路分桶** | **未实现**:`graph_update` 只带 `data.sessionId`(会话级),**无 `turnId`** |
| **第二路进行态 system 摘要**(`WithParallelNotice`) | **未实现**:全仓无该符号;`session_parallel.go` 明示留待后续切片 |
| **env `CHAT_MAX_PARALLEL_TURNS`** | **未接线**:`Deps.MaxParallelTurns` 是构造期旋钮,但组合根未从任何 env 读它 ⇒ **生产恒为默认 2** |
@@ -1214,6 +1252,8 @@
21. **(v0.74 登记)`message_persisted` 的 `proactive` 判据只活在实时帧上,不落库**:它是「这条是她主动说的话」在线上**唯一**的结构化判据(帧顶布尔键,由 `gateway.BroadcastProactivePersisted` 打上),**没有对应数据库列**(本片不动 schema)。因此 ① `GET /sessions/{id}/messages`、握手 `history` 回放、`GET /admin/channels/**` 读到的历史行**不带**它(预期:通知只对实时到达有意义);② 客户端**不得**从 `platform` / `adapterId` / `turnId` 等行字段反推「是不是主动消息」—— 主会话里主动推送写的是**解析后的真实平台**(例如 `weixin-ilink`),与普通回复行同一形状,反推必然误伤。**落库失败时不发该帧**(没有权威 `id` 的 `message_persisted` 会被客户端整帧丢弃)。
22. **(v0.75 登记)试算端点(B14)是「只读计算」,它**不改**任何状态**:它不写 `memory_tidy_candidates` / `memory_tidy_runs`、不改 `memory_items`、不写审计链、**也不推进心跳**(不写 `lastRun`、不影响 `nextDelay`)。因此 ① 试算**不会**让「候选队列」(尚未实现,见 §7.2)多出任何东西;② 试算结果**不是缓存**:每次调用都重新算一遍,两次调用之间数据变了结果就可能不同(这是「同一套规则」的正常语义,不是不确定性),客户端**不要**把它当成会稳定的列表去 diff 或自动轮询;③ **`recallMode:"lexical"` 是降级信号**:它表示这轮没有走到语义向量(进程没接 embedder),此时召回更保守(命中更少)—— 若进程**接了** embedder 而它不可用,试算**直接报错**(`tidy_recall_unavailable`),**不会**在响应里假装成 lexical。
23. **(v0.76 登记 · 已按代码与库中数据实测确认)消息 DTO 的 `turnId` 与链路图的 `turnId` / `graph.id` 是**两套互不相干的标识**:前者是编排器发的那一路的**随机路标识**(`turn-` + 16 位十六进制,落 `chat_messages.turn_id`;同路的 user 行与 assistant 行同值),后者是发布器的**会话 + 轮次序号**(`<session>-<round>`,如 `web-6`,即 `trace.FlowGraph.ID` / `flow_graphs.turn_id`,**进程内**递增、重启即从头开始)。**两者不可互推、不可互相查询** —— 客户端**不得**拿消息的 `turnId` 去 `GET /admin/flow/graph?turnId=`(那是图侧的轮次键),也**不得**把图上的轮次序号当成会话里的回合序号(序号会在后端重启后重复)。这也是 v0.76 的 `toolCalls[]` 至今**未实现**的直接原因(见 §7.2)—— 若要按消息回看「那一轮调了什么工具」,必须先让链路图带上回合标识。
---
*本文件由「以代码为真源」维护(当前 `v0.75`;`v0.74` / `v0.73` / `v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.75` 同步了 `internal/services/gateway/**`(`admin_memory_tidy.go` / `routes.go` / `deps.go` / `server.go`)、`internal/runtime/memory/**`(`tidy_dryrun.go` / `tidy_recall.go` / `tidy.go`)、`cmd/cyrene/wire_frontend.go`、`frontend/web/src/**` 与本文件;`v0.74` 同步了 `internal/services/gateway/**`(`proactive.go` / `broadcast.go`)、`internal/services/channelbridge/**`(`proactive.go` / `persist.go` / `proactive_notify.go`)、`cmd/cyrene/wire_frontend.go`、`frontend/web/src/lib/ws/handlers/**` 与本文件;`v0.73` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`internal/runtime/flowgraph/**`、`internal/db/migrations/0022_flow_graphs.*`、`frontend/web/src/**` 与本文件;`v0.72` 同步了 `internal/services/gateway/**`(`proactive*`)、`internal/services/backthink/**`、`frontend/web/src/**` 与本文件;`v0.71` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`frontend/web/src/**` 与本文件。*
*本文件由「以代码为真源」维护(当前 `v0.76`;`v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.76` 的**未读面(②)**同步了 `internal/services/gateway/**`(`broadcast.go` / `session_read.go`)、`internal/runtime/history/session_read.go` 与本文件(§3.4.4 / §4.13 / §6 / §7.2 / §7.4),并**按代码 + 库中真实数据实测确证**了 `chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源** —— 据此把 ①`toolCalls[]` 明确标为**未实现**(原「契约先行」口径作废);v0.76 的 ③ 试算加性异步由另一片维护、①**未落地**;`v0.75` 同步了 `internal/services/gateway/**`(`admin_memory_tidy.go` / `routes.go` / `deps.go` / `server.go`)、`internal/runtime/memory/**`(`tidy_dryrun.go` / `tidy_recall.go` / `tidy.go`)、`cmd/cyrene/wire_frontend.go`、`frontend/web/src/**` 与本文件;`v0.74` 同步了 `internal/services/gateway/**`(`proactive.go` / `broadcast.go`)、`internal/services/channelbridge/**`(`proactive.go` / `persist.go` / `proactive_notify.go`)、`cmd/cyrene/wire_frontend.go`、`frontend/web/src/lib/ws/handlers/**` 与本文件;`v0.73` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`internal/runtime/flowgraph/**`、`internal/db/migrations/0022_flow_graphs.*`、`frontend/web/src/**` 与本文件;`v0.72` 同步了 `internal/services/gateway/**`(`proactive*`)、`internal/services/backthink/**`、`frontend/web/src/**` 与本文件;`v0.71` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`frontend/web/src/**` 与本文件。*