mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-22 15:16:07 +00:00
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:
parent
bbcbc0929c
commit
b3cb0e8b24
8 changed files with 9 additions and 258 deletions
|
|
@ -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.
|
||||
5
.changeset/session-title-generation.md
Normal file
5
.changeset/session-title-generation.md
Normal 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.
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue