From 501841d3a3fbc09e93616ef0f43c1b48f1be2f46 Mon Sep 17 00:00:00 2001 From: iosmanthus Date: Sat, 22 Aug 2026 02:16:23 +0800 Subject: [PATCH] fix(tui): honor the auto_session_title experimental flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine only generates an AI session title when a client asks for one. `kimi web` asks each time a turn settles; the TUI never asked. So "AI session titles" shows up in the TUI's /experiments panel, with a description promising that "clients auto-generate once the first turn completes", and turning it on there changes nothing — the session keeps the truncated first prompt as its name. Ask from `handleTurnEnded`, with the web client's policy: the `first_turn` excerpt, at most three attempts per session, and no further attempts once a title lands. Everything downstream is already wired — the engine refuses to overwrite a renamed or already generated title, and the `session.meta.updated` it publishes reaches the status panel and the terminal title through `handleSessionMetaChanged`. Also documents `KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE`, which the env-var reference was missing. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/tui-auto-session-title.md | 5 ++ apps/kimi-code/src/tui/kimi-tui.ts | 43 +++++++++ .../test/tui/kimi-tui-message-flow.test.ts | 87 +++++++++++++++++++ docs/en/configuration/env-vars.md | 1 + docs/zh/configuration/env-vars.md | 1 + 5 files changed, 137 insertions(+) create mode 100644 .changeset/tui-auto-session-title.md diff --git a/.changeset/tui-auto-session-title.md b/.changeset/tui-auto-session-title.md new file mode 100644 index 0000000000..a1518fb838 --- /dev/null +++ b/.changeset/tui-auto-session-title.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Generate AI session titles in the CLI, not just in `kimi web`. With the `auto_session_title` experimental flag on, the TUI now asks for a title once a turn completes, so the session name is a short summary instead of the truncated first prompt. diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 5e0ec77003..22972dc819 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -303,6 +303,12 @@ interface SendMessageOptions { /** How long the one-shot "moved to background" footer hint stays visible. */ const DETACH_HINT_DISPLAY_MS = 4_000; +/** Engine flag id — see `packages/agent-core-v2/src/session/sessionTitle/flag.ts`. */ +const AUTO_SESSION_TITLE_FLAG = 'auto_session_title'; + +/** Generation attempts per session before the TUI stops asking, as in the web client. */ +const MAX_SESSION_TITLE_ATTEMPTS = 3; + export class KimiTUI { readonly harness: KimiHarness; readonly options: KimiTUIOptions; @@ -316,6 +322,8 @@ export class KimiTUI { private readonly approvalController = new ApprovalController(); private readonly questionController = new QuestionController(); private readonly reverseRpcDisposers: Array<() => void> = []; + /** AI session title bookkeeping, keyed by session id — see maybeGenerateSessionTitle. */ + private readonly sessionTitleGeneration = new Map(); private skillCommands: readonly KimiSlashCommand[] = []; readonly skillCommandMap = new Map(); private pluginCommands: readonly KimiSlashCommand[] = []; @@ -1731,6 +1739,41 @@ export class KimiTUI { handleTurnEnded(event: TurnEndedEvent): void { this.staging.handleTurnEnded(event); + this.maybeGenerateSessionTitle(event); + } + + /** + * The engine generates an AI session title only when a client asks for one. + * `kimi web` asks every time a turn settles; the TUI never asked, so turning + * "AI session titles" on in /experiments changed nothing here and the title + * stayed the truncated first prompt. Ask on the same trigger and with the + * same policy as the web client: the `first_turn` excerpt, at most + * MAX_SESSION_TITLE_ATTEMPTS tries per session, and no further tries once one + * lands. The rest is already wired — the engine leaves a renamed or already + * generated title alone, and the `session.meta.updated` it publishes reaches + * the status panel and the terminal title through handleSessionMetaChanged. + */ + private maybeGenerateSessionTitle(event: TurnEndedEvent): void { + if (event.reason !== 'completed') return; + if (!this.engineV2) return; + if (!isExperimentalFlagEnabled(AUTO_SESSION_TITLE_FLAG)) return; + const sessionId = this.session?.id; + if (sessionId === undefined) return; + const state = this.sessionTitleGeneration.get(sessionId); + if (state?.done === true) return; + const attempts = state?.attempts ?? 0; + if (attempts >= MAX_SESSION_TITLE_ATTEMPTS) return; + this.sessionTitleGeneration.set(sessionId, { attempts: attempts + 1, done: false }); + void this.harness + .generateSessionTitle({ id: sessionId, source: 'first_turn' }) + .then((title) => { + if (title === undefined) return; + this.sessionTitleGeneration.set(sessionId, { attempts: 0, done: true }); + }) + .catch(() => { + // A title is cosmetic: keep the prompt-derived one and let a later + // turn retry while the attempt budget lasts. + }); } releaseStagingMedia(mediaAttachmentIds: readonly number[]): void { 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 044c71f3b0..bc84aafce3 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 @@ -50,6 +50,7 @@ import { import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; import type { SessionReplayRenderer } from '#/tui/controllers/session-replay'; import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; +import { setExperimentalFeatures } from '#/tui/commands/experimental-flags'; import { handleFeedbackCommand } from '#/tui/commands/info'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; import { openUrl } from '#/utils/open-url'; @@ -8530,3 +8531,89 @@ describe('transcript step and assistant folding', () => { expect(stripSgr(lastAssistant.render(120).join('\n'))).toContain(`msg-${cycles - 1}`); }); }); +describe('AI session titles', () => { + // The flag snapshot is module state shared by every driver in this file. + afterEach(() => { + setExperimentalFeatures([]); + }); + + async function makeTitleDriver( + generateSessionTitle: ReturnType, + flagEnabled = true, + ): Promise { + const session = makeSession(); + const { driver } = await makeDriver( + session, + { + getExperimentalFeatures: vi.fn(async () => [ + { id: 'auto_session_title', enabled: flagEnabled }, + ]), + generateSessionTitle, + }, + { ...makeStartupInput(), engineV2: true }, + ); + // The v2 engine starts session-less and creates on first prompt; the turn + // events below stand in for that first prompt. + await driver.setSession(session); + return driver; + } + + it('asks the engine to generate a title when a turn completes', async () => { + const generateSessionTitle = vi.fn(async () => 'Wire up the terminal title'); + const driver = await makeTitleDriver(generateSessionTitle); + + emitTurn(driver, 1); + + await vi.waitFor(() => { + expect(generateSessionTitle).toHaveBeenCalledWith({ + id: 'ses-1', + source: 'first_turn', + }); + }); + + // A generated title is applied once; later turns must not ask again. + emitTurn(driver, 2); + expect(generateSessionTitle).toHaveBeenCalledTimes(1); + }); + + it('stays quiet while the experimental flag is off', async () => { + const generateSessionTitle = vi.fn(async () => 'Never requested'); + const driver = await makeTitleDriver(generateSessionTitle, false); + + emitTurn(driver, 1); + + expect(generateSessionTitle).not.toHaveBeenCalled(); + }); + + it('ignores a cancelled turn', async () => { + const generateSessionTitle = vi.fn(async () => 'Never requested'); + const driver = await makeTitleDriver(generateSessionTitle); + + driver.sessionEventHandler.handleEvent( + { type: 'turn.started', agentId: 'main', turnId: 1, origin: { kind: 'user' } } as Event, + vi.fn(), + ); + driver.sessionEventHandler.handleEvent( + { type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'cancelled' } as Event, + vi.fn(), + ); + + expect(generateSessionTitle).not.toHaveBeenCalled(); + }); + + it('gives up after three unproductive attempts', async () => { + // `undefined` is what the engine returns when it declines to generate — + // no managed login, or the platform request failed. + const generateSessionTitle = vi.fn(async () => undefined); + const driver = await makeTitleDriver(generateSessionTitle); + + for (const turnId of [1, 2, 3, 4]) { + emitTurn(driver, turnId); + await vi.waitFor(() => { + expect(generateSessionTitle).toHaveBeenCalledTimes(Math.min(turnId, 3)); + }); + } + + expect(generateSessionTitle).toHaveBeenCalledTimes(3); + }); +}); diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 4d8b900c15..74eebfa36a 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -151,6 +151,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_TUI_FULL_SCREEN` | Enable the experimental fullscreen alternate-screen UI: scrollable transcript viewport, mouse text selection, clickable links, and Ctrl-Shift-F transcript search | `1` enables it; anything else keeps the regular inline UI | | `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | Enable the experimental [subagent model pool](./config-files.md#subagent-model-pool) in every launch mode, including the interactive TUI; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK` | Enable the experimental `fork` parameter on the `Agent` and `AgentSwarm` tools, letting the model start a subagent with a snapshot of the calling agent's conversation history instead of an empty context; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | +| `KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE` | Let the managed `chat_title` tool name the session from the conversation instead of keeping the truncated first prompt; requires a managed Kimi Code OAuth login, and the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | Global default connection timeout (ms) for all MCP servers; takes higher priority than `[mcp] startup_timeout_ms` in `config.toml`, but a per-server `startupTimeoutMs` in `mcp.json` still wins (default `30000`) | Integer from `1` to `2147483647`; invalid values are ignored | | `KIMI_MCP_TOOL_TIMEOUT_MS` | Global default single tool-call timeout (ms) for all MCP servers; takes higher priority than `[mcp] tool_timeout_ms` in `config.toml`, but a per-server `toolTimeoutMs` in `mcp.json` still wins (default `60000`) | Integer from `1` to `2147483647`; invalid values are ignored | | `KIMI_LOOP_MAX_STEPS_PER_TURN` | Maximum Agent steps per turn; takes higher priority than `[loop_control] max_steps_per_turn` in `config.toml` (unset or `0` means unlimited) | Non-negative integer; invalid values are ignored | diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 67df6d1832..6dcc27d125 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -151,6 +151,7 @@ kimi | `KIMI_CODE_TUI_FULL_SCREEN` | 启用实验性的 fullscreen alternate-screen 界面:可滚动的 transcript 视口、鼠标选择文本、可点击链接、Ctrl-Shift-F 搜索 | `1` 开启;其他值保持常规内联界面 | | `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | 在包括交互式 TUI 在内的所有启动方式下启用实验性的[subagent 模型池](./config-files.md#subagent-模型池);master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK` | 在 `Agent` 和 `AgentSwarm` 工具上启用实验性的 `fork` 参数,让模型可以以调用方 Agent 对话历史的快照而不是空上下文启动 subagent;master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | +| `KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE` | 让托管的 `chat_title` 工具根据对话内容生成会话标题,而不是沿用截断后的第一条提示词;需要托管的 Kimi Code OAuth 登录,master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | 所有 MCP server 的全局默认连接超时(毫秒);优先级高于 `config.toml` 的 `[mcp] startup_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `startupTimeoutMs`(默认 `30000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | | `KIMI_MCP_TOOL_TIMEOUT_MS` | 所有 MCP server 的全局默认单次工具调用超时(毫秒);优先级高于 `config.toml` 的 `[mcp] tool_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `toolTimeoutMs`(默认 `60000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | | `KIMI_LOOP_MAX_STEPS_PER_TURN` | Agent 单轮最大步数;优先级高于 `config.toml` 的 `[loop_control] max_steps_per_turn`(不设或 `0` 表示无上限) | 非负整数;非法值被忽略 |