mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-04 14:02:22 +00:00
fix: preserve automatic session title invariants
This commit is contained in:
parent
6ef7c260b8
commit
2162ec97da
12 changed files with 172 additions and 31 deletions
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -999,6 +999,7 @@ function stubSessionMetadata(meta: SessionMeta): ISessionMetadata {
|
|||
read: async () => meta,
|
||||
update: async () => {},
|
||||
setTitle: async () => {},
|
||||
setGeneratedTitleIfUncustomized: async () => false,
|
||||
setArchived: async () => {},
|
||||
registerAgent: async () => {},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -424,6 +424,7 @@ function sessionMetadataStub(agents: Readonly<Record<string, AgentMeta>>): ISess
|
|||
}),
|
||||
update: async () => {},
|
||||
setTitle: async () => {},
|
||||
setGeneratedTitleIfUncustomized: async () => false,
|
||||
setArchived: async () => {},
|
||||
registerAgent: async () => {},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue