import { GroupBlock, GroupTurn } from './group-turns'; import { Message } from './api/models'; import { Turn } from './turn-aggregator'; import { isTurnTimedOut, TURN_TIMEOUT_MS, turnsFromDm, turnsFromGroup, waitText, } from './assistant-turn'; /** * 回合视图模型单测(2026-11-06) * * 覆盖重点是**拆分口径**:哪些内容进顶部过程区、哪些留下半区, * 以及单聊/群聊两条输入路径产出的结构是否同构。 */ /* ---------- 构造器 ---------- */ /** * 构造一条消息。 * * 🔴 `ts` 用**相对偏移**而不是绝对时间戳:`ts=0` 会被 `new Date(0)` 解释为 * 1970 年,与 `startedAt/updatedAt` 的判空口径(0 = 无数据)冲突。 * 这里统一加一个基准时间,保证 `ts > 0`。 */ const T0 = Date.parse('2026-11-06T10:00:00Z'); function msg(id: string, sender: 'me' | 'agent', text: string, ts = 0): Message { return { id, conversationId: 'c1', sender, kind: 'text', text, ts: new Date(T0 + ts).toISOString(), state: 'sent', }; } function block(agentId: string, messages: Message[], opts: Partial = {}): GroupBlock { const times = messages.map((m) => Date.parse(m.ts)).filter((n) => n > 0); return { agentId, messages, startedAt: times.length ? Math.min(...times) : 0, updatedAt: times.length ? Math.max(...times) : 0, typing: false, closed: true, ...opts, }; } function groupTurn(blocks: GroupBlock[], opts: Partial = {}): GroupTurn { return { id: 'u1', userMessage: msg('u1', 'me', '帮我看看'), blocks, targets: blocks.map((b) => b.agentId), userMessageTs: 0, closed: true, ...opts, }; } function dmTurn(blocks: Message[], opts: Partial = {}): Turn { const times = blocks.map((m) => Date.parse(m.ts)).filter((n) => n > 0); return { id: 'u1', userMessage: msg('u1', 'me', '你好'), blocks, startedAt: times.length ? Math.min(...times) : 0, updatedAt: times.length ? Math.max(...times) : 0, closed: true, ...opts, }; } /* ---------- 用例 ---------- */ describe('turnsFromDm(单聊)', () => { it('固定产出 1 个块,agentId 为 null', () => { const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', '好的')])], 0, false); expect(t.blocks.length).toBe(1); expect(t.blocks[0].agentId).toBeNull(); expect(t.blocks[0].key).toBe('agent'); }); it('正文留在下半区,不被误判为过程', () => { const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', '这是正常的一段回答。')])], 0, false); expect(t.blocks[0].steps.length).toBe(0); expect(t.blocks[0].body.length).toBe(1); expect(t.blocks[0].body[0].kind).toBe('text'); }); it('混在正文里的过程行被拆到顶部过程区', () => { const text = '我先看一下环境。\n· bash\n找到了。'; const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', text)])], 0, false); const b = t.blocks[0]; expect(b.steps.length).toBeGreaterThan(0); // 正文里不能再出现过程行,否则折叠后还会看到一遍 const bodyText = b.body .filter((x) => x.kind === 'text') .map((x) => (x.kind === 'text' ? x.message.text : '')) .join('\n'); expect(bodyText).toContain('我先看一下环境'); expect(bodyText).not.toContain('· bash'); }); it('以 emoji 开头的**成句中文**按正文处理,不折进过程区', () => { /* * 真实案例(2026-11-07 实测):容器这样汇报一次自我升级 —— * 💾 自我升级小结:🔧 「multi-artifact-delivery」磨得更锋利了。 * 行首 💾 会命中工具 emoji,但它是一句结论,用户要读的是这句话。 */ const text = '💾 自我升级小结:🔧 「multi-artifact-delivery」磨得更锋利了。'; const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', text)])], 0, false); const b = t.blocks[0]; expect(b.steps.length).toBe(0); expect(b.body.length).toBe(1); expect(b.body[0].kind === 'text' && b.body[0].message.text).toContain('自我升级小结'); }); it('真正的终端活动行(英文工具名)仍然进过程区', () => { const text = '💻 bash\n✍️ Writing /opt/data/work/index.html'; const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', text)])], 0, false); expect(t.blocks[0].steps.length).toBeGreaterThan(0); expect(t.blocks[0].body.length).toBe(0); }); it('正在输出时 live 为真,且不受 closed 影响时按静默窗口判断', () => { const [open] = turnsFromDm( [dmTurn([msg('a1', 'agent', '输出中', 1000)], { closed: false })], T0 + 1500, true, ); expect(open.live).toBe(true); expect(open.phase).toBe('replying'); // 末条消息之后已远超静默窗口,且不再 typing → 视为结束 const [quiet] = turnsFromDm( [dmTurn([msg('a1', 'agent', '说完了', 1000)], { closed: false })], T0 + 60_000, false, ); expect(quiet.live).toBe(false); }); /* * 耗时口径(2026-11-10 用户裁决):从**用户发出消息**起算。 * 原实现是「首末 agent 消息时间差」,会卡住再跳变,已废弃。 */ it('耗时从用户发消息起算,取首末消息跨度', () => { const [t] = turnsFromDm( [dmTurn([msg('a1', 'agent', 'x', 0), msg('a2', 'agent', 'y', 4200)], { userMessage: undefined })], 0, false, ); // 无用户消息时回落到首条 agent 消息作锚点 expect(t.blocks[0].seconds).toBe(4); }); it('live 时按当前时钟稳定递增(不再卡住)', () => { const userAt = msg('u1', 'me', '你好', 0); const turn = dmTurn([msg('a1', 'agent', 'x', 2000)], { userMessage: userAt, closed: false, }); // 第三个参数是 typing —— 容器正在输出,回合保持 live const at10s = turnsFromDm([turn], T0 + 10_000, true)[0]; const at15s = turnsFromDm([turn], T0 + 15_000, true)[0]; // 同一个回合、同一条最后消息,但时钟推进 → 秒数必须跟着涨 expect(at10s.blocks[0].live).toBe(true); expect(at10s.blocks[0].seconds).toBe(10); expect(at15s.blocks[0].seconds).toBe(15); }); it('回合结束后冻结,不再随时间增长', () => { const userAt = msg('u1', 'me', '你好', 0); const turn = dmTurn([msg('a1', 'agent', 'x', 8000)], { userMessage: userAt, closed: true, }); const early = turnsFromDm([turn], T0 + 30_000, false)[0]; const later = turnsFromDm([turn], T0 + 600_000, false)[0]; expect(early.blocks[0].live).toBe(false); // 冻结在「末条消息 - 用户发消息」= 8 秒 expect(early.blocks[0].seconds).toBe(8); expect(later.blocks[0].seconds).toBe(8); }); it('容器排队时间也算进用户等待', () => { const userAt = msg('u1', 'me', '你好', 0); // 用户 0s 发出,容器 40s 才回 const turn = dmTurn([msg('a1', 'agent', 'x', 40_000)], { userMessage: userAt, closed: true, }); const [t] = turnsFromDm([turn], T0 + 60_000, false); expect(t.blocks[0].seconds).toBe(40); }); it('无 message 的回合 userMessage 为空', () => { const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', 'x')], { userMessage: undefined })], 0, false); expect(t.userMessage).toBeUndefined(); }); }); describe('turnsFromGroup(群聊)', () => { it('每个容器一块,顺序保持 targets 声明顺序', () => { const turns = [ groupTurn([ block('agent-a', [msg('a1', 'agent', 'A 的回复')]), block('agent-b', [msg('b1', 'agent', 'B 的回复')]), ]), ]; const [t] = turnsFromGroup(turns, 0); expect(t.blocks.map((b) => b.agentId)).toEqual(['agent-a', 'agent-b']); expect(t.blocks.every((b) => b.agentId !== null)).toBe(true); }); it('nameOf 能注入成员显示名', () => { const turns = [groupTurn([block('agent-a', [msg('a1', 'agent', 'x')])])]; const [t] = turnsFromGroup(turns, 0, 3000, (id) => (id === 'agent-a' ? '见微' : id)); expect(t.blocks[0].agentName).toBe('见微'); }); it('userFiles 透传到回合视图', () => { const file = { id: 'f1', name: 'a.png', url: 'u', mimeType: 'image/png', size: 1024 }; const turns = [ groupTurn([block('agent-a', [msg('a1', 'agent', 'x')])], { userFiles: [{ ...msg('f1', 'me', ''), file: { kind: 'file', ...file } }], }), ]; const [t] = turnsFromGroup(turns, 0); expect(t.userFiles.length).toBe(1); }); it('某成员未响应时,该块 empty 为真', () => { const turns = [ groupTurn([ block('agent-a', [msg('a1', 'agent', '有内容')]), block('agent-b', [], { startedAt: 0, updatedAt: 0 }), ]), ]; const [t] = turnsFromGroup(turns, 0); expect(t.blocks[0].empty).toBe(false); expect(t.blocks[1].empty).toBe(true); }); it('回合步骤数为各块之和', () => { const turns = [ groupTurn([ block('agent-a', [msg('a1', 'agent', '· bash')]), block('agent-b', [msg('b1', 'agent', '· read_file')]), ]), ]; const [t] = turnsFromGroup(turns, 0); expect(t.stepCount).toBe(t.blocks[0].steps.length + t.blocks[1].steps.length); expect(t.stepCount).toBeGreaterThan(0); }); it('多个成员并行输出时,任一 live 即回合 live', () => { const turns = [ groupTurn( [ block('agent-a', [msg('a1', 'agent', 'x', 1000)], { typing: true, closed: false }), block('agent-b', [msg('b1', 'agent', 'y', 1000)], { typing: false, closed: false }), ], { closed: false }, ), ]; const [t] = turnsFromGroup(turns, 1200); expect(t.blocks[0].live).toBe(true); expect(t.blocks[1].live).toBe(true); // 静默窗口内仍算在输出 expect(t.live).toBe(true); }); }); describe('单聊与群聊的结构一致性', () => { it('两条路径产出的 body 块结构相同(单聊 1 块 vs 群聊 N 块)', () => { const dm = turnsFromDm([dmTurn([msg('a1', 'agent', '同一段回答')])], 0, false)[0]; const gp = turnsFromGroup( [groupTurn([block('agent-a', [msg('a1', 'agent', '同一段回答')])])], 0, )[0]; expect(dm.blocks.length).toBe(1); expect(gp.blocks.length).toBe(1); expect(dm.blocks[0].body.map((b) => b.kind)).toEqual(gp.blocks[0].body.map((b) => b.kind)); expect(dm.stepCount).toBe(gp.stepCount); 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('🔴 已结束且什么也没有 → 不再显示「未响应」气泡(2026-11-10 改为全局提示)', () => { expect(waitText(30_000, false, false, true)).toBe(''); }); }); /* * 🔴 2026-11-10:「已完成」必须代表真的有交付。 * 原判据「只要不是全空就算 done」,容器只回一串进度也会标「已完成」。 */ describe('phaseOf(交付口径)', () => { const user = () => msg('u1', 'me', '帮我写个文件', 0); it('有正文 → done', () => { const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', '这是最终答复。', 1000)], { userMessage: user() })], T0 + 60_000, false); expect(t.phase).toBe('done'); }); it('🔴 只有过程步骤、没有正文 → progressOnly', () => { const progress = ['🐍 Running code from hermes_tools import terminal r=t...', '🐍 Running code from hermes_tools import search_files...'].join('\n'); const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', progress, 1000)], { userMessage: user() })], T0 + 60_000, false); expect(t.phase).toBe('progressOnly'); }); it('什么都没有 → idle', () => { const [t] = turnsFromDm([dmTurn([], { userMessage: user() })], T0 + 60_000, false); expect(t.phase).toBe('idle'); }); }); /* * 超时未响应判定(2026-11-10 用户裁决:超时改为全局提示)。 */ describe('isTurnTimedOut', () => { const MIN = 60_000; it('超过 3 分钟且一个字没回 → 超时', () => { expect(isTurnTimedOut(181_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('🔴 已经有输出 → 不算超时(容器干活慢不等于异常)', () => { expect(isTurnTimedOut(10 * MIN, true, false)).toBe(false); }); it('🔴 仍在进行中 → 不算超时(可能正在跑长任务)', () => { expect(isTurnTimedOut(10 * MIN, false, true)).toBe(false); }); }); /* * 🔴 2026-11-10 用户实测:容器不回时界面永远挂「已送达,等待回复…(253s)」, * 超时全局提示永远不弹。根因是 `awaitingFirstReply` 让 `live` 恒为真。 */ describe('未回复回合的超时收尾', () => { const userMsg = () => msg('u1', 'me', '你好', 0); /** 用户发了消息,容器一条都没回 */ const emptyTurn = (): Turn => dmTurn([], { userMessage: userMsg(), closed: false }); it('🔴 等待未到 3 分钟 → 仍算 live(正常等待)', () => { const [t] = turnsFromDm([emptyTurn()], T0 + 30_000, false); expect(t.live).toBe(true); expect(t.blocks[0].live).toBe(true); }); it('🔴 等待超过 3 分钟 → 不再 live,占位文案消失', () => { const [t] = turnsFromDm([emptyTurn()], T0 + TURN_TIMEOUT_MS + 5_000, false); expect(t.blocks[0].live).toBe(false); // 不再是「等待回复…」,而是空(交给全局提示) const text = waitText( T0 + TURN_TIMEOUT_MS + 5_000 - T0, false, t.blocks[0].live, t.blocks[0].empty, ); expect(text).toBe(''); }); it('🔴 超过 3 分钟后判定为超时(全局提示依据)', () => { const elapsed = TURN_TIMEOUT_MS + 5_000; const [t] = turnsFromDm([emptyTurn()], T0 + elapsed, false); expect(isTurnTimedOut(elapsed, false, t.blocks[0].live)).toBe(true); }); it('已经回过内容 → 不受该窗口影响', () => { const reply = msg('a1', 'agent', '这是回复。', 2_000); const t0 = dmTurn([reply], { userMessage: userMsg(), closed: false }); const [t] = turnsFromDm([t0], T0 + TURN_TIMEOUT_MS + 5_000, false); expect(t.blocks[0].body.length).toBeGreaterThan(0); }); });