mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-22 23:26:12 +00:00
feat: request session title generation from the TUI after each turn
This commit is contained in:
parent
86b11635ac
commit
68ed623561
13 changed files with 231 additions and 0 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
13
packages/klient/src/contract/session/title.ts
Normal file
13
packages/klient/src/contract/session/title.ts
Normal 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;
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue