// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========

import {
  TaskWorkLogAccordion,
  buildAgentBlocks,
  getBlockHeaderParts,
  getSingleAgentActiveForm,
  getTaskRunDisplayStatus,
  groupBlocksByAgent,
  groupConsecutiveToolItems,
  injectHumanInputReceipts,
  terminalWorkLogI18nKey,
  type AgentBlock,
  type AgentGroup,
  type RepeatedToolItem,
  type TimelineItem,
  type ToolItem,
} from '@/components/ChatBox/MessageItem/TaskWorkLogAccordion';
import type { VanillaChatStore } from '@/store/chatStore';
import {
  AgentStep,
  ChatTaskStatus,
  SessionMode,
  TaskStatus,
  type AgentStepType,
} from '@/types/constants';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

vi.mock('react-i18next', () => ({
  initReactI18next: {
    type: '3rdParty',
    init: vi.fn(),
  },
  Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
  useTranslation: () => ({
    t: (key: string, options: Record<string, unknown> = {}) =>
      key === 'chat.repeated-tool-events'
        ? `${options.tool} · ${options.count} events`
        : key,
    i18n: { language: 'en', changeLanguage: vi.fn() },
  }),
}));

type TaggedLog = Parameters<typeof buildAgentBlocks>[0][number];

function tag(
  agentId: string,
  agentType: string,
  agentName: string,
  entry: AgentMessage
): TaggedLog {
  return { agentId, agentType, agentName, entry };
}

function mk(
  step: AgentStepType,
  data: AgentMessage['data'] = {}
): AgentMessage {
  return { step, data };
}

function findTool(items: TimelineItem[], idx: number) {
  const tools = items.filter((i) => i.kind === 'tool');
  return tools[idx];
}

function findMessage(items: TimelineItem[], idx: number) {
  const messages = items.filter((i) => i.kind === 'message');
  return messages[idx];
}

function makeToolItem(
  id: string,
  overrides: Partial<Omit<ToolItem, 'kind' | 'id'>> = {}
): ToolItem {
  return {
    kind: 'tool',
    id,
    rowTitle: 'Browser Toolkit · Browser visit page',
    toolkitName: 'Browser Toolkit',
    method: 'Browser visit page',
    detail: '',
    input: '',
    output: '',
    status: 'done',
    ...overrides,
  };
}

describe('groupConsecutiveToolItems', () => {
  it('keeps a single tool call on the existing row path', () => {
    const call = makeToolItem('call-1');
    const result = groupConsecutiveToolItems([call]);

    expect(result).toEqual([call]);
    expect(result[0]).toBe(call);
  });

  it('groups adjacent matching toolkit and method calls', () => {
    const first = makeToolItem('call-1');
    const second = makeToolItem('call-2', { status: 'running' });
    const result = groupConsecutiveToolItems([first, second]);

    expect(result).toHaveLength(1);
    expect(result[0]).toMatchObject({
      kind: 'repeated-tool',
      id: 'repeated-tool:call-1',
      rowTitle: 'Browser Toolkit · Browser visit page',
      status: 'running',
    });
    expect((result[0] as RepeatedToolItem).calls).toEqual([first, second]);
  });

  it('normalizes cosmetic toolkit and method separators', () => {
    const first = makeToolItem('call-1');
    const second = makeToolItem('call-2', {
      toolkitName: 'browser_toolkit',
      method: 'browser-visit-page',
      rowTitle: 'browser_toolkit · Browser-visit-page',
    });

    expect(groupConsecutiveToolItems([first, second])).toHaveLength(1);
    expect(groupConsecutiveToolItems([first, second])[0]!.kind).toBe(
      'repeated-tool'
    );
  });

  it('treats messages and Human Toolkit receipts as chronology boundaries', () => {
    const message: TimelineItem = {
      kind: 'message',
      id: 'message-1',
      text: 'Opening the next result',
      source: 'reasoning',
      running: false,
      pairKey: null,
    };
    const first = makeToolItem('call-1');
    const second = makeToolItem('call-2');

    expect(groupConsecutiveToolItems([first, message, second])).toEqual([
      first,
      message,
      second,
    ]);

    const humanTool = makeToolItem('human-call', {
      humanInput: {
        kind: 'human-input',
        id: 'human-input-1',
        question: 'Which output tone?',
        response: null,
      },
    });
    expect(groupConsecutiveToolItems([humanTool, second])).toEqual([
      humanTool,
      second,
    ]);
  });

  it('does not group a different toolkit or method', () => {
    const first = makeToolItem('call-1');
    const differentMethod = makeToolItem('call-2', {
      method: 'Browser open',
      rowTitle: 'Browser Toolkit · Browser open',
    });
    const differentToolkit = makeToolItem('call-3', {
      toolkitName: 'Search Toolkit',
      rowTitle: 'Search Toolkit · Browser visit page',
    });

    expect(
      groupConsecutiveToolItems([first, differentMethod, differentToolkit])
    ).toEqual([first, differentMethod, differentToolkit]);
  });

  it('keeps the first-call group identity when another live call arrives', () => {
    const calls = [
      makeToolItem('call-1'),
      makeToolItem('call-2'),
      makeToolItem('call-3'),
    ];

    const firstGroup = groupConsecutiveToolItems(calls.slice(0, 2))[0];
    const updatedGroup = groupConsecutiveToolItems(calls)[0];

    expect(firstGroup?.id).toBe('repeated-tool:call-1');
    expect(updatedGroup?.id).toBe(firstGroup?.id);
  });

  it('does not mutate the source timeline', () => {
    const source = [makeToolItem('call-1'), makeToolItem('call-2')];
    const snapshot = structuredClone(source);

    groupConsecutiveToolItems(source);

    expect(source).toEqual(snapshot);
  });
});

describe('TaskWorkLogAccordion repeated tool-call rendering', () => {
  function completedCall(
    toolkitName: string,
    method: string,
    request: string,
    response: string
  ): AgentMessage[] {
    return [
      mk(AgentStep.ACTIVATE_TOOLKIT, {
        toolkit_name: toolkitName,
        method_name: method,
        message: request,
      }),
      mk(AgentStep.DEACTIVATE_TOOLKIT, {
        toolkit_name: toolkitName,
        method_name: method,
        message: response,
      }),
    ];
  }

  function createWorkLogStore(log: AgentMessage[]): VanillaChatStore {
    const state = {
      tasks: {
        'task-1': {
          status: ChatTaskStatus.RUNNING,
          sessionMode: SessionMode.WORKFORCE,
          taskTime: 0,
          elapsed: 0,
          messages: [],
          askList: [],
          taskAssigning: [
            {
              agent_id: 'agent-1',
              type: 'browser',
              name: 'Researcher',
              tasks: [],
              log,
            },
          ],
        },
      },
    };

    return {
      getState: () => state,
      subscribe: () => () => undefined,
    } as unknown as VanillaChatStore;
  }

  it('renders duplicate Browser and Todo calls as expandable count rows', () => {
    const log = [
      mk(AgentStep.ACTIVATE_AGENT),
      ...completedCall(
        'Browser Toolkit',
        'Browser visit page',
        '{"url":"https://example.com/one"}',
        'First page'
      ),
      ...completedCall(
        'Browser Toolkit',
        'Browser visit page',
        '{"url":"https://example.com/two"}',
        'Second page'
      ),
      ...completedCall(
        'TodoToolkit',
        'Todo_write',
        '{"todo":"one"}',
        'Saved first todo'
      ),
      ...completedCall(
        'TodoToolkit',
        'Todo_write',
        '{"todo":"two"}',
        'Saved second todo'
      ),
    ];

    render(
      <TaskWorkLogAccordion
        chatStore={createWorkLogStore(log)}
        taskId="task-1"
      />
    );

    const browserGroup = screen.getByRole('button', {
      name: 'Browser Toolkit · Browser visit page · 2 events',
    });
    const todoGroup = screen.getByRole('button', {
      name: 'TodoToolkit · Todo_write · 2 events',
    });

    expect(browserGroup).toHaveAttribute('aria-expanded', 'false');
    expect(todoGroup).toHaveAttribute('aria-expanded', 'false');
    expect(
      screen.getAllByRole('button', {
        name: 'Browser Toolkit · Browser visit page · 2 events',
      })
    ).toHaveLength(1);

    fireEvent.click(browserGroup);

    expect(browserGroup).toHaveAttribute('aria-expanded', 'true');
    expect(
      screen.getAllByRole('button', {
        name: 'Browser Toolkit · Browser visit page',
      })
    ).toHaveLength(2);
  });
});

describe('TaskWorkLogAccordion human-tool detail', () => {
  function createHumanQuestionStore(answer?: string): VanillaChatStore {
    const interactionId = 'interaction-human-1';
    const messages: Message[] = [
      {
        id: 'ask-1',
        role: 'agent',
        content: 'Which output tone should I use: concise or detailed?',
        step: AgentStep.ASK,
        agent_name: 'Researcher',
        interaction: {
          interaction_id: interactionId,
          interaction_type: 'question',
          run_id: 'task-1',
          question: 'Which output tone should I use: concise or detailed?',
        },
      },
    ];
    if (answer) {
      messages.push({
        id: 'answer-1',
        role: 'user',
        content: answer,
        interactionResponseTo: interactionId,
      });
    }

    const state = {
      tasks: {
        'task-1': {
          status: ChatTaskStatus.RUNNING,
          sessionMode: SessionMode.WORKFORCE,
          taskTime: 0,
          elapsed: 0,
          messages,
          askList: [],
          taskAssigning: [
            {
              agent_id: 'agent-1',
              type: 'browser',
              name: 'Researcher',
              tasks: [],
              log: [
                mk(AgentStep.ACTIVATE_AGENT),
                mk(AgentStep.ACTIVATE_TOOLKIT, {
                  toolkit_name: 'Human Toolkit',
                  method_name: 'Ask human via gui',
                  message: '{"question":"Which output tone?"}',
                }),
                mk(AgentStep.DEACTIVATE_TOOLKIT, {
                  toolkit_name: 'Human Toolkit',
                  method_name: 'Ask human via gui',
                  message: 'null',
                }),
              ],
            },
          ],
        },
      },
    };

    return {
      getState: () => state,
      subscribe: () => () => undefined,
    } as unknown as VanillaChatStore;
  }

  it('owns the question inside the Human Toolkit accordion and folds after answer', async () => {
    const scrollTo = vi
      .spyOn(window, 'scrollTo')
      .mockImplementation(() => undefined);
    const { rerender } = render(
      <TaskWorkLogAccordion
        chatStore={createHumanQuestionStore()}
        taskId="task-1"
      />
    );

    const pendingTool = screen.getByRole('button', {
      name: 'Human Toolkit · Ask human via gui',
    });
    expect(pendingTool).toHaveAttribute('aria-expanded', 'true');
    expect(
      screen.getByText('Which output tone should I use: concise or detailed?')
    ).toBeInTheDocument();
    expect(screen.getByText('Input required')).toHaveClass('!text-label-xs');
    expect(screen.getByText('Question')).toHaveClass('!text-label-xs');

    rerender(
      <TaskWorkLogAccordion
        chatStore={createHumanQuestionStore('detailed')}
        taskId="task-1"
      />
    );

    const completedTool = screen.getByRole('button', {
      name: 'Human Toolkit · Ask human via gui',
    });
    expect(completedTool).toHaveAttribute('aria-expanded', 'false');
    await waitFor(() =>
      expect(screen.queryByText('detailed')).not.toBeInTheDocument()
    );

    fireEvent.click(completedTool);
    expect(screen.getByText('Answer')).toHaveClass('!text-label-xs');
    expect(screen.getByText('detailed')).toHaveClass('!text-label-xs');
    scrollTo.mockRestore();
  });
});

describe('injectHumanInputReceipts permission approvals', () => {
  it('does not stack canonical permission approvals onto an unrelated legacy agent group', () => {
    const tools = [
      makeToolItem('todo-write', {
        rowTitle: 'TodoToolkit · Todo_write',
        toolkitName: 'TodoToolkit',
        method: 'Todo_write',
      }),
      makeToolItem('skill-list', {
        rowTitle: 'SkillToolkit · List_skills',
        toolkitName: 'SkillToolkit',
        method: 'List_skills',
      }),
      makeToolItem('memory-search', {
        rowTitle: 'Memory Toolkit · Search',
        toolkitName: 'Memory Toolkit',
        method: 'Search',
      }),
    ];
    const group: AgentGroup = {
      kind: 'agent-group',
      id: 'group-single-agent',
      agentId: 'single-agent',
      agentType: 'single_agent',
      agentName: 'single_agent',
      items: tools,
      status: 'running',
      doneToolCount: tools.length,
      totalToolCount: tools.length,
    };
    const approval = (id: string, title: string): Message => ({
      id: `ask-${id}`,
      role: 'agent',
      content: '',
      step: AgentStep.ASK,
      agent_name: 'single_agent',
      interaction: {
        interaction_id: `approval:${id}`,
        interaction_type: 'approval',
        run_id: 'task-1',
        title,
        question: `The agent wants to run ${title}.`,
      },
    });

    const result = injectHumanInputReceipts(
      [group],
      [
        approval('mcp-call-1', 'execute_action'),
        approval('mcp-call-2', 'execute_action'),
      ]
    );

    expect(result[0]?.items).toEqual(tools);
    expect(
      result
        .flatMap((entry) => entry.items)
        .filter((item) => {
          if (item.kind === 'human-input') return true;
          return item.kind === 'tool' && Boolean(item.humanInput);
        })
    ).toHaveLength(0);
  });
});

describe('terminal Run presentation', () => {
  it('lets a recorded error override a compatibility interrupted status', () => {
    expect(
      getTaskRunDisplayStatus({
        durableRunStatus: 'interrupted',
        messages: [
          {
            step: AgentStep.ERROR,
            content: '❌ **Error**: Client Closed Request',
          },
        ],
      })
    ).toBe('failed');
    expect(terminalWorkLogI18nKey('failed')).toBe('chat.failed-after');
  });

  it('uses explicit labels for interrupted and stopped Runs', () => {
    expect(terminalWorkLogI18nKey('interrupted')).toBe(
      'chat.interrupted-after'
    );
    expect(terminalWorkLogI18nKey('stopped')).toBe('chat.stopped-after');
  });
});

describe('getSingleAgentActiveForm', () => {
  function taskWithAgents(
    agents: Array<{
      type: string;
      tasks: Array<{ id: string; content: string; status: string }>;
    }>
  ): Parameters<typeof getSingleAgentActiveForm>[0] {
    return {
      taskAssigning: agents.map((agent, index) => ({
        agent_id: `agent-${index}`,
        name: agent.type,
        type: agent.type,
        tasks: agent.tasks,
        log: [],
      })) as Agent[],
    };
  }

  it('uses the running single-agent todo content', () => {
    const task = taskWithAgents([
      {
        type: 'single_agent',
        tasks: [
          {
            id: 'todo-1',
            content: 'Read the docs',
            status: TaskStatus.COMPLETED,
          },
          {
            id: 'todo-2',
            content: 'Search for examples',
            status: TaskStatus.RUNNING,
          },
        ],
      },
    ]);

    expect(getSingleAgentActiveForm(task)).toBe('Search for examples');
  });

  it('falls back to the most recent completed single-agent todo', () => {
    const task = taskWithAgents([
      {
        type: 'single_agent',
        tasks: [
          {
            id: 'todo-1',
            content: 'Read the docs',
            status: TaskStatus.COMPLETED,
          },
          {
            id: 'todo-2',
            content: 'Summarize findings',
            status: TaskStatus.COMPLETED,
          },
        ],
      },
    ]);

    expect(getSingleAgentActiveForm(task)).toBe('Summarize findings');
  });

  it('ignores non single-agent groups', () => {
    const task = taskWithAgents([
      {
        type: 'browser_agent',
        tasks: [
          {
            id: 'task-1',
            content: 'Browse',
            status: TaskStatus.RUNNING,
          },
        ],
      },
    ]);

    expect(getSingleAgentActiveForm(task)).toBe('');
  });
});

