Bläddra i källkod

fix(chat): 连发不拆轮 + 历史会话不再永远输出中 + 会话级任务名

用户实测三个问题,全部定位并修复:

## 1. 连发两条消息,UI 顺序错乱
现象:发第一条 → 「已送达等待回复」→ 用户第二条插在中间 → 容器才回。
根因:拆轮判据只看 askCount>1,把「连发补充说明」当成两轮。
修法:追加条件——相邻提问之间必须有 agent 回复才算两轮。

## 2. 历史会话「26208s 输出中」且文字逐字蹦
根因:heldOpenByNoSignal 没有时间上限。✅ 是实时事件,
历史回合的 ✅ 早已发生、App 收不到 → 永远判成在输出。
修法:加 10 分钟上限(覆盖实测长任务 3 分钟+)。

## 3. 容器说空闲,卡片还挂「输出中」
根因:agentIdle 只把 quietMs 置 0,保持逻辑不看它。
修法:agentIdle=true 直接收尾(与 ✅ 同级),并把 agentIdle 改成
三态 boolean|undefined —— 初版把「不知道」当「空闲」会误杀正在跑的回合。

## 4. 其他会话显示当前容器的任务名(串台)
根因:task-pulse 回落全局 status/currentTask。
修法:去掉全局回落;改由容器上报的 currentSessions[].task 提供
本会话任务名,没有则显示「正在处理…」。

## 容器侧配合(已实测生效)
currentSessions 每条新增 task 字段(本轮提问首行,60 字内,仅 running 时输出)。

## 验证
tsc ✅ · 1676 条测试全过 ✅ · 构建 ✅
scripts/verify-unified-state.mjs 5/5 ✅
scripts/verify-session-task.mjs ✅(真实 running 记录带 task)

**未推送、未部署**(用户要求先本地测试)

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 16 timmar sedan
förälder
incheckning
cc4c6af47c

+ 60 - 0
scripts/verify-session-task.mjs

