From 6ec995d49be393529475dd6cc54bdeb52e37d1ff Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:56:47 +0800 Subject: [PATCH] fix(session-title): keep the first output line and bound the cap by the request Claude wraps some titles in asterisk emphasis and writes the title, a blank line, then an explanation when the messages say little. Joining the text blocks made that explanation part of the saved title. Take the first non-empty line, drop one emphasis pair around it, and ask the model for a short best-effort title instead of an explanation. The dispatched cap is now the smaller of the configured cap and the cap the session's current main request recorded, so a route the deployment capped below the shipped ceiling is never asked for more than that cap. --- .../session/session-title-llm/src/index.ts | 43 ++++++++++-- .../session-title-llm/tests/llm.spec.ts | 70 ++++++++++++++++++- 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/packages/session/session-title-llm/src/index.ts b/packages/session/session-title-llm/src/index.ts index 837160a50d..02f69e2d19 100644 --- a/packages/session/session-title-llm/src/index.ts +++ b/packages/session/session-title-llm/src/index.ts @@ -64,7 +64,7 @@ export interface SessionTitleLlmConfig { readonly targetCjkCharacters: number /** Maximum UTF-8 bytes in the final JSON-framed user prompt. */ readonly maxInputBytes: number - /** Auxiliary generation output-token cap. */ + /** Auxiliary generation output-token cap, never above the cap the session's current request recorded. */ readonly maxOutputTokens: number /** End-to-end auxiliary request deadline in milliseconds. */ readonly timeoutMs: number @@ -191,6 +191,23 @@ function resolveRoute( return request.route } +/** + * Bound the configured output cap by the cap the session's current main + * request materialized, so a route the deployment capped below the configured + * value is never asked for more. A session whose header records no cap leaves + * the configured cap unchanged. + * @param config - validated model-provider policy. + * @param request - service-owned session of this title request. + * @returns the smaller of the configured cap and the logged request cap. + */ +function titleMaxTokens( + config: ResolvedSessionTitleLlmConfig, + request: SessionTitleProviderRequest, +): number { + const materialized = request.session.requestHeader()?.config.maxTokens + return materialized === undefined ? config.maxOutputTokens : Math.min(config.maxOutputTokens, materialized) +} + /** Stable language-aware system instruction shared by both provider plugins. */ function systemPrompt(config: ResolvedSessionTitleLlmConfig): string { return [ @@ -198,9 +215,26 @@ function systemPrompt(config: ResolvedSessionTitleLlmConfig): string { 'Return only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.', 'Use the language of the messages.', `Aim for about ${config.targetWords} words in non-CJK languages or ${config.targetCjkCharacters} CJK characters.`, + 'If the messages give little to name, still return a short best-effort title, such as Greeting, instead of explaining.', ].join('\n') } +/** Asterisk emphasis wrapping a whole line, such as `**Title**`. */ +const EMPHASIS_WRAPPER = /^(?\*{1,3})(?\S(?:.*\S)?)\k$/u + +/** + * Take the title from model output: the first non-empty line, without + * asterisk emphasis that wraps that whole line. Models that disregard the + * one-line instruction put the title first and commentary after it. + */ +function titleFromOutput(text: string): string { + const line = text.split(/\r?\n/u).map(item => item.trim()).find(item => item.length > 0) ?? '' + const groups = EMPHASIS_WRAPPER.exec(line)?.groups + const marker = groups?.['marker'] + const inner = groups?.['inner'] + return marker === undefined || inner === undefined || inner.includes(marker) ? line : inner +} + /** Frame exact messages as JSON so user text cannot break structural delimiters. */ function frameMessages(messages: readonly SessionTitleUserMessage[]): string { return `Generate the session title from this JSON array of human messages:\n${JSON.stringify(messages)}` @@ -258,12 +292,13 @@ export async function generateSessionTitleWithLlm( })] const system = systemPrompt(config) using callDeadline = deadline(request.signal, config.timeoutMs, SESSION_TITLE_TIMEOUT_CODE) + const maxTokens = titleMaxTokens(config, request) const options: GenerateOptions = deepFreeze({ provider: route.provider, model: route.model, messages, system, - maxTokens: config.maxOutputTokens, + maxTokens, sessionId: request.session.id, purpose: 'session-title', signal: callDeadline.signal, @@ -274,7 +309,7 @@ export async function generateSessionTitleWithLlm( route, system, messages, - maxTokens: config.maxOutputTokens, + maxTokens, }) callDeadline.signal.throwIfAborted() const assembler = new BlockAssembler() @@ -293,7 +328,7 @@ export async function generateSessionTitleWithLlm( .filter((block): block is Extract<(typeof blocks)[number], { type: 'text' }> => block.type === 'text') .map(block => block.text) .join(' ') - const title = normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER) + const title = normalizeSessionTitle(titleFromOutput(text), Number.MAX_SAFE_INTEGER) if (title.length === 0) throw new Error('session-title-llm: title model produced no text') return { title, diff --git a/packages/session/session-title-llm/tests/llm.spec.ts b/packages/session/session-title-llm/tests/llm.spec.ts index 240ddefa2b..17f746915c 100644 --- a/packages/session/session-title-llm/tests/llm.spec.ts +++ b/packages/session/session-title-llm/tests/llm.spec.ts @@ -76,11 +76,21 @@ const CONFIG = { const TITLE_PROVIDER = SessionTitleProviderId('test-title-provider') let nextSession = 0 -function request(ctx: Context, signal = new AbortController().signal): SessionTitleProviderRequest { +function request( + ctx: Context, + signal = new AbortController().signal, + headerMaxTokens?: number, +): SessionTitleProviderRequest { const session = ctx.sessions.create(SessionId(`title-call-${++nextSession}`)) session.append('turn/start', { turn: 1, }) + if (headerMaxTokens !== undefined) { + session.append('request/header', { + header: { config: { provider: 'current-route', model: 'current-model', maxTokens: headerMaxTokens } }, + reason: 'initial', + }) + } const first = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'first prompt' }], source: { kind: 'user' }, @@ -159,6 +169,7 @@ describe('generateSessionTitleWithLlm', () => { }) expect(options.system).toContain('5 words') expect(options.system).toContain('10 CJK characters') + expect(options.system).toContain('still return a short best-effort title') const prompt = options.messages[0]?.content[0] expect(prompt?.type === 'text' && prompt.text).toContain('first prompt') expect(prompt?.type === 'text' && prompt.text).toContain('第二个问题') @@ -173,6 +184,38 @@ describe('generateSessionTitleWithLlm', () => { }) }) + it('bounds the dispatched output cap by a smaller cap the session request recorded', async () => { + const { ctx, adapter } = await withScript(SCRIPT) + const providerRequest = request(ctx, undefined, 8) + + await expect(generateSessionTitleWithLlm( + ctx, + resolveSessionTitleLlmConfig(CONFIG), + providerRequest, + providerRequest.messages, + TITLE_PROVIDER, + )).resolves.toMatchObject({ title: '五个字标题' }) + + expect(adapter.requests[0]?.maxTokens).toBe(8) + expect(providerRequest.session.snapshotEvents() + .findLast(event => event.type === 'session/title-llm-request')?.data.maxTokens).toBe(8) + }) + + it('keeps the configured output cap when the session request recorded a larger one', async () => { + const { ctx, adapter } = await withScript(SCRIPT) + const providerRequest = request(ctx, undefined, 4_096) + + await expect(generateSessionTitleWithLlm( + ctx, + resolveSessionTitleLlmConfig(CONFIG), + providerRequest, + providerRequest.messages, + TITLE_PROVIDER, + )).resolves.toMatchObject({ title: '五个字标题' }) + + expect(adapter.requests[0]?.maxTokens).toBe(32) + }) + it('uses paired explicit overrides and bounds the final framed input before model dispatch', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -280,6 +323,31 @@ describe('generateSessionTitleWithLlm', () => { )).rejects.toThrow(error) }) + it('takes the title from the first non-empty output line without wrapping emphasis', async () => { + const cases: [readonly string[], string][] = [ + [['**Continuing Previous', ' Session**\n\nThe only message is "continue".'], 'Continuing Previous Session'], + [['\n *Greeting* \n'], 'Greeting'], + [['**a** and **b**\nnote'], '**a** and **b**'], + [['Use **bold** for emphasis'], 'Use **bold** for emphasis'], + [['****'], '****'], + ] + for (const [deltas, title] of cases) { + const { ctx } = await withScript([ + { type: 'block-start', index: 0, blockType: 'text' }, + ...deltas.map((text): StreamChunk => ({ type: 'text-delta', index: 0, text })), + { type: 'finish', reason: { kind: 'stop' } }, + ]) + const providerRequest = request(ctx) + await expect(generateSessionTitleWithLlm( + ctx, + resolveSessionTitleLlmConfig(CONFIG), + providerRequest, + providerRequest.messages, + TITLE_PROVIDER, + )).resolves.toMatchObject({ title }) + } + }) + it('rejects tool-call blocks and a successful response with no text', async () => { const toolScript: StreamChunk[] = [ { type: 'block-start', index: 0, blockType: 'tool-call' },