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.
This commit is contained in:
7Sageer 2026-08-03 11:01:17 +08:00
parent bbcbc0929c
commit b3cb0e8b24
8 changed files with 9 additions and 258 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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<AppState>): void;
@ -165,7 +162,6 @@ export class SessionEventHandler {
private queuedGoalPromotionPending = false;
private queuedGoalPromotionInFlight = false;
private queuedGoalPromotionTimer: ReturnType<typeof setTimeout> | 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 {

View file

@ -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);
}

View file

@ -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<string, unknown>) => {
Object.assign(host.state.appState, patch);

View file

@ -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 };
}

View file

@ -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<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();
});
});

View file

@ -282,7 +282,6 @@ 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(),
@ -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 () => {