describe('buildAgentBlocks', () => {
  it('starts a new block on ACTIVATE_AGENT and captures reasoning as the first message', () => {
    const logs = [
      tag(
        'a1',
        'browser_agent',
        'Browser',
        mk(AgentStep.ACTIVATE_AGENT, { message: 'I will open the page.' })
      ),
      tag(
        'a1',
        'browser_agent',
        'Browser',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'Browser Toolkit',
          method_name: 'open',
          message: 'https://example.com',
        })
      ),
    ];
    const blocks = buildAgentBlocks(logs);
    expect(blocks).toHaveLength(1);
    const items = blocks[0]!.items;
    const first = findMessage(items, 0);
    expect(first?.kind).toBe('message');
    expect(first?.kind === 'message' && first.source).toBe('reasoning');
    expect(first?.kind === 'message' && first.text).toBe(
      'I will open the page.'
    );
    const tool = findTool(items, 0);
    expect(tool?.kind === 'tool' && tool.rowTitle).toBe(
      'Browser Toolkit · Open'
    );
  });

  it('preserves chronological message → tool → message → tool ordering', () => {
    const logs = [
      tag('a1', 'x', 'X', mk(AgentStep.ACTIVATE_AGENT, { message: 'plan' })),
      tag(
        'a1',
        'x',
        'X',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'T',
          method_name: 'one',
          message: 'arg=1',
        })
      ),
      tag(
        'a1',
        'x',
        'X',
        mk(AgentStep.DEACTIVATE_TOOLKIT, {
          toolkit_name: 'T',
          method_name: 'one',
          message: 'ok',
        })
      ),
      tag(
        'a1',
        'x',
        'X',
        mk(AgentStep.NOTICE, { notice: 'I found 12 results.' })
      ),
      tag(
        'a1',
        'x',
        'X',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'T',
          method_name: 'two',
          message: 'arg=2',
        })
      ),
    ];
    const blocks = buildAgentBlocks(logs);
    const kinds = blocks[0]!.items.map((i) =>
      i.kind === 'message' ? `m:${i.source}` : `t:${i.method}`
    );
    expect(kinds).toEqual(['m:reasoning', 't:one', 'm:notice', 't:two']);
  });

  it('inserts a sibling narration message above a prose toolkit message', () => {
    const logs = [
      tag('a1', 'x', 'X', mk(AgentStep.ACTIVATE_AGENT, { message: 'plan' })),
      tag(
        'a1',
        'x',
        'X',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'Browser Toolkit',
          method_name: 'clone',
          message:
            'Cloning session abc123 with shared user_data_dir at /tmp/foo',
        })
      ),
    ];
    const blocks = buildAgentBlocks(logs);
    const items = blocks[0]!.items;
    expect(items.map((i) => i.kind)).toEqual(['message', 'message', 'tool']);
    const narration = items[1]!;
    expect(narration.kind).toBe('message');
    expect(narration.kind === 'message' && narration.source).toBe(
      'toolkit_message'
    );
    expect(narration.kind === 'message' && narration.running).toBe(true);
  });

  it('does not add narration for kwargs-shaped toolkit messages', () => {
    const logs = [
      tag(
        'a1',
        'x',
        'X',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'Browser Toolkit',
          method_name: 'open',
          message: "url='https://example.com'",
        })
      ),
    ];
    const blocks = buildAgentBlocks(logs);
    const items = blocks[0]!.items;
    expect(items.map((i) => i.kind)).toEqual(['tool']);
  });

  it('settles the sibling narration when DEACTIVATE_TOOLKIT pairs with the tool', () => {
    const logs = [
      tag('a1', 'x', 'X', mk(AgentStep.ACTIVATE_AGENT, { message: 'plan' })),
      tag(
        'a1',
        'x',
        'X',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'Browser Toolkit',
          method_name: 'clone',
          message:
            'Cloning session abc123 with shared user_data_dir at /tmp/foo',
        })
      ),
      tag(
        'a1',
        'x',
        'X',
        mk(AgentStep.DEACTIVATE_TOOLKIT, {
          toolkit_name: 'Browser Toolkit',
          method_name: 'clone',
          message: 'session ready',
        })
      ),
    ];
    const blocks = buildAgentBlocks(logs);
    const items = blocks[0]!.items;
    const narration = items[1]!;
    expect(narration.kind === 'message' && narration.running).toBe(false);
    const tool = findTool(items, 0);
    expect(tool?.kind === 'tool' && tool.status).toBe('done');
    expect(tool?.kind === 'tool' && tool.detail).toContain('Cloning session');
    expect(tool?.kind === 'tool' && tool.detail).toContain('session ready');
  });

  it('opens a new block on agent-id change without requiring ACTIVATE_AGENT', () => {
    const logs = [
      tag(
        'a1',
        'browser_agent',
        'Browser',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'Browser Toolkit',
          method_name: 'open',
          message: "url='x'",
        })
      ),
      tag(
        'a2',
        'developer_agent',
        'Developer',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'Terminal Toolkit',
          method_name: 'shell_exec',
          message: 'ls',
        })
      ),
    ];
    const blocks = buildAgentBlocks(logs);
    expect(blocks).toHaveLength(2);
    expect(blocks[0]?.agentId).toBe('a1');
    expect(blocks[1]?.agentId).toBe('a2');
  });

  it('marks every non-last block as done', () => {
    const logs = [
      tag('a1', 'x', 'X', mk(AgentStep.ACTIVATE_AGENT, { message: 'one' })),
      tag(
        'a1',
        'x',
        'X',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'T',
          method_name: 'm',
          message: 'a',
        })
      ),
      tag('a1', 'x', 'X', mk(AgentStep.ACTIVATE_AGENT, { message: 'two' })),
    ];
    const blocks = buildAgentBlocks(logs);
    expect(blocks).toHaveLength(2);
    expect(blocks[0]?.status).toBe('done');
    expect(blocks[1]?.status).toBe('running');
  });

  it('flips status to done on DEACTIVATE_AGENT for the current block', () => {
    const logs = [
      tag('a1', 'x', 'X', mk(AgentStep.ACTIVATE_AGENT, { message: 'r' })),
      tag('a1', 'x', 'X', mk(AgentStep.DEACTIVATE_AGENT, {})),
    ];
    const blocks = buildAgentBlocks(logs);
    expect(blocks[0]?.status).toBe('done');
  });

  it('drops NOTICE step into an inline notice message', () => {
    const logs = [
      tag('a1', 'x', 'X', mk(AgentStep.ACTIVATE_AGENT, { message: 'r' })),
      tag('a1', 'x', 'X', mk(AgentStep.NOTICE, { notice: 'heads-up' })),
    ];
    const blocks = buildAgentBlocks(logs);
    const items = blocks[0]!.items;
    const notice = items[1]!;
    expect(notice.kind).toBe('message');
    expect(notice.kind === 'message' && notice.source).toBe('notice');
    expect(notice.kind === 'message' && notice.text).toBe('heads-up');
  });

  it('drops `notice` toolkit messages into inline notice messages, not tools', () => {
    const logs = [
      tag('a1', 'x', 'X', mk(AgentStep.ACTIVATE_AGENT, { message: 'r' })),
      tag(
        'a1',
        'x',
        'X',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'notice',
          message: 'heads-up',
        })
      ),
    ];
    const blocks = buildAgentBlocks(logs);
    const items = blocks[0]!.items;
    expect(items.filter((i) => i.kind === 'tool')).toHaveLength(0);
    expect(items.filter((i) => i.kind === 'message')).toHaveLength(2);
    expect(items[1]?.kind === 'message' && items[1].source).toBe('notice');
  });

  it('skips ACTIVATE_TOOLKIT events with neither method nor message', () => {
    const logs = [
      tag('a1', 'x', 'X', mk(AgentStep.ACTIVATE_AGENT, { message: 'r' })),
      tag(
        'a1',
        'x',
        'X',
        mk(AgentStep.ACTIVATE_TOOLKIT, { toolkit_name: 'T' })
      ),
    ];
    const blocks = buildAgentBlocks(logs);
    expect(blocks[0]?.items.filter((i) => i.kind === 'tool')).toHaveLength(0);
  });
});

