fix: preserve automatic session title invariants

This commit is contained in:
7Sageer 2026-07-29 19:44:32 +08:00
parent 6ef7c260b8
commit 2162ec97da
12 changed files with 172 additions and 31 deletions

View file

@ -54,6 +54,7 @@ export interface ISessionMetadata {
read(): Promise<SessionMeta>;
update(patch: SessionMetaPatch): Promise<void>;
setTitle(title: string): Promise<void>;
setGeneratedTitleIfUncustomized(title: string): Promise<boolean>;
setArchived(archived: boolean): Promise<void>;
registerAgent(agentId: string, meta: AgentMeta): Promise<void>;
}

View file

@ -112,6 +112,15 @@ export class SessionMetadata extends Disposable implements ISessionMetadata {
await this.update({ title, isCustomTitle: true });
}
async setGeneratedTitleIfUncustomized(title: string): Promise<boolean> {
return this.enqueueUpdate(async () => {
await this.ready;
if (this.data.isCustomTitle === true) return false;
await this.applyUpdate({ title, isCustomTitle: false });
return true;
});
}
async setArchived(archived: boolean): Promise<void> {
await this.update({ archived });
}
@ -126,9 +135,12 @@ export class SessionMetadata extends Disposable implements ISessionMetadata {
});
}
private enqueueUpdate(work: () => Promise<void>): Promise<void> {
private enqueueUpdate<T>(work: () => Promise<T>): Promise<T> {
const run = this.updateQueue.then(work, work);
this.updateQueue = run.catch(() => {});
this.updateQueue = run.then(
() => undefined,
() => undefined,
);
return run;
}
@ -216,8 +228,14 @@ export function normalizeSessionMeta(raw: SessionMeta, sessionId: string): Sessi
: undefined);
const legacyCustomTitle =
typeof legacy.customTitle === 'string' ? legacy.customTitle : undefined;
const title = legacyCustomTitle ?? raw.title;
const isCustomTitle = legacyCustomTitle === undefined ? raw.isCustomTitle : true;
const hasModernTitleState =
typeof raw.title === 'string' && typeof raw.isCustomTitle === 'boolean';
const title = hasModernTitleState ? raw.title : (legacyCustomTitle ?? raw.title);
const isCustomTitle = hasModernTitleState
? raw.isCustomTitle
: legacyCustomTitle === undefined
? raw.isCustomTitle
: true;
if (raw.version === SESSION_META_VERSION) {
if (cwd === raw.cwd && title === raw.title && isCustomTitle === raw.isCustomTitle) {
return raw;

View file

@ -17,7 +17,7 @@
import {
KIMI_CODE_PROVIDER_NAME,
OAuthUnauthorizedError,
OAuthError,
fetchChatTitle,
kimiCodeToolsUrl,
parseKimiCodeCustomHeaders,
@ -119,7 +119,7 @@ export class SessionTitleService extends Disposable implements ISessionTitleServ
try {
token = await tokenProvider.getAccessToken();
} catch (error) {
if (!(error instanceof OAuthUnauthorizedError)) throw error;
if (!(error instanceof OAuthError)) throw error;
this.log.debug(`chat_title request unavailable: ${error.message}`);
return undefined;
}
@ -139,11 +139,9 @@ export class SessionTitleService extends Disposable implements ISessionTitleServ
this.log.debug(`chat_title request failed: ${result.message}`);
return undefined;
}
// The user may have renamed the session while the request was in flight.
const currentAfterRequest = await this.metadata.read();
if (hasCustomTitle(currentAfterRequest)) return undefined;
const title = result.title.slice(0, MAX_GENERATED_TITLE_LENGTH);
await this.metadata.update({ title, isCustomTitle: false });
const applied = await this.metadata.setGeneratedTitleIfUncustomized(title);
if (!applied) return undefined;
this.eventService.publish({
type: 'session.meta.updated',
payload: {
@ -158,9 +156,13 @@ export class SessionTitleService extends Disposable implements ISessionTitleServ
}
function hasCustomTitle(metadata: {
readonly isCustomTitle?: boolean;
readonly title?: unknown;
readonly isCustomTitle?: unknown;
readonly customTitle?: unknown;
}): boolean {
if (typeof metadata.title === 'string' && typeof metadata.isCustomTitle === 'boolean') {
return metadata.isCustomTitle;
}
return metadata.isCustomTitle === true || typeof metadata.customTitle === 'string';
}

View file

@ -999,6 +999,7 @@ function stubSessionMetadata(meta: SessionMeta): ISessionMetadata {
read: async () => meta,
update: async () => {},
setTitle: async () => {},
setGeneratedTitleIfUncustomized: async () => false,
setArchived: async () => {},
registerAgent: async () => {},
};

View file

@ -107,6 +107,7 @@ function metadataStub(): ISessionMetadata {
read: () => Promise.resolve({} as never),
update: () => Promise.resolve(),
setTitle: () => Promise.resolve(),
setGeneratedTitleIfUncustomized: () => Promise.resolve(false),
setArchived: () => Promise.resolve(),
registerAgent: () => Promise.resolve(),
};

View file

@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
@ -60,7 +60,10 @@ describe('SessionMetadata', () => {
ix.set(ISessionMetadata, new SyncDescriptor(SessionMetadata));
});
afterEach(() => { disposables.dispose(); });
afterEach(() => {
disposables.dispose();
vi.restoreAllMocks();
});
it('creates an initial document on first read', async () => {
const meta = ix.get(ISessionMetadata);
@ -93,6 +96,16 @@ describe('SessionMetadata', () => {
expect(await meta.read()).toMatchObject({ title: 't', archived: true });
});
it('sets a generated title while the metadata remains uncustomized', async () => {
const meta = ix.get(ISessionMetadata);
await expect(meta.setGeneratedTitleIfUncustomized('generated title')).resolves.toBe(true);
await expect(meta.read()).resolves.toMatchObject({
title: 'generated title',
isCustomTitle: false,
});
});
it('persists across instances', async () => {
const meta = ix.get(ISessionMetadata);
await meta.update({ title: 'persisted' });
@ -149,7 +162,7 @@ describe('SessionMetadata', () => {
});
});
it('preserves a legacy customTitle when title is also present', async () => {
it('trusts modern custom title state over a stale legacy customTitle', async () => {
const store = ix.get(IAtomicDocumentStore);
await store.set(META_SCOPE, 'state.json', {
id: 's1',
@ -157,25 +170,64 @@ describe('SessionMetadata', () => {
createdAt: 1700000000000,
updatedAt: 1700000000000,
archived: false,
title: 'base title',
title: 'renamed title',
isCustomTitle: true,
customTitle: 'legacy custom title',
});
const meta = ix.get(ISessionMetadata);
await expect(meta.read()).resolves.toMatchObject({
title: 'legacy custom title',
title: 'renamed title',
isCustomTitle: true,
});
await meta.update({ archived: true });
const fresh = createFreshMetadata(ix);
await expect(fresh.read()).resolves.toMatchObject({
title: 'legacy custom title',
title: 'renamed title',
isCustomTitle: true,
archived: true,
});
});
it('keeps a queued custom title when a generated title is enqueued afterward', async () => {
const meta = ix.get(ISessionMetadata);
await meta.ready;
const store = ix.get(IAtomicDocumentStore);
const set = store.set.bind(store);
let releaseWrite: (() => void) | undefined;
let markWriteStarted: (() => void) | undefined;
const writeStarted = new Promise<void>((resolve) => {
markWriteStarted = resolve;
});
const writeReleased = new Promise<void>((resolve) => {
releaseWrite = resolve;
});
let shouldBlock = true;
vi.spyOn(store, 'set').mockImplementation(async (scope, key, value) => {
if (shouldBlock) {
shouldBlock = false;
markWriteStarted?.();
await writeReleased;
}
await set(scope, key, value);
});
const priorWrite = meta.update({ lastPrompt: 'hello' });
await writeStarted;
const rename = meta.setTitle('user title');
const generated = meta.setGeneratedTitleIfUncustomized('generated title');
releaseWrite?.();
await priorWrite;
await rename;
await expect(generated).resolves.toBe(false);
await expect(meta.read()).resolves.toMatchObject({
title: 'user title',
isCustomTitle: true,
});
});
it('leaves existing agents/custom maps untouched', async () => {
const store = ix.get(IAtomicDocumentStore);
await store.set(META_SCOPE, 'state.json', {

View file

@ -5,7 +5,7 @@
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { OAuthUnauthorizedError } from '@moonshot-ai/kimi-code-oauth';
import { OAuthConnectionError, OAuthUnauthorizedError } from '@moonshot-ai/kimi-code-oauth';
import { DisposableStore, type IDisposable } from '#/_base/di/lifecycle';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
@ -88,6 +88,12 @@ class FakeSessionMetadata implements ISessionMetadata {
return this.update({ title, isCustomTitle: true });
}
async setGeneratedTitleIfUncustomized(title: string): Promise<boolean> {
if (this.meta.isCustomTitle === true) return false;
await this.update({ title, isCustomTitle: false });
return true;
}
setArchived(archived: boolean): Promise<void> {
return this.update({ archived });
}
@ -168,7 +174,6 @@ describe('SessionTitleService', () => {
reg.define(ISessionTitleService, SessionTitleService);
},
});
// Construct the SUT so its bus subscription is live.
ix.get(ISessionTitleService);
});
@ -300,6 +305,24 @@ describe('SessionTitleService', () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it('returns unavailable when OAuth token retrieval has an operational failure', async () => {
tokenError = new OAuthConnectionError('connection failed');
await metadata.update({ lastPrompt: 'hello' });
await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
});
it('propagates unexpected token provider failures', async () => {
tokenError = new Error('unexpected failure');
await metadata.update({ lastPrompt: 'hello' });
await expect(ix.get(ISessionTitleService).generateTitle()).rejects.toThrow(
'unexpected failure',
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('includes environment custom headers', async () => {
vi.stubEnv('KIMI_CODE_CUSTOM_HEADERS', 'X-Proxy-Header: from-env\n');
await metadata.update({ lastPrompt: 'hello' });
@ -338,6 +361,19 @@ describe('SessionTitleService', () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it('trusts a modern non-custom title over a stale legacy customTitle', async () => {
metadata.meta = {
...metadata.meta,
title: 'easy title',
isCustomTitle: false,
lastPrompt: 'hello',
customTitle: 'stale legacy title',
} as SessionMeta;
await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBe('生成的标题');
expect(metadata.meta.title).toBe('生成的标题');
});
it('shares an in-flight generation between automatic and manual requests', async () => {
let resolveFetch: ((response: Response) => void) | undefined;
fetchMock.mockImplementationOnce(

View file

@ -424,6 +424,7 @@ function sessionMetadataStub(agents: Readonly<Record<string, AgentMeta>>): ISess
}),
update: async () => {},
setTitle: async () => {},
setGeneratedTitleIfUncustomized: async () => false,
setArchived: async () => {},
registerAgent: async () => {},
};

View file

@ -394,6 +394,11 @@ function isUntitled(title: unknown): boolean {
}
function hasCustomTitle(metadata: SessionMeta): boolean {
if (metadata.isCustomTitle) return true;
return typeof (metadata as SessionMeta & { customTitle?: unknown }).customTitle === 'string';
if (typeof metadata.title === 'string' && typeof metadata.isCustomTitle === 'boolean') {
return metadata.isCustomTitle;
}
return (
metadata.isCustomTitle ||
typeof (metadata as SessionMeta & { customTitle?: unknown }).customTitle === 'string'
);
}

View file

@ -147,6 +147,31 @@ describe('SessionAPIImpl auto title', () => {
expect(titleEvents.at(-1)).toMatchObject({ title: '生成的标题' });
});
it('trusts modern non-custom title state over a stale legacy customTitle', async () => {
stubChatTitleFetch('生成的标题');
const { api, session, events, agent } = await setupAutoTitleSession({
autoTitle: true,
});
session.metadata = {
...session.metadata,
title: 'New Session',
isCustomTitle: false,
customTitle: 'stale legacy title',
} as typeof session.metadata;
await api.prompt({ agentId: 'main', input: [{ type: 'text', text: '帮我看个 Go 报错' }] });
if (agent.turn.hasActiveTurn) {
await agent.turn.waitForCurrentTurn();
}
await waitFor(() =>
events.some((event) =>
event['type'] === 'session.meta.updated' && event['title'] === '生成的标题'
),
);
expect(session.metadata.title).toBe('生成的标题');
});
it('sends managed request headers with the established layer precedence', async () => {
vi.stubEnv(
'KIMI_CODE_CUSTOM_HEADERS',

View file

@ -41,14 +41,13 @@ export async function fetchChatTitle(
controller.abort();
}, opts.timeoutMs ?? 8000);
try {
const headers = new Headers(opts.headers);
headers.set('Authorization', `Bearer ${accessToken}`);
headers.set('Accept', 'application/json');
headers.set('Content-Type', 'application/json');
const res = await fetch(url, {
method: 'POST',
headers: {
...opts.headers,
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
headers,
body: JSON.stringify({
method: 'chat_title',
params: { chat_content: chatContent },

View file

@ -60,7 +60,7 @@ describe('fetchChatTitle', () => {
});
});
it('keeps protocol headers authoritative when custom headers collide', async () => {
it('keeps protocol headers authoritative when custom header casing differs', async () => {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ title: '标题' }), {
@ -72,9 +72,9 @@ describe('fetchChatTitle', () => {
await fetchChatTitle('https://api.example/tools', 'access-token', 'user: hi', {
headers: {
Authorization: 'Bearer wrong-token',
Accept: 'text/plain',
'Content-Type': 'text/plain',
authorization: 'Bearer wrong-token',
aCcEpT: 'text/plain',
'content-TYPE': 'text/plain',
'X-Proxy-Header': 'present',
},
});