Переглянути джерело

feat: 发送即时状态 + 空会话语义 + 会话状态三层模型

## 发送即出卡(三处统一)
- 根因:回合卡出卡条件是 `t.merged || t.live`,两者都依赖容器回复,
  发送动作本身不产生任何待回复状态,导致用户干等约 10 秒才看到"正在思考"
- 改法:toBlockView 新增 awaitingFirstReply,为"已发出未回复"的回合置 live=true
- 三处出卡条件放宽为 `t.merged || t.live || t.userMessage`

## 空会话不再伪装成新建
- 根因:有会话但消息归属不到时 _state='empty',模板渲染"下午好,我是 xxx"引导页
- 改法:新增 ChatService.emptyReason,区分 fresh(真空)/ unloaded(取不到)
  判据用 rawTimeline 长度,不用 messageCount(实测虚高:云端 21 条 vs 实际 2 条)

## 状态三层模型
- 新增 agentLayers():alive(心跳)/ link(通道)/ busy(执行)三层独立判定
- alive 必须用心跳时间戳,不能用 status(实测 45 天没心跳仍报 online)
- 阈值统一:HEARTBEAT_STALE_SEC 80 分钟、ALIVE_DEAD_SEC 120 分钟
  与容器上报周期(1 小时 + 0~15 分钟抖动)匹配
- 修复两套阈值并存:resolveAgentState 用 HEARTBEAT_STALE_SEC、
  agentLayers 用 ALIVE_STALE_SEC,原来不同步导致同一容器各页结论相反

## 新建对话只显示在线状态
- 用户裁决:只需判断能否对话,运行状态属于会话页
- stateOf() 从 resolveAgentState 改为基于 agentLayers 的二值判定

## 会话级状态(方案 A 前端部分)
- Agent.currentSessions + mapAgent 解析 extra.currentSessions
- sessionStateOf / sessionRunning 容错匹配 App 会话 id 与容器 sessionKey
- agentIdle 优先用本会话状态,旧容器回落全局 status

## 其他
- 跨浏览器同步:窗口 focus 时重拉会话目录
- 推送失败报错:pushFailed 信号 + 界面提示 + 重试入口
- 修复 LoopGuard 误判与消息重复(另见 2026-11-09 前两轮留痕)

## 验证
- tsc --noEmit ✅
- npm test 1376 SUCCESS ✅
- npm run build ✅
- 数据清理:会话目录 38 → 31 条,零重复(软删 7 条脏数据)

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 16 годин тому
батько
коміт
d5559a1dbc
31 змінених файлів з 2628 додано та 204 видалено
  1. 141 0
      docs/00-tracking/2026-11-09-修改留痕-LoopGuard与会话去重.md
  2. 145 0
      docs/00-tracking/2026-11-09-修改留痕-五项缺陷修复第二轮.md
  3. 153 0
      docs/00-tracking/2026-11-09-修改留痕-发送即时状态与数据清理.md
  4. 96 0
      docs/00-tracking/2026-11-09-踩坑-乐观消息回读认领.md
  5. 22 0
      docs/00-tracking/操作追踪.md
  6. 300 0
      docs/02-design/plans/2026-11-09-容器状态模型与五项缺陷适配方案.md
  7. 243 0
      docs/04-quality/review/2026-11-09-LoopGuard与会话去重-评审.md
  8. 2 0
      docs/README.md
  9. 2 0
      src/app/components/domain/assistant-turn/assistant-turn.spec.ts
  10. 78 11
      src/app/components/domain/assistant-turn/assistant-turn.ts
  11. 15 7
      src/app/components/domain/new-session-modal/new-session-modal.ts
  12. 18 11
      src/app/components/domain/presence-badge/presence-badge.ts
  13. 58 4
      src/app/components/domain/session-tree/session-tree.ts
  14. 124 4
      src/app/core/agent-state.spec.ts
  15. 147 2
      src/app/core/agent-state.ts
  16. 41 1
      src/app/core/api/models.ts
  17. 58 0
      src/app/core/api/normalize.spec.ts
  18. 32 0
      src/app/core/api/parse-api.ts
  19. 36 1
      src/app/core/assistant-turn.spec.ts
  20. 86 2
      src/app/core/assistant-turn.ts
  21. 29 2
      src/app/core/chat-session.service.ts
  22. 127 12
      src/app/core/chat.service.spec.ts
  23. 170 76
      src/app/core/chat.service.ts
  24. 93 3
      src/app/core/loop-guard.service.spec.ts
  25. 123 30
      src/app/core/loop-guard.service.ts
  26. 89 0
      src/app/core/loop-guard.spec.ts
  27. 75 17
      src/app/core/loop-guard.ts
  28. 23 0
      src/app/core/matrix-client.service.ts
  29. 21 2
      src/app/desktop/pages/chat/chat.ts
  30. 9 11
      src/app/desktop/pages/groups/groups.ts
  31. 72 8
      src/app/mobile/pages/chat/chat.ts

+ 141 - 0
docs/00-tracking/2026-11-09-修改留痕-LoopGuard与会话去重.md

@@ -0,0 +1,141 @@
+# 修改留痕:LoopGuard 误判 / 乐观消息重复 / 等待反馈(2026-11-09)
+
+| 字段 | 值 |
+|---|---|
+| 日期 | 2026-11-09 |
+| 来源 | 2026-11-08 诊断遗留清单(`操作追踪.md:7132-7193`)P0-1 / P0-2 / P1-1 / P2-3 / P2-2 |
+| 用户裁决 | ① 群聊回环提示先移除、另立设计 ② 等待文案 8 秒后升级并**持续兜底到回复返回**,输出开始时切换文案 ③ 单测 + 用户陪同端到端复测 |
+| 关联踩坑 | `2026-11-08-踩坑-会话隔离与状态判定.md` |
+
+---
+
+## 一、缺陷 1:LoopGuard 误判,把正常回复挡在界面外
+
+**现象**:发「你好」看不到回复,界面提示「检测到自动对话循环,已暂停自动回复」。
+实际容器 13 秒后已在 Matrix 上正确回复。
+
+**根因**(代码级):
+1. `repeat` 规则只看最后 N 条,**不要求连续、不区分发送者、不区分会话** ——
+   用户在 A/B/C 三个会话各发一句「你好」,窗口里凑够 3 条即触发。
+2. `dismissedFor` 存的是 agentId 而非 sessionId,同容器的两个会话之间切换不清暂停态。
+3. 身份判定依赖 `provisionInfo.userMxId`,未就绪时为空串 → 所有消息都被当成 agent。
+
+**改动**:
+
+| 文件 | 改动 |
+|---|---|
+| `core/loop-guard.ts` | `LoopMessage` 加 `fromMe`;新增 `agentTail()` 取尾部**连续** agent 段,streak 与 repeat 共用;`repeat` 只在段内判;`rate` 只统计 agent;新增 `opts.quiet` 保护期短路 |
+| `core/loop-guard.service.ts` | 暂停态改成 `Map<sessionKey, LoopVerdict>`;新增 `watch(sessionId)`;`resume()` 只解当前会话;身份只看 `Message.sender`;删掉 `dismissedFor` |
+| `desktop/pages/chat/chat.ts` | constructor 加 `effect(() => loop.watch(chat.sessionId()))` |
+| `desktop/pages/groups/groups.ts` | **移除**回环横幅与注入(读的是单聊数据源,不是群消息,属接错线,另立设计) |
+| `mobile/pages/chat/chat.ts` | 接入(见缺陷 4) |
+
+**回归约束**:`loop-guard.spec.ts` 原有 21 个用例 **0 改动全绿**(`fromMe` 未提供时回落 `isHuman`)。
+
+---
+
+## 二、缺陷 2:新建会话内存残留多条「你好」
+
+**根因**:项目里**根本没有「乐观消息被 echo 替换」这一步**。
+乐观 id `local-${Date.now()}`,回读 id `$eventId`,合并时按 id 做并集 → 永远对不上,
+同一条消息以「本地一条 + 回读一条」并存;三条写入路径各落一次 = 实测的 3 条。
+
+**改动**:
+
+| 文件 | 改动 |
+|---|---|
+| `core/matrix-client.service.ts` | 新增 `LOCAL_ID_KEY = 'com.fmode.local'` 与 `extractLocalId()`;`MxMessage.localId`;`timelineToMessages` 与 `bindGlobalMessageStream` **两处**解析 |
+| `core/api/models.ts` | `Message.localId?` |
+| `core/chat.service.ts` | 乐观 id 带随机串;`sendReal(text, localId)` 把它写进 content;新增 `mergeTimeline()` 统一合并(认领 / 保留失败消息 / 稳定排序);`paginate()` 改用合并;`appendIncoming` 认领后替换;`ensureSessionForSend` recreate 时清空 `_messages` |
+| `core/chat.service.ts` | 删除 `belongsToSession()` 死代码:`if (!m.threadId)` 第二段(永远进不去)与 `:1804` 恒假分支 |
+
+---
+
+## 三、缺陷 3:13 秒等待期没有反馈
+
+**根因**:`MessageState` 只有 `sending/sent/failed`,`sent` 在 UI 中完全不渲染;
+唯一等待态是回合卡「正在思考…」,无时长、无阶段。
+
+**改动**:
+- `core/assistant-turn.ts`:`TurnBlockView` 加 `waitingSince` / `waitMs`;新增纯函数 `waitText()` 与常量 `WAIT_HINT_MS = 8_000`。
+- `components/domain/assistant-turn/assistant-turn.ts`:卡级与段级占位共用 `waitLabel()`,秒数由每秒钟 `now` 信号 + 锚点现算;live 时起表、结束即清。
+- `mobile/pages/chat/chat.ts`:「正在输入」补 `aiRows().length === 0`,与桌面同口径,避免与回合卡重复。
+
+**文案阶梯**:
+
+| 阶段 | 文案 |
+|---|---|
+| 已发出,未首字,< 8s | 正在思考… |
+| 已发出,未首字,≥ 8s | 已送达,等待回复…(Ns)——**一直显示到首字到达** |
+| 开始输出 | 正在输出…(Ns) |
+| 结束且全空 | 未响应 |
+
+---
+
+## 四、缺陷 4 / P2-2:移动端提示 + 容器核查
+
+- `mobile/pages/chat/chat.ts`:注入 `LoopGuardService`、跟随会话 watch、新增 `.m-loop-bar`(取 `--warning` / `--warning-subtle` / `--r-md` 令牌,动作按钮触达区 ≥44px)。
+- **容器只读核查**(`node scripts/matrix-check.mjs`,未做任何写操作):
+
+| 容器 | 记录 | App 可见性 | 容器自报 |
+|---|---|---|---|
+| `agent-node-lj-test` | ✅ | ✅ | ✅ connected / listenerOk |
+| `agent-node-app-lj-test-v1` | ✅ | ✅ | ✅ connected / listenerOk |
+| `agent-lj` | ✅ | ✅ | ✅ connected / listenerOk |
+
+三个容器均**无失败项**,只剩「收发闭环」需人工实测(脚本不代发消息)。
+容器配置**未做任何修改**(见下风险)。
+
+---
+
+## 五、未改动的容器项与风险
+
+| 项 | 现状 | 处置 |
+|---|---|---|
+| `MATRIX_REQUIRE_MENTION` | 容器内 `.env` 已 `false`,进程仍 `true` | **不动**。已由 `adapter.py` 证明与私聊无关(DM 走 `is_dm` 分支),不是本次故障根因 |
+| reporter `status=running` 硬编码 | 容器脚本 `build_values()` | 前端已有 `isReporterSelfTalk` 兜底;根治需容器侧改脚本,属另一条线 |
+| `messageCount` 因 `noteMessage` 双写虚高 | 被当作「是否新建态」判据 | 本批**只标注**,不改判据(属数据模型变更,需单独评审) |
+
+> ⚠️ **风险(未处置)**:宿主 `.env` 若仍为 `MATRIX_REQUIRE_MENTION=true`,
+> 下次重建容器会覆盖回 `true`。与本次问题无关,记录备查。
+
+---
+
+## 六、验证
+
+| 项 | 结果 |
+|---|---|
+| `tsc --noEmit`(app + spec) | ✅ |
+| `loop-guard.spec.ts` | 27 SUCCESS(21 原有 + 6 新增) |
+| `loop-guard.service.spec.ts` | 11 SUCCESS(6 原有 + 5 新增) |
+| `chat.service.spec.ts` | 58 SUCCESS(含新增去重 / 切会话回归) |
+| `assistant-turn`(core + 组件) | 36 SUCCESS |
+| 全量 `npm test` | ✅ **1360 SUCCESS** |
+| `npm run build` | ✅ 通过(571.43 kB / 148.59 kB,预算警告为存量) |
+| 容器只读核查 | 3/3 无失败项 |
+
+**未验证项(需实测)**:
+1. `com.fmode.local` 是否完整出现在服务端 echo 的 content 中 —— 若被剥离,退回内容+时间窗口启发式。
+2. 真实账号端到端(用户陪同)。
+
+---
+
+## 七、评审闭环(规则 11)
+
+评审报告:`docs/04-quality/review/2026-11-09-LoopGuard与会话去重-评审.md`
+独立评审员子代理,只读代码,结论「需修改后发布」,提 3 个阻塞项。
+
+| 编号 | 评审意见 | 核实 | 处置 |
+|---|---|---|---|
+| B1 | 等待计时每秒重建气泡 | **成立**:`segments` 依赖 `now`,每秒重建整轮对象 | 拆出独立 `waitLabels` computed,`segments` 不再依赖时钟 |
+| B2 | `setMode('off')` 写 `_dismissed`,切回 standard 后真回环不再提示 | **成立**:真 bug | 切档不动 `_dismissed`;去掉 `_scope` 双来源,只认 `chat.sessionId()` |
+| B3 | `mergeTimeline` 不分会话,旧会话乐观消息会被重新并回 | **成立** | 只保留当前 agent、且仍在发送中 / 已失败的本地消息;补回归用例 |
+| I1 | `paginate()` 缺 `_blankSessionId` 保护 | **不成立**:`:1512` 已有一致保护 | 不改 |
+| — | NG0600 策略违规风险 | 代码里 `evaluate()` 未读 `paused`,不会触发;但耦合脆弱 | 按建议收敛为单一来源 |
+
+**评审之外自查发现并修复的自身缺陷**:
+- `_paused.update` 里写成 `if (v.loop === cur.has(key))` —— 应判断**本会话**的暂停态,否则新会话的暂停写不进去。
+- 群聊页模板注释里写了带反引号的代码,被模板解析器当成表达式 → **生产构建失败**(单测全绿也挡不住)。
+
+**踩坑(测试)**:`paused` 是 computed,依赖的是 `chat.sessionId()`。测试里若用 jasmine spy 返回可变值,
+**computed 不会失效**,断言读到旧缓存。必须用真 `signal` 喂 stub。

+ 145 - 0
docs/00-tracking/2026-11-09-修改留痕-五项缺陷修复第二轮.md

@@ -0,0 +1,145 @@
+# 修改留痕:五项缺陷修复(第二轮,2026-11-09)
+
+| 字段 | 值 |
+|---|---|
+| 日期 | 2026-11-09 |
+| 来源 | 真实账号端到端复测(用户 9836),发现 5 项新缺陷 |
+| 用户裁决 | ① 内容**只追加,永不覆盖** ② 状态**拆成「连接」和「执行」两个维度** ③ 容器侧现在就改 ④ 五项依次全修 |
+| 关联方案 | `docs/02-design/plans/2026-11-09-容器状态模型与五项缺陷适配方案.md` |
+
+---
+
+## 一、实测取证(不是推测)
+
+### 1.1 一轮回复的真实事件序列(lj-test,用户要 Excel)
+
+直接从 Matrix 房间拉出:
+
+```
+ts=...074307  💻 terminal ```python3 -c "import openpyxl"```     ← 过程
+ts=...149968  openpyxl 装好了,现在生成文件。                       ← 正文①
+ts=...157242  💻 terminal ```cd /opt/data/work/xlsx...```         ← 过程
+ts=...166208  好了,Excel 文件已经生成。我另外用 LibreOffice...     ← 正文②
+ts=...174605  容器里没装 LibreOffice,所以缓存值暂时是空的...        ← 正文③
+ts=...174655  ```cd /opt/data/work/xlsx...```                     ← 过程
+ts=...189407  文件做好了,一张表,5 列 6 行:...                    ← 正文④
+ts=...189492  简单示例.xlsx                                        ← 附件
+ts=...236915  💾 修为提升:🔧 技能「multi-artifact-delivery」修正完毕。← 正文⑤
+```
+
+**9 条事件 = 5 段正文 + 4 段过程**,正文被过程反复打断。
+
+### 1.2 直接问容器得到的底层事实
+
+| 问题 | 容器回答 |
+|---|---|
+| 一轮是几条事件 | **多条**。`splits_long_messages = True`,超长自动分块 |
+| 有轮次标识吗 | **没有**。块之间只有相同的 `m.relates_to` |
+| 修正是编辑还是新消息 | **新开一条消息**,不是 `m.replace` |
+| 一轮结束有信号吗 | **Matrix 里没有**。只有 `post_llm_call` 钩子异步切 `status=idle`,**延迟 1~3 秒** |
+| 并发 | asyncio 并行,但**几乎总是 1 个活跃 turn** |
+| 会话标识 | 自建 `session_key` = `<ns>:<platform>:<chat_type>:<chat_id>:<thread_id>` |
+
+### 1.3 关键验证:渲染函数本身没问题
+
+把上面 9 条真实事件原样喂给 `turnsFromDm()`,输出文本是**完整拼接**的:
+
+```
+openpyxl 装好了…
+好了,Excel 文件已经生成…
+容器里没装 LibreOffice…
+文件做好了,一张表…
+修为提升:技能修正完毕。
+```
+
+→ **「覆盖」不在数据层,也不在合并层,而在组件的仿流式渲染。**
+
+---
+
+## 二、五项修复
+
+### ① 正文「覆盖」 — `components/domain/assistant-turn/assistant-turn.ts`
+
+**根因**:`advance()` 里用 `anyGrow` 决定是否启动 rAF,而 `anyGrow` 判断的是
+「目标**长度是否变化**」,不是「是否已追上目标」。
+
+```
+动画一旦因任何原因中断(组件重建 / rAF 取消 / 目标短暂未变)
+  → 下次数据没变化 → 不重启 rAF
+  → shown 永久冻在旧值
+  → 渲染时 streaming = live && n < length 永远为真
+  → slice(0, n) 只显示前半截
+```
+
+**改法**:
+- `anyGrow` → `needAdvance`:判「还没追上」而不是「变了」
+- 差距 > 400 字直接追赶,不做长距离逐字演
+
+### ② 读秒跳变(95s → 100s)— `core/chat.service.ts`
+
+**根因**:本地乐观消息的 `ts` 是**浏览器时钟**,服务端 echo 的 `ts` 是**服务端时钟**。
+`mergeTimeline` 用 echo 顶掉本地那条时,`ts` 跟着换 → 锚点跳变。
+
+**改法**:echo 认领本地消息时**沿用本地的 `ts`**。这是我方发出的消息,用我方记下的发送时刻更准。
+
+### ③ 回合永远「输出中」— `core/assistant-turn.ts`
+
+**根因**:`live = !closed && (typing || now - updatedAt < quietMs)`。
+容器一轮结束后**不发 `m.typing:false`**,`peerTyping` 停在 true → `live` 恒真 →
+永远「输出中」、永远不自动折叠(用户实测「运行了 2 步 · 163s 输出中」)。
+
+**改法**:`typing` 也受静默窗口约束 —— `typingAlive = typing && idleFor < quietMs * 2`。
+
+> ⚠️ 这是**前端兜底**。根治需要容器 reporter 不再硬编码 `status=running`。
+
+### ④ 历史串入新建会话 — `core/chat.service.ts:320`
+
+**根因**:保护解除判据是「timeline 里有任意 `threadId === sid` 的事件」。
+但**容器的回复也挂在同一个线程根下** —— 容器刚回第一条就撤掉保护,
+紧接着的回填把整个房间的旧历史灌进来(用户实测:新建会话直接出现「加载更早」和旧内容)。
+
+**改法**:必须是「本会话的**用户消息**」才算落地 —— `m.threadId === sid && m.sender === myUserId`。
+
+### ⑤ 状态三层模型 — `core/agent-state.ts`
+
+**根因**:全站 4 套判定函数并存(原始态 / 接入态 / 合成态 / 任务行),
+PC 通讯录用接入态("未接入")、移动端用合成态("离线")→ 同一容器说法不同。
+
+**改法**:新增 `agentLayers()` 输出三层:
+
+| 层 | 判据 |
+|---|---|
+| `alive` | **心跳新鲜度**:≤2 分钟 alive / ≤10 分钟 stale / 否则 dead |
+| `link` | `matrixReady` |
+| `busy` | 容器自报 running(**且不是上报器自指**) |
+
+> 🔴 `alive` 必须用心跳,不能用 `status` —— 实测有容器 **45 天没心跳仍报 `online`**。
+
+---
+
+## 三、验证
+
+| 项 | 结果 |
+|---|---|
+| `tsc --noEmit`(app + spec) | ✅ |
+| 全量 `npm test` | ✅ **1360 SUCCESS**(无回归) |
+| `npm run build` | ✅ 通过 |
+
+**尚未完成**:
+1. `agentLayers()` 只加了函数,**页面还没接入**
+2. 容器侧修复(等容器 R1-R5 调查结论)
+3. 五项端到端复测
+
+---
+
+## 四、容器侧待办
+
+| 项 | 待确认 |
+|---|---|
+| 上报程序结构 | 容器称有**两套**:旧脚本 `/opt/data/git-repos/agent-status-reporter/scripts/status-report.py` + 另一套 |
+| 由谁维持运行 | cron / s6 / while true —— 决定改完如何生效 |
+| `status` 赋值位置 | 决定改哪几行 |
+| 能否按 `session_key` 上报 | 决定方案 A 是改字段还是改协议 |
+| 改动是否需要重建容器 | 决定是否需要用户手动 restart |
+
+**用户已确认**:容器改完后要给出**带容器 ID 的 `docker restart` 命令**。

+ 153 - 0
docs/00-tracking/2026-11-09-修改留痕-发送即时状态与数据清理.md

@@ -0,0 +1,153 @@
+# 修改留痕:发送即时状态 / 空会话语义 / 脏数据清理(2026-11-09 第三轮)
+
+| 字段 | 值 |
+|---|---|
+| 日期 | 2026-11-09 |
+| 来源 | 真实账号实测(用户 9836)四项反馈 |
+| 用户裁决 | ① 发送**即出卡** ② 删数据库垃圾数据 ③ 新建对话只显示在线 ④ 提交并推送 |
+
+---
+
+## 一、发送后 10 秒才显示状态
+
+### 根因(实测确认,非推测)
+
+**代码里没有任何 10 秒定时器。** 全仓搜 `setTimeout` / `10_000` / `debounce`,
+唯一的 `10_000` 是 `chat-session.service.ts:835` 的会话目录推送节流,与消息显示无关。
+
+真正原因:回合卡的出卡条件是
+
+```ts
+if (t.merged || t.live)   // desktop/chat.ts:2101、mobile/chat.ts:864
+```
+
+- `t.merged` 要有 **agent 正文**才非空
+- `t.live` 依赖 `typing` 或「末条消息距今 < QUIET_MS」
+
+**发送动作本身不产生任何待回复状态**,所以只能等容器约 10 秒后首次回话。
+读秒机制(`waitText` + `waitingSince` + 每秒 tick)早就写好了,**只是从未被触发**。
+
+### 改法
+
+1. `core/assistant-turn.ts` 的 `toBlockView()` 新增 `awaitingFirstReply` 参数,
+   为 true 时 `live = true`
+2. `turnsFromDm()`:有 `userMessage` 且 `blocks` 为空 → 传 `awaitingFirstReply = true`
+3. `turnsFromGroup()`:每块的 `messages` 为空且未结束 → 传 `awaitingFirstReply = true`
+4. 三处出卡条件放宽为 `t.merged || t.live || t.userMessage`
+
+### 效果
+
+发送后**立刻**出现回合卡 → 8 秒内「正在思考…」→ 之后「已送达,等待回复…(Ns)」→
+容器开始输出时切「正在输出…(Ns)」。
+
+---
+
+## 二、空会话被显示成「新建引导页」
+
+### 根因
+
+`openReal()` 过滤后 `_messages` 为空 → `_state = 'empty'`(`chat.service.ts:897`);
+模板 `@case('empty')` 且 `currentSession` 存在 → 渲染「下午好,我是 lj-test」引导页。
+
+**有会话但没有消息**被当成了**新建会话**。
+
+### 改法
+
+新增 `ChatService.emptyReason` 信号,区分两种「空」:
+
+| 值 | 含义 | 界面 |
+|---|---|---|
+| `'fresh'` | 房间内一条消息都没有 | 「下午好,我是 xxx」引导页 |
+| `'unloaded'` | 房间里有消息,但一条都归属不到这个会话 | 「这个会话没有可加载的消息」 |
+| `null` | 不空 | 正常 |
+
+判据用 `rawTimeline.length > 0`,**不用 `messageCount`**
+(实测虚高:云端记 21 条、Matrix 实际只有 2 条)。
+
+桌面:`desktop/pages/chat/chat.ts` 的 `@case('empty')` 增加 `'unloaded'` 分支。
+移动:`mobile/pages/chat/chat.ts` 同样处理。
+
+---
+
+## 三、清理会话目录脏数据
+
+### 审计结果
+
+| 类别 | 数量 |
+|---|---|
+| 云端会话总记录 | 38 条 |
+| 唯一 threadRoot | 33 个 |
+| **重复记录**(同一 threadRoot 存了两遍) | **5 条** |
+| **无消息记录**(Matrix 里查不到任何消息) | **2 条** |
+
+### 处置
+
+走 **软删**(`isDeleted = true`),不硬删 —— 规则 6「删除用软删」。
+
+**已删除的 7 条:**
+
+| objectId | threadRoot | 原因 |
+|---|---|---|
+| `YKBOmntAMi` | `$approot_mw8txj5e_nr3jteai` | Matrix 无消息 |
+| `Fil5dc4mO0` | `$approot_mw32ytav_xsabpi4k` | Matrix 无消息(云端记 16 条) |
+| `pe8tAZK0Jw` | `$approot_mw7l1ezj_wnuym1jt` | 重复(保留 `CIjzVe901P`) |
+| `TGIvAKG7fu` | `$approot_mw7eg5cy_ckef8n1q` | 重复(保留 `HLZ92X0FXG`) |
+| `eo7ZwgqGJW` | `$approot_mw7d21nj_7ej1w0tk` | 重复(保留 `fPlCoonr1u`) |
+| `IESRzuIIaA` | `$approot_mw7d0x60_5mmnsfag` | 重复(保留 `DK7nisnnHs`) |
+| `LxCcR8H1rt` | `$approot_mw76tzbh_62iqm0xp` | 重复(保留 `LDBxPa4ZL5`) |
+
+**验证**:删除后 31 条 / 31 个唯一 threadRoot / 零重复 / 已删记录不在列表中。
+
+---
+
+## 四、新建对话只显示在线状态
+
+**用户原话**:「新建对话这里只需要显示容器是否在线就行,不需要显示容器的状态,
+容器的状态是在会话页面显示的」。
+
+`new-session-modal.ts` 的 `stateOf()` 从 `resolveAgentState()`(输出「正在执行/空闲/离线」)
+改为基于 `agentLayers()` 的**二值判定**:
+
+```
+在线 ← alive 且 link === 'ready'
+离线 ← 其余
+```
+
+---
+
+## 五、心跳阈值(本轮顺带修)
+
+| 项 | 改前 | 改后 |
+|---|---|---|
+| `HEARTBEAT_STALE_SEC` | 10 分钟 | **80 分钟** |
+| `ALIVE_DEAD_SEC` | 90 分钟 | **120 分钟** |
+
+原因:容器上报周期已改为 **1 小时 + 0~15 分钟随机抖动**,
+卡着 1 小时判会频繁闪红(用户实测:心跳 13 分钟被旧代码判成离线)。
+
+🔴 同时修掉一个**两套阈值并存**的问题:`resolveAgentState` 用 `HEARTBEAT_STALE_SEC`、
+`agentLayers` 用 `ALIVE_STALE_SEC`,两者原来不同步。现已统一为同一常量。
+
+---
+
+## 六、验证
+
+| 项 | 结果 |
+|---|---|
+| `tsc --noEmit`(app / spec) | ✅ |
+| 全量 `npm test` | ✅ **1376 SUCCESS** |
+| `npm run build` | ✅ 成功 |
+| 数据清理 | ✅ 38 → 31 条,零重复 |
+
+**未验证项(需实测)**:
+1. 发送后是否真的立刻出卡(需真实账号在浏览器验证)
+2. 容器首次回复的真实延迟(确认「10 秒」在容器侧)
+
+---
+
+## 七、遗留项
+
+| 项 | 说明 |
+|---|---|
+| `messageCount` 虚高 | `noteMessage` 双写导致云端计数不准(实测 21 vs 实际 2)。**本轮未修**,改判据属数据模型变更,需单独评审 |
+| 群聊 `markSent` 延后 | `group-send.service.ts` 在所有目标串行发完后才标记,状态仍偏晚。本轮靠「空块即等待」部分缓解,未彻底解决 |

