mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-31 12:05:17 +00:00
refactor(tui): ask for the session title only once per session attach
This commit is contained in:
parent
bd10d5ef06
commit
bbcbc0929c
2 changed files with 45 additions and 116 deletions
|
|
@ -166,7 +166,6 @@ export class SessionEventHandler {
|
|||
private queuedGoalPromotionInFlight = false;
|
||||
private queuedGoalPromotionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private titleGenerationDisabled = false;
|
||||
private titleGenerationEpoch = 0;
|
||||
|
||||
resetRuntimeState(): void {
|
||||
this.backgroundTasks.clear();
|
||||
|
|
@ -185,7 +184,6 @@ export class SessionEventHandler {
|
|||
this.queuedGoalPromotionPending = false;
|
||||
this.queuedGoalPromotionInFlight = false;
|
||||
this.clearQueuedGoalPromotionTimer();
|
||||
this.titleGenerationEpoch += 1;
|
||||
this.titleGenerationDisabled = false;
|
||||
this.stopAllMcpServerStatusSpinners();
|
||||
}
|
||||
|
|
@ -391,15 +389,14 @@ export class SessionEventHandler {
|
|||
}
|
||||
}
|
||||
this.pluginMcpToolsUsedInTurn.clear();
|
||||
this.requestSessionTitleGeneration();
|
||||
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 later turns skip
|
||||
* the internally no-op generation call. Only ever closes the gate —
|
||||
* 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 {
|
||||
|
|
@ -409,34 +406,21 @@ export class SessionEventHandler {
|
|||
}
|
||||
|
||||
/**
|
||||
* Best-effort auto title: right after a prompt is accepted by the engine
|
||||
* (and again after each completed turn), ask the engine to generate a
|
||||
* title from the first prompts until one lands. The engine
|
||||
* overwrites the prompt-derived easy title but never a custom title
|
||||
* (enforced server-side), and dedupes in-flight requests. A resolved string
|
||||
* means a title was applied — stop asking so later turns don't regenerate
|
||||
* over it; a resolved `undefined` (no managed login / no prompt yet / a
|
||||
* custom title) just retries on the next turn; a rejection (v1 engine,
|
||||
* dead RPC) disables further attempts for this session. The generated
|
||||
* title lands through the regular `session.meta.updated` event.
|
||||
* 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;
|
||||
const epoch = this.titleGenerationEpoch;
|
||||
const isCurrentGeneration = () =>
|
||||
epoch === this.titleGenerationEpoch && sessionId === this.host.state.appState.sessionId;
|
||||
void this.host.harness.generateSessionTitle({ id: sessionId }).then(
|
||||
(title) => {
|
||||
if (!isCurrentGeneration()) return;
|
||||
if (title !== undefined) this.titleGenerationDisabled = true;
|
||||
},
|
||||
() => {
|
||||
if (!isCurrentGeneration()) return;
|
||||
this.titleGenerationDisabled = true;
|
||||
},
|
||||
);
|
||||
this.titleGenerationDisabled = true;
|
||||
void this.host.harness.generateSessionTitle({ id: sessionId }).catch(() => undefined);
|
||||
}
|
||||
|
||||
private handleStepBegin(event: TurnStepStartedEvent): void {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ 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<string | undefined> } = {}) {
|
||||
function makeHost(options: { generateTitle?: () => Promise<string | undefined> } = {}) {
|
||||
const harness = {
|
||||
generateSessionTitle: vi.fn(options.generateTitle ?? (async () => undefined)),
|
||||
};
|
||||
|
|
@ -11,7 +11,7 @@ function makeHost(options: { sessionTitle?: string | null; generateTitle?: () =>
|
|||
state: {
|
||||
appState: {
|
||||
sessionId: 's1',
|
||||
sessionTitle: options.sessionTitle ?? null,
|
||||
sessionTitle: null,
|
||||
workDir: '/tmp/work',
|
||||
streamingPhase: 'waiting',
|
||||
model: 'kimi-model',
|
||||
|
|
@ -75,123 +75,68 @@ async function flushMicrotasks() {
|
|||
}
|
||||
|
||||
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('requests a title after a turn ends even when an easy title exists', () => {
|
||||
const { host, harness } = makeHost({ sessionTitle: '首条 prompt 的截断标题' });
|
||||
const handler = new SessionEventHandler(host);
|
||||
|
||||
handler.handleEvent(turnEndedEvent(), vi.fn());
|
||||
|
||||
expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' });
|
||||
});
|
||||
|
||||
it('stops requesting after a title was generated', async () => {
|
||||
const { host, harness } = makeHost({ generateTitle: async () => '生成的标题' });
|
||||
const handler = new SessionEventHandler(host);
|
||||
|
||||
handler.handleEvent(turnEndedEvent(), vi.fn());
|
||||
await flushMicrotasks();
|
||||
handler.handleEvent(turnEndedEvent(), vi.fn());
|
||||
|
||||
expect(harness.generateSessionTitle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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');
|
||||
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.handleEvent(turnEndedEvent(), vi.fn());
|
||||
handler.requestSessionTitleGeneration();
|
||||
await flushMicrotasks();
|
||||
handler.handleEvent(turnEndedEvent(), vi.fn());
|
||||
handler.requestSessionTitleGeneration();
|
||||
|
||||
expect(harness.generateSessionTitle).toHaveBeenCalledTimes(1);
|
||||
|
||||
handler.resetRuntimeState();
|
||||
handler.handleEvent(turnEndedEvent(), vi.fn());
|
||||
expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2);
|
||||
expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' });
|
||||
});
|
||||
|
||||
it('ignores a generated title from the previous session after runtime reset', async () => {
|
||||
let resolveTitle: ((title: string | undefined) => void) | undefined;
|
||||
const pendingTitle = new Promise<string | undefined>((resolve) => {
|
||||
resolveTitle = resolve;
|
||||
});
|
||||
it('does not request a title when a turn ends', () => {
|
||||
const { host, harness } = makeHost();
|
||||
harness.generateSessionTitle.mockReturnValueOnce(pendingTitle);
|
||||
const handler = new SessionEventHandler(host);
|
||||
|
||||
handler.handleEvent(turnEndedEvent('s1'), vi.fn());
|
||||
host.state.appState.sessionId = 's2';
|
||||
handler.resetRuntimeState();
|
||||
resolveTitle?.('s1 generated title');
|
||||
await flushMicrotasks();
|
||||
handler.handleEvent(turnEndedEvent('s2'), vi.fn());
|
||||
|
||||
expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2);
|
||||
expect(harness.generateSessionTitle).toHaveBeenLastCalledWith({ id: 's2' });
|
||||
});
|
||||
|
||||
it('ignores a rejection from the previous runtime after same-session reset', async () => {
|
||||
let rejectTitle: ((error: Error) => void) | undefined;
|
||||
const pendingTitle = new Promise<string | undefined>((_resolve, reject) => {
|
||||
rejectTitle = reject;
|
||||
});
|
||||
const { host, harness } = makeHost();
|
||||
harness.generateSessionTitle.mockReturnValueOnce(pendingTitle);
|
||||
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();
|
||||
rejectTitle?.(new Error('stale request failed'));
|
||||
await flushMicrotasks();
|
||||
handler.handleEvent(turnEndedEvent(), vi.fn());
|
||||
handler.requestSessionTitleGeneration();
|
||||
|
||||
expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(['generated', 'custom'] as const)(
|
||||
'stops requesting when the resumed session already has a %s title',
|
||||
'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.handleEvent(turnEndedEvent(), vi.fn());
|
||||
handler.requestSessionTitleGeneration();
|
||||
|
||||
expect(harness.generateSessionTitle).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['replaceable', undefined] as const)(
|
||||
'keeps requesting when the resumed title state is %s',
|
||||
'requests when the resumed title state is %s',
|
||||
(titleKind) => {
|
||||
const { host, harness } = makeHost();
|
||||
const handler = new SessionEventHandler(host);
|
||||
|
||||
handler.syncTitleGenerationGate(titleKind);
|
||||
handler.handleEvent(turnEndedEvent(), vi.fn());
|
||||
handler.requestSessionTitleGeneration();
|
||||
|
||||
expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' });
|
||||
},
|
||||
|
|
@ -203,7 +148,7 @@ describe('session auto title generation', () => {
|
|||
|
||||
handler.syncTitleGenerationGate('generated');
|
||||
handler.resetRuntimeState();
|
||||
handler.handleEvent(turnEndedEvent(), vi.fn());
|
||||
handler.requestSessionTitleGeneration();
|
||||
|
||||
expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' });
|
||||
});
|
||||
|
|
@ -222,7 +167,7 @@ describe('session auto title generation', () => {
|
|||
} as const,
|
||||
vi.fn(),
|
||||
);
|
||||
handler.handleEvent(turnEndedEvent(), vi.fn());
|
||||
handler.requestSessionTitleGeneration();
|
||||
|
||||
expect(harness.generateSessionTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue