mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-17 04:35:50 +00:00
* fix(web): remember the thinking level per model Persist kimi-web.thinking as a JSON map of model id to level instead of a single global value, and resolve the active level against the model's catalog (stored pick when still declared, else the model default) at loadModels, setModel, and on active-model changes via a watcher. Fixes the empty, unresponsive thinking picker shown for a model that does not declare a previously stored level (e.g. a max-only model with a stale global 'low'). * fix(web): resolve a submitted prompt's thinking from its own model submitPromptInternal and the steer path read the single active-session rawState.thinking, so a queue drain for a background session submitted the level of whichever session the user had switched to since enqueueing — the same cross-model leak on the submit path. Thinking now joins model and the per-session modes in being resolved from the prompt's own session model (its stored pick when declared, else the catalog default), falling back to the active value only when the model has left the catalog. * fix(web): keep model switches from persisting derived thinking defaults setModel routed the resolved level through applyThinkingLevel, which writes per-model storage unconditionally — a switch to a model with no saved pick stored the catalog default as if it were an explicit choice, pinning the user to it across later default changes, and the rollback path did the same write for a switch that never happened. Model switches now update the in-memory level only; storage writes stay with setThinking, the explicit picker path. * fix(web): resolve thinking per target session on the BTW and skill paths sendSideChatPromptOn combined the captured parent's model with the active-session level, so a session switch during the startBtw await sent the BTW first turn at the wrong model's effort — resolve it from the parent's own model, falling back to the active value off-catalog, same as the other submit paths. activateSkill carries no thinking either, so the daemon ran skills at the session profile effort, which can predate the per-model restore the picker now shows. Persist the resolved level to the session profile first, mirroring the new-session skill path; that path itself now resolves against the new session's model instead of the raw active value. * fix(web): keep per-model thinking picks in memory as the runtime truth The resolver re-read localStorage on every submission, letting storage — not the displayed state — decide what the daemon receives: with storage unavailable (policy/quota) an explicit pick reached the UI while every submit path fell back to the catalog default, and a pick made in another tab silently changed what this tab submits mid-session. Per-model picks now live in an in-memory map hydrated from localStorage at startup; explicit picks update it first and persist best-effort (read-modify-write merge, so concurrent tabs' entries still survive). localStorage is only hydration plus persistence — another tab's pick can no longer alter this tab's runtime level. * fix(web): carry the legacy global thinking pick forward as a fallback Pre-map installs stored a single global level as a raw string; the map parser dropped it, silently resetting the user's explicit preference to the catalog default on upgrade. The legacy value is now carried as a fallback for models without their own entry — validated against each model's catalog at resolution, so effort models keep the user's pick while a max-only model still falls through to its default and can never be trapped by it. * fix(web): keep the legacy thinking fallback across the first map rewrite The first explicit pick after an upgrade rewrote the raw legacy value into a map containing only that one model, so the next reload saw a nonempty map and dropped the legacy fallback for every other model. The migrated value now lives inside the map under a '*' key that no real model id can collide with: per-model entries override it, and rewrites persist it alongside them instead of deleting it. * fix(web): persist only the changed thinking pick on write Overlaying the whole in-memory map on write could revert a newer pick made in another tab for a model this tab still held a stale copy of. Write the changed entry alone (delta-style, like saveUnread), carrying only the migrated legacy '*' fallback along so it survives the first rewrite into map format. * fix(web): abort skill activation when the thinking profile persist fails persistSessionProfile surfaces failures itself and resolves, so awaiting it never blocked a following activation: a failed /profile write still launched the skill at the session's stale effort. It now resolves a success flag; both activation paths (existing session and new-session draft) gate on it and skip activating when the persist fails, without reporting a second, synthetic error. * refactor(web): persist the new-session skill profile's thinking once startSessionAndActivateSkill persisted the resolved thinking and then activateSkill persisted it again unconditionally — a redundant profile update and status refresh whose transient failure would false-veto an activation whose prerequisite profile was already applied. Thinking is now written by activateSkill alone (the single, gated writer); the draft patch carries only model, plan/swarm and permission. * fix(web): throw an Error instance for the profile-persist sentinel oxlint --type-aware (only-throw-error) rejects throwing a Symbol; the identity-based sentinel works the same as a shared Error instance. * fix(web): resolve an empty session model through the default before skills session.model can be '' transiently (daemon profile echo), so activateSkill fell back to the raw active-view level; in the new-session flow a concurrent switch could persist another model's effort onto the target session. Normalize '' through the configured default_model first, same as the prompt/BTW/steer paths.
142 lines
4.7 KiB
TypeScript
142 lines
4.7 KiB
TypeScript
// apps/kimi-web/test/side-chat.test.ts
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { createInitialState } from '../src/api/daemon/eventReducer';
|
|
import { useSideChat } from '../src/composables/client/useSideChat';
|
|
import type { ExtendedState } from '../src/composables/useKimiWebClient';
|
|
|
|
const apiMock = vi.hoisted(() => ({
|
|
startBtw: vi.fn(),
|
|
submitPrompt: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../src/api', () => ({
|
|
getKimiWebApi: () => apiMock,
|
|
}));
|
|
|
|
function createState(): ExtendedState {
|
|
return {
|
|
...createInitialState(),
|
|
sessions: [
|
|
{
|
|
id: 'sess_1',
|
|
title: 'Session',
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
busy: false as const,
|
|
archived: false,
|
|
currentPromptId: null,
|
|
cwd: '/workspace',
|
|
model: 'kimi-code',
|
|
usage: {
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheReadTokens: 0,
|
|
cacheCreationTokens: 0,
|
|
totalCostUsd: 0,
|
|
contextTokens: 0,
|
|
contextLimit: 0,
|
|
turnCount: 0,
|
|
},
|
|
messageCount: 0,
|
|
lastSeq: 0,
|
|
},
|
|
],
|
|
activeSessionId: 'sess_1',
|
|
permission: 'auto',
|
|
thinking: 'high',
|
|
planModeBySession: { sess_1: true },
|
|
swarmModeBySession: {},
|
|
sideChatMessagesByAgent: {},
|
|
sideChatSendingByAgent: {},
|
|
sideChatUserMessageIdsBySession: {},
|
|
} as unknown as ExtendedState;
|
|
}
|
|
|
|
describe('useSideChat — sendSideChatPromptOn', () => {
|
|
it('carries model, thinking, permission and plan/swarm modes on the prompt', async () => {
|
|
apiMock.startBtw.mockReset();
|
|
apiMock.submitPrompt.mockReset();
|
|
apiMock.startBtw.mockResolvedValue({ agentId: 'agent_btw_1' });
|
|
apiMock.submitPrompt.mockResolvedValue({ promptId: 'pr_btw', userMessageId: 'msg_opt_btw' });
|
|
|
|
const state = createState();
|
|
const pushOperationFailure = vi.fn();
|
|
const sideChat = useSideChat(state, {
|
|
pushOperationFailure,
|
|
nextOptimisticMsgId: () => 'msg_opt_btw',
|
|
connectEventsIfNeeded: vi.fn(),
|
|
getEventConn: () => null,
|
|
thinkingLevelForModelId: () => undefined,
|
|
});
|
|
|
|
await sideChat.openSideChatOn('sess_1', 'what changed?');
|
|
|
|
expect(apiMock.startBtw).toHaveBeenCalledWith('sess_1');
|
|
expect(apiMock.submitPrompt).toHaveBeenCalledWith(
|
|
'sess_1',
|
|
expect.objectContaining({
|
|
agentId: 'agent_btw_1',
|
|
model: 'kimi-code',
|
|
thinking: 'high',
|
|
permissionMode: 'auto',
|
|
planMode: true,
|
|
swarmMode: false,
|
|
}),
|
|
);
|
|
expect(pushOperationFailure).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back to the active level when the parent model has left the catalog', async () => {
|
|
// thinkingLevelForModelId returns undefined for a model the catalog no
|
|
// longer lists — the submit then keeps the active-session level (same
|
|
// fallback as the normal prompt paths).
|
|
apiMock.startBtw.mockReset();
|
|
apiMock.submitPrompt.mockReset();
|
|
apiMock.startBtw.mockResolvedValue({ agentId: 'agent_btw_1' });
|
|
apiMock.submitPrompt.mockResolvedValue({ promptId: 'pr_btw', userMessageId: 'msg_opt_btw' });
|
|
|
|
const state = createState();
|
|
state.thinking = 'max';
|
|
const sideChat = useSideChat(state, {
|
|
pushOperationFailure: vi.fn(),
|
|
nextOptimisticMsgId: () => 'msg_opt_btw',
|
|
connectEventsIfNeeded: vi.fn(),
|
|
getEventConn: () => null,
|
|
thinkingLevelForModelId: () => undefined,
|
|
});
|
|
|
|
await sideChat.openSideChatOn('sess_1', 'what changed?');
|
|
|
|
expect(apiMock.submitPrompt).toHaveBeenCalledWith(
|
|
'sess_1',
|
|
expect.objectContaining({ thinking: 'max' }),
|
|
);
|
|
});
|
|
|
|
it('resolves thinking from the parent model, not the level of the session the user switched to', async () => {
|
|
// startBtw spans an await during which the user can switch sessions; the
|
|
// BTW prompt must still carry the PARENT model's level ('low'), never the
|
|
// active view's ('max').
|
|
apiMock.startBtw.mockReset();
|
|
apiMock.submitPrompt.mockReset();
|
|
apiMock.startBtw.mockResolvedValue({ agentId: 'agent_btw_1' });
|
|
apiMock.submitPrompt.mockResolvedValue({ promptId: 'pr_btw', userMessageId: 'msg_opt_btw' });
|
|
|
|
const state = createState();
|
|
state.thinking = 'max'; // the user is now viewing a max-only session elsewhere
|
|
const sideChat = useSideChat(state, {
|
|
pushOperationFailure: vi.fn(),
|
|
nextOptimisticMsgId: () => 'msg_opt_btw',
|
|
connectEventsIfNeeded: vi.fn(),
|
|
getEventConn: () => null,
|
|
thinkingLevelForModelId: (id) => (id === 'kimi-code' ? 'low' : undefined),
|
|
});
|
|
|
|
await sideChat.openSideChatOn('sess_1', 'what changed?');
|
|
|
|
expect(apiMock.submitPrompt).toHaveBeenCalledWith(
|
|
'sess_1',
|
|
expect.objectContaining({ model: 'kimi-code', thinking: 'low' }),
|
|
);
|
|
});
|
|
});
|