kimi-code/packages/node-sdk/test/create-session-transport.test.ts
7Sageer efac96c8a9
feat(agent-core): custom agent files and secondary model on the v1 engine (#2232)
* 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>
2026-07-29 12:06:26 +08:00

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();
}