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 cb06e0137..8cd1088cc 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -166,7 +166,6 @@ export class SessionEventHandler { private queuedGoalPromotionInFlight = false; private queuedGoalPromotionTimer: ReturnType | undefined; private titleGenerationDisabled = false; - private titleGenerationEpoch = 0; resetRuntimeState(): void { this.backgroundTasks.clear(); @@ -185,7 +184,6 @@ export class SessionEventHandler { this.queuedGoalPromotionPending = false; this.queuedGoalPromotionInFlight = false; this.clearQueuedGoalPromotionTimer(); - this.titleGenerationEpoch += 1; this.titleGenerationDisabled = false; this.stopAllMcpServerStatusSpinners(); } @@ -391,15 +389,14 @@ export class SessionEventHandler { } } this.pluginMcpToolsUsedInTurn.clear(); - this.requestSessionTitleGeneration(); 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 later turns skip - * the internally no-op generation call. Only ever closes the gate — + * 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 { @@ -409,34 +406,21 @@ export class SessionEventHandler { } /** - * Best-effort auto title: right after a prompt is accepted by the engine - * (and again after each completed turn), ask the engine to generate a - * title from the first prompts until one lands. The engine - * overwrites the prompt-derived easy title but never a custom title - * (enforced server-side), and dedupes in-flight requests. A resolved string - * means a title was applied — stop asking so later turns don't regenerate - * over it; a resolved `undefined` (no managed login / no prompt yet / a - * custom title) just retries on the next turn; a rejection (v1 engine, - * dead RPC) disables further attempts for this session. The generated - * title lands through the regular `session.meta.updated` event. + * 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; - const epoch = this.titleGenerationEpoch; - const isCurrentGeneration = () => - epoch === this.titleGenerationEpoch && sessionId === this.host.state.appState.sessionId; - void this.host.harness.generateSessionTitle({ id: sessionId }).then( - (title) => { - if (!isCurrentGeneration()) return; - if (title !== undefined) this.titleGenerationDisabled = true; - }, - () => { - if (!isCurrentGeneration()) return; - this.titleGenerationDisabled = true; - }, - ); + this.titleGenerationDisabled = true; + void this.host.harness.generateSessionTitle({ id: sessionId }).catch(() => undefined); } private handleStepBegin(event: TurnStepStartedEvent): void { 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 index 36d7f956a..da33a95b2 100644 --- 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 @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; import { getBuiltInPalette } from '#/tui/theme'; -function makeHost(options: { sessionTitle?: string | null; generateTitle?: () => Promise } = {}) { +function makeHost(options: { generateTitle?: () => Promise } = {}) { const harness = { generateSessionTitle: vi.fn(options.generateTitle ?? (async () => undefined)), }; @@ -11,7 +11,7 @@ function makeHost(options: { sessionTitle?: string | null; generateTitle?: () => state: { appState: { sessionId: 's1', - sessionTitle: options.sessionTitle ?? null, + sessionTitle: null, workDir: '/tmp/work', streamingPhase: 'waiting', model: 'kimi-model', @@ -75,123 +75,68 @@ async function flushMicrotasks() { } describe('session auto title generation', () => { - it('requests a title after a turn ends while the session has none', () => { - const { host, harness } = makeHost(); - const handler = new SessionEventHandler(host); - - handler.handleEvent(turnEndedEvent(), vi.fn()); - - expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); - }); - - it('requests a title after a turn ends even when an easy title exists', () => { - const { host, harness } = makeHost({ sessionTitle: '首条 prompt 的截断标题' }); - const handler = new SessionEventHandler(host); - - handler.handleEvent(turnEndedEvent(), vi.fn()); - - expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); - }); - - it('stops requesting after a title was generated', async () => { - const { host, harness } = makeHost({ generateTitle: async () => '生成的标题' }); - const handler = new SessionEventHandler(host); - - handler.handleEvent(turnEndedEvent(), vi.fn()); - await flushMicrotasks(); - handler.handleEvent(turnEndedEvent(), vi.fn()); - - expect(harness.generateSessionTitle).toHaveBeenCalledTimes(1); - }); - - it('keeps requesting after an unavailable (undefined) result', async () => { - const { host, harness } = makeHost(); - const handler = new SessionEventHandler(host); - - handler.handleEvent(turnEndedEvent(), vi.fn()); - await flushMicrotasks(); - handler.handleEvent(turnEndedEvent(), vi.fn()); - - expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2); - }); - - it('stops requesting after the harness rejects, and resetRuntimeState re-enables', async () => { - const { host, harness } = makeHost({ - generateTitle: async () => { - throw new Error('not implemented'); + 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.handleEvent(turnEndedEvent(), vi.fn()); + handler.requestSessionTitleGeneration(); await flushMicrotasks(); - handler.handleEvent(turnEndedEvent(), vi.fn()); + handler.requestSessionTitleGeneration(); + expect(harness.generateSessionTitle).toHaveBeenCalledTimes(1); - - handler.resetRuntimeState(); - handler.handleEvent(turnEndedEvent(), vi.fn()); - expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2); + expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); }); - it('ignores a generated title from the previous session after runtime reset', async () => { - let resolveTitle: ((title: string | undefined) => void) | undefined; - const pendingTitle = new Promise((resolve) => { - resolveTitle = resolve; - }); + it('does not request a title when a turn ends', () => { const { host, harness } = makeHost(); - harness.generateSessionTitle.mockReturnValueOnce(pendingTitle); - const handler = new SessionEventHandler(host); - - handler.handleEvent(turnEndedEvent('s1'), vi.fn()); - host.state.appState.sessionId = 's2'; - handler.resetRuntimeState(); - resolveTitle?.('s1 generated title'); - await flushMicrotasks(); - handler.handleEvent(turnEndedEvent('s2'), vi.fn()); - - expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2); - expect(harness.generateSessionTitle).toHaveBeenLastCalledWith({ id: 's2' }); - }); - - it('ignores a rejection from the previous runtime after same-session reset', async () => { - let rejectTitle: ((error: Error) => void) | undefined; - const pendingTitle = new Promise((_resolve, reject) => { - rejectTitle = reject; - }); - const { host, harness } = makeHost(); - harness.generateSessionTitle.mockReturnValueOnce(pendingTitle); 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(); - rejectTitle?.(new Error('stale request failed')); - await flushMicrotasks(); - handler.handleEvent(turnEndedEvent(), vi.fn()); + handler.requestSessionTitleGeneration(); expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2); }); it.each(['generated', 'custom'] as const)( - 'stops requesting when the resumed session already has a %s title', + '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.handleEvent(turnEndedEvent(), vi.fn()); + handler.requestSessionTitleGeneration(); expect(harness.generateSessionTitle).not.toHaveBeenCalled(); }, ); it.each(['replaceable', undefined] as const)( - 'keeps requesting when the resumed title state is %s', + 'requests when the resumed title state is %s', (titleKind) => { const { host, harness } = makeHost(); const handler = new SessionEventHandler(host); handler.syncTitleGenerationGate(titleKind); - handler.handleEvent(turnEndedEvent(), vi.fn()); + handler.requestSessionTitleGeneration(); expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); }, @@ -203,7 +148,7 @@ describe('session auto title generation', () => { handler.syncTitleGenerationGate('generated'); handler.resetRuntimeState(); - handler.handleEvent(turnEndedEvent(), vi.fn()); + handler.requestSessionTitleGeneration(); expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); }); @@ -222,7 +167,7 @@ describe('session auto title generation', () => { } as const, vi.fn(), ); - handler.handleEvent(turnEndedEvent(), vi.fn()); + handler.requestSessionTitleGeneration(); expect(harness.generateSessionTitle).not.toHaveBeenCalled(); });