| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437 |
- 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> = {}): 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> = {}): 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> = {}): 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);
- });
- });
|