mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-22 15:16:07 +00:00
* refactor(web): extract pure turn-rendering helpers from ChatPane * chore: add changeset for chat pane helper extraction * test(kimi-web): cover chat turn-rendering helpers Pure-logic tests for the helpers extracted from ChatPane, focused on assistantRenderBlocks (tool-stack grouping, interrupt/media break, single tool) plus the formatting/boundary helpers. Doubles as a safety net confirming the extraction preserved behavior.
179 lines
6.2 KiB
TypeScript
179 lines
6.2 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { ChatTurn, ToolCall, TurnBlock } from '../src/types';
|
|
import {
|
|
assistantRenderBlocks,
|
|
formatDuration,
|
|
formatTokens,
|
|
rendersToolCard,
|
|
renderBlockKey,
|
|
toolStackPosition,
|
|
turnBlocks,
|
|
turnFinalText,
|
|
turnToMarkdown,
|
|
} from '../src/components/chatTurnRendering';
|
|
|
|
function tool(id: string, over: Partial<ToolCall> = {}): ToolCall {
|
|
return { id, name: 'read', arg: `· ${id}.ts`, status: 'ok', ...over };
|
|
}
|
|
|
|
function toolBlock(id: string, over: Partial<ToolCall> = {}): Extract<TurnBlock, { kind: 'tool' }> {
|
|
return { kind: 'tool', tool: tool(id, over) };
|
|
}
|
|
|
|
function assistantTurn(blocks: TurnBlock[], over: Partial<ChatTurn> = {}): ChatTurn {
|
|
return { id: 't1', role: 'assistant', no: 1, text: '', blocks, ...over };
|
|
}
|
|
|
|
describe('formatTokens', () => {
|
|
it('keeps small counts verbatim and abbreviates at the k / M thresholds', () => {
|
|
expect(formatTokens(0)).toBe('0');
|
|
expect(formatTokens(999)).toBe('999');
|
|
expect(formatTokens(1000)).toBe('1.0k');
|
|
expect(formatTokens(1500)).toBe('1.5k');
|
|
expect(formatTokens(1_000_000)).toBe('1.0M');
|
|
expect(formatTokens(2_500_000)).toBe('2.5M');
|
|
});
|
|
});
|
|
|
|
describe('formatDuration', () => {
|
|
it('switches units at the 1s and 1m boundaries', () => {
|
|
expect(formatDuration(999)).toBe('999ms');
|
|
expect(formatDuration(1000)).toBe('1.0s');
|
|
expect(formatDuration(59_999)).toBe('60.0s');
|
|
expect(formatDuration(60_000)).toBe('1m0.0s');
|
|
expect(formatDuration(90_500)).toBe('1m30.5s');
|
|
});
|
|
});
|
|
|
|
describe('turnBlocks', () => {
|
|
it('returns the ordered blocks as-is when present', () => {
|
|
const blocks: TurnBlock[] = [{ kind: 'text', text: 'hi' }];
|
|
expect(turnBlocks(assistantTurn(blocks))).toBe(blocks);
|
|
});
|
|
|
|
it('falls back to thinking -> text -> tools order when blocks are absent', () => {
|
|
const turn: ChatTurn = {
|
|
id: 't1',
|
|
role: 'assistant',
|
|
no: 1,
|
|
text: 'answer',
|
|
thinking: 'plan',
|
|
tools: [tool('a')],
|
|
};
|
|
expect(turnBlocks(turn)).toEqual([
|
|
{ kind: 'thinking', thinking: 'plan' },
|
|
{ kind: 'text', text: 'answer' },
|
|
{ kind: 'tool', tool: tool('a') },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('rendersToolCard', () => {
|
|
it('hides the card only for a successful tool that carries inline media', () => {
|
|
expect(rendersToolCard(toolBlock('a'))).toBe(true);
|
|
expect(rendersToolCard(toolBlock('r', { status: 'running' }))).toBe(true);
|
|
expect(
|
|
rendersToolCard(toolBlock('m', { status: 'ok', media: { kind: 'image', url: 'x' } })),
|
|
).toBe(false);
|
|
// media but errored -> still rendered as a card
|
|
expect(
|
|
rendersToolCard(toolBlock('e', { status: 'error', media: { kind: 'image', url: 'x' } })),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('toolStackPosition', () => {
|
|
it('marks a lone tool single and otherwise reports first/middle/last', () => {
|
|
expect(toolStackPosition(0, 1)).toBe('single');
|
|
expect(toolStackPosition(0, 0)).toBe('single');
|
|
expect(toolStackPosition(0, 3)).toBe('first');
|
|
expect(toolStackPosition(1, 3)).toBe('middle');
|
|
expect(toolStackPosition(2, 3)).toBe('last');
|
|
});
|
|
});
|
|
|
|
describe('assistantRenderBlocks', () => {
|
|
it('groups consecutive renderable tools into one tool-stack', () => {
|
|
const rendered = assistantRenderBlocks(assistantTurn([toolBlock('a'), toolBlock('b')]));
|
|
expect(rendered).toHaveLength(1);
|
|
expect(rendered[0]).toMatchObject({ kind: 'tool-stack' });
|
|
if (rendered[0]?.kind === 'tool-stack') {
|
|
expect(rendered[0].tools.map((t) => t.tool.id)).toEqual(['a', 'b']);
|
|
expect(rendered[0].tools.map((t) => t.sourceIndex)).toEqual([0, 1]);
|
|
}
|
|
});
|
|
|
|
it('renders a lone tool as a standalone tool, not a stack', () => {
|
|
const rendered = assistantRenderBlocks(assistantTurn([toolBlock('a')]));
|
|
expect(rendered).toEqual([{ kind: 'tool', tool: tool('a'), sourceIndex: 0 }]);
|
|
});
|
|
|
|
it('breaks the stack when a non-tool block interrupts the run', () => {
|
|
const rendered = assistantRenderBlocks(
|
|
assistantTurn([toolBlock('a'), { kind: 'text', text: 'x' }, toolBlock('b')]),
|
|
);
|
|
expect(rendered.map((b) => b.kind)).toEqual(['tool', 'text', 'tool']);
|
|
});
|
|
|
|
it('breaks the stack when a media tool (no card) interrupts the run', () => {
|
|
const rendered = assistantRenderBlocks(
|
|
assistantTurn([
|
|
toolBlock('a'),
|
|
toolBlock('b'),
|
|
toolBlock('c', { status: 'ok', media: { kind: 'image', url: 'x' } }),
|
|
]),
|
|
);
|
|
expect(rendered.map((b) => b.kind)).toEqual(['tool-stack', 'tool']);
|
|
if (rendered[0]?.kind === 'tool-stack') {
|
|
expect(rendered[0].tools.map((t) => t.tool.id)).toEqual(['a', 'b']);
|
|
}
|
|
});
|
|
|
|
it('preserves thinking/text order with their source indexes', () => {
|
|
const rendered = assistantRenderBlocks(
|
|
assistantTurn([
|
|
{ kind: 'thinking', thinking: 'plan' },
|
|
{ kind: 'text', text: 'answer' },
|
|
]),
|
|
);
|
|
expect(rendered).toEqual([
|
|
{ kind: 'thinking', thinking: 'plan', sourceIndex: 0 },
|
|
{ kind: 'text', text: 'answer', sourceIndex: 1 },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('turnFinalText', () => {
|
|
it('joins only the text blocks, dropping thinking and tools', () => {
|
|
const turn = assistantTurn([
|
|
{ kind: 'thinking', thinking: 'plan' },
|
|
{ kind: 'text', text: 'first' },
|
|
toolBlock('a'),
|
|
{ kind: 'text', text: 'second' },
|
|
]);
|
|
expect(turnFinalText(turn)).toBe('first\n\nsecond');
|
|
});
|
|
});
|
|
|
|
describe('turnToMarkdown', () => {
|
|
it('renders thinking as a quote, text verbatim, and tool output as a fenced block', () => {
|
|
const turn = assistantTurn([
|
|
{ kind: 'thinking', thinking: 'line1\nline2' },
|
|
{ kind: 'text', text: 'hello' },
|
|
toolBlock('a', { name: 'bash', output: ['out1', 'out2'] }),
|
|
]);
|
|
expect(turnToMarkdown(turn)).toBe(
|
|
['> **Thinking**\n> line1\n> line2', 'hello', '```\n[bash]\nout1\nout2\n```'].join('\n\n'),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('renderBlockKey', () => {
|
|
it('derives stable keys per block kind', () => {
|
|
expect(renderBlockKey({ kind: 'text', text: 'x', sourceIndex: 2 }, 0)).toBe('text-2');
|
|
expect(renderBlockKey({ kind: 'tool', tool: tool('a'), sourceIndex: 3 }, 0)).toBe('a');
|
|
expect(
|
|
renderBlockKey({ kind: 'tool-stack', tools: [{ tool: tool('a'), sourceIndex: 5 }] }, 0),
|
|
).toBe('tool-stack-5');
|
|
});
|
|
});
|