@@ -0,0 +1,60 @@
+#!/usr/bin/env node
+/**
+ * 验证容器是否已上报会话级 `task` 字段(2026-11-11)
+ *
+ * 用途:App 顶栏要显示「本会话在做什么」,依赖 `currentSessions[].task`。
+ * 这个脚本直接回读云端,比看容器自述可靠。
+ *
+ * 用法:node scripts/verify-session-task.mjs
+ */
+import { readFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+const F = 'https://server.fmode.cn/api/functions';
+const AGENT = 'agent-node-lj-test';
+const tok = JSON.parse(readFileSync(join(homedir(), '.fmode/config/user.json'), 'utf8')).sessionToken;
+
+const r = await fetch(F, {
+  method: 'POST',
+  headers: { 'Content-Type': 'application/json' },
+  body: JSON.stringify({ id: 'xlosi3m46q', token: tok, params: { agentId: AGENT } }),
+});
+const j = await r.json();
+const a = j.agent ?? j.result ?? j;
+const ex = typeof a.extra === 'string' ? JSON.parse(a.extra) : a.extra;
+const list = ex?.currentSessions ?? [];
+
+console.log('=== 容器上报的 currentSessions ===\n');
+if (!list.length) {
+  console.log('⚠️ 列表为空 —— 容器可能没有活动会话,先发一条消息再跑本脚本');
+  process.exit(1);
+}
+
+for (const s of list) {
+  console.log(`  status  = ${s.status}`);
+  console.log(`  key     = ${s.sessionKey}`);
+  console.log(`  userId  = ${s.userId ?? '(无)'}`);
+  console.log(`  task    = ${s.task ?? '(无)'}`);
+  console.log(`  字段    = ${Object.keys(s).sort().join(', ')}`);
+  console.log('');
+}
+
+console.log('=== 判定 ===');
+const hasField = list.some((s) => 'task' in s);
+const running = list.filter((s) => s.status === 'running');
+const runningWithTask = running.filter((s) => typeof s.task === 'string' && s.task.trim());
+
+console.log(`有 task 字段的条目:${list.filter((s) => 'task' in s).length} / ${list.length}`);
+console.log(`running 条目:${running.length},其中带 task:${runningWithTask.length}`);
+
+if (!hasField) {
+  console.log('\n❌ 未发现 task 字段 —— 容器还没改完,或没重启 gateway');
+  process.exit(1);
+}
+if (running.length && !runningWithTask.length) {
+  console.log('\n⚠️ 有 running 会话但没带 task —— 容器需要修');
+  process.exit(1);
+}
+console.log('\n✅ task 字段已就绪,App 顶栏可以显示本会话任务名');
+if (runningWithTask[0]) console.log(`   示例:${runningWithTask[0].task}`);

+ 143 - 0
scripts/verify-unified-state.mjs

@@ -0,0 +1,143 @@
+#!/usr/bin/env node
+/**
+ * 统一状态裁决 · 自动化验证(2026-11-11)
+ *
+ * 目的:在**真实容器数据**上验证 App 的状态判定,而不是只看单测。
+ *
+ * 覆盖 5 个核心场景:
+ *   ① 本会话在跑 → 显示「正在回复」
+ *   ② 本会话空闲、别的会话在跑 → 显示「在线」(不串台)
+ *   ③ 收到 ✅ reaction → 立刻收尾
+ *   ④ 容器没上报会话级状态 → 只显示在线/离线,不猜
+ *   ⑤ 多人共用:别人在跑不影响我
+ *
+ * 用法:node scripts/verify-unified-state.mjs
+ */
+import { readFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+const F = 'https://server.fmode.cn/api/functions';
+const AGENT = 'agent-node-lj-test';
+const tok = JSON.parse(readFileSync(join(homedir(), '.fmode/config/user.json'), 'utf8')).sessionToken;
+
+/**
+ * 复刻 App 侧 `resolveSessionPhase()` 的优先级。
+ * 🔴 这段必须与 `src/app/core/agent-liveness.ts` 保持一致,
+ *    脚本只是把同一套规则搬到 Node 里跑真实数据。
+ */
+function resolveSessionPhase({ turnEnded, sessionRunning, typing, turnLive }) {
+  if (turnEnded) return { busy: false, reason: 'reaction' };
+  if (sessionRunning === true) return { busy: true, phase: 'replying', reason: 'session' };
+  if (sessionRunning === false) return { busy: false, reason: 'session' };
+  if (typing) return { busy: true, phase: 'thinking', reason: 'typing' };
+  if (turnLive) return { busy: true, phase: 'replying', reason: 'turn' };
+  return { busy: false, reason: 'idle' };
+}
+
+/** 复刻 `sessionStateOf()`:userId 收窄 → thread 根 → roomId */
+function sessionRunning(sessions, appSessionId, roomId, myUserId) {
+  if (!sessions?.length || !appSessionId) return undefined;
+  const core = appSessionId.split(':')[0];
+  const reportedUsers = sessions.some((s) => !!s.userId);
+  const pool =
+    myUserId && reportedUsers
+      ? sessions.filter((s) => !s.userId || s.userId === myUserId)
+      : sessions;
+  /* 过滤后为空 = 容器没报我的状态 = 不知道,不回落到全集 */
+  if (!pool.length) return undefined;
+  const byThread = pool.find((s) => s.sessionKey.includes(core) || s.sessionKey.includes(appSessionId));
+  const hit = byThread ?? (roomId ? pool.find((s) => s.sessionKey.includes(roomId)) : undefined);
+  return hit ? hit.status === 'running' : undefined;
+}
+
+async function readAgent() {
+  const r = await fetch(F, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ id: 'xlosi3m46q', token: tok, params: { agentId: AGENT } }),
+  });
+  const j = await r.json();
+  const a = j.agent ?? j.result ?? j;
+  const ex = typeof a.extra === 'string' ? JSON.parse(a.extra) : a.extra;
+  return { status: a.status, sessions: ex?.currentSessions ?? [] };
+}
+
+let pass = 0;
+let fail = 0;
+function check(name, actual, expected) {
+  const ok = JSON.stringify(actual) === JSON.stringify(expected);
+  console.log(`  ${ok ? '✅' : '❌'} ${name}`);
+  if (!ok) console.log(`     期望 ${JSON.stringify(expected)} / 实际 ${JSON.stringify(actual)}`);
+  ok ? pass++ : fail++;
+}
+
+console.log('=== 读取容器真实上报 ===\n');
+const d = await readAgent();
+console.log('全局 status =', d.status);
+console.log('currentSessions =', d.sessions.length, '条');
+for (const s of d.sessions) {
+  console.log(`  ${String(s.status).padEnd(8)} ${s.sessionKey}  userId=${s.userId ?? '(无)'}`);
+}
+
+const running = d.sessions.find((s) => s.status === 'running');
+const idle = d.sessions.find((s) => s.status === 'idle');
+const myMx = d.sessions[0]?.userId ?? '@xhvrjtmwqh:fmode.cn';
+const MY_SID = '$approot_selftest_abc:fmode.cn';
+
+console.log('\n=== 场景验证 ===\n');
+
+console.log('① 本会话在跑 → 正在回复');
+{
+  const room = running?.sessionKey;
+  if (room) {
+    const sr = sessionRunning([running], MY_SID, room, myMx);
+    const r = resolveSessionPhase({ turnEnded: false, sessionRunning: sr, typing: false, turnLive: false });
+    check('本会话 running → 忙碌', r.busy, true);
+    check('归类为 session 信号', r.reason, 'session');
+  } else {
+    console.log('  ⏭ 当前没有 running 会话,跳过(可发一条长任务后重跑)');
+  }
+}
+
+console.log('\n② 本会话空闲、别的会话在跑 → 在线(不串台)');
+{
+  if (idle && running) {
+    const sr = sessionRunning([idle, running], MY_SID, idle.sessionKey, myMx);
+    const r = resolveSessionPhase({ turnEnded: false, sessionRunning: sr, typing: true, turnLive: true });
+    check('别的在跑不算我在跑', r.busy, false);
+    check('typing 残留也不能翻盘', r.reason, 'session');
+  } else {
+    console.log('  ⏭ 需要同时存在 running 和 idle 两条,跳过');
+  }
+}
+
+console.log('\n③ 收到 ✅ reaction → 立刻收尾');
+{
+  const r = resolveSessionPhase({ turnEnded: true, sessionRunning: true, typing: true, turnLive: true });
+  check('reaction 一票否决', r.busy, false);
+  check('归类为 reaction', r.reason, 'reaction');
+}
+
+console.log('\n④ 容器没上报会话级状态 → 不猜');
+{
+  const r = resolveSessionPhase({ turnEnded: false, sessionRunning: undefined, typing: false, turnLive: false });
+  check('无信号 → 空闲', r.busy, false);
+  check('不拿全局 status 冒充', r.reason, 'idle');
+}
+
+console.log('\n⑤ 多人共用:别人的会话不影响我');
+{
+  const other = d.sessions.find((s) => s.userId && s.userId !== myMx);
+  if (other) {
+    const sr = sessionRunning([other], MY_SID, other.sessionKey, myMx);
+    check('别人的 running 不算我的', sr, undefined);
+  } else {
+    console.log('  ⏭ 当前库里只有本人会话,用构造数据验证');
+    const fake = [{ sessionKey: '!shared', userId: '@other:fmode.cn', status: 'running' }];
+    check('别人的 running 不算我的', sessionRunning(fake, MY_SID, '!shared', myMx), undefined);
+  }
+}
+
+console.log(`\n=== 结果:${pass} 通过 / ${fail} 失败 ===`);
+process.exit(fail ? 1 : 0);

+ 11 - 0
src/app/core/api/models.ts

@@ -126,6 +126,17 @@ export interface AgentSessionState {
    * (App 按「无 user 信息」处理 → 退回 roomId 匹配,不误判)。
    */
   userId?: string;
+  /**
+   * 本会话本轮的任务摘要(2026-11-11 新增)。
+   *
+   * 容器取 `pre_llm_call` 里的 `user_message` 首行,截断到 60 字。
+   * **只在 `status === 'running'` 时有意义**;idle 时容器可能省略。
+   *
+   * 🔴 为什么需要:容器级的 `currentTask` 没有会话归属 —— 容器跑会话 A 时,
+   * 用户在会话 B 也会看到 A 的任务名(用户实测反馈)。
+   * 这个字段让顶栏能精确显示「**本会话**在做什么」。
+   */
+  task?: string;
   /** 该会话当前是否在跑 */
   status: 'running' | 'idle';
   /** 本轮开始的 epoch 毫秒(idle 时可缺省) */

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

@@ -1143,10 +1143,13 @@ function mapAgent(raw: Record<string, unknown>): Agent {
          * 形状不对就省略 —— 宁可退回 roomId 匹配,也不要拿脏值去比对。
          */
         const uid = typeof s['userId'] === 'string' && s['userId'].startsWith('@') ? s['userId'] : undefined;
+        /* 会话级任务摘要(2026-11-11):形状不对就省略,顶栏退回通用文案 */
+        const task = typeof s['task'] === 'string' && s['task'].trim() ? s['task'].trim() : undefined;
         return [
           {
             sessionKey: key,
             ...(uid ? { userId: uid } : {}),
+            ...(task ? { task } : {}),
             status: st as 'running' | 'idle',
             turnStartAt:
               typeof s['turnStartAt'] === 'number' && Number.isFinite(s['turnStartAt'])

+ 105 - 0
src/app/core/assistant-turn.spec.ts

@@ -1098,3 +1098,108 @@ describe('fmode.kind 优先于文本启发式', () => {
     expect(bodyTextOf(t.blocks[0])).not.toContain('ls -la');
   });
 });
+
+/*
+ * 🔴 历史会话不得永远「输出中」(2026-11-11 用户实测)。
+ *
+ * 现象:点开几天前的会话,卡片显示「26208s 输出中」,文字还在逐字蹦。
+ * 根因:`heldOpenByNoSignal` 初版没有时间上限 ——
+ * 「有步骤 + 没收到 ✅」就永远 live。可 ✅ 是**实时事件**,
+ * 历史回合的 ✅ 早就过去了,App 根本收不到,于是永远判成在跑。
+ */
+describe('历史会话不得永远输出中', () => {
+  const oldTurn = (): Turn =>
+    dmTurn([msg('a1', 'agent', '💻 Reading doc.txt\n这是很久以前的一条回复。', 2000)], {
+      userMessage: msg('u1', 'me', '看下这个文件', 0),
+      closed: true,
+    });
+
+  it('🔴 超过保持上限(10 分钟)→ 已结束,不再转圈', () => {
+    /*
+     * 10 分钟是「有步骤+无信号」的保持上限:覆盖实测到的长任务(容器跑过 3 分钟+),
+     * 又不至于让历史回合永远转圈。
+     *
+     * 注意:真实长任务和「刚结束但没收到 ✅ 的回合」在数据上无法区分,
+     * 所以这里只能靠时间兜底 —— 这是有意的取舍,不是缺陷。
+     */
+    const [t] = turnsFromDm([oldTurn()], T0 + 12 * 60_000, false);
+    expect(t.blocks[0].live).withContext('超过上限必须收尾').toBeFalse();
+  });
+
+  it('🔴 3 天前的历史回合 → 已结束,不演动画', () => {
+    const [t] = turnsFromDm([oldTurn()], T0 + 3 * 24 * 3600_000, false);
+    expect(t.blocks[0].live).withContext('历史会话必须一次到位').toBeFalse();
+    expect(t.live).toBeFalse();
+  });
+
+  it('真实长任务(3 分钟内)仍保持输出中', () => {
+    const [t] = turnsFromDm([oldTurn()], T0 + 3 * 60_000, false);
+    expect(t.blocks[0].live).withContext('10 分钟内的长任务要如实显示在忙').toBeTrue();
+  });
+});
+
+/*
+ * 🔴 「容器说空闲」与「✅ reaction」是同级事实信号(2026-11-11 用户实测)。
+ *
+ * 现象:右上角已显示「在线」(容器报了 idle),卡片却还挂着「50s 输出中」。
+ * 根因:`agentIdle` 只把 quietMs 置 0,「有步骤+无信号」的保持逻辑不看它。
+ */
+describe('容器明确空闲 → 卡片立即收尾', () => {
+  const workTurn = (): Turn =>
+    dmTurn([msg('a1', 'agent', '💻 Searching the web\n结果如下……', 2000)], {
+      userMessage: msg('u1', 'me', '查一下天气', 0),
+      closed: true,
+    });
+
+  it('🔴 agentIdle=true → 即使有步骤且没收到 ✅,也判结束', () => {
+    const [t] = turnsFromDm([workTurn()], T0 + 30_000, false, 25_000, true);
+    expect(t.blocks[0].live).withContext('容器说空闲就该收尾').toBeFalse();
+  });
+
+  it('agentIdle=false(明确在跑)→ 保持输出中', () => {
+    const [t] = turnsFromDm([workTurn()], T0 + 30_000, false, 25_000, false);
+    expect(t.blocks[0].live).toBeTrue();
+  });
+
+  it('🔴 agentIdle=undefined(不知道)→ 不参与判定,走原有保持逻辑', () => {
+    const [t] = turnsFromDm([workTurn()], T0 + 30_000, false, 25_000, undefined);
+    expect(t.blocks[0].live).withContext('不知道不等于空闲').toBeTrue();
+  });
+});
+
+/*
+ * 🔴 用户连发两条,不得被拆成两轮(2026-11-11 用户截图实测)。
+ *
+ * 现象:发第一条 → 显示「已送达等待回复…」→ 用户第二条插在中间 → 容器才回。
+ * 根因:原判据只看 `askCount > 1`,把「用户连发两条补充说明」当成两轮,
+ * 第二条用户消息被挪到两个 agent 块之间渲染。
+ *
+ * 修正:相邻两个提问之间**必须有 agent 消息**(提问1 → 回复 → 提问2)才算两轮。
+ */
+describe('拆轮:用户连发不拆', () => {
+  it('🔴 连发两条、中间无回复 → 只有 1 块', () => {
+    const t = dmTurn([msg('a1', 'agent', '💻 Searching\n结果如下', 30_000)], {
+      userMessage: msg('u1', 'me', '查天气', 0),
+      userAskTimes: [T0, T0 + 3000], // 两条提问,间隔 3 秒,中间没回复
+      rounds: [[msg('a1', 'agent', '💻 Searching\n结果如下', 30_000)]],
+    });
+    const [out] = turnsFromDm([t], T0 + 40_000, false);
+    expect(out.blocks.length).withContext('连发不该拆轮').toBe(1);
+  });
+
+  it('真两轮(提问1 → 回复 → 提问2)→ 才拆', () => {
+    const t = dmTurn(
+      [msg('a1', 'agent', '第一轮回复', 5_000), msg('a2', 'agent', '第二轮回复', 50_000)],
+      {
+        userMessage: msg('u1', 'me', '第一问', 0),
+        userAskTimes: [T0, T0 + 30_000],
+        rounds: [
+          [msg('a1', 'agent', '第一轮回复', 5_000)],
+          [msg('a2', 'agent', '第二轮回复', 50_000)],
+        ],
+      },
+    );
+    const [out] = turnsFromDm([t], T0 + 60_000, false);
+    expect(out.blocks.length).withContext('真两轮要拆').toBe(2);
+  });
+});

+ 91 - 7
src/app/core/assistant-turn.ts

@@ -576,7 +576,17 @@ function toBlockView(
    *    但「已发出」本身就是一个确定的状态,应该立刻可见。
    */
   awaitingFirstReply = false,
-): TurnBlockView {
+  /**
+   * 容器**明确**上报的本会话空闲态(2026-11-11)。
+   *
+   * 三态:`true` 明确空闲 → 直接收尾(与 ✅ reaction 同级的事实信号);
+   * `false` 明确在跑;`undefined` 不知道 → 不参与判定。
+   *
+   * 用户实测:右上角已显示「在线」(容器报 idle),卡片却还挂着「50s 输出中」。
+   * 根因就是这里只把 `quietMs` 置 0,而「有步骤 + 无信号」的保持逻辑不看它。
+   */
+  agentIdle: boolean | undefined = undefined,
+) : TurnBlockView {
   const { steps, body } = splitIntoParts(src.messages);
   const files = body
     .filter((b): b is Extract<TurnBodyBlock, { kind: 'file' }> => b.kind === 'file')
@@ -731,8 +741,48 @@ function toBlockView(
    * ⚠️ 只在**有过程步骤**时启用:没有步骤的纯文本回合(容器只回一句话),
    *    仍按静默窗口收尾 —— 那种回合发完就是发完了,不该无限挂着。
    */
+  /*
+   * 🔴 「有步骤 + 没收到结束信号」→ 保持输出中,但**必须有上限**(2026-11-11)。
+   *
+   * ## 初版没有上限,导致历史会话永远转圈
+   *
+   * 初版写成 `hasSteps && !turnEnded && waitingSince > 0` —— 只要回合有工具步骤、
+   * 又没收到过 ✅,就永远算 live。
+   *
+   * 但 **✅ reaction 是实时事件**:打开几天前的历史会话时,
+   * 那些回合的 ✅ 早就发生过了、App 根本收不到(不是当前房间的实时流)。
+   * 于是每一条历史回合都被判成「还在输出」——
+   * 用户实测:「点开历史会话,显示 26208s 输出中,而且文字还在一个个蹦出来」。
+   *
+   * ## 现在的判据:**只有在「用户刚发出、且还在合理等待窗口内」才保持**
+   *
+   * 以用户提问时刻为锚点,超过 `LONG_TASK_HOLD_MS`(10 分钟)就不再保持 ——
+   *   · 真实长任务:实测容器跑过 3 分钟以上,10 分钟足够覆盖
+   *   · 历史会话:`waitingSince` 是几天前,立刻超出 → 正确判成已结束
+   *   · 容器真挂了:10 分钟后着陆,由 `isTurnStalled` 弹「无响应」提示,不会永远转
+   *
+   * 这个上限**小于** `TURN_TIMEOUT_MS`(5 分钟用户级超时)的语义边界:
+   * 两者都只是「什么时候停止说在忙」,不改写已经发生的事实。
+   */
+  const LONG_TASK_HOLD_MS = 10 * 60_000;
+  /*
+   * 🔴 容器明确说「本会话空闲」→ **立即收尾,优先级高于保持逻辑**(2026-11-11)。
+   *
+   * 用户实测:右上角已经显示「在线」(容器报了 idle),卡片却还挂着「50s 输出中」。
+   * 根因是 `agentIdle` 只把 `quietMs` 置 0,而 `heldOpenByNoSignal` 不看它 ——
+   * 「有步骤 + 没 ✅」照样让块保持 live。
+   *
+   * 这不合理:**容器说空闲** 和 **✅ reaction** 是同一级别的事实信号
+   * (都是容器自己宣布「这轮完了」),只是通道不同、到达时刻可能差几秒。
+   * 任何一个到了都该收尾。
+   */
+  const settledBySession = agentIdle === true;
   const heldOpenByNoSignal =
-    hasSteps && !turnEnded && waitingSince > 0;
+    hasSteps &&
+    !turnEnded &&
+    !settledBySession &&
+    waitingSince > 0 &&
+    now - waitingSince < LONG_TASK_HOLD_MS;
   /*
    * 🔴 超时**不再强行改写状态**(2026-11-11 用户裁决「应该保持到会话结束」)。
    *
@@ -748,6 +798,7 @@ function toBlockView(
     !closedBlocks &&
     !(waitingTooLong && !hasSteps) &&
     !turnEnded &&
+    !settledBySession &&
     (awaitingFirstReply || typingAlive || idleFor < graceMs || heldOpenByNoSignal);
   /*
    * 「还在等回复」——与 `live` **解耦**(2026-11-11 用户实测修复)。
@@ -1086,7 +1137,14 @@ export function turnsFromDm(
   now: number,
   typing: boolean,
   quietMs = DEFAULT_QUIET_MS,
-  agentIdle = false,
+  /**
+   * 容器**明确**上报的本会话空闲态(2026-11-11 统一方案)。
+   *
+   * 🔴 三态:`true` 明确空闲(直接收尾)/ `false` 明确在跑 / `undefined` 不知道。
+   * 初版是布尔且默认 `false`,但调用方传的 `agentIdle()` 曾把「没上报」
+   * 当成「空闲」,会误杀所有仍在跑的回合。现在三态分开,`undefined` 不参与判定。
+   */
+  agentIdle: boolean | undefined = undefined,
   turnEndedAfter: string | null = null,
 ): AssistantTurn[] {
   return turns.map((t) => {
@@ -1119,8 +1177,33 @@ export function turnsFromDm(
      * 现在只在**确实有多个用户提问**(图文各发一条)时才拆轮;
      * 单次提问不论间隔多久都只出 1 块。
      */
-    const askCount = (t.userAskTimes ?? []).filter((a) => a > 0).length;
-    const canSplit = askCount > 1 && (t.rounds?.length ?? 0) > 1;
+    const userAskTimes = (t.userAskTimes ?? []).filter((a) => a > 0);
+    const askCount = userAskTimes.length;
+    /*
+     * 🔴 拆轮的真正前提:**每个提问之间都隔着新一轮对话**(2026-11-11 修正)。
+     *
+     * 用户实测(截图):发第一条后马上补发第二条「以 html 的形式发给我」,
+     * 界面出现「已送达等待回复 → 用户第二条 → 容器回复」的错乱顺序。
+     *
+     * 根因:原判据只看 `askCount > 1`,把「用户连发两条补充说明」也当成两轮,
+     * 于是第二条用户消息被挪到两个 agent 块之间渲染。
+     *
+     * 现在追加一条:**相邻两个提问之间必须有 agent 消息**
+     * (即提问 1 → 回复 → 提问 2),才认为真的是两轮。
+     * 连发两条(中间容器一句话都没说)视为同一轮,不拆。
+     */
+    const asks = [...userAskTimes].sort((a, b) => a - b);
+    const hasReplyBetweenAsks =
+      asks.length > 1 &&
+      asks.slice(1).every((at, i) => {
+        const prev = asks[i];
+        return (t.blocks ?? []).some((m) => {
+          const mt = Date.parse(m.ts) || 0;
+          return mt > prev && mt < at;
+        });
+      });
+    const canSplit =
+      askCount > 1 && hasReplyBetweenAsks && (t.rounds?.length ?? 0) > 1;
     const rounds: Message[][] = canSplit
       ? t.rounds!
       : t.blocks.length > 0
@@ -1168,9 +1251,10 @@ export function turnsFromDm(
                 msgs[msgs.length - 1]?.id === turnEndedAfter,
             },
             now,
-            agentIdle ? 0 : quietMs,
+            agentIdle === true ? 0 : quietMs,
             startAt,
             false,
+            agentIdle,
           );
         })
       : [
@@ -1185,7 +1269,7 @@ export function turnsFromDm(
               closed: t.closed,
             },
             now,
-            agentIdle ? 0 : quietMs,
+            agentIdle === true ? 0 : quietMs,
             userAsk,
             pendingReply,
           ),

+ 44 - 16
src/app/core/chat.service.ts

@@ -47,7 +47,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';
+import { sessionRunning, sessionStateOf } from './agent-state';
 
 export type ConnectionState = 'connecting' | 'connected' | 'disconnected';
 
@@ -252,23 +252,51 @@ export class ChatService {
    * 容器说空闲 → 立刻收尾;容器在跑或状态未知 → 依静默窗口判定。
    * 只有容器能宣布「干完了」,前端不替它下结论。
    */
-  readonly agentIdle = computed(() => {
+  /**
+   * 容器**明确**上报的「本会话空闲」(2026-11-11 统一方案)。
+   *
+   * 🔴 三态语义,不能用布尔把「不知道」和「空闲」混在一起:
+   *
+   * | 值 | 含义 | 用途 |
+   * |---|---|---|
+   * | `true`  | 容器明确说本会话 idle | **直接收尾**(与 ✅ 同级的事实信号) |
+   * | `false` | 容器明确说本会话在跑 | 保持输出中 |
+   * | `undefined` | 容器没上报 / 不知道该查哪条 | **不参与判定**,交给其他信号 |
+   *
+   * 初版把 `undefined` 也当成「空闲」返回 `true`,会让**所有**回合被
+   * `settledBySession` 判成已结束 —— 那等于把「不知道」当成了「干完了」。
+   *
+   * 另外:**不再回落容器全局 `status`**。它是「一个容器一个值」,
+   * 会话 A 在跑时会话 B 也被判成不空闲,正是「串台」的根源。
+   */
+  readonly agentIdle = computed<boolean | undefined>(() => {
     const id = this._agentId();
-    if (!id) return true;
+    if (!id) return undefined;
     const agent = this.live.agentById(id);
-    if (!agent) return true;
-    /*
-     * 🔴 只用**本会话**的状态,不再回落容器全局 `status`(2026-11-11 统一方案)。
-     *
-     * 原来的最后一行 `return agent.status === 'idle'` 是「串台」的根源:
-     * `status` 是整个容器一个值,会话 A 在跑时会话 B 也被判成「不空闲」,
-     * 于是 B 的回合永远收不了尾、右上角显示 A 的进度。
-     *
-     * 现在:容器没上报本会话 → 返回**空闲**(不知道就是不知道)。
-     * 「是不是在干活」由 `resolveSessionPhase()` 统一裁决,
-     * 这里只回答「容器有没有明确说本会话在跑」。
-     */
-    return sessionRunning(agent.currentSessions, this._sessionId(), this.roomId, this.myMxId()) !== true;
+    if (!agent) return undefined;
+    const running = sessionRunning(
+      agent.currentSessions,
+      this._sessionId(),
+      this.roomId,
+      this.myMxId(),
+    );
+    return running === undefined ? undefined : !running;
+  });
+
+  /**
+   * 本会话当前在做什么(2026-11-11)。
+   *
+   * 容器在 `currentSessions[].task` 里上报本轮提问摘要。
+   * 没有就返回 undefined —— 顶栏退回通用文案「正在处理」,
+   * **绝不拿容器级 `currentTask` 冒充本会话**(那是串台的根源)。
+   */
+  readonly sessionTask = computed<string | undefined>(() => {
+    const id = this._agentId();
+    if (!id) return undefined;
+    const agent = this.live.agentById(id);
+    if (!agent) return undefined;
+    const s = sessionStateOf(agent.currentSessions, this._sessionId(), this.roomId, this.myMxId());
+    return s?.status === 'running' ? s.task : undefined;
   });
 
   /**

+ 19 - 7
src/app/core/task-pulse.service.spec.ts

@@ -46,10 +46,17 @@ describe('TaskPulseService', () => {
     svc = TestBed.inject(TaskPulseService);
   });
 
-  it('容器报 running → 显示任务描述', () => {
+  it('🔴 只有全局 running 不显示任务名(2026-11-11:全局值不得冒充会话值)', () => {
+    /*
+     * 用户实测:「在其他会话里也能看到当前会话的容器状态」。
+     * 根因就是这里回落了全局 currentTask —— 容器在跑会话 A,
+     * 会话 B 的顶栏也显示 A 的任务名。
+     *
+     * 现在只认本会话:没有会话级上报就不显示任务名。
+     */
     const line = svc.describe(agent({ status: 'running', currentTask: '处理企业微信用户对话' }));
-    expect(line.text).toBe('处理企业微信用户对话');
-    expect(line.active).toBeTrue();
+    expect(line.text).withContext('不得显示别人的任务名').not.toBe('处理企业微信用户对话');
+    expect(line.active).toBeFalsy();
   });
 
   it('🔴 用户发消息 → 立刻显示「刚刚开始」(不等容器上报)', () => {
@@ -165,14 +172,19 @@ describe('TaskPulseService', () => {
       expect(line.active).toBeTrue();
     });
 
-    it('🔴 容器没上报会话级状态(老容器)→ 回落全局口径,行为不变', () => {
+    it('🔴 容器没上报会话级状态 → 不回落全局,不显示别人的任务名', () => {
       const line = svc.describe(agent({ status: 'running', currentTask: '写周报' }), Date.now(), 'sess-A');
-      expect(line.text).toBe('写周报');
+      expect(line.text).withContext('会话页不得拿全局 currentTask 冒充').not.toBe('写周报');
     });
 
-    it('不传 sessionId(列表页)→ 仍走全局口径', () => {
+    it('列表页(不传 sessionId)同样不显示全局任务名', () => {
+      /*
+       * 列表页一行代表「这个容器」,显示容器在跑什么本来是合理的;
+       * 但当前实现里 `currentTask` 没有会话归属,与顶栏共用同一份判定,
+       * 一并收敛为「不显示任务名」以免两处口径不一致。
+       */
       const line = svc.describe(agent({ status: 'running', currentTask: '写周报' }));
-      expect(line.text).toBe('写周报');
+      expect(line.text).not.toBe('写周报');
     });
   });
 

+ 9 - 3
src/app/core/task-pulse.service.ts

@@ -306,10 +306,16 @@ export class TaskPulseService {
       return { text: '正在回复…', tone: 'info', active: true };
     }
     /*
-     * `mine === true` 表示**本会话确实在跑**,此时才采信容器的任务名;
-     * 没上报会话级状态(老容器)仍按全局 `status` 判断,行为不变。
+     * 🔴 只认**本会话确实在跑**(2026-11-11 用户实测)。
+     *
+     * 原判据 `mine === true || (mine === undefined && agent.status === 'running')`
+     * 的后半段是全局冒充:容器在跑会话 A 时,会话 B 的顶栏也会显示
+     * A 的 `currentTask` 文案(用户实测:「在其他会话里也能看到当前会话的容器状态」)。
+     *
+     * 现在去掉全局回落:容器没报本会话 → 不显示任务名,显示「在线」。
+     * 不知道就是不知道,不拿别人的任务名冒充。
      */
-    if ((mine === true || (mine === undefined && agent.status === 'running')) && !isReporterSelfTalk(agent)) {
+    if (mine === true && !isReporterSelfTalk(agent)) {
       const task = agent.currentTask?.trim() || agent.currentAction?.trim();
       return { text: task || '正在处理', tone: 'success', active: true };
     }

+ 8 - 1
src/app/desktop/pages/chat/chat.ts

@@ -2187,14 +2187,21 @@ export class DkChatPage implements OnDestroy {
      * 会话页只看**本会话**的进度(2026-11-11 用户裁决):
      * 容器同一时刻可能在跑别的会话,拿全局 status 会显示别人的任务。
      * 本会话没在跑时返回「在线」,而不是容器全局的「已完成 · 用时 …」。
+     *
+     * 🔴 任务名也必须是**本会话的**(2026-11-11):容器会在
+     * `currentSessions[].task` 里报本轮提问摘要;有它就显示具体任务,
+     * 没有就退回「正在处理」——绝不拿容器级 `currentTask` 冒充。
      */
-    return this.pulse.describe(
+    const line = this.pulse.describe(
       this.live.agentById(agentId),
       Date.now(),
       this.chat.sessionId(),
       this.chat.currentRoomId(),
       this.chat.myMxId(),
     );
+    if (!line.active) return line;
+    const task = this.chat.sessionTask();
+    return task ? { ...line, text: task } : { ...line, text: '正在处理…' };
   }
 
   /** 列表行紧凑标签:一次只答一个问题 —— 在不在干活 */