|
|
@@ -402,6 +402,72 @@ describe('turnsFromDm(单聊)', () => {
|
|
|
expect(t.blocks[0].seconds).withContext('首末 agent 消息差').toBe(7);
|
|
|
});
|
|
|
|
|
|
+ /*
|
|
|
+ * 🔴 容器结束信号(2026-11-11 用户实测:正文 16.8 秒发完,
|
|
|
+ * 界面挂到 50 多秒仍是「输出中」)。
|
|
|
+ *
|
|
|
+ * 根因:容器整轮不发任何结束信号,App 只能等静默窗口
|
|
|
+ * (无步骤 25 秒 / 有步骤 60 秒)。容器侧插件改为在 `post_llm_call`
|
|
|
+ * 发 `m.typing=false` 后,App 收到即收尾。
|
|
|
+ */
|
|
|
+ describe('容器结束信号 turnEndedAfter(按消息 id 认领)', () => {
|
|
|
+ it('🔴 信号绑定到本块末条消息 → 立刻收尾,不再等 60 秒宽限', () => {
|
|
|
+ const [t] = turnsFromDm(
|
|
|
+ // 有步骤(空 body 但有过程)时宽限是 STALL_MS=60s,这里正是要绕开它
|
|
|
+ [dmTurn([msg('a1', 'agent', '💻 bash', 16_000)], { closed: false })],
|
|
|
+ T0 + 20_000,
|
|
|
+ false,
|
|
|
+ 25_000,
|
|
|
+ false,
|
|
|
+ 'a1', // 信号认领的就是本块末条消息
|
|
|
+ );
|
|
|
+ const b = t.blocks[0];
|
|
|
+ expect(b.turnEnded).toBe(true);
|
|
|
+ expect(b.live).withContext('有结束信号就不该继续输出中').toBe(false);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('无信号(未改造的老容器)→ 仍走静默窗口兜底', () => {
|
|
|
+ const [t] = turnsFromDm(
|
|
|
+ [dmTurn([msg('a1', 'agent', '💻 bash', 16_000)], { closed: false })],
|
|
|
+ T0 + 20_000,
|
|
|
+ false,
|
|
|
+ 25_000,
|
|
|
+ false,
|
|
|
+ null, // 无信号
|
|
|
+ );
|
|
|
+ expect(t.blocks[0].turnEnded).toBe(false);
|
|
|
+ expect(t.blocks[0].live).withContext('没信号时行为不变,继续输出中').toBe(true);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('信号绑到的是更早的消息(上一轮残留)→ 不认,避免误切断', () => {
|
|
|
+ const [t] = turnsFromDm(
|
|
|
+ [dmTurn([msg('a1', 'agent', 'a', 8000), msg('a2', 'agent', 'b', 30_000)], { closed: false })],
|
|
|
+ T0 + 32_000,
|
|
|
+ false,
|
|
|
+ 25_000,
|
|
|
+ false,
|
|
|
+ 'a1', // 本块末条是 a2,信号只说 a1 结束
|
|
|
+ );
|
|
|
+ expect(t.blocks[0].turnEnded).toBe(false);
|
|
|
+ expect(t.blocks[0].live).toBe(true);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('有信号时读数定格在末条消息,与容器真实耗时一致', () => {
|
|
|
+ const [t] = turnsFromDm(
|
|
|
+ [dmTurn([msg('a1', 'agent', 'x', 4000), msg('a2', 'agent', 'y', 16_800)])],
|
|
|
+ T0 + 25_000,
|
|
|
+ false,
|
|
|
+ 25_000,
|
|
|
+ false,
|
|
|
+ 'a2',
|
|
|
+ );
|
|
|
+ const b = t.blocks[0];
|
|
|
+ expect(b.live).toBe(false);
|
|
|
+ // 16.8 秒干完 → 读数就是 16 秒,不会因为等了 25 秒而显示 25
|
|
|
+ expect(elapsedSeconds(b, T0 + 25_000)).toBe(16);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
it('无锚点(容器主动发言)→ 0,不显示', () => {
|
|
|
const [t] = turnsFromDm(
|
|
|
[dmTurn([msg('a1', 'agent', 'x', 1000)], { userMessage: undefined, startedAt: 0, userAskTimes: [] })],
|
|
|
@@ -609,8 +675,20 @@ describe('phaseOf(交付口径)', () => {
|
|
|
const [running] = turnsFromDm([turnOf()], T0 + 30_000, false);
|
|
|
expect(running.phase).withContext('跑工具的正常空档仍算输出中').toBe('replying');
|
|
|
|
|
|
- const [stuck] = turnsFromDm([turnOf()], T0 + 90_000, false);
|
|
|
- expect(stuck.phase).withContext('静止超过 60 秒才算结束').toBe('progressOnly');
|
|
|
+ /*
|
|
|
+ * 🔴 2026-11-11 口径再调整(用户裁决「应该保持到会话结束」):
|
|
|
+ * 静止 90 秒**不再**判结束 —— 容器跑 2 分钟长任务是正常的。
|
|
|
+ * 只有到 180 秒用户级超时才收尾(异常态,页面弹「容器无响应」)。
|
|
|
+ */
|
|
|
+ const [stillRunning] = turnsFromDm([turnOf()], T0 + 90_000, false);
|
|
|
+ expect(stillRunning.phase).withContext('没有结束信号就继续输出中').toBe('replying');
|
|
|
+
|
|
|
+ /*
|
|
|
+ * 180 秒后**仍不判结束** —— 状态如实显示,异常由 `isTurnStalled` 弹提示。
|
|
|
+ * (见下面「长任务宽限」用例的完整说明。)
|
|
|
+ */
|
|
|
+ const [timedOut] = turnsFromDm([turnOf()], T0 + 185_000, false);
|
|
|
+ expect(timedOut.phase).withContext('没有结束信号就继续如实显示').toBe('replying');
|
|
|
});
|
|
|
|
|
|
it('什么都没有 → idle', () => {
|
|
|
@@ -625,13 +703,13 @@ describe('phaseOf(交付口径)', () => {
|
|
|
describe('isTurnTimedOut', () => {
|
|
|
const MIN = 60_000;
|
|
|
|
|
|
- it('超过 3 分钟且一个字没回 → 超时', () => {
|
|
|
- expect(isTurnTimedOut(181_000, false, false)).toBe(true);
|
|
|
+ it('超过 5 分钟且一个字没回 → 超时', () => {
|
|
|
+ expect(isTurnTimedOut(301_000, false, false)).toBe(true);
|
|
|
expect(isTurnTimedOut(TURN_TIMEOUT_MS, false, false)).toBe(true);
|
|
|
});
|
|
|
|
|
|
- it('未到 3 分钟 → 不算超时', () => {
|
|
|
- expect(isTurnTimedOut(179_000, false, false)).toBe(false);
|
|
|
+ it('未到 5 分钟 → 不算超时', () => {
|
|
|
+ expect(isTurnTimedOut(299_000, false, false)).toBe(false);
|
|
|
});
|
|
|
|
|
|
it('🔴 已经有输出 → 不算超时(容器干活慢不等于异常)', () => {
|
|
|
@@ -658,8 +736,16 @@ describe('isTurnStalled(卡住检测)', () => { it('有输出、已停止
|
|
|
expect(isTurnStalled(59_000, true, false)).toBe(false);
|
|
|
});
|
|
|
|
|
|
- it('🔴 仍在 live → 属于正常思考,不判卡住', () => {
|
|
|
- expect(isTurnStalled(10 * 60_000, true, true)).toBe(false);
|
|
|
+ it('🔴 live 但长时间没有新增 → 同样要提示(2026-11-11 口径定稿)', () => {
|
|
|
+ /*
|
|
|
+ * 旧判据 `if (live) return false` 是基于「live 说明还在动」的假设。
|
|
|
+ * 但 `live` 现在的语义是「还没收到结束信号」—— 长任务卡死时它一直为真,
|
|
|
+ * 于是「容器无响应」永远不弹,用户只能干等。
|
|
|
+ *
|
|
|
+ * 新判据只看**有没有新东西**:有输出 + 长时间无新增 = 该提醒了(只是提示,不改状态)。
|
|
|
+ */
|
|
|
+ expect(isTurnStalled(10 * 60_000, true, true)).withContext('卡死要提示').toBe(true);
|
|
|
+ expect(isTurnStalled(5_000, true, true)).withContext('刚有新增不打扰').toBe(false);
|
|
|
});
|
|
|
|
|
|
it('🔴 一个字都没回 → 交给 isTurnTimedOut,两个判据不重叠', () => {
|
|
|
@@ -699,10 +785,87 @@ describe('🔴 长任务宽限:有过程步骤时不应过早判已结束(20
|
|
|
expect(t.phase).withContext('不能说「已结束」').toBe('replying');
|
|
|
});
|
|
|
|
|
|
- it('静止 70 秒(超过 60s 宽限)→ 才判已结束', () => {
|
|
|
+ /*
|
|
|
+ * 🔴 2026-11-11 口径定稿(用户裁决「应该保持到会话结束」)。
|
|
|
+ *
|
|
|
+ * 结束**只看容器信号**(✅/❌ reaction),不靠时间猜。
|
|
|
+ * 只要这一轮有过程步骤、容器没说结束,就一直算「输出中」,
|
|
|
+ * 唯一的出口是 180 秒用户级超时(那时会弹「容器无响应」异常提示,
|
|
|
+ * 而不是伪装成「已完成」)。
|
|
|
+ */
|
|
|
+ it('🔴 静止 70 秒 + 无结束信号 → 仍算进行中(不靠时间猜结束)', () => {
|
|
|
const [t] = turnsFromDm([workingTurn()], T0 + 72_000, false);
|
|
|
+ expect(t.blocks[0].live).withContext('没有结束信号就不能说完成').toBeTrue();
|
|
|
+ expect(t.phase).withContext('不能显示「已完成」').toBe('replying');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('🔴 静止 170 秒(长任务)+ 无结束信号 → 仍在进行中', () => {
|
|
|
+ const [t] = turnsFromDm([workingTurn()], T0 + 170_000, false);
|
|
|
+ expect(t.blocks[0].live).withContext('长任务跑 170 秒是正常的,不能判完成').toBeTrue();
|
|
|
+ expect(t.phase).toBe('replying');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('🔴 超过 180 秒 → 状态仍如实显示「输出中」,由 isTurnStalled 弹提示', () => {
|
|
|
+ /*
|
|
|
+ * 2026-11-11 用户裁决:「应该保持到会话结束」。
|
|
|
+ * 超时**不再强行把状态改成已结束** —— 那还是「用时间猜」。
|
|
|
+ * 长任务跑多久就显示多久,同时由页面层弹「容器无响应」提示告知用户。
|
|
|
+ */
|
|
|
+ const now = T0 + 185_000;
|
|
|
+ const [t] = turnsFromDm([workingTurn()], now, false);
|
|
|
+ expect(t.blocks[0].live).withContext('干活期间始终如实显示').toBeTrue();
|
|
|
+ // 但异常提示照常触发:已经有输出、且很久没有新增
|
|
|
+ const idleMs = now - t.blocks[0].updatedAt;
|
|
|
+ expect(isTurnStalled(idleMs, true, t.blocks[0].live)).withContext('要弹「容器无响应」').toBeTrue();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('🔴 收到结束信号 → 立刻收尾,不等 90 秒兜底', () => {
|
|
|
+ const turn = workingTurn();
|
|
|
+ const lastId = turn.blocks[turn.blocks.length - 1].id;
|
|
|
+ const [t] = turnsFromDm([turn], T0 + 30_000, false, 25_000, false, lastId);
|
|
|
+ expect(t.blocks[0].turnEnded).toBeTrue();
|
|
|
expect(t.blocks[0].live).toBeFalse();
|
|
|
- expect(t.phase).toBe('progressOnly');
|
|
|
+ });
|
|
|
+
|
|
|
+ /*
|
|
|
+ * 🔴 截图实证场景(2026-11-11 用户反馈):
|
|
|
+ *
|
|
|
+ * 19:16 用户提问「帮我查天气」
|
|
|
+ * 19:16 容器回「收到,我这就去查,大约一分钟内给结论。」
|
|
|
+ * 过程区「运行了 1 步 · Running code … 10s 已完成」
|
|
|
+ * 然后静默 60 秒以上,真正的天气结果才到
|
|
|
+ *
|
|
|
+ * 用户质问:为什么 10s 就显示「已完成」?计时为什么停了?
|
|
|
+ *
|
|
|
+ * 这个场景的特征是「**有工具步骤 + 有一句正文 + 长时间静默**」,
|
|
|
+ * 与纯过程(`progressOnly`)不同 —— 它已经有交付内容了,会被判成 `done`。
|
|
|
+ */
|
|
|
+ it('🔴 截图场景:有步骤 + 一句正文 + 静默 → 收尾前不能说已完成', () => {
|
|
|
+ const shotTurn = (): Turn =>
|
|
|
+ dmTurn(
|
|
|
+ [msg('a1', 'agent', '💻 Running code from hermes_tools\n收到,我这就去查,大约一分钟内给结论。', 1000)],
|
|
|
+ { userMessage: msg('u1', 'me', '帮我查天气', 0), closed: true },
|
|
|
+ );
|
|
|
+ // 装好:确实被拆成「过程 + 正文」两块内容
|
|
|
+ const [pre] = turnsFromDm([shotTurn()], T0 + 12_000, false);
|
|
|
+ expect(pre.blocks[0].steps.length).withContext('要有过程步骤').toBeGreaterThan(0);
|
|
|
+ expect(pre.blocks[0].body.length).withContext('要有正文').toBeGreaterThan(0);
|
|
|
+
|
|
|
+ // 静默 60 秒(超过 STALL_MS)但没收到结束信号 → 仍应算进行中
|
|
|
+ const [t] = turnsFromDm([shotTurn()], T0 + 62_000, false);
|
|
|
+ expect(t.blocks[0].live).withContext('容器还在跑,不能标已完成').toBeTrue();
|
|
|
+ expect(t.phase).withContext('不能显示「已完成」').toBe('replying');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('🔴 截图场景:收到 ✅ reaction 后立刻收尾', () => {
|
|
|
+ const shotTurn = (): Turn =>
|
|
|
+ dmTurn(
|
|
|
+ [msg('a1', 'agent', '💻 Running code from hermes_tools\n收到,我这就去查,大约一分钟内给结论。', 1000)],
|
|
|
+ { userMessage: msg('u1', 'me', '帮我查天气', 0), closed: true },
|
|
|
+ );
|
|
|
+ const [t] = turnsFromDm([shotTurn()], T0 + 62_000, false, 25_000, false, 'a1');
|
|
|
+ expect(t.blocks[0].turnEnded).withContext('✅ 到达后应认领').toBeTrue();
|
|
|
+ expect(t.blocks[0].live).withContext('收到结束信号才收尾').toBeFalse();
|
|
|
});
|
|
|
|
|
|
it('没有任何步骤 → 仍按原静默窗口收尾(宽限只给真在干活的)', () => {
|