mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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:
parent
a200a297ac
commit
07ed2cf9d4
8 changed files with 305 additions and 53 deletions
5
.changeset/stream-json-resume-hint.md
Normal file
5
.changeset/stream-json-resume-hint.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Emit session resume hint as a structured meta message in stream-json output format.
|
||||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
48
apps/kimi-code/src/cli/telemetry.ts
Normal file
48
apps/kimi-code/src/cli/telemetry.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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]!,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue