mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-12 18:27:39 +00:00
* feat(agent-core): custom agent files and secondary model on the v1 engine
Migrate the custom agentfile and secondary-model capabilities from
agent-core-v2 to the v1 engine so they work in the TUI and plain
kimi -p sessions:
- discover Markdown agent files from user/project/extra/explicit
directories with the v2 precedence rules, a merged session profile
catalog replacing the hardcoded builtin profile lookups, SYSTEM.md
main prompt override, and ${base_prompt} backed by the effective
default
- --agent/--agent-file now work in print mode on the default engine;
CreateSessionOptions gains agentProfile/agentFiles
- [secondary_model] config + KIMI_SECONDARY_MODEL/EFFORT bind newly
spawned subagents to a cheaper model behind the secondary-model
experiment flag, with primary/secondary model params on Agent and
AgentSwarm and upfront session warnings
- full disallowedTools deny semantics (exact names + mcp__ globs)
evaluated by the tool manager and persisted in the agent wire
* fix(cli): guard optional agentFiles in the prompt runner
runPrompt is also driven programmatically (headless goal flow) with
options that never pass through the CLI parser defaults, so agentFiles
can be undefined; mirror the addDirs optional-chaining pattern. Also
extend the SDK experimental-feature assertion with the secondary-model
flag.
* fix(agent-core): preserve custom agent bindings on v1
* fix(agent-core): narrow secondary model error hints
* fix(agent-core): persist custom agent profile bindings
* Delete .changeset/sdk-agent-profile-options.md
Signed-off-by: 7Sageer <sag77r@hotmail.com>
* Update v1-custom-agent-files.md
Signed-off-by: 7Sageer <sag77r@hotmail.com>
* Update v1-secondary-model.md
Signed-off-by: 7Sageer <sag77r@hotmail.com>
* Update v1-custom-agent-files.md
Signed-off-by: 7Sageer <sag77r@hotmail.com>
* fix(agent-core): keep SYSTEM.md a prompt-only overlay for delegation
* docs: update agent file and secondary model availability wording
* fix(cli): reject --agent-file combined with session resume
The resume path only forwards the agent file's name for the bound-profile
assertion; the file's content is never re-applied (the session keeps its
creation-time catalog snapshot). Previously the combination was silently
accepted, so an edited file (or a same-named one) appeared to apply but did
not. Reject it at option validation and document the constraint.
* refactor(agent-core): share prompt-section prose and note v2 twins in agentfile headers
The Windows notes, additional-dirs and skills prose blocks existed twice:
inline in the builtin default template (system.md) and as constants in the
agent-file renderer (from-file.ts). Extract them to profile/prompt-sections.ts
as the single source: system.md renders them through injected KIMI_* template
variables and from-file.ts imports the same constants. Rendered prompts are
byte-identical for all four builtin profiles across macOS/Windows and
skills/dirs on/off; a new test pins system.md to the shared constants.
Also mark each profile/agentfile file with the path of its agent-core-v2
counterpart so format/semantics changes land in both engines.
* feat(cli): add /secondary_model command for the subagent model
Mirror /model: a picker with a thinking-effort step that persists [secondary_model] and live-applies to the current session via a new Session.setSecondaryModel RPC (node-sdk wrapper included), so newly spawned subagents bind the new model right away. The /model picker now hides the synthesized __secondary__ derived entry; docs and the update-config builtin skill mention the section.
* feat(tui): show the bound model in subagent run stats
Subagents report their model alias via agent.status.updated after spawn; resolve it to a display name and surface it in tool-call subagent stats and agent-group rows.
* fix(agent-core): validate agent profile before session persistence
* fix(agent-core): refresh subagent tools after model switch
* fix(agent-core): show subagent model preferences
* fix(agent-core): preserve secondary model recipe on live apply
* fix(agent-core): make secondary model apply explicit
* fix(tui): refresh secondary model display state
* chore: merge secondary model changesets into one
* Add /secondary_model command for subagent configuration
Show each subagent's model in the subagent card header and agent-group rows. Requires the secondary-model experiment (KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL=1); run /secondary_model to pick a model and thinking effort, applied to the current session immediately.
Signed-off-by: 7Sageer <sag77r@hotmail.com>
* fix(agent-core): align explicit agent file precedence
* fix(agent-core): let disallowedTools deny select_tools
* chore(cli): drop engine mention from --agent/--agent-file help text
* feat(cli): support --agent/--agent-file in the interactive TUI
Bind the selected agent profile to the startup session when launching
the TUI with --agent/--agent-file, including the session created after
an OAuth login at startup. Sessions created later in the process (/new)
keep the default profile.
Make both flags creation-only in every mode: combining them with
--session/--continue is now rejected in print mode too, since resume
restores the bound agent from the session automatically.
* fix(agent-core): persist new secondary-model selections under env overrides
stripSecondaryModelConfig restored secondary_model.model/default_effort
from raw whenever KIMI_SECONDARY_MODEL/KIMI_SECONDARY_EFFORT was set, so
a /secondary_model pick made under the env vars was silently discarded
on write. Restore from raw only when the value being written still
equals the env value (an overlay round-trip), mirroring the pointer
check in stripEnvModelConfig; a genuinely different selection now
reaches config.toml.
* fix(cli): report the effective secondary model when env overrides the pick
/secondary_model toasted the picked alias even when
KIMI_SECONDARY_MODEL/KIMI_SECONDARY_EFFORT made the session bind a
different model. Read the effective binding back from the reloaded
config (as /model does from session status) and warn with the
env-overridden values instead.
* feat(tui): show the bound model name in the AgentSwarm panel header
---------
Signed-off-by: 7Sageer <sag77r@hotmail.com>
948 lines
28 KiB
TypeScript
948 lines
28 KiB
TypeScript
/**
|
|
* Scenario: KimiHarness session creation and resume transport behavior.
|
|
* Responsibilities: SDK options reach the in-process core and session identity remains stable.
|
|
* Wiring: the real SDK/core are used; model/network boundaries are configured but never called.
|
|
* Run: pnpm -C packages/node-sdk exec vitest run test/create-session-transport.test.ts
|
|
*/
|
|
|
|
import { existsSync } from 'node:fs';
|
|
import { mkdir, 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',
|
|
);
|
|
}
|
|
|
|
async function writeReviewerAgent(workDir: string): Promise<void> {
|
|
const agentDir = join(workDir, '.kimi-code', 'agents');
|
|
await mkdir(agentDir, { recursive: true });
|
|
await writeFile(
|
|
join(agentDir, 'reviewer.md'),
|
|
'---\nname: reviewer\ndescription: Reviews code.\nsubagents:\n - explore\n---\n\nReview the requested change.\n',
|
|
'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('does not persist a session record when the requested agent profile is missing', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
});
|
|
|
|
try {
|
|
await expect(
|
|
harness.createSession({
|
|
id: 'ses_missing_agent_profile',
|
|
workDir,
|
|
agentProfile: 'missing-agent',
|
|
}),
|
|
).rejects.toMatchObject({
|
|
name: 'KimiError',
|
|
code: 'agent.not_found',
|
|
});
|
|
expect(await harness.listSessions({ workDir })).toEqual([]);
|
|
expect(existsSync(join(homeDir, 'session_index.jsonl'))).toBe(false);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('allows the session ID to be reused after agent profile selection fails', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
});
|
|
|
|
try {
|
|
await expect(
|
|
harness.createSession({
|
|
id: 'ses_reusable_after_missing_profile',
|
|
workDir,
|
|
agentProfile: 'missing-agent',
|
|
}),
|
|
).rejects.toMatchObject({ code: 'agent.not_found' });
|
|
|
|
await expect(
|
|
harness.createSession({
|
|
id: 'ses_reusable_after_missing_profile',
|
|
workDir,
|
|
}),
|
|
).resolves.toMatchObject({ id: 'ses_reusable_after_missing_profile' });
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('does not persist a session record when an explicit agent file cannot be loaded', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
});
|
|
|
|
try {
|
|
await expect(
|
|
harness.createSession({
|
|
id: 'ses_missing_explicit_agent_file',
|
|
workDir,
|
|
agentFiles: [join(workDir, 'missing-agent.md')],
|
|
}),
|
|
).rejects.toThrow(/missing-agent\.md/);
|
|
expect(await harness.listSessions({ workDir })).toEqual([]);
|
|
} 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('permanently deletes an active session', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const harness = createKimiHarness({
|
|
identity: TEST_IDENTITY,
|
|
homeDir,
|
|
});
|
|
|
|
try {
|
|
const session = await harness.createSession({ id: 'ses_delete_active', workDir });
|
|
const [summary] = await harness.listSessions({ sessionId: session.id });
|
|
|
|
await harness.deleteSession(session.id);
|
|
|
|
expect(harness.getSession(session.id)).toBeUndefined();
|
|
await expect(harness.listSessions({ sessionId: session.id })).resolves.toEqual([]);
|
|
expect(existsSync(summary!.sessionDir)).toBe(false);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('returns session.not_found when deleteSession targets a missing id', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir });
|
|
|
|
try {
|
|
await expect(harness.deleteSession('ses_delete_missing')).rejects.toMatchObject({
|
|
name: 'KimiError',
|
|
code: 'session.not_found',
|
|
} satisfies Partial<KimiError>);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('allows a deleted session id to be created again', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir });
|
|
const sessionId = 'ses_delete_recreate';
|
|
|
|
try {
|
|
await harness.createSession({ id: sessionId, workDir });
|
|
await harness.deleteSession(sessionId);
|
|
|
|
const recreated = await harness.createSession({ id: sessionId, workDir });
|
|
|
|
expect(recreated.id).toBe(sessionId);
|
|
await expect(harness.listSessions({ sessionId })).resolves.toHaveLength(1);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('preserves a legacy source directory referenced by session metadata', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
const legacySourceDir = await makeTempDir();
|
|
const markerPath = join(legacySourceDir, 'legacy-marker.txt');
|
|
await writeFile(markerPath, 'legacy source remains', 'utf-8');
|
|
const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir });
|
|
|
|
try {
|
|
const session = await harness.createSession({
|
|
id: 'ses_delete_migrated',
|
|
workDir,
|
|
metadata: { kimi_cli_source_path: legacySourceDir },
|
|
});
|
|
|
|
await harness.deleteSession(session.id);
|
|
|
|
await expect(readFile(markerPath, 'utf-8')).resolves.toBe('legacy source remains');
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
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,
|
|
});
|
|
});
|
|
|
|
it('rejects an active session resume when the requested profile differs from its binding', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
await writeTestModelConfig(homeDir);
|
|
await writeReviewerAgent(workDir);
|
|
const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir });
|
|
|
|
try {
|
|
const session = await harness.createSession({
|
|
id: 'ses_active_profile_identity',
|
|
workDir,
|
|
agentProfile: 'reviewer',
|
|
});
|
|
|
|
await expect(
|
|
harness.resumeSession({ id: session.id, agentProfile: 'agent' }),
|
|
).rejects.toThrow(
|
|
'agent is already bound to profile "reviewer"; cannot switch to "agent" in this session',
|
|
);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('returns the active session when the requested profile matches its binding', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
await writeTestModelConfig(homeDir);
|
|
await writeReviewerAgent(workDir);
|
|
const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir });
|
|
|
|
try {
|
|
const session = await harness.createSession({
|
|
id: 'ses_matching_profile_identity',
|
|
workDir,
|
|
agentProfile: 'reviewer',
|
|
});
|
|
|
|
await expect(
|
|
harness.resumeSession({ id: session.id, agentProfile: 'reviewer' }),
|
|
).resolves.toBe(session);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
|
|
it('rejects a persisted session resume when the requested profile differs from its binding', async () => {
|
|
const homeDir = await makeTempDir();
|
|
const workDir = await makeTempDir();
|
|
await writeTestModelConfig(homeDir);
|
|
await writeReviewerAgent(workDir);
|
|
const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir });
|
|
|
|
try {
|
|
const session = await harness.createSession({
|
|
id: 'ses_persisted_profile_identity',
|
|
workDir,
|
|
agentProfile: 'reviewer',
|
|
});
|
|
await session.close();
|
|
|
|
await expect(
|
|
harness.resumeSession({ id: session.id, agentProfile: 'agent' }),
|
|
).rejects.toThrow(
|
|
'agent is already bound to profile "reviewer"; cannot switch to "agent" in this session',
|
|
);
|
|
} finally {
|
|
await harness.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
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();
|
|
}
|