diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts index 28c14ad24..161c520d7 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts @@ -54,6 +54,7 @@ export interface ISessionMetadata { read(): Promise; update(patch: SessionMetaPatch): Promise; setTitle(title: string): Promise; + setGeneratedTitleIfUncustomized(title: string): Promise; setArchived(archived: boolean): Promise; registerAgent(agentId: string, meta: AgentMeta): Promise; } diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 744bc1f7e..3951239a8 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -112,6 +112,15 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { await this.update({ title, isCustomTitle: true }); } + async setGeneratedTitleIfUncustomized(title: string): Promise { + 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 { await this.update({ archived }); } @@ -126,9 +135,12 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { }); } - private enqueueUpdate(work: () => Promise): Promise { + private enqueueUpdate(work: () => Promise): Promise { 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; diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index 2f80463e8..25d29bf8e 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -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'; } diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 7cfdab0be..bfeebba16 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -999,6 +999,7 @@ function stubSessionMetadata(meta: SessionMeta): ISessionMetadata { read: async () => meta, update: async () => {}, setTitle: async () => {}, + setGeneratedTitleIfUncustomized: async () => false, setArchived: async () => {}, registerAgent: async () => {}, }; diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index 40766015e..eca1bd241 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -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(), }; diff --git a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts index 28060aa74..a4ed327be 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -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((resolve) => { + markWriteStarted = resolve; + }); + const writeReleased = new Promise((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', { diff --git a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts index 40af63f59..1b7cfc039 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -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 { + if (this.meta.isCustomTitle === true) return false; + await this.update({ title, isCustomTitle: false }); + return true; + } + setArchived(archived: boolean): Promise { 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( diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index fa98629ac..ca7df7c49 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -424,6 +424,7 @@ function sessionMetadataStub(agents: Readonly>): ISess }), update: async () => {}, setTitle: async () => {}, + setGeneratedTitleIfUncustomized: async () => false, setArchived: async () => {}, registerAgent: async () => {}, }; diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index ff52c0356..4a704ee9b 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -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' + ); } diff --git a/packages/agent-core/test/session/prompt-metadata.test.ts b/packages/agent-core/test/session/prompt-metadata.test.ts index dccbd1c3e..d3ef3b58c 100644 --- a/packages/agent-core/test/session/prompt-metadata.test.ts +++ b/packages/agent-core/test/session/prompt-metadata.test.ts @@ -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', diff --git a/packages/oauth/src/managed-tools.ts b/packages/oauth/src/managed-tools.ts index 546e7faa6..c859239c8 100644 --- a/packages/oauth/src/managed-tools.ts +++ b/packages/oauth/src/managed-tools.ts @@ -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 }, diff --git a/packages/oauth/test/managed-tools.test.ts b/packages/oauth/test/managed-tools.test.ts index ea5d71166..fd7429603 100644 --- a/packages/oauth/test/managed-tools.test.ts +++ b/packages/oauth/test/managed-tools.test.ts @@ -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', }, });