From 07ed2cf9d4f01985c00c004b3bc0cc8d2587044b Mon Sep 17 00:00:00 2001 From: Haozhe Date: Tue, 26 May 2026 13:57:22 +0800 Subject: [PATCH] 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 --- .changeset/stream-json-resume-hint.md | 5 ++ apps/kimi-code/src/cli/run-prompt.ts | 54 ++++++++++++---- apps/kimi-code/src/cli/run-shell.ts | 28 +++------ apps/kimi-code/src/cli/sub/export.ts | 26 ++++---- apps/kimi-code/src/cli/telemetry.ts | 48 +++++++++++++++ apps/kimi-code/test/cli/export.test.ts | 71 +++++++++++++++++++++- apps/kimi-code/test/cli/run-prompt.test.ts | 65 ++++++++++++++++++-- apps/kimi-code/test/cli/run-shell.test.ts | 61 ++++++++++++++++++- 8 files changed, 305 insertions(+), 53 deletions(-) create mode 100644 .changeset/stream-json-resume-hint.md create mode 100644 apps/kimi-code/src/cli/telemetry.ts diff --git a/.changeset/stream-json-resume-hint.md b/.changeset/stream-json-resume-hint.md new file mode 100644 index 000000000..d73768ded --- /dev/null +++ b/.changeset/stream-json-resume-hint.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Emit session resume hint as a structured meta message in stream-json output format. diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index ca2cce05a..c3b179598 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -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[] = []; diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 6e3e3204b..a69749925 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -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, diff --git a/apps/kimi-code/src/cli/sub/export.ts b/apps/kimi-code/src/cli/sub/export.ts index 93608cc3d..9ec9aafdc 100644 --- a/apps/kimi-code/src/cli/sub/export.ts +++ b/apps/kimi-code/src/cli/sub/export.ts @@ -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 { let harness: KimiHarness | undefined; + let telemetryBootstrap: ReturnType | undefined; let telemetryInitialized = false; let telemetryShutdown = false; const identity = createKimiCodeHostIdentity(); @@ -129,8 +129,14 @@ function createDefaultExportDeps(overrides: Partial = {}): ExportDep withContext: withTelemetryContext, setContext: setTelemetryContext, }; + const getTelemetryBootstrap = (): ReturnType => { + 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 = {}): ExportDep }; const initializeDefaultTelemetry = async (): Promise => { 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; }; diff --git a/apps/kimi-code/src/cli/telemetry.ts b/apps/kimi-code/src/cli/telemetry.ts new file mode 100644 index 000000000..b0f85fd01 --- /dev/null +++ b/apps/kimi-code/src/cli/telemetry.ts @@ -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; + 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'); + } +} diff --git a/apps/kimi-code/test/cli/export.test.ts b/apps/kimi-code/test/cli/export.test.ts index 4a124cc80..5413da10e 100644 --- a/apps/kimi-code/test/cli/export.test.ts +++ b/apps/kimi-code/test/cli/export.test.ts @@ -36,26 +36,36 @@ const mocks = vi.hoisted(() => ({ })), harnessGetCachedAccessToken: vi.fn(), harnessExportSession: vi.fn(), + harnessTrack: vi.fn(), createKimiDeviceId: vi.fn(() => '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(); 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 { @@ -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(); + 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]!, + ); + }); }); diff --git a/apps/kimi-code/test/cli/run-prompt.test.ts b/apps/kimi-code/test/cli/run-prompt.test.ts index 54897eb4e..8b4e62662 100644 --- a/apps/kimi-code/test/cli/run-prompt.test.ts +++ b/apps/kimi-code/test/cli/run-prompt.test.ts @@ -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) => ({ @@ -64,6 +67,9 @@ const mocks = vi.hoisted(() => { setTelemetryContext: vi.fn(), lifecycleTrack: vi.fn(), withTelemetryContext: vi.fn(() => ({ track: vi.fn() })), + createKimiDeviceId: vi.fn(() => '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(); 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(); + 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'), ); diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 64f9e02aa..01f9415a8 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -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(); 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(); + 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',