mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
* feat: support multi-level thinking effort switching
- kimi provider: emit thinking.effort in the new wire format; keep reasoning_effort mirrored during the transition
- model catalog: thread support_efforts / default_effort from oauth through to /models
- config schema: add supportEfforts / defaultEffort on model aliases
- TUI: multi-segment thinking control in /model, new /effort command, footer effort display
- switch status uses displayName and distinguishes model vs effort-only changes
* docs: add thinking effort design plans
- thinking-effort-switching.md: implemented multi-level effort switching
- thinking-model-overhaul.md: follow-up refactor plan for the thinking state model
* docs: collapse thinking overhaul plan into a single PR
* refactor!: overhaul thinking config and effort resolution
Replace default_thinking and thinking.mode with a single [thinking] enabled/effort table. ThinkingEffort is now an open string ('off' | 'on' | model-declared effort); effort levels come from each model's support_efforts instead of a fixed enum.
Centralize default and always_thinking clamp logic in resolveThinkingEffort/defaultThinkingEffortFor, and honor an explicitly configured effort when an always_thinking model is forced back on.
TUI keeps a single thinkingEffort field instead of the boolean + level pair; 'on' is normalized to the model default at the UI boundary.
BREAKING CHANGE: default_thinking and thinking.mode are removed from config; migrate to [thinking] enabled/effort.
* refactor: rename residual thinking level wording to effort
Rename comments, error messages, parameter names, the SetThinkingPayload wire field (level -> effort), and TUI local variables so the thinking effort naming is consistent throughout. No behavior change.
* refactor: rename remaining camelCase thinking level identifiers to effort
Rename liveLevel/prevLevel/levelChanged/commitLevel/effectiveLevel to liveEffort/prevEffort/effortChanged/commitEffort/effectiveEffort in the TUI model picker and config commands.
* refactor: eliminate remaining thinking level wording in comments and tests
Rename levelLabel -> effortLabel, EffortSelectorOptions.levels -> efforts, and 'effort level(s)' / 'default level' / 'requested level' wording in comments, error messages, slash-command description, and test titles to effort. Also restore the withThinking(effort) parameter rename in the Kimi provider that was accidentally reverted.
* fix: address codex review feedback on thinking effort handling
- OpenAI thinkingEffortToReasoningEffort and Anthropic clampEffort now normalize 'on' / unrecognized efforts instead of throwing, so boolean non-Kimi models no longer crash on session start.
- ACP resolveCurrentThinkingEnabled treats a non-empty thinking.effort as enabled, matching agent-core's resolveThinkingEffort.
- REST promptThinkingSchema accepts any non-empty effort string so model-declared efforts are not rejected at the API boundary.
* test: align kimi e2e expectations with supportEfforts-gated reasoning_effort
The kimi provider now sends reasoning_effort only when the model declares support_efforts; boolean models (no support_efforts) send only thinking.type. Update the kimi e2e tests to drop the stale reasoning_effort expectation for the boolean test model.
* test: cover [thinking] effort parsing in config.test
Add effort = "high" to the documented [thinking] table in the config parse test and assert config.thinking.effort is resolved, so the new [thinking] effort field has direct parse coverage.
* docs: add thinking test coverage gap analysis
Capture the explore agent's test coverage review for the thinking overhaul PR, including P1/P2 gaps and the two open design questions, for follow-up test additions.
* feat(oauth): parse nested think_efforts from /models response
The /models endpoint now returns effort levels under a nested think_efforts object ({ support, valid_efforts, default_effort }). Parse it preferentially in both managed-kimi-code and open-platform model parsing, falling back to the legacy flat support_efforts / default_effort fields for older servers.
* refactor(oauth): only read nested think_efforts; gate on support=true
Drop the legacy flat support_efforts / default_effort fallback. The think_efforts object is now the single source, and its support flag gates the whole object — when support is not true, valid_efforts and default_effort are ignored entirely.
* chore: remove unused parseStringArray import in open-platform
* docs: finalize thinking effort release notes
Downgrade the changeset to minor with an English summary, drop the version-specific 'added in 1.0.0' info block, and present the deprecated config fields as a table (field / deprecated in 0.21.0 / description).
* refactor: drop temporary refresh toggles and kimi reasoning_effort mirror
Remove the always-true REFRESH_MODELS_ON_PICKER_OPEN / REFRESH_PROVIDER_MODELS_ON_STARTUP toggles and their stale re-enable TODOs, and stop sending reasoning_effort from the kimi provider (thinking.effort is the only wire field now).
* fix(tui): avoid persisting "on" as thinking effort
* fix: preserve persisted thinking effort across login and provider setup
* fix(tui): show actual thinking effort in /status and footer
* test(tui): align message-flow expectations with effort persistence and /status display
* fix(vis): rename thinkingLevel to thinkingEffort in config.update analysis
134 lines
6.6 KiB
TypeScript
134 lines
6.6 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
|
|
import { analyzeWire } from '../src/lib/analysis';
|
|
import type { WireEntry } from '../src/types';
|
|
|
|
let line = 0;
|
|
function e(data: Record<string, unknown>, time?: number): WireEntry {
|
|
line += 1;
|
|
return { lineNo: line, data: { ...data, time }, raw: data } as unknown as WireEntry;
|
|
}
|
|
function loop(event: Record<string, unknown>, time?: number): WireEntry {
|
|
return e({ type: 'context.append_loop_event', event }, time);
|
|
}
|
|
|
|
describe('analyzeWire', () => {
|
|
it('folds a session into turns/steps/tools with derived metrics', () => {
|
|
line = 0;
|
|
const entries: WireEntry[] = [
|
|
e({ type: 'turn.prompt', input: [{ type: 'text', text: 'hello' }], origin: { kind: 'user' } }, 1000),
|
|
loop({ type: 'step.begin', uuid: 's1', turnId: 'T1', step: 0 }, 1100),
|
|
loop({ type: 'tool.call', uuid: 'tc1', turnId: 'T1', step: 0, stepUuid: 's1', toolCallId: 'c1', name: 'Read' }, 1200),
|
|
loop({ type: 'tool.result', parentUuid: 'tc1', toolCallId: 'c1', result: { output: 'x'.repeat(50), truncated: true } }, 1500),
|
|
loop({ type: 'step.end', uuid: 's1', turnId: 'T1', step: 0, finishReason: 'tool_use', llmFirstTokenLatencyMs: 40, usage: { inputOther: 100, output: 20, inputCacheRead: 80, inputCacheCreation: 10 } }, 1600),
|
|
loop({ type: 'step.begin', uuid: 's2', turnId: 'T1', step: 1 }, 1700),
|
|
loop({ type: 'step.end', uuid: 's2', turnId: 'T1', step: 1, finishReason: 'end_turn', usage: { inputOther: 200, output: 50, inputCacheRead: 150, inputCacheCreation: 0 } }, 2000),
|
|
// Big idle gap → waiting for the user, then a second turn that errors.
|
|
e({ type: 'turn.prompt', input: [{ type: 'text', text: 'again' }], origin: { kind: 'user' } }, 10000),
|
|
loop({ type: 'step.begin', uuid: 's3', turnId: 'T2', step: 0 }, 10100),
|
|
loop({ type: 'tool.call', uuid: 'tc2', turnId: 'T2', step: 0, stepUuid: 's3', toolCallId: 'c2', name: 'Read' }, 10200),
|
|
loop({ type: 'tool.result', parentUuid: 'tc2', toolCallId: 'c2', result: { output: 'y'.repeat(10), isError: true } }, 10250),
|
|
loop({ type: 'step.end', uuid: 's3', turnId: 'T2', step: 0, finishReason: 'filtered', usage: { inputOther: 300, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, 10300),
|
|
];
|
|
|
|
const a = analyzeWire(entries);
|
|
|
|
// Turn grouping
|
|
expect(a.turns).toHaveLength(2);
|
|
expect(a.turns[0]!.promptText).toBe('hello');
|
|
expect(a.turns[0]!.trigger).toBe('prompt');
|
|
expect(a.turns[0]!.steps).toHaveLength(2);
|
|
expect(a.turns[1]!.steps).toHaveLength(1);
|
|
|
|
// Tool duration + truncation + size
|
|
const tc = a.turns[0]!.steps[0]!.toolCalls[0]!;
|
|
expect(tc.durationMs).toBe(300);
|
|
expect(tc.truncated).toBe(true);
|
|
expect(tc.outputBytes).toBe(50);
|
|
expect(tc.isError).toBe(false);
|
|
|
|
// Context-window fill snapshots (agent-core formula)
|
|
expect(a.turns[0]!.steps[0]!.contextTokens).toBe(210); // 100+20+80+10
|
|
expect(a.turns[0]!.steps[1]!.contextTokens).toBe(400); // 200+50+150+0
|
|
expect(a.summary.peakContextTokens).toBe(400);
|
|
expect(a.contextSeries.map((p) => p.contextTokens)).toEqual([210, 400, 300]);
|
|
|
|
// Per-turn token cost = sum of step usages
|
|
expect(a.turns[0]!.tokens).toEqual({ inputOther: 300, output: 70, inputCacheRead: 230, inputCacheCreation: 10 });
|
|
|
|
// Idle / wait
|
|
expect(a.turns[1]!.waitBeforeMs).toBe(8000);
|
|
expect(a.idleGaps).toHaveLength(1);
|
|
expect(a.idleGaps[0]).toMatchObject({ gapMs: 8000, kind: 'between_turns', afterLineNo: 7, beforeLineNo: 8 });
|
|
|
|
// Errors
|
|
expect(a.turns[1]!.steps[0]!.isError).toBe(true); // finishReason 'filtered'
|
|
expect(a.turns[1]!.toolErrorCount).toBe(1);
|
|
|
|
// Summary
|
|
expect(a.summary.turnCount).toBe(2);
|
|
expect(a.summary.stepCount).toBe(3);
|
|
expect(a.summary.toolCallCount).toBe(2);
|
|
expect(a.summary.toolErrorCount).toBe(1);
|
|
expect(a.summary.truncatedToolCount).toBe(1);
|
|
|
|
// Tool stats
|
|
const read = a.toolStats.find((s) => s.name === 'Read')!;
|
|
expect(read.count).toBe(2);
|
|
expect(read.errorCount).toBe(1);
|
|
expect(read.truncatedCount).toBe(1);
|
|
expect(read.timedCount).toBe(2);
|
|
expect(read.totalMs).toBe(350); // 300 + 50
|
|
expect(read.avgMs).toBe(175);
|
|
expect(read.maxMs).toBe(300);
|
|
});
|
|
|
|
it('handles an empty wire', () => {
|
|
const a = analyzeWire([]);
|
|
expect(a.turns).toEqual([]);
|
|
expect(a.summary.turnCount).toBe(0);
|
|
expect(a.cache.hitRate).toBeNull();
|
|
});
|
|
|
|
it('computes cache hit rate from summed input usage', () => {
|
|
line = 0;
|
|
const a = analyzeWire([
|
|
e({ type: 'turn.prompt', input: [{ type: 'text', text: 'q' }], origin: { kind: 'user' } }, 0),
|
|
loop({ type: 'step.begin', uuid: 'x', turnId: 'A', step: 0 }, 1),
|
|
loop({ type: 'step.end', uuid: 'x', turnId: 'A', step: 0, finishReason: 'end_turn', usage: { inputOther: 25, output: 5, inputCacheRead: 75, inputCacheCreation: 0 } }, 2),
|
|
]);
|
|
// hitRate = 75 / (75 + 0 + 25) = 0.75
|
|
expect(a.cache.hitRate).toBeCloseTo(0.75, 5);
|
|
});
|
|
|
|
it('collects config.update changes', () => {
|
|
line = 0;
|
|
const a = analyzeWire([
|
|
e({ type: 'config.update', modelAlias: 'opus', thinkingEffort: 'high', systemPrompt: 'x'.repeat(120) }, 0),
|
|
e({ type: 'config.update', modelAlias: 'sonnet' }, 10),
|
|
]);
|
|
expect(a.configChanges).toHaveLength(2);
|
|
expect(a.configChanges[0]!.changed).toEqual([
|
|
{ field: 'model', value: 'opus' },
|
|
{ field: 'thinking', value: 'high' },
|
|
{ field: 'systemPrompt', value: '120 chars' },
|
|
]);
|
|
expect(a.configChanges[1]!.changed).toEqual([{ field: 'model', value: 'sonnet' }]);
|
|
});
|
|
|
|
it('does not reset context-window fill on a zero-usage step.end', () => {
|
|
line = 0;
|
|
const a = analyzeWire([
|
|
e({ type: 'turn.prompt', input: [{ type: 'text', text: 'q' }], origin: { kind: 'user' } }, 0),
|
|
loop({ type: 'step.begin', uuid: 's1', turnId: 'T', step: 0 }, 1),
|
|
loop({ type: 'step.end', uuid: 's1', turnId: 'T', step: 0, finishReason: 'tool_use', usage: { inputOther: 100, output: 20, inputCacheRead: 80, inputCacheCreation: 0 } }, 2),
|
|
loop({ type: 'step.begin', uuid: 's2', turnId: 'T', step: 1 }, 3),
|
|
// content-filtered: usage all zero — must keep the prior 200, not drop to 0.
|
|
loop({ type: 'step.end', uuid: 's2', turnId: 'T', step: 1, finishReason: 'filtered', usage: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, 4),
|
|
]);
|
|
expect(a.turns[0]!.steps[0]!.contextTokens).toBe(200);
|
|
expect(a.turns[0]!.steps[1]!.contextTokens).toBe(200); // carried, not 0
|
|
expect(a.contextSeries.map((p) => p.contextTokens)).toEqual([200, 200]);
|
|
expect(a.summary.peakContextTokens).toBe(200);
|
|
});
|
|
});
|