mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-25 17:14:54 +00:00
784 lines
27 KiB
TypeScript
784 lines
27 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
collectFilePathAliases,
|
|
findFilePathLinks,
|
|
parseFilePathLinkCandidate,
|
|
} from '../src/lib/filePathLinks';
|
|
import { parseDiff } from '../src/lib/parseDiff';
|
|
import { buildDiffLines } from '../src/lib/diffLines';
|
|
import { buildEditDiffLines } from '../src/lib/toolDiff';
|
|
import { createCoalescedAsyncRunner } from '../src/lib/snapshotSync';
|
|
import { mergeSnapshotMessages } from '../src/lib/snapshotMessages';
|
|
import { mergeSnapshotSubagents } from '../src/lib/taskMerge';
|
|
import { normalizeToolName, toolSummary } from '../src/lib/toolMeta';
|
|
import { collapsePrompt, humanizeCron } from '../src/lib/cronHumanize';
|
|
import {
|
|
currentValidatedWorkspacePath,
|
|
isWorkspacePathInput,
|
|
joinWorkspacePathCandidate,
|
|
parseWorkspacePathInput,
|
|
} from '../src/lib/workspacePathInput';
|
|
import {
|
|
coerceThinkingForModel,
|
|
commitLevel,
|
|
defaultThinkingLevelFor,
|
|
effortLabel,
|
|
modelThinkingAvailability,
|
|
segmentsFor,
|
|
} from '../src/lib/modelThinking';
|
|
import type { AppMessage, AppModel, AppTask } from '../src/api/types';
|
|
import { resolveToolRenderer } from '../src/components/chat/tool-calls/toolRegistry';
|
|
import AgentTool from '../src/components/chat/tool-calls/AgentTool.vue';
|
|
import EditTool from '../src/components/chat/tool-calls/EditTool.vue';
|
|
import GenericTool from '../src/components/chat/tool-calls/GenericTool.vue';
|
|
import type { ToolCall } from '../src/types';
|
|
import {
|
|
clearTrace,
|
|
installClientErrorCapture,
|
|
sanitizeForTrace,
|
|
sessionExportTraceToJsonl,
|
|
traceEntries,
|
|
traceKeyEvent,
|
|
tracePaused,
|
|
traceRestRequest,
|
|
traceToJsonl,
|
|
traceWsIn,
|
|
} from '../src/debug/trace';
|
|
|
|
// The trace tests exercise its exported recording/serialization contract:
|
|
// session exports receive only bounded, explicitly selected metadata.
|
|
|
|
describe('bounded Web trace', () => {
|
|
beforeEach(() => {
|
|
tracePaused.value = false;
|
|
clearTrace();
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearTrace();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('copies only allowlisted key-path metadata into the independent export ring', () => {
|
|
const secret = 'PROMPT_TEXT_MUST_NOT_BE_EXPORTED';
|
|
const metadata = {
|
|
sessionId: 'sess_1',
|
|
contentCount: 2,
|
|
mediaCount: 1,
|
|
text: secret,
|
|
apiKey: secret,
|
|
};
|
|
traceKeyEvent('prompt:start', metadata);
|
|
|
|
metadata.sessionId = 'changed_after_recording';
|
|
|
|
expect(traceEntries()).toHaveLength(1);
|
|
expect(JSON.parse(sessionExportTraceToJsonl())).toEqual({
|
|
ts: expect.any(Number),
|
|
event: 'prompt:start',
|
|
sessionId: 'sess_1',
|
|
contentCount: 2,
|
|
mediaCount: 1,
|
|
});
|
|
expect(sessionExportTraceToJsonl()).not.toContain(secret);
|
|
});
|
|
|
|
it('records export metadata even while the full debug panel is paused', () => {
|
|
tracePaused.value = true;
|
|
|
|
traceKeyEvent('ws:connection', { status: 'connected' });
|
|
|
|
expect(traceEntries()).toHaveLength(0);
|
|
expect(JSON.parse(sessionExportTraceToJsonl())).toMatchObject({
|
|
event: 'ws:connection',
|
|
status: 'connected',
|
|
});
|
|
});
|
|
|
|
it('caps object keys and reports how many were omitted', () => {
|
|
const input = Object.fromEntries(Array.from({ length: 60 }, (_, index) => [`key${index}`, index]));
|
|
|
|
const result = sanitizeForTrace(input) as Record<string, unknown>;
|
|
|
|
expect(result['_truncatedKeys']).toBe(10);
|
|
expect(Object.keys(result)).toHaveLength(51);
|
|
});
|
|
|
|
it('keeps at most 500 of the newest export entries', () => {
|
|
for (let index = 0; index < 501; index++) {
|
|
traceKeyEvent('ws:connection', { status: String(index) });
|
|
}
|
|
|
|
const exported = sessionExportTraceToJsonl().split('\n').map((line) => JSON.parse(line));
|
|
expect(exported).toHaveLength(500);
|
|
expect(exported[0]).toMatchObject({ status: '1' });
|
|
expect(exported.at(-1)).toMatchObject({ status: '500' });
|
|
});
|
|
|
|
it('keeps export JSONL within the 256 KiB UTF-8 budget including newlines', () => {
|
|
for (let index = 0; index < 500; index++) {
|
|
traceKeyEvent('export:failed', {
|
|
sessionId: `sess-${index}-${'😀'.repeat(200)}`,
|
|
status: '😀'.repeat(200),
|
|
promptId: '😀'.repeat(200),
|
|
errorName: '😀'.repeat(200),
|
|
requestId: '😀'.repeat(200),
|
|
phase: '😀'.repeat(200),
|
|
});
|
|
}
|
|
|
|
const jsonl = sessionExportTraceToJsonl();
|
|
expect(new TextEncoder().encode(jsonl).byteLength).toBeLessThanOrEqual(256 * 1024);
|
|
expect(JSON.parse(jsonl.split('\n').at(-1)!)).toMatchObject({
|
|
sessionId: expect.stringMatching(/^sess-499-/),
|
|
});
|
|
});
|
|
|
|
it('never copies prompt, WebSocket, or console content from the full debug trace', () => {
|
|
vi.stubGlobal('location', { search: '?debug=1' });
|
|
const promptSecret = 'PROMPT_SECRET_9fdb1a';
|
|
const wsSecret = 'WS_PAYLOAD_SECRET_b84c7e';
|
|
const consoleSecret = 'CONSOLE_SECRET_a2d693';
|
|
|
|
traceRestRequest({
|
|
method: 'POST',
|
|
path: '/sessions/sess_1/prompts',
|
|
url: 'http://daemon.test/api/v1/sessions/sess_1/prompts',
|
|
requestId: 'req_1',
|
|
body: { prompt: promptSecret },
|
|
});
|
|
traceWsIn({
|
|
type: 'event',
|
|
session_id: 'sess_1',
|
|
seq: 4,
|
|
payload: { text: wsSecret },
|
|
});
|
|
|
|
const originalLog = console.log;
|
|
console.log = vi.fn();
|
|
const dispose = installClientErrorCapture();
|
|
try {
|
|
console.log(consoleSecret, { value: consoleSecret });
|
|
} finally {
|
|
dispose();
|
|
console.log = originalLog;
|
|
}
|
|
|
|
traceKeyEvent('prompt:start', {
|
|
sessionId: 'sess_1',
|
|
contentCount: 1,
|
|
mediaCount: 0,
|
|
text: promptSecret,
|
|
});
|
|
|
|
const fullDebugTrace = traceToJsonl();
|
|
expect(fullDebugTrace).toContain(promptSecret);
|
|
expect(fullDebugTrace).toContain(wsSecret);
|
|
expect(fullDebugTrace).toContain(consoleSecret);
|
|
|
|
const sessionExportTrace = sessionExportTraceToJsonl();
|
|
expect(sessionExportTrace).not.toContain(promptSecret);
|
|
expect(sessionExportTrace).not.toContain(wsSecret);
|
|
expect(sessionExportTrace).not.toContain(consoleSecret);
|
|
expect(JSON.parse(sessionExportTrace)).toEqual({
|
|
ts: expect.any(Number),
|
|
event: 'prompt:start',
|
|
sessionId: 'sess_1',
|
|
contentCount: 1,
|
|
mediaCount: 0,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('workspace path input', () => {
|
|
it('recognizes the supported absolute path forms', () => {
|
|
expect(isWorkspacePathInput('/tmp/project')).toBe(true);
|
|
expect(isWorkspacePathInput('~/project')).toBe(true);
|
|
expect(isWorkspacePathInput('C:\\project')).toBe(true);
|
|
expect(isWorkspacePathInput('C:/project')).toBe(true);
|
|
expect(isWorkspacePathInput('\\\\server\\share')).toBe(true);
|
|
expect(isWorkspacePathInput('project')).toBe(false);
|
|
});
|
|
|
|
it('normalizes separators without changing UNC roots or POSIX backslashes', () => {
|
|
expect(parseWorkspacePathInput('/tmp//project/', '').target).toBe('/tmp/project');
|
|
expect(parseWorkspacePathInput('//server//share/project/', '').target).toBe('//server/share/project');
|
|
expect(parseWorkspacePathInput('///tmp//project/', '').target).toBe('/tmp/project');
|
|
expect(parseWorkspacePathInput('/tmp/project\\', '').target).toBe('/tmp/project\\');
|
|
expect(parseWorkspacePathInput('~/project', '/home/alice').target).toBe('/home/alice/project');
|
|
});
|
|
|
|
it('preserves Windows root separators in parent paths', () => {
|
|
expect(parseWorkspacePathInput('C:\\Use', '')).toMatchObject({
|
|
parent: 'C:\\',
|
|
base: 'Use',
|
|
separator: '\\',
|
|
});
|
|
expect(parseWorkspacePathInput('C:/Use', '')).toMatchObject({
|
|
parent: 'C:/',
|
|
base: 'Use',
|
|
separator: '/',
|
|
});
|
|
expect(parseWorkspacePathInput('\\\\server\\share\\pro', '')).toMatchObject({
|
|
parent: '\\\\server\\share',
|
|
base: 'pro',
|
|
separator: '\\',
|
|
});
|
|
expect(parseWorkspacePathInput('//server/share/pro', '')).toMatchObject({
|
|
parent: '//server/share',
|
|
base: 'pro',
|
|
separator: '/',
|
|
});
|
|
});
|
|
|
|
it('treats backslashes as literal characters in POSIX paths', () => {
|
|
expect(parseWorkspacePathInput('/tmp/foo\\bar', '')).toMatchObject({
|
|
parent: '/tmp',
|
|
base: 'foo\\bar',
|
|
separator: '/',
|
|
});
|
|
});
|
|
|
|
it('builds completion paths from the lexical parent', () => {
|
|
const parsed = parseWorkspacePathInput('/tmp/link/proje', '');
|
|
expect(joinWorkspacePathCandidate(parsed.parent, 'project', parsed.separator)).toBe('/tmp/link/project');
|
|
});
|
|
|
|
it('only returns a validated path while it still matches the current input', () => {
|
|
expect(currentValidatedWorkspacePath('/var', '', '/tmp')).toBeNull();
|
|
expect(currentValidatedWorkspacePath('/tmp/', '', '/tmp')).toBe('/tmp');
|
|
});
|
|
});
|
|
|
|
describe('parseDiff', () => {
|
|
it('parses multiple files and keeps hunk line numbers', () => {
|
|
const diff = [
|
|
'diff --git a/src/a.ts b/src/a.ts',
|
|
'index 1111111..2222222 100644',
|
|
'--- a/src/a.ts',
|
|
'+++ b/src/a.ts',
|
|
'@@ -1,2 +1,3 @@',
|
|
' const a = 1;',
|
|
'-const b = 2;',
|
|
'+const b = 3;',
|
|
'+const c = 4;',
|
|
'diff --git a/src/comment.sql b/src/comment.sql',
|
|
'@@ -5,1 +5,1 @@',
|
|
'--- old comment',
|
|
'+++ new comment',
|
|
].join('\n');
|
|
|
|
expect(parseDiff(diff)).toEqual([
|
|
{ type: 'hunk', text: '@@ -1,2 +1,3 @@' },
|
|
{ type: 'context', text: 'const a = 1;', oldNo: 1, newNo: 1 },
|
|
{ type: 'del', text: 'const b = 2;', oldNo: 2 },
|
|
{ type: 'add', text: 'const b = 3;', newNo: 2 },
|
|
{ type: 'add', text: 'const c = 4;', newNo: 3 },
|
|
{ type: 'hunk', text: '@@ -5,1 +5,1 @@' },
|
|
{ type: 'del', text: '-- old comment', oldNo: 5 },
|
|
{ type: 'add', text: '++ new comment', newNo: 5 },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('buildDiffLines', () => {
|
|
it('lines up context, deletions and additions with old/new line numbers', () => {
|
|
const before = 'a\nb\nc';
|
|
const after = 'a\nB\nc\nd';
|
|
expect(buildDiffLines(before, after)).toEqual([
|
|
{ type: 'context', text: 'a', oldNo: 1, newNo: 1 },
|
|
{ type: 'del', text: 'b', oldNo: 2 },
|
|
{ type: 'add', text: 'B', newNo: 2 },
|
|
{ type: 'context', text: 'c', oldNo: 3, newNo: 3 },
|
|
{ type: 'add', text: 'd', newNo: 4 },
|
|
]);
|
|
});
|
|
|
|
it('treats an empty before as an all-addition write', () => {
|
|
expect(buildDiffLines('', 'x\ny')).toEqual([
|
|
{ type: 'add', text: 'x', newNo: 1 },
|
|
{ type: 'add', text: 'y', newNo: 2 },
|
|
]);
|
|
});
|
|
|
|
it('returns all context for identical texts and empty for two empties', () => {
|
|
expect(buildDiffLines('a\nb', 'a\nb')).toEqual([
|
|
{ type: 'context', text: 'a', oldNo: 1, newNo: 1 },
|
|
{ type: 'context', text: 'b', oldNo: 2, newNo: 2 },
|
|
]);
|
|
expect(buildDiffLines('', '')).toEqual([]);
|
|
});
|
|
|
|
it('returns null when the LCS matrix would be too large', () => {
|
|
const big = Array.from({ length: 2000 }, (_, i) => `line${i}`).join('\n');
|
|
expect(buildDiffLines(big, `${big}\nextra`)).toBeNull();
|
|
});
|
|
|
|
it('returns null when one side is huge even though the matrix is small', () => {
|
|
const huge = Array.from({ length: 6000 }, (_, i) => `line${i}`).join('\n');
|
|
expect(buildDiffLines('one line', huge)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('buildEditDiffLines', () => {
|
|
it('builds a diff for a single Edit', () => {
|
|
const arg = JSON.stringify({ path: 'a.ts', old_string: 'a\nb', new_string: 'a\nB' });
|
|
expect(buildEditDiffLines({ name: 'Edit', arg })).toEqual([
|
|
{ type: 'context', text: 'a', oldNo: 1, newNo: 1 },
|
|
{ type: 'del', text: 'b', oldNo: 2 },
|
|
{ type: 'add', text: 'B', newNo: 2 },
|
|
]);
|
|
});
|
|
|
|
it('falls back to output for replace_all edits', () => {
|
|
const arg = JSON.stringify({ path: 'a.ts', old_string: 'a', new_string: 'b', replace_all: true });
|
|
expect(buildEditDiffLines({ name: 'Edit', arg })).toBeNull();
|
|
});
|
|
|
|
it('falls back to output for every Write (new file or overwrite)', () => {
|
|
expect(buildEditDiffLines({ name: 'Write', arg: JSON.stringify({ path: 'a.ts', content: 'x' }) })).toBeNull();
|
|
expect(
|
|
buildEditDiffLines({ name: 'Write', arg: JSON.stringify({ path: 'a.ts', content: 'x', mode: 'append' }) }),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('returns null for non-edit/write tools', () => {
|
|
expect(buildEditDiffLines({ name: 'Bash', arg: JSON.stringify({ command: 'ls' }) })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('filePathLinks', () => {
|
|
it('rejects URLs and bare unknown filenames', () => {
|
|
expect(parseFilePathLinkCandidate('https://example.com/a.ts')).toBeNull();
|
|
expect(parseFilePathLinkCandidate('e2e-success.png')).toBeNull();
|
|
});
|
|
|
|
it('finds path links with line numbers and resolves aliases', () => {
|
|
const aliases = collectFilePathAliases('<img src="/assets/demo.png">');
|
|
expect(aliases.get('demo.png')).toBe('/assets/demo.png');
|
|
|
|
expect(
|
|
findFilePathLinks('Open src/a.ts#L12 and demo.png.', { aliases }),
|
|
).toMatchObject([
|
|
{ path: 'src/a.ts', line: 12, text: 'src/a.ts#L12' },
|
|
{ path: '/assets/demo.png', text: 'demo.png' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('toolMeta', () => {
|
|
it('normalizes common tool aliases', () => {
|
|
expect(normalizeToolName('WebFetch')).toBe('web_fetch');
|
|
expect(normalizeToolName('MultiEdit')).toBe('multi_edit');
|
|
expect(normalizeToolName('TodoWrite')).toBe('todo');
|
|
expect(normalizeToolName('rg')).toBe('grep');
|
|
});
|
|
|
|
it('summarizes tool arguments for card headers', () => {
|
|
expect(
|
|
toolSummary('Read', JSON.stringify({ path: 'src/a.ts', offset: 10, limit: 5 })),
|
|
).toBe('src/a.ts:10-15');
|
|
expect(toolSummary('Read', '{}')).toBe('');
|
|
expect(toolSummary('Bash', JSON.stringify({ command: 'pnpm test' }))).toBe('pnpm test');
|
|
expect(
|
|
toolSummary('WebFetch', JSON.stringify({ url: 'https://example.com/path/to' })),
|
|
).toBe('example.com/path');
|
|
});
|
|
});
|
|
|
|
describe('resolveToolRenderer', () => {
|
|
// Minimal ToolCall factory — resolveToolRenderer only reads `name`, `status`
|
|
// and `media`, so the rest is filled with placeholders.
|
|
const tool = (name: string, status: ToolCall['status'] = 'running'): ToolCall => ({
|
|
id: 't1',
|
|
name,
|
|
arg: '',
|
|
status,
|
|
});
|
|
|
|
// Regression: normalizeToolName() folds `agent`/`subagent` into the canonical
|
|
// `task` kind, so the renderer must match on `task`. If it matched on the raw
|
|
// `agent` string these calls would fall through to GenericTool and lose the
|
|
// inline "Open" button for the subagent detail panel.
|
|
it('routes Agent / subagent calls to the Agent renderer', () => {
|
|
expect(resolveToolRenderer(tool('agent'))).toBe(AgentTool);
|
|
expect(resolveToolRenderer(tool('Agent'))).toBe(AgentTool);
|
|
expect(resolveToolRenderer(tool('subagent'))).toBe(AgentTool);
|
|
expect(resolveToolRenderer(tool('task'))).toBe(AgentTool);
|
|
});
|
|
|
|
it('routes edit-like calls to the Edit renderer', () => {
|
|
expect(resolveToolRenderer(tool('edit'))).toBe(EditTool);
|
|
expect(resolveToolRenderer(tool('write'))).toBe(EditTool);
|
|
expect(resolveToolRenderer(tool('multi_edit'))).toBe(EditTool);
|
|
});
|
|
|
|
it('falls back to the Generic renderer for unknown tools', () => {
|
|
expect(resolveToolRenderer(tool('bash'))).toBe(GenericTool);
|
|
expect(resolveToolRenderer(tool('read'))).toBe(GenericTool);
|
|
});
|
|
});
|
|
|
|
describe('createCoalescedAsyncRunner', () => {
|
|
it('reuses the in-flight promise for the same key', async () => {
|
|
let runs = 0;
|
|
let resolveRun!: () => void;
|
|
const runner = createCoalescedAsyncRunner(async (_key: string) => {
|
|
runs += 1;
|
|
await new Promise<void>((resolve) => {
|
|
resolveRun = resolve;
|
|
});
|
|
return runs;
|
|
});
|
|
|
|
const first = runner.run('session-a');
|
|
const second = runner.run('session-a');
|
|
|
|
expect(runs).toBe(1);
|
|
resolveRun();
|
|
await expect(Promise.all([first, second])).resolves.toEqual([1, 1]);
|
|
expect(runs).toBe(1);
|
|
});
|
|
|
|
it('queues at most one rerun requested while a run is in flight', async () => {
|
|
let runs = 0;
|
|
const resolvers: Array<() => void> = [];
|
|
const runner = createCoalescedAsyncRunner(async (_key: string) => {
|
|
runs += 1;
|
|
await new Promise<void>((resolve) => {
|
|
resolvers.push(resolve);
|
|
});
|
|
return runs;
|
|
});
|
|
|
|
const first = runner.run('session-a');
|
|
runner.request('session-a');
|
|
runner.request('session-a');
|
|
expect(runs).toBe(1);
|
|
|
|
resolvers[0]!();
|
|
await first;
|
|
await Promise.resolve();
|
|
|
|
expect(runs).toBe(2);
|
|
resolvers[1]!();
|
|
await Promise.resolve();
|
|
expect(runs).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('modelThinking', () => {
|
|
const effortModel = (over: Partial<AppModel> = {}): AppModel => ({
|
|
id: 'k',
|
|
provider: 'p',
|
|
model: 'k',
|
|
maxContextSize: 1,
|
|
capabilities: ['thinking'],
|
|
supportEfforts: ['low', 'high', 'max'],
|
|
defaultEffort: 'high',
|
|
...over,
|
|
});
|
|
const booleanModel = (capabilities: string[] = ['thinking']): AppModel => ({
|
|
id: 'b',
|
|
provider: 'p',
|
|
model: 'b',
|
|
maxContextSize: 1,
|
|
capabilities,
|
|
});
|
|
const unsupportedModel = (): AppModel => ({
|
|
id: 'u',
|
|
provider: 'p',
|
|
model: 'u',
|
|
maxContextSize: 1,
|
|
capabilities: [],
|
|
});
|
|
|
|
describe('modelThinkingAvailability', () => {
|
|
it('toggle when model has thinking capability', () => {
|
|
expect(modelThinkingAvailability(booleanModel())).toBe('toggle');
|
|
});
|
|
it('always-on when model has always_thinking', () => {
|
|
expect(modelThinkingAvailability(booleanModel(['always_thinking']))).toBe('always-on');
|
|
});
|
|
it('unsupported when model lacks thinking capability', () => {
|
|
expect(modelThinkingAvailability(unsupportedModel())).toBe('unsupported');
|
|
});
|
|
it('toggle when adaptiveThinking is set', () => {
|
|
expect(modelThinkingAvailability({ ...unsupportedModel(), adaptiveThinking: true })).toBe('toggle');
|
|
});
|
|
});
|
|
|
|
describe('defaultThinkingLevelFor', () => {
|
|
it('effort model returns defaultEffort', () => {
|
|
expect(defaultThinkingLevelFor(effortModel())).toBe('high');
|
|
});
|
|
it('effort model without defaultEffort returns middle effort', () => {
|
|
expect(defaultThinkingLevelFor(effortModel({ defaultEffort: undefined }))).toBe('high');
|
|
});
|
|
it('boolean model returns on', () => {
|
|
expect(defaultThinkingLevelFor(booleanModel())).toBe('on');
|
|
});
|
|
it('unsupported model returns off', () => {
|
|
expect(defaultThinkingLevelFor(unsupportedModel())).toBe('off');
|
|
});
|
|
});
|
|
|
|
describe('segmentsFor', () => {
|
|
it('effort toggle → off + efforts (off left)', () => {
|
|
expect(segmentsFor(effortModel())).toEqual(['off', 'low', 'high', 'max']);
|
|
});
|
|
it('effort always-on → efforts only (no off)', () => {
|
|
expect(segmentsFor(effortModel({ capabilities: ['thinking', 'always_thinking'] }))).toEqual([
|
|
'low',
|
|
'high',
|
|
'max',
|
|
]);
|
|
});
|
|
it('boolean toggle → on/off (on left)', () => {
|
|
expect(segmentsFor(booleanModel())).toEqual(['on', 'off']);
|
|
});
|
|
it('boolean always-on → on', () => {
|
|
expect(segmentsFor(booleanModel(['always_thinking']))).toEqual(['on']);
|
|
});
|
|
it('unsupported → off', () => {
|
|
expect(segmentsFor(unsupportedModel())).toEqual(['off']);
|
|
});
|
|
});
|
|
|
|
describe('commitLevel', () => {
|
|
it('on normalizes to the model default', () => {
|
|
expect(commitLevel(effortModel(), 'on')).toBe('high');
|
|
expect(commitLevel(booleanModel(), 'on')).toBe('on');
|
|
});
|
|
it('off stays off', () => {
|
|
expect(commitLevel(effortModel(), 'off')).toBe('off');
|
|
});
|
|
it('concrete effort passes through', () => {
|
|
expect(commitLevel(effortModel(), 'max')).toBe('max');
|
|
});
|
|
});
|
|
|
|
describe('coerceThinkingForModel', () => {
|
|
it('undefined model preserves the requested level (catalog not loaded yet)', () => {
|
|
expect(coerceThinkingForModel(undefined, 'high')).toBe('high');
|
|
expect(coerceThinkingForModel(undefined, 'max')).toBe('max');
|
|
expect(coerceThinkingForModel(undefined, 'on')).toBe('on');
|
|
expect(coerceThinkingForModel(undefined, 'off')).toBe('off');
|
|
});
|
|
it('unsupported model → off', () => {
|
|
expect(coerceThinkingForModel(unsupportedModel(), 'high')).toBe('off');
|
|
});
|
|
it('always-on + off → default level', () => {
|
|
expect(
|
|
coerceThinkingForModel(effortModel({ capabilities: ['thinking', 'always_thinking'] }), 'off'),
|
|
).toBe('high');
|
|
});
|
|
it('effort model + undeclared level → default', () => {
|
|
expect(coerceThinkingForModel(effortModel(), 'xhigh')).toBe('high');
|
|
});
|
|
it('effort model + declared level → kept', () => {
|
|
expect(coerceThinkingForModel(effortModel(), 'max')).toBe('max');
|
|
});
|
|
it('boolean model + non-off level → on', () => {
|
|
expect(coerceThinkingForModel(booleanModel(), 'high')).toBe('on');
|
|
});
|
|
it('toggle + off → off', () => {
|
|
expect(coerceThinkingForModel(booleanModel(), 'off')).toBe('off');
|
|
});
|
|
});
|
|
|
|
describe('effortLabel', () => {
|
|
it('capitalizes the first letter', () => {
|
|
expect(effortLabel('max')).toBe('Max');
|
|
expect(effortLabel('off')).toBe('Off');
|
|
expect(effortLabel('xhigh')).toBe('Xhigh');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('humanizeCron', () => {
|
|
const dict: Record<string, string> = {
|
|
'conversation.cron.everyMinute': 'Every minute',
|
|
'conversation.cron.everyNMinutes': 'Every {n} minutes',
|
|
'conversation.cron.everyHour': 'Every hour',
|
|
'conversation.cron.everyNHours': 'Every {n} hours',
|
|
'conversation.cron.dailyAt': 'Daily at {time}',
|
|
'conversation.cron.weekdaysAt': 'Weekdays at {time}',
|
|
};
|
|
const t = (key: string, params?: Record<string, unknown>): string => {
|
|
let s = dict[key] ?? key;
|
|
if (params) for (const [k, v] of Object.entries(params)) s = s.replace(`{${k}}`, String(v));
|
|
return s;
|
|
};
|
|
|
|
it('labels the common cadences', () => {
|
|
expect(humanizeCron('* * * * *', t)).toBe('Every minute');
|
|
expect(humanizeCron('*/5 * * * *', t)).toBe('Every 5 minutes');
|
|
expect(humanizeCron('*/1 * * * *', t)).toBe('Every minute');
|
|
expect(humanizeCron('0 * * * *', t)).toBe('Every hour');
|
|
expect(humanizeCron('0 */2 * * *', t)).toBe('Every 2 hours');
|
|
});
|
|
|
|
it('labels fixed daily and weekday times', () => {
|
|
expect(humanizeCron('5 9 * * *', t)).toBe('Daily at 9:05');
|
|
expect(humanizeCron('0 9 * * 1-5', t)).toBe('Weekdays at 9:00');
|
|
});
|
|
|
|
it('falls back to the raw expression for unrecognized shapes', () => {
|
|
expect(humanizeCron('0 9 1 * *', t)).toBe('0 9 1 * *');
|
|
expect(humanizeCron('bad', t)).toBe('bad');
|
|
});
|
|
});
|
|
|
|
describe('collapsePrompt', () => {
|
|
it('keeps a short single-line prompt intact with no expand toggle', () => {
|
|
expect(collapsePrompt('Check the deploy status')).toEqual({
|
|
text: 'Check the deploy status',
|
|
hasMore: false,
|
|
});
|
|
});
|
|
|
|
it('truncates a long one-line prompt with an ellipsis and reports hasMore', () => {
|
|
const long = 'a'.repeat(150);
|
|
const result = collapsePrompt(long, 120);
|
|
expect(result.hasMore).toBe(true);
|
|
expect(result.text.length).toBeLessThan(long.length);
|
|
expect(result.text.endsWith('…')).toBe(true);
|
|
});
|
|
|
|
it('shows only the first line for a multi-line prompt', () => {
|
|
expect(collapsePrompt('first line\nsecond line\nthird line')).toEqual({
|
|
text: 'first line',
|
|
hasMore: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('mergeSnapshotMessages', () => {
|
|
function msg(id: string, createdAt: string): AppMessage {
|
|
return { id, sessionId: 's1', role: 'assistant', content: [], createdAt };
|
|
}
|
|
|
|
it('keeps loaded messages older than the snapshot window', () => {
|
|
const loaded = [
|
|
msg('old-1', '2026-01-01T00:00:00.000Z'),
|
|
msg('old-2', '2026-01-02T00:00:00.000Z'),
|
|
msg('recent-live', '2026-01-03T00:00:00.000Z'),
|
|
];
|
|
const snapshot = [
|
|
msg('m0', '2026-01-03T00:00:00.000Z'),
|
|
msg('m1', '2026-01-04T00:00:00.000Z'),
|
|
];
|
|
expect(mergeSnapshotMessages(loaded, snapshot).map((m) => m.id)).toEqual([
|
|
'old-1',
|
|
'old-2',
|
|
'm0',
|
|
'm1',
|
|
]);
|
|
});
|
|
|
|
it('returns the snapshot when there is no older loaded prefix', () => {
|
|
const loaded = [msg('recent-live', '2026-01-03T00:00:00.000Z')];
|
|
const snapshot = [
|
|
msg('m0', '2026-01-03T00:00:00.000Z'),
|
|
msg('m1', '2026-01-04T00:00:00.000Z'),
|
|
];
|
|
expect(mergeSnapshotMessages(loaded, snapshot)).toBe(snapshot);
|
|
});
|
|
|
|
it('returns the snapshot when either side is empty', () => {
|
|
const snapshot = [msg('m0', '2026-01-03T00:00:00.000Z')];
|
|
expect(mergeSnapshotMessages([], snapshot)).toBe(snapshot);
|
|
expect(mergeSnapshotMessages(snapshot, [])).toEqual([]);
|
|
});
|
|
|
|
function optimisticUser(id: string, createdAt: string, text: string, promptId: string): AppMessage {
|
|
return {
|
|
id,
|
|
sessionId: 's1',
|
|
role: 'user',
|
|
content: [{ type: 'text', text }],
|
|
createdAt,
|
|
promptId,
|
|
metadata: { 'kimiWeb.optimisticUserMessage': true },
|
|
};
|
|
}
|
|
|
|
function realUser(id: string, createdAt: string, text: string): AppMessage {
|
|
return {
|
|
id,
|
|
sessionId: 's1',
|
|
role: 'user',
|
|
content: [{ type: 'text', text }],
|
|
createdAt,
|
|
};
|
|
}
|
|
|
|
it('drops an optimistic user message when its promptId is the snapshot message id', () => {
|
|
const loaded = [optimisticUser('msg_opt_1', '2026-01-02T23:59:59.000Z', 'hello', 'msg_9')];
|
|
const snapshot = [realUser('msg_9', '2026-01-03T00:00:00.000Z', 'hello')];
|
|
expect(mergeSnapshotMessages(loaded, snapshot).map((m) => m.id)).toEqual(['msg_9']);
|
|
});
|
|
|
|
it('keeps an optimistic user message when a different snapshot message repeats its content', () => {
|
|
const loaded = [optimisticUser('msg_opt_1', '2026-01-02T23:59:59.000Z', 'hello', 'msg_8')];
|
|
const snapshot = [realUser('msg_9', '2026-01-03T00:00:00.000Z', 'hello')];
|
|
expect(mergeSnapshotMessages(loaded, snapshot).map((m) => m.id)).toEqual(['msg_opt_1', 'msg_9']);
|
|
});
|
|
});
|
|
|
|
describe('mergeSnapshotSubagents', () => {
|
|
function subagent(id: string, overrides: Partial<AppTask> = {}): AppTask {
|
|
return {
|
|
id,
|
|
sessionId: 's1',
|
|
kind: 'subagent',
|
|
description: `task ${id}`,
|
|
status: 'running',
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
it('seeds an empty store from the roster', () => {
|
|
const roster = [
|
|
subagent('a1', { subagentPhase: 'working', swarmIndex: 0, parentToolCallId: 'call-1' }),
|
|
subagent('a2', { subagentPhase: 'queued', swarmIndex: 1, parentToolCallId: 'call-1' }),
|
|
];
|
|
expect(mergeSnapshotSubagents(roster, [])).toEqual(roster);
|
|
});
|
|
|
|
it('keeps reducer-owned accumulated output from an already-live task', () => {
|
|
const live = subagent('a1', {
|
|
subagentPhase: 'queued',
|
|
outputLines: ['line 1'],
|
|
text: 'partial answer',
|
|
});
|
|
const roster = [subagent('a1', { subagentPhase: 'working' })];
|
|
const [merged] = mergeSnapshotSubagents(roster, [live]);
|
|
// Roster is authoritative for identity/status/phase…
|
|
expect(merged?.subagentPhase).toBe('working');
|
|
// …but the accumulated output survives the seed.
|
|
expect(merged?.outputLines).toEqual(['line 1']);
|
|
expect(merged?.text).toBe('partial answer');
|
|
});
|
|
|
|
it('keeps tasks the roster does not know about', () => {
|
|
const background: AppTask = {
|
|
id: 'bash-1',
|
|
sessionId: 's1',
|
|
kind: 'bash',
|
|
description: 'npm test',
|
|
status: 'running',
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
};
|
|
const roster = [subagent('a1')];
|
|
const merged = mergeSnapshotSubagents(roster, [background, subagent('a1')]);
|
|
expect(merged.map((t) => t.id)).toEqual(['a1', 'bash-1']);
|
|
});
|
|
|
|
it('returns the existing list untouched when the roster is empty', () => {
|
|
const existing = [subagent('a1')];
|
|
expect(mergeSnapshotSubagents([], existing)).toBe(existing);
|
|
});
|
|
});
|