describe('buildAgentBlocks — preparation phase', () => {
  it('collapses the leading run of `register agent` events into one Preparing block', () => {
    const logs = [
      tag(
        'a-browser',
        'browser_agent',
        'Browser Agent',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'Browser Toolkit',
          method_name: 'register agent',
          message: 'ChatAgent(Browser Agent)',
        })
      ),
      tag(
        'a-browser',
        'browser_agent',
        'Browser Agent',
        mk(AgentStep.DEACTIVATE_TOOLKIT, {
          toolkit_name: 'Browser Toolkit',
          method_name: 'register agent',
          message: 'null',
        })
      ),
      tag(
        'a-mm',
        'multi_modal_agent',
        'Multi Modal Agent',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'Open Ai Image Toolkit',
          method_name: 'register agent',
          message: 'ChatAgent(Multi Modal Agent)',
        })
      ),
      tag(
        'a-doc',
        'document_agent',
        'Document Agent',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'File Toolkit',
          method_name: 'register agent',
          message: 'ChatAgent(Document Agent)',
        })
      ),
    ];
    const [prep, ...rest] = buildAgentBlocks(logs);
    expect(rest).toHaveLength(0);
    expect(prep?.kind).toBe('preparation');
    expect(prep?.agentName).toBe('Preparing agents');
    const tools = prep!.items.filter(
      (i): i is Extract<TimelineItem, { kind: 'tool' }> => i.kind === 'tool'
    );
    expect(tools).toHaveLength(3);
    expect(tools[0]?.rowTitle).toBe('Browser Agent · Browser Toolkit');
    expect(tools[0]?.status).toBe('done');
    expect(tools[1]?.status).toBe('running');
  });

  it('uses the singular "Preparing agent" label in single-agent mode', () => {
    const logs = [
      tag(
        'a-single',
        'single_agent',
        'CAMEL Agent',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'Todo Toolkit',
          method_name: 'register agent',
          message: 'ChatAgent(CAMEL Agent)',
        })
      ),
    ];
    const [prep] = buildAgentBlocks(logs, true);
    expect(prep?.kind).toBe('preparation');
    expect(prep?.agentName).toBe('Preparing agent');
  });

  it('ends the Preparing block when a non-register event arrives', () => {
    const logs = [
      tag(
        'a-browser',
        'browser_agent',
        'Browser Agent',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'Browser Toolkit',
          method_name: 'register agent',
          message: 'x',
        })
      ),
      tag(
        'a-browser',
        'browser_agent',
        'Browser Agent',
        mk(AgentStep.ACTIVATE_AGENT, { message: 'let me open the page' })
      ),
      tag(
        'a-browser',
        'browser_agent',
        'Browser Agent',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'Browser Toolkit',
          method_name: 'open',
          message: 'https://example.com',
        })
      ),
    ];
    const blocks = buildAgentBlocks(logs);
    expect(blocks).toHaveLength(2);
    expect(blocks[0]?.kind).toBe('preparation');
    expect(blocks[0]?.status).toBe('done');
    expect(blocks[1]?.kind).toBe('action');
    const reasoning = blocks[1]!.items[0];
    expect(reasoning?.kind === 'message' && reasoning.source).toBe('reasoning');
    expect(reasoning?.kind === 'message' && reasoning.text).toBe(
      'let me open the page'
    );
  });

  it('routes mid-run register events to the Preparing block without interrupting the active agent', () => {
    const logs = [
      tag(
        'a-dev',
        'developer_agent',
        'Developer Agent',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'Terminal Toolkit',
          method_name: 'register agent',
          message: 'ChatAgent(Developer Agent)',
        })
      ),
      tag(
        'a-browser',
        'browser_agent',
        'Browser Agent',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'Browser Toolkit',
          method_name: 'register agent',
          message: 'ChatAgent(Browser Agent)',
        })
      ),
      tag(
        'a-browser',
        'browser_agent',
        'Browser Agent',
        mk(AgentStep.ACTIVATE_AGENT, { message: 'let me open the page' })
      ),
      tag(
        'a-browser',
        'browser_agent',
        'Browser Agent',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'Browser Toolkit',
          method_name: 'open',
          message: 'https://example.com',
        })
      ),
      tag(
        'a-mm',
        'multi_modal_agent',
        'Multi Modal Agent',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'Open Ai Image Toolkit',
          method_name: 'register agent',
          message: 'ChatAgent(Multi Modal Agent)',
        })
      ),
    ];
    const blocks = buildAgentBlocks(logs);
    expect(blocks).toHaveLength(2);
    expect(blocks[0]?.kind).toBe('preparation');
    const prepTools = blocks[0]!.items.filter((i) => i.kind === 'tool');
    expect(prepTools.map((t) => t.kind === 'tool' && t.rowTitle)).toEqual([
      'Developer Agent · Terminal Toolkit',
      'Browser Agent · Browser Toolkit',
      'Multi Modal Agent · Open Ai Image Toolkit',
    ]);
    expect(blocks[1]?.kind).toBe('action');
    expect(blocks[1]?.agentId).toBe('a-browser');
    const actionTools = blocks[1]!.items.filter((i) => i.kind === 'tool');
    expect(actionTools).toHaveLength(1);
    expect(actionTools[0]?.kind === 'tool' && actionTools[0].rowTitle).toBe(
      'Browser Toolkit · Open'
    );
  });

  it('routes browser `clone for new session` events to the Preparing block', () => {
    const logs = [
      tag(
        'a-browser',
        'browser_agent',
        'Browser Agent',
        mk(AgentStep.ACTIVATE_AGENT, { message: 'opening the page' })
      ),
      tag(
        'a-browser',
        'browser_agent',
        'Browser Agent',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'Browser Toolkit',
          method_name: 'clone for new session',
          message: 'Cloning session abc123',
        })
      ),
      tag(
        'a-browser',
        'browser_agent',
        'Browser Agent',
        mk(AgentStep.DEACTIVATE_TOOLKIT, {
          toolkit_name: 'Browser Toolkit',
          method_name: 'clone for new session',
          message: 'session ready',
        })
      ),
    ];
    const blocks = buildAgentBlocks(logs);
    expect(blocks).toHaveLength(2);
    expect(blocks[0]?.kind).toBe('preparation');
    const prepTools = blocks[0]!.items.filter((i) => i.kind === 'tool');
    expect(prepTools).toHaveLength(1);
    expect(prepTools[0]?.kind === 'tool' && prepTools[0].rowTitle).toBe(
      'Browser Agent · Browser Toolkit'
    );
    expect(prepTools[0]?.kind === 'tool' && prepTools[0].status).toBe('done');
    // The browser agent's action block must not contain the clone event.
    expect(blocks[1]?.kind).toBe('action');
    const actionTools = blocks[1]!.items.filter((i) => i.kind === 'tool');
    expect(actionTools).toHaveLength(0);
  });

  it('creates a Preparing block even when the first event is an action (for late registrations)', () => {
    const logs = [
      tag(
        'a1',
        'browser_agent',
        'Browser',
        mk(AgentStep.ACTIVATE_AGENT, { message: 'hi' })
      ),
      tag(
        'a1',
        'browser_agent',
        'Browser',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'Browser Toolkit',
          method_name: 'register agent',
          message: 'late register',
        })
      ),
    ];
    const blocks = buildAgentBlocks(logs);
    expect(blocks).toHaveLength(2);
    expect(blocks[0]?.kind).toBe('preparation');
    const prepTools = blocks[0]!.items.filter((i) => i.kind === 'tool');
    expect(prepTools).toHaveLength(1);
    expect(blocks[1]?.kind).toBe('action');
    const actionTools = blocks[1]!.items.filter((i) => i.kind === 'tool');
    expect(actionTools).toHaveLength(0);
  });
});

describe('getBlockHeaderParts', () => {
  function makeBlock(
    items: TimelineItem[],
    status: 'running' | 'done' = 'running'
  ): AgentBlock {
    return {
      id: 'b1',
      agentId: 'a1',
      agentType: 'document_agent',
      agentName: 'Document Agent',
      items,
      status,
      kind: 'action',
    };
  }

  it('shows the latest tool title with a running shimmer when the tool is in flight', () => {
    const block = makeBlock([
      {
        kind: 'tool',
        id: 't1',
        rowTitle: 'File Toolkit · Open',
        toolkitName: 'File Toolkit',
        method: 'open',
        detail: '',
        input: '',
        output: '',
        status: 'running',
      },
    ]);
    expect(getBlockHeaderParts(block)).toEqual({
      agentLabel: 'Document Agent',
      detail: 'File Toolkit · Open',
      detailRunning: true,
    });
  });

  it('updates the detail to the most recent tool as new ones arrive', () => {
    const block = makeBlock([
      {
        kind: 'tool',
        id: 't1',
        rowTitle: 'File Toolkit · Open',
        toolkitName: 'File Toolkit',
        method: 'open',
        detail: '',
        input: '',
        output: '',
        status: 'done',
      },
      {
        kind: 'message',
        id: 'm1',
        text: 'opening DuckDuckGo',
        source: 'reasoning',
        running: false,
        pairKey: null,
      },
      {
        kind: 'tool',
        id: 't2',
        rowTitle: 'File Toolkit · Write',
        toolkitName: 'File Toolkit',
        method: 'write',
        detail: '',
        input: '',
        output: '',
        status: 'running',
      },
    ]);
    const parts = getBlockHeaderParts(block);
    expect(parts.detail).toBe('File Toolkit · Write');
    expect(parts.detailRunning).toBe(true);
  });

  it('drops the shimmer once the latest tool finishes', () => {
    const block = makeBlock(
      [
        {
          kind: 'tool',
          id: 't1',
          rowTitle: 'File Toolkit · Open',
          toolkitName: 'File Toolkit',
          method: 'open',
          detail: '',
          input: '',
          output: '',
          status: 'done',
        },
      ],
      'done'
    );
    const parts = getBlockHeaderParts(block);
    expect(parts.detail).toBe('File Toolkit · Open');
    expect(parts.detailRunning).toBe(false);
  });

  it('shows "Thinking…" while a running block has no tool yet', () => {
    const block = makeBlock([
      {
        kind: 'message',
        id: 'm1',
        text: 'plan',
        source: 'reasoning',
        running: false,
        pairKey: null,
      },
    ]);
    const parts = getBlockHeaderParts(block);
    expect(parts.detail).toBe('Thinking…');
    expect(parts.detailRunning).toBe(true);
  });

  it('shows registered count for the preparation block', () => {
    const prep: AgentBlock = {
      id: 'b-prep',
      agentId: '__prep__',
      agentType: '__prep__',
      agentName: 'Preparing agents',
      items: [
        {
          kind: 'tool',
          id: 't0',
          rowTitle: 'A · B',
          toolkitName: 'A',
          method: 'register agent',
          detail: '',
          input: '',
          output: '',
          status: 'done',
        },
        {
          kind: 'tool',
          id: 't1',
          rowTitle: 'C · D',
          toolkitName: 'C',
          method: 'register agent',
          detail: '',
          input: '',
          output: '',
          status: 'running',
        },
      ],
      status: 'running',
      kind: 'preparation',
    };
    const parts = getBlockHeaderParts(prep);
    expect(parts.agentLabel).toBe('Preparing agents');
    expect(parts.detail).toBe('2 Registered');
    expect(parts.detailRunning).toBe(false);
  });
});

