Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
63f195c
feat: auto-generate session titles via the managed chat_title tool
7Sageer Jul 29, 2026
b8c1244
fix: harden auto-generated session titles
7Sageer Jul 29, 2026
db79855
fix: preserve managed title request headers
7Sageer Jul 29, 2026
7e09814
Pair auto-title endpoint overrides with matching OAuth credentials
7Sageer Jul 29, 2026
6ef7c26
fix: preserve legacy custom session titles
7Sageer Jul 29, 2026
2162ec9
fix: preserve automatic session title invariants
7Sageer Jul 29, 2026
f66a0c5
refactor: keep only the on-demand session title generation interface
7Sageer Jul 29, 2026
42fe810
feat: generate session title from the first recorded prompts
7Sageer Jul 30, 2026
159d444
test: make session title race tests deterministic
7Sageer Jul 30, 2026
81c6c64
Generate session titles from agent conversation history
7Sageer Jul 30, 2026
1b66557
fix: reject title generation without user prompts
7Sageer Jul 30, 2026
c4f5f3f
fix: bound session title prompt history
7Sageer Jul 30, 2026
d22a434
feat: enable session title generation without an experimental flag
7Sageer Jul 30, 2026
cee7f54
Merge remote-tracking branch 'origin/main' into auto-title
7Sageer Jul 30, 2026
86b1163
test: cover session title generation through the public REST path
7Sageer Jul 30, 2026
68ed623
feat: request session title generation from the TUI after each turn
7Sageer Jul 30, 2026
cca5387
Retry auto title generation for prompt-derived session titles
7Sageer Jul 30, 2026
87408d0
feat: record session title source and harden the generation lifecycle
7Sageer Jul 30, 2026
17271c9
chore: follow agent-core comment convention
7Sageer Jul 30, 2026
30cd334
fix: ignore stale session title callbacks
7Sageer Jul 30, 2026
a108338
fix: preserve session title state invariants
7Sageer Jul 31, 2026
531dbd3
Merge remote-tracking branch 'origin/main' into auto-title
7Sageer Jul 31, 2026
5e16893
refactor: seed session lifetime instead of querying the workspace han…
7Sageer Jul 31, 2026
b68c993
fix: honor the legacy custom title marker over a stale titleKind
7Sageer Jul 31, 2026
4196abf
fix: serialize session access and expose the session title state
7Sageer Jul 31, 2026
c63c511
chore: add session title changesets
7Sageer Jul 31, 2026
4ffb1b7
fix: close the session lifecycle races around close and title generation
7Sageer Jul 31, 2026
46a7797
feat: project the session title state through the session index
7Sageer Jul 31, 2026
add1f26
fix: serialize the remaining session access paths in the SDK
7Sageer Jul 31, 2026
9a95e1c
fix: detach the external abort listener once the chat title request s…
7Sageer Jul 31, 2026
e7c397a
fix: harden the session close/archive and create/fork lifecycle
7Sageer Jul 31, 2026
939627c
fix: keep forced title regeneration independent and veto queued title…
7Sageer Jul 31, 2026
a9727a0
fix: carry the title state through the session index and klient contract
7Sageer Jul 31, 2026
f4445aa
fix: coalesce harness resumes, lock fork targets, and cover the title…
7Sageer Jul 31, 2026
0e89f70
fix: serve the canonical title state from the session index and versi…
7Sageer Jul 31, 2026
c2d7217
fix: let the newest title generation request win the write-back
7Sageer Jul 31, 2026
cd1cea0
fix: fold archive into close and own the create/fork rollback
7Sageer Jul 31, 2026
7292ccc
fix: key harness resume coalescing by the full input
7Sageer Jul 31, 2026
1a23797
refactor(agent-core-v2): drop session close-awareness from title gene…
7Sageer Jul 31, 2026
7972c82
Delete .changeset/sdk-session-title-kind.md
7Sageer Jul 31, 2026
e11da34
refactor(session-title): drop the unused force regeneration path
7Sageer Jul 31, 2026
21945e6
refactor(agent-core-v2): drop the title state projection from the ses…
7Sageer Jul 31, 2026
6523e81
refactor(agent-core-v2): inline the transcript live-tail merge into m…
7Sageer Jul 31, 2026
9f65e2a
Merge remote-tracking branch 'origin/main' into auto-title
7Sageer Jul 31, 2026
67a240c
refactor(agent-core-v2): drop the closing registry from the session l…
7Sageer Jul 31, 2026
64099bd
Merge branch 'main' into auto-title
7Sageer Jul 31, 2026
65b7767
fix(agent-core-v2): complete the HostRequestHeaders migration in the …
7Sageer Jul 31, 2026
e56b878
fix(node-sdk): mark resume telemetry field ignored
7Sageer Jul 31, 2026
771a55f
feat(tui): request the session title as soon as a prompt is accepted
7Sageer Jul 31, 2026
bd10d5e
fix(agent-core-v2): drop the numbered user prefixes from the title re…
7Sageer Jul 31, 2026
bbcbc09
refactor(tui): ask for the session title only once per session attach
7Sageer Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/auto-session-title.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@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.
41 changes: 41 additions & 0 deletions apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import type {
GoalChange,
GoalUpdatedEvent,
HookResultEvent,
KimiHarness,
Session,
SessionMetaUpdatedEvent,
SessionTitleKind,
SkillActivatedEvent,
PluginCommandActivatedEvent,
ThinkingDeltaEvent,
Expand Down Expand Up @@ -94,6 +96,7 @@ export interface SessionEventHost {
aborted: boolean;
sessionEventUnsubscribe: (() => void) | undefined;
readonly streamingUI: StreamingUIController;
readonly harness: KimiHarness;

requireSession(): Session;
setAppState(patch: Partial<AppState>): void;
Expand Down Expand Up @@ -162,6 +165,7 @@ export class SessionEventHandler {
private queuedGoalPromotionPending = false;
private queuedGoalPromotionInFlight = false;
private queuedGoalPromotionTimer: ReturnType<typeof setTimeout> | undefined;
private titleGenerationDisabled = false;

resetRuntimeState(): void {
this.backgroundTasks.clear();
Expand All @@ -180,6 +184,7 @@ export class SessionEventHandler {
this.queuedGoalPromotionPending = false;
this.queuedGoalPromotionInFlight = false;
this.clearQueuedGoalPromotionTimer();
this.titleGenerationDisabled = false;
this.stopAllMcpServerStatusSpinners();
}

Expand Down Expand Up @@ -387,6 +392,37 @@ 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);
Expand Down Expand Up @@ -891,6 +927,11 @@ 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 {
Expand Down
14 changes: 10 additions & 4 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1357,10 +1357,15 @@ export class KimiTUI {
});
return;
}
void session.prompt(sdkInput).catch((error: unknown) => {
const message = formatErrorMessage(error);
this.failSessionRequest(`Failed to send: ${message}`);
});
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}`);
},
);
}

sendSkillActivation(session: Session, skillName: string, skillArgs: string): void {
Expand Down Expand Up @@ -1613,6 +1618,7 @@ export class KimiTUI {
sessionTitle: session.summary?.title ?? null,
goal: goalResult.goal,
});
this.sessionEventHandler.syncTitleGenerationGate(session.summary?.titleKind);
this.syncAdditionalDirs(session);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => {
Object.assign(host.state.appState, patch);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
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<string | undefined> } = {}) {
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<string | undefined> => undefined],
['applied', async (): Promise<string | undefined> => '生成的标题'],
[
'rejected',
async (): Promise<string | undefined> => {
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();
});
});
26 changes: 26 additions & 0 deletions apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ function makeHarness(session = makeSession(), overrides: Record<string, unknown>
return interactiveAgentScope.run(agentId, fn);
}),
getExperimentalFeatures: vi.fn(async () => []),
generateSessionTitle: vi.fn(async () => undefined),
auth: {
status: vi.fn(),
login: vi.fn(),
Expand Down Expand Up @@ -1202,6 +1203,31 @@ 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 () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@ export interface SessionStateSnapshot {
readonly id: string;
readonly version?: number;
readonly title?: string;
readonly isCustomTitle?: boolean;
readonly titleKind?: 'replaceable' | 'generated' | 'custom';
readonly lastPrompt?: string;
readonly createdAt: number;
readonly updatedAt: number;
Expand Down
13 changes: 8 additions & 5 deletions packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
*/

import type { IEventService } from '#/app/event/event';
import type { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import type {
ISessionMetadata,
SessionTitleKind,
} from '#/session/sessionMetadata/sessionMetadata';

import {
promptMetadataTextFromContentParts,
Expand Down Expand Up @@ -60,12 +63,12 @@ export async function applyPromptMetadataUpdate(
): Promise<void> {
if (text === undefined) return;
const current = await target.metadata.read();
const patch: { lastPrompt: string; title?: string; isCustomTitle?: boolean } = {
const patch: { lastPrompt: string; title?: string; titleKind?: SessionTitleKind } = {
lastPrompt: text,
};
if (!current.isCustomTitle && isUntitled(current.title)) {
if (current.titleKind !== 'custom' && isUntitled(current.title)) {
patch.title = titleFromPromptMetadataText(text);
patch.isCustomTitle = false;
patch.titleKind = 'replaceable';
}
await target.metadata.update(patch);
target.eventService.publish({
Expand All @@ -76,7 +79,7 @@ export async function applyPromptMetadataUpdate(
title: patch.title,
patch: {
title: patch.title,
isCustomTitle: patch.isCustomTitle,
isCustomTitle: patch.titleKind === undefined ? undefined : false,
lastPrompt: text,
},
},
Expand Down
Loading
Loading