mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
* feat: add workspace add-dir support
Add multi-directory /add-dir management with session-only or project-remembered persistence, directory completion, confirmation UI, and runtime workspace/permission wiring.
* fix: honor --add-dir for resumed sessions
Pass CLI additional directories through shell and prompt resume paths, resolve caller-relative dirs against workDir, and add regression coverage.
* fix: keep additional dirs AGENTS.md out of default context
Load only user-level and cwd AGENTS.md by default, while preserving additional directory listings in the prompt context.
* feat: append /add-dir result as user message
Add a session appendUserMessage RPC and use it after /add-dir so the command result is recorded as a normal user message and surfaced in the transcript.
* docs: add add-dir research and follow-up todos
Document the add-dir / local-command-stdout research findings and the follow-up tasks for stdout wrapping, slash file completion, and hints.
* feat: wrap /add-dir output as local-command-stdout
Insert the /add-dir result as a user-role <local-command-stdout> record with an injection origin directly inside Session.addAdditionalDir. It enters the model context on the next turn but does not start a turn, and stays out of the live and resumed transcript; the transient status toast is kept for immediate feedback. --add-dir is unaffected since it bypasses addAdditionalDir.
Remove the now-unused appendUserMessage RPC and SDK method.
* feat: reopen /add-dir completion after accepting a directory
Generalize the slash-argument completion reopen so it fires whenever the text before the cursor ends with '/', not only when the literal '/' key is typed. After Tab-accepting a directory (or auto-applying a single-child dir), the next level's completion list reappears automatically, so repeated Tab keeps drilling down into subdirectories. '@' file mention is unaffected.
* feat: reopen file mention completion after accepting a directory
Extend the path completion reopen so it also fires for '@' file mentions. After Tab-accepting a directory in an '@' mention, the next level's completion list reappears automatically, matching the '/add-dir' continuous-Tab behavior.
* feat: show inline argument hints for slash commands
Render a dim ghost-text argument hint inside the input box after a slash command that takes arguments, replacing the popup-only hint that was easy to miss. The hint appears once the command is typed and disappears as soon as an argument is entered, and is truncated to fit the box width. Add argument hints for /compact, /swarm, /goal and /title; /add-dir already had one.
* test: remove stale additional-dirs AGENTS.md assertion
The subagent-host test still asserted that an additional directory's AGENTS.md content appears in the agent system prompt, but additional-dirs AGENTS.md has been intentionally excluded from the default context since an earlier commit (covered by context.test.ts). Drop the stale assertion.
* fix: resolve /add-dir paths against workdir and persist via kaos
Resolve user-supplied /add-dir paths against the current workdir instead of the project root, so launching from a subdirectory behaves like the CLI --add-dir flag. Also route the local.toml read/write through the kaos abstraction instead of host fs, so the remember path works for non-local sessions.
* fix: expand ~ in /add-dir paths before resolving
The /add-dir completer emits ~/... values, but the core treated ~/foo as a relative path because pathe isAbsolute('~/foo') is false, producing <workDir>/~/foo. Expand ~ and ~/ to the home directory (via kaos.gethome()) before resolving.
* chore: remove add-dir dev docs from the branch
These were working notes (research and follow-up todos) that don't belong in the PR.
* chore: clarify add-dir changeset for users
* docs: document /add-dir, --add-dir, and local.toml
* test: flush records before reading wire in add-dir runtime tests
FileSystemAgentRecordPersistence.append buffers records and flushes asynchronously, so readMainWire can read the wire before the local-command-stdout record lands. Flush the main agent's records explicitly in the two add-dir runtime tests to make them deterministic.
646 lines
20 KiB
TypeScript
646 lines
20 KiB
TypeScript
import { execSync } from 'node:child_process';
|
|
|
|
import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { runShell } from '#/cli/run-shell';
|
|
|
|
import { captureProcessWrite, ExitCalled, mockProcessExit } from '../helpers/process';
|
|
|
|
type CreateKimiDeviceId = typeof createKimiDeviceIdFn;
|
|
|
|
const mocks = vi.hoisted(() => {
|
|
type TuiConfigFallback = {
|
|
theme: 'dark' | 'light' | 'auto';
|
|
editorCommand: string | null;
|
|
notifications: { enabled: boolean; condition: 'unfocused' | 'always' };
|
|
};
|
|
|
|
class TuiConfigParseError extends Error {
|
|
readonly fallback: TuiConfigFallback;
|
|
|
|
constructor(fallback: TuiConfigFallback) {
|
|
super('Invalid TUI config in ~/.kimi-code/tui.toml; using defaults.');
|
|
this.fallback = fallback;
|
|
}
|
|
}
|
|
|
|
const lifecycleTrack = vi.fn();
|
|
|
|
return {
|
|
loadTuiConfig: vi.fn(),
|
|
detectTerminalTheme: vi.fn(),
|
|
kimiHarnessConstructor: vi.fn(),
|
|
harnessEnsureConfigFile: vi.fn(),
|
|
harnessGetConfig: vi.fn(async () => ({
|
|
providers: {},
|
|
defaultModel: 'k2',
|
|
telemetry: true,
|
|
})),
|
|
harnessGetConfigDiagnostics: vi.fn(async () => ({ warnings: [] as readonly string[] })),
|
|
harnessGetCachedAccessToken: vi.fn(),
|
|
harnessClose: vi.fn(),
|
|
detectPendingMigration: vi.fn<() => Promise<unknown>>(async () => null),
|
|
harnessTrack: vi.fn(),
|
|
kimiTuiConstructor: vi.fn(),
|
|
tuiStart: vi.fn(),
|
|
tuiGetStartupMcpMs: vi.fn(async () => 0),
|
|
tuiGetCurrentSessionId: vi.fn(() => ''),
|
|
tuiHasSessionContent: vi.fn(() => false),
|
|
createKimiDeviceId: vi.fn<CreateKimiDeviceId>(() => 'device-1'),
|
|
initializeTelemetry: vi.fn(),
|
|
setCrashPhase: vi.fn(),
|
|
shutdownTelemetry: vi.fn(),
|
|
telemetryTrack: vi.fn(),
|
|
setTelemetryContext: vi.fn(),
|
|
lifecycleTrack,
|
|
withTelemetryContext: vi.fn(() => ({
|
|
track: lifecycleTrack,
|
|
})),
|
|
resolveKimiHome: vi.fn((homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home'),
|
|
harnessCreatesDeviceIdOnConstruction: false,
|
|
execSync: vi.fn(),
|
|
TuiConfigParseError,
|
|
};
|
|
});
|
|
|
|
vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@moonshot-ai/kimi-code-sdk')>();
|
|
return {
|
|
...actual,
|
|
resolveKimiHome: mocks.resolveKimiHome,
|
|
createKimiHarness: (...args: unknown[]) => {
|
|
const options = args[0] as { readonly homeDir?: string } | undefined;
|
|
const homeDir = options?.homeDir ?? '/tmp/kimi-code-test-home';
|
|
if (mocks.harnessCreatesDeviceIdOnConstruction) {
|
|
mocks.createKimiDeviceId(homeDir);
|
|
}
|
|
mocks.kimiHarnessConstructor(...args);
|
|
return {
|
|
homeDir,
|
|
auth: {
|
|
getCachedAccessToken: mocks.harnessGetCachedAccessToken,
|
|
},
|
|
ensureConfigFile: mocks.harnessEnsureConfigFile,
|
|
getConfig: mocks.harnessGetConfig,
|
|
getConfigDiagnostics: mocks.harnessGetConfigDiagnostics,
|
|
close: mocks.harnessClose,
|
|
track: mocks.harnessTrack,
|
|
};
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('@moonshot-ai/kimi-code-oauth', async () => {
|
|
const actual = await vi.importActual<typeof import('@moonshot-ai/kimi-code-oauth')>(
|
|
'@moonshot-ai/kimi-code-oauth',
|
|
);
|
|
return {
|
|
...actual,
|
|
createKimiDeviceId: mocks.createKimiDeviceId,
|
|
KIMI_CODE_PROVIDER_NAME: 'kimi-code',
|
|
};
|
|
});
|
|
|
|
vi.mock('@moonshot-ai/kimi-telemetry', () => ({
|
|
initializeTelemetry: mocks.initializeTelemetry,
|
|
setCrashPhase: mocks.setCrashPhase,
|
|
shutdownTelemetry: mocks.shutdownTelemetry,
|
|
track: mocks.telemetryTrack,
|
|
setTelemetryContext: mocks.setTelemetryContext,
|
|
withTelemetryContext: mocks.withTelemetryContext,
|
|
}));
|
|
|
|
vi.mock('../../src/tui/config', () => ({
|
|
loadTuiConfig: mocks.loadTuiConfig,
|
|
TuiConfigParseError: mocks.TuiConfigParseError,
|
|
}));
|
|
|
|
vi.mock('../../src/tui/index', () => ({
|
|
KimiTUI: class {
|
|
onExit?: () => Promise<void>;
|
|
|
|
constructor(...args: unknown[]) {
|
|
mocks.kimiTuiConstructor(this, ...args);
|
|
}
|
|
|
|
start = mocks.tuiStart;
|
|
getStartupMcpMs = mocks.tuiGetStartupMcpMs;
|
|
getCurrentSessionId = mocks.tuiGetCurrentSessionId;
|
|
hasSessionContent = mocks.tuiHasSessionContent;
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/tui/theme/detect', () => ({
|
|
detectTerminalTheme: mocks.detectTerminalTheme,
|
|
}));
|
|
|
|
vi.mock('../../src/migration/index', () => ({
|
|
detectPendingMigration: mocks.detectPendingMigration,
|
|
}));
|
|
|
|
vi.mock('node:child_process', () => ({
|
|
execSync: mocks.execSync,
|
|
}));
|
|
|
|
describe('runShell', () => {
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.harnessGetConfig.mockResolvedValue({
|
|
providers: {},
|
|
defaultModel: 'k2',
|
|
telemetry: true,
|
|
});
|
|
mocks.tuiGetStartupMcpMs.mockResolvedValue(0);
|
|
mocks.tuiGetCurrentSessionId.mockReturnValue('');
|
|
mocks.tuiHasSessionContent.mockReturnValue(false);
|
|
mocks.createKimiDeviceId.mockImplementation(() => 'device-1');
|
|
mocks.resolveKimiHome.mockImplementation(
|
|
(homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home',
|
|
);
|
|
mocks.harnessCreatesDeviceIdOnConstruction = false;
|
|
});
|
|
|
|
it('constructs KimiHarness and KimiTUI with startup input', async () => {
|
|
mocks.loadTuiConfig.mockResolvedValue({
|
|
theme: 'dark',
|
|
editorCommand: null,
|
|
notifications: { enabled: true, condition: 'unfocused' },
|
|
});
|
|
mocks.tuiStart.mockResolvedValue(undefined);
|
|
mocks.tuiGetStartupMcpMs.mockResolvedValue(47);
|
|
mocks.tuiGetCurrentSessionId.mockReturnValue('ses-startup');
|
|
|
|
const cliOptions = {
|
|
session: undefined,
|
|
continue: false,
|
|
yolo: true,
|
|
auto: false,
|
|
plan: true,
|
|
model: undefined,
|
|
outputFormat: undefined,
|
|
prompt: undefined,
|
|
skillsDirs: [],
|
|
addDirs: ['../shared', '/tmp/extra'],
|
|
};
|
|
|
|
await runShell(cliOptions, '1.2.3-test');
|
|
|
|
expect(mocks.kimiHarnessConstructor).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
identity: expect.objectContaining({
|
|
userAgentProduct: 'kimi-code-cli',
|
|
version: '1.2.3-test',
|
|
}),
|
|
sessionStartedProperties: { yolo: true, auto: false, plan: true, afk: false },
|
|
}),
|
|
);
|
|
expect(mocks.harnessEnsureConfigFile).toHaveBeenCalledOnce();
|
|
expect(mocks.harnessEnsureConfigFile.mock.invocationCallOrder[0]).toBeLessThan(
|
|
mocks.harnessGetConfig.mock.invocationCallOrder[0]!,
|
|
);
|
|
expect(execSync).toHaveBeenCalledWith('stty -ixon', { stdio: 'ignore' });
|
|
expect(mocks.kimiTuiConstructor).toHaveBeenCalledTimes(1);
|
|
expect(mocks.createKimiDeviceId).toHaveBeenCalledWith(
|
|
'/tmp/kimi-code-test-home',
|
|
expect.any(Object),
|
|
);
|
|
expect(mocks.initializeTelemetry).toHaveBeenCalledWith({
|
|
homeDir: '/tmp/kimi-code-test-home',
|
|
deviceId: 'device-1',
|
|
enabled: true,
|
|
appName: 'kimi-code-cli',
|
|
version: '1.2.3-test',
|
|
uiMode: 'shell',
|
|
model: 'k2',
|
|
getAccessToken: expect.any(Function),
|
|
});
|
|
expect(mocks.setCrashPhase).toHaveBeenCalledWith('runtime');
|
|
|
|
const [, harness, startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!;
|
|
expect(harness).toBeTypeOf('object');
|
|
expect(startupInput).toMatchObject({
|
|
cliOptions,
|
|
additionalDirs: ['../shared', '/tmp/extra'],
|
|
tuiConfig: {
|
|
theme: 'dark',
|
|
editorCommand: null,
|
|
notifications: { enabled: true, condition: 'unfocused' },
|
|
},
|
|
version: '1.2.3-test',
|
|
workDir: process.cwd(),
|
|
});
|
|
expect(mocks.tuiStart).toHaveBeenCalledOnce();
|
|
expect(mocks.withTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses-startup' });
|
|
expect(mocks.lifecycleTrack).toHaveBeenCalledWith('startup_perf', {
|
|
duration_ms: expect.any(Number),
|
|
config_ms: expect.any(Number),
|
|
init_ms: expect.any(Number),
|
|
mcp_ms: 47,
|
|
});
|
|
});
|
|
|
|
it('tracks first launch when device id creation reports first launch', async () => {
|
|
mocks.loadTuiConfig.mockResolvedValue({
|
|
theme: 'dark',
|
|
editorCommand: null,
|
|
notifications: { enabled: true, condition: 'unfocused' },
|
|
});
|
|
mocks.tuiStart.mockResolvedValue(undefined);
|
|
mocks.createKimiDeviceId.mockImplementationOnce((homeDir, options) => {
|
|
const deviceId = `device-for-${homeDir}`;
|
|
options?.onFirstLaunch?.(deviceId);
|
|
return deviceId;
|
|
});
|
|
|
|
await runShell(
|
|
{
|
|
session: undefined,
|
|
continue: false,
|
|
yolo: false,
|
|
auto: false,
|
|
plan: false,
|
|
model: undefined,
|
|
outputFormat: undefined,
|
|
prompt: undefined,
|
|
skillsDirs: [],
|
|
},
|
|
'1.2.3-test',
|
|
);
|
|
|
|
expect(mocks.createKimiDeviceId).toHaveBeenCalledWith(
|
|
'/tmp/kimi-code-test-home',
|
|
expect.objectContaining({ onFirstLaunch: expect.any(Function) }),
|
|
);
|
|
expect(mocks.harnessTrack).toHaveBeenCalledWith('first_launch');
|
|
});
|
|
|
|
it('registers first launch before harness construction can create the device id', async () => {
|
|
mocks.loadTuiConfig.mockResolvedValue({
|
|
theme: 'dark',
|
|
editorCommand: null,
|
|
notifications: { enabled: true, condition: 'unfocused' },
|
|
});
|
|
mocks.tuiStart.mockResolvedValue(undefined);
|
|
mocks.harnessCreatesDeviceIdOnConstruction = true;
|
|
const createdHomes = new Set<string>();
|
|
mocks.createKimiDeviceId.mockImplementation((homeDir, options) => {
|
|
const deviceId = `device-for-${homeDir}`;
|
|
if (!createdHomes.has(homeDir)) {
|
|
createdHomes.add(homeDir);
|
|
options?.onFirstLaunch?.(deviceId);
|
|
}
|
|
return deviceId;
|
|
});
|
|
|
|
await runShell(
|
|
{
|
|
session: undefined,
|
|
continue: false,
|
|
yolo: false,
|
|
auto: false,
|
|
plan: false,
|
|
model: undefined,
|
|
outputFormat: undefined,
|
|
prompt: undefined,
|
|
skillsDirs: [],
|
|
},
|
|
'1.2.3-test',
|
|
);
|
|
|
|
expect(mocks.createKimiDeviceId).toHaveBeenNthCalledWith(
|
|
1,
|
|
'/tmp/kimi-code-test-home',
|
|
expect.objectContaining({ onFirstLaunch: expect.any(Function) }),
|
|
);
|
|
expect(mocks.createKimiDeviceId.mock.invocationCallOrder[0]).toBeLessThan(
|
|
mocks.kimiHarnessConstructor.mock.invocationCallOrder[0]!,
|
|
);
|
|
expect(mocks.kimiHarnessConstructor).toHaveBeenCalledWith(
|
|
expect.objectContaining({ homeDir: '/tmp/kimi-code-test-home' }),
|
|
);
|
|
expect(mocks.harnessTrack).toHaveBeenCalledWith('first_launch');
|
|
});
|
|
|
|
it('binds startup_perf to the session captured before MCP metrics resolve', async () => {
|
|
mocks.loadTuiConfig.mockResolvedValue({
|
|
theme: 'dark',
|
|
editorCommand: null,
|
|
notifications: { enabled: true, condition: 'unfocused' },
|
|
});
|
|
mocks.tuiStart.mockResolvedValue(undefined);
|
|
let currentSessionId = 'ses-startup';
|
|
mocks.tuiGetCurrentSessionId.mockImplementation(() => currentSessionId);
|
|
mocks.tuiGetStartupMcpMs.mockImplementation(async () => {
|
|
currentSessionId = 'ses-later';
|
|
return 47;
|
|
});
|
|
|
|
await runShell(
|
|
{
|
|
session: undefined,
|
|
continue: false,
|
|
yolo: false,
|
|
auto: false,
|
|
plan: false,
|
|
model: undefined,
|
|
outputFormat: undefined,
|
|
prompt: undefined,
|
|
skillsDirs: [],
|
|
},
|
|
'1.2.3-test',
|
|
);
|
|
|
|
expect(mocks.withTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses-startup' });
|
|
expect(mocks.withTelemetryContext).not.toHaveBeenCalledWith({ sessionId: 'ses-later' });
|
|
expect(mocks.lifecycleTrack).toHaveBeenCalledWith('startup_perf', {
|
|
duration_ms: expect.any(Number),
|
|
config_ms: expect.any(Number),
|
|
init_ms: expect.any(Number),
|
|
mcp_ms: 47,
|
|
});
|
|
});
|
|
|
|
it('bridges OAuth refresh outcomes to telemetry', async () => {
|
|
mocks.loadTuiConfig.mockResolvedValue({
|
|
theme: 'dark',
|
|
editorCommand: null,
|
|
notifications: { enabled: true, condition: 'unfocused' },
|
|
});
|
|
mocks.tuiStart.mockResolvedValue(undefined);
|
|
|
|
await runShell(
|
|
{
|
|
session: undefined,
|
|
continue: false,
|
|
yolo: false,
|
|
auto: false,
|
|
plan: false,
|
|
model: undefined,
|
|
outputFormat: undefined,
|
|
prompt: undefined,
|
|
skillsDirs: [],
|
|
},
|
|
'1.2.3-test',
|
|
);
|
|
|
|
const [harnessOptions] = mocks.kimiHarnessConstructor.mock.calls[0] as [
|
|
{
|
|
readonly onOAuthRefresh: (
|
|
outcome:
|
|
| { readonly success: true }
|
|
| { readonly success: false; readonly reason: 'unauthorized' | 'network_or_other' },
|
|
) => void;
|
|
},
|
|
];
|
|
|
|
harnessOptions.onOAuthRefresh({ success: true });
|
|
harnessOptions.onOAuthRefresh({ success: false, reason: 'unauthorized' });
|
|
harnessOptions.onOAuthRefresh({ success: false, reason: 'network_or_other' });
|
|
|
|
expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { success: true });
|
|
expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', {
|
|
success: false,
|
|
reason: 'unauthorized',
|
|
});
|
|
expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', {
|
|
success: false,
|
|
reason: 'network_or_other',
|
|
});
|
|
});
|
|
|
|
it('detects auto theme and forwards config parse warnings as startup notice', async () => {
|
|
mocks.loadTuiConfig.mockRejectedValue(
|
|
new mocks.TuiConfigParseError({
|
|
theme: 'auto',
|
|
editorCommand: 'vim',
|
|
notifications: { enabled: true, condition: 'always' },
|
|
}),
|
|
);
|
|
mocks.detectTerminalTheme.mockResolvedValue('light');
|
|
mocks.tuiStart.mockResolvedValue(undefined);
|
|
|
|
await runShell(
|
|
{
|
|
session: '',
|
|
continue: false,
|
|
yolo: false,
|
|
auto: false,
|
|
plan: false,
|
|
model: undefined,
|
|
outputFormat: undefined,
|
|
prompt: undefined,
|
|
skillsDirs: [],
|
|
},
|
|
'1.2.3-test',
|
|
);
|
|
|
|
expect(mocks.detectTerminalTheme).toHaveBeenCalledOnce();
|
|
const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!;
|
|
expect(startupInput).toMatchObject({
|
|
startupNotice: 'Invalid TUI config in ~/.kimi-code/tui.toml; using defaults.',
|
|
tuiConfig: {
|
|
theme: 'auto',
|
|
editorCommand: 'vim',
|
|
notifications: { enabled: true, condition: 'always' },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('forwards config.toml diagnostics as startup notices', async () => {
|
|
mocks.loadTuiConfig.mockResolvedValue({
|
|
theme: 'dark',
|
|
editorCommand: null,
|
|
notifications: { enabled: true, condition: 'unfocused' },
|
|
});
|
|
mocks.harnessGetConfigDiagnostics.mockResolvedValue({
|
|
warnings: ['Ignored invalid config in config.toml: loop_control.'],
|
|
});
|
|
mocks.tuiStart.mockResolvedValue(undefined);
|
|
|
|
await runShell(
|
|
{
|
|
session: '',
|
|
continue: false,
|
|
yolo: false,
|
|
auto: false,
|
|
plan: false,
|
|
model: undefined,
|
|
outputFormat: undefined,
|
|
prompt: undefined,
|
|
skillsDirs: [],
|
|
},
|
|
'1.2.3-test',
|
|
);
|
|
|
|
const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!;
|
|
expect(startupInput).toMatchObject({
|
|
startupNotice: 'Ignored invalid config in config.toml: loop_control.',
|
|
});
|
|
});
|
|
|
|
it('closes the harness when TUI startup fails', async () => {
|
|
mocks.loadTuiConfig.mockResolvedValue({
|
|
theme: 'dark',
|
|
editorCommand: null,
|
|
notifications: { enabled: true, condition: 'unfocused' },
|
|
});
|
|
mocks.tuiStart.mockRejectedValue(new Error('boom'));
|
|
|
|
await expect(
|
|
runShell(
|
|
{
|
|
session: undefined,
|
|
continue: false,
|
|
yolo: false,
|
|
auto: false,
|
|
plan: false,
|
|
model: undefined,
|
|
outputFormat: undefined,
|
|
prompt: undefined,
|
|
skillsDirs: [],
|
|
},
|
|
'1.2.3-test',
|
|
),
|
|
).rejects.toThrow('boom');
|
|
|
|
expect(mocks.setCrashPhase).toHaveBeenCalledWith('shutdown');
|
|
expect(mocks.harnessTrack).toHaveBeenCalledWith('exit', { duration_s: expect.any(Number) });
|
|
expect(mocks.shutdownTelemetry).toHaveBeenCalledOnce();
|
|
expect(mocks.harnessClose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('tracks exit and prints resume instructions from the TUI exit handler', async () => {
|
|
mocks.loadTuiConfig.mockResolvedValue({
|
|
theme: 'dark',
|
|
editorCommand: null,
|
|
notifications: { enabled: true, condition: 'unfocused' },
|
|
});
|
|
mocks.tuiStart.mockResolvedValue(undefined);
|
|
mocks.tuiGetCurrentSessionId.mockReturnValue('ses-1');
|
|
mocks.tuiHasSessionContent.mockReturnValue(true);
|
|
|
|
const stdout = captureProcessWrite('stdout');
|
|
const stderr = captureProcessWrite('stderr');
|
|
const exitSpy = mockProcessExit();
|
|
|
|
try {
|
|
await runShell(
|
|
{
|
|
session: undefined,
|
|
continue: false,
|
|
yolo: false,
|
|
auto: false,
|
|
plan: false,
|
|
model: undefined,
|
|
outputFormat: undefined,
|
|
prompt: undefined,
|
|
skillsDirs: [],
|
|
},
|
|
'1.2.3-test',
|
|
);
|
|
const [tui] = mocks.kimiTuiConstructor.mock.calls[0]!;
|
|
mocks.harnessTrack.mockClear();
|
|
mocks.lifecycleTrack.mockClear();
|
|
mocks.withTelemetryContext.mockClear();
|
|
|
|
await expect((tui as { onExit: () => Promise<void> }).onExit()).rejects.toBeInstanceOf(
|
|
ExitCalled,
|
|
);
|
|
|
|
expect(mocks.setCrashPhase).toHaveBeenCalledWith('shutdown');
|
|
expect(mocks.withTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses-1' });
|
|
expect(mocks.lifecycleTrack).toHaveBeenCalledWith('exit', {
|
|
duration_s: expect.any(Number),
|
|
});
|
|
expect(mocks.harnessTrack).not.toHaveBeenCalledWith('exit', expect.anything());
|
|
expect(mocks.shutdownTelemetry).toHaveBeenCalledOnce();
|
|
expect(stdout.text()).toBe(' Bye!\n');
|
|
expect(stderr.text()).toContain(' To resume this session: kimi -r ses-1');
|
|
} finally {
|
|
exitSpy.mockRestore();
|
|
stdout.restore();
|
|
stderr.restore();
|
|
}
|
|
});
|
|
|
|
it('prints the opened web URL from the TUI exit handler when set', async () => {
|
|
mocks.loadTuiConfig.mockResolvedValue({
|
|
theme: 'dark',
|
|
editorCommand: null,
|
|
notifications: { enabled: true, condition: 'unfocused' },
|
|
});
|
|
mocks.tuiStart.mockResolvedValue(undefined);
|
|
mocks.tuiGetCurrentSessionId.mockReturnValue('ses-1');
|
|
mocks.tuiHasSessionContent.mockReturnValue(true);
|
|
|
|
const stdout = captureProcessWrite('stdout');
|
|
const stderr = captureProcessWrite('stderr');
|
|
const exitSpy = mockProcessExit();
|
|
|
|
try {
|
|
await runShell(
|
|
{
|
|
session: undefined,
|
|
continue: false,
|
|
yolo: false,
|
|
auto: false,
|
|
plan: false,
|
|
model: undefined,
|
|
outputFormat: undefined,
|
|
prompt: undefined,
|
|
skillsDirs: [],
|
|
},
|
|
'1.2.3-test',
|
|
);
|
|
const [tui] = mocks.kimiTuiConstructor.mock.calls[0]!;
|
|
const openedUrl = 'http://127.0.0.1:58627/sessions/ses-1';
|
|
(tui as { exitOpenUrl?: string }).exitOpenUrl = openedUrl;
|
|
|
|
await expect((tui as { onExit: () => Promise<void> }).onExit()).rejects.toBeInstanceOf(
|
|
ExitCalled,
|
|
);
|
|
|
|
expect(stderr.text()).toContain(' To resume this session: kimi -r ses-1');
|
|
expect(stderr.text()).toContain('open ');
|
|
expect(stderr.text()).toContain(openedUrl);
|
|
} finally {
|
|
exitSpy.mockRestore();
|
|
stdout.restore();
|
|
stderr.restore();
|
|
}
|
|
});
|
|
|
|
it('surfaces an invalid target config as an error for kimi migrate, not silently', async () => {
|
|
mocks.loadTuiConfig.mockResolvedValue({
|
|
theme: 'dark',
|
|
editorCommand: null,
|
|
notifications: { enabled: true, condition: 'unfocused' },
|
|
});
|
|
mocks.detectPendingMigration.mockResolvedValue({ totalSessions: 1 });
|
|
mocks.harnessGetConfig.mockRejectedValue(
|
|
new Error('Invalid configuration in ~/.kimi-code/config.toml'),
|
|
);
|
|
|
|
// A broken config.toml must fail loudly — `kimi migrate` must not swallow
|
|
// it and proceed, or the user never learns their config is broken.
|
|
await expect(
|
|
runShell(
|
|
{
|
|
session: undefined,
|
|
continue: false,
|
|
yolo: false,
|
|
auto: false,
|
|
plan: false,
|
|
model: undefined,
|
|
outputFormat: undefined,
|
|
prompt: undefined,
|
|
skillsDirs: [],
|
|
},
|
|
'1.2.3-test',
|
|
{ migrateOnly: true },
|
|
),
|
|
).rejects.toThrow('Invalid configuration');
|
|
expect(mocks.tuiStart).not.toHaveBeenCalled();
|
|
});
|
|
});
|