describe('groupBlocksByAgent', () => {
  function makeBlock(
    agentId: string,
    agentType: string,
    agentName: string,
    items: TimelineItem[],
    status: 'running' | 'done' = 'running',
    kind: 'preparation' | 'action' = 'action'
  ): AgentBlock {
    return {
      id: `b-${agentId}-${Math.random().toString(36).slice(2, 6)}`,
      agentId,
      agentType,
      agentName,
      items,
      status,
      kind,
    };
  }

  function makeTool(
    id: string,
    status: 'running' | 'done' = 'done'
  ): TimelineItem {
    return {
      kind: 'tool',
      id,
      rowTitle: `Toolkit · Method`,
      toolkitName: 'Toolkit',
      method: 'Method',
      detail: '',
      input: '',
      output: '',
      status,
    };
  }

  function makeMessage(id: string): TimelineItem {
    return {
      kind: 'message',
      id,
      text: 'some narration',
      source: 'reasoning',
      running: false,
      pairKey: null,
    };
  }

  it('produces a single AgentGroup for a single agent with one block', () => {
    const blocks: AgentBlock[] = [
      makeBlock('a1', 'dev', 'Dev', [makeTool('t1'), makeTool('t2')]),
    ];
    const result = groupBlocksByAgent(blocks);
    expect(result).toHaveLength(1);
    expect(result[0]!.kind).toBe('agent-group');
    const group = result[0] as AgentGroup;
    expect(group.agentId).toBe('a1');
    expect(group.items).toHaveLength(2);
    expect(group.totalToolCount).toBe(2);
    expect(group.doneToolCount).toBe(2);
  });

  it('preserves alternating blocks from the same agent as A, B, A', () => {
    const blocks: AgentBlock[] = [
      makeBlock('a1', 'dev', 'Dev', [makeTool('t1')], 'done'),
      makeBlock('a2', 'browser', 'Browser', [makeTool('t2')], 'done'),
      makeBlock('a1', 'dev', 'Dev', [makeTool('t3')], 'running'),
    ];
    const result = groupBlocksByAgent(blocks);
    expect(result).toHaveLength(3);

    const g1 = result[0] as AgentGroup;
    expect(g1.kind).toBe('agent-group');
    expect(g1.agentId).toBe('a1');
    expect(g1.items.map((i) => i.id)).toEqual(['t1']);
    expect(g1.status).toBe('done');

    const g2 = result[1] as AgentGroup;
    expect(g2.kind).toBe('agent-group');
    expect(g2.agentId).toBe('a2');
    expect(g2.items).toHaveLength(1);
    expect(g2.status).toBe('done');

    const g3 = result[2] as AgentGroup;
    expect(g3.agentId).toBe('a1');
    expect(g3.items.map((i) => i.id)).toEqual(['t3']);
    expect(g3.status).toBe('running');
  });

  it('preserves the preparation block at its original position', () => {
    const prep: AgentBlock = {
      id: 'b-prep',
      agentId: '__prep__',
      agentType: '__prep__',
      agentName: 'Preparing agents',
      items: [makeTool('tp1')],
      status: 'done',
      kind: 'preparation',
    };
    const blocks: AgentBlock[] = [
      prep,
      makeBlock('a1', 'dev', 'Dev', [makeTool('t1')]),
    ];
    const result = groupBlocksByAgent(blocks);
    expect(result).toHaveLength(2);
    expect(result[0]!.kind).toBe('preparation');
    expect(result[1]!.kind).toBe('agent-group');
  });

  it('propagates running status when any block is running', () => {
    const blocks: AgentBlock[] = [
      makeBlock('a1', 'dev', 'Dev', [makeTool('t1')], 'done'),
      makeBlock('a1', 'dev', 'Dev', [makeTool('t2', 'running')], 'running'),
    ];
    const result = groupBlocksByAgent(blocks);
    const group = result[0] as AgentGroup;
    expect(group.status).toBe('running');
  });

  it('computes tool counts correctly across merged blocks', () => {
    const blocks: AgentBlock[] = [
      makeBlock(
        'a1',
        'dev',
        'Dev',
        [makeTool('t1', 'done'), makeMessage('m1'), makeTool('t2', 'done')],
        'done'
      ),
      makeBlock('a1', 'dev', 'Dev', [makeTool('t3', 'running')], 'running'),
    ];
    const result = groupBlocksByAgent(blocks);
    const group = result[0] as AgentGroup;
    expect(group.totalToolCount).toBe(3);
    expect(group.doneToolCount).toBe(2);
    expect(group.items).toHaveLength(4);
  });

  it('handles an empty block merged into a group', () => {
    const blocks: AgentBlock[] = [makeBlock('a1', 'dev', 'Dev', [], 'done')];
    const result = groupBlocksByAgent(blocks);
    const group = result[0] as AgentGroup;
    expect(group.items).toHaveLength(0);
    expect(group.totalToolCount).toBe(0);
    expect(group.doneToolCount).toBe(0);
  });

  it('preserves chronological group order', () => {
    const blocks: AgentBlock[] = [
      makeBlock('a2', 'browser', 'Browser', [makeTool('t1')], 'done'),
      makeBlock('a1', 'dev', 'Dev', [makeTool('t2')], 'done'),
      makeBlock('a3', 'doc', 'Doc', [makeTool('t3')], 'done'),
      makeBlock('a2', 'browser', 'Browser', [makeTool('t4')], 'running'),
    ];
    const result = groupBlocksByAgent(blocks);
    expect(result).toHaveLength(4);
    expect((result[0] as AgentGroup).agentId).toBe('a2');
    expect((result[1] as AgentGroup).agentId).toBe('a1');
    expect((result[2] as AgentGroup).agentId).toBe('a3');
    expect((result[3] as AgentGroup).agentId).toBe('a2');
  });

  it('integrates with buildAgentBlocks for interleaved multi-agent logs', () => {
    const logs = [
      tag(
        'a1',
        'dev',
        'Dev',
        mk(AgentStep.ACTIVATE_AGENT, { message: 'plan' })
      ),
      tag(
        'a1',
        'dev',
        'Dev',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'T',
          method_name: 'one',
          message: 'x',
        })
      ),
      tag(
        'a2',
        'browser',
        'Browser',
        mk(AgentStep.ACTIVATE_AGENT, { message: 'browse' })
      ),
      tag(
        'a2',
        'browser',
        'Browser',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'B',
          method_name: 'open',
          message: 'y',
        })
      ),
      tag(
        'a1',
        'dev',
        'Dev',
        mk(AgentStep.ACTIVATE_AGENT, { message: 'continue' })
      ),
      tag(
        'a1',
        'dev',
        'Dev',
        mk(AgentStep.ACTIVATE_TOOLKIT, {
          toolkit_name: 'T',
          method_name: 'two',
          message: 'z',
        })
      ),
    ];
    const blocks = buildAgentBlocks(logs);
    expect(blocks.length).toBeGreaterThanOrEqual(3);

    const grouped = groupBlocksByAgent(blocks);
    const agentGroups = grouped.filter(
      (e): e is AgentGroup => e.kind === 'agent-group'
    );
    expect(agentGroups).toHaveLength(3);
    expect(agentGroups[0]!.agentId).toBe('a1');
    expect(agentGroups[1]!.agentId).toBe('a2');
    expect(agentGroups[2]!.agentId).toBe('a1');

    const devTools = agentGroups
      .filter((group) => group.agentId === 'a1')
      .flatMap((group) => group.items)
      .filter((i) => i.kind === 'tool');
    expect(devTools).toHaveLength(2);
  });
});