+ 96 - 0
docs/00-tracking/2026-11-09-踩坑-乐观消息回读认领.md

@@ -0,0 +1,96 @@
+# 踩坑记录:乐观消息没有「回读认领」,会自己长出副本(2026-11-09)
+
+| 字段 | 值 |
+|---|---|
+| 日期 | 2026-11-09 |
+| 来源 | 用户实测:新建会话发一句「你好」,`messages()` 返回 3 条 |
+| 修复留痕 | `2026-11-09-修改留痕-LoopGuard与会话去重.md` |
+
+---
+
+## 一、现象
+
+用户只发了一条「你好」,聊天区/内存里出现 3 条。
+
+---
+
+## 二、根因
+
+**项目里从来没有「乐观消息被服务端 echo 替换」这一步。**
+
+一条消息在内存里可以有三种身份:
+
+| 来源 | id 形态 | 写入点 |
+|---|---|---|
+| 乐观插入(点击发送立刻显示) | `local-<时间戳>` | `sendText()` |
+| 实时 echo(`/sync` 推回来的那份) | `$eventId` | `appendIncoming()` |
+| 历史回填 | `$eventId` | `storeVersion` effect |
+
+三条路径各自的去重都是**按 id 相等**判断:
+
+```ts
+// 回填合并(旧实现)
+const byId = new Map(next.map((m) => [m.id, m]));
+for (const m of local) {
+  if (!byId.has(m.id)) byId.set(m.id, m);   // local-* 永远不等于 $eventId
+}
+```
+
+`local-xxx` 和 `$evt_yyy` **永远不相等**,于是乐观消息和它的 echo 双双保留。
+三条路径各落一次,就正好是实测的 3 条。
+
+还有两个放大器:
+- 乐观 id 用 `local-${Date.now()}` —— **同一毫秒连点两次会撞键**,两条消息互相顶替。
+- `paginate()` 用 `_messages.set(next)` **整表覆盖**,翻页时把还在发送中的消息直接抹掉。
+
+---
+
+## 三、修法
+
+**把本地 id 随事件写进 Matrix content,回读时按它认领。**
+
+```ts
+export const LOCAL_ID_KEY = 'com.fmode.local';
+```
+
+- 发送:`sendText(..., { 'com.fmode.local': localId })`
+- 回读:`timelineToMessages()` 与实时流**两处**都解析(两处必须同步,否则刷新前后不一致)
+- 合并:`mergeTimeline()` —— echo 带 localId 且能对上本地那条,就**认领并移除**本地副本;
+  没被认领的本地消息保留(发送中 / 失败待重试)
+
+选这条路而不是「靠 `sendText` 返回的 `event_id` 对账」,是因为:
+`appendIncoming` / `mapTimeline` 在回读时**拿不到那次调用的返回值**,
+本地 id 和 `$event_id` 之间没有任何映射来源。
+
+---
+
+## 四、可复用经验
+
+### 4.1 乐观 UI 必须有「回读认领」这一步
+
+只要「本地先显示 + 服务端回读」两条链路并存,就必然需要一处代码回答:
+**「这两条是不是同一条?」** 没有这一步,去重就只能靠 id 恰好相等 —— 而它们一般不相等。
+
+### 4.2 去重键要么两端同源,要么显式映射
+
+按 id 去重只在「id 由同一个地方生成」时成立。跨系统(本地 / 服务端)时,
+必须在事件里带一个双方都认的键。
+
+### 4.3 合并策略要统一
+
+同一份 `_messages` 有三处写入,却用了三种策略(并集 / 整表覆盖 / 追加)。
+**策略不一致本身就是 bug 温床** —— 收敛成一个 `mergeTimeline()`,三处共用。
+
+### 4.4 测试缺口比缺陷更隐蔽
+
+原来的用例 `发送请求未返回前,实时消息不得混入新会话` 在 `releaseSend()` 之后
+**只 await 不检查** —— 而「发送成功之后」才是问题真正爆发的地方。
+**测试的断言点要对准用户报障的那一刻,不是最容易构造的那一刻。**
+
+---
+
+## 五、未验证
+
+- `com.fmode.local` 是否完整出现在服务端 echo 中(Matrix 允许任意额外字段,
+  且 `com.fmode.session` 已证明可用,但**必须实测**确认)。若被剥离,
+  退回「内容 + 时间窗口」启发式。

+ 22 - 0
docs/00-tracking/操作追踪.md

@@ -7191,3 +7191,25 @@ tr '\0' '\n' < /proc/$(pgrep -f 'hermes gateway run')/environ | grep REQUIRE_MEN
 | 容器内能否自助重启 | ❌ 无 docker.sock、无 s6 控制权 | 需宿主执行 `docker restart e14260fdce46` |
 
 **明天决定**:保持 `false` 不重启 / 改回 `true` / 重启让其生效 —— 三选一,建议保持不动。
+
+---
+
+## 2026-11-09 · LoopGuard 误判 / 乐观消息重复 / 等待反馈 / 容器只读核查
+
+| 项 | 内容 |
+|---|---|
+| **触发** | 2026-11-08 诊断遗留清单(本文 7132-7193 行)P0-1 / P0-2 / P1-1 / P2-3 / P2-2 |
+| **用户裁决** | ① 群聊回环提示先移除、另立设计 ② 等待文案 8 秒后升级并**持续兜底到回复返回**,输出开始时切换 ③ 单测 + 用户陪同端到端复测 |
+| **P0-1 根因** | `repeat` 规则不要求连续、不区分发送者、不区分会话(三会话各发「你好」即触发);`dismissedFor` 存 agentId 而非 sessionId;身份判定依赖 `provisionInfo.userMxId`,未就绪时为空串 |
+| **P0-1 修复** | `core/loop-guard.ts` 加 `fromMe` + `agentTail()` 连续段 + `opts.quiet`;`core/loop-guard.service.ts` 暂停态改 `Map<sessionKey, Verdict>` + `watch(sessionId)`;桌面 chat 接线;桌面 groups **移除**横幅;移动端接入 |
+| **P0-2 根因** | 项目里**根本没有「乐观消息被 echo 替换」这一步**:乐观 id `local-*`,回读 id `$eventId`,按 id 并集永远对不上 |
+| **P0-2 修复** | 新增 `LOCAL_ID_KEY='com.fmode.local'` 随事件写进 content,回读两处解析;`mergeTimeline()` 统一三处合并(认领 / 保留失败消息 / 稳定排序);乐观 id 加随机串防撞键;`ensureSessionForSend` recreate 时清空 |
+| **P1-1 修复** | `waitText()` 纯函数 + 组件 `waitLabel()`:<8s「正在思考…」→ ≥8s「已送达,等待回复…(Ns)」**持续兜底** → 输出时「正在输出…(Ns)」→ 结束「未响应」 |
+| **P2-3 修复** | 移动端接入 LoopGuard + `.m-loop-bar`(warning 令牌、按钮 ≥44px);「正在输入」补 `aiRows().length===0` |
+| **P2-2 核查** | `matrix-check.mjs` 只读核查 `lj-test` / `app-lj-test-v1` / `lj` 三者:**全部无失败项**,仅「收发闭环」需人工实测 |
+| **容器配置** | **未做任何修改**。`REQUIRE_MENTION` 已证明与私聊无关,保持不动;reporter 硬编码 `running` 需容器侧改脚本,属另一条线 |
+| **死代码清理** | `belongsToSession()` 内 `if (!m.threadId)` 第二段(永远进不去)+ `:1804` 恒假分支 |
+| **评审(规则 11)** | 结论「需修改后发布」,3 个阻塞项已全部修复:B1 等待计时每秒重建气泡 / B2 setMode(off) 污染 dismissed / B3 mergeTimeline 不分会话。报告:`docs/04-quality/review/2026-11-09-LoopGuard与会话去重-评审.md` |
+| **自查发现** | ① `_paused.update` 判断写错(比整张表而非本会话)② 群聊模板注释含反引号导致**生产构建失败**(单测全绿挡不住) |
+| **验证** | `tsc` ✅ / 全量 `npm test` **1360 SUCCESS** / `npm run build` ✅(571.43 kB,预算警告为存量,已比对基线) |
+| **留痕** | `docs/00-tracking/2026-11-09-修改留痕-LoopGuard与会话去重.md`、`2026-11-09-踩坑-乐观消息回读认领.md` |

+ 300 - 0
docs/02-design/plans/2026-11-09-容器状态模型与五项缺陷适配方案.md

@@ -0,0 +1,300 @@
+# 适配方案:容器状态模型 + 多会话进度 + 五项缺陷修复(2026-11-09)
+
+> 状态:**待用户确认**,未开始编码。
+> 前置:2026-11-09 首轮修复已交付(LoopGuard / 消息去重 / 等待文案),1360 单测 + 构建通过。
+> 本文档基于**真实账号实测**与**直接问容器**得到的事实,不含推测。
+
+---
+
+## 一、实测得到的容器底层事实
+
+### 1.1 消息输出(容器自述,已用真实事件流验证)
+
+| 项 | 事实 | 证据 |
+|---|---|---|
+| 一轮回复是几条事件 | **多条**。`splits_long_messages = True`,超长自动分块 | 容器回答 A;实测一轮出现 9 条事件 |
+| 块之间有轮次标识吗 | **没有**。只有相同的 `m.relates_to` | 容器回答 B |
+| 修正内容怎么发 | **新开一条消息**,不是 `m.replace` 编辑 | 容器回答 C |
+| 一轮结束有信号吗 | **Matrix 事件流里没有**。只有 `post_llm_call` 钩子异步切 `status=idle`,**延迟 1~3 秒**才写进表 | 容器回答 D |
+
+**实测的一轮完整事件序列**(`lj-test`,用户要求生成 Excel):
+
+```
+ts=...074307  💻 terminal ```python3 -c "import openpyxl"```    ← 过程
+ts=...149968  openpyxl 装好了,现在生成文件。                      ← 正文①
+ts=...157242  💻 terminal ```cd /opt/data/work/xlsx...```        ← 过程
+ts=...166208  好了,Excel 文件已经生成。我另外用 LibreOffice...    ← 正文②
+ts=...174605  容器里没装 LibreOffice,所以缓存值暂时是空的...       ← 正文③
+ts=...174655  ```cd /opt/data/work/xlsx...```                    ← 过程
+ts=...189407  文件做好了,一张表,5 列 6 行:...                   ← 正文④
+ts=...189492  简单示例.xlsx                                       ← 附件
+ts=...236915  💾 修为提升:🔧 技能「multi-agent-delivery」修正完毕。 ← 正文⑤
+```
+
+**结论**:正文被过程行反复打断,形成 **5 段正文 + 4 段过程**。
+
+### 1.2 会话模型(容器自述)
+
+| 项 | 事实 |
+|---|---|
+| 并发 | asyncio 并行,但实测**几乎总是 1 个活跃 turn** |
+| 会话标识 | 自建 `session_key` = `<ns>:<platform>:<chat_type>:<chat_id>:<thread_id>` |
+| status 粒度 | ⚠️ **待确认**:容器内部按会话组织,但上报到 `FmodeAgent` 的是**单值字段** |
+
+---
+
+## 二、状态模型设计(你要的三层)
+
+### 2.1 三层状态,各自独立
+
+| 层 | 含义 | 数据来源 | 用在哪 |
+|---|---|---|---|
+| **L1 存活** | 容器还活着吗 | `heartbeatAt` **新鲜度**(不是 `status`) | 能否新建会话 / 加入群聊 |
+| **L2 连接** | Matrix 通道通不通 | `matrixReady` / `syncState` | 能不能发出去 |
+| **L3 执行** | 这一轮跑到哪了 | 本会话最后一条消息时间 + 云函数 `status` | 会话内的进度显示 |
+
+**🔴 L1 必须用心跳时间戳,不能用 `status`**:实测有容器 **45 天没心跳仍上报 `status=online`**。
+
+建议判据:
+
+| 心跳距今 | 判定 |
+|---|---|
+| ≤ 2 分钟 | 存活 |
+| 2 分钟 ~ 10 分钟 | 可疑(显示「状态未同步」) |
+| > 10 分钟 | 视为掉线 |
+
+### 2.2 多会话问题(你的核心提问)
+
+**问题**:同一个容器的多个会话,各自该显示什么进度?
+
+**现状**:全站只有一个 `status` 字段。会话 A 在跑时,会话 B 的界面也会显示「正在执行」。
+
+**三种可能,取决于容器能否按会话上报**:
+
+| 方案 | 前提 | 前端做法 | 容器改动 |
+|---|---|---|---|
+| **A(首选)** | 容器能按 `session_key` 上报 | 按会话读状态,各显示各的 | 上报协议加会话维度 |
+| **B(过渡)** | 容器只有全局状态 | 全局「执行中」标记 + 本会话「有没有未返回的提问」 | 无 |
+| **C(最简)** | 容器串行、几乎总是 1 个 turn | 只标出「当前在跑哪个会话」,其余显示「排队中」 | 无 |
+
+**我的建议:先做 B,同时向容器确认 A 的可行性。**
+
+理由:
+- B 不需要容器改,能立刻解决「会话 B 误显示在跑」;
+- 容器自述「几乎总是 1 个活跃 turn」,说明这个场景不常见,不值得大改;
+- 等容器确认能否加会话维度,再升级到 A。
+
+**B 的具体判据**:
+
+```
+会话有两类信号:
+  ① 本会话「有提问未得到任何回复」     → 等待中(明确)
+  ② 本会话最近 25s 内有消息往来        → 活跃中(推断)
+  ③ 以上都不是                        → 空闲(即使全局 status=running)
+
+容器全局 status 只用于:在会话列表顶部显示「容器整体忙」的角标,
+不覆盖单会话的判定。
+```
+
+---
+
+## 三、五项缺陷的修复方案
+
+| # | 现象 | 根因(已定位到行) | 层次 | 改法 |
+|---|---|---|---|---|
+| 1 | 读秒 95→100 跳变 | 宿主与组件两套 `Date.now()`;echo 替换把锚点从浏览器时钟换成服务端时钟 | 前端 | 锚点固定为「首次看到该用户消息的时刻」,之后不再改写 |
+| 2 | 163s 一直「输出中」 | `agentIdle` 依赖容器 `status`;reporter 硬编码 `running` → 永不为 idle;`peerTyping` 卡 true 时 `live` 恒真 | **容器 + 前端** | 前端加「内容已稳定 N 秒即收尾」兜底;容器修 reporter |
+| 3 | 历史串入新建会话 | 保护解除判据过宽(`:320-323`),任意 `threadId===sid` 就撤保护 | 前端 | 改为「本会话用户消息已落地」才撤保护 |
+| 4 | 状态各端不一致 | 存在 4 套判定函数,PC 用 B、移动用 C | 前端 | 收敛为 §2.1 的三层模型,全站单一入口 |
+| 5 | 正文「覆盖」 | **仿流式截断**:`shown[b.key]` 不收缩,新内容到达后 `slice(0,n)` 只显示前段 | 前端 | 内容变短/变长时重算 `shown`,或直接对已结束的块不截断 |
+
+### 3.1 明细
+
+**① 读秒跳变**
+
+`assistant-turn.ts:674-676` 的锚点来自 `userMessage.ts`,而 echo 会替换本地消息(`chat.service.ts` 的 `mergeTimeline`),`ts` 从浏览器时钟切到服务端时钟 → 跳变。
+
+改法:回合建立时记下**首个锚点**并锁定,后续消息替换不改变它。或者在 `AssistantTurn` 上增加 `anchoredAt`,只在回合首次创建时赋值。
+
+**② 回合不结束**
+
+`live = !closed && (typing || now - updatedAt < quietMs)`,`quietMs` 在 `agentIdle` 时传 0。
+
+问题链:
+- 容器 reporter 硬编码 `status=running` → `agentIdle` 永远 false
+- `quietMs` 保持 25000 → 理论上 25 秒后该收尾
+- 但 `aiRows` 是 computed,依赖没变就不重算 → **静默期间没有信号驱动,不会自动关**
+
+改法(前端兜底,不依赖容器):
+```
+判定收尾的条件(满足任一):
+  · agentIdle === true(容器明确空闲)          ← 现有
+  · 最后一条消息距今 > QUIET_MS                 ← 现有
+  · 本回合最后一条是「有内容的消息」且已静默 > QUIET_MS,且容器全局 status 不是 running  ← 新增
+  · 强制兜底:> 120 秒无条件收尾                 ← 新增
+```
+并且组件里的 `now` tick 要改成**无条件每秒跑**(或在 live 时也跑),保证静默期也能触发重算。
+
+**③ 历史串入**
+
+`:320-323` 现在只要 timeline 里出现任意 `threadId === sid` 就撤保护。改为:
+```
+只有「本会话的用户消息」真的落在 timeline 里,才撤保护
+(即 m.threadId === sid && m.sender === 我自己)
+```
+
+**④ 状态口径统一**
+
+现状(调研结论):
+- 桌面通讯录 → `agentPresence`(显示「未接入」)
+- 移动通讯录 / 聊天页 / 会话列表 → `resolveAgentState`(显示「离线」)
+- 桌面工作台 → `AGENT_STATUS_TEXT`(裸 status)
+- 桌面群成员行 → 裸 `status`
+
+改法:所有位置统一读 `resolveAgentState`,且它内部改为输出 §2.1 的三层字段:
+```ts
+interface AgentView {
+  alive: 'alive' | 'stale' | 'dead';      // L1 心跳
+  link: 'ready' | 'unready';               // L2 接入
+  busy: boolean;                           // L3 执行(仅作全局角标)
+}
+```
+
+**⑤ 正文覆盖**
+
+`assistant-turn.ts:674-676`:
+```ts
+const n = shown[b.key] ?? 0;
+streaming = b.live && n < src.text.length;
+message = streaming ? { ...src.message, text: src.text.slice(0, n) } : src.message;
+```
+
+问题:第二段到达后 `src.text` 变长,但 `shown` 停在前一段长度。
+
+改法:
+- `src.text` 变长时,`shown` 以当前位置继续推进(不是从 0 重启,也不能停在旧值)
+- **已结束(`!live`)的块一律不截断**,直接显示全文
+
+---
+
+## 四、容器侧需要做什么
+
+### 4.0 🔴 重要更正:reporter 没有硬编码 running(2026-11-09 实测推翻)
+
+**此前判断(已撤回)**:「容器 reporter 把 `status` 硬编码成 `running`,导致回合永远不结束」。
+
+**实测推翻**。让容器**真正执行 grep**(不是凭记忆答),原始输出:
+
+```
+$ grep -n 'status' /opt/data/plugins/plugin-active-reporter/__init__.py
+  8:  * pre_llm_call   → 每个 turn 开始时整轮只触发一次,报 status=running + currentTask
+ 11:                     报 status=idle
+209:            "status": "running",
+238:            "status": "idle",
+258:    只报 heartbeatAt + balance,不改 status —— 状态由 turn 事件负责。
+
+$ grep -n 'def _on_' /opt/data/plugins/plugin-active-reporter/__init__.py
+183:def _on_pre_llm_call(**kw: Any) -> None:
+225:def _on_post_llm_call(**kw: Any) -> None:
+```
+
+**结论:两个钩子都在,running / idle 切换机制正常。容器侧没有「永远 running」的问题。**
+
+> 另注:`status-report.py` 是**旧脚本,已不参与运行**。真正生效的是
+> `/opt/data/plugins/plugin-active-reporter/__init__.py`。
+> 若按旧文档去改 `status-report.py`,**改了也无效**。
+
+### 4.1 「输出中不结束」的真实断点在前端
+
+| 环节 | 状态 | 延迟 |
+|---|---|---|
+| 容器 `post_llm_call` 设 `idle` | ✅ 正常 | 立即 |
+| 写入云函数表 | ✅ 正常 | 1~3 秒(后台线程) |
+| App 轮询读到 idle | ✅ 正常 | 轮询间隔 |
+| **App 重算回合 `live`** | ❌ **断点** | — |
+
+`aiRows` 是 computed;`agentIdle` 变化时若无其他信号同变,界面不重算 ——
+所以「已完成」要退出重进才显示(退出重进强制重算)。
+
+→ 这一条**纯前端可解**(已修:`typing` 受静默窗口约束 + tick 驱动重算)。
+
+### 4.2 🔴 方案 A 的技术路径(已探明,不改平台云函数)
+
+**容器答**:「能实现。目前做不到:`_push()` 是按 `field` 逐个写入固定字段,
+`state.json` 只有单个 `last_status`。需要改两处:① 在 `pre/post_llm_call` 里从 `kw`
+拿 `session_id`,维护 `{sessionKey: {status, turnStartAt}}` 字典;② 新增字段上报。」
+
+**平台约束(已核实)**:云函数 `xlosi3m46q`(`fmodeagent-status.js:61`)有字段白名单:
+
+```js
+var FIELDS = ['status','currentTask','currentAction','progress','name','containerRef','host',
+              'heartbeatAt','botName','botAvatar','gender','ownerName','ownerMobile',
+              'balance','channels','extra','sessionStatus','usage','usageSessions'];
+```
+
+**不在白名单里的字段会被丢弃** —— 直接新增 `currentSessions` 不行。
+
+**✅ 突破口:白名单里有 `extra`。** 容器已经在用它
+(`status-report.py`:`slice_data["extra"] = json.dumps(extra, ...)`)。
+把会话状态塞进 `extra`,**平台云函数零改动**。
+
+完整链路:
+
+| 环节 | 要做的事 | 谁做 |
+|---|---|---|
+| ① 容器采集 | `pre/post_llm_call` 从 `kw` 取 `session_id`,维护 `{sessionKey: {status,turnStartAt}}` | 容器 |
+| ② 容器上报 | 写进 `extra` 的 `currentSessions` 子字段(JSON) | 容器 |
+| ③ 平台存储 | 无需改动,`extra` 已是合法白名单字段 | — |
+| ④ 前端映射 | `mapAgent()` 解析 `raw['extra']` → `Agent.currentSessions` | **前端(未做)** |
+| ⑤ 前端展示 | 按 `sessionKey` 匹配当前会话,显示该会话的进度 | **前端(未做)** |
+
+> ⚠️ **前端目前完全没有接 `extra`**(`parse-api.ts:831 mapAgent()` 里没有映射)。
+> 这是方案 A 必须先补的一环,否则容器上报了也读不到。
+
+**待确认**:容器 `extra` 里现有的键会不会与 `currentSessions` 冲突;
+`session_id` 与 App 的 `sessionId`(thread 根)能否对上 —— 这一条**必须实测**,
+对不上就会「报是报了,但匹配不到会话」。
+
+### 4.2 容器侧真正需要改的
+
+| 项 | 现状 | 需要改 | 优先级 |
+|---|---|---|---|
+| **会话级状态上报** | 只有全局 `last_status` | 增加会话维度(待容器确认可行性) | **P1** |
+| 分块加 turn 标识 | 没有 | 若要精确聚合一轮,需要加 | P2(前端靠时间邻近也能聚) |
+| 结束信号 | Matrix 里没有 | 可选:发一条 `m.fmode.turn_end` | P2 |
+
+**容器侧我目前没有改动任何配置,也没有重启容器。**
+
+---
+
+## 五、实施顺序
+
+1. **前端 ⑤ 正文覆盖**(最容易验证,改动小)
+2. **前端 ① 读秒跳变**
+3. **前端 ② 回合收尾兜底 + 组件 tick 改无条件**
+4. **前端 ③ 历史串入**
+5. **前端 ④ 状态三层模型统一**
+6. **前端 §2.2 方案 B 的会话级进度**
+7. 容器上报协议(需你协调)
+
+---
+
+## 六、验证
+
+| 项 | 方式 |
+|---|---|
+| 单测 | `npm test`(现有 1360 为基线,不得回归) |
+| 构建 | `npm run build` |
+| 正文追加 | 让容器生成一份带过程的文件(如 Excel),确认**所有正文段都在** |
+| 读秒 | 观察 30 秒,秒数应单调递增、无跳变 |
+| 回合收尾 | 内容结束后 N 秒应自动显示「已完成」并折叠 |
+| 历史串入 | 新建会话 → 不应出现「加载更早」和旧内容 |
+| 状态 | PC 与移动端同一容器显示一致 |
+
+---
+
+## 七、需要你确认的问题
+
+1. **会话级状态**:是否接受先做「方案 B」(前端推断,容器不改),之后再向容器确认方案 A?
+2. **容器 reporter 整改**:这条必须容器侧改,是否现在就安排?
+3. **⚠️ 生效范围**:我**没有容器的重启权限**(容器内无 docker socket)。若需容器改动生效,需要你在宿主执行重启。

