From 68ed6235618350bfac7dd7769f905688446d2f56 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 30 Jul 2026 15:56:35 +0800 Subject: [PATCH] feat: request session title generation from the TUI after each turn --- .../tui/controllers/session-event-handler.ts | 24 ++++ .../session-event-handler-goal-queue.test.ts | 1 + ...ssion-event-handler-plugin-updates.test.ts | 1 + .../session-event-handler-title.test.ts | 124 ++++++++++++++++++ .../test/tui/kimi-tui-message-flow.test.ts | 1 + packages/klient/src/contract/index.ts | 2 + packages/klient/src/contract/session/title.ts | 13 ++ packages/klient/src/core/facade/session.ts | 8 ++ .../src/transports/memory/serviceRegistry.ts | 2 + packages/node-sdk/src/kimi-harness.ts | 14 ++ packages/node-sdk/src/rpc.ts | 14 ++ packages/node-sdk/src/sdk-rpc-client-v2.ts | 23 ++++ packages/node-sdk/src/types.ts | 4 + 13 files changed, 231 insertions(+) create mode 100644 apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts create mode 100644 packages/klient/src/contract/session/title.ts 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 b82f91961..ff30b5e50 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -14,6 +14,7 @@ import type { GoalChange, GoalUpdatedEvent, HookResultEvent, + KimiHarness, Session, SessionMetaUpdatedEvent, SkillActivatedEvent, @@ -94,6 +95,7 @@ export interface SessionEventHost { aborted: boolean; sessionEventUnsubscribe: (() => void) | undefined; readonly streamingUI: StreamingUIController; + readonly harness: KimiHarness; requireSession(): Session; setAppState(patch: Partial): void; @@ -162,6 +164,7 @@ export class SessionEventHandler { private queuedGoalPromotionPending = false; private queuedGoalPromotionInFlight = false; private queuedGoalPromotionTimer: ReturnType | undefined; + private titleGenerationDisabled = false; resetRuntimeState(): void { this.backgroundTasks.clear(); @@ -180,6 +183,7 @@ export class SessionEventHandler { this.queuedGoalPromotionPending = false; this.queuedGoalPromotionInFlight = false; this.clearQueuedGoalPromotionTimer(); + this.titleGenerationDisabled = false; this.stopAllMcpServerStatusSpinners(); } @@ -384,9 +388,29 @@ export class SessionEventHandler { } } this.pluginMcpToolsUsedInTurn.clear(); + this.requestSessionTitleGeneration(); this.scheduleQueuedGoalPromotion(); } + /** + * Best-effort auto title: while the session has no title yet, ask the + * engine to generate one from the first prompts after each completed turn. + * The engine keeps custom titles untouched and dedupes in-flight requests; + * a resolved `undefined` (no managed login / no prompt yet) just retries on + * the next turn, while a rejection (v1 engine, dead RPC) disables further + * attempts for this session. The generated title lands through the regular + * `session.meta.updated` event. + */ + private requestSessionTitleGeneration(): void { + if (this.titleGenerationDisabled) return; + const { sessionId, sessionTitle } = this.host.state.appState; + if (sessionId.length === 0) return; + if (typeof sessionTitle === 'string' && sessionTitle.trim().length > 0) return; + void this.host.harness.generateSessionTitle({ id: sessionId }).catch(() => { + this.titleGenerationDisabled = true; + }); + } + private handleStepBegin(event: TurnStepStartedEvent): void { this.host.streamingUI.flushNow(); this.host.streamingUI.setStep(event.step); 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 8b9d5fbdf..fea52b835 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,6 +95,7 @@ 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 3220d1b91..9a6d6c8fd 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,6 +56,7 @@ 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 new file mode 100644 index 000000000..7fb56e5ff --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts @@ -0,0 +1,124 @@ +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 } = {}) { + const harness = { + generateSessionTitle: vi.fn(options.generateTitle ?? (async () => undefined)), + }; + const host = { + state: { + appState: { + sessionId: 's1', + sessionTitle: options.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() { + return { + type: 'turn.ended', + sessionId: 's1', + agentId: 'main', + turnId: 1, + reason: 'completed', + } as const; +} + +async function flushMicrotasks() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +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('does not request a title when the session already has one', () => { + const { host, harness } = makeHost({ sessionTitle: 'custom title' }); + const handler = new SessionEventHandler(host); + + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(harness.generateSessionTitle).not.toHaveBeenCalled(); + }); + + 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'); + }, + }); + const handler = new SessionEventHandler(host); + + handler.handleEvent(turnEndedEvent(), vi.fn()); + await flushMicrotasks(); + handler.handleEvent(turnEndedEvent(), vi.fn()); + expect(harness.generateSessionTitle).toHaveBeenCalledTimes(1); + + handler.resetRuntimeState(); + handler.handleEvent(turnEndedEvent(), vi.fn()); + expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2); + }); +}); 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 b46eab17b..d6a509e54 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,6 +282,7 @@ 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(), diff --git a/packages/klient/src/contract/index.ts b/packages/klient/src/contract/index.ts index 39065bf55..78e337358 100644 --- a/packages/klient/src/contract/index.ts +++ b/packages/klient/src/contract/index.ts @@ -33,6 +33,7 @@ import { sessionInteractionContract } from './session/interaction.js'; import { sessionLifecycleContract } from './session/lifecycle.js'; import { sessionMetadataContract } from './session/metadata.js'; import { sessionQuestionContract } from './session/question.js'; +import { sessionTitleContract } from './session/title.js'; export const globalContract: KlientContract = { // core (app scope) @@ -55,6 +56,7 @@ export const globalContract: KlientContract = { sessionInteractionService: sessionInteractionContract, sessionApprovalService: sessionApprovalContract, sessionQuestionService: sessionQuestionContract, + sessionTitleService: sessionTitleContract, // agent scope agentRPCService: agentRpcContract, agentActivityView: agentActivityViewContract, diff --git a/packages/klient/src/contract/session/title.ts b/packages/klient/src/contract/session/title.ts new file mode 100644 index 000000000..cf936bc6c --- /dev/null +++ b/packages/klient/src/contract/session/title.ts @@ -0,0 +1,13 @@ +/** + * `sessionTitleService` — on-demand session title generation. Mirrors + * `agent-core-v2/session/sessionTitle/sessionTitle.ts`. + */ + +import { z } from 'zod'; + +import { maybe } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const sessionTitleContract = { + generateTitle: { input: z.tuple([]), output: maybe(z.string()) }, +} satisfies ServiceContract; diff --git a/packages/klient/src/core/facade/session.ts b/packages/klient/src/core/facade/session.ts index cce957aa6..3f1447628 100644 --- a/packages/klient/src/core/facade/session.ts +++ b/packages/klient/src/core/facade/session.ts @@ -63,6 +63,12 @@ export type SessionStatus = 'running' | 'idle' | 'awaiting_approval' | 'awaiting export interface SessionFacade { get(): Promise; setTitle(title: string): Promise; + /** + * Generate and apply a title from the main agent's first prompts via the + * managed `chat_title` tool. `undefined` when generation is unavailable + * (no managed OAuth login, no prompt yet, or a custom title is set). + */ + generateTitle(): Promise; update(patch: SessionMetaPatch): Promise; setArchived(archived: boolean): Promise; status(): Promise; @@ -99,6 +105,8 @@ export function createSessionFacade(call: ScopedCaller, sessionId: string): Sess return { get: read, setTitle: (title) => call(scope, 'sessionMetadata', 'setTitle', [title]) as Promise, + generateTitle: () => + call(scope, 'sessionTitleService', 'generateTitle', []) as Promise, update: (patch) => call(scope, 'sessionMetadata', 'update', [patch]) as Promise, setArchived: (archived) => call(scope, 'sessionMetadata', 'setArchived', [archived]) as Promise, diff --git a/packages/klient/src/transports/memory/serviceRegistry.ts b/packages/klient/src/transports/memory/serviceRegistry.ts index 354089a17..e56dfe17a 100644 --- a/packages/klient/src/transports/memory/serviceRegistry.ts +++ b/packages/klient/src/transports/memory/serviceRegistry.ts @@ -27,6 +27,7 @@ import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMeta import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/interaction/interaction'; import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval'; import { ISessionQuestionService } from '@moonshot-ai/agent-core-v2/session/question/question'; +import { ISessionTitleService } from '@moonshot-ai/agent-core-v2/session/sessionTitle/sessionTitle'; import { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc'; import { IAgentActivityView } from '@moonshot-ai/agent-core-v2/agent/activityView/activityView'; import { IAgentPlanService } from '@moonshot-ai/agent-core-v2/agent/plan/plan'; @@ -55,6 +56,7 @@ export const serviceTokens: Readonly>> sessionInteractionService: ISessionInteractionService, sessionApprovalService: ISessionApprovalService, sessionQuestionService: ISessionQuestionService, + sessionTitleService: ISessionTitleService, agentRPCService: IAgentRPCService, agentActivityView: IAgentActivityView, agentShellCommandService: IAgentShellCommandService, diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 8197d9bac..8951677f5 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -17,6 +17,7 @@ import type { ExportSessionInput, ExportSessionResult, ForkSessionInput, + GenerateSessionTitleInput, GetConfigOptions, KimiConfig, KimiConfigPatch, @@ -237,6 +238,19 @@ export class KimiHarness { this.activeSessions.get(input.id)?.emitMetaUpdated({ title: input.title }); } + /** + * Generate and apply a session title from the main agent's first prompts + * (v2 engine only). Resolves to `undefined` when generation is unavailable + * and the current title is kept. + */ + async generateSessionTitle(input: GenerateSessionTitleInput): Promise { + const title = await this.rpc.generateSessionTitle(input); + if (title !== undefined) { + this.activeSessions.get(input.id)?.emitMetaUpdated({ title }); + } + return title; + } + async exportSession(input: ExportSessionInput): Promise { const result = await this.rpc.exportSession({ ...input, diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 42fc747ab..7d3e96be4 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -2,6 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { ErrorCodes, + KimiError, makeErrorPayload, type AgentContextData, type ApprovalRequest, @@ -32,6 +33,7 @@ import type { ExportSessionResult, CreateGoalInput, ForkSessionInput, + GenerateSessionTitleInput, GetConfigOptions, McpServerConfig, GoalSnapshot, @@ -228,6 +230,18 @@ export abstract class SDKRpcClientBase { }); } + /** + * v2-only capability (`ISessionTitleService`); the v1 engine has no title + * generation, so the base fails loudly and `SDKRpcClientV2` overrides it. + */ + async generateSessionTitle(input: GenerateSessionTitleInput): Promise { + void input; + throw new KimiError( + ErrorCodes.NOT_IMPLEMENTED, + 'generateSessionTitle is only available on the agent-core-v2 engine.', + ); + } + async exportSession(input: ExportSessionInput): Promise { const rpc = await this.getRpc(); return rpc.exportSession({ diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 31f5c64d1..fc021722b 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -256,6 +256,7 @@ import type { ExportSessionInput, ExportSessionResult, ForkSessionInput, + GenerateSessionTitleInput, GetConfigOptions, GetCronTasksResult, GoalSnapshot, @@ -995,6 +996,28 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } } + /** + * v2-only (`ISessionTitleService`, session scope). Like `renameSession`, a + * closed session is resumed, titled, and closed again so generation does + * not leak a live session. `undefined` means generation was unavailable + * (no managed OAuth login, no prompt yet, or a custom title is set) — the + * current title is kept. + */ + override async generateSessionTitle( + input: GenerateSessionTitleInput, + ): Promise { + if (this.sessionLifecycle.get(input.id) !== undefined) { + return this.klient.session(input.id).generateTitle(); + } + const handle = await this.sessionLifecycle.resume(input.id); + if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); + try { + return await this.klient.session(input.id).generateTitle(); + } finally { + await this.sessionLifecycle.close(input.id); + } + } + /** * Through `engineAccessor` (`ISessionLifecycleService.fork`) because the * klient facade fork takes no explicit target id. Known gaps vs v1: the diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index d65e5d30c..3f8462a62 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -133,6 +133,10 @@ export interface RenameSessionInput { readonly title: string; } +export interface GenerateSessionTitleInput { + readonly id: string; +} + export interface ResumeSessionInput { readonly id: string; readonly kaos?: Kaos | undefined;