diff --git a/apps/kimi-code/src/cli/prompt-render.ts b/apps/kimi-code/src/cli/prompt-render.ts index e8d55dcbb..eee5b09b9 100644 --- a/apps/kimi-code/src/cli/prompt-render.ts +++ b/apps/kimi-code/src/cli/prompt-render.ts @@ -9,10 +9,20 @@ * event-filtering / completion flow intact. */ -import type { HookResultEvent } from '@moonshot-ai/kimi-code-sdk'; - import type { PromptOutputFormat } from './options'; +/** + * Structural hook-result shape the renderer reads. Both the v1 SDK + * `HookResultEvent` and the v2 native `hook.result` `DomainEvent` satisfy it, + * so the renderer stays engine-agnostic without depending on either event + * definition. + */ +interface HookResultEventLike { + readonly hookEvent: string; + readonly content: string; + readonly blocked?: boolean; +} + export interface PromptOutput { readonly columns?: number | undefined; write(chunk: string): boolean; @@ -23,7 +33,7 @@ const PROMPT_BLOCK_INDENT = ' '; export interface PromptTurnWriter { writeAssistantDelta(delta: string): void; - writeHookResult(event: HookResultEvent): void; + writeHookResult(event: HookResultEventLike): void; writeThinkingDelta(delta: string): void; writeToolCall(toolCallId: string, name: string, args: unknown): void; writeToolCallDelta( @@ -72,7 +82,7 @@ export class PromptTranscriptWriter implements PromptTurnWriter { this.assistantWriter.write(delta); } - writeHookResult(event: HookResultEvent): void { + writeHookResult(event: HookResultEventLike): void { this.thinkingWriter.finish(); this.assistantWriter.finish(); this.assistantWriter.write(formatHookResultPlain(event)); @@ -109,7 +119,7 @@ export class PromptJsonWriter implements PromptTurnWriter { this.assistantText += delta; } - writeHookResult(event: HookResultEvent): void { + writeHookResult(event: HookResultEventLike): void { this.flushAssistant(); this.writeJsonLine({ role: 'assistant', @@ -263,15 +273,15 @@ function visibleCharWidth(char: string): number { return char === '\t' ? 4 : 1; } -function formatHookResultPlain(event: HookResultEvent): string { +function formatHookResultPlain(event: HookResultEventLike): string { return `${formatHookResultTitle(event)}\n\n${formatHookResultBody(event)}`; } -function formatHookResultTitle(event: HookResultEvent): string { +function formatHookResultTitle(event: HookResultEventLike): string { return `${event.hookEvent} hook${event.blocked === true ? ' blocked' : ''}`; } -function formatHookResultBody(event: HookResultEvent): string { +function formatHookResultBody(event: HookResultEventLike): string { const content = event.content.trim(); return content.length === 0 ? '(empty)' : content; } diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index 0c4f549d6..603f57899 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -185,10 +185,7 @@ export async function runPrompt( restorePromptSessionPermission = restorePermission; initializeCliTelemetry({ - homeDir: harness.homeDir, - auth: harness.auth, - track: (event, properties) => - properties === undefined ? harness.track(event) : harness.track(event, properties), + harness, bootstrap: telemetryBootstrap, config, version, diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index f85d11e52..caeae907c 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -112,10 +112,7 @@ export async function runShell( }); initializeCliTelemetry({ - homeDir: harness.homeDir, - auth: harness.auth, - track: (event, properties) => - properties === undefined ? harness.track(event) : harness.track(event, properties), + harness, bootstrap: telemetryBootstrap, config, version, diff --git a/apps/kimi-code/src/cli/sub/export.ts b/apps/kimi-code/src/cli/sub/export.ts index fe0f5d964..a93c3e271 100644 --- a/apps/kimi-code/src/cli/sub/export.ts +++ b/apps/kimi-code/src/cli/sub/export.ts @@ -159,12 +159,7 @@ function createDefaultExportDeps(overrides: Partial = {}): ExportDep await currentHarness.ensureConfigFile(); const config = await currentHarness.getConfig(); initializeCliTelemetry({ - homeDir: currentHarness.homeDir, - auth: currentHarness.auth, - track: (event, properties) => - properties === undefined - ? currentHarness.track(event) - : currentHarness.track(event, properties), + harness: currentHarness, bootstrap: currentTelemetryBootstrap, config, version: identity.version, diff --git a/apps/kimi-code/src/cli/telemetry.ts b/apps/kimi-code/src/cli/telemetry.ts index 4c64a10e5..7a52cc23d 100644 --- a/apps/kimi-code/src/cli/telemetry.ts +++ b/apps/kimi-code/src/cli/telemetry.ts @@ -6,9 +6,9 @@ import { resolveKimiHome, type KimiConfig, type TelemetryClient, - type TelemetryProperties, } from '@moonshot-ai/kimi-code-sdk'; +import type { PromptHarness } from './prompt-session'; import { initializeTelemetry, setTelemetryContext, @@ -27,11 +27,7 @@ export interface CliTelemetryBootstrap { } export interface InitializeCliTelemetryOptions { - readonly homeDir: string; - readonly auth: { - getCachedAccessToken(name: string): Promise; - }; - readonly track: (event: string, properties?: TelemetryProperties) => void; + readonly harness: PromptHarness; readonly bootstrap: CliTelemetryBootstrap; readonly config: Pick; readonly version: string; @@ -53,7 +49,7 @@ export function createCliTelemetryBootstrap(): CliTelemetryBootstrap { export function initializeCliTelemetry(options: InitializeCliTelemetryOptions): void { initializeTelemetry({ - homeDir: options.homeDir, + homeDir: options.harness.homeDir, deviceId: options.bootstrap.deviceId, enabled: options.config.telemetry !== false, appName: CLI_USER_AGENT_PRODUCT, @@ -62,10 +58,10 @@ export function initializeCliTelemetry(options: InitializeCliTelemetryOptions): model: options.model ?? options.config.defaultModel, sessionId: options.sessionId, getAccessToken: async () => - (await options.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null, + (await options.harness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null, }); if (options.bootstrap.firstLaunch) { - options.track('first_launch'); + options.harness.track('first_launch'); } } diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index 96c4c0c4e..ef888ef24 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -16,6 +16,7 @@ */ import { + CloudAppender, IAgentGoalService, IAgentLifecycleService, IAgentPermissionModeService, @@ -25,35 +26,30 @@ import { IAuthSummaryService, IConfigService, IEventBus, + IFileSystemStorageService, + IOAuthToolkit, ISessionIndex, ISessionLifecycleService, + ITelemetryService, bootstrap, ensureMainAgent, hostRequestHeadersSeed, logSeed, + resolveKimiHome, resolveLoggingConfig, type DomainEvent, type IAgentScopeHandle, type ISessionScopeHandle, type Scope, } from '@moonshot-ai/agent-core-v2'; -import { createKimiDefaultHeaders } from '@moonshot-ai/kimi-code-oauth'; -import { - KimiAuthFacade, - log, - resolveConfigPath, - type HookResultEvent, - type KimiConfig, -} from '@moonshot-ai/kimi-code-sdk'; -import { - setCrashPhase, - shutdownTelemetry, - track as telemetryTrack, - withTelemetryContext, -} from '@moonshot-ai/kimi-telemetry'; +import { createKimiDefaultHeaders, createKimiDeviceId } from '@moonshot-ai/kimi-code-oauth'; import { resolve } from 'pathe'; -import { CLI_SHUTDOWN_TIMEOUT_MS, PROMPT_CLEANUP_TIMEOUT_MS } from '#/constant/app'; +import { + CLI_SHUTDOWN_TIMEOUT_MS, + CLI_USER_AGENT_PRODUCT, + PROMPT_CLEANUP_TIMEOUT_MS, +} from '#/constant/app'; import { formatGoalSummaryText, @@ -70,7 +66,6 @@ import { raceWithTimeout, requireConfiguredModel, } from '../run-prompt'; -import { createCliTelemetryBootstrap, initializeCliTelemetry } from '../telemetry'; import { createKimiCodeHostIdentity } from '../version'; import type { CLIOptions, PromptOutputFormat } from '../options'; @@ -106,65 +101,51 @@ export async function runV2Print( writeExperimentalVersion(version, outputFormat, stdout, stderr); - const telemetryBootstrap = createCliTelemetryBootstrap(); - const homeDir = telemetryBootstrap.homeDir; - const configPath = resolveConfigPath({ homeDir }); + const homeDir = resolveKimiHome(); + let firstLaunch = false; + const deviceId = createKimiDeviceId(homeDir, { + onFirstLaunch: () => { + firstLaunch = true; + }, + }); const logging = resolveLoggingConfig({ homeDir, env: process.env }); const identity = createKimiCodeHostIdentity(version); const hostHeaders = createKimiDefaultHeaders({ homeDir, ...identity }); - const { app } = bootstrap({ homeDir, configPath }, [ + const { app } = bootstrap({ homeDir }, [ ...logSeed(logging), ...hostRequestHeadersSeed(hostHeaders), ]); - - const auth = new KimiAuthFacade({ - homeDir, - configPath, - identity, - onRefresh: (outcome) => { - telemetryTrack('oauth_refresh', { - outcome: outcome.success ? 'success' : 'error', - ...(outcome.success ? {} : { reason: outcome.reason }), - }); - }, - }); - - log.info('kimi-code starting', { - version, - uiMode: PROMPT_UI_MODE, - nodeVersion: process.version, - platform: `${process.platform}/${process.arch}`, - workDir, - }); + const auth = app.accessor.get(IOAuthToolkit); const configService = app.accessor.get(IConfigService); await configService.ready; const defaultModel = configService.get('defaultModel') ?? undefined; - let telemetryConfig: KimiConfig['telemetry']; + let telemetryEnabled = true; try { - telemetryConfig = configService.get('telemetry'); + telemetryEnabled = configService.get('telemetry') !== false; } catch { - telemetryConfig = undefined; + telemetryEnabled = true; } for (const diagnostic of configService.diagnostics()) { if (diagnostic.severity === 'warning') { stderr.write(`Warning: ${diagnostic.message}\n`); } } - const config = { defaultModel, telemetry: telemetryConfig }; let restorePermission = async (): Promise => {}; let removeTerminationCleanup: (() => void) | undefined; let cleanupPromise: Promise | undefined; + let telemetryService: ITelemetryService | undefined; const cleanup = async (): Promise => { const pending = (cleanupPromise ??= (async () => { removeTerminationCleanup?.(); - setCrashPhase('shutdown'); try { await restorePermission(); } finally { - await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + if (telemetryService !== undefined) { + await raceWithTimeout(telemetryService.shutdown(), CLI_SHUTDOWN_TIMEOUT_MS); + } app.dispose(); } })()); @@ -176,18 +157,25 @@ export async function runV2Print( const resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr); restorePermission = resolved.restorePermission; - initializeCliTelemetry({ - homeDir, - auth, - track: (event, properties) => telemetryTrack(event, properties), - bootstrap: telemetryBootstrap, - config, - version, - uiMode: PROMPT_UI_MODE, - model: resolved.telemetryModel, - sessionId: resolved.session.id, - }); - setCrashPhase('runtime'); + telemetryService = app.accessor.get(ITelemetryService); + if (telemetryEnabled) { + telemetryService.setAppender( + new CloudAppender({ + storage: app.accessor.get(IFileSystemStorageService), + deviceId, + appName: CLI_USER_AGENT_PRODUCT, + version, + uiMode: PROMPT_UI_MODE, + model: resolved.telemetryModel, + getAccessToken: async () => (await auth.getCachedAccessToken()) ?? null, + env: process.env, + }), + ); + } + telemetryService.setContext({ sessionId: resolved.session.id }); + if (firstLaunch) { + telemetryService.track('first_launch'); + } const goalCreate = parseHeadlessGoalCreate(opts.prompt!); if (goalCreate !== undefined) { @@ -214,7 +202,7 @@ export async function runV2Print( } writeResumeHint(resolved.session.id, outputFormat, stdout, stderr); - withTelemetryContext({ sessionId: resolved.session.id }).track('exit', { + telemetryService.withContext({ sessionId: resolved.session.id }).track('exit', { duration_ms: Date.now() - startedAt, }); } finally { @@ -369,8 +357,9 @@ async function runNativeTurn( if (result.reason === 'completed') { try { await drainBackgroundTasks(app, session); - } catch (error) { - log.warn('drainBackgroundTasks failed', { error }); + } catch { + // Draining is best-effort; a wedged background task must not fail the + // (already completed) turn. Swallow and proceed to finish. } writer.finish(); return; @@ -444,7 +433,7 @@ function dispatchNativeEvent( writer.writeAssistantDelta(event.delta); return; case 'hook.result': - writer.writeHookResult(event as unknown as HookResultEvent); + writer.writeHookResult(event); return; case 'thinking.delta': writer.writeThinkingDelta(event.delta); diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index 3be61801a..860c4423d 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -105,10 +105,7 @@ export async function handleUpgradeCommand(version: string): Promise { await harness.ensureConfigFile(); const config = await harness.getConfig(); initializeCliTelemetry({ - homeDir: harness.homeDir, - auth: harness.auth, - track: (event, properties) => - properties === undefined ? harness.track(event) : harness.track(event, properties), + harness, bootstrap: telemetryBootstrap, config, version, diff --git a/apps/kimi-code/test/cli/main.test.ts b/apps/kimi-code/test/cli/main.test.ts index c1960e9bd..31411e8b1 100644 --- a/apps/kimi-code/test/cli/main.test.ts +++ b/apps/kimi-code/test/cli/main.test.ts @@ -396,8 +396,9 @@ describe('main entry command handling', () => { })); expect(mocks.harness.ensureConfigFile).toHaveBeenCalledTimes(1); expect(mocks.initializeCliTelemetry).toHaveBeenCalledWith(expect.objectContaining({ - homeDir: '/tmp/kimi-home', - track: expect.any(Function), + harness: expect.objectContaining({ + homeDir: '/tmp/kimi-home', + }), bootstrap: { homeDir: '/tmp/kimi-home', deviceId: 'device-id', diff --git a/apps/kimi-code/test/cli/v2-run-print.test.ts b/apps/kimi-code/test/cli/v2-run-print.test.ts index 1619e2973..4bc5b58d7 100644 --- a/apps/kimi-code/test/cli/v2-run-print.test.ts +++ b/apps/kimi-code/test/cli/v2-run-print.test.ts @@ -10,8 +10,11 @@ import { IAuthSummaryService, IConfigService, IEventBus, + IFileSystemStorageService, + IOAuthToolkit, ISessionIndex, ISessionLifecycleService, + ITelemetryService, type DomainEvent, } from '@moonshot-ai/agent-core-v2'; @@ -182,6 +185,21 @@ describe('runV2Print', () => { }, ], [ISessionIndex, { list: vi.fn(async () => ({ items: [] })) }], + [IOAuthToolkit, { getCachedAccessToken: vi.fn(async () => undefined) }], + [IFileSystemStorageService, {}], + [ + ITelemetryService, + (() => { + const svc = { + setAppender: vi.fn(), + setContext: vi.fn(), + track: vi.fn(), + shutdown: vi.fn(async () => {}), + withContext: vi.fn(() => svc), + }; + return svc; + })(), + ], ]); const app = fakeScope('app', appServices);