feat: generate session title from the first recorded prompts

Record up to three sanitized natural-language prompts in session
metadata (skill / plugin activations excluded) and compose the
chat_title input as order-labeled lines truncated to a 1000-char
budget, falling back to lastPrompt for sessions without recorded
prompts.
This commit is contained in:
7Sageer 2026-07-30 12:10:13 +08:00
parent f66a0c592f
commit 42fe810d63
9 changed files with 181 additions and 20 deletions

View file

@ -336,6 +336,7 @@ export interface SessionStateSnapshot {
readonly title?: string;
readonly isCustomTitle?: boolean;
readonly lastPrompt?: string;
readonly prompts?: readonly string[];
readonly createdAt: number;
readonly updatedAt: number;
readonly archived: boolean;
@ -1010,7 +1011,7 @@ export interface AgentStateSnapshot {
'llmRequester.lastConfigLogSignature': string | undefined;
'llmRequester.mediaDegradedTurns': Set<number>;
'llmRequester.mediaStrippedTurns': Map<number, /* MediaStripSnapshot — packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts */ {
readonly "__@mediaStripSnapshotBrand@2671": undefined;
readonly "__@mediaStripSnapshotBrand@2672": undefined;
}>;
'llmRequester.turnConfigs': Map<number, /* TurnRequestConfig — packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts */ {
readonly resolved: /* ProfileModelContext — packages/agent-core-v2/src/agent/profile/profile.ts */ {

View file

@ -3,12 +3,18 @@
*
* Derives title and last-prompt text from native and legacy prompt payloads,
* persists metadata through `sessionMetadata`, and publishes live updates
* through `event`. Shared by the native `rpc` prompt path and the v1 legacy
* prompt adapter so both surfaces keep the same easy-title behavior.
* through `event`. Natural-language prompts also append to the metadata's
* bounded `prompts` list (the title-generation input); skill / plugin
* activations only refresh `lastPrompt`. Shared by the native `rpc` prompt
* path and the v1 legacy prompt adapter so both surfaces keep the same
* easy-title behavior.
*/
import type { IEventService } from '#/app/event/event';
import type { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import {
type ISessionMetadata,
SESSION_META_PROMPT_LIMIT,
} from '#/session/sessionMetadata/sessionMetadata';
import {
promptMetadataTextFromContentParts,
@ -58,16 +64,28 @@ export interface PromptMetadataUpdateTarget {
export async function applyPromptMetadataUpdate(
target: PromptMetadataUpdateTarget,
text: string | undefined,
recordPrompt = false,
): Promise<void> {
if (text === undefined) return;
const current = await target.metadata.read();
const patch: { lastPrompt: string; title?: string; isCustomTitle?: boolean } = {
const patch: {
lastPrompt: string;
title?: string;
isCustomTitle?: boolean;
prompts?: readonly string[];
} = {
lastPrompt: text,
};
if (!current.isCustomTitle && isUntitled(current.title)) {
patch.title = titleFromPromptMetadataText(text);
patch.isCustomTitle = false;
}
// Keep the session's first few natural-language prompts for title
// generation. Skill / plugin activations pass recordPrompt=false so command
// text never becomes title input.
if (recordPrompt && (current.prompts?.length ?? 0) < SESSION_META_PROMPT_LIMIT) {
patch.prompts = [...(current.prompts ?? []), text];
}
await target.metadata.update(patch);
target.eventService.publish({
type: 'session.meta.updated',

View file

@ -95,7 +95,7 @@ export class AgentRPCService implements IAgentRPCService {
throw error;
}
}
await this.updatePromptMetadata(promptMetadataTextFromPayload(payload));
await this.updatePromptMetadata(promptMetadataTextFromPayload(payload), true);
const handle = await this.promptService.enqueue({ message: {
role: 'user',
content: [...payload.input],
@ -204,7 +204,10 @@ export class AgentRPCService implements IAgentRPCService {
await this.updatePromptMetadata(promptMetadataTextFromPluginCommand(payload));
}
private async updatePromptMetadata(text: string | undefined): Promise<void> {
private async updatePromptMetadata(
text: string | undefined,
recordPrompt = false,
): Promise<void> {
await applyPromptMetadataUpdate(
{
metadata: this.metadata,
@ -212,6 +215,7 @@ export class AgentRPCService implements IAgentRPCService {
sessionId: this.sessionContext.sessionId,
},
text,
recordPrompt,
);
}

View file

@ -25,12 +25,19 @@ export interface AgentMeta {
export const SESSION_META_VERSION = 2;
/** How many of the session's first natural-language prompts are kept. */
export const SESSION_META_PROMPT_LIMIT = 3;
export interface SessionMeta {
readonly id: string;
readonly version?: number;
readonly title?: string;
readonly isCustomTitle?: boolean;
readonly lastPrompt?: string;
/** The session's first sanitized prompt texts (bounded by
* `SESSION_META_PROMPT_LIMIT`), recorded for title generation. Absent on
* documents written before the recording existed. */
readonly prompts?: readonly string[];
readonly createdAt: number;
readonly updatedAt: number;
readonly archived: boolean;

View file

@ -19,7 +19,7 @@ export const autoTitleFlag: FlagDefinitionInput = {
id: AUTO_TITLE_FLAG_ID,
title: 'Auto session title',
description:
'Generate the session title on demand from the first prompt through the managed chat_title tool. Requires a managed Kimi Code OAuth login.',
'Generate the session title on demand from the first prompts through the managed chat_title tool. Requires a managed Kimi Code OAuth login.',
env: AUTO_TITLE_FLAG_ENV,
default: false,
surface: 'both',

View file

@ -2,8 +2,8 @@
* `sessionTitle` domain (L6) session title generation contract.
*
* Defines the `ISessionTitleService` that (re)generates the session's title
* on demand from its first prompt through the managed platform's `chat_title`
* tool. Bound at Session scope one instance per session.
* on demand from its first prompts through the managed platform's
* `chat_title` tool. Bound at Session scope one instance per session.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';

View file

@ -1,9 +1,10 @@
/**
* `sessionTitle` domain (L6) `ISessionTitleService` implementation.
*
* Generates the session's title from its already-sanitized first prompt
* (`sessionMetadata`'s `lastPrompt`, secrets redacted by the prompt-metadata
* flow) through the managed platform `/tools` `chat_title` endpoint, persists
* Generates the session's title from its already-sanitized first prompts
* (`sessionMetadata`'s bounded `prompts` list, secrets redacted by the
* prompt-metadata flow; `lastPrompt` as the fallback for older documents)
* through the managed platform `/tools` `chat_title` endpoint, persists
* it through `sessionMetadata`, and rebroadcasts `session.meta.updated`.
* Generation is on demand only: `generateTitle()` is the single entry point
* (the kap-server route), gated by the `auto-title` experimental flag and a
@ -32,13 +33,19 @@ import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
import { IProviderService } from '#/kosong/provider/provider';
import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import {
ISessionMetadata,
type SessionMeta,
} from '#/session/sessionMetadata/sessionMetadata';
import { AUTO_TITLE_FLAG_ID } from './flag';
import { ISessionTitleService } from './sessionTitle';
const MAX_GENERATED_TITLE_LENGTH = 200;
/** Total budget for the composed prompt texts sent to `chat_title`. */
const MAX_TITLE_INPUT_LENGTH = 1000;
export class SessionTitleService implements ISessionTitleService {
declare readonly _serviceBrand: undefined;
@ -58,8 +65,9 @@ export class SessionTitleService implements ISessionTitleService {
async generateTitle(): Promise<string | undefined> {
if (!this.flags.enabled(AUTO_TITLE_FLAG_ID)) return undefined;
const current = await this.metadata.read();
if (current.lastPrompt === undefined || hasCustomTitle(current)) return undefined;
return this.generateAndApply(current.lastPrompt);
const input = titleInputFromMeta(current);
if (input === undefined || hasCustomTitle(current)) return undefined;
return this.generateAndApply(input);
}
private async generateAndApply(chatContent: string): Promise<string | undefined> {
@ -103,7 +111,7 @@ export class SessionTitleService implements ISessionTitleService {
const result = await fetchChatTitle(
kimiCodeToolsUrl(runtimeAuth.baseUrl),
token,
`user: ${chatContent}`,
chatContent,
{
headers: {
...parseKimiCodeCustomHeaders(),
@ -143,6 +151,25 @@ function hasCustomTitle(metadata: {
return metadata.isCustomTitle === true || typeof metadata.customTitle === 'string';
}
/**
* Composes the `chat_title` input from the session's first recorded prompts
* (order-labeled), falling back to `lastPrompt` for documents written before
* prompt recording existed. Truncated to the total budget, keeping the head.
*/
function titleInputFromMeta(meta: SessionMeta): string | undefined {
const prompts =
meta.prompts !== undefined && meta.prompts.length > 0
? meta.prompts
: meta.lastPrompt !== undefined
? [meta.lastPrompt]
: undefined;
if (prompts === undefined) return undefined;
return prompts
.map((prompt, index) => `user ${index + 1}: ${prompt}`)
.join('\n')
.slice(0, MAX_TITLE_INPUT_LENGTH);
}
registerScopedService(
LifecycleScope.Session,
ISessionTitleService,

View file

@ -7,12 +7,24 @@
* - an inline image-compression caption (harness metadata placed next to
* the image by prompt ingestion) never leaks into titles/lastPrompt,
* whether it is a standalone text part or merged into the user's text
* - natural-language prompts append to the bounded `prompts` metadata list
* (the title-generation input) while skill / plugin activations do not
*/
import { describe, expect, it } from 'vitest';
import { promptMetadataTextFromPayload } from '#/agent/rpc/prompt-metadata';
import {
applyPromptMetadataUpdate,
promptMetadataTextFromPayload,
type PromptMetadataUpdateTarget,
} from '#/agent/rpc/prompt-metadata';
import { buildImageCompressionCaption } from '#/agent/media/image-compress';
import type { IEventService } from '#/app/event/event';
import {
type ISessionMetadata,
type SessionMeta,
type SessionMetaPatch,
} from '#/session/sessionMetadata/sessionMetadata';
const CAPTION = buildImageCompressionCaption({
original: { width: 3264, height: 666, byteLength: 344 * 1024, mimeType: 'image/png' },
@ -53,3 +65,56 @@ describe('promptMetadataTextFromPayload', () => {
expect(text).not.toContain('Image compressed');
});
});
describe('applyPromptMetadataUpdate', () => {
function createTarget(initial: Partial<SessionMeta> = {}) {
let meta: SessionMeta = {
id: 'sess-1',
createdAt: 0,
updatedAt: 0,
archived: false,
...initial,
};
const target: PromptMetadataUpdateTarget = {
metadata: {
read: () => Promise.resolve(meta),
update: (patch: SessionMetaPatch) => {
meta = { ...meta, ...patch };
return Promise.resolve();
},
} as unknown as ISessionMetadata,
eventService: { publish: () => undefined } as unknown as IEventService,
sessionId: 'sess-1',
};
return { target, readMeta: () => meta };
}
it('records natural-language prompts into the bounded prompts list', async () => {
const { target, readMeta } = createTarget();
await applyPromptMetadataUpdate(target, '第一条', true);
await applyPromptMetadataUpdate(target, '第二条', true);
expect(readMeta().prompts).toEqual(['第一条', '第二条']);
expect(readMeta().lastPrompt).toBe('第二条');
expect(readMeta().title).toBe('第一条');
});
it('does not record skill / plugin activations', async () => {
const { target, readMeta } = createTarget();
await applyPromptMetadataUpdate(target, '/compact');
expect(readMeta().lastPrompt).toBe('/compact');
expect(readMeta().prompts).toBeUndefined();
});
it('stops recording once the prompt limit is reached', async () => {
const { target, readMeta } = createTarget({ prompts: ['一', '二', '三'] });
await applyPromptMetadataUpdate(target, '第四条', true);
expect(readMeta().prompts).toEqual(['一', '二', '三']);
expect(readMeta().lastPrompt).toBe('第四条');
});
});

View file

@ -184,7 +184,7 @@ describe('SessionTitleService', () => {
});
it('replaces the easy title with the generated one', async () => {
await metadata.update({ lastPrompt: '帮我看一下这个 Go 的 nil pointer 报错' });
await metadata.update({ prompts: ['帮我看一下这个 Go 的 nil pointer 报错'] });
const title = await ix.get(ISessionTitleService).generateTitle();
@ -195,7 +195,7 @@ describe('SessionTitleService', () => {
const [, init] = fetchMock.mock.calls[0]!;
expect(JSON.parse(init?.body as string)).toEqual({
method: 'chat_title',
params: { chat_content: 'user: 帮我看一下这个 Go 的 nil pointer 报错' },
params: { chat_content: 'user 1: 帮我看一下这个 Go 的 nil pointer 报错' },
});
expect(new Headers(init?.headers as Record<string, string>).get('authorization')).toBe(
'Bearer test-token',
@ -209,6 +209,45 @@ describe('SessionTitleService', () => {
expect(rebroadcast).toBeDefined();
});
it('composes the title input from the recorded prompts in order', async () => {
await metadata.update({
prompts: ['先帮我搭一个 Vite 项目', '加上路由', '现在配一下 ESLint'],
});
await ix.get(ISessionTitleService).generateTitle();
const [, init] = fetchMock.mock.calls[0]!;
expect(JSON.parse(init?.body as string)).toEqual({
method: 'chat_title',
params: {
chat_content: 'user 1: 先帮我搭一个 Vite 项目\nuser 2: 加上路由\nuser 3: 现在配一下 ESLint',
},
});
});
it('truncates the composed title input to the total budget, keeping the head', async () => {
await metadata.update({ prompts: ['很长的输入'.repeat(400), '第二条'] });
await ix.get(ISessionTitleService).generateTitle();
const [, init] = fetchMock.mock.calls[0]!;
const body = JSON.parse(init?.body as string) as { params: { chat_content: string } };
expect(body.params.chat_content.startsWith('user 1: 很长的输入')).toBe(true);
expect(body.params.chat_content).toHaveLength(1000);
});
it('falls back to lastPrompt for sessions without recorded prompts', async () => {
await metadata.update({ lastPrompt: '帮我看一下这个 Go 的 nil pointer 报错' });
await ix.get(ISessionTitleService).generateTitle();
const [, init] = fetchMock.mock.calls[0]!;
expect(JSON.parse(init?.body as string)).toEqual({
method: 'chat_title',
params: { chat_content: 'user 1: 帮我看一下这个 Go 的 nil pointer 报错' },
});
});
it('does nothing without a managed OAuth provider', async () => {
delete providers['managed:kimi-code'];
await metadata.update({ lastPrompt: 'hello' });