mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-25 08:34:39 +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
706 lines
20 KiB
TypeScript
706 lines
20 KiB
TypeScript
import { existsSync } from 'node:fs';
|
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import type { Kaos } from '@moonshot-ai/kaos';
|
|
import { createKimiHarness, KimiHarness } from '#/index';
|
|
import type { KimiError } from '#/index';
|
|
import type { ResumeSessionInput, ResumedSessionSummary } from '#/types';
|
|
import { SDKRpcClientBase } from '#/rpc';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import { waitForAgentWireEvent } from './session-runtime-helpers';
|
|
import { recordingTelemetry, type TelemetryRecord } from './telemetry';
|
|
import { TEST_IDENTITY } from './test-identity';
|
|
|
|
// node-sdk/agent-core normalize paths to forward slashes (pathe). Mirror that
|
|
// in path assertions so they hold on Windows, where node:path produces
|
|
// backslashes.
|
|
const toPosix = (p: string): string => p.replaceAll('\\', '/');
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
async function makeTempDir(): Promise<string> {
|
|
const dir = await mkdtemp(join(tmpdir(), 'kimi-sdk-create-'));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
async function writeTestModelConfig(homeDir: string, modelName = 'kimi-test-model'): Promise<void> {
|
|
await writeFile(
|
|
join(homeDir, 'config.toml'),
|
|
`
|
|
[providers.local]
|
|
type = "kimi"
|
|
base_url = "https://example.test/v1"
|
|
api_key = "sk-test"
|
|
|
|
[models."${modelName}"]
|
|
provider = "local"
|
|
model = "${modelName}"
|
|
max_context_size = 1000
|
|
`,
|
|
'utf-8',
|
|
);
|
|
}
|
|
|
|
class StubRpc extends SDKRpcClientBase {
|
|
resumeCalls: Array<{ input: ResumeSessionInput; kaos: Kaos; persistenceKaos?: Kaos }> = [];
|
|
|
|
protected async getRpc(): Promise<never> {
|
|
throw new Error('not used');
|
|
}
|
|
|
|
override async createSession(input: { id?: string; workDir: string }) {
|
|
return {
|
|
id: input.id ?? 'ses_stub',
|
|
workDir: input.workDir,
|
|
sessionDir: '/tmp/session',
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
|
|
override async resumeSessionWithKaos(input: ResumeSessionInput, kaos: Kaos, persistenceKaos?: Kaos): Promise<ResumedSessionSummary> {
|
|
this.resumeCalls.push({ input, kaos, persistenceKaos });
|
|
return {
|
|
id: input.id,
|
|
workDir: '/tmp/work',
|
|
sessionDir: '/tmp/session',
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
sessionMetadata: {
|
|
createdAt: '',
|
|
updatedAt: '',
|
|
title: '',
|
|
isCustomTitle: false,
|
|
agents: {},
|
|
custom: {},
|
|
},
|
|
agents: {},
|
|
};
|
|
}
|
|
}
|
|
|
|
describe('KimiHarness.createSession transport link', () => {
|
|
it('emits session_started with client attribution when a session is opened', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const records: TelemetryRecord[] = [];
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
telemetry: recordingTelemetry(records),
|
|
});
|
|
|
|
try {
|
|
const session = await harness.createSession({
|
|
id: 'ses_session_started',
|
|
workDir,
|
|
});
|
|
await harness.resumeSession({ id: session.id });
|
|
|
|
expect(records).toContainEqual({
|
|
event: 'session_started',
|
|
sessionId: session.id,
|
|
properties: {
|
|
client_id: null,
|
|
client_name: 'kimi-code-cli',
|
|
client_version: '0.0.0-test',
|
|
ui_mode: 'shell',
|
|
resumed: false,
|
|
},
|
|
});
|
|
expect(records.filter((record) => record.event === 'session_started')).toHaveLength(1);
|
|
expect(records).toContainEqual({
|
|
event: 'session_new',
|
|
sessionId: session.id,
|
|
properties: undefined,
|
|
});
|
|
|
|
await session.close();
|
|
await harness.resumeSession({ id: session.id });
|
|
|
|
expect(records.filter((record) => record.event === 'session_started')).toHaveLength(2);
|
|
expect(records).toContainEqual({
|
|
event: 'session_started',
|
|
sessionId: session.id,
|
|
properties: {
|
|
client_id: null,
|
|
client_name: 'kimi-code-cli',
|
|
client_version: '0.0.0-test',
|
|
ui_mode: 'shell',
|
|
resumed: true,
|
|
},
|
|
});
|
|
expect(records).toContainEqual({
|
|
event: 'session_resume',
|
|
sessionId: session.id,
|
|
properties: undefined,
|
|
});
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('uses the configured UI mode for session_started attribution', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const records: TelemetryRecord[] = [];
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
telemetry: recordingTelemetry(records),
|
|
uiMode: 'print',
|
|
});
|
|
|
|
try {
|
|
const session = await harness.createSession({
|
|
id: 'ses_session_started_print',
|
|
workDir,
|
|
});
|
|
|
|
expect(records).toContainEqual({
|
|
event: 'session_started',
|
|
sessionId: session.id,
|
|
properties: {
|
|
client_id: null,
|
|
client_name: 'kimi-code-cli',
|
|
client_version: '0.0.0-test',
|
|
ui_mode: 'print',
|
|
resumed: false,
|
|
},
|
|
});
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('merges process-level sessionStartedProperties into session_started', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const records: TelemetryRecord[] = [];
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
telemetry: recordingTelemetry(records),
|
|
sessionStartedProperties: { yolo: true, plan: false },
|
|
});
|
|
|
|
try {
|
|
const session = await harness.createSession({
|
|
id: 'ses_process_props',
|
|
workDir,
|
|
});
|
|
|
|
expect(records).toContainEqual({
|
|
event: 'session_started',
|
|
sessionId: session.id,
|
|
properties: {
|
|
client_id: null,
|
|
client_name: 'kimi-code-cli',
|
|
client_version: '0.0.0-test',
|
|
ui_mode: 'shell',
|
|
resumed: false,
|
|
yolo: true,
|
|
plan: false,
|
|
},
|
|
});
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('merges session-level sessionStartedProperties and overrides process-level ones', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const records: TelemetryRecord[] = [];
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
telemetry: recordingTelemetry(records),
|
|
sessionStartedProperties: { mode: 'process', source: 'process' },
|
|
});
|
|
|
|
try {
|
|
const session = await harness.createSession({
|
|
id: 'ses_scoped_props',
|
|
workDir,
|
|
sessionStartedProperties: { mode: 'new' },
|
|
});
|
|
|
|
expect(records).toContainEqual({
|
|
event: 'session_started',
|
|
sessionId: session.id,
|
|
properties: {
|
|
client_id: null,
|
|
client_name: 'kimi-code-cli',
|
|
client_version: '0.0.0-test',
|
|
ui_mode: 'shell',
|
|
resumed: false,
|
|
mode: 'new',
|
|
source: 'process',
|
|
},
|
|
});
|
|
|
|
await session.close();
|
|
await harness.resumeSession({
|
|
id: session.id,
|
|
sessionStartedProperties: { mode: 'load' },
|
|
});
|
|
|
|
expect(records).toContainEqual({
|
|
event: 'session_started',
|
|
sessionId: session.id,
|
|
properties: {
|
|
client_id: null,
|
|
client_name: 'kimi-code-cli',
|
|
client_version: '0.0.0-test',
|
|
ui_mode: 'shell',
|
|
resumed: true,
|
|
mode: 'load',
|
|
source: 'process',
|
|
},
|
|
});
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('does not let sessionStartedProperties override canonical session_started fields', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const records: TelemetryRecord[] = [];
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
telemetry: recordingTelemetry(records),
|
|
});
|
|
|
|
try {
|
|
const session = await harness.createSession({
|
|
id: 'ses_reserved_keys',
|
|
workDir,
|
|
sessionStartedProperties: {
|
|
client_name: 'evil',
|
|
client_version: 'evil',
|
|
ui_mode: 'evil',
|
|
resumed: true,
|
|
extra: 'kept',
|
|
},
|
|
});
|
|
|
|
expect(records).toContainEqual({
|
|
event: 'session_started',
|
|
sessionId: session.id,
|
|
properties: {
|
|
client_id: null,
|
|
client_name: 'kimi-code-cli',
|
|
client_version: '0.0.0-test',
|
|
ui_mode: 'shell',
|
|
resumed: false,
|
|
extra: 'kept',
|
|
},
|
|
});
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('emits session_fork with the forked session context', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const records: TelemetryRecord[] = [];
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
telemetry: recordingTelemetry(records),
|
|
});
|
|
|
|
try {
|
|
const source = await harness.createSession({
|
|
id: 'ses_fork_source',
|
|
workDir,
|
|
});
|
|
const forked = await harness.forkSession({
|
|
id: source.id,
|
|
forkId: 'ses_fork_child',
|
|
title: 'Forked child',
|
|
});
|
|
|
|
expect(forked.id).toBe('ses_fork_child');
|
|
expect(records).toContainEqual({
|
|
event: 'session_started',
|
|
sessionId: forked.id,
|
|
properties: {
|
|
client_id: null,
|
|
client_name: 'kimi-code-cli',
|
|
client_version: '0.0.0-test',
|
|
ui_mode: 'shell',
|
|
resumed: true,
|
|
},
|
|
});
|
|
expect(records).toContainEqual({
|
|
event: 'session_fork',
|
|
sessionId: forked.id,
|
|
properties: undefined,
|
|
});
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('does not invent client attribution without host identity', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const records: TelemetryRecord[] = [];
|
|
const harness = createKimiHarness({
|
|
homeDir,
|
|
telemetry: recordingTelemetry(records),
|
|
});
|
|
|
|
try {
|
|
const session = await harness.createSession({
|
|
id: 'ses_session_started_shell',
|
|
workDir,
|
|
});
|
|
|
|
expect(records).toContainEqual({
|
|
event: 'session_started',
|
|
sessionId: session.id,
|
|
properties: {
|
|
client_id: null,
|
|
client_name: null,
|
|
client_version: null,
|
|
ui_mode: 'shell',
|
|
resumed: false,
|
|
},
|
|
});
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('creates metadata and keeps the session active in the harness', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
await writeTestModelConfig(homeDir);
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
});
|
|
|
|
try {
|
|
const session = await harness.createSession({
|
|
id: 'ses_transport_link',
|
|
workDir,
|
|
model: 'kimi-test-model',
|
|
});
|
|
|
|
expect(session.id).toBe('ses_transport_link');
|
|
expect(session.workDir).toBe(toPosix(workDir));
|
|
await expect(session.getStatus()).resolves.toMatchObject({ model: 'kimi-test-model' });
|
|
expect(harness.sessions.get(session.id)).toBe(session);
|
|
const configEvent = await waitForAgentWireEvent(
|
|
homeDir,
|
|
session.id,
|
|
'config.update',
|
|
(event) => event['modelAlias'] === 'kimi-test-model',
|
|
);
|
|
expect(configEvent).toMatchObject({
|
|
type: 'config.update',
|
|
modelAlias: 'kimi-test-model',
|
|
});
|
|
expect(configEvent).not.toHaveProperty('provider');
|
|
|
|
const summaries = await harness.listSessions({ workDir });
|
|
const summary = summaries.find((item) => item.id === session.id);
|
|
expect(summary?.sessionDir).not.toBe(join(homeDir, 'sessions', session.id));
|
|
expect(summary?.sessionDir).toContain(toPosix(join(homeDir, 'sessions')));
|
|
expect(existsSync(join(summary!.sessionDir, 'state.json'))).toBe(true);
|
|
expect(await readFile(join(homeDir, 'session_index.jsonl'), 'utf-8')).toContain(session.id);
|
|
|
|
const summariesById = await harness.listSessions({ sessionId: session.id });
|
|
expect(summariesById).toHaveLength(1);
|
|
expect(summariesById[0]).toMatchObject({
|
|
id: session.id,
|
|
workDir: toPosix(workDir),
|
|
});
|
|
await expect(harness.listSessions({ sessionId: 'ses_missing' })).resolves.toEqual([]);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('accepts configured model aliases while creating the core session', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
await writeFile(
|
|
join(homeDir, 'config.toml'),
|
|
`
|
|
default_model = "alias-model"
|
|
|
|
[providers.local]
|
|
type = "openai"
|
|
base_url = "https://example.test/v1"
|
|
api_key = "sk-test"
|
|
|
|
[models.alias-model]
|
|
provider = "local"
|
|
model = "real-model"
|
|
max_context_size = 1000
|
|
|
|
[thinking]
|
|
effort = "medium"
|
|
`,
|
|
'utf-8',
|
|
);
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
});
|
|
|
|
try {
|
|
const session = await harness.createSession({ id: 'ses_alias_model', workDir });
|
|
expect(session.id).toBe('ses_alias_model');
|
|
await expect(session.getStatus()).resolves.toMatchObject({ model: 'alias-model' });
|
|
expect(harness.sessions.get(session.id)).toBe(session);
|
|
const configEvent = await waitForAgentWireEvent(
|
|
homeDir,
|
|
session.id,
|
|
'config.update',
|
|
(event) => event['modelAlias'] === 'alias-model',
|
|
);
|
|
expect(configEvent).toMatchObject({
|
|
type: 'config.update',
|
|
modelAlias: 'alias-model',
|
|
});
|
|
expect(configEvent).not.toHaveProperty('provider');
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('does not require provider config or API keys before prompt is implemented', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
});
|
|
|
|
try {
|
|
const session = await harness.createSession({ id: 'ses_empty_config', workDir });
|
|
expect(session.id).toBe('ses_empty_config');
|
|
expect((await session.getStatus()).model).toBeUndefined();
|
|
expect(harness.sessions.get(session.id)).toBe(session);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('requires a non-empty workDir on createSession', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
|
|
|
try {
|
|
await expect(
|
|
harness.createSession({ id: 'ses_missing_workdir' } as never),
|
|
).rejects.toMatchObject({
|
|
name: 'KimiError',
|
|
code: 'request.work_dir_required',
|
|
} satisfies Partial<KimiError>);
|
|
await expect(
|
|
harness.createSession({ id: 'ses_blank_workdir', workDir: ' ' }),
|
|
).rejects.toMatchObject({
|
|
name: 'KimiError',
|
|
code: 'request.work_dir_required',
|
|
} satisfies Partial<KimiError>);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('does not persist a session record when MCP config validation fails', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
// Project-local mcp.json is intentionally ignored, so plant the malformed
|
|
// file under the user home dir where the loader actually reads from.
|
|
await writeFile(join(homeDir, 'mcp.json'), '{not json}', 'utf-8');
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
});
|
|
|
|
try {
|
|
await expect(
|
|
harness.createSession({ id: 'ses_bad_mcp_config', workDir }),
|
|
).rejects.toMatchObject({
|
|
name: 'KimiError',
|
|
code: 'config.invalid',
|
|
});
|
|
expect(await harness.listSessions({ workDir })).toEqual([]);
|
|
expect(existsSync(join(homeDir, 'session_index.jsonl'))).toBe(false);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('closes active runtime handles through closeSession, session.close, and close', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
await writeTestModelConfig(homeDir);
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
});
|
|
|
|
const first = await harness.createSession({
|
|
id: 'ses_close_one',
|
|
workDir,
|
|
model: 'kimi-test-model',
|
|
});
|
|
const second = await harness.createSession({
|
|
id: 'ses_close_two',
|
|
workDir,
|
|
model: 'kimi-test-model',
|
|
});
|
|
expect(coreSessionIds(harness)).toEqual([first.id, second.id]);
|
|
|
|
await harness.closeSession(first.id);
|
|
expect(harness.getSession(first.id)).toBeUndefined();
|
|
expect(coreSessionIds(harness)).toEqual([second.id]);
|
|
|
|
await second.close();
|
|
expect(harness.getSession(second.id)).toBeUndefined();
|
|
expect(coreSessionIds(harness)).toEqual([]);
|
|
|
|
await harness.close();
|
|
expect(harness.sessions.size).toBe(0);
|
|
expect(coreSessionIds(harness)).toEqual([]);
|
|
});
|
|
|
|
it('applies initial thinking and permission runtime options', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
});
|
|
|
|
try {
|
|
const session = await harness.createSession({
|
|
id: 'ses_initial_runtime_options',
|
|
workDir,
|
|
thinking: 'low',
|
|
permission: 'auto',
|
|
});
|
|
|
|
await expect(
|
|
waitForAgentWireEvent(
|
|
homeDir,
|
|
session.id,
|
|
'config.update',
|
|
(event) => event['thinkingEffort'] === 'low',
|
|
),
|
|
).resolves.toMatchObject({
|
|
type: 'config.update',
|
|
thinkingEffort: 'low',
|
|
});
|
|
await expect(
|
|
waitForAgentWireEvent(
|
|
homeDir,
|
|
session.id,
|
|
'permission.set_mode',
|
|
(event) => event['mode'] === 'auto',
|
|
),
|
|
).resolves.toMatchObject({
|
|
type: 'permission.set_mode',
|
|
mode: 'auto',
|
|
});
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('applies configured default permission mode to new sessions', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
await writeFile(join(homeDir, 'config.toml'), 'default_permission_mode = "auto"\n', 'utf-8');
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
});
|
|
|
|
try {
|
|
const session = await harness.createSession({
|
|
id: 'ses_default_permission_mode',
|
|
workDir,
|
|
});
|
|
|
|
await expect(session.getStatus()).resolves.toMatchObject({ permission: 'auto' });
|
|
await expect(
|
|
waitForAgentWireEvent(
|
|
homeDir,
|
|
session.id,
|
|
'permission.set_mode',
|
|
(event) => event['mode'] === 'auto',
|
|
),
|
|
).resolves.toMatchObject({
|
|
type: 'permission.set_mode',
|
|
mode: 'auto',
|
|
});
|
|
|
|
const explicit = await harness.createSession({
|
|
id: 'ses_default_permission_explicit_override',
|
|
workDir,
|
|
permission: 'manual',
|
|
});
|
|
await expect(explicit.getStatus()).resolves.toMatchObject({ permission: 'manual' });
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('rebinds an active session when resumeSession receives a new Kaos', async () => {
|
|
const records: TelemetryRecord[] = [];
|
|
const rpc = new StubRpc();
|
|
const harness = new KimiHarness(rpc, {
|
|
homeDir: '/tmp/home',
|
|
configPath: '/tmp/config.toml',
|
|
auth: { status: async () => ({ providers: [] }) } as never,
|
|
telemetry: recordingTelemetry(records),
|
|
ensureConfigFile: async () => undefined,
|
|
onClose: () => undefined,
|
|
});
|
|
|
|
const session = await harness.createSession({ id: 'ses_active', workDir: '/tmp/work' });
|
|
const kaos = {} as Kaos;
|
|
|
|
const resumed = await harness.resumeSession({ id: session.id, kaos });
|
|
|
|
expect(resumed).toBe(session);
|
|
expect(rpc.resumeCalls).toHaveLength(1);
|
|
expect(rpc.resumeCalls[0]).toMatchObject({
|
|
input: { id: 'ses_active' },
|
|
kaos,
|
|
persistenceKaos: undefined,
|
|
});
|
|
});
|
|
});
|
|
|
|
function coreSessionIds(harness: KimiHarness): readonly string[] {
|
|
const core = (
|
|
harness as unknown as {
|
|
readonly rpc: { readonly core: { readonly sessions: ReadonlyMap<string, unknown> } };
|
|
}
|
|
).rpc.core;
|
|
return Array.from(core.sessions.keys()).toSorted();
|
|
}
|