agentChatEvents.test.js 5.03 KB
/**
 * 回归测试:9b1c3f1 修的"多聊天串台"不变式(40 号文档方案三)。
 *
 * 不变式:handleEvent 收到的每一条事件都要无条件写进它自己的 st(每个 agent 一份),
 * 但只有在事件所属的 requestAgentId 仍等于"用户当前选中的 agent"时,才允许触碰
 * 全局副作用(路由跳转 / 对话列表 / DOM 滚动)。这条测试不需要挂载整个 Vue 组件——
 * handleEvent 已经被抽成不依赖组件实例的纯函数(agentChatEvents.js),直接喂事件、
 * 断言 st 内容和副作用回调的调用次数即可。
 */
import { describe, it, expect, vi } from 'vitest';
import { createAgentChatEventHandler, createTypewriterQueue, flushTypewriter } from './agentChatEvents.js';

function makeState() {
  return {
    messages: [], activeConvId: null,
    sending: false, streaming: false, streamContent: '',
    subAgents: {}, subLogList: [], acpLog: [],
    thinkingText: '', thinkingContent: '', showThinking: true,
  };
}

describe('agentChatEvents · 串台加固', () => {
  it('切走之后,旧 agent 的事件继续更新它自己的 st,但不再触发全局副作用', () => {
    let current = 'agent-A';
    const scrollToBottom = vi.fn();
    const scrollAcpLog = vi.fn();
    const onNewConversation = vi.fn();
    const handleEvent = createAgentChatEventHandler({
      getCurrentAgentId: () => current,
      onNewConversation,
      scrollToBottom,
      scrollAcpLog,
      scrollSubLogs: vi.fn(),
      pushChunk: (st, text) => { st.streamContent += text; }, // 跳过打字机定时器,测同步内容
    });

    const stA = makeState();
    handleEvent(stA, 'agent-A', 'session', { conversationId: 'conv-A' });
    handleEvent(stA, 'agent-A', 'agent_output', { message: '你好' });
    expect(scrollToBottom).toHaveBeenCalledTimes(1); // 只有 agent_output 会调 scrollToBottom
    expect(onNewConversation).toHaveBeenCalledTimes(1); // session 走的是 onNewConversation

    // 用户切到另一个 agent
    current = 'agent-B';
    scrollToBottom.mockClear();
    scrollAcpLog.mockClear();

    handleEvent(stA, 'agent-A', 'agent_output', { message: ',世界' });
    handleEvent(stA, 'agent-A', 'tool_call', { tool: 'bash' });

    // st 照常被写:内容没丢
    expect(stA.streamContent).toBe('你好,世界');
    expect(stA.acpLog).toHaveLength(1);
    // 但不再是当前 agent,全局副作用一次都不该触发
    expect(scrollToBottom).not.toHaveBeenCalled();
    expect(scrollAcpLog).not.toHaveBeenCalled();

    // 切回来之后,新事件恢复触发副作用,且历史内容完好
    current = 'agent-A';
    handleEvent(stA, 'agent-A', 'tool_call', { tool: 'grep' });
    expect(scrollAcpLog).toHaveBeenCalledTimes(1);
    expect(stA.acpLog).toHaveLength(2);
  });

  it('两个 agent 同时在跑,事件不会互相串台到对方的 st 里', () => {
    let current = 'agent-A';
    const handleEvent = createAgentChatEventHandler({
      getCurrentAgentId: () => current,
      onNewConversation: vi.fn(),
      scrollToBottom: vi.fn(),
      scrollAcpLog: vi.fn(),
      scrollSubLogs: vi.fn(),
      pushChunk: (st, text) => { st.streamContent += text; },
    });

    const stA = makeState();
    const stB = makeState();
    // 交替喂 A、B 两条流的事件,模拟后台并发
    handleEvent(stA, 'agent-A', 'agent_output', { message: 'A1' });
    handleEvent(stB, 'agent-B', 'agent_output', { message: 'B1' });
    current = 'agent-B'; // 用户切到 B
    handleEvent(stA, 'agent-A', 'agent_output', { message: 'A2' });
    handleEvent(stB, 'agent-B', 'agent_output', { message: 'B2' });

    expect(stA.streamContent).toBe('A1A2');
    expect(stB.streamContent).toBe('B1B2');
  });
});

describe('agentChatEvents · 打字机渲染缓冲', () => {
  it('文本按固定节奏匀速吐出,而不是一次性整段落地', () => {
    vi.useFakeTimers();
    try {
      const st = makeState();
      const pushChunk = createTypewriterQueue({ tickMs: 10, charsPerTick: 2 });
      pushChunk(st, 'abcdef');

      expect(st.streamContent).toBe(''); // 入队即刻不落地,等定时器吐
      vi.advanceTimersByTime(10);
      expect(st.streamContent).toBe('ab');
      vi.advanceTimersByTime(10);
      expect(st.streamContent).toBe('abcd');
      vi.advanceTimersByTime(10);
      expect(st.streamContent).toBe('abcdef');
      vi.advanceTimersByTime(10); // 队列空了,定时器该自己停
      expect(st._typeTimer).toBeNull();
    } finally {
      vi.useRealTimers();
    }
  });

  it('flushTypewriter 立刻吐光剩余队列(打断/结束时用)', () => {
    vi.useFakeTimers();
    try {
      const st = makeState();
      const pushChunk = createTypewriterQueue({ tickMs: 10, charsPerTick: 1 });
      pushChunk(st, 'hello');
      vi.advanceTimersByTime(10); // 只吐了 1 个字符
      expect(st.streamContent).toBe('h');

      flushTypewriter(st);
      expect(st.streamContent).toBe('hello');
      expect(st._typeTimer).toBeNull();
      expect(st._typeQueue).toBe('');
    } finally {
      vi.useRealTimers();
    }
  });
});