mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
481 lines
16 KiB
TypeScript
481 lines
16 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import type {
|
|
AppConfig,
|
|
AppMessage,
|
|
AppSession,
|
|
KimiEventHandlers,
|
|
KimiWebApi,
|
|
} from '../src/api/types';
|
|
|
|
const now = '2026-06-11T00:00:00.000Z';
|
|
|
|
class NotificationMock {
|
|
static permission = 'granted';
|
|
static requestPermission = vi.fn(async () => 'granted');
|
|
static instances: NotificationMock[] = [];
|
|
title: string;
|
|
onclick: (() => void) | null = null;
|
|
constructor(title: string) {
|
|
this.title = title;
|
|
NotificationMock.instances.push(this);
|
|
}
|
|
close(): void {}
|
|
}
|
|
|
|
function session(id: string): AppSession {
|
|
return {
|
|
id,
|
|
title: id,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
status: 'idle',
|
|
cwd: '/repo',
|
|
model: 'kimi-test',
|
|
usage: {
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheReadTokens: 0,
|
|
cacheCreationTokens: 0,
|
|
totalCostUsd: 0,
|
|
contextTokens: 0,
|
|
contextLimit: 128_000,
|
|
turnCount: 0,
|
|
},
|
|
messageCount: 0,
|
|
lastSeq: 0,
|
|
};
|
|
}
|
|
|
|
function userMessage(sessionId: string, id: string): AppMessage {
|
|
return {
|
|
id,
|
|
sessionId,
|
|
role: 'user',
|
|
content: [{ type: 'text', text: id }],
|
|
createdAt: now,
|
|
};
|
|
}
|
|
|
|
async function setup(messages: AppMessage[] = []) {
|
|
vi.resetModules();
|
|
vi.stubGlobal('WebSocket', class WebSocket {});
|
|
|
|
let handlers: KimiEventHandlers | undefined;
|
|
const eventConn = {
|
|
subscribe: vi.fn(),
|
|
unsubscribe: vi.fn(),
|
|
bindNextPromptId: vi.fn(),
|
|
seedSnapshot: vi.fn(),
|
|
abort: vi.fn(),
|
|
close: vi.fn(),
|
|
};
|
|
const created = session('sess_1');
|
|
const initialConfig: AppConfig = { providers: {}, defaultModel: 'kimi/default' };
|
|
const api = {
|
|
createSession: vi.fn(async () => created),
|
|
listMessages: vi.fn(async () => ({ items: messages, hasMore: false })),
|
|
getSessionSnapshot: vi.fn(async () => ({
|
|
asOfSeq: 0,
|
|
epoch: 'ep_test',
|
|
session: created,
|
|
messages,
|
|
hasMoreMessages: false,
|
|
inFlightTurn: null,
|
|
pendingApprovals: [],
|
|
pendingQuestions: [],
|
|
})),
|
|
submitPrompt: vi.fn(async () => ({ promptId: 'pr_1', userMessageId: 'msg_real' })),
|
|
listTasks: vi.fn(async () => []),
|
|
getGitStatus: vi.fn(async () => ({ branch: 'main', ahead: 0, behind: 0, entries: {}, additions: 0, deletions: 0 })),
|
|
getSessionStatus: vi.fn(async () => ({
|
|
model: 'kimi-test',
|
|
thinkingLevel: 'high',
|
|
permission: 'manual',
|
|
planMode: false,
|
|
swarmMode: false,
|
|
contextTokens: 0,
|
|
maxContextTokens: 128_000,
|
|
contextUsage: 0,
|
|
})),
|
|
getConfig: vi.fn(async () => initialConfig),
|
|
setConfig: vi.fn(async (patch: Partial<AppConfig>) => ({
|
|
...initialConfig,
|
|
...patch,
|
|
providers: patch.providers ?? initialConfig.providers,
|
|
})),
|
|
connectEvents: vi.fn((nextHandlers: KimiEventHandlers) => {
|
|
handlers = nextHandlers;
|
|
return eventConn;
|
|
}),
|
|
getFileUrl: vi.fn((fileId: string) => `/files/${fileId}`),
|
|
} as unknown as KimiWebApi;
|
|
|
|
vi.doMock('../src/api', () => ({ getKimiWebApi: () => api }));
|
|
const { useKimiWebClient } = await import('../src/composables/useKimiWebClient');
|
|
|
|
return {
|
|
api,
|
|
client: useKimiWebClient(),
|
|
eventConn,
|
|
getHandlers: () => {
|
|
if (!handlers) throw new Error('connectEvents was not called');
|
|
return handlers;
|
|
},
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.resetModules();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('useKimiWebClient session memory cache', () => {
|
|
it('treats an already loaded empty message array as an L1 hit', async () => {
|
|
const { api, client, eventConn } = await setup([]);
|
|
|
|
await client.createSession('/repo');
|
|
expect(api.getSessionSnapshot).toHaveBeenCalledTimes(1);
|
|
expect(client.sessionLoading.value).toBe(false);
|
|
|
|
const secondSelect = client.selectSession('sess_1');
|
|
|
|
expect(client.sessionLoading.value).toBe(false);
|
|
await secondSelect;
|
|
// L1 hit: no second snapshot fetch — re-subscribe at the tracked cursor.
|
|
expect(api.getSessionSnapshot).toHaveBeenCalledTimes(1);
|
|
expect(eventConn.subscribe).toHaveBeenLastCalledWith('sess_1', {
|
|
seq: 0,
|
|
epoch: 'ep_test',
|
|
});
|
|
});
|
|
|
|
it('adds a local content summary to loaded session rows', async () => {
|
|
const initial: AppMessage = {
|
|
id: 'msg_1',
|
|
sessionId: 'sess_1',
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'Please review this\n\n```ts\nconst value = 1;\n```\n\nand explain the risk.' }],
|
|
createdAt: now,
|
|
};
|
|
const { client } = await setup([initial]);
|
|
|
|
await client.createSession('/repo');
|
|
|
|
expect(client.sessionsForView.value[0]?.summary).toBe('Please review this code block and explain the risk.');
|
|
});
|
|
|
|
it('does not raise the loading state for a locally created session', async () => {
|
|
const { client } = await setup([]);
|
|
|
|
// Locally created sessions are trusted to start empty, so the empty-composer
|
|
// renders immediately without flashing the chat-pane loading state.
|
|
const pending = client.createSession('/repo');
|
|
expect(client.sessionLoading.value).toBe(false);
|
|
await pending;
|
|
expect(client.sessionLoading.value).toBe(false);
|
|
});
|
|
|
|
it('raises the loading state when selecting an existing session reported as empty', async () => {
|
|
const { client, getHandlers } = await setup([]);
|
|
await client.createSession('/repo');
|
|
|
|
// A second, never-opened session whose daemon-reported messageCount is 0.
|
|
// We no longer trust messageCount for existing sessions (it can be stale),
|
|
// so we load the snapshot before deciding what to render.
|
|
const empty = session('sess_empty'); // messageCount: 0
|
|
getHandlers().onEvent(
|
|
{ type: 'sessionCreated', session: empty },
|
|
{ sessionId: 'sess_empty', seq: 1 },
|
|
);
|
|
|
|
const pending = client.selectSession('sess_empty');
|
|
expect(client.sessionLoading.value).toBe(true);
|
|
await pending.catch(() => {});
|
|
expect(client.sessionLoading.value).toBe(false);
|
|
});
|
|
|
|
it('raises the loading state when selecting a non-empty unloaded session', async () => {
|
|
const { client, getHandlers } = await setup([]);
|
|
await client.createSession('/repo');
|
|
|
|
const filled = { ...session('sess_filled'), messageCount: 3 };
|
|
getHandlers().onEvent(
|
|
{ type: 'sessionCreated', session: filled },
|
|
{ sessionId: 'sess_filled', seq: 1 },
|
|
);
|
|
|
|
const pending = client.selectSession('sess_filled');
|
|
// A session with history shows the loading state until the snapshot arrives.
|
|
expect(client.sessionLoading.value).toBe(true);
|
|
await pending.catch(() => {});
|
|
});
|
|
|
|
it('re-subscribes an L1 hit with the reducer-maintained latest seq', async () => {
|
|
const initial = userMessage('sess_1', 'msg_1');
|
|
const { api, client, eventConn, getHandlers } = await setup([initial]);
|
|
|
|
await client.createSession('/repo');
|
|
expect(api.getSessionSnapshot).toHaveBeenCalledTimes(1);
|
|
expect(eventConn.subscribe).toHaveBeenLastCalledWith('sess_1', {
|
|
seq: 0,
|
|
epoch: 'ep_test',
|
|
});
|
|
|
|
getHandlers().onEvent(
|
|
{ type: 'messageCreated', message: userMessage('sess_1', 'msg_2') },
|
|
{ sessionId: 'sess_1', seq: 7 },
|
|
);
|
|
|
|
await client.selectSession('sess_1');
|
|
|
|
expect(api.getSessionSnapshot).toHaveBeenCalledTimes(1);
|
|
expect(eventConn.subscribe).toHaveBeenLastCalledWith('sess_1', {
|
|
seq: 7,
|
|
epoch: 'ep_test',
|
|
});
|
|
});
|
|
|
|
it('marks a background session unread on idle and clears it on open', async () => {
|
|
const { client, getHandlers } = await setup([]);
|
|
await client.createSession('/repo'); // sess_1 is active
|
|
|
|
const bg = session('sess_bg');
|
|
getHandlers().onEvent(
|
|
{ type: 'sessionCreated', session: bg },
|
|
{ sessionId: 'sess_bg', seq: 1 },
|
|
);
|
|
|
|
// A background session finishing a turn lights up its unread dot.
|
|
getHandlers().onEvent(
|
|
{ type: 'sessionStatusChanged', sessionId: 'sess_bg', status: 'idle', previousStatus: 'running' },
|
|
{ sessionId: 'sess_bg', seq: 2 },
|
|
);
|
|
expect(client.unreadBySession.value['sess_bg']).toBe(true);
|
|
|
|
// The ACTIVE session finishing does not mark itself unread.
|
|
getHandlers().onEvent(
|
|
{ type: 'sessionStatusChanged', sessionId: 'sess_1', status: 'idle', previousStatus: 'running' },
|
|
{ sessionId: 'sess_1', seq: 3 },
|
|
);
|
|
expect(client.unreadBySession.value['sess_1']).toBeUndefined();
|
|
|
|
// Opening the background session clears its unread flag.
|
|
await client.selectSession('sess_bg').catch(() => {});
|
|
expect(client.unreadBySession.value['sess_bg']).toBeUndefined();
|
|
});
|
|
|
|
it('uses the fast moon class only for high-speed active-session output', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const { client, getHandlers } = await setup([]);
|
|
await client.createSession('/repo');
|
|
|
|
getHandlers().onEvent(
|
|
{ type: 'assistantDelta', sessionId: 'sess_bg', messageId: 'msg_bg', contentIndex: 0, delta: { text: 'x'.repeat(80) } },
|
|
{ sessionId: 'sess_bg', seq: 1 },
|
|
);
|
|
expect(client.fastMoon.value).toBe(false);
|
|
|
|
getHandlers().onEvent(
|
|
{ type: 'assistantDelta', sessionId: 'sess_1', messageId: 'msg_1', contentIndex: 0, delta: { text: 'x'.repeat(80) } },
|
|
{ sessionId: 'sess_1', seq: 2 },
|
|
);
|
|
expect(client.fastMoon.value).toBe(true);
|
|
|
|
getHandlers().onEvent(
|
|
{ type: 'sessionStatusChanged', sessionId: 'sess_1', status: 'idle', previousStatus: 'running' },
|
|
{ sessionId: 'sess_1', seq: 3 },
|
|
);
|
|
expect(client.fastMoon.value).toBe(false);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('fires a browser notification when a background session completes (opt-in)', async () => {
|
|
NotificationMock.instances = [];
|
|
vi.stubGlobal('Notification', NotificationMock);
|
|
const { client, getHandlers } = await setup([]);
|
|
await client.createSession('/repo'); // sess_1 active
|
|
|
|
const bg = session('sess_bg');
|
|
getHandlers().onEvent(
|
|
{ type: 'sessionCreated', session: bg },
|
|
{ sessionId: 'sess_bg', seq: 1 },
|
|
);
|
|
|
|
// Ensure notifications are off before testing opt-in behavior.
|
|
await client.setNotifyOnComplete(false);
|
|
|
|
// Off by default → no notification on completion.
|
|
getHandlers().onEvent(
|
|
{ type: 'sessionStatusChanged', sessionId: 'sess_bg', status: 'idle', previousStatus: 'running' },
|
|
{ sessionId: 'sess_bg', seq: 2 },
|
|
);
|
|
expect(NotificationMock.instances).toHaveLength(0);
|
|
|
|
// Opt in (permission already granted) → completion fires a notification.
|
|
await client.setNotifyOnComplete(true);
|
|
expect(client.notifyOnComplete.value).toBe(true);
|
|
getHandlers().onEvent(
|
|
{ type: 'sessionStatusChanged', sessionId: 'sess_bg', status: 'idle', previousStatus: 'running' },
|
|
{ sessionId: 'sess_bg', seq: 3 },
|
|
);
|
|
expect(NotificationMock.instances).toHaveLength(1);
|
|
expect(NotificationMock.instances[0]!.title).toBe('sess_bg');
|
|
});
|
|
|
|
it('keeps the optimistic user turn key stable after submit resolves', async () => {
|
|
const { client, eventConn } = await setup([]);
|
|
|
|
await client.createSession('/repo');
|
|
await client.sendPrompt('hello');
|
|
|
|
const userTurn = client.turns.value.find((turn) => turn.role === 'user');
|
|
expect(userTurn?.id).toMatch(/^msg_opt_/);
|
|
expect(eventConn.bindNextPromptId).toHaveBeenCalledWith('sess_1', 'pr_1');
|
|
});
|
|
|
|
it('merges a user message echo into the optimistic turn instead of appending', async () => {
|
|
const { client, getHandlers } = await setup([]);
|
|
|
|
await client.createSession('/repo');
|
|
await client.sendPrompt('hello');
|
|
const optimisticId = client.turns.value.find((turn) => turn.role === 'user')!.id;
|
|
|
|
getHandlers().onEvent(
|
|
{
|
|
type: 'messageCreated',
|
|
message: {
|
|
id: 'msg_echo',
|
|
sessionId: 'sess_1',
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'hello' }],
|
|
createdAt: now,
|
|
promptId: 'pr_1',
|
|
},
|
|
},
|
|
{ sessionId: 'sess_1', seq: 8 },
|
|
);
|
|
|
|
const userTurns = client.turns.value.filter((turn) => turn.role === 'user');
|
|
expect(userTurns).toHaveLength(1);
|
|
expect(userTurns[0]!.id).toBe(optimisticId);
|
|
});
|
|
|
|
it('keeps daemon config writes and configChanged events in client state', async () => {
|
|
const { api, client, getHandlers } = await setup([]);
|
|
|
|
await client.updateConfig({ defaultModel: 'kimi/k2' });
|
|
|
|
expect(api.setConfig).toHaveBeenCalledWith({ defaultModel: 'kimi/k2' });
|
|
expect(client.config.value?.defaultModel).toBe('kimi/k2');
|
|
expect(client.defaultModel.value).toBe('kimi/k2');
|
|
|
|
await client.createSession('/repo');
|
|
getHandlers().onEvent(
|
|
{
|
|
type: 'configChanged',
|
|
changedFields: ['default_model'],
|
|
config: { providers: {}, defaultModel: 'openai/gpt-5' },
|
|
},
|
|
{ sessionId: '__global__', seq: 8 },
|
|
);
|
|
|
|
expect(client.config.value?.defaultModel).toBe('openai/gpt-5');
|
|
expect(client.defaultModel.value).toBe('openai/gpt-5');
|
|
});
|
|
});
|
|
|
|
describe('session view-model status / busy', () => {
|
|
it('surfaces the real lifecycle status and only spins for running', async () => {
|
|
const { client, getHandlers } = await setup([]);
|
|
await client.createSession('/repo'); // sess_1 active
|
|
|
|
const bg = session('sess_bg');
|
|
getHandlers().onEvent(
|
|
{ type: 'sessionCreated', session: bg },
|
|
{ sessionId: 'sess_bg', seq: 1 },
|
|
);
|
|
|
|
const find = () => client.sessions.value.find((s) => s.id === 'sess_bg')!;
|
|
|
|
// Awaiting the user is NOT busy — the row must not show a working spinner.
|
|
getHandlers().onEvent(
|
|
{ type: 'sessionStatusChanged', sessionId: 'sess_bg', status: 'awaitingApproval', previousStatus: 'running' },
|
|
{ sessionId: 'sess_bg', seq: 2 },
|
|
);
|
|
expect(find().status).toBe('awaitingApproval');
|
|
expect(find().busy).toBe(false);
|
|
|
|
// Aborted is a distinct, non-busy state (not collapsed to idle).
|
|
getHandlers().onEvent(
|
|
{ type: 'sessionStatusChanged', sessionId: 'sess_bg', status: 'aborted', previousStatus: 'awaitingApproval' },
|
|
{ sessionId: 'sess_bg', seq: 3 },
|
|
);
|
|
expect(find().status).toBe('aborted');
|
|
expect(find().busy).toBe(false);
|
|
|
|
// Running (no tasks loaded yet → trust the status) IS busy.
|
|
getHandlers().onEvent(
|
|
{ type: 'sessionStatusChanged', sessionId: 'sess_bg', status: 'running', previousStatus: 'aborted' },
|
|
{ sessionId: 'sess_bg', seq: 4 },
|
|
);
|
|
expect(find().status).toBe('running');
|
|
expect(find().busy).toBe(true);
|
|
});
|
|
|
|
it('treats an aborted turn as a turn end (flushes like idle)', async () => {
|
|
const { client, getHandlers } = await setup([]);
|
|
await client.createSession('/repo'); // sess_1 active
|
|
|
|
const bg = session('sess_bg');
|
|
getHandlers().onEvent(
|
|
{ type: 'sessionCreated', session: bg },
|
|
{ sessionId: 'sess_bg', seq: 1 },
|
|
);
|
|
|
|
// Aborting a background turn must run the same turn-end cleanup as idle —
|
|
// observable here as the unread dot lighting up.
|
|
getHandlers().onEvent(
|
|
{ type: 'sessionStatusChanged', sessionId: 'sess_bg', status: 'aborted', previousStatus: 'running' },
|
|
{ sessionId: 'sess_bg', seq: 2 },
|
|
);
|
|
expect(client.unreadBySession.value['sess_bg']).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('unread persistence across reload', () => {
|
|
it('restores unread dots from storage and clears them on open', async () => {
|
|
try { localStorage.removeItem('kimi-web.unread'); } catch { /* ignore */ }
|
|
try {
|
|
// First "page load": a background session finishes a turn → unread.
|
|
const first = await setup([]);
|
|
await first.client.createSession('/repo');
|
|
first.getHandlers().onEvent(
|
|
{ type: 'sessionCreated', session: session('sess_bg') },
|
|
{ sessionId: 'sess_bg', seq: 1 },
|
|
);
|
|
first.getHandlers().onEvent(
|
|
{ type: 'sessionStatusChanged', sessionId: 'sess_bg', status: 'idle', previousStatus: 'running' },
|
|
{ sessionId: 'sess_bg', seq: 2 },
|
|
);
|
|
expect(first.client.unreadBySession.value['sess_bg']).toBe(true);
|
|
|
|
// Refresh: a brand-new client (vi.resetModules) seeds unread from storage
|
|
// instead of starting empty — the dot survives the reload.
|
|
const second = await setup([]);
|
|
expect(second.client.unreadBySession.value['sess_bg']).toBe(true);
|
|
|
|
// Opening the session clears the flag and the persisted entry.
|
|
await second.client.selectSession('sess_bg').catch(() => {});
|
|
expect(second.client.unreadBySession.value['sess_bg']).toBeUndefined();
|
|
|
|
const third = await setup([]);
|
|
expect(third.client.unreadBySession.value['sess_bg']).toBeUndefined();
|
|
} finally {
|
|
try { localStorage.removeItem('kimi-web.unread'); } catch { /* ignore */ }
|
|
}
|
|
});
|
|
});
|