From b3cb0e8b241c46edff809b115a5dfdd09bea45ed Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 3 Aug 2026 11:01:17 +0800 Subject: [PATCH] refactor(tui): drop the automatic session title trigger Keep the capability only: the engine-side title generation service, the POST /sessions/{id}/title/generate route, and the SDK generateSessionTitle method stay; the TUI no longer requests a title on prompt accept or on session attach. The changeset now covers the SDK capability instead of a CLI-facing auto title. --- .changeset/auto-session-title.md | 5 - .changeset/session-title-generation.md | 5 + .../tui/controllers/session-event-handler.ts | 41 ----- apps/kimi-code/src/tui/kimi-tui.ts | 14 +- .../session-event-handler-goal-queue.test.ts | 1 - ...ssion-event-handler-plugin-updates.test.ts | 1 - .../session-event-handler-title.test.ts | 174 ------------------ .../test/tui/kimi-tui-message-flow.test.ts | 26 --- 8 files changed, 9 insertions(+), 258 deletions(-) delete mode 100644 .changeset/auto-session-title.md create mode 100644 .changeset/session-title-generation.md delete mode 100644 apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts diff --git a/.changeset/auto-session-title.md b/.changeset/auto-session-title.md deleted file mode 100644 index 2ff827ab3..000000000 --- a/.changeset/auto-session-title.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": minor ---- - -Generate a concise session title automatically as soon as the first prompt is sent when signed in with a managed Kimi account. A title you set yourself is never overwritten. diff --git a/.changeset/session-title-generation.md b/.changeset/session-title-generation.md new file mode 100644 index 000000000..c7d118b0d --- /dev/null +++ b/.changeset/session-title-generation.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": minor +--- + +Add SDK support for generating a concise session title from the session's first prompts when signed in with a managed Kimi account; session summaries now report whether the title is custom or generated, and a custom title is never overwritten. Call `generateSessionTitle` on the harness to try it. diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 8cd1088cc..b82f91961 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -14,10 +14,8 @@ import type { GoalChange, GoalUpdatedEvent, HookResultEvent, - KimiHarness, Session, SessionMetaUpdatedEvent, - SessionTitleKind, SkillActivatedEvent, PluginCommandActivatedEvent, ThinkingDeltaEvent, @@ -96,7 +94,6 @@ export interface SessionEventHost { aborted: boolean; sessionEventUnsubscribe: (() => void) | undefined; readonly streamingUI: StreamingUIController; - readonly harness: KimiHarness; requireSession(): Session; setAppState(patch: Partial): void; @@ -165,7 +162,6 @@ export class SessionEventHandler { private queuedGoalPromotionPending = false; private queuedGoalPromotionInFlight = false; private queuedGoalPromotionTimer: ReturnType | undefined; - private titleGenerationDisabled = false; resetRuntimeState(): void { this.backgroundTasks.clear(); @@ -184,7 +180,6 @@ export class SessionEventHandler { this.queuedGoalPromotionPending = false; this.queuedGoalPromotionInFlight = false; this.clearQueuedGoalPromotionTimer(); - this.titleGenerationDisabled = false; this.stopAllMcpServerStatusSpinners(); } @@ -392,37 +387,6 @@ export class SessionEventHandler { this.scheduleQueuedGoalPromotion(); } - /** - * Seeds the title-generation gate from the persisted title state (read off - * the resumed session's summary): a session whose title was already - * generated or customized has nothing left to ask for, so the one-shot - * request is skipped. Only ever closes the gate — - * reopening stays with `resetRuntimeState` on a session switch. - */ - syncTitleGenerationGate(titleKind: SessionTitleKind | undefined): void { - if (titleKind === 'generated' || titleKind === 'custom') { - this.titleGenerationDisabled = true; - } - } - - /** - * Best-effort auto title: right after a prompt is accepted by the engine, - * ask it once to generate a title from the first prompts. One shot per - * session attach — the prompt-derived easy title is an acceptable - * fallback, so the outcome is not acted on and failures (no managed login, - * v1 engine, dead RPC) are not retried. The engine overwrites the - * prompt-derived easy title but never a custom title (enforced - * server-side), and a generated title lands through the regular - * `session.meta.updated` event. - */ - requestSessionTitleGeneration(): void { - if (this.titleGenerationDisabled) return; - const { sessionId } = this.host.state.appState; - if (sessionId.length === 0) return; - this.titleGenerationDisabled = true; - void this.host.harness.generateSessionTitle({ id: sessionId }).catch(() => undefined); - } - private handleStepBegin(event: TurnStepStartedEvent): void { this.host.streamingUI.flushNow(); this.host.streamingUI.setStep(event.step); @@ -927,11 +891,6 @@ export class SessionEventHandler { this.host.setAppState({ sessionTitle: title }); this.host.updateTerminalTitle(); } - // A custom rename (here or by another client) settles title generation: - // the engine would only keep returning undefined for it. - if (event.patch?.['isCustomTitle'] === true) { - this.titleGenerationDisabled = true; - } } private handleSessionError(event: ErrorEvent): void { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index a00cb17aa..834bd7777 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1357,15 +1357,10 @@ export class KimiTUI { }); return; } - void session.prompt(sdkInput).then( - // The prompt is enqueued engine-side by the time this resolves, so the - // title endpoint can already read it — no need to wait for turn end. - () => this.sessionEventHandler.requestSessionTitleGeneration(), - (error: unknown) => { - const message = formatErrorMessage(error); - this.failSessionRequest(`Failed to send: ${message}`); - }, - ); + void session.prompt(sdkInput).catch((error: unknown) => { + const message = formatErrorMessage(error); + this.failSessionRequest(`Failed to send: ${message}`); + }); } sendSkillActivation(session: Session, skillName: string, skillArgs: string): void { @@ -1618,7 +1613,6 @@ export class KimiTUI { sessionTitle: session.summary?.title ?? null, goal: goalResult.goal, }); - this.sessionEventHandler.syncTitleGenerationGate(session.summary?.titleKind); this.syncAdditionalDirs(session); } diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index fea52b835..8b9d5fbdf 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -95,7 +95,6 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, tasksBrowserController: {}, - harness: { generateSessionTitle: vi.fn(async () => undefined) }, }; host.setAppState.mockImplementation((patch: Record) => { Object.assign(host.state.appState, patch); diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts index 9a6d6c8fd..3220d1b91 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts @@ -56,7 +56,6 @@ function makeHost() { shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, tasksBrowserController: {}, - harness: { generateSessionTitle: vi.fn(async () => undefined) }, }; return { host: host as never, streamingUI }; } diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts deleted file mode 100644 index da33a95b2..000000000 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; -import { getBuiltInPalette } from '#/tui/theme'; - -function makeHost(options: { generateTitle?: () => Promise } = {}) { - const harness = { - generateSessionTitle: vi.fn(options.generateTitle ?? (async () => undefined)), - }; - const host = { - state: { - appState: { - sessionId: 's1', - sessionTitle: null, - workDir: '/tmp/work', - streamingPhase: 'waiting', - model: 'kimi-model', - permissionMode: 'auto', - }, - queuedMessages: [], - queuedMessageDispatchPending: false, - theme: { palette: getBuiltInPalette('dark') }, - toolOutputExpanded: false, - todoPanel: { getTodos: vi.fn(() => []) }, - transcriptContainer: { addChild: vi.fn() }, - ui: { requestRender: vi.fn() }, - }, - session: undefined, - aborted: false, - sessionEventUnsubscribe: undefined, - streamingUI: { - setTurnId: vi.fn(), - flushNow: vi.fn(), - resetToolUi: vi.fn(), - finalizeTurn: vi.fn(), - }, - harness, - requireSession: vi.fn(), - setAppState: vi.fn(), - patchLivePane: vi.fn(), - resetLivePane: vi.fn(), - showError: vi.fn(), - showStatus: vi.fn(), - showNotice: vi.fn(), - track: vi.fn(), - mountEditorReplacement: vi.fn(), - restoreEditor: vi.fn(), - restoreInputText: vi.fn(), - appendTranscriptEntry: vi.fn(), - sendNormalUserInput: vi.fn(), - sendQueuedMessage: vi.fn(), - shiftQueuedMessage: vi.fn(), - updateActivityPane: vi.fn(), - updateTerminalTitle: vi.fn(), - handleShellOutput: vi.fn(), - handleShellStarted: vi.fn(), - btwPanelController: { routeEvent: vi.fn(() => false) }, - tasksBrowserController: {}, - }; - return { host: host as any, harness }; -} - -function turnEndedEvent(sessionId = 's1') { - return { - type: 'turn.ended', - sessionId, - agentId: 'main', - turnId: 1, - reason: 'completed', - } as const; -} - -async function flushMicrotasks() { - await new Promise((resolve) => setTimeout(resolve, 0)); -} - -describe('session auto title generation', () => { - it.each([ - ['unavailable', async (): Promise => undefined], - ['applied', async (): Promise => '生成的标题'], - [ - 'rejected', - async (): Promise => { - throw new Error('core rpc unavailable'); - }, - ], - ] as const)('requests only once per runtime when the attempt is %s', async (_outcome, generateTitle) => { - const { host, harness } = makeHost({ generateTitle }); - const handler = new SessionEventHandler(host); - - handler.requestSessionTitleGeneration(); - await flushMicrotasks(); - handler.requestSessionTitleGeneration(); - - expect(harness.generateSessionTitle).toHaveBeenCalledTimes(1); - expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); - }); - - it('does not request a title when a turn ends', () => { - const { host, harness } = makeHost(); - const handler = new SessionEventHandler(host); - - handler.handleEvent(turnEndedEvent(), vi.fn()); - - expect(harness.generateSessionTitle).not.toHaveBeenCalled(); - }); - - it('grants a fresh attempt on runtime reset', () => { - const { host, harness } = makeHost(); - const handler = new SessionEventHandler(host); - - handler.requestSessionTitleGeneration(); - handler.resetRuntimeState(); - handler.requestSessionTitleGeneration(); - - expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2); - }); - - it.each(['generated', 'custom'] as const)( - 'skips the request when the resumed session already has a %s title', - (titleKind) => { - const { host, harness } = makeHost(); - const handler = new SessionEventHandler(host); - - handler.syncTitleGenerationGate(titleKind); - handler.requestSessionTitleGeneration(); - - expect(harness.generateSessionTitle).not.toHaveBeenCalled(); - }, - ); - - it.each(['replaceable', undefined] as const)( - 'requests when the resumed title state is %s', - (titleKind) => { - const { host, harness } = makeHost(); - const handler = new SessionEventHandler(host); - - handler.syncTitleGenerationGate(titleKind); - handler.requestSessionTitleGeneration(); - - expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); - }, - ); - - it('re-opens the gate on runtime reset for a session seeded as settled', () => { - const { host, harness } = makeHost(); - const handler = new SessionEventHandler(host); - - handler.syncTitleGenerationGate('generated'); - handler.resetRuntimeState(); - handler.requestSessionTitleGeneration(); - - expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); - }); - - it('stops requesting after a custom rename event arrives', () => { - const { host, harness } = makeHost(); - const handler = new SessionEventHandler(host); - - handler.handleEvent( - { - type: 'session.meta.updated', - agentId: 'main', - sessionId: 's1', - title: '用户手工标题', - patch: { title: '用户手工标题', isCustomTitle: true }, - } as const, - vi.fn(), - ); - handler.requestSessionTitleGeneration(); - - expect(harness.generateSessionTitle).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 6a00aaa5b..b46eab17b 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -282,7 +282,6 @@ function makeHarness(session = makeSession(), overrides: Record return interactiveAgentScope.run(agentId, fn); }), getExperimentalFeatures: vi.fn(async () => []), - generateSessionTitle: vi.fn(async () => undefined), auth: { status: vi.fn(), login: vi.fn(), @@ -1203,31 +1202,6 @@ command = "vim" ]); }); - it('requests a session title once the prompt is accepted', async () => { - const { driver, harness } = await makeDriver(); - - driver.handleUserInput('hello'); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 'ses-1' }); - }); - - it('does not request a session title when the prompt send fails', async () => { - const { driver, harness } = await makeDriver( - makeSession({ - prompt: vi.fn(async () => { - throw new Error('core rpc unavailable'); - }), - }), - ); - - driver.handleUserInput('hello'); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(harness.generateSessionTitle).not.toHaveBeenCalled(); - expect(driver.state.appState.streamingPhase).toBe('idle'); - }); - it('keeps the transcript intact when undo RPC fails', async () => { const session = makeSession({ undoHistory: vi.fn(async () => {