+ 243 - 0
docs/04-quality/review/2026-11-09-LoopGuard与会话去重-评审.md

@@ -0,0 +1,243 @@
+# 评审报告:LoopGuard 误判修复 / 乐观消息去重 / 等待文案(2026-11-09)
+
+| 字段 | 值 |
+|---|---|
+| 评审对象 | 工作区未提交改动(相对 HEAD `00ce1cb`),17 个文件,+778 / -128 |
+| 评审人 | 独立评审员子代理(不携带开发过程上下文) |
+| 评审方式 | 只读代码(按要求未运行测试) |
+| 评审日期 | 2026-11-09 |
+
+---
+
+## 总体结论
+
+**需修改后发布。**
+
+三条缺陷的根因定位准确,修复方向总体正确:LoopGuard 的「尾部连续 agent 段」+「身份只看 `sender`」+「按会话隔离」与 `com.fmode.local` 认领 echo 的思路都能对得上报告的现象。新增用例也基本能打到缺陷链路。
+
+但存在 **2 个阻塞项**和若干重要项:
+
+- **阻塞 1(严重)**:`LoopGuardService` 的 `paused` / `pauseDetail` 两个 computed 与 `evaluate()` 写同一个 `_scope` 信号,形成 **computed → effect 的策略循环**(Angular write-through-computed 会在开发模式下报 `NG0600`,阻止写入;具体后果见下文,依赖运行时,需实测确认)。另外 `evaluate()` 在「守卫自己的 effect 执行期间」写 `_scope`,会触发 `ExpressionChangedAfterItHasBeenChecked` / `NG0601` 类策略违规。这是本次改动新引入的(原实现 `paused` 只是 `asReadonly`)。
+- **阻塞 2(功能仍不完整)**:`mergeTimeline()` 的「保留本地消息」规则会把**不属于当前会话**的本地乐观消息也留下来。`open()` 虽然在开头清过 `_messages`,但 `storeVersion` effect 每次 sync 都会重新「保留」它们(`chat.service.ts:353-354`),与 `storeVersion` effect 里「新建态 / 会话切换必须清空旧内容」的设计直接冲突。用户「发一条出现三条」的主诉是否**彻底**消失,还取决于「乐观消息 → 服务端 echo」这条认领链路在真实 Matrix SDK 上的时序(桌面端 `paginate()` 路径 100% 清不掉,见问题 O2),不能只凭单测判定。
+
+---
+
+## 逐条评审
+
+### 1. 正确性:`agentTail` / `hasRepeat` / `detectLoop` 的边界
+
+**结论:通过(逻辑自洽),一处小瑕疵为「次要」。**
+
+- `agentTail()`(`loop-guard.ts:202-211`)从尾往前扫,遇到 `isFromMe` 即停。`isFromMe` 优先 `fromMe`,未填时回落 `isHuman(sender)`(`loop-guard.ts:190-192`)。旧用例的 `HUMAN` / `A` 发送者不填 `fromMe` 时行为不变,自洽。
+- `streak` 与 `repeat` 共用同一段(`loop-guard.ts:113-115`、`150-152`),口径统一,正确。
+- **段长恰好等于窗口**:`hasRepeat` 判据是 `segment.length < window → false`,即 `length === window` 时参与比较(`loop-guard.ts:220`)。`standard.repeatWindow = 3` 时,尾部恰好 3 条 agent 且高度相似 → `repeated === true` → 命中 `reason: 'repeat'`,不依赖 `maxStreak`(5)。这是原有设计(「重复度独立于连续数」),行为合理。
+- **`opts.quiet` 短路**:`loop-guard.ts:155-157` 在计算完 `streak`、`perMinute`、`repeated` 之后才短路,返回 `{ loop: false, streak, perMinute }`,**没有** `reason` / `detail`。`LoopVerdict` 的 `reason`/`detail` 都是可选字段,类型合法;诊断值正确,单测 `loop-guard.spec.ts:326-334` 覆盖了 `streak === 3`。**通过。**
+- **瑕疵(次要)**:`perMinute` 的过滤写成 `!isFromMe(m, isHuman)`(`loop-guard.ts:143-145`)。只要调用方传了 `fromMe`(`LoopGuardService` 就传了),`isHuman` 参数完全不起作用;而 `isHuman` 又只在 `fromMe === undefined` 时生效。两个参数**互斥**,组合在一起会误导读者(以为 `isHuman` 仍然参与身份判定)。建议:`LoopGuardService` 的调用点把 `isHuman` 改成恒定返回同一结果,或干脆在文档里写明「二选一」。功能上无错。
+- **`opts.quiet` 目前没有生产调用方**:`LoopGuardService.evaluate()` 调用 `detectLoop(msgs, ..., cfg)`(`loop-guard.service.ts:180`)没有传 `opts`。`DetectOptions` 是本次新增但**只有测试在用的死参数**——属于「新增死代码」倾向(见第 8 条)。不影响正确性。
+
+### 2. 正确性:`mergeTimeline()` 是否会丢 / 重,以及「认领」在三个边界情形
+
+**结论:有问题(重要),并有一处「存疑」需要运行时验证。**
+
+`mergeTimeline`(`chat.service.ts:1866-1889`):
+```
+claimed = { next 中所有 m.localId }
+out = [...next] + local.filter(m => !claimed.has(m.id) && !(m.localId && claimed.has(m.localId)))
+sort by Date.parse(ts)
+```
+- 不会因为 id 重复而丢服务端消息(`next` 内部按 eventId 去重由调用方 `mapTimeline` 上游保证)。
+- 但 **`local` 中的消息只要没有被认领就一律保留**,不分「sending / failed / 已经 sent 但没有 echo」。这条规则在以下两种情况下会出错:
+  1. **会话切换后残留**:见 O2(`paginate()` 永久保留本地消息),以及 **open() 不按会话清空**:`open()` 只是 `_messages.set([])`,而 `storeVersion` effect 每次 sync 会 `mergeTimeline(next, local)`。`local` 里若有旧会话的乐观消息(比如发送中切走了会话),它们会被再次并回新会话。目前只有 `mergeTimeline` 这一层没有区分 `conversationId`,`Message.conversationId` 字段虽然存在(`models.ts:231`),但没有参与过滤。
+  2. **发送失败后服务端其实已收到**:`sendReal` 抛出异常时 `markFailed`(`chat.service.ts:1296`),乐观消息 `state: 'failed'` 且 `localId` 不变。如果服务端其实已经写入,`storeVersion` 回读到带 `localId` 的 echo,会认领并**删除**这条 failed 消息(`claimed.has(m.localId)` 分支),此时它会被替换成 server 版本 `state: 'sent'`——**这一情形反而是对的**。
+  
+  需要分清的是:失败分支的「认领」是**成功**的,真正的风险点是 `paginate()` 里 `mergeTimeline(next, local)` 不受 `_blankSessionId` 保护(O2)。
+
+- **echo 先于发送 Promise 返回**:乐观消息在 `sendText()` 里先 `_messages.update` 加入(`chat.service.ts:1169`),`sendReal` 在 `await` 里。实时 echo 到达走 `appendIncoming`(`chat.service.ts:1955`),在 `_messages.update` 中按 `localId` 过滤掉旧条并追加 mapped。**顺位正确。**
+  这里有一个细节:`appendIncoming` 在 `_messages` 的更新里依赖 `list` 当前快照做 `filter`,而 Angular signal 的 `update` 在同步调用栈内是串行的,不存在 race。
+- **发送 Promise 先于 echo 返回**:`markSent(msg.id)`(`chat.service.ts:1283`)执行时乐观消息还在,于是被标成 `state:'sent'`;随后 echo 到达,`appendIncoming` 认领(filter 掉),`storeVersion` 到达时 `mergeTimeline` 也会认领。**不会重复。**
+- **retry 之后产生新 eventId**:`retry()`(`chat.service.ts:1545-1561`)复用**原 `localId`**(`this.sendReal(msg.text, msg.localId)`),好消息是:如果第一次失败但服务端其实已写入,第二次成功写入的事件也带同一个 `localId`,回读时两条 echo 都会 claim 同一个 `localId` → 两条都会替换掉同一条乐观消息,结果是**两条 echo 同时出现**(服务端已落地 + 重试又落一条)。这正是「发送失败但服务端其实已收到」组合下会真实发生的情形。它**不会**产生「本地 + echo」的三条,但会产生「$e1 + $e2」两条。这一条**需要运行时验证**,因为「sendEvent 抛错但其实已写入」是否真的存在取决于 SDK 的重试策略。**标记为存疑。**
+
+### 3. 正确性:`appendIncoming` 里 `mergeTimeline([...rest, mapped], [])` 的语义
+
+**结论:语义是「排序合并」,不冲突,但接口形状不匹配(次要)。**
+
+- 传入空数组作为 `local`,意味着「不保留任何本地消息」,只对 `rest + mapped` 排序,这一步只依赖 `mergeTimeline` 的「稳定 sort」功能。这与「保留本地消息」的设计**不冲突**,因为 `rest` 已经包含原 list 中的其它消息。
+- **问题**:这叫「用一个带保留语义的函数,传空 local 只为了排序」,读者要拐个弯才能看懂。另外 `rest.filter(x => x.id !== localId)` 只删「`id === localId`」的乐观消息。如果用户对**同一条**乐观消息连续触发两次 echo(例如服务端重发带同一 `localId` 的两个不同 eventId),第一次 echo 已经把这条乐观消息删掉;第二次 echo 到达时 `x.id !== localId` 过滤不到任何乐观消息(它已经不存在),于是**两条服务端消息会同时出现在列表里**——不是这次的主要 bug,但依然是 O2 相关现象的同源问题。建议:`mergeTimeline` 拆出纯排序 `sortByTs(list)`,或至少在注释写明「传 `[]` = 不需要保留本地残留」。
+- 另外,`appendIncoming` 的调用也没检查 `belongsToSession`(`live=true` 就会进来)。如果发现**不是当前会话**的 echo 带了当前会话的 `localId`,理论上会替换错人——但 `localId` 是本地随机生成的,服务端不同会话撞上的概率几乎为零。**不作为问题。**
+
+### 4. 正确性:`ensureSessionForSend` 的 recreate 分支
+
+**结论:有问题(重要)。**
+
+改动见 `chat.service.ts:1636-1649`:`_messages.set([])`、`_ownedThreadRoots.clear()`、`_blankSessionId = recreated.id`。
+
+- **时序冲突**:`ensureSessionForSend` 在 `sendText()` 流程中,**在**乐观消息加入 `_messages`(`chat.service.ts:1169`)**之前**——`_sessionId` 被置空时才会走到重建,此时乐观消息还没入表,`_messages.set([])` 清掉的是旧会话残留,本身不会误伤本次的乐观消息。**这一点是对的。**
+- **但**:
+  1. `_blankSessionId` 被重置后,`sendText` 里后续要执行 `this.sessions.setMessageCount(curSid, ...)`、`pulse.noteUserAction` 等逻辑(`chat.service.ts:1233-1256`)还依赖 `curSid`;`curSid` 是在 `sendText` 开头拿的旧值(`chat.service.ts:1210`),无法同步到新 id。这会导致 `setMessageCount` 写到了一个已经不存在的 `curSid`。**这是一个 pre-existing 问题**,但本次**加上了 `_blankSessionId` 重置**,让保护期比原来更长:成功分支不清 `_blankSessionId`(依赖后续 `storeVersion` effect 落地),失败分支才清(`chat.service.ts:1302`)。合理。
+  2. **真正的回归风险**:`_ownedThreadRoots.clear()` 发生在 `ensureSessionForSend` 中,而 `storeVersion` 每次 sync 都会用 `rootsOfSession(raw, sid, myUserId)` **重新计算** `ownedRoots`(`chat.service.ts:336`),中间不会读 `_ownedThreadRoots`。但 **`paginate()`** 直接读 `_ownedThreadRoots`(`chat.service.ts:1533` `paginate()` 内 `belongsToSession(m, sid)` 没传 ownedRoots → 走 `this._ownedThreadRoots`)。如果 `_ownedThreadRoots` 被清空后该会话还没 sync 成功过,翻页会**丢掉所有容器子线程的消息**。考虑到 recreate 场景极少(会话被 `pruneEmpty` 删掉),属于**次要**,但需在 `paginate()` 里补上 `_blankSessionId` 保护(见 O3 逻辑,与既有「保护期内不许翻页」的约束应当一致)。
+  3. **重建之后 `sessions.create()` 出来的是「空会话」**,其 `messageCount` 为 0。`belongsToSession()` 中的**房间级消息认领规则**(`chat.service.ts:1756-1760`)在 `(target.messageCount ?? 0) > 0` 为 false 时会拒绝该会话认领房间级历史——这是**设计如此**(新建即空白),没问题。但 recreate 之后用户原来在房间里的图片等房间级消息**会消失**(这是既有「新建会话」的既有行为)。是否可接受需要业务侧确认。**标记为存疑(业务语义)。**
+  4. `_blankWorkspace` 处理保持原地不动,正确。
+
+### 5. Signal 正确性:`paused` / `pauseDetail` 与 `evaluate()` 的循环
+
+**结论:有问题(阻塞)。**
+
+- `LoopGuardService.paused`(`loop-guard.service.ts:52-55`)是 `computed`,内部读 `this._scope()`(经 `keyOf`)和 `this._paused()`。`pauseDetail`(`:57-60`)同理。
+- `LoopGuardService` 的 `constructor` effect(`:104-110`)读 `chat.messages()`、`_mode()`,然后调 `evaluate()`。`evaluate()` 内部 `keyOf(null)` 读 `this._scope()` 与 `this.chat.sessionId()`。
+- 页面 effect(`desktop/pages/chat/chat.ts:2305`、`mobile/pages/chat/chat.ts:963`)写 `_scope`。
+- **循环链路(WRC)**:`computed(paused)` 读 `_scope` → 同时被 template 读取 → 若 template 的渲染 effect 和 `evaluate` 所在的 effect 顺序导致 `_scope` 在「computed 已被消费之后」被写 → Angular 开发模式会抛 `NG0600: Writing to signals is not allowed in a computed or an effect by default`. 本次新增的写点:
+  - **页面 effect → `watch()` → `_scope.set()`**:页面 effect 只要不读 `paused`/`pauseDetail` 就不会触发 NG0600。**当前代码没有问题**(`effect(() => this.loop.watch(this.chat.sessionId()))` 只读 `chat.sessionId()`)。
+  - **`evaluate()` 写 `_scope`?** 实际没有直接写 `_scope`。所以「computed 自依赖」只存在于「`paused` 读 `_scope` 与 `evaluate` 读 `_scope` 同源」的间接层面,**不构成写回环**。
+
+  但我仍然将其定为阻塞,原因如下(**都需要实测**):
+  1. `evaluate()` 内 `this._dismissed.update(...)` 与 `this._paused.update(...)` 在**同一个 `evaluate()` 调用内**修改两个信号,且这两个信号都被 `computed(paused)` 与 `computed(pauseDetail)` 读——如果 `evaluate()` 的后续代码再读回 `paused`/`pauseDetail`(目前没有),就会 NG0600。目前**没有**这个读回。但这是**脆弱耦合**:任何后续在 `evaluate()` 末尾加 `if (this.paused())` 调试代码就会炸。
+  2. **更严重的问题**:`evaluate()` **无条件**每次消息变化都执行,包括每次 UI 渲染时 `chat.messages()` 若发生变化。`_dismissed.update` 与 `_paused.update` 中「值没变就返回同一个引用」(`loop-guard.service.ts:168-197` 部分),**但 `_dismissed.update` 没有「值没变就返回同一引用」的短路**(`:185-191`):`_dismissed.update((s) => { if (!s.has(key)) return s; ... })` **有**短路(否则返回 `s`)。**因此实际上没有无谓信号更新。**
+  3. **真正的问题**:`_scope` 与 `sessionId` 的**语义冲突**。`keyOf()` 优先用 `_scope`;页面 effect 里 `_scope` 被 `chat.sessionId()` 同步。如果页面 A 是单聊页打开了 `chat.sessionId() = S1`,随后用户切到群聊页(`DkGroupPage`),**群聊页没有 `watch()`**,而单聊页的 effect 如果在路由销毁后仍未被销毁(Angular 20 中 standalone 页面组件的 effect 会随组件销毁),`_scope` 会**一直停留在 S1**。这时群聊页注入的 `LoopGuardService`(我已确认 `groups.ts` 已**移除注入**,见 `groups.ts:154`),暂无影响。但如果未来再接入群聊/其他页面,就会读到一个**陈旧 scope**。
+  4. `setMode('off')` 调用 `resume()`,`resume()` 会 `_dismissed.update` 加当前 key。之后 `setMode('standard')` 时 `evaluate()` 末尾 `else if (this._dismissed().has(key))` 会**立即 return 并 clearPaused**(`:183-186`),导致「切回 standard 后即使真有回环也不提示」。这是**行为正确性问题**(不是 Signal 层面)。用户手动从 off 切回 standard 期望「重新检测」,但被 `_dismissed` 挡住直到用户自己再发一句。**重要。**
+  5. **`evaluate()` 在 effect 内写信号是否安全**:`effect` 内写 signal 在 Angular 20 默认是**允许**的(只要不是写被同一 effect 读的 signal);`evaluate()` 读 `chat.messages()`、`_mode()`、`_scope()`、`chat.sessionId()`、`chat.agentId()`,写 `_paused`、`_verdict`、`_dismissed`。**没有**写回同一 effect 所读的 signal。**安全。** 但 `_scope` 由页面 effect 写,`evaluate` 由 service effect 读,同一 signal 跨 effect 读写——Angular 允许,但会造成「页面 effect 先于 service effect 执行」的调度顺序依赖。当前 `effect(() => loop.watch(...))` 与 service 的构造函数 effect 都在各自注入上下文中,service 的 effect 属于 root injector,**先于**页面 effect 创建,所以**首次**消息变化时 `_scope` 可能是空串(回落 `chat.sessionId()`),随后页面 effect 补写 `_scope`,**没有**触发 `evaluate()` 重算——因为 `evaluate()` 是在 service effect 里被调用的,`_scope` 变化不会让 service effect 重跑。**因此 `paused()` 会「滞后一帧」**:页面切到另一个会话时,`_scope` 更新,`paused` 重算(正确);但下一次新消息到达时,如果 `_scope` 为空(例如页面 effect 还没跑),`evaluate()` 会按 `chat.sessionId()` 算,而 `paused` 按 `_scope` 算——**两者 key 一致时无问题**。这是由 `keyOf` 回退逻辑兜住的。**结论:安全,但「滞后一帧」+「陈旧 scope」是隐患。**
+
+- **另外一个我确认的循环**:`evaluate()` 中 `this._verdict.set(v)`(`:190`)每次都会 `set` 新对象引用(`detectLoop` 每次返回新对象),即使 `loop` 值没变。这会触发**任何** `verdict` 消费者的重算。当前没有任何 UI 消费 `verdict`,但 `verdict` 已 `asReadonly()` 暴露给外部——如果未来有人用它,每次 sync 都会重渲染。**次要。**
+
+### 6. UI 正确性:`now` 每秒信号 / `waitLabel` / 移动端一致性 / 文案重复
+
+**结论:有问题(重要,定时器泄漏),文案重复已处理。**
+
+- **定时器未清(泄漏)**:`assistant-turn.ts:491-496`:
+  ```
+  effect(() => {
+    if (!this.turn().live) return;
+    const id = setInterval(() => this.now.set(Date.now()), 1000);
+    return () => clearInterval(id);
+  });
+  ```
+  `effect` 返回的 cleanup 会在「下一次 effect 重跑」或「组件销毁」时调用。**这本身是对的**:Angular 20 的 effect 注入式注册会自动随组件销毁调用 cleanup。**但**:`ngOnDestroy` 里(`assistant-turn.ts:500-502`)只调了 `cancel()`(清 RAF),没有清 interval——**依赖 effect 的自动清理**,这在 Angular 20 中**是支持的**(`effect` 注册在组件的 injector 上下文中),所以我不能断定这是泄漏。
+  - **但**:该 effect 在**每次** `turn()` 值变化时(`input.required<AssistantTurn>`,每帧/每条消息都会产生新对象)会**重新** `if (!live) return` → 如果 `live` 一直是 `false`,**每次**重跑都会**先** `return`(没有 cleanup 返回),**再**进入 `if (!live) return` 前**不创建**interval——没问题。
+  - **真正的问题**:`now` 信号每秒 `set`,而 `now` 同时被 `waitLabel()`(`:716`)读取,`waitLabel` 又被 `segments`(`:681`)和 `thinking`(`:704`)读取。`segments` 是 `computed`,**默认没有依赖 `now`**——`waitLabel` 是**方法**,在 `computed` 内部被调用,所以它读 `now` 会**把 `now` 记成 `segments` 的依赖**。每次 `now` 变化,`segments` 重算 → 整个回合段视图重建 → `app-message-bubble` **大量重建**,每秒一次。这是**性能问题**(尤其群聊多段)。更严重的是,`segments` 的注释明确写着「每段的消息对象必须稳定复用……否则流式每帧都会重建 `app-message-bubble` → 抖动」(`assistant-turn.ts:640-642`),本次改动**直接违反了这条既定约束**。**重要。**
+  - `thinking` computed 也读 `now`,同理。
+  - 建议:把 `waitLabel` 从 `computed` 中移出,改为**模板直接调用**(模板表达式中调用方法不会成为 computed 依赖,但**每次 CD 都会执行**)或在 `segments` 中只暴露「等待文案的锚点 `waitingSince`、`live`、`empty`」,由模板判断 `now() - waitingSince`。**更干净的方案**:`segments` 不读 `now`,新增独立 `computed waitLabels = () => map<blockKey, string>`,由模板 `@if (seg.live) { {{ waitLabels()[seg.key] }} }` 读取,这样 `segments` 的 key/消息对象稳定性不受影响。
+
+- **文案重复(卡级 + 段级)**:`thinking` 只在 `!t.merged && 没有任何块会渲染成段` 时返回,与段级 `waiting` 互斥(`assistant-turn.ts:700-708`)。**逻辑正确,不会同时显示。**
+- **移动端与桌面一致性**:两端都渲染 `AssistantTurnComponent`(移动端 `:174`,桌面字面上相同),文案一致。移动端新加的 `m-typing-line` 与卡级 `waitLabel` 的互斥条件(`aiRows().length === 0`)与桌面一致(`desktop:450`)。**通过。**
+- **`waitLabel` 在 `b.waitingSince === 0` 时回落 `b.waitMs`**:`waitMs` 是构建时的快照,`now` 的变化不影响它——这意味着「容器主动发言」(无锚点)时秒数**不会**跳动,文案会一直停在构建时刻的值。这与「秒数要自己走」的意图不符,但可以接受(因为没有用户消息锚点,等待时长本身无意义)。**次要。**
+- **移动端 `m-loop-bar` 使用 `--warning` 等令牌**(`mobile/pages/chat/chat.ts:430-460`),没有硬编码色值。**通过。**
+- **移动端 `m-loop-act` 是 `<button type="button">`**,没有经过「官方组件底座」。规则 18 负面清单第 1 条:「禁止使用浏览器原生控件直接承载交互」。但移动端现有样式文件里已有大量原生按钮(如 `.m-loop` 同类样式),且 `m-loop-act` 有 `min-height: 44px` + `font: inherit`,是「底座没有该组件时的就地实现」——**属于规范偏差(次要)**,与 `m-conn-bar` 同类,不单独列为阻塞。
+
+### 7. 回归风险:删除 `belongsToSession()` 中 `if (!m.threadId)` 第二段
+
+**结论:通过(第一段已完全覆盖),但注释「到这里 `m.threadId` 必有值」不精确(次要)。**
+
+- `chat.service.ts:1729-1760`:函数开头 `if (!m.threadId) { ... return ... }` 分支**每个**子路径都返回(`m.dispatch` → false;`isAfterLastDispatch` → false;`m.sessionId` → 比较;`target.messageCount` → 比较),**不可能** fall through。
+- 删除的第二段 `if (!m.threadId) return false;`(旧代码位置在 `m.threadId === sessionId` 之后)**不可达**——因为进入到这里时 `m.threadId` 必然为 truthy。**删除是安全的等价变换。**
+- 但注释写「**到这里 `m.threadId` 必有值**」,这在**上下文**上成立,但读者容易误以为「`m.threadId` 可能为空」。建议改为「以下规则只处理**带 thread** 的消息(无 thread 分支已在开头 return)」。**次要。**
+
+### 8. 规范符合
+
+**结论:小问题(次要)。**
+
+- **硬编码色值**:无。`m-loop-bar` 用 `--warning` 令牌,`m-loop-act` 用 `var(--warning)`。**通过。**
+- **`console.log`**:`git diff` 新增行中未发现。**通过。**
+- **死代码**:
+  - `DetectOptions` / `opts.quiet`:**只有测试在用**,生产无调用(`loop-guard.service.ts:180` 不传 `opts`)。属于**新增死代码**。
+  - `LoopGuardService.scope`:已 `asReadonly()` 暴露(`:42`),但**没有任何消费方**。属于**新增死代码**(次要)。
+  - `LoopGuardService.verdict`:已有,未变(`_verdict` 只有 `evaluate` 写)。
+  - `_verdict` 的写入是**唯一写点**,但 `verdict` 只读 exposes,无消费。**既有死代码,不是本次引入**。
+- **未使用变量 / import**:
+  - `groups.ts` 的 `LoopGuardService` import 已删除(`-1` 行),`this.loop` 也删了。**通过。**
+  - 但 `groups.ts:150` 仍有 `{{ hint }}` / `.loop-detail` 样式(`.loop-detail` 用于 `hint`),**这是另一条提示(不是回环),保留正确。** 我确认它来自 `hint` computed,**不是残留**。
+- **注释掉的旧逻辑**:`groups.ts` 的移除用 HTML 注释块说明了原因(`groups.ts:157-166`),这是**合理留痕**,不是「注释掉的旧逻辑」。**但**:注释里说「原实现注入的是单聊 Guard……」——本应更简洁;可以在 CHANGELOG 里留,注释里只说「群聊回环另立设计」。**次要。**
+- **中文注释**:改动注释均为中文。**通过。**
+- **最小改动**:`groups.ts` 移除横幅 + 注入不违反最小改动(问题修复必需)。**通过。**
+
+### 9. 测试质量
+
+**结论:有问题(重要)。**
+
+新增用例能打到的问题:
+- `loop-guard.spec.ts:252-334`(6 条):用**真实 `detectLoop`** 测「跨会话重复不触发 / 我方连发不计 / 人类隔断 / 真回环仍触发 / quiet 短路」。**能打到根因 ①**(`repeat` 跨会话)。**好。**
+- `loop-guard.service.spec.ts:118-186`(5 条):**能打到**「按 sessionId 隔离」与「provisionInfo 为空仍能判定」。**好。**
+- `chat.service.spec.ts:1024-1053`:「发送完成后恰好 1 条」。**能打到根因 ②**的最终表现,好。
+- `chat.service.spec.ts:1060-1116`:「echo 带回 localId → 只留服务端那条」+「连点两次 → 2 条且 id 唯一」+「失败消息保留」。**能打到 echo 认领路径**,好。
+- `assistant-turn.spec.ts:263-292`(6 条):纯 `waitText`,**只能测文案规则**,**测不到** `now` 每秒跳 + `segments` 重建的问题。**不够。**
+
+**抓不到 bug 的用例 / 遗漏路径**(重要):
+1. **`_scope` 陈旧 / 页面切换**:`loop-guard.service.spec.ts` 用 `watch('s1')` 直接调,**没有测**「页面 effect 与 service effect 的**执行顺序**」。无法发现「切会话后 `paused` 滞后」的问题(需集成测试)。
+2. **`ensureSessionForSend` recreate 分支**:**没有任何用例**覆盖。`_blankSessionId = recreated.id` 的新行为只在 `storeVersion` effect 落地时解除,这条链路(发送成功 → 保护期保持 → /sync 落地 → 解除)是**本次最重要的时序**,但现有 spec 里的 `storeVersion` 是常量 `() => 0`(`chat.service.spec.ts:789`),**effect 不会重跑**,所以**完全测不到**。任务里说「回填路径 storeVersion effect 没有被任何用例覆盖」——**属实**。
+3. **`paginate()` 路径**:本次把它改成 `mergeTimeline(next, untracked(...))`,**没有任何用例**。
+4. **`retry()`**:**没有用例**覆盖「retry 复用 localId 后,第二次 echo 认领同一条」的重复风险。这是**最容易漏的回归**。
+5. **「连点两次」用例的断言不够**:只断言 `mine.length === 2` 和 id 唯一,**没有断言两条的内容 / 顺序**(`'第一条'` / `'第二条'`)。如果 `mergeTimeline` 的稳定排序把两条顺序颠倒,测试依然通过。**建议补断言顺序。**
+6. **「静默等待文案」的秒数连续性**:`waitText` 只测输入 → 输出,**没有测**「`now` signal 每秒更新 → `segments` 重算」的组件行为。组件层 spec(`components/domain/assistant-turn.spec.ts`)只补了 `waitingSince`/`waitMs` 字段,**没有测** `waitLabel` 的时钟跳动。**建议补组件级 fakeAsync / jasmine.clock 测试。**
+7. **`agentTail` / `detectLoop` 的 quiet**:quiet 只在 `detectLoop` 纯函数测试,**没有测** service 层(如果未来 `evaluate()` 传 `opts`,要测「quiet 时 `paused` 为 false 但 `streak` 有值」)——目前 service 不传,**不是必须**。
+
+### 10. 未验证项(依赖运行时事实)
+
+以下**仅凭代码无法证明**,需要运行时/端到端验证:
+
+1. **Matrix SDK `sendEvent` 抛错但实际上服务端已写入**:如果存在,retry 复用 `localId` 会产生两条 echo。
+2. **`sendEvent` 的本地 echo 是否在 Promise resolve 之前进入 `timelineToMessages`**:如果不是(例如 `storeVersion` 落后的窗口),`storeVersion` effect 的 `mergeTimeline(next, local)` 会把乐观消息保留,直到下一次 sync——期间用户会看到「本地一条 + echo 一条」的短暂双条。需要确认是否在窗口内。
+3. **`com.fmode.local` 字段在 Matrix homeserver 上是否被原样存储与 broadcast**:Matrix 规范允许额外字段,但服务端可能有大小限制 / 裁剪策略。
+4. **`evaluate()` 与页面 effect 的执行顺序**:首次进入页面时 `_scope` 是否为空。这会影响「第一条 agent 消息到达时能否被正确判定」。
+5. **`segments` 每秒重算导致的 `app-message-bubble` 重建是否真的引起抖动**:需要实际运行截图 / Performance 面板确认。
+6. **`ngOnDestroy` 后 `setInterval` 是否被 Angular 清理**:依赖 Angular 版本对 `effect` 在组件 destroy 时执行 cleanup 的行为,需实测(理论上支持)。
+7. **`agentIdle` / `quietMs` 与 `waitText` 的配合**:`agentIdle=true` 时 `quietMs=0`,`live` 立即变 false,`waitText` 返回 `''` 或 `'未响应'`;如果容器误报 idle,用户会提前看到「未响应」——需业务确认。
+
+---
+
+## 问题清单(按严重程度排序)
+
+### 阻塞
+
+| # | 文件:行号 | 问题 | 为什么是问题 | 建议改法 |
+|---|---|---|---|---|
+| B1 | `src/app/components/domain/assistant-turn/assistant-turn.ts:681`、`:704`、`:715-717` | `segments` / `thinking` 两个 `computed` 通过 `waitLabel()` **读取 `now` 信号**,每秒重算一次 | 1) 违反文件自身「每段的消息对象必须稳定复用,否则每帧重建 `app-message-bubble` → 抖动」的注释(`:640-642`);2) `segments` 的每一项 `message` 是 `{...src.message, text: ...}`,每次重算都会产生新对象引用,每秒触发整个回合气泡重建;3) 群聊多段时每秒 N 次重建 | 把 `waitLabel` 从 computed 里移出去:`segments` 只暴露 `waitingSince` / `live` / `empty` / `hasOutput`,模板里用 `{{ waitText(now() - s.waitingSince, s.hasOutput, s.live, s.empty) }}`(方法调用不成为 computed 依赖)或新增独立 `computed labels` 按 key 映射,不动 `segments` 的 key/对象稳定性 |
+| B2 | `src/app/core/loop-guard.service.ts:52-60`、`:114-121`、`:104-110` | `paused` / `pauseDetail` 是 computed,经 `keyOf()` 读 `_scope`;而 `_scope` 由页面 effect 写、`evaluate()` 也在 service effect 内经 `keyOf()` 读 `_scope`。跨 effect 读写同一 signal,且「页面 effect 在路由销毁后 `_scope` 可能停留在旧会话」 | 1) 当前没有写回 self,但这是**脆弱耦合**:任何 `evaluate()` 内读 `paused` 或 `pauseDetail` 都会 NG0600;2) `_scope` 陈旧会污染未来接入的其他页面(群聊页已移除注入,但临时接入即触发);3) `setMode('off')` → `resume()` 写 `_dismissed`,切回 `standard` 后 `evaluate()` 会因 `_dismissed` 命中而**立即 return 并 clearPaused**,导致真回环不再提示(`:183-186`) | ① 去掉 `_scope` 与 `keyOf()` 的双源,直接统一用 `chat.sessionId()`(`watch()` 只用于「显式锁定」场景,并在页面 `ngOnDestroy` 里 `watch(null)` 复位);② `setMode('off')` 不写 `_dismissed`,保持 `_dismissed` 只由用户显式「继续协作」写入;③ `evaluate()` 末尾不要读 `paused`/`pauseDetail`,用局部变量代替 |
+| B3 | `src/app/core/chat.service.ts:1866-1889`、`:353-354` | `mergeTimeline(next, local)` 无条件保留本地消息,且**不区分 `conversationId` / `sessionId`**;`storeVersion` effect 每次 sync 都会重新保留旧会话的乐观消息 | 用户「一条你好变三条」的主诉若尚未完全消失,根因就在这里;切会话 / 新建会话后 `storeVersion` 回填会把旧会话的本地乐观消息重新灌进来,与「新建即空白」保护(`:320-323`)冲突 | `mergeTimeline` 增加参数 `sessionId`,过滤 `local` 时只保留 `m.conversationId === 当前 agentId` 且「发送中 / 失败」的消息;`sent` 状态且没被 echo 认领的本地消息应考虑丢弃或按时间窗过期 |
+
+### 重要
+
+| # | 文件:行号 | 问题 | 为什么是问题 | 建议改法 |
+|---|---|---|---|---|
+| I1 | `src/app/core/chat.service.ts:1533-1535` | `paginate()` 用 `mergeTimeline(next, local)` 合并,**没有** `_blankSessionId` 保护;本地乐观消息会被永久保留 | 与「基准 I3」(`_blankSessionId` 防回填)的设计冲突:翻页时如果本地存在旧会话的乐观消息,`next` 只含当前会话 timeline,`local` 里的旧会话乐观消息会被追加进来 → 翻页会「出现别人会话的消息」 | 在 `paginate()` 中 `if (this._blankSessionId === sid) return false`(与既有保护一致),或 `mergeTimeline` 按会话语义过滤(见 B3) |
+| I2 | `src/app/core/chat.service.ts:1958-1963` | `appendIncoming` 用 `mergeTimeline([...rest, mapped], [])` 只排序;**同一 `localId` 的第二次 echo 会同时留下两条服务端消息** | retry 复用 `localId` 时,如果第一次 echo 已到达而第二次成功又产生新的 eventId,两条都带同一个 `localId`,各自 id 不重复,`claimed` 认领的是同一条乐观消息(已不存在),两条服务端消息都会保留 | 在 `appendIncoming` 中对 `mapped.localId` 检查:若 `list` 中**已存在**同 `localId` 的另一条 server 消息,做「保留最新 / 去重」策略(按 eventId 排序取最后一条),并记录日志 |
+| I3 | `src/app/core/chat.service.ts:1636-1649` | `ensureSessionForSend()` recreate 分支 `_blankSessionId = recreated.id` 之后的**成功分支不会清**,靠 `storeVersion` effect 落地才解除;但 `setMessageCount(curSid, ...)` 中的 `curSid` 是**旧 id** | 重建后 `curSid`(`chat.service.ts:1210`)仍指向已被 `pruneEmpty` 删掉的旧会话,`setMessageCount` 写入无效会话(既有问题,但本次新增 `_blankSessionId` 让保护窗口更长,Audio「会话列表消息数」在重建场景可能显示 0) | `ensureSessionForSend()` 返回重建后的 id,`sendText()` 内用返回值替换 `curSid` 做后续记账 |
+| I4 | `src/app/core/loop-guard.service.ts:180` + `loop-guard.ts:59-68` | `DetectOptions.quiet` 新增但**生产无调用方**,只有测试在用 | 新增死代码;且规则 18 只测「单元」不测「接线」,未来接线时没有接线测试 | 若本迭代不使用,删除 `DetectOptions` / `opts.quiet`;若确需保留(如「新建会话前 N 秒保护」是既定设计),在 `evaluate()` 里接线并补接线测试 |
+| I5 | `src/app/core/loop-guard.service.ts:42` | `scope` 已 `asReadonly()` 暴露,但无消费方 | 死接口 | 删除,或文档说明是「调试用」 |
+| I6 | `src/app/core/loop-guard.service.ts:190` | `_verdict.set(v)` 每次消息变化都写新对象(即使 `loop` 值不变),`verdict` 无消费方但已暴露 | 无谓信号写;未来任何消费方都会每秒/每条消息重渲染 | 仅在 `v` 与已知值不同时写,或直接删除 `_verdict` / `verdict` |
+| I7 | `src/app/core/chat.service.spec.ts`(整段) | 关键路径**未被用例覆盖**:`storeVersion` effect 回填、`paginate()`、`ensureSessionForSend()` recreate 分支、`retry()` 复用 `localId` | 这些是本次改动最容易回归的地方;`storeVersion` effect 在 spec 中被 stub 为常量 `() => 0`,effect **不会重跑**,等于这条链路完全没有验证 | 补一个可手动推高 `storeVersion` 的 fake:`storeVersion: () => this.version()`,在用例里 `version.set(n)` 触发 effect,断言「echo 到达后 `messages()` 恰好 1 条」「切会话后旧会话乐观消息不残留」 |
+| I8 | 全部 spec | 断言只检查「数量」不检查「内容 / 顺序」(`'连点两次'` 用例只断言 2 条、id 唯一) | 稳定排序出错(顺序颠倒)测试仍会通过 | 补齐 `mine.map(m => m.text)` 的顺序断言 |
+
+### 次要
+
+| # | 文件:行号 | 问题 | 为什么是问题 | 建议改法 |
+|---|---|---|---|---|
+| M1 | `loop-guard.ts:143-145`、`:190-192` | `isFromMe` 与 `isHuman` 互斥,`perMinute` 里 `!isFromMe(m, isHuman)` 写法易误解 | 读者会以为 `isHuman` 在 `fromMe` 存在时仍参与判定 | 改为 `!m.fromMe`,并把 `isHuman` 改成「仅旧用例」的 fallback,文档写明二选一 |
+| M2 | `chat.service.ts:1734-1736` | 注释「到这里 `m.threadId` 必有值」表述不准确(读者会以为类型上 nullable) | 误导后续维护 | 改为「以下规则只处理**带 thread** 的消息;无 thread 已在开头 return」 |
+| M3 | `chat.service.ts:1960` | `mergeTimeline([...rest, mapped], [])` 传空 `local` 只为排序,接口形状不匹配 | 阅读成本 | 抽出纯排序 `sortByTs()`,或注释明确「传 `[]` = 不保留本地残留,只排序」 |
+| M4 | `assistant-turn.ts:716` | `waitingSince === 0` 时回落 `b.waitMs`(构建时快照),秒数**不会跳** | 与「秒数要自己走」的意图不符(虽无锚点时等待时长本身无意义) | 无锚点时直接返回 `'正在思考…'` 或空串,避免「冻结的秒数」误导 |
+| M5 | `mobile/pages/chat/chat.ts:429-460` | `m-loop-act` 使用原生 `<button>`,未走底座 | 与规则 18「禁止原生控件承载交互」存在偏差 | 若底座有 `ui-m-button` 或类似组件,改用;否则在注释里说明「底座无同形态紧凑按钮,就地实现」并保证 44px 触达 |
+| M6 | `desktop/pages/groups/groups.ts:157-166` | 移除横幅的 HTML 注释过长,混入「原实现注入的是单聊 Guard……」实现细节 | 注释应留给设计原因,不适宜长期保留 | 精简为「群聊回环提示另立设计(2026-11-09 用户裁决)」,实现细节放修改留痕 |
+| M7 | `loop-guard.service.ts:88-101` | `resume()` 内 `_paused.update` 与 `clearPaused()` 逻辑重复 | 代码重复 | `resume()` 复用 `clearPaused(key)` |
+
+---
+
+## 附:已确认「无问题」的点(避免重复返工)
+
+- `belongsToSession()` 删除第二段 `if (!m.threadId) return false`:**安全**,第一段所有子路径均 return。
+- `agentTail` 边界 `length === window`:**正确**。
+- `opts.quiet` 短路返回 `streak` / `perMinute`:**正确**,单测覆盖。
+- `echo 先于发送 Promise 返回`:`appendIncoming` 认领顺序正确。
+- `发送 Promise 先于 echo 返回`:`markSent` 只改 status,echo 到达后认领替换,不重复。
+- `卡级 + 段级文案重复`:`thinking` 互斥条件与段级 `waiting` 正确。
+- 移动端与桌面「正在输入」与回合卡等待文案互斥:两端口径一致(均 `aiRows().length === 0`)。
+- `groups.ts` 删除 `LoopGuardService` 注入:无遗留引用(`hint` 是另一功能,保留正确)。
+- 硬编码色值 / `console.log` / 注释掉的旧逻辑:**未发现新增**。
+
+---
+
+## 验证建议(按优先级)
+
+1. **端到端**:桌面 + 移动各发一条「你好」,在 Matrix 服务端确认 echo 到达前后 `messages()` 始终 1 条(覆盖 B3 / 未验证项 2)。
+2. **retry**:模拟 `sendEvent` 抛错但服务端已写入(可在 Matrix 侧观察),然后 retry → 确认最终只有一条(覆盖 I2)。
+3. **新建会话**:新建 → 立即发消息 → 在 `/sync` 落地前切走再切回 → 确认不会灌入旧历史(覆盖 I1 / I3)。
+4. **回环**:3 个会话各发「你好」→ 不提示;同一会话 agent 连发 5 条「收到」→ 提示;点「继续协作」→ 不再提示;`off` → `standard` 切换后**重新检测**(覆盖 B2 第 ③ 点)。
+5. **等待文案**:等待 15 秒观察秒数是否连续跳动,Performance 面板确认 `app-message-bubble` 是否每秒重建(覆盖 B1)。
+6. **移动端**:确认 `m-loop-bar` 在安全区 / 底部栏上方不被遮挡,44px 触达。

