feat: request session title generation from the TUI after each turn

This commit is contained in:
7Sageer 2026-07-30 15:56:35 +08:00
parent 86b11635ac
commit 68ed623561
13 changed files with 231 additions and 0 deletions

View file

@ -14,6 +14,7 @@ import type {
GoalChange,
GoalUpdatedEvent,
HookResultEvent,
KimiHarness,
Session,
SessionMetaUpdatedEvent,
SkillActivatedEvent,
@ -94,6 +95,7 @@ export interface SessionEventHost {
aborted: boolean;
sessionEventUnsubscribe: (() => void) | undefined;
readonly streamingUI: StreamingUIController;
readonly harness: KimiHarness;
requireSession(): Session;
setAppState(patch: Partial<AppState>): void;
@ -162,6 +164,7 @@ export class SessionEventHandler {
private queuedGoalPromotionPending = false;
private queuedGoalPromotionInFlight = false;
private queuedGoalPromotionTimer: ReturnType<typeof setTimeout> | undefined;
private titleGenerationDisabled = false;
resetRuntimeState(): void {
this.backgroundTasks.clear();
@ -180,6 +183,7 @@ export class SessionEventHandler {
this.queuedGoalPromotionPending = false;
this.queuedGoalPromotionInFlight = false;
this.clearQueuedGoalPromotionTimer();
this.titleGenerationDisabled = false;
this.stopAllMcpServerStatusSpinners();
}
@ -384,9 +388,29 @@ export class SessionEventHandler {
}
}
this.pluginMcpToolsUsedInTurn.clear();
this.requestSessionTitleGeneration();
this.scheduleQueuedGoalPromotion();
}
/**
* Best-effort auto title: while the session has no title yet, ask the
* engine to generate one from the first prompts after each completed turn.
* The engine keeps custom titles untouched and dedupes in-flight requests;
* a resolved `undefined` (no managed login / no prompt yet) just retries on
* the next turn, while a rejection (v1 engine, dead RPC) disables further
* attempts for this session. The generated title lands through the regular
* `session.meta.updated` event.
*/
private requestSessionTitleGeneration(): void {
if (this.titleGenerationDisabled) return;
const { sessionId, sessionTitle } = this.host.state.appState;
if (sessionId.length === 0) return;
if (typeof sessionTitle === 'string' && sessionTitle.trim().length > 0) return;
void this.host.harness.generateSessionTitle({ id: sessionId }).catch(() => {
this.titleGenerationDisabled = true;
});
}
private handleStepBegin(event: TurnStepStartedEvent): void {
this.host.streamingUI.flushNow();
this.host.streamingUI.setStep(event.step);

View file

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

View file

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

View file

@ -0,0 +1,124 @@
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> } = {}) {
const harness = {
generateSessionTitle: vi.fn(options.generateTitle ?? (async () => undefined)),
};
const host = {
state: {
appState: {
sessionId: 's1',
sessionTitle: options.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() {
return {
type: 'turn.ended',
sessionId: 's1',
agentId: 'main',
turnId: 1,
reason: 'completed',
} as const;
}
async function flushMicrotasks() {
await new Promise((resolve) => setTimeout(resolve, 0));
}
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('does not request a title when the session already has one', () => {
const { host, harness } = makeHost({ sessionTitle: 'custom title' });
const handler = new SessionEventHandler(host);
handler.handleEvent(turnEndedEvent(), vi.fn());
expect(harness.generateSessionTitle).not.toHaveBeenCalled();
});
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');
},
});
const handler = new SessionEventHandler(host);
handler.handleEvent(turnEndedEvent(), vi.fn());
await flushMicrotasks();
handler.handleEvent(turnEndedEvent(), vi.fn());
expect(harness.generateSessionTitle).toHaveBeenCalledTimes(1);
handler.resetRuntimeState();
handler.handleEvent(turnEndedEvent(), vi.fn());
expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2);
});
});

View file

@ -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(),

View file

