refactor(tui): ask for the session title only once per session attach

This commit is contained in:
7Sageer 2026-07-31 19:45:41 +08:00
parent bd10d5ef06
commit bbcbc0929c
2 changed files with 45 additions and 116 deletions

View file

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

View file

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