fix(cli): fix first launch telemetry timing and extract telemetry init (#47)

* fix(cli): fix first launch telemetry timing and extract telemetry init (7 files)

- extract telemetry initialization into cli/telemetry.ts\n- ensure deviceId is created before harness construction for correct first_launch tracking\n- emit session resume hint as meta message in stream-json output format
This commit is contained in:
Haozhe 2026-05-26 13:57:22 +08:00 committed by GitHub
parent a200a297ac
commit 07ed2cf9d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 305 additions and 53 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Emit session resume hint as a structured meta message in stream-json output format.

View file

@ -1,6 +1,4 @@
import { createKimiDeviceId, KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth';
import {
initializeTelemetry,
setCrashPhase,
setTelemetryContext,
shutdownTelemetry,
@ -17,9 +15,10 @@ import {
type TelemetryClient,
} from '@moonshot-ai/kimi-code-sdk';
import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_USER_AGENT_PRODUCT } from '#/constant/app';
import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app';
import type { CLIOptions, PromptOutputFormat } from './options';
import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
import { createKimiCodeHostIdentity } from './version';
interface PromptOutput {
@ -54,12 +53,14 @@ export async function runPrompt(
const stderr = io.stderr ?? process.stderr;
const promptProcess = io.process ?? process;
const workDir = process.cwd();
const telemetryBootstrap = createCliTelemetryBootstrap();
const telemetryClient: TelemetryClient = {
track,
withContext: withTelemetryContext,
setContext: setTelemetryContext,
};
const harness = new KimiHarness({
homeDir: telemetryBootstrap.homeDir,
identity: createKimiCodeHostIdentity(version),
uiMode: PROMPT_UI_MODE,
skillDirs: opts.skillsDirs,
@ -112,17 +113,13 @@ export async function runPrompt(
);
restorePromptSessionPermission = restorePermission;
const deviceId = createKimiDeviceId(harness.homeDir);
initializeTelemetry({
homeDir: harness.homeDir,
deviceId,
enabled: config.telemetry !== false,
appName: CLI_USER_AGENT_PRODUCT,
initializeCliTelemetry({
harness,
bootstrap: telemetryBootstrap,
config,
version,
uiMode: PROMPT_UI_MODE,
model: telemetryModel,
getAccessToken: async () =>
(await harness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null,
});
setCrashPhase('runtime');
@ -133,8 +130,9 @@ export async function runPrompt(
afk: true,
});
await runPromptTurn(session, opts.prompt!, opts.outputFormat ?? 'text', stdout, stderr);
stderr.write(`To resume this session: kimi -r ${session.id}\n`);
const outputFormat = opts.outputFormat ?? 'text';
await runPromptTurn(session, opts.prompt!, outputFormat, stdout, stderr);
writeResumeHint(session.id, outputFormat, stdout, stderr);
withTelemetryContext({ sessionId: session.id }).track('exit', {
duration_s: (Date.now() - startedAt) / 1000,
@ -476,6 +474,36 @@ interface PromptJsonToolMessage {
content: string;
}
interface PromptJsonResumeMetaMessage {
role: 'meta';
type: 'session.resume_hint';
session_id: string;
command: string;
content: string;
}
function writeResumeHint(
sessionId: string,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
): void {
const command = `kimi -r ${sessionId}`;
const content = `To resume this session: ${command}`;
if (outputFormat === 'stream-json') {
const message: PromptJsonResumeMetaMessage = {
role: 'meta',
type: 'session.resume_hint',
session_id: sessionId,
command,
content,
};
stdout.write(`${JSON.stringify(message)}\n`);
return;
}
stderr.write(`${content}\n`);
}
class PromptJsonWriter implements PromptTurnWriter {
private assistantText = '';
private readonly toolCalls: PromptJsonToolCall[] = [];

View file

@ -2,9 +2,7 @@ import { execSync } from 'node:child_process';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { createKimiDeviceId, KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth';
import {
initializeTelemetry,
setCrashPhase,
setTelemetryContext,
shutdownTelemetry,
@ -13,7 +11,7 @@ import {
} from '@moonshot-ai/kimi-telemetry';
import { KimiHarness, log, type TelemetryClient } from '@moonshot-ai/kimi-code-sdk';
import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, CLI_USER_AGENT_PRODUCT } from '#/constant/app';
import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app';
import { detectPendingMigration } from '#/migration/index';
import type { TuiConfig } from '#/tui/config';
import { loadTuiConfig, TuiConfigParseError } from '#/tui/config';
@ -22,6 +20,7 @@ import { KimiTUI } from '#/tui/index';
import { detectTerminalTheme } from '#/tui/theme/detect';
import type { CLIOptions } from './options';
import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
import { createKimiCodeHostIdentity } from './version';
export async function runShell(
@ -46,12 +45,14 @@ export async function runShell(
const resolvedTheme = tuiConfig.theme === 'auto' ? await detectTerminalTheme() : tuiConfig.theme;
const workDir = process.cwd();
const telemetryBootstrap = createCliTelemetryBootstrap();
const telemetryClient: TelemetryClient = {
track,
withContext: withTelemetryContext,
setContext: setTelemetryContext,
};
const harness = new KimiHarness({
homeDir: telemetryBootstrap.homeDir,
identity: createKimiCodeHostIdentity(version),
telemetry: telemetryClient,
onOAuthRefresh: (outcome) => {
@ -96,28 +97,15 @@ export async function runShell(
migrateOnly: runOptions.migrateOnly,
});
let firstLaunch = false;
const deviceId = createKimiDeviceId(harness.homeDir, {
onFirstLaunch: () => {
firstLaunch = true;
},
});
initializeTelemetry({
homeDir: harness.homeDir,
deviceId,
enabled: config.telemetry !== false,
appName: CLI_USER_AGENT_PRODUCT,
initializeCliTelemetry({
harness,
bootstrap: telemetryBootstrap,
config,
version,
uiMode: CLI_UI_MODE,
model: config.defaultModel,
getAccessToken: async () =>
(await harness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null,
});
setCrashPhase('runtime');
if (firstLaunch) {
harness.track('first_launch');
}
const resumed = opts.continue || opts.session !== undefined;
const trackLifecycleForSession = (
sessionId: string,

View file

@ -7,9 +7,7 @@
import { createInterface } from 'node:readline/promises';
import { createKimiDeviceId, KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth';
import {
initializeTelemetry,
setTelemetryContext,
shutdownTelemetry,
track,
@ -24,7 +22,8 @@ import {
} from '@moonshot-ai/kimi-code-sdk';
import type { Command } from 'commander';
import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, CLI_USER_AGENT_PRODUCT } from '#/constant/app';
import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app';
import { createCliTelemetryBootstrap, initializeCliTelemetry } from '#/cli/telemetry';
import { createKimiCodeHostIdentity } from '#/cli/version';
interface WritableLike {
@ -121,6 +120,7 @@ export function registerExportCommand(parent: Command, deps?: Partial<ExportDeps
function createDefaultExportDeps(overrides: Partial<ExportDeps> = {}): ExportDeps {
let harness: KimiHarness | undefined;
let telemetryBootstrap: ReturnType<typeof createCliTelemetryBootstrap> | undefined;
let telemetryInitialized = false;
let telemetryShutdown = false;
const identity = createKimiCodeHostIdentity();
@ -129,8 +129,14 @@ function createDefaultExportDeps(overrides: Partial<ExportDeps> = {}): ExportDep
withContext: withTelemetryContext,
setContext: setTelemetryContext,
};
const getTelemetryBootstrap = (): ReturnType<typeof createCliTelemetryBootstrap> => {
telemetryBootstrap ??= createCliTelemetryBootstrap();
return telemetryBootstrap;
};
const getHarness = (): KimiHarness => {
const currentTelemetryBootstrap = getTelemetryBootstrap();
harness ??= new KimiHarness({
homeDir: currentTelemetryBootstrap.homeDir,
identity,
telemetry: telemetryClient,
});
@ -138,20 +144,16 @@ function createDefaultExportDeps(overrides: Partial<ExportDeps> = {}): ExportDep
};
const initializeDefaultTelemetry = async (): Promise<void> => {
if (telemetryInitialized) return;
const currentTelemetryBootstrap = getTelemetryBootstrap();
const currentHarness = getHarness();
await currentHarness.ensureConfigFile();
const config = await currentHarness.getConfig();
const deviceId = createKimiDeviceId(currentHarness.homeDir);
initializeTelemetry({
homeDir: currentHarness.homeDir,
deviceId,
enabled: config.telemetry !== false,
appName: CLI_USER_AGENT_PRODUCT,
initializeCliTelemetry({
harness: currentHarness,
bootstrap: currentTelemetryBootstrap,
config,
version: identity.version,
uiMode: CLI_UI_MODE,
model: config.defaultModel,
getAccessToken: async () =>
(await currentHarness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null,
});
telemetryInitialized = true;
};

View file

@ -0,0 +1,48 @@
import { createKimiDeviceId, KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth';
import { initializeTelemetry } from '@moonshot-ai/kimi-telemetry';
import { resolveKimiHome, type KimiConfig, type KimiHarness } from '@moonshot-ai/kimi-code-sdk';
import { CLI_USER_AGENT_PRODUCT } from '#/constant/app';
export interface CliTelemetryBootstrap {
readonly homeDir: string;
readonly deviceId: string;
readonly firstLaunch: boolean;
}
export interface InitializeCliTelemetryOptions {
readonly harness: KimiHarness;
readonly bootstrap: CliTelemetryBootstrap;
readonly config: Pick<KimiConfig, 'defaultModel' | 'telemetry'>;
readonly version: string;
readonly uiMode: string;
readonly model?: string;
}
export function createCliTelemetryBootstrap(): CliTelemetryBootstrap {
let firstLaunch = false;
const homeDir = resolveKimiHome();
const deviceId = createKimiDeviceId(homeDir, {
onFirstLaunch: () => {
firstLaunch = true;
},
});
return { homeDir, deviceId, firstLaunch };
}
export function initializeCliTelemetry(options: InitializeCliTelemetryOptions): void {
initializeTelemetry({
homeDir: options.harness.homeDir,
deviceId: options.bootstrap.deviceId,
enabled: options.config.telemetry !== false,
appName: CLI_USER_AGENT_PRODUCT,
version: options.version,
uiMode: options.uiMode,
model: options.model ?? options.config.defaultModel,
getAccessToken: async () =>
(await options.harness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null,
});
if (options.bootstrap.firstLaunch) {
options.harness.track('first_launch');
}
}

View file

@ -36,26 +36,36 @@ const mocks = vi.hoisted(() => ({
})),
harnessGetCachedAccessToken: vi.fn(),
harnessExportSession: vi.fn(),
harnessTrack: vi.fn(),
createKimiDeviceId: vi.fn<CreateKimiDeviceId>(() => 'device-1'),
initializeTelemetry: vi.fn(),
shutdownTelemetry: vi.fn(),
telemetryTrack: vi.fn(),
setTelemetryContext: vi.fn(),
withTelemetryContext: vi.fn(),
resolveKimiHome: vi.fn((homeDir?: string) => homeDir ?? '/tmp/kimi-export-home'),
harnessCreatesDeviceIdOnConstruction: false,
}));
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,
KimiHarness: class {
homeDir = '/tmp/kimi-export-home';
homeDir: string;
auth = {
getCachedAccessToken: mocks.harnessGetCachedAccessToken,
};
ensureConfigFile = mocks.harnessEnsureConfigFile;
getConfig = mocks.harnessGetConfig;
track = mocks.harnessTrack;
constructor(...args: unknown[]) {
const options = args[0] as { readonly homeDir?: string } | undefined;
this.homeDir = options?.homeDir ?? '/tmp/kimi-export-home';
if (mocks.harnessCreatesDeviceIdOnConstruction) {
mocks.createKimiDeviceId(this.homeDir);
}
mocks.kimiHarnessConstructor(...args);
}
@ -95,6 +105,11 @@ afterEach(() => {
defaultModel: 'k2',
telemetry: true,
});
mocks.createKimiDeviceId.mockImplementation(() => 'device-1');
mocks.resolveKimiHome.mockImplementation(
(homeDir?: string) => homeDir ?? '/tmp/kimi-export-home',
);
mocks.harnessCreatesDeviceIdOnConstruction = false;
});
function makeSummary(id: string, overrides: Partial<SessionSummary> = {}): SessionSummary {
@ -383,7 +398,10 @@ describe('kimi export', () => {
);
expect(mocks.harnessEnsureConfigFile).toHaveBeenCalledOnce();
expect(mocks.harnessGetConfig).toHaveBeenCalledOnce();
expect(mocks.createKimiDeviceId).toHaveBeenCalledWith('/tmp/kimi-export-home');
expect(mocks.createKimiDeviceId).toHaveBeenCalledWith(
'/tmp/kimi-export-home',
expect.objectContaining({ onFirstLaunch: expect.any(Function) }),
);
expect(mocks.initializeTelemetry).toHaveBeenCalledWith({
homeDir: '/tmp/kimi-export-home',
deviceId: 'device-1',
@ -443,4 +461,53 @@ describe('kimi export', () => {
);
expect(mocks.shutdownTelemetry).toHaveBeenCalledWith({ timeoutMs: 3000 });
});
it('tracks first launch around default export telemetry before harness construction can create the device id', async () => {
const program = new Command('kimi');
const output = join(tmp, 'telemetry-first-launch.zip');
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;
});
mocks.harnessExportSession.mockResolvedValue(makeResult('ses_first_launch', output));
registerExportCommand(program, {
cwd: () => tmp,
stdout: {
write: () => true,
},
stderr: {
write: () => true,
},
exit: ((code: number) => {
throw new ExitCalled(code);
}) as ExportDeps['exit'],
});
await program.parseAsync(['node', 'kimi', 'export', 'ses_first_launch', '--output', output], {
from: 'node',
});
expect(mocks.createKimiDeviceId).toHaveBeenNthCalledWith(
1,
'/tmp/kimi-export-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-export-home' }),
);
expect(mocks.harnessTrack).toHaveBeenCalledWith('first_launch');
expect(mocks.initializeTelemetry.mock.invocationCallOrder[0]).toBeLessThan(
mocks.harnessTrack.mock.invocationCallOrder[0]!,
);
});
});

View file

@ -1,7 +1,10 @@
import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { runPrompt } from '#/cli/run-prompt';
type CreateKimiDeviceId = typeof createKimiDeviceIdFn;
const mocks = vi.hoisted(() => {
const eventHandlers = new Set<(event: any) => void>();
const agentEvent = (agentId: string, event: Record<string, unknown>) => ({
@ -64,6 +67,9 @@ const mocks = vi.hoisted(() => {
setTelemetryContext: vi.fn(),
lifecycleTrack: vi.fn(),
withTelemetryContext: vi.fn(() => ({ track: vi.fn() })),
createKimiDeviceId: vi.fn<CreateKimiDeviceId>(() => 'device-1'),
resolveKimiHome: vi.fn((homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home'),
harnessCreatesDeviceIdOnConstruction: false,
};
});
@ -71,8 +77,9 @@ 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,
KimiHarness: class {
homeDir = '/tmp/kimi-code-test-home';
homeDir: string;
auth = { getCachedAccessToken: mocks.harnessGetCachedAccessToken };
ensureConfigFile = mocks.harnessEnsureConfigFile;
getConfig = mocks.harnessGetConfig;
@ -83,6 +90,11 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => {
track = mocks.harnessTrack;
constructor(...args: unknown[]) {
const options = args[0] as { readonly homeDir?: string } | undefined;
this.homeDir = options?.homeDir ?? '/tmp/kimi-code-test-home';
if (mocks.harnessCreatesDeviceIdOnConstruction) {
mocks.createKimiDeviceId(this.homeDir);
}
mocks.kimiHarnessConstructor(...args);
}
},
@ -95,7 +107,7 @@ vi.mock('@moonshot-ai/kimi-code-oauth', async () => {
);
return {
...actual,
createKimiDeviceId: vi.fn(() => 'device-1'),
createKimiDeviceId: mocks.createKimiDeviceId,
KIMI_CODE_PROVIDER_NAME: 'kimi-code',
};
});
@ -169,6 +181,11 @@ describe('runPrompt', () => {
afterEach(() => {
vi.clearAllMocks();
mocks.eventHandlers.clear();
mocks.createKimiDeviceId.mockImplementation(() => 'device-1');
mocks.resolveKimiHome.mockImplementation(
(homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home',
);
mocks.harnessCreatesDeviceIdOnConstruction = false;
});
it('creates a fresh auto-permission session and streams assistant output to stdout', async () => {
@ -211,6 +228,37 @@ describe('runPrompt', () => {
);
});
it('tracks first launch in prompt mode before harness construction can create the device id', async () => {
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 runPrompt(opts(), '1.2.3-test', {
stdout: { write: vi.fn(() => true) },
stderr: { write: vi.fn(() => true) },
});
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('formats thinking and assistant output as transcript blocks', async () => {
mocks.session.prompt.mockImplementationOnce(async () => {
for (const handler of mocks.eventHandlers) {
@ -387,14 +435,20 @@ describe('runPrompt', () => {
);
});
it('writes stream-json output as assistant JSONL without transcript bullets', async () => {
it('writes stream-json output as assistant JSONL with resume meta without transcript bullets', async () => {
const stdout = writer();
const stderr = writer();
await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { stdout, stderr });
expect(stdout.text()).toBe('{"role":"assistant","content":"hello world"}\n');
expect(stderr.text()).toBe('To resume this session: kimi -r ses_prompt\n');
expect(stdout.text()).toBe(
[
'{"role":"assistant","content":"hello world"}',
'{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kimi -r ses_prompt","content":"To resume this session: kimi -r ses_prompt"}',
'',
].join('\n'),
);
expect(stderr.text()).toBe('');
});
it('writes stream-json tool calls and tool results as JSONL messages', async () => {
@ -435,6 +489,7 @@ describe('runPrompt', () => {
'{"role":"assistant","content":"checking","tool_calls":[{"type":"function","id":"tc_1","function":{"name":"Shell","arguments":"{\\"command\\":\\"ls\\"}"}}]}',
'{"role":"tool","tool_call_id":"tc_1","content":"file1.py\\nfile2.py"}',
'{"role":"assistant","content":"done"}',
'{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"kimi -r ses_prompt","content":"To resume this session: kimi -r ses_prompt"}',
'',
].join('\n'),
);

View file

@ -56,6 +56,8 @@ const mocks = vi.hoisted(() => {
withTelemetryContext: vi.fn(() => ({
track: lifecycleTrack,
})),
resolveKimiHome: vi.fn((homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home'),
harnessCreatesDeviceIdOnConstruction: false,
execSync: vi.fn(),
TuiConfigParseError,
};
@ -65,8 +67,9 @@ 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,
KimiHarness: class {
homeDir = '/tmp/kimi-code-test-home';
homeDir: string;
auth = {
getCachedAccessToken: mocks.harnessGetCachedAccessToken,
};
@ -76,6 +79,11 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => {
track = mocks.harnessTrack;
constructor(...args: unknown[]) {
const options = args[0] as { readonly homeDir?: string } | undefined;
this.homeDir = options?.homeDir ?? '/tmp/kimi-code-test-home';
if (mocks.harnessCreatesDeviceIdOnConstruction) {
mocks.createKimiDeviceId(this.homeDir);
}
mocks.kimiHarnessConstructor(...args);
}
},
@ -145,6 +153,11 @@ describe('runShell', () => {
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 () => {
@ -264,6 +277,52 @@ describe('runShell', () => {
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,
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('marks resumed lifecycle starts from session flags', async () => {
mocks.loadTuiConfig.mockResolvedValue({
theme: 'dark',