@ -33,6 +33,7 @@ import { sessionInteractionContract } from './session/interaction.js';
import { sessionLifecycleContract } from './session/lifecycle.js';
import { sessionMetadataContract } from './session/metadata.js';
import { sessionQuestionContract } from './session/question.js';
import { sessionTitleContract } from './session/title.js';
export const globalContract: KlientContract = {
// core (app scope)
@ -55,6 +56,7 @@ export const globalContract: KlientContract = {
sessionInteractionService: sessionInteractionContract,
sessionApprovalService: sessionApprovalContract,
sessionQuestionService: sessionQuestionContract,
sessionTitleService: sessionTitleContract,
// agent scope
agentRPCService: agentRpcContract,
agentActivityView: agentActivityViewContract,

View file

@ -0,0 +1,13 @@
/**
* `sessionTitleService` on-demand session title generation. Mirrors
* `agent-core-v2/session/sessionTitle/sessionTitle.ts`.
*/
import { z } from 'zod';
import { maybe } from '../helpers.js';
import type { ServiceContract } from '../types.js';
export const sessionTitleContract = {
generateTitle: { input: z.tuple([]), output: maybe(z.string()) },
} satisfies ServiceContract;

View file

@ -63,6 +63,12 @@ export type SessionStatus = 'running' | 'idle' | 'awaiting_approval' | 'awaiting
export interface SessionFacade {
get(): Promise<SessionMeta>;
setTitle(title: string): Promise<void>;
/**
* Generate and apply a title from the main agent's first prompts via the
* managed `chat_title` tool. `undefined` when generation is unavailable
* (no managed OAuth login, no prompt yet, or a custom title is set).
*/
generateTitle(): Promise<string | undefined>;
update(patch: SessionMetaPatch): Promise<void>;
setArchived(archived: boolean): Promise<void>;
status(): Promise<SessionStatus>;
@ -99,6 +105,8 @@ export function createSessionFacade(call: ScopedCaller, sessionId: string): Sess
return {
get: read,
setTitle: (title) => call(scope, 'sessionMetadata', 'setTitle', [title]) as Promise<void>,
generateTitle: () =>
call(scope, 'sessionTitleService', 'generateTitle', []) as Promise<string | undefined>,
update: (patch) => call(scope, 'sessionMetadata', 'update', [patch]) as Promise<void>,
setArchived: (archived) =>
call(scope, 'sessionMetadata', 'setArchived', [archived]) as Promise<void>,

View file

@ -27,6 +27,7 @@ import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMeta
import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/interaction/interaction';
import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval';
import { ISessionQuestionService } from '@moonshot-ai/agent-core-v2/session/question/question';
import { ISessionTitleService } from '@moonshot-ai/agent-core-v2/session/sessionTitle/sessionTitle';
import { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc';
import { IAgentActivityView } from '@moonshot-ai/agent-core-v2/agent/activityView/activityView';
import { IAgentPlanService } from '@moonshot-ai/agent-core-v2/agent/plan/plan';
@ -55,6 +56,7 @@ export const serviceTokens: Readonly<Record<string, ServiceIdentifier<unknown>>>
sessionInteractionService: ISessionInteractionService,
sessionApprovalService: ISessionApprovalService,
sessionQuestionService: ISessionQuestionService,
sessionTitleService: ISessionTitleService,
agentRPCService: IAgentRPCService,
agentActivityView: IAgentActivityView,
agentShellCommandService: IAgentShellCommandService,

View file

@ -17,6 +17,7 @@ import type {
ExportSessionInput,
ExportSessionResult,
ForkSessionInput,
GenerateSessionTitleInput,
GetConfigOptions,
KimiConfig,
KimiConfigPatch,
@ -237,6 +238,19 @@ export class KimiHarness {
this.activeSessions.get(input.id)?.emitMetaUpdated({ title: input.title });
}
/**
* Generate and apply a session title from the main agent's first prompts
* (v2 engine only). Resolves to `undefined` when generation is unavailable
* and the current title is kept.
*/
async generateSessionTitle(input: GenerateSessionTitleInput): Promise<string | undefined> {
const title = await this.rpc.generateSessionTitle(input);
if (title !== undefined) {
this.activeSessions.get(input.id)?.emitMetaUpdated({ title });
}
return title;
}
async exportSession(input: ExportSessionInput): Promise<ExportSessionResult> {
const result = await this.rpc.exportSession({
...input,

View file

@ -2,6 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
import {
ErrorCodes,
KimiError,
makeErrorPayload,
type AgentContextData,
type ApprovalRequest,
@ -32,6 +33,7 @@ import type {
ExportSessionResult,
CreateGoalInput,
ForkSessionInput,
GenerateSessionTitleInput,
GetConfigOptions,
McpServerConfig,
GoalSnapshot,
@ -228,6 +230,18 @@ export abstract class SDKRpcClientBase {
});
}
/**
* v2-only capability (`ISessionTitleService`); the v1 engine has no title
* generation, so the base fails loudly and `SDKRpcClientV2` overrides it.
*/
async generateSessionTitle(input: GenerateSessionTitleInput): Promise<string | undefined> {
void input;
throw new KimiError(
ErrorCodes.NOT_IMPLEMENTED,
'generateSessionTitle is only available on the agent-core-v2 engine.',
);
}
async exportSession(input: ExportSessionInput): Promise<ExportSessionResult> {
const rpc = await this.getRpc();
return rpc.exportSession({

View file

@ -256,6 +256,7 @@ import type {
ExportSessionInput,
ExportSessionResult,
ForkSessionInput,
GenerateSessionTitleInput,
GetConfigOptions,
GetCronTasksResult,
GoalSnapshot,
@ -995,6 +996,28 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
}
}
/**
* v2-only (`ISessionTitleService`, session scope). Like `renameSession`, a
* closed session is resumed, titled, and closed again so generation does
* not leak a live session. `undefined` means generation was unavailable
* (no managed OAuth login, no prompt yet, or a custom title is set) the
* current title is kept.
*/
override async generateSessionTitle(
input: GenerateSessionTitleInput,
): Promise<string | undefined> {
if (this.sessionLifecycle.get(input.id) !== undefined) {
return this.klient.session(input.id).generateTitle();
}
const handle = await this.sessionLifecycle.resume(input.id);
if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(input.id);
try {
return await this.klient.session(input.id).generateTitle();
} finally {
await this.sessionLifecycle.close(input.id);
}
}
/**
* Through `engineAccessor` (`ISessionLifecycleService.fork`) because the
* klient facade fork takes no explicit target id. Known gaps vs v1: the

View file

@ -133,6 +133,10 @@ export interface RenameSessionInput {
readonly title: string;
}
export interface GenerateSessionTitleInput {
readonly id: string;
}
export interface ResumeSessionInput {
readonly id: string;
readonly kaos?: Kaos | undefined;