+ 2 - 0
docs/README.md

@@ -36,6 +36,8 @@
 | [操作追踪.md](00-tracking/操作追踪.md) | 生效 | 🔴 综合日志,按动作逐条追加(⚠️ 12:43–13:46 段曾误删,已重建,见文首说明) |
 | [2026-11-08-修改留痕-会话隔离与容器状态统一.md](00-tracking/2026-11-08-修改留痕-会话隔离与容器状态统一.md) | 生效 | v1.0 · 会话隔离收尾 + 容器状态收敛到 `resolveAgentState` + 输出状态口径统一(提交 `f6f6e48`) |
 | [2026-11-08-踩坑-会话隔离与状态判定.md](00-tracking/2026-11-08-踩坑-会话隔离与状态判定.md) | 生效 | v1.0 · 两次误判的复盘 + 遗留 P0/P1/P2 清单(明日处理) |
+| [2026-11-09-修改留痕-LoopGuard与会话去重.md](00-tracking/2026-11-09-修改留痕-LoopGuard与会话去重.md) | 生效 | v1.0 · LoopGuard 按会话隔离 + 乐观消息回读认领 + 等待文案兜底 + 容器只读核查 |
+| [2026-11-09-踩坑-乐观消息回读认领.md](00-tracking/2026-11-09-踩坑-乐观消息回读认领.md) | 生效 | v1.0 · 「乐观 UI 必须有回读认领」+ 合并策略必须统一 |
 
 ### 01-requirements/ 需求文档
 | 文档 | 状态 | 说明 |

+ 2 - 0
src/app/components/domain/assistant-turn/assistant-turn.spec.ts

@@ -42,6 +42,8 @@ function turn(over: Partial<AssistantTurn> = {}): AssistantTurn {
         live: false,
         empty: false,
         merged: { key: 'a1', text: '正文', message: msg('a1', '正文') },
+        waitingSince: 0,
+        waitMs: 0,
       },
     ],
     phase: 'done',

+ 78 - 11
src/app/components/domain/assistant-turn/assistant-turn.ts

@@ -1,6 +1,6 @@
 import { ChangeDetectionStrategy, Component, OnDestroy, computed, effect, inject, input, output, signal, untracked } from '@angular/core';
 import { FileCard, Message } from '../../../core/api/models';
-import { AssistantTurn, TurnBlockView } from '../../../core/assistant-turn';
+import { AssistantTurn, TurnBlockView, waitText } from '../../../core/assistant-turn';
 import { TurnCollapseService } from '../../../core/turn-collapse.service';
 import { AssistantFilesComponent } from '../assistant-files/assistant-files';
 import { MessageBubbleComponent } from '../message-bubble/message-bubble';
@@ -125,7 +125,7 @@ import { MessageBubbleComponent } from '../message-bubble/message-bubble';
             } @else if (seg.waiting) {
               <p class="seg-wait">
                 <i class="fa-solid fa-circle-notch fa-spin" aria-hidden="true"></i>
-                {{ seg.waiting }}
+                {{ waitLabels()[seg.key] }}
               </p>
             }
           </section>
@@ -449,6 +449,13 @@ export class AssistantTurnComponent implements OnDestroy {
 
   /* ---------------- 仿流式 ---------------- */
 
+  /**
+   * 实时时钟(每秒跳一次,仅在 live 时运行)。
+   *
+   * 等待文案里的秒数由它驱动,避免「13s」停在原地不动。
+   */
+  protected readonly now = signal(Date.now());
+
   /**
    * **每个块**已「吐出」的文本长度(2026-11-07 群聊流式修复)。
    *
@@ -475,6 +482,19 @@ export class AssistantTurnComponent implements OnDestroy {
       const targets = this.targetsOf(t);
       untracked(() => this.advance(targets));
     });
+
+    /*
+     * 等待计时(2026-11-09)。
+     *
+     * 🔴 「已送达,等待回复…(13s)」里的秒数要自己走 —— 回合数据只在
+     *    上一条消息变化时重算,静默等待期间没人推进它,秒数会冻住。
+     *    只在 live 时起表,结束即清,不常驻。
+     */
+    effect(() => {
+      if (!this.turn().live) return;
+      const id = setInterval(() => this.now.set(Date.now()), 1000);
+      return () => clearInterval(id);
+    });
   }
 
   ngOnDestroy(): void {
@@ -562,20 +582,32 @@ export class AssistantTurnComponent implements OnDestroy {
      * 宁可暂时多显示,等下一轮数据回来再对齐。
      */
     const reset: Record<string, number> = { ...base };
-    let anyGrow = false;
+    /** 是否有块「还没显示完」(不是「目标变了」) */
+    let needAdvance = false;
     for (const [k, target] of Object.entries(targets)) {
       const now = reset[k];
       if (now === undefined) {
         // 新出现的块:从 0 开始(同样要流式,不直出)
         reset[k] = 0;
-        if (target > 0) anyGrow = true;
+        if (target > 0) needAdvance = true;
         continue;
       }
-      if (target > now) anyGrow = true;
+      /*
+       * 🔴 判「是否需要继续推进」,而不是「目标是否变化」(2026-11-09 修复)。
+       *
+       * 原实现用 `target > now` 判断是否启动 rAF —— 那意味着:
+       *   · 目标没变但**还没追上**(上一帧被中断 / 组件重建)→ 直接 return,
+       *     `shown` 永远停在半截;
+       *   · 渲染时 `streaming = live && n < length` 又永远走 `slice(0, n)`,
+       *     于是界面**只显示到 n 为止**,后面的内容看起来像「被覆盖掉了」。
+       *
+       * 现在:只要还没追上目标就继续推进。变短仍保持不收缩。
+       */
+      if (target > now) needAdvance = true;
       // 注意:target < now 时**保持 now**,不收缩
     }
     this.shownMap.set(reset);
-    if (!anyGrow) return;
+    if (!needAdvance) return;
 
     this.lastTick = 0;
     const tick = (now: number): void => {
@@ -631,7 +663,8 @@ export class AssistantTurnComponent implements OnDestroy {
       hue: string;
       seconds: number;
       message: Message | null;
-      waiting: string;
+      /** 没有可渲染消息时才可能显示占位;具体文案见 `waitLabels` */
+      waiting: boolean;
       streaming: boolean;
     }[] = [];
 
@@ -651,8 +684,19 @@ export class AssistantTurnComponent implements OnDestroy {
          * 取不到进度时按 0 处理,让 rAF 一帧帧补上来。
          */
         const n = shown[b.key] ?? 0;
-        streaming = b.live && n < src.text.length;
-        message = streaming ? { ...src.message, text: src.text.slice(0, n) } : src.message;
+        /*
+         * 🔴 差距过大时直接追赶,不做长距离「逐字补」(2026-11-09)。
+         *
+         * 容器一轮会分 5+ 条事件到达,相邻两条之间可能隔十几秒;
+         * 若此时 rAF 才从头追,用户会长时间只看到前半截,
+         * 观感就是「后面的内容迟迟不出现」。超过一屏的量没必要逐字演。
+         */
+        const CATCH_UP_GAP = 400;
+        const effective = src.text.length - n > CATCH_UP_GAP ? src.text.length : n;
+        streaming = b.live && effective < src.text.length;
+        message = streaming
+          ? { ...src.message, text: src.text.slice(0, effective) }
+          : src.message;
       }
 
       out.push({
@@ -661,7 +705,7 @@ export class AssistantTurnComponent implements OnDestroy {
         hue: this.hueOf(b),
         seconds: b.seconds,
         message,
-        waiting: !message ? (b.live ? '正在思考…' : b.empty ? '未响应' : '') : '',
+        waiting: !message,
         streaming,
       });
     }
@@ -675,13 +719,36 @@ export class AssistantTurnComponent implements OnDestroy {
    *    块级已有各自的「正在思考…」,两层同时出现就会显示成两条
    *    「正在思考…」(2026-11-07 用户截图实测)。
    */
+  /**
+   * 等待 / 输出中的占位文案(按块 key 索引)。
+   *
+   * 🔴 刻意**独立于 `segments`**(2026-11-09 评审修正):
+   *    这里要读每秒变化的 `now`,若放进 `segments`,整个回合的气泡对象
+   *    每秒都会重建,与上面「消息对象必须稳定复用」的约束直接冲突。
+   *    拆开后 `segments` 只随消息变化,文案单独每秒刷新。
+   */
+  protected readonly waitLabels = computed<Record<string, string>>(() => {
+    const now = this.now();
+    const out: Record<string, string> = {};
+    for (const b of this.turn().blocks) {
+      const hasOutput = b.body.length > 0 || b.steps.length > 0;
+      const elapsed = b.waitingSince > 0 ? Math.max(0, now - b.waitingSince) : 0;
+      const text = waitText(elapsed, hasOutput, b.live, b.empty);
+      if (text) out[b.key] = text;
+    }
+    return out;
+  });
+
   protected readonly thinking = computed(() => {
     const t = this.turn();
     if (t.merged) return '';
     // 只要还有任何块会渲染成段(有内容或 live),就交给段级提示
     const anySeg = t.blocks.some((b) => b.merged || b.live || b.empty);
     if (anySeg) return '';
-    return t.live ? '正在思考…' : '';
+    const b = t.blocks[0];
+    if (!b) return '';
+    // 与段级共用同一份文案表,避免两套判据
+    return this.waitLabels()[b.key] ?? '';
   });
 
   protected toggle(): void {

+ 15 - 7
src/app/components/domain/new-session-modal/new-session-modal.ts

@@ -6,8 +6,8 @@ import { LiveService } from '../../../core/live.service';
 import { NewSessionModalService } from './new-session-modal.service';
 import { WorkspaceService } from '../../../core/workspace.service';
 import { GroupChatService } from '../../../core/group-chat.service';
-import { Agent } from '../../../core/api/models';
-import { resolveAgentState, AgentStateView } from '../../../core/agent-state';
+import { Agent, SemanticTone } from '../../../core/api/models';
+import { agentLayers } from '../../../core/agent-state';
 
 /**
  * 「新建对话」模态框(全局单例,挂在外壳上)
@@ -315,13 +315,21 @@ export class NewSessionModalComponent {
   );
 
   /**
-   * 容器状态(2026-11-08):全站唯一口径 `resolveAgentState`。
+   * 容器**是否在线**(2026-11-09 用户裁决)。
    *
-   * 不自己拼 `statusText ?? status` —— 那会把 45 天没心跳的僵尸容器显示成「正在执行」,
-   * 也会把英文 `running` 原样露给用户。
+   * 🔴 用户原话:「新建对话这里只需要显示容器是否在线就行,不需要显示容器的状态,
+   *    容器的状态是在会话页面显示的」。
+   *
+   * 所以这里只回答一个问题:**能不能跟它说话**。
+   * 判据用三层模型:心跳活着(`alive`)+ 通道通(`link === 'ready'`)。
+   * 不再输出「正在执行 / 空闲」——那是会话页的职责。
    */
-  protected stateOf(a: Agent): AgentStateView {
-    return resolveAgentState({ agent: a });
+  protected stateOf(a: Agent): { text: string; tone: SemanticTone } {
+    const l = agentLayers(a);
+    const online = l.alive !== 'dead' && l.alive !== 'stale' && l.link === 'ready';
+    return online
+      ? { text: '在线', tone: 'success' }
+      : { text: '离线', tone: 'danger' };
   }
 
   protected createIn(agentId: string): void {

+ 18 - 11
src/app/components/domain/presence-badge/presence-badge.ts

@@ -4,9 +4,9 @@ import {
   AGENT_PRESENCE_TEXT,
   Agent,
   AgentPresence,
-  agentPresence,
   agentPresenceTone,
 } from '../../../core/api/models';
+import { agentLayers } from '../../../core/agent-state';
 
 /**
  * 数字生命「可对话状态」徽标 —— **配色 + 文字**(用户明确要求)
@@ -20,9 +20,13 @@ import {
  *
  * ## 状态口径
  *
- * 判定唯一实现在 `models.agentPresence()` —— 以**接入态**(能否对话)为准,
- * 不与「运行态」(`Agent.status`,reporter 历史值)混用。
- * 通讯录 / 会话列表 / 工作台 / 详情**四处统一**走本组件。
+ * 🔴 统一走 `agentLayers()`(2026-11-09)。
+ *
+ * 此前 PC 通讯录用接入态(`agentPresence`),移动端用合成态(`resolveAgentState`)——
+ * 同一个容器 PC 说「未接入」、手机说「离线」,用户直接看到两套说法。
+ *
+ * 现在三层拆开判:`alive`(心跳)/ `link`(能否对话)/ `busy`(在不在干活),
+ * 文案按「先说死了没,再说通不通」排,各端一致。
  */
 @Component({
   selector: 'app-presence-badge',
@@ -43,8 +47,8 @@ export class PresenceBadgeComponent {
   readonly agent = input<Agent | undefined>(undefined);
   readonly presence = input<AgentPresence | undefined>(undefined);
 
-  /** 最终状态:显式传入优先,否则按 agent 判定 */
-  readonly state = computed<AgentPresence>(() => this.presence() ?? agentPresence(this.agent()));
+  /** 显式传入的状态优先;否则按三层模型判定 */
+  private readonly layers = computed(() => agentLayers(this.agent()));
 
   /**
    * 语义色 → 底座徽标 variant。
@@ -53,11 +57,14 @@ export class PresenceBadgeComponent {
    *    故此处映射到 `'secondary'`(同样用于「弱化/中性」表达)。
    */
   readonly tone = computed<'success' | 'warning' | 'secondary'>(() => {
-    const t = agentPresenceTone(this.state());
-    if (t === 'success') return 'success';
-    if (t === 'warning') return 'warning';
-    return 'secondary';
+    // 显式传入旧口径时沿用旧映射,避免调用方大面积改签名
+    if (this.presence()) {
+      const t = agentPresenceTone(this.presence()!);
+      return t === 'success' ? 'success' : t === 'warning' ? 'warning' : 'secondary';
+    }
+    const t = this.layers().tone;
+    return t === 'success' ? 'success' : t === 'warning' ? 'warning' : 'secondary';
   });
 
-  readonly text = computed(() => AGENT_PRESENCE_TEXT[this.state()]);
+  readonly text = computed(() => this.presence() ? AGENT_PRESENCE_TEXT[this.presence()!] : this.layers().text);
 }

+ 58 - 4
src/app/components/domain/session-tree/session-tree.ts

@@ -1,5 +1,5 @@
 import { NgTemplateOutlet } from '@angular/common';
-import { ChangeDetectionStrategy, Component, computed, inject, input, output, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, OnDestroy, computed, inject, input, output, signal } from '@angular/core';
 import { NavigationEnd, Router, RouterLink } from '@angular/router';
 import { effect } from '@angular/core';
 import { UiAvatar, UiButton, UiEmpty, UiInput, UiModal, UiSkeleton, UiSwitch, UiTooltip } from '../../../ui';
@@ -428,6 +428,19 @@ export interface NewSessionRequest {
                 <p class="sect-hint">日常新建的对话会出现在这里。</p>
               }
 
+              <!--
+                新建会话推送失败提示(2026-11-09 用户要求「推送失败要报错」)。
+                原来静默吞掉:会话只存在于本机,换浏览器永远看不到,用户毫不知情。
+              -->
+              @if (pushFailed(); as pf) {
+                <div class="sect-error" role="alert">
+                  <p>{{ pf }}</p>
+                  <button type="button" class="retry" (click)="retryPush()">
+                    <i class="fa-solid fa-rotate-right" aria-hidden="true"></i> 重新同步
+                  </button>
+                </div>
+              }
+
               @for (s of daily(); track s.id) {
                 <ng-container *ngTemplateOutlet="row; context: { $implicit: s, owner: null }"></ng-container>
               }
@@ -1767,7 +1780,7 @@ export interface NewSessionRequest {
     `,
   ],
 })
-export class SessionTreeComponent {
+export class SessionTreeComponent implements OnDestroy {
   private readonly sessionsSvc = inject(ChatSessionService);
   private readonly workspaceSvc = inject(WorkspaceService);
   private readonly navMenu = inject(NavMenuService);
@@ -1781,6 +1794,23 @@ export class SessionTreeComponent {
   private readonly session = inject(SessionService);
   private readonly router = inject(Router);
   private readonly device = inject(DeviceService);
+  /** 窗口对象(单测里可替换,避免直接摸全局) */
+  private readonly win = window;
+  /**
+   * 窗口重新获得焦点 → 重拉会话目录(2026-11-09)。
+   *
+   * 用户诉求:换浏览器 / 换标签页回来必须看到最新数据。
+   * 只拉「确实变化了」的东西,不做定时轮询。
+   */
+  private readonly onWindowFocus = (): void => {
+    if (!this.session.isLoggedIn()) return;
+    const ids = this.liveSvc
+      .agents()
+      .filter((a) => a.matrixReady)
+      .map((a) => a.id);
+    if (ids.length === 0) return;
+    void this.sessionsSvc.reloadAll(ids);
+  };
   /** 与 chat 页共用同一实例:打开会话走同一套 URL 规则 */
   protected readonly chat = inject(ChatService);
 
@@ -1928,8 +1958,7 @@ export class SessionTreeComponent {
      *    而且 localStorage 里根本没有对应键(create 时 key() 有值却没人 load 过)。
      */
     this.workspaceSvc.load();
-    this.navMenu.load();
-    // 会话字号在首帧前落位,避免进入会话页时字号"跳一下"
+    this.navMenu.load();    // 会话字号在首帧前落位,避免进入会话页时字号"跳一下"
     this.chatFont.load();
     /*
      * 会话目录预热(2026-11-01)。
@@ -1951,6 +1980,18 @@ export class SessionTreeComponent {
       this.loadedAgentsSig = sig;
       void this.sessionsSvc.reloadAll(ids);
     });
+
+    /*
+     * 🔴 窗口重新获得焦点时重拉会话目录(2026-11-09 用户要求)。
+     *
+     * 用户的诉求:「在一个浏览器操作了,去另一个浏览器看,必须看到最新的」。
+     *
+     * 原来的 effect 只在**容器清单变化**时拉一次 —— 容器清单基本不变,
+     * 于是换标签页 / 换浏览器回来时永远不会重新拉,看到的是上一次的数据。
+     *
+     * 用 focus 而不是定时轮询:只在用户真正回来时才发请求,零空转流量。
+     */
+    this.win.addEventListener('focus', this.onWindowFocus);
     // 数据/尺寸变化后重测溢出(工作区与会话列表都是信号,变化即重算)
     effect(() => {
       this.workspaces();
@@ -2015,6 +2056,8 @@ export class SessionTreeComponent {
   /** 会话目录是否可信(false = 云端拉取失败,列表不可展示) */
   protected readonly sessionsReady = this.sessionsSvc.ready;
   protected readonly sessionsError = this.sessionsSvc.error;
+  /** 新建会话推送云端失败的原因(2026-11-09) */
+  protected readonly pushFailed = this.sessionsSvc.pushFailed;
 
   /**
    * 重试拉取:按当前打开的容器再拉一次;没有打开任何容器时拉全部容器的列表。
@@ -2388,4 +2431,15 @@ export class SessionTreeComponent {
   protected unreadOf(agentId: string): number {
     return this.unreadSvc.countOf(agentId);
   }
+
+  /** 重新同步失败的待推会话(用户点「重新同步」) */
+  protected retryPush(): void {
+    const id = this.chat.agentId();
+    if (id) void this.sessionsSvc.pushAll(id);
+  }
+
+  ngOnDestroy(): void {
+    // 组件销毁时必须摘掉监听,否则切页面后旧的实例仍在响应 focus
+    this.win.removeEventListener('focus', this.onWindowFocus);
+  }
 }

+ 124 - 4
src/app/core/agent-state.spec.ts

@@ -1,4 +1,4 @@
-import { resolveAgentState, isAgentUsable } from './agent-state';
+import { resolveAgentState, isAgentUsable, agentLayers, sessionStateOf, sessionRunning } from './agent-state';
 import { Agent } from './api/models';
 
 /**
@@ -76,9 +76,13 @@ describe('resolveAgentState', () => {
     expect(v.state).toBe('running');
   });
 
-  it('心跳刚过期(超过 10 分钟)就判离线', () => {
-    expect(resolveAgentState({ agent: agent({ heartbeatAgeSec: 601 }) }).state).toBe('offline');
-    expect(resolveAgentState({ agent: agent({ heartbeatAgeSec: 599 }) }).state).toBe('idle');
+  /*
+   * 阈值 2026-11-09 用户裁决改为 80 分钟:
+   * 容器上报周期是 1 小时 + 0~15 分钟随机抖动,卡着 1 小时判会频繁闪红。
+   */
+  it('心跳超过 80 分钟判离线,80 分钟以内仍算「能连」', () => {
+    expect(resolveAgentState({ agent: agent({ heartbeatAgeSec: 81 * 60 }) }).state).toBe('offline');
+    expect(resolveAgentState({ agent: agent({ heartbeatAgeSec: 75 * 60 }) }).state).toBe('idle');
   });
 
   it('没有心跳字段时回落到 heartbeatAt', () => {
@@ -163,3 +167,119 @@ describe('isAgentUsable', () => {
     expect(isAgentUsable(resolveAgentState({ agent: agent(), phase: 'replying' }))).toBe(true);
   });
 });
+
+/**
+ * 三层状态(2026-11-09 用户裁决)。
+ *
+ * 用户明确要求:「拆成『连接』和『执行』两个状态」。
+ * 实测问题:同一容器 PC 显示「不在线」、移动端显示「在线·空闲」,而它其实能正常回复。
+ */
+describe('agentLayers(三层:存活 / 连接 / 执行)', () => {
+  const NOW = 1_800_000_000_000;
+
+  it('没有 agent → 全部不可用', () => {
+    const v = agentLayers(null, NOW);
+    expect(v.alive).toBe('dead');
+    expect(v.link).toBe('unready');
+    expect(v.busy).toBeFalse();
+  });
+
+  it('🔴 心跳 45 天但 status=running → 判已离线(不能信 status)', () => {
+    const v = agentLayers(agent({ status: 'running', heartbeatAgeSec: 3_914_610 }), NOW);
+    expect(v.alive).withContext('必须死').toBe('dead');
+    expect(v.busy).withContext('死了不可能在干活').toBeFalse();
+    expect(v.text).toBe('已离线');
+  });
+
+  /*
+   * 阈值按用户裁决改为 1 小时粒度(2026-11-09):
+   * 容器改成 1 小时 + 随机错峰上报,不再需要「2 分钟 / 10 分钟」这种精细判据。
+   */
+  it('心跳 100 分钟 → 可疑(状态未同步),不是「正在执行」', () => {
+    const v = agentLayers(agent({ status: 'running', heartbeatAgeSec: 100 * 60 }), NOW);
+    expect(v.alive).toBe('stale');
+    expect(v.text).toBe('状态未同步');
+  });
+
+  it('🔴 心跳 75 分钟 → 仍算存活(上报周期 1h+0~15min,不能卡着 1 小时判死)', () => {
+    const v = agentLayers(agent({ status: 'idle', heartbeatAgeSec: 75 * 60 }), NOW);
+    expect(v.alive).toBe('alive');
+    expect(v.text).toBe('空闲');
+  });
+
+  it('心跳新鲜 + running → 正在执行', () => {
+    const v = agentLayers(agent({ status: 'running', heartbeatAgeSec: 30 }), NOW);
+    expect(v.alive).toBe('alive');
+    expect(v.link).toBe('ready');
+    expect(v.busy).toBeTrue();
+    expect(v.text).toBe('正在执行');
+  });
+
+  it('心跳新鲜 + idle → 空闲', () => {
+    const v = agentLayers(agent({ status: 'idle', heartbeatAgeSec: 30 }), NOW);
+    expect(v.busy).toBeFalse();
+    expect(v.text).toBe('空闲');
+  });
+
+  it('🔴 存活但未接通 → 显示「未接通」,不再让各端各说各话', () => {
+    const v = agentLayers(agent({ matrixReady: false, heartbeatAgeSec: 30 }), NOW);
+    expect(v.alive).toBe('alive');
+    expect(v.link).toBe('unready');
+    expect(v.text).toBe('未接通');
+  });
+
+  it('上报器自说自话的 running 不算在干活', () => {
+    const v = agentLayers(
+      agent({
+        status: 'running',
+        heartbeatAgeSec: 30,
+        currentAction: 'reporter heartbeat @ 12:00',
+        currentTask: 'periodic status reporter (xlosi3m46q)',
+      }),
+      NOW,
+    );
+    expect(v.busy).toBeFalse();
+  });
+
+  it('心跳时间戳形式也能判(没有 heartbeatAgeSec 时回落 heartbeatAt)', () => {
+    const v = agentLayers(
+      agent({ status: 'running', heartbeatAt: new Date(NOW - 30_000).toISOString() }),
+      NOW,
+    );
+    expect(v.alive).toBe('alive');
+    expect(v.text).toBe('正在执行');
+  });
+});
+
+/**
+ * 会话级状态匹配(2026-11-09 方案 A)。
+ *
+ * 容器上报 `status` 只有全局一个值,同一容器多会话会互相串状态。
+ * 容器改为在 `extra.currentSessions` 里按 session_key 上报。
+ */
+describe('sessionStateOf / sessionRunning(会话级状态)', () => {
+  const APP_SID = '$approot_mw7l1ezj_wnuym1jt:fmode.cn';
+  const SESSIONS = [
+    { sessionKey: 'agent:main:matrix:dm:!room1:other_thread_xxx', status: 'idle' as const },
+    { sessionKey: `agent:main:matrix:dm:!room1:${APP_SID}`, status: 'running' as const },
+  ];
+
+  it('能从容器 sessionKey 里匹配到当前会话', () => {
+    const s = sessionStateOf(SESSIONS, APP_SID);
+    expect(s?.sessionKey).toContain('wnuym1jt');
+    expect(s?.status).toBe('running');
+  });
+
+  it('只为**当前**会话报在跑,别的会话不影响', () => {
+    expect(sessionRunning(SESSIONS, APP_SID)).toBeTrue();
+    expect(sessionRunning(SESSIONS, '$approot_zzz_other:fmode.cn')).toBeUndefined();
+  });
+
+  it('容器没上报会话列表 → undefined(调用方回落全局 status)', () => {
+    expect(sessionRunning(undefined, APP_SID)).toBeUndefined();
+  });
+
+  it('没打开会话 → undefined', () => {
+    expect(sessionRunning(SESSIONS, null)).toBeUndefined();
+  });
+});

+ 147 - 2
src/app/core/agent-state.ts

@@ -1,4 +1,4 @@
-import { Agent, AgentStatus, SemanticTone, agentPresence } from './api/models';
+import { Agent, AgentSessionState, AgentStatus, SemanticTone, agentPresence } from './api/models';
 import { MemberPhase } from './group-live.service';
 
 /**
@@ -69,7 +69,17 @@ const BROKEN: ReadonlySet<AgentStatus> = new Set<AgentStatus>(['error', 'stopped
  * reporter 每 30 秒一次心跳;给 10 分钟宽限,
  * 既不会被网络抖动误判,又能及时反映真实掉线。
  */
-export const HEARTBEAT_STALE_SEC = 10 * 60;
+/**
+ * 心跳超时阈值(2026-11-09 用户裁决:改为 1 小时粒度)。
+ *
+ * 用户原话:「不需要实现很新鲜的程度,比如 2 分钟、10 分钟,默认都是一小时同步一次」。
+ *
+ * 🔴 这里必须和 `agentLayers` 的 `ALIVE_STALE_SEC` **保持一致**。
+ *    原来这里是 10 分钟、`agentLayers` 是 1 小时,两套并存 ——
+ *    结果新建弹窗(走本常量)把心跳 13 分钟的容器判成「离线」,
+ *    而别的页面(走 agentLayers)判成「在线」,同屏自相矛盾(用户实测)。
+ */
+export const HEARTBEAT_STALE_SEC = 80 * 60;
 
 /**
  * 心跳是否已过期。
@@ -182,3 +192,138 @@ export function runningWithoutTask(agent: Agent): boolean {
 export function isAgentUsable(v: AgentStateView): boolean {
   return v.state !== 'offline' && v.state !== 'error';
 }
+
+/* ------------------------------------------------------------------ */
+/* 三层状态视图(2026-11-09)                                          */
+/* ------------------------------------------------------------------ */
+
+/**
+ * 数字生命的**三层独立状态**。
+ *
+ * 用户裁决(2026-11-09):不能把「它还在吗 / 能不能发 / 它在忙吗」合成一个
+ * 「在线 / 离线」——那会导致同一个容器在不同页面结论相反(实测:PC 显示
+ * 「不在线」、移动端显示「在线·空闲」,而它其实能正常回复)。
+ *
+ * | 层 | 回答什么 | 关键判据 |
+ * |---|---|---|
+ * | `alive` | 容器还活着吗 | **心跳新鲜度**(不是 status,45 天没心跳也会报 running) |
+ * | `link`  | 消息通道通不通 | `matrixReady`(能不能把消息发出去) |
+ * | `busy`  | 它现在在干活吗 | 容器自报 running(**仅作全局角标**,不代表某个会话) |
+ *
+ * 🔴 `busy` 是**整个容器**的状态,不是某个会话的。
+ *    同一容器的多个会话共用它 —— 具体某个会话的进度必须另算
+ *    (见 `sessionProgress()`)。
+ */
+export interface AgentLayers {
+  alive: 'alive' | 'stale' | 'dead';
+  link: 'ready' | 'unready';
+  /** 容器自报在干活(全局,不代表当前会话) */
+  busy: boolean;
+  /** 一句人话,给徽标用 */
+  text: string;
+  tone: SemanticTone;
+}
+
+/**
+ * 心跳阈值(2026-11-09 用户裁决:改为一小时粒度)。
+ *
+ * 用户原话:「不需要实现很新鲜的程度,比如 2 分钟、10 分钟,默认都是一小时同步一次」。
+ *
+ * 容器上报周期 = **1 小时 + 0~15 分钟随机错峰**(2026-11-09 用户确认),
+ * 所以判死阈值必须宽于这个周期:
+ *   · ≤ 1 小时 20 分   存活
+ *   · 1.5 ~ 2 小时     可疑(可能刚好错过一轮)
+ *   · > 2 小时         视为已离线
+ *
+ * 🔴 阈值取 80 分钟(1 小时 20 分)而不是严格 1 小时:
+ *    上报本身就带 0~15 分钟抖动,卡着 1 小时判会频繁闪红 ——
+ *    用户实测就踩到了(心跳 13 分钟被旧代码判成离线)。
+ *
+ * 🔴 代价:容器真挂了,最坏要 **2 小时**才能发现(原来 30 秒心跳时是 10 分钟)。
+ *    这是用户明确接受的取舍 —— 换来的是上报流量大幅下降。
+ */
+const ALIVE_STALE_SEC = HEARTBEAT_STALE_SEC;
+/** 心跳超过这个时长视为「已死」:容忍错过一轮 + 一半余量 */
+const ALIVE_DEAD_SEC = 120 * 60;
+
+function heartbeatAgeSec(a: Agent, now: number): number | null {
+  if (typeof a.heartbeatAgeSec === 'number' && Number.isFinite(a.heartbeatAgeSec)) {
+    return a.heartbeatAgeSec;
+  }
+  if (!a.heartbeatAt) return null;
+  const t = Date.parse(a.heartbeatAt);
+  return Number.isFinite(t) ? (now - t) / 1000 : null;
+}
+
+/**
+ * 算三层状态。
+ *
+ * @param now 注入当前时间,便于单测
+ */
+export function agentLayers(agent: Agent | null | undefined, now = Date.now()): AgentLayers {
+  if (!agent) {
+    return { alive: 'dead', link: 'unready', busy: false, text: '未接入', tone: 'neutral' };
+  }
+  const age = heartbeatAgeSec(agent, now);
+  const alive: AgentLayers['alive'] =
+    age === null ? 'alive' : age > ALIVE_DEAD_SEC ? 'dead' : age > ALIVE_STALE_SEC ? 'stale' : 'alive';
+  const link: AgentLayers['link'] = agentPresence(agent) === 'offline' ? 'unready' : 'ready';
+
+  /*
+   * busy 只认「容器在跑且不是说自己的事」。
+   * 上报器自指(reporter heartbeat)不算干活 —— 见 isReporterSelfTalk。
+   */
+  const busy = alive !== 'dead' && agent.status === 'running' && !isReporterSelfTalk(agent);
+
+  // 文案按「先说死了没,再说通不通,最后说忙不忙」排,保证最重要的信息在前
+  if (alive === 'dead') return { alive, link, busy: false, text: '已离线', tone: 'danger' };
+  if (alive === 'stale') return { alive, link, busy, text: '状态未同步', tone: 'warning' };
+  if (link === 'unready') return { alive, link, busy, text: '未接通', tone: 'warning' };
+  if (agent.status === 'error') return { alive, link, busy, text: '异常', tone: 'danger' };
+  return { alive, link, busy, text: busy ? '正在执行' : '空闲', tone: busy ? 'success' : 'neutral' };
+}
+
+/* ------------------------------------------------------------------ */
+/* 会话级状态(2026-11-09,方案 A)                                     */
+/* ------------------------------------------------------------------ */
+
+/**
+ * 从容器上报的会话列表里,找出**当前 App 会话**对应的那一条。
+ *
+ * 🔴 两边的标识格式不同,必须容错匹配:
+ *
+ * ```
+ * App 会话 id      $approot_mw7l1ezj_wnuym1jt:fmode.cn     ← Matrix thread 根 event_id
+ * 容器 sessionKey  agent:main:matrix:dm:!roomId:threadId    ← build_session_key()
+ * ```
+ *
+ * 所以判据是「容器的 sessionKey 里**包含** App 的会话 id 主体」——
+ * 容器把 thread_id 拼在 key 的末尾,而 thread_id 就是 App 造的根 event_id。
+ *
+ * 🔴 这条匹配是否成立**必须实测**(见方案文档「待确认」)。
+ *    匹配不到就返回 undefined,调用方回落到全局状态,不显示「未知」。
+ */
+export function sessionStateOf(
+  sessions: AgentSessionState[] | undefined,
+  appSessionId: string | null | undefined,
+): AgentSessionState | undefined {
+  if (!sessions?.length || !appSessionId) return undefined;
+  // 去掉 App 会话 id 的域名后缀,只留主体($approot_xxx)
+  const core = appSessionId.split(':')[0];
+  if (!core) return undefined;
+  return sessions.find((s) => s.sessionKey.includes(core) || s.sessionKey.includes(appSessionId));
+}
+
+/**
+ * 当前会话是否正在跑。
+ *
+ * 容器没上报会话级状态时返回 `undefined`(**不知道**),
+ * 由调用方决定回落到什么 —— 不能当作「空闲」,也不能当作「在跑」。
+ */
+export function sessionRunning(
+  sessions: AgentSessionState[] | undefined,
+  appSessionId: string | null | undefined,
+): boolean | undefined {
+  const s = sessionStateOf(sessions, appSessionId);
+  return s ? s.status === 'running' : undefined;
+}

+ 41 - 1
src/app/core/api/models.ts

@@ -91,6 +91,25 @@ export interface UsageSession {
   startedAt?: number;
 }
 
+/**
+ * 容器上报的**单个会话**运行状态(2026-11-09,方案 A)。
+ *
+ * 由容器在 `pre_llm_call` / `post_llm_call` 钩子里维护,写进 `extra.currentSessions`。
+ * `sessionKey` 是容器的 `build_session_key()` 产物,形如:
+ * `agent:main:matrix:dm:<!roomId>[:<threadId>]`
+ *
+ * 🔴 App 的会话 id 是 Matrix 的 **thread 根 event_id**,与这里的 `sessionKey`
+ *    是否对得上**必须实测验证** —— 对不上就是「报了但匹配不到会话」。
+ */
+export interface AgentSessionState {
+  /** 容器的 session_key 原文 */
+  sessionKey: string;
+  /** 该会话当前是否在跑 */
+  status: 'running' | 'idle';
+  /** 本轮开始的 epoch 毫秒(idle 时可缺省) */
+  turnStartAt?: number;
+}
+
 export interface Person {
   id: string;
   name: string;
@@ -149,7 +168,20 @@ export interface Agent {
   owner?: Person;
   companyId?: string;
   /**
-   * 所属部门 —— **预留字段**(2026-11-04)。
+   * 会话级运行状态(2026-11-09,方案 A)。
+   *
+   * 🔴 为什么需要:容器上报的 `status` 是**整个容器一个值** ——
+   *    同一容器的多个会话共用它,于是「会话 A 在跑」时,会话 B 的界面也显示
+   *    「正在执行」(用户实测)。
+   *
+   * 容器把每个会话的状态塞进 `extra.currentSessions`(`extra` 是平台
+   * 云函数字段白名单里的合法字段,无需改云函数),这里解析出来供按会话展示。
+   *
+   * 容器尚未上报时为 `undefined` —— 调用方必须回落到全局 `status`,
+   * 不能因为缺这个字段就显示「未知」。
+   */
+  currentSessions?: AgentSessionState[];
+  /** 所属部门 —— **预留字段**(2026-11-04)。
    * 服务端暂无此维度,恒为 undefined;通讯录归入「未分组」。
    */
   department?: string;
@@ -234,6 +266,14 @@ export interface Message {
   state: MessageState;
   /** 失败原因(AC-04.8:失败消息不丢失,可重试) */
   error?: string;
+  /**
+   * 发送时写入 Matrix 事件的本地 id(`com.fmode.local`)。
+   *
+   * 服务端 echo 会原样带回它 —— 回读时据此**认领**本地那条乐观消息,
+   * 避免同一句话同时以「本地一条 + 回读一条」出现两遍(2026-11-09)。
+   * 容器 / 历史事件没有这个字段。
+   */
+  localId?: string;
 }
 
 // ---------- 成果与项目 ----------

+ 58 - 0
src/app/core/api/normalize.spec.ts

@@ -30,6 +30,64 @@ describe('parse-api 字段归一(兼容非标准容器)', () => {
     api = new ParseApi(() => 'r:t', () => 'all');
   });
 
+
+  /*
+   * 会话级状态(2026-11-09 方案 A)。
+   *
+   * 容器把每个会话的状态写进 `extra.currentSessions`(`extra` 已在平台白名单里,
+   * 无需改云函数)。形状不对必须忽略 —— 不能让解析失败把状态显示成「未知」。
+   */
+  describe('extra.currentSessions → currentSessions', () => {
+    it('正常形态:解析出会话列表', async () => {
+      stubFetch({
+        code: 200,
+        agents: [
+          {
+            agentId: 'a',
+            extra: JSON.stringify({
+              currentSessions: [
+                { sessionKey: 'agent:main:matrix:dm:!r:$approot_abc', status: 'running', turnStartAt: 1000 },
+                { sessionKey: 'agent:main:matrix:dm:!r:$approot_def', status: 'idle' },
+              ],
+            }),
+          },
+        ],
+      });
+      const [a] = await api.fetchAgents();
+      expect(a.currentSessions?.length).toBe(2);
+      expect(a.currentSessions?.[0].status).toBe('running');
+      expect(a.currentSessions?.[0].turnStartAt).toBe(1000);
+    });
+
+    it('没有 extra / 没有该键 → undefined(回落全局 status)', async () => {
+      stubFetch({ code: 200, agents: [{ agentId: 'a' }] });
+      const [a] = await api.fetchAgents();
+      expect(a.currentSessions).toBeUndefined();
+    });
+
+    it('🔴 形状不对一律忽略(不抛错、不产生脏数据)', async () => {
+      stubFetch({
+        code: 200,
+        agents: [
+          {
+            agentId: 'a',
+            extra: JSON.stringify({
+              currentSessions: [
+                null,
+                'oops',
+                { status: 'running' }, // 缺 sessionKey
+                { sessionKey: 'ok', status: 'weird' }, // status 非法 → 归一为 idle
+              ],
+            }),
+          },
+        ],
+      });
+      const [a] = await api.fetchAgents();
+      expect(a.currentSessions?.length).withContext('只保留带 sessionKey 的').toBe(1);
+      expect(a.currentSessions?.[0].status).withContext('非法状态归一为 idle').toBe('idle');
+    });
+  });
+
   describe('channels 数组 → 对象', () => {
     it('["wecom"] → {wecom:true}(matrix 未点亮)', async () => {
       stubFetch({ code: 200, agents: [{ agentId: 'a', channels: '["wecom"]' }] });

+ 32 - 0
src/app/core/api/parse-api.ts

@@ -854,6 +854,38 @@ function mapAgent(raw: Record<string, unknown>): Agent {
     heartbeatAgeSec:
       typeof raw['heartbeatAgeSec'] === 'number' ? (raw['heartbeatAgeSec'] as number) : undefined,
     updatedAt: raw['updatedAt'] ? String(raw['updatedAt']) : undefined,
+    /*
+     * 会话级状态(2026-11-09,方案 A)。
+     *
+     * 容器上报的 `status` 是**整个容器一个值**,同一容器的多个会话共用,
+     * 导致「会话 A 在跑」时会话 B 也显示正在执行(用户实测)。
+     * 容器把每个会话的状态写进 `extra.currentSessions`(`extra` 已在平台白名单里)。
+     *
+     * 形状不对一律忽略 —— 宁可回落到全局 status,也不能因解析失败显示「未知」。
+     */
+    currentSessions: (() => {
+      const ex = parseJsonLoose<Record<string, unknown>>(raw['extra']);
+      const list = ex?.['currentSessions'];
+      if (!Array.isArray(list)) return undefined;
+      const out = list.flatMap((it) => {
+        if (!it || typeof it !== 'object') return [];
+        const s = it as Record<string, unknown>;
+        const key = typeof s['sessionKey'] === 'string' ? s['sessionKey'] : '';
+        if (!key) return [];
+        const st = s['status'] === 'running' ? 'running' : 'idle';
+        return [
+          {
+            sessionKey: key,
+            status: st as 'running' | 'idle',
+            turnStartAt:
+              typeof s['turnStartAt'] === 'number' && Number.isFinite(s['turnStartAt'])
+                ? s['turnStartAt']
+                : undefined,
+          },
+        ];
+      });
+      return out.length ? out : undefined;
+    })(),
     host,
     balance: balance
       ? {

+ 36 - 1
src/app/core/assistant-turn.spec.ts

@@ -1,7 +1,7 @@
 import { GroupBlock, GroupTurn } from './group-turns';
 import { Message } from './api/models';
 import { Turn } from './turn-aggregator';
-import { turnsFromDm, turnsFromGroup } from './assistant-turn';
+import { turnsFromDm, turnsFromGroup, waitText } from './assistant-turn';
 
 /**
  * 回合视图模型单测(2026-11-06)
@@ -253,3 +253,38 @@ describe('单聊与群聊的结构一致性', () => {
     expect(dm.phase).toBe(gp.phase);
   });
 });
+
+/**
+ * 等待文案(2026-11-09 用户裁决)。
+ *
+ * 容器回一轮要十几秒。原来界面上只有「正在思考…」,没有时长也没有阶段,
+ * 用户分不清是「发出去了在等」还是「根本没发出去」。
+ */
+describe('waitText(等待 / 输出占位文案)', () => {
+  it('刚开始等待(< 8s)→ 正在思考…', () => {
+    expect(waitText(0, false, true, false)).toBe('正在思考…');
+    expect(waitText(7_000, false, true, false)).toBe('正在思考…');
+  });
+
+  it('超过 8s → 明确告诉用户「已送达,等待回复」并带秒数', () => {
+    expect(waitText(8_000, false, true, false)).toBe('已送达,等待回复…(8s)');
+    expect(waitText(13_000, false, true, false)).toBe('已送达,等待回复…(13s)');
+  });
+
+  it('🔴 等待文案一直兜底,不会因为等得久就消失', () => {
+    expect(waitText(60_000, false, true, false)).toContain('等待回复');
+    expect(waitText(300_000, false, true, false)).toContain('等待回复');
+  });
+
+  it('开始输出 → 切换为「正在输出」并带秒数', () => {
+    expect(waitText(13_000, true, true, false)).toBe('正在输出…(13s)');
+  });
+
+  it('已结束且有内容 → 不显示占位', () => {
+    expect(waitText(20_000, true, false, false)).toBe('');
+  });
+
+  it('已结束且什么也没有 → 未响应', () => {
+    expect(waitText(30_000, false, false, true)).toBe('未响应');
+  });
+});

+ 86 - 2
src/app/core/assistant-turn.ts

@@ -75,6 +75,15 @@ export interface TurnBlockView {
    * 单聊只有一块,用回合级的 `merged` 即可。
    */
   merged: TurnMergedBody | null;
+  /** 用户发出这条消息的时刻(毫秒)。0 = 无锚点(容器主动发言) */
+  waitingSince: number;
+  /**
+   * 构建这一帧时,距用户发消息已经等了多久(毫秒)。
+   *
+   * 🔴 组件渲染用 `waitingSince` + 实时时钟现算,`waitMs` 只是构建快照,
+   *    供纯函数与测试使用(2026-11-09)。
+   */
+  waitMs: number;
 }
 
 export type TurnPhase = 'thinking' | 'replying' | 'done' | 'failed' | 'idle';
@@ -320,7 +329,23 @@ function proseOf(text: string): boolean {
  */
 const DEFAULT_QUIET_MS = 25_000;
 
-function toBlockView(src: BlockSource, now: number, quietMs: number): TurnBlockView {
+function toBlockView(
+  src: BlockSource,
+  now: number,
+  quietMs: number,
+  waitingSince = 0,
+  /**
+   * 用户已发出消息、但容器还一条都没回(2026-11-09)。
+   *
+   * 🔴 为什么需要独立标志:
+   *    这类回合 `blocks` 为空 → `updatedAt = 0` → `idleFor` 极大 → `live` 恒 false,
+   *    于是回合卡根本不会生成,界面在发送后**什么状态都不显示**,
+   *    只能干等容器约 10 秒后第一次回话(用户实测「10 秒后才显示正在思考」)。
+   *
+   *    但「已发出」本身就是一个确定的状态,应该立刻可见。
+   */
+  awaitingFirstReply = false,
+): TurnBlockView {
   const { steps, body } = splitIntoParts(src.messages);
   const files = body
     .filter((b): b is Extract<TurnBodyBlock, { kind: 'file' }> => b.kind === 'file')
@@ -329,8 +354,18 @@ function toBlockView(src: BlockSource, now: number, quietMs: number): TurnBlockV
   /*
    * live 判定与 `isTurnOpen` 同口径:容器正在 typing,或末条消息至今未超过静默窗口。
    * 群聊里某个成员可能先回完,所以按**块**判断而不是按回合。
+   *
+   * 🔴 typing 不能无限期作数(2026-11-09)。
+   *
+   * 实测:容器一轮结束后**不会**发 `m.typing:false`,`peerTyping` 一直停在 true。
+   * 这时 `live` 恒为真 → 回合永远「输出中」、永远不会自动折叠
+   * (用户实测:内容早已输出完,界面挂着「运行了 2 步 · 163s 输出中」)。
+   *
+   * 所以 typing 也受静默窗口约束:已经很久没有新内容,就不再信它。
    */
-  const live = !src.closed && (src.typing || now - src.updatedAt < quietMs);
+  const idleFor = now - src.updatedAt;
+  const typingAlive = src.typing && idleFor < quietMs * 2;
+  const live = !src.closed && (awaitingFirstReply || typingAlive || idleFor < quietMs);
   const seconds =
     src.startedAt > 0 && src.updatedAt >= src.startedAt
       ? Math.max(0, Math.round((src.updatedAt - src.startedAt) / 1000))
@@ -346,6 +381,8 @@ function toBlockView(src: BlockSource, now: number, quietMs: number): TurnBlockV
     live,
     empty: !live && body.length === 0 && steps.length === 0,
     merged: mergeBodies([{ body }]),
+    waitingSince,
+    waitMs: waitingSince > 0 ? Math.max(0, now - waitingSince) : 0,
   };
 }
 
@@ -358,6 +395,34 @@ function phaseOf(blocks: TurnBlockView[], closed: boolean): TurnPhase {
   return blocks.some((b) => !b.empty) ? 'done' : 'idle';
 }
 
+/**
+ * 等待中的占位文案(2026-11-09 用户裁决)。
+ *
+ * | 阶段 | 判据 | 文案 |
+ * |---|---|---|
+ * | 已发出,还没有任何输出 | `live` 且无内容 | 8 秒内「正在思考…」;之后「已送达,等待回复…(Ns)」 |
+ * | 开始输出 | `live` 且有内容 | 「正在输出…(Ns)」 |
+ * | 结束且全空 | 非 live 且 empty | 「未响应」 |
+ *
+ * 🔴 文案要**一直兜底到回复真的回来**,中途不能消失:
+ *    容器回一轮要十几秒,界面空白会被理解成「发了没反应」。
+ *
+ * @param waitMs 距用户发出消息已经过了多久
+ * @param hasOutput 是否已经开始输出内容
+ * @param live 是否仍在进行中
+ * @param empty 是否已结束且什么也没有
+ */
+export function waitText(waitMs: number, hasOutput: boolean, live: boolean, empty: boolean): string {
+  if (!live) return empty ? '未响应' : '';
+  const sec = Math.max(0, Math.round(waitMs / 1000));
+  if (hasOutput) return sec > 0 ? `正在输出…(${sec}s)` : '正在输出…';
+  if (waitMs < WAIT_HINT_MS) return '正在思考…';
+  return sec > 0 ? `已送达,等待回复…(${sec}s)` : '已送达,等待回复…';
+}
+
+/** 超过这个时长就明确告诉用户「发出了、在等」,不再只是「正在思考」 */
+export const WAIT_HINT_MS = 8_000;
+
 /* ------------------------------------------------------------------ */
 /* 两种输入 → 统一的回合视图                                           */
 /* ------------------------------------------------------------------ */
@@ -386,6 +451,14 @@ export function turnsFromGroup(
         },
         now,
         quietMs,
+        t.userMessage ? Date.parse(t.userMessage.ts) || 0 : 0,
+        /*
+         * 群聊里「这一块还没收到任何回复」= 等待中(2026-11-09,与单聊同口径)。
+         *
+         * 原来空块的 `updatedAt = 0` 会让 `idleFor` 极大,`live` 恒 false,
+         * 于是卡片是个空壳、没有文字 —— 用户看不到「谁在思考」(实测)。
+         */
+        b.messages.length === 0 && !b.closed,
       );
       const name = nameOf?.(b.agentId);
       return name ? { ...view, agentName: name } : view;
@@ -426,6 +499,14 @@ export function turnsFromDm(
   agentIdle = false,
 ): AssistantTurn[] {
   return turns.map((t) => {
+    /*
+     * 「已发出、容器还没回一个字」——这是发送后立刻可见的状态(2026-11-09)。
+     *
+     * 判据:有用户消息、没有 agent 消息、回合还没结束。
+     * 注意要用 `t.userMessage` 的时间做锚点,不能用 `t.updatedAt`
+     * (后者在无 agent 消息时等于用户消息时间,但语义上不是「最后活动」)。
+     */
+    const awaitingFirstReply = !!t.userMessage && t.blocks.length === 0 && !t.closed;
     const block = toBlockView(
       {
         agentId: null,
@@ -438,6 +519,9 @@ export function turnsFromDm(
       now,
       /* 容器明确空闲 → 不必再等静默窗口,立刻收尾 */
       agentIdle ? 0 : quietMs,
+      /* 等待时长从用户发出那条消息算起 */
+      t.userMessage ? Date.parse(t.userMessage.ts) || 0 : 0,
+      awaitingFirstReply,
     );
     const live = block.live;
 

+ 29 - 2
src/app/core/chat-session.service.ts

@@ -101,6 +101,20 @@ export class ChatSessionService {
   private readonly _error = signal<string | null>(null);
   readonly error = this._error.asReadonly();
 
+  /**
+   * 新建会话**推送上云失败**的原因(2026-11-09 用户要求)。
+   *
+   * 🔴 用户原话:「推送失败要报错」。
+   *
+   * 原来是静默吞掉的:本地照常显示,但云端没有 ——
+   * 用户在 A 浏览器建了会话,B 浏览器永远看不到,而且**A 这边也没有任何提示**,
+   * 用户根本不知道这条会话只存在于这一台机器上。
+   *
+   * 现在:失败就记下来,界面提示「这条会话还没同步到云端」并给重试入口。
+   */
+  private readonly _pushFailed = signal<string | null>(null);
+  readonly pushFailed = this._pushFailed.asReadonly();
+
   /** 上次云端拉取失败的原因(`null` = 正常);供调用方判断能否继续 */
   isCloudStale(): boolean {
     return !this._ready();
@@ -403,6 +417,8 @@ export class ChatSessionService {
     await this.trySync(() => this.api!.upsertChatThreads(list.map(toUpsert)));
     // 整批推成功后,这些会话云端已存在,撤销「待同步」标记
     for (const s of list) this._pendingPush.delete(s.id);
+    // 补齐成功 → 清掉「未同步」的错误提示(2026-11-09)
+    if (this._pushFailed()) this._pushFailed.set(null);
   }
 
   /**
@@ -436,8 +452,19 @@ export class ChatSessionService {
       try {
         await this.api!.upsertChatThreads([toUpsert(s)]);
         this._pendingPush.delete(s.id);
-      } catch {
-        /* 离线/服务异常:保留标记,等下次 hydrate 或 pushAll 再补 */
+        // 成功一次就把之前的失败提示清掉
+        if (this._pushFailed()) this._pushFailed.set(null);
+      } catch (e) {
+        /*
+         * 推送失败 → **必须让用户知道**(2026-11-09 用户要求)。
+         *
+         * 原来这里是静默吞掉的。后果:这条会话只存在于当前这台机器上,
+         * 换浏览器永远看不到,而用户完全不知情,会以为「同步慢」而反复操作。
+         *
+         * 保留 `_pendingPush` 标记 —— 下次 hydrate 或 pushAll 会自动补推。
+         */
+        const msg = e instanceof Error && e.message ? e.message : '网络异常';
+        this._pushFailed.set(`会话「${s.title || '新对话'}」尚未同步到云端:${msg}`);
       }
     })();
   }

+ 127 - 12
src/app/core/chat.service.spec.ts

@@ -516,13 +516,19 @@ describe('ChatService', () => {
       const last = svc.messages().at(-1)!;
       expect(last.text).toBe('你好'); // 已 trim
       expect(last.state).toBe('sent');
-      // 🔴 必须带会话线程根:不带 thread 容器会把每条消息切成新会话(实测)
+      /*
+       * 🔴 必须带会话线程根:不带 thread 容器会把每条消息切成新会话(实测)。
+       * 🔴 第五个参数是 extra:把本地 id 写进 content,服务端 echo 会原样带回,
+       *    回读时据此认领乐观消息(2026-11-09 修「一条你好显示三条」)。
+       */
       expect(mxStub.sendText).toHaveBeenCalledWith(
         '!room1:fmode.cn',
         '你好',
         svc.sessionId()!,
         undefined,
+        { 'com.fmode.local': last.localId! },
       );
+      expect(last.localId).toBeTruthy();
     });
 
     it('第二次发送带上一条消息的 event_id(m.in_reply_to 降级用)', async () => {
@@ -530,12 +536,12 @@ describe('ChatService', () => {
       mxStub.sendText.calls.reset();
       await svc.sendText('第二条');
       // 第一条是本地乐观 id(local-*),不是真实 event,故 lastEventId 应为 undefined
-      expect(mxStub.sendText).toHaveBeenCalledWith(
-        '!room1:fmode.cn',
-        '第二条',
-        svc.sessionId()!,
-        undefined,
-      );
+      const args = mxStub.sendText.calls.mostRecent().args;
+      expect(args[0]).toBe('!room1:fmode.cn');
+      expect(args[1]).toBe('第二条');
+      expect(args[2]).toBe(svc.sessionId()!);
+      expect(args[3]).toBeUndefined();
+      expect(args[4]).toEqual({ 'com.fmode.local': jasmine.any(String) });
     });
 
     it('发送后清空草稿', async () => {
@@ -857,13 +863,18 @@ describe('ChatService', () => {
       expect(texts).toContain('我的会话');
       expect(texts).not.toContain('别人的会话');
       /*
-       * 2026-11-03 口径变更:无 thread 的消息是**房间级消息**,归入当前会话。
+       * 🔴 2026-11-09 用户裁决变更:房间级消息(无 thread)**一律丢弃**。
+       *
+       * 原规则(2026-11-03)把它们归入当前会话,当时是为了让「用户直接发的图」
+       * (m.image 不带 thread)能看见。但实测发现更大的问题:
+       *   外部工具/脚本往房间发的消息没有 thread,会被**新建会话全部认领**,
+       *   表现为「新建一个会话,上面立刻出现一堆不相干的历史」。
        *
-       * 原规则直接丢弃它们,导致用户直接发图(m.image 不带 thread)时
-       * 图片在任何会话里都看不到(实测故障)。跨会话隔离仍然由
-       * `$approot_` 前缀保证 —— 上一行已覆盖。
+       * 用户明确裁决:宁可丢弃,也不要把别处的消息错认成本会话的。
+       * 代价:容器历史上无 thread 的旧回复不再显示
+       *      (容器现已改为带 thread 回复,影响有限)。
        */
-      expect(texts).toContain('无 thread 的历史');
+      expect(texts).not.toContain('无 thread 的历史');
     });
 
     /*
@@ -960,6 +971,8 @@ describe('ChatService · 新建会话期间不得回填历史(2026-11-08)',
     agents: () => [AGENT],
     state: () => 'ready',
     error: () => null,
+    /** TaskPulseService 会调它做快照对比 */
+    tick: () => 0,
   };
 
   /** 可以手动放行的 sendText —— 模拟「网络请求还没返回」 */
@@ -1016,5 +1029,107 @@ describe('ChatService · 新建会话期间不得回填历史(2026-11-08)',
 
     releaseSend();
     await pending;
+
+    /*
+     * 🔴 原来这里只 await 不检查 —— 而「发送成功之后」才是问题真正爆发的地方(2026-11-09)。
+     *    用户实测:发一条「你好」,`messages()` 返回 3 条。
+     */
+    expect(svc.messages().filter((m) => m.sender === 'me').length)
+      .withContext('发送完成后仍应恰好 1 条')
+      .toBe(1);
+  });
+
+  /*
+   * 🔴 乐观消息 ↔ 服务端 echo 去重(2026-11-09)。
+   *
+   * 病根:乐观消息 id 是 `local-*`,echo id 是 `$eventId`,按 id 做并集永远对不上,
+   * 于是同一条消息「本地一条 + 回读一条」并存。现在靠 content 里的
+   * `com.fmode.local` 认领。这些用例守住这条链路。
+   */
+  describe('🔴 乐观消息与 echo 去重', () => {
+    /** 取 openReal 注册的实时回调 —— 直接走生产链路(appendIncoming),不硬造信号 */
+    function emitter(): (m: MxMessage) => void {
+      const calls = mxStub.onMessage.calls.allArgs();
+      return calls[calls.length - 1][1] as (m: MxMessage) => void;
+    }
+
+    it('echo 带回同一个 localId → 只留服务端那条,不重复', async () => {
+      await svc.open('agent-node-lj-test');
+      const pending = svc.sendText('你好');
+      await Promise.resolve();
+      const localId = svc.messages()[0].localId!;
+      expect(localId).toBeTruthy();
+
+      releaseSend();
+      await pending;
+      expect(svc.messages().filter((m) => m.sender === 'me').length).toBe(1);
+
+      // 服务端 echo 实时到达:id 是 eventId,但带回 localId
+      emitter()({
+        roomId: '!room1:fmode.cn',
+        eventId: '$e1',
+        sender: '@me:fmode.cn',
+        body: '你好',
+        ts: Date.now(),
+        kind: 'text',
+        live: true,
+        localId,
+        threadId: svc.sessionId() ?? undefined,
+      });
+
+      const mine = svc.messages().filter((m) => m.sender === 'me');
+      expect(mine.length).withContext('echo 认领后应恰好 1 条').toBe(1);
+      expect(mine[0].id).toBe('$e1');
+    });
+
+    it('连点两次发送 → 两条,且 id 不冲突', async () => {
+      await svc.open('agent-node-lj-test');
+      // 两次发送各挂一个 resolver(第一次的会先被放行,第二次的会被新赋值覆盖)
+      mxStub.sendText.and.callFake(
+        () => new Promise<{ event_id?: string }>((res) => setTimeout(() => res({ event_id: '$x' }), 0)),
+      );
+      await Promise.all([svc.sendText('第一条'), svc.sendText('第二条')]);
+
+      const mine = svc.messages().filter((m) => m.sender === 'me');
+      expect(mine.length).toBe(2);
+      expect(new Set(mine.map((m) => m.id)).size).withContext('id 必须唯一').toBe(2);
+    });
+
+    it('失败消息在实时流到达后仍保留(可重试,AC-04.8)', async () => {
+      await svc.open('agent-node-lj-test');
+      mxStub.sendText.and.returnValue(Promise.reject(new Error('网络断了')));
+      await svc.sendText('会失败');
+
+      const failed = svc.messages().filter((m) => m.state === 'failed');
+      expect(failed.length).toBe(1);
+
+      // 容器回复到达,不能把失败消息冲掉
+      emitter()({
+        roomId: '!room1:fmode.cn',
+        eventId: '$r1',
+        sender: '@agent:fmode.cn',
+        body: '收到',
+        ts: Date.now(),
+        kind: 'text',
+        live: true,
+        threadId: svc.sessionId() ?? undefined,
+      });
+      expect(svc.messages().filter((m) => m.state === 'failed').length).toBe(1);
+    });
+
+    /*
+     * 🔴 2026-11-09 评审 B3:合并时不分会话 → 切会话后旧会话的乐观消息残留。
+     */
+    it('🔴 新建会话后,上一条会话的发送中消息不会残留', async () => {
+      await svc.open('agent-node-lj-test');
+      // 第一条:挂起不返回(发送中)
+      void svc.sendText('上一条会话的消息');
+      await Promise.resolve();
+      expect(svc.messages().filter((m) => m.state === 'sending').length).toBe(1);
+
+      // 新建会话 —— 应当干净
+      await svc.startBlank('agent-node-lj-test');
+      expect(svc.messages().length).withContext('新建会话必须空白').toBe(0);
+    });
   });
 });

+ 170 - 76
src/app/core/chat.service.ts

@@ -18,6 +18,7 @@ import { SessionService } from './session.service';
 import { UnreadService } from './unread.service';
 import {
   DISPATCH_KEY,
+  LOCAL_ID_KEY,
   MxDispatchMeta,
   MxMessage,
   MatrixClientService,
@@ -42,6 +43,7 @@ import { ChatSession, deriveTitle, isAppSessionId, placeholderSession } from './
 import { ChatSessionService } from './chat-session.service';
 import { RelayService } from './relay.service';
 import { TaskPulseService } from './task-pulse.service';
+import { sessionRunning } from './agent-state';
 
 export type ConnectionState = 'connecting' | 'connected' | 'disconnected';
 
@@ -133,6 +135,19 @@ export class ChatService {
   /** 是否可能还有更早历史 */
   private readonly _hasMoreHistory = signal(false);
 
+  /**
+   * 当前会话「为什么是空的」(2026-11-09)。
+   *
+   * - `'fresh'`:真的没有消息 —— 新建对话,显示引导页
+   * - `'unloaded'`:房间里有消息,但一条都归属不到这个会话 —— 显示「没有可加载的消息」
+   * - `null`:不空
+   *
+   * 🔴 不区分的话,一个历史取不到的旧会话会显示成「下午好,我是 xxx」,
+   *    用户以为会话还在、内容丢了,实际是被归属规则挡住了(实测)。
+   */
+  private readonly _emptyReason = signal<'fresh' | 'unloaded' | null>(null);
+  readonly emptyReason = this._emptyReason.asReadonly();
+
   readonly agentId = this._agentId.asReadonly();
   /** 当前会话 id(线程根) */
   readonly sessionId = this._sessionId.asReadonly();
@@ -174,7 +189,18 @@ export class ChatService {
   readonly agentIdle = computed(() => {
     const id = this._agentId();
     if (!id) return false;
-    return this.live.agentById(id)?.status === 'idle';
+    const agent = this.live.agentById(id);
+    if (!agent) return false;
+    /*
+     * 🔴 优先用**本会话**的状态(2026-11-09 方案 A)。
+     *
+     * 容器上报的 `status` 是整个容器一个值:同一容器的会话 A 在跑时,
+     * 会话 B 也会被告知「不空闲」→ 回合永远收不了尾(用户实测)。
+     * 容器按会话上报时以它为准;没上报(旧容器)才回落到全局 status。
+     */
+    const mine = sessionRunning(agent.currentSessions, this._sessionId());
+    if (mine !== undefined) return !mine;
+    return agent.status === 'idle';
   });
   /** 正在加载更早历史 */
   readonly paginating = this._paginating.asReadonly();
@@ -317,7 +343,17 @@ export class ChatService {
        *   · 有   → 解除保护,本次及之后的回填都按正常规则走
        */
       if (this._blankSessionId && sid === this._blankSessionId) {
-        const landed = raw.some((m) => m.threadId === sid);
+        /*
+         * 🔴 必须是「本会话的**用户消息**」才算落地(2026-11-09 修正)。
+         *
+         * 原判据是「timeline 里有任意 `threadId === sid` 的事件」。
+         * 但容器回复也可以挂在这个线程根下 —— 于是容器刚回的**第一条**就
+         * 撤掉了保护,紧接着的回填把整个房间的旧历史灌进来
+         * (用户实测:新建会话后直接出现「加载更早」和旧内容)。
+         *
+         * 用户自己发的那条才说明「这段会话真正开始了」。
+         */
+        const landed = raw.some((m) => m.threadId === sid && m.sender === myUserId);
         if (!landed) return;
         this._blankSessionId = null;
       }
@@ -350,11 +386,7 @@ export class ChatService {
        *    直接读会形成自依赖 → 无限重算(实测会导致页面卡死、按钮点不动)。
        */
       const local = untracked(() => this._messages());
-      const byId = new Map(next.map((m) => [m.id, m]));
-      for (const m of local) {
-        if (!byId.has(m.id)) byId.set(m.id, m);
-      }
-      this._messages.set([...byId.values()]);
+      this._messages.set(this.mergeTimeline(next, local));
       /*
        * 🔴 这里**不能**标记「正在回复」:这是历史重读(每次 sync 都会重新灌一遍),
        *    刷新页面时尾部 id 必然变 → 一打开就显示「正在回复」(用户实测)。
@@ -875,6 +907,17 @@ export class ChatService {
 
     // ⑥ 连接状态由 SDK 同步事件驱动
     this._connection.set(this.mx.syncState() === 'connected' ? 'connected' : 'connecting');
+    /*
+     * 「空」有两种,界面上必须区分(2026-11-09):
+     *   ① 真正新建的会话 —— 显示「下午好,我是 xxx」引导页
+     *   ② 有历史但一条也取不到 —— 只是空着,不能伪装成新建
+     *
+     * 判据:rawTimeline 里有没有**任何**属于这个房间的消息。
+     * 有消息但过滤后为空 = 我们的归属规则把它们挡掉了,属于 ②。
+     * 注意不能用 `messageCount`(实测虚高:云端记 21 条、Matrix 实际 2 条)。
+     */
+    const hadAnyEvent = rawTimeline.length > 0;
+    this._emptyReason.set(this._messages().length > 0 ? null : hadAnyEvent ? 'unloaded' : 'fresh');
     this._state.set(this._messages().length === 0 ? 'empty' : 'ready');
     // timeline pagination token 是否存在 = 更早可能有历史
     this._hasMoreHistory.set(this.mx.paginationToken(rid) != null);
@@ -1149,8 +1192,17 @@ export class ChatService {
     const conversationId = this._agentId();
     if (!conversationId) return;
 
+    /*
+     * 乐观消息 id(2026-11-09)。
+     *
+     * 🔴 原来用 `local-${Date.now()}`:连点两次(同一毫秒)会生成**同一个 id**,
+     *    两条消息互相顶替;而且这个 id 和它自己的服务端 echo(`$eventId`)对不上,
+     *    合并时双双保留 → 一条「你好」显示成 3 条。
+     *    现在带上随机串,并把这个 id 随事件写进 content,回读时按它认领。
+     */
+    const localId = `local-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
     const msg: Message = {
-      id: `local-${Date.now()}`,
+      id: localId,
       conversationId,
       sender: 'me',
       kind: 'text',
@@ -1158,6 +1210,7 @@ export class ChatService {
       text: body || undefined,
       ts: new Date().toISOString(),
       state: 'sending',
+      localId,
     };
     if (body) this._messages.update((m) => [...m, msg]);
     this._draft.set('');
@@ -1271,7 +1324,7 @@ export class ChatService {
           return;
         }
         const relayed = await this.tryRelay(body, msg, conversationId);
-        if (!relayed) await this.sendReal(body);
+        if (!relayed) await this.sendReal(body, msg.localId);
       }
       this.markSent(msg.id);
       /*
@@ -1521,8 +1574,11 @@ export class ChatService {
        * 分页后**保留完整已加载区间**(不做上限截断):
        * 用户是在往上翻,超出窗口的内容用户正要看,截断会让人往前翻却看不到。
        * 真正省开销的是首屏只取一屏 + 后续按需加载,而不是丢弃已加载内容。
+       *
+       * 🔴 但与本地乐观消息**合并**,不整表覆盖(2026-11-09):
+       *    翻页时还在发送中的消息不在 timeline 里,覆盖掉就凭空消失了。
        */
-      this._messages.set(next);
+      this._messages.set(this.mergeTimeline(next, untracked(() => this._messages())));
       // 条数未增长 = 已到最早,停止继续请求
       this._hasMoreHistory.set(ok && next.length > before);
       return this._hasMoreHistory();
@@ -1542,7 +1598,7 @@ export class ChatService {
       if (environment.useMockData) {
         await this.simulateSend();
       } else if (msg.text) {
-        await this.sendReal(msg.text);
+        await this.sendReal(msg.text, msg.localId);
       }
       this.markSent(messageId);
     } catch (e) {
@@ -1567,7 +1623,7 @@ export class ChatService {
    * ⚠️ 「历史对话」特例已于 2026-10-29 移除 —— 该会话类型不再存在,
    *    所有发送一律带线程根,避免容器侧把消息切成新会话(容器失忆)。
    */
-  private async sendReal(text: string): Promise<void> {
+  private async sendReal(text: string, localId?: string): Promise<void> {
     if (!this.roomId) throw new Error('会话未就绪,请稍后重试');
     let sid = this._sessionId();
     if (!sid) {
@@ -1583,7 +1639,17 @@ export class ChatService {
     const lastId = this._messages().at(-1)?.id;
     // 自造的会话 id 还没有任何消息时,不填 m.in_reply_to(那是降级展示用的引用)
     const last = lastId && !lastId.startsWith('local-') ? lastId : undefined;
-    await this.mx.sendText(this.roomId, text, sid, last);
+    /*
+     * `localId` 随事件写进 content:服务端 echo 会原样带回(Matrix 允许任意额外字段),
+     * 回读时据此认领本地那条乐观消息,避免同一句话出现两遍。
+     */
+    await this.mx.sendText(
+      this.roomId,
+      text,
+      sid,
+      last,
+      localId ? { [LOCAL_ID_KEY]: localId } : undefined,
+    );
   }
 
   /**
@@ -1615,6 +1681,14 @@ export class ChatService {
       if (!found) {
         const recreated = this.sessions.create(roomId, agentId);
         this._sessionId.set(recreated.id);
+        /*
+         * 🔴 换了会话标识,内存里属于旧会话的消息必须清掉(2026-11-09)。
+         *    否则旧会话的内容挂在新的会话 id 下,观感就是「串线」。
+         *    同时重置新建态保护,让这次发送按正常的新会话走。
+         */
+        this._messages.set([]);
+        this._ownedThreadRoots.clear();
+        this._blankSessionId = recreated.id;
         if (this._blankWorkspace) {
           this.sessions.moveTo(recreated.id, this._blankWorkspace);
           this._blankWorkspace = null;
@@ -1706,77 +1780,34 @@ export class ChatService {
   ): boolean {
     if (!m.threadId) {
       /*
-       * 🔴 群聊派活的隔离(2026-11-06 用户裁决:单聊永远看不到)。
+       * 房间级消息(无 thread)的归属(2026-11-09 用户裁决调整)。
        *
-       * 群聊与单聊共用同一个 DM 房间,`sendToAgent` 发的是**房间级消息**。
-       * 不拦的话,派活和容器回复都会被主会话认领 ——
-       * 表现为「群里派了活,日常对话里也出现了同样的内容和回复」。
+       * 用户原话:「房间级的这个消息,可以丢弃掉,没有问题」。
        *
-       * ① 派活消息本身带 `dispatch` 标记(随事件持久化,刷新后仍有效)
-       * ② 派活**之后**的房间级回复没有标记(容器不知道),靠 eventId 锚点判定
-       */
-      if (m.dispatch) return false;
-      if (this.isAfterLastDispatch(m)) return false;
-      /*
-       * 单聊自己发起的房间级消息(图片/附件不带 thread)带 `sessionId` 标记,
-       * 优先归属 —— 否则会被上一条规则误挡(派活之后用户从单聊发的图会消失)。
-       */
-      if (m.sessionId) return m.sessionId === sessionId;
-      const list = this.sessions.listFor(this._agentId() ?? '', { includeArchived: true });
-      // 新建态会话(messageCount 为 0 / undefined)不认领房间级历史
-      const target = list.find((s) => s.id === sessionId);
-      if (!target) return false;
-      return (target.messageCount ?? 0) > 0;
-    }
-    if (m.threadId === sessionId) return true;
-    /*
-     * 无 thread 的消息 = **房间级消息**,归入该房间的「主会话」。
-     *
-     * ## 为什么需要这条规则(2026-11-03)
-     *
-     * 原来这里直接 `return false`,导致房间级消息(用户直接发的图片 `m.image`、
-     * 容器早期的无 thread 回复)**在任何会话里都看不到**。
-     *
-     * ## 为什么不能一律归入「当前会话」(2026-11-04 回归修复)
-     *
-     * 上一版写成 `if (!m.threadId) return true`,结果是:
-     * **新建一个空会话后,该房间所有历史消息都灌进来了**(用户实测:
-     * 点「新建对话」,界面上立刻出现几天前的「容器上线自检」对话)。
-     *
-     * 正确口径:只有**已经承载过历史的那条会话**(即该房间的主会话)才认领
-     * 房间级消息;刚新建、从没发过消息的空会话不认领任何历史。
-     *
-     * 判定方式:「主会话」= 该 agent 下最早创建且非新建态的那条。这里用
-     * `sessions.listFor(agentId)` 的最后一条(按 updatedAt 正序的最新=最早创建)
-     * 会随消息变动,所以改用更稳的判据:**该会话是否已有多条消息**。
-     */
-    if (!m.threadId) {
-      /*
-       * 🔴 群聊派活的隔离(2026-11-06 用户裁决:单聊永远看不到)。
+       * 背景:同一个 DM 房间里,App 的正常会话**都带 thread**;
+       * 只有两种情况会产生无 thread 的消息:
+       *   ① 群聊派活(`sendToAgent` 发房间级)—— 有 `dispatch` 标记可识别
+       *   ② **外部工具 / 脚本**直接往房间发的消息 —— 没有任何归属信息
        *
-       * 群聊与单聊共用同一个 DM 房间,`sendToAgent` 发的是**房间级消息**。
-       * 不拦的话,派活和容器回复都会被主会话认领 ——
-       * 表现为「群里派了活,日常对话里也出现了同样的内容和回复」。
+       * ② 是实测踩到的坑:用脚本调试容器发出的消息没有 thread,
+       * 结果被新建会话全部认领,表现为「新建会话出现一堆历史」。
        *
-       * ① 派活消息本身带 `dispatch` 标记(随事件持久化,刷新后仍有效)
-       * ② 派活**之后**的房间级回复没有标记(容器不知道),靠 eventId 锚点判定
+       * 现在的规则:**一律不认领**。唯一例外是单聊自己发的房间级附件 ——
+       * 那种消息带 `sessionId` 标记,归属是明确的。
+       *
+       * 代价:容器历史上无 thread 的旧回复不再显示。这是有意的取舍 ——
+       * 宁可少显示,也不要把别处的消息错认成这个会话的。
        */
       if (m.dispatch) return false;
-      if (this.isAfterLastDispatch(m)) return false;
-      /*
-       * 单聊自己发起的房间级消息(图片/附件不带 thread)带 `sessionId` 标记,
-       * 优先归属 —— 否则会被上一条规则误挡(派活之后用户从单聊发的图会消失)。
-       */
       if (m.sessionId) return m.sessionId === sessionId;
-      const list = this.sessions.listFor(this._agentId() ?? '', { includeArchived: true });
-      // 新建态会话(messageCount 为 0 / undefined)不认领房间级历史
-      const target = list.find((s) => s.id === sessionId);
-      if (!target) return false;
-      return (target.messageCount ?? 0) > 0;
+      return false;
     }
     if (m.threadId === sessionId) return true;
     /*
-     * 🔴 **容器自己开的子线程,也算当前会话的内容**。
+     * 无 thread 的消息(房间级消息)已在函数开头处理完毕 ——
+     * 到这里 `m.threadId` 必有值。
+     *
+     * 🔴 **容器自己开的子线程,也算当前会话的内容**。
      *
      * 实测:容器在回复时会另开 `m.thread` 分支跑内部任务
      * (调工具、跑浏览器、自我改进)。那些消息也是这次对话的一部分 ——
@@ -1801,7 +1832,6 @@ export class ChatService {
      * **实时事件**(`live=true`)仍然接纳:那是容器刚刚发出的内容,
      * 一定是当前这次对话的产出(截图、交付物都在里面)。
      */
-    if (!m.threadId) return false;
     if (live) return !isAppSessionId(m.threadId);
     const roots = ownedRoots ?? this._ownedThreadRoots;
     return roots.has(m.threadId);
@@ -1870,6 +1900,59 @@ export class ChatService {
     for (const s of stale) this.sessions.remove(s.id);
   }
 
+  /**
+   * 把「服务端 timeline」和「本地内存里的消息」合并成一份列表。
+   *
+   * 🔴 为什么需要它(2026-11-09,用户实测「发一条你好,出现三条」):
+   *    乐观消息的 id 是 `local-*`,它自己的服务端 echo 是 `$eventId`,
+   *    **两者 id 天然不同**。原实现按 id 做并集 —— 于是同一条消息
+   *    以「本地一条 + 回读一条」并存;再加上实时追加,最多时出现 3 条。
+   *
+   * 规则:
+   *   ① echo 带 `localId` 且能对上本地某条 → **认领**它,只保留服务端那条
+   *   ② 没被认领的本地消息,**只保留仍在发送中 / 已失败**的
+   *      (失败消息不能丢,可重试 AC-04.8;已 `sent` 却没有 echo 的,
+   *       说明它属于服务端时间线之外 —— 留着只会重复)
+   *   ③ 只保留**属于当前 agent** 的本地消息
+   *      (🔴 2026-11-09 评审 B3:原实现不分会话,切会话后
+   *       `storeVersion` 每次 sync 都会把旧会话的乐观消息重新并回来)
+   *   ④ 按时间稳定排序,echo 落在它本来的位置,不跳到末尾
+   */
+  private mergeTimeline(next: Message[], local: Message[]): Message[] {
+    // 服务端 echo 认领了哪些本地 id
+    const claimed = new Set<string>();
+    for (const m of next) {
+      if (m.localId) claimed.add(m.localId);
+    }
+    const agentId = this._agentId() ?? '';
+    /*
+     * 🔴 echo 认领本地消息时**沿用本地的 ts**(2026-11-09 修复秒数跳变)。
+     *
+     * 本地乐观消息的 ts 是浏览器时钟,服务端 echo 的 ts 是服务端时钟,
+     * 两者有几秒偏差。直接换成服务端的,会让「等待了多久」这个锚点跳变
+     * (用户实测 95s → 100s)。
+     * 这一条是我方发出的消息,用我方记下的发送时刻更准。
+     */
+    const localTs = new Map<string, string>();
+    for (const m of local) {
+      if (m.localId) localTs.set(m.localId, m.ts);
+    }
+    const out: Message[] = next.map((m) =>
+      m.localId && localTs.has(m.localId) ? { ...m, ts: localTs.get(m.localId)! } : m,
+    );
+    for (const m of local) {
+      if (claimed.has(m.id) || (m.localId && claimed.has(m.localId))) continue;
+      if (m.state === 'sent') continue;
+      if (agentId && m.conversationId !== agentId) continue;
+      out.push(m);
+    }
+    /*
+     * 稳定排序:时间相同(同一毫秒)时保持原顺序。
+     * Array.prototype.sort 在现代 V8 里是稳定的,不必额外带序号。
+     */
+    return out.sort((a, b) => (Date.parse(a.ts) || 0) - (Date.parse(b.ts) || 0));
+  }
+
   /** 归一 timeline → 领域消息 */
   private mapTimeline(list: MxMessage[], myUserId: string): Message[] {
     const cid = this._agentId() ?? '';
@@ -1902,6 +1985,8 @@ export class ChatService {
           : undefined,
         ts: new Date(m.ts).toISOString(),
         state: 'sent' as const,
+        // 透传本地 id:合并时用它认领乐观消息(见 mergeTimeline)
+        localId: m.localId,
       };
     });
   }
@@ -1928,7 +2013,16 @@ export class ChatService {
     const eid = m.eventId || `${m.ts}-${m.sender}`;
     if (this._messages().some((x) => x.id === eid)) return;
     const [mapped] = this.mapTimeline([m], myUserId);
-    this._messages.update((list) => [...list, mapped]);
+    /*
+     * 🔴 自己的 echo 到达时**替换**乐观消息,而不是追加(2026-11-09)。
+     *    否则「我发的那条」会以本地 / 实时两条并存。
+     */
+    const localId = mapped?.localId;
+    this._messages.update((list) => {
+      if (!localId) return [...list, mapped];
+      const rest = list.filter((x) => x.id !== localId);
+      return this.mergeTimeline([...rest, mapped], []);
+    });
     this.rememberDemoHistory();
     if (this._state() === 'empty') this._state.set('ready');
     // 会话元信息(预览/标题/时间)随消息推进

+ 93 - 3
src/app/core/loop-guard.service.spec.ts

@@ -1,3 +1,4 @@
+import { signal } from '@angular/core';
 import { TestBed } from '@angular/core/testing';
 import { Agent, Message } from './api/models';
 import { ChatService } from './chat.service';
@@ -14,16 +15,33 @@ import { LoopGuardService } from './loop-guard.service';
  *   ④ off 模式是否真的不检测
  */
 describe('LoopGuardService', () => {
-  let svc: LoopGuardService;
+  let svc!: LoopGuardService;
   const MY = '@me:fmode.cn';
   const AGENT = '@a:fmode.cn';
 
+  /**
+   * 当前会话标识。
+   *
+   * 🔴 必须是**真信号**:`paused` / `pauseDetail` 是 computed,
+   *    只会在依赖的信号变化时重算。用 jasmine spy 返回可变值时
+   *    computed 不会失效,断言会读到旧缓存(2026-11-09 踩坑)。
+   */
+  const sessionSig = signal<string | null>('s1');
+
   const chatStub = {
     messages: jasmine.createSpy('messages').and.returnValue([] as Message[]),
     agentId: jasmine.createSpy('agentId').and.returnValue('a1'),
+    sessionId: () => sessionSig(),
     provisionInfo: { userMxId: MY, agentMxId: AGENT } as { userMxId?: string; agentMxId?: string } | null,
   };
 
+  /** 切换当前会话(守卫只认 `chat.sessionId()` 这一个来源) */
+  function setSession(id: string): void {
+    sessionSig.set(id);
+    // 信号变化后让守卫重算一次(生产里由 effect 自动做)
+    svc.reevaluate();
+  }
+
   function msg(sender: 'me' | 'agent', text: string, i: number): Message {
     return {
       id: `m${i}`,
@@ -37,8 +55,8 @@ describe('LoopGuardService', () => {
   }
 
   beforeEach(() => {
-    chatStub.messages.calls.reset();
-    chatStub.agentId.calls.reset();
+    sessionSig.set('s1');
+    chatStub.messages.calls.reset();    chatStub.agentId.calls.reset();
     chatStub.messages.and.returnValue([]);
     chatStub.agentId.and.returnValue('a1');
     chatStub.provisionInfo = { userMxId: MY, agentMxId: AGENT };
@@ -112,4 +130,76 @@ describe('LoopGuardService', () => {
     svc.setMode('standard');
     expect(svc.paused()).toBeFalse();
   });
+
+  /*
+   * 🔴 会话隔离(2026-11-09)。
+   *
+   * 原实现 `dismissedFor` 存的是 agentId,同一个容器的两个会话
+   * 之间切换不会清暂停态 → 上个会话的判定串到下一个会话。
+   */
+  describe('🔴 按会话隔离', () => {
+    /** s1:回环中 */
+    const LOOPING = () => Array.from({ length: 8 }, (_, i) => msg('agent', '收到', i));
+    /** s2:正常对话,不该暂停 */
+    const NORMAL = () => [
+      msg('agent', '我先看一下配置文件', 0),
+      msg('agent', '改好了,重启验证', 1),
+      msg('me', '好的', 2),
+    ];
+
+    it('同 agentId 切到另一个会话 → 前一会话的暂停态不显示', () => {
+      chatStub.messages.and.returnValue(LOOPING());
+      setSession('s1');
+      expect(svc.paused()).toBeTrue();
+
+      chatStub.messages.and.returnValue(NORMAL());
+      setSession('s2');
+      expect(svc.paused()).withContext('s2 不应继承 s1 的暂停态').toBeFalse();
+    });
+
+    it('两个会话各自独立:切回 s1 仍是暂停', () => {
+      chatStub.messages.and.returnValue(LOOPING());
+      setSession('s1');
+      chatStub.messages.and.returnValue(NORMAL());
+      setSession('s2');
+      expect(svc.paused()).toBeFalse();
+
+      chatStub.messages.and.returnValue(LOOPING());
+      setSession('s1');
+      expect(svc.paused()).withContext('切回来应恢复暂停').toBeTrue();
+    });
+
+    it('resume() 只解除当前会话,不影响别的会话', () => {
+      chatStub.messages.and.returnValue(LOOPING());
+      setSession('s1');
+      expect(svc.paused()).toBeTrue();
+
+      setSession('s2'); svc.resume();
+      expect(svc.paused()).withContext('s2 本来就没暂停').toBeFalse();
+
+      setSession('s1');
+      expect(svc.paused()).withContext('s1 不该被 s2 的 resume 解除').toBeTrue();
+    });
+
+    it('🔴 provisionInfo 为空时仍能正确判定(不依赖 mxId)', () => {
+      chatStub.provisionInfo = null;
+      chatStub.messages.and.returnValue(
+        Array.from({ length: 8 }, (_, i) => msg('agent', '收到', i)),
+      );
+      setSession('s1');
+      svc.setMode('standard');
+      expect(svc.paused()).toBeTrue();
+    });
+
+    it('🔴 我方在多个会话各发一句「你好」→ 不暂停', () => {
+      chatStub.messages.and.returnValue([
+        msg('me', '你好', 0),
+        msg('me', '你好', 1),
+        msg('me', '你好', 2),
+      ]);
+      setSession('s1');
+      svc.setMode('standard');
+      expect(svc.paused()).toBeFalse();
+    });
+  });
 });

+ 123 - 30
src/app/core/loop-guard.service.ts

@@ -37,29 +37,82 @@ export class LoopGuardService {
   private readonly _mode = signal<LoopGuardMode>(DEFAULT_MODE);
   readonly mode = this._mode.asReadonly();
 
-  /** 当前会话是否被判定为回环 */
-  private readonly _paused = signal(false);
-  readonly paused = this._paused.asReadonly();
+  /**
+   * 每个会话**各自**维护暂停态:key = 会话标识,value = 判定结论。
+   *
+   * 🔴 为什么必须按会话隔离(2026-11-09):原实现只有一个全局 `_paused`,
+   *    换会话靠 agentId 变化来清。同一个容器的两个会话之间切换时不清,
+   *    上一个会话的误判会残留到下一个会话 —— 用户看到的是「到处都在提示回环」。
+   *
+   * 会话标识**只取自 `chat.sessionId()`**(单一来源)。页面不必再 `watch()`
+   * 喂一个作用域进来 —— 双来源曾让「页面没接」和「页面接了但已切走」两种
+   * 状态互相打架(2026-11-09 评审 B2)。
+   */
+  private readonly _paused = signal<ReadonlyMap<string, LoopVerdict>>(new Map());
+
+  /** 用户点过「继续协作」的会话:在该会话再发言前不再提示 */
+  private readonly _dismissed = signal<ReadonlySet<string>>(new Set());
 
   private readonly _verdict = signal<LoopVerdict | null>(null);
   readonly verdict = this._verdict.asReadonly();
 
-  /** 触发时的诊断信息(连续数、速率),供 UI 提示与审计 */
-  readonly pauseDetail = computed(() => this._verdict()?.detail ?? '');
+  /** 当前会话是否被判定为回环 */
+  readonly paused = computed(() => {
+    const key = this.keyOf();
+    return !!key && this._paused().has(key);
+  });
 
-  /** 用户手动解除的次数(换会话时清零) */
-  private dismissedFor = '';
+  /** 触发时的诊断信息(连续数、速率),供 UI 提示与审计 */
+  readonly pauseDetail = computed(() => {
+    const key = this.keyOf();
+    return (key && this._paused().get(key)?.detail) || '';
+  });
 
   setMode(mode: LoopGuardMode): void {
+    const prev = this._mode();
     this._mode.set(mode);
-    if (mode === 'off') this.resume();
-    else this.evaluate();
+    /*
+     * 🔴 关掉再打开 = 用户明确要求重新检测(2026-11-09 评审 B2)。
+     *
+     * 原实现 `off` 时调 `resume()`,而 `resume()` 会写 `_dismissed` ——
+     * 于是 `off → standard` 之后真回环也被当成「用户已手动放过」,再也不提示。
+     * 切档只清暂停态,**不动 `_dismissed`**。
+     */
+    if (mode === 'off') {
+      this._paused.set(new Map());
+      this._verdict.set(null);
+      return;
+    }
+    if (prev === 'off') {
+      const key = this.keyOf();
+      if (key) {
+        this._dismissed.update((s) => {
+          if (!s.has(key)) return s;
+          const next = new Set(s);
+          next.delete(key);
+          return next;
+        });
+      }
+    }
+    this.evaluate();
   }
 
-  /** 人类发消息 → 自动解除(这是最自然的恢复方式,无需点按钮) */
+  /** 用户手动「继续协作」:只解除**当前会话**,别的会话不受影响 */
   resume(): void {
-    this._paused.set(false);
-    this._verdict.set(null);
+    const key = this.keyOf();
+    if (!key) return;
+    this._dismissed.update((s) => new Set(s).add(key));
+    this.clearPaused(key);
+  }
+
+  /**
+   * 立即按当前状态重算一次。
+   *
+   * 正常由 effect 自动驱动(消息 / 模式 / 会话变化)。这里公开出来是给
+   * 「调用方明确知道状态变了、但信号没变」的场景兜底,也便于单测驱动。
+   */
+  reevaluate(): void {
+    this.evaluate();
   }
 
   constructor() {
@@ -71,37 +124,77 @@ export class LoopGuardService {
     });
   }
 
+  /**
+   * 当前会话标识(**唯一来源**:ChatService)。
+   *
+   * 优先 `sessionId`;还没分配到会话时用 agentId 兜底,
+   * 保证「新建但还没落库」的那条会话也能独立判定。
+   */
+  private keyOf(): string {
+    const sid = this.chat.sessionId();
+    if (sid) return sid;
+    const agentId = this.chat.agentId();
+    return agentId ? `blank:${agentId}` : '';
+  }
+
+  private clearPaused(key: string): void {
+    if (!key) return;
+    this._paused.update((cur) => {
+      if (!cur.has(key)) return cur;
+      const next = new Map(cur);
+      next.delete(key);
+      return next;
+    });
+  }
+
   private evaluate(): void {
+    const key = this.keyOf();
     const cfg = configOf(this._mode());
-    if (!cfg) {
-      this._paused.set(false);
-      return;
-    }
-    const agentId = this.chat.agentId();
-    if (!agentId) {
-      this.resume();
+    if (!key || !cfg || !this.chat.agentId()) {
+      this.clearPaused(key);
       return;
     }
-    // 换会话:清掉上一个会话的暂停状态
-    if (this.dismissedFor !== agentId) {
-      this.dismissedFor = agentId;
-      this._paused.set(false);
-      this._verdict.set(null);
-    }
 
-    const mxId = this.chat.provisionInfo?.userMxId ?? '';
-    const agentMxId = this.chat.provisionInfo?.agentMxId ?? '';
+    /*
+     * 🔴 身份只看 `Message.sender`,不比对 mxId(2026-11-09)。
+     *    原实现用 `provisionInfo.userMxId` 比对;未就绪时是空串,
+     *    所有消息都会被当成 agent,尾部一有重复就误亮横幅。
+     */
     const msgs: LoopMessage[] = this.chat
       .messages()
       .filter((m) => !!m.text)
       .map((m) => ({
-        sender: m.sender === 'me' ? mxId : agentMxId,
+        sender: m.sender === 'me' ? 'me' : 'agent',
         body: m.text ?? '',
         ts: Date.parse(m.ts) || 0,
+        fromMe: m.sender === 'me',
       }));
 
-    const v = detectLoop(msgs, (s) => s === mxId, cfg);
+    const last = msgs[msgs.length - 1];
+    if (last?.fromMe) {
+      // 人类在这个会话里发了言 —— 这是最自然的恢复方式,无需点按钮
+      this._dismissed.update((s) => {
+        if (!s.has(key)) return s;
+        const next = new Set(s);
+        next.delete(key);
+        return next;
+      });
+    } else if (this._dismissed().has(key)) {
+      // 用户已手动解除,且没有新的我方发言 → 本会话保持静默
+      this.clearPaused(key);
+      return;
+    }
+
+    const v = detectLoop(msgs, (s) => s === 'me', cfg);
     this._verdict.set(v);
-    this._paused.set(v.loop);
+    this._paused.update((cur) => {
+      // 🔴 判的是「**本会话**的暂停态」有没有变,不是看整张表里有没有这个 key
+      const was = cur.has(key);
+      if (v.loop === was) return cur;
+      const next = new Map(cur);
+      if (v.loop) next.set(key, v);
+      else next.delete(key);
+      return next;
+    });
   }
 }

+ 89 - 0
src/app/core/loop-guard.spec.ts

@@ -242,4 +242,93 @@ describe('loop-guard', () => {
       expect(detectLoop(five, isHuman, LOOP_GUARD_PRESETS.standard, t0 + 50_000).loop).toBeTrue();
     });
   });
+
+  /*
+   * 🔴 2026-11-08 真实故障回归(2026-11-09 修复)。
+   *
+   * 用户报告「发消息容器不回」。三方采样后发现容器 13 秒后已正常回复,
+   * 是 LoopGuard 把回复「挡」在了界面提示层:横幅显示「检测到自动对话循环,已暂停」,
+   * 用户据此以为系统停了。
+   *
+   * 根因是 `repeat` 规则在同一条用户消息跨会话重复时触发 —— 用户自己发的
+   * 内容是用户,不是复读机。
+   */
+  describe('🔴 误判回归:用户自己的内容不算回环', () => {
+    it('三个会话各发一句「你好」,凑到第三句 → 不触发', () => {
+      const t0 = 7_000_000;
+      const msgs: LoopMessage[] = [
+        { sender: HUMAN, body: '你好', ts: t0, fromMe: true },
+        { sender: HUMAN, body: '你好', ts: t0 + 20_000, fromMe: true },
+        { sender: HUMAN, body: '你好', ts: t0 + 40_000, fromMe: true },
+      ];
+      const v = detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard, t0 + 40_000);
+      expect(v.loop).withContext(v.detail ?? '').toBeFalse();
+    });
+
+    it('我方连发 6 条相同内容 → 不触发(且不进 agent 速率统计)', () => {
+      const t0 = 7_100_000;
+      const msgs = Array.from({ length: 6 }, (_, i) => ({
+        sender: HUMAN,
+        body: '测试',
+        ts: t0 + i * 1000,
+        fromMe: true,
+      }));
+      const v = detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard, t0 + 6000);
+      expect(v.loop).toBeFalse();
+      expect(v.streak).toBe(0);
+      expect(v.perMinute).toBe(0);
+    });
+
+    it('agent 连发 2 条「收到」+ 我方 2 条相同内容 → 连续段只有 2 条,不触发', () => {
+      const t0 = 7_200_000;
+      const msgs: LoopMessage[] = [
+        { sender: A, body: '收到', ts: t0, fromMe: false },
+        { sender: A, body: '收到', ts: t0 + 1000, fromMe: false },
+        { sender: HUMAN, body: '你好', ts: t0 + 2000, fromMe: true },
+        { sender: HUMAN, body: '你好', ts: t0 + 3000, fromMe: true },
+      ];
+      const v = detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard, t0 + 3000);
+      expect(v.loop).withContext(v.detail ?? '').toBeFalse();
+      expect(v.streak).toBe(0);
+    });
+
+    it('重复内容被人类消息隔断在不同段 → 不触发', () => {
+      const t0 = 7_300_000;
+      const msgs: LoopMessage[] = [
+        { sender: A, body: '收到', ts: t0, fromMe: false },
+        { sender: A, body: '收到', ts: t0 + 1000, fromMe: false },
+        { sender: HUMAN, body: '继续', ts: t0 + 2000, fromMe: true },
+        { sender: A, body: '收到', ts: t0 + 3000, fromMe: false },
+        { sender: A, body: '收到', ts: t0 + 4000, fromMe: false },
+      ];
+      const v = detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard, t0 + 4000);
+      expect(v.loop).withContext(v.detail ?? '').toBeFalse();
+    });
+
+    it('agent 连续 3 条「收到」仍然触发(收紧口径没有放过真回环)', () => {
+      const t0 = 7_400_000;
+      const msgs: LoopMessage[] = [
+        { sender: HUMAN, body: '你好', ts: t0, fromMe: true },
+        { sender: A, body: '收到', ts: t0 + 1000, fromMe: false },
+        { sender: A, body: '收到。', ts: t0 + 2000, fromMe: false },
+        { sender: A, body: '收到!', ts: t0 + 3000, fromMe: false },
+      ];
+      const v = detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard, t0 + 3000);
+      expect(v.loop).toBeTrue();
+      expect(v.reason).toBe('repeat');
+    });
+
+    it('保护期内(quiet=true)不触发,但诊断值照常返回', () => {
+      const t0 = 7_500_000;
+      const msgs = [A, A, A].map((s, i) => ({
+        sender: s,
+        body: '收到',
+        ts: t0 + i * 1000,
+        fromMe: false,
+      }));
+      const v = detectLoop(msgs, isHuman, LOOP_GUARD_PRESETS.standard, t0 + 3000, { quiet: true });
+      expect(v.loop).toBeFalse();
+      expect(v.streak).toBe(3);
+    });
+  });
 });

+ 75 - 17
src/app/core/loop-guard.ts

@@ -39,6 +39,25 @@ export interface LoopMessage {
   body: string;
   /** 毫秒时间戳 */
   ts: number;
+  /**
+   * 是否我方(用户)发出。来源 `Message.sender === 'me'`,是**唯一可信的身份判据**。
+   *
+   * 🔴 为什么不用 mxId 比对(2026-11-09):`provisionInfo` 未就绪时 `userMxId` 为空串,
+   *    所有消息都会被当成 agent,尾部一有重复就误亮横幅。单聊里非我方消息必然来自容器,
+   *    根本不需要比对 ID。
+   *
+   * 不填时回落到 `isHuman(sender)`,让纯逻辑的老用例不受影响。
+   */
+  fromMe?: boolean;
+}
+
+/** 判定选项 */
+export interface DetectOptions {
+  /**
+   * 是否处于保护期(如新建会话后的前若干秒)。
+   * 为 true 时三路规则都短路为「不触发」,但 `streak` / `perMinute` 照常计算供诊断。
+   */
+  quiet?: boolean;
 }
 
 export interface LoopGuardConfig {
@@ -79,24 +98,33 @@ export interface LoopVerdict {
  * 判定是否进入回环。
  *
  * @param messages 按时间**升序**排列(旧 → 新)
- * @param isHuman 判断某条消息是否来自人类(非 agent)
+ * @param isHuman 判断某条消息是否来自人类(非 agent);`fromMe` 有值时优先用 `fromMe`
+ * @param opts 判定选项(保护期等)
  */
 export function detectLoop(
   messages: LoopMessage[],
   isHuman: (sender: string) => boolean,
   cfg: LoopGuardConfig | null,
   now: number = Date.now(),
+  opts: DetectOptions = {},
 ): LoopVerdict {
   if (!cfg || messages.length === 0) {
     return { loop: false, streak: 0, perMinute: 0 };
   }
 
-  // ① 连续数:从最后一条往前数,直到遇到人类消息
-  let streak = 0;
-  for (let i = messages.length - 1; i >= 0; i--) {
-    if (isHuman(messages[i].sender)) break;
-    streak++;
-  }
+  /*
+   * 🔴 回环只可能发生在 agent 之间(2026-11-09)。
+   *
+   * 踩坑:`repeat` 规则原来拿「最近 3 条任意消息」比重复,不区分发送者。
+   * 用户在 A / B / C 三个会话各发一句「你好」,进到第三个会话时窗口里
+   * 三条都是「你好」→ 判定回环 → 界面提示「已暂停自动回复」,
+   * 用户以为容器不回,实际容器 13 秒后就回了。
+   *
+   * 现在统一在「尾部连续的 agent 段」里判定:人类消息是段的边界,
+   * 也永远不参与重复比较。
+   */
+  const tail = agentTail(messages, isHuman);
+  const streak = tail.length;
 
   /*
    * ①b **连续数只是必要条件,不是充分条件**(2026-10-21 实测修正)
@@ -112,14 +140,21 @@ export function detectLoop(
    *   · 连续数达标 **但** 每条内容不同 → 是在干活,放过
    */
   const streakExceeded = streak >= cfg.maxStreak;
-  const repeatedOnStreak = streakExceeded && hasRepeat(messages, cfg.repeatWindow);
+  const repeatedOnStreak = streakExceeded && hasRepeat(tail, cfg.repeatWindow);
 
-  // ② 速率:最近一分钟内的消息数
+  // ② 速率:最近一分钟内的消息数(只算 agent,用户连着说话不算回环)
   const since = now - 60_000;
-  const perMinute = messages.filter((m) => m.ts >= since).length;
+  const perMinute = messages.filter(
+    (m) => m.ts >= since && !isFromMe(m, isHuman),
+  ).length;
 
-  // ③ 重复度(独立于连续数)
-  const repeated = hasRepeat(messages, cfg.repeatWindow);
+  // ③ 重复度(独立于连续数,但同样只在连续 agent 段内看)
+  const repeated = hasRepeat(tail, cfg.repeatWindow);
+
+  // 保护期:诊断值照常返回,但一律不触发
+  if (opts.quiet) {
+    return { loop: false, streak, perMinute };
+  }
 
   if (repeatedOnStreak) {
     return {
@@ -143,7 +178,7 @@ export function detectLoop(
     return {
       loop: true,
       reason: 'repeat',
-      detail: `最近 ${cfg.repeatWindow} 条内容高度重复`,
+      detail: `连续 ${streak} 条内容高度重复`,
       streak,
       perMinute,
     };
@@ -151,15 +186,38 @@ export function detectLoop(
   return { loop: false, streak, perMinute };
 }
 
+/** 该条是否我方发出:`fromMe` 有值时以它为准,否则回落到 `isHuman` */
+function isFromMe(m: LoopMessage, isHuman: (sender: string) => boolean): boolean {
+  return m.fromMe !== undefined ? m.fromMe : isHuman(m.sender);
+}
+
+/**
+ * 尾部**连续**的 agent 消息(从后往前,遇到我方消息即停)。
+ *
+ * ① 连续数与 ③ 重复度共用同一段,保证两者口径一致。
+ */
+function agentTail(
+  messages: LoopMessage[],
+  isHuman: (sender: string) => boolean,
+): LoopMessage[] {
+  let i = messages.length;
+  while (i > 0) {
+    if (isFromMe(messages[i - 1], isHuman)) break;
+    i--;
+  }
+  return messages.slice(i);
+}
+
 /**
- * 最后 N 条是否内容高度重复。
+ * 一段消息中最后 N 条是否内容高度重复。
  *
+ * 入参是 `agentTail()` 的结果(尾部连续 agent 消息),不含我方消息。
  * 用「去掉标点空白后的字符二元组相似度」,对「收到」「还在处理」「继续等待」
  * 这类近似但不完全相同的复读也有效。
  */
-function hasRepeat(messages: LoopMessage[], window: number): boolean {
-  if (messages.length < window || window < 2) return false;
-  const tail = messages.slice(-window).map((m) => normalize(m.body));
+function hasRepeat(segment: LoopMessage[], window: number): boolean {
+  if (segment.length < window || window < 2) return false;
+  const tail = segment.slice(-window).map((m) => normalize(m.body));
   // 与第一条比,其余都高度相似 → 复读
   const first = tail[0];
   if (!first) return false;

+ 23 - 0
src/app/core/matrix-client.service.ts

@@ -88,6 +88,15 @@ export interface MxMessage {
    * 避免被「派活之后的房间级消息」规则误挡。
    */
   sessionId?: string;
+  /**
+   * 发送时写入的本地消息 id(`com.fmode.local`)。
+   *
+   * 🔴 用于把「乐观消息」和它的服务端 echo 对上(2026-11-09):
+   *    乐观消息 id 是 `local-*`,echo 的 id 是 `$eventId`,两者天然不同。
+   *    没有这个键,同一条消息会在「本地一条 + 回读一条」地重复出现
+   *    (实测:发一句「你好」,`messages()` 里出现 3 条)。
+   */
+  localId?: string;
 }
 
 /** 群聊派活元信息(写入 Matrix content 的 `com.fmode.dispatch`) */
@@ -104,6 +113,8 @@ export interface MxDispatchMeta {
 export const DISPATCH_KEY = 'com.fmode.dispatch';
 /** Matrix content 里存放单聊归属的字段名 */
 export const SESSION_KEY = 'com.fmode.session';
+/** Matrix content 里存放本地消息 id 的字段名(把乐观消息和自己的 echo 对上) */
+export const LOCAL_ID_KEY = 'com.fmode.local';
 
 /**
  * 从消息 content 解析群聊派活标记。
@@ -133,6 +144,16 @@ export function extractSessionId(content: Record<string, unknown>): string | und
   return undefined;
 }
 
+/**
+ * 从消息 content 解析发送时写入的本地消息 id。
+ *
+ * 形状不对一律 undefined —— 宁可多显示一条,也不能认错人。
+ */
+export function extractLocalId(content: Record<string, unknown>): string | undefined {
+  const raw = content[LOCAL_ID_KEY];
+  return typeof raw === 'string' && raw ? raw : undefined;
+}
+
 /** 同步连接状态(驱动 ChatService 的 ConnectionState) */
 export type MxSyncState = 'connecting' | 'connected' | 'disconnected';
 
@@ -740,6 +761,7 @@ export class MatrixClientService implements OnDestroy {
         //    与实时流那条路径**两处都要加**,否则刷新后归属信息丢失。
         dispatch: extractDispatch(e.getContent()),
         sessionId: extractSessionId(e.getContent()),
+        localId: extractLocalId(e.getContent()),
         body: (e.getContent()['body'] as string) ?? '',
         // 原生附件 → 转成 FileCard(mxc:// 在这里转成可访问的 HTTP 地址)
         media: (() => {
@@ -1091,6 +1113,7 @@ export class MatrixClientService implements OnDestroy {
         // 与 timelineToMessages 同一套解析(两处必须一致,否则刷新前后归属不同)
         dispatch: extractDispatch(ev.getContent()),
         sessionId: extractSessionId(ev.getContent()),
+        localId: extractLocalId(ev.getContent()),
         body: (ev.getContent()['body'] as string) ?? '',
         media,
         ts: ev.getTs(),

+ 21 - 2
src/app/desktop/pages/chat/chat.ts

@@ -369,6 +369,19 @@ interface ConversationRow {
                       新建对话
                     </ui-button>
                   </div>
+                } @else if (chat.emptyReason() === 'unloaded') {
+                <!--
+                  有会话、但一条消息也归属不到它(2026-11-09)。
+                  🔴 不能显示「下午好,我是 xxx」引导页 —— 那会让用户以为
+                     这是一个新建会话、内容丢了,实际是这个会话的历史取不到。
+                -->
+                <div class="no-session">
+                  <i class="fa-solid fa-inbox" aria-hidden="true"></i>
+                  <h2 class="no-session-title">这个会话没有可加载的消息</h2>
+                  <p class="no-session-sub">
+                    会话记录还在,但关联的消息暂时取不到。你可以直接发消息开始新的对话。
+                  </p>
+                </div>
                 } @else {
                 <!--
                   空会话欢迎区(2026-10-29,参考 ChatGPT 落地页):
@@ -2097,8 +2110,14 @@ export class DkChatPage {
       if (t.userMessage) {
         out.push({ kind: 'msg', key: t.userMessage.id, message: t.userMessage });
       }
-      // 有内容或仍在输出才出卡,避免空回合占位
-      if (t.merged || t.live) {
+      /*
+       * 有内容 / 仍在输出 / **已发出等待回复** 都要出卡(2026-11-09)。
+       *
+       * 🔴 最后一个条件是新加的。原来只有前两个,而「刚发出去、容器还没回」
+       *    时既没有内容也不 live,于是**什么都不显示**,用户干等约 10 秒
+       *    才看到「正在思考」(实测)。「已发出」本身就该立刻可见。
+       */
+      if (t.merged || t.live || t.userMessage) {
         out.push({ kind: 'turn', key: `turn-${t.id}`, turn: t });
       }
     }

+ 9 - 11
src/app/desktop/pages/groups/groups.ts

@@ -26,7 +26,6 @@ import { LiveService } from '../../../core/live.service';
 import { DeliverablesService } from '../../../core/deliverables.service';
 import { Deliverable } from '../../../core/api/models';
 import { Material, fromAttachment, fromDeliverable, mergeMaterials } from '../../../core/materials';
-import { LoopGuardService } from '../../../core/loop-guard.service';
 import { ARTIFACT_HINT, buildContext, looksLikeResume } from '../../../core/group-context';
 import { availabilityOf } from '../../../core/target-availability';
 import { leaderHealth } from '../../../core/leader-health';
@@ -154,15 +153,15 @@ import { FmodeApi } from '../../../core/api/fmode-api';
             </div>
           }
 
-          <!-- 回环提示(群聊是高发场景:多个 agent 互相回) -->
-          @if (loop.paused()) {
-            <div class="banner-hold">
-              <ui-alert type="warning" title="检测到自动对话循环,已暂停">
-                <p class="loop-detail">{{ loop.pauseDetail() }}</p>
-                <ui-button variant="outline" size="sm" (click)="loop.resume()">继续</ui-button>
-              </ui-alert>
-            </div>
-          }
+          <!--
+            回环提示暂时移除(2026-11-09)。
+
+            原实现注入的是单聊 Guard,读的是当前单聊会话的消息,不是群消息流。
+            结果是群聊页显示的是「当前单聊会话」的判定,两端都串门。
+
+            群聊回环的正确数据源是群消息流,需单独设计后再接入,
+            不在这里复用单聊守卫。
+          -->
 
           <div class="messages" #msgList (scroll)="onScroll()">
             @if (loading()) {
@@ -1182,7 +1181,6 @@ export class DkGroupPage {
   readonly groups = inject(GroupsService);
   readonly roles = inject(GroupRolesService);
   private readonly deliverables = inject(DeliverablesService);
-  readonly loop = inject(LoopGuardService);
   private readonly live = inject(LiveService);
   private readonly chat = inject(ChatService);
   private readonly mx = inject(MatrixClientService);

+ 72 - 8
src/app/mobile/pages/chat/chat.ts

@@ -21,6 +21,7 @@ import { previewKindOf } from '../../../components/preview/file-kind';
 import { LockScrollDirective } from '../../directives/lock-scroll.directive';
 import { BadgeTone, MBadgeComponent, TASK_TONE, TASK_STATUS_TEXT } from '../../components/m-badge/m-badge';
 import { ChatService } from '../../../core/chat.service';
+import { LoopGuardService } from '../../../core/loop-guard.service';
 import { Task } from '../../../core/api/models';
 import { ChatSessionService } from '../../../core/chat-session.service';
 import { TasksService } from '../../../core/tasks.service';
@@ -115,6 +116,20 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
         </button>
       }
 
+      <!--
+        回环提示(2026-11-09):桌面早就有,移动端一直没有 ——
+        手机上看不到「为什么没回复」,只会一脸茫然。
+      -->
+      @if (loop.paused()) {
+        <div class="m-loop-bar" role="status">
+          <div class="m-loop-text">
+            <i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i>
+            <span>检测到自动对话循环,已暂停:{{ loop.pauseDetail() }}</span>
+          </div>
+          <button type="button" class="m-loop-act" (click)="loop.resume()">继续协作</button>
+        </div>
+      }
+
       <div class="m-chat-body" #scroller (scroll)="onScroll($event)">
         @switch (chat.state()) {
           @case ('loading') {
@@ -132,11 +147,20 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
             />
           }
           @case ('empty') {
-            <app-m-empty
-              icon="fa-solid fa-comments"
-              title="开始新的对话"
-              description="发一条消息,数字生命就会开始工作"
-            />
+            @if (chat.emptyReason() === 'unloaded') {
+              <!-- 有会话但历史取不到(2026-11-09):不能伪装成「开始新的对话」 -->
+              <app-m-empty
+                icon="fa-solid fa-inbox"
+                title="这个会话没有可加载的消息"
+                description="会话记录还在,但关联的消息暂时取不到。可以直接发消息开始新的对话。"
+              />
+            } @else {
+              <app-m-empty
+                icon="fa-solid fa-comments"
+                title="开始新的对话"
+                description="发一条消息,数字生命就会开始工作"
+              />
+            }
           }
           @default {
             <!-- 触顶自动加载(不再需要按钮);这里只显示加载指示 -->
@@ -172,7 +196,8 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
             }
           }
         }
-        @if (chat.peerTyping()) {
+        <!-- 与桌面同口径:回合卡已经在显示等待文案时,这里不再重复一条(2026-11-09) -->
+        @if (chat.peerTyping() && aiRows().length === 0) {
           <p class="m-typing-line">{{ agentName() }} 正在输入…</p>
         }
       </div>
@@ -404,7 +429,44 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
         font-size: var(--fs-12);
         cursor: pointer;
       }
-      /* 回合容器:一次提问的全部输出(2026-11-05) */
+      /* 回环提示条(与 m-conn-bar 同一套 warning 令牌,但主体是说明 + 独立动作按钮) */
+      .m-loop-bar {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-2);
+        width: calc(100% - var(--mob-gutter) * 2);
+        margin: 0 var(--mob-gutter) var(--sp-2);
+        padding: var(--sp-2) var(--sp-3);
+        border: 1px solid var(--warning);
+        border-radius: var(--r-md);
+        background: var(--warning-subtle);
+        color: var(--warning);
+        font-size: var(--fs-12);
+      }
+      .m-loop-text {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-2);
+        flex: 1;
+        min-width: 0;
+      }
+      .m-loop-text span {
+        /* 提示较长,允许换行但不挤掉按钮 */
+        overflow-wrap: anywhere;
+      }
+      .m-loop-act {
+        flex: none;
+        /* 触达区 ≥44px(WCAG 2.2 AA) */
+        min-height: 44px;
+        padding: 0 var(--sp-3);
+        border: 1px solid var(--warning);
+        border-radius: var(--r-sm);
+        background: transparent;
+        color: var(--warning);
+        font: inherit;
+        font-size: var(--fs-13);
+        cursor: pointer;
+      }
       .m-turn {
         display: flex;
         flex-direction: column;
@@ -736,6 +798,7 @@ export class MbChatPage implements OnDestroy {
   private readonly sessionsSvc = inject(ChatSessionService);
   private readonly view = inject(MobileChatViewService);
   protected readonly chat = inject(ChatService);
+  protected readonly loop = inject(LoopGuardService);
 
   protected readonly sheetOpen = signal(false);
   /** 图片走灯箱 */
@@ -807,7 +870,8 @@ export class MbChatPage implements OnDestroy {
       if (t.userMessage) {
         out.push({ kind: 'msg', key: t.userMessage.id, message: t.userMessage });
       }
-      if (t.merged || t.live) {
+      // 已发出等待回复也要出卡(2026-11-09,与桌面同口径)
+      if (t.merged || t.live || t.userMessage) {
         out.push({ kind: 'turn', key: `turn-${t.id}`, turn: t });
       }
     }