From 63f195cbea5c4d34138b0068566d9dc6343ffb0d Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 29 Jul 2026 15:11:57 +0800 Subject: [PATCH 01/47] feat: auto-generate session titles via the managed chat_title tool With the auto-title experimental flag on and a managed OAuth login, the session title is generated from the first prompt, replacing the truncated-prompt easy title. A custom title set by the user is never overwritten, and generation failures degrade silently to the easy title. - oauth: fetchChatTitle for the platform /tools chat_title method - agent-core (v1): fire-and-forget generation on the first prompt - agent-core-v2: sessionTitle domain watching the easy-title event - kap-server: POST /sessions/{id}/title/generate for manual regeneration --- .changeset/auto-session-title.md | 5 + .../scripts/check-domain-layers.mjs | 4 + packages/agent-core-v2/src/index.ts | 3 + .../src/session/sessionTitle/flag.ts | 28 ++ .../src/session/sessionTitle/sessionTitle.ts | 18 ++ .../sessionTitle/sessionTitleService.ts | 140 +++++++++ .../sessionTitle/sessionTitleService.test.ts | 295 ++++++++++++++++++ packages/agent-core/src/flags/registry.ts | 9 + packages/agent-core/src/session/auto-title.ts | 44 +++ .../src/session/provider-manager.ts | 13 + packages/agent-core/src/session/rpc.ts | 37 +++ .../test/session/prompt-metadata.test.ts | 137 ++++++++ .../kap-server/src/protocol/error-codes.ts | 2 + packages/kap-server/src/routes/sessions.ts | 49 +++ .../apiSurface.snapshot.test.ts.snap | 4 + packages/oauth/src/index.ts | 7 + packages/oauth/src/managed-tools.ts | 90 ++++++ packages/oauth/test/managed-tools.test.ts | 162 ++++++++++ 18 files changed, 1047 insertions(+) create mode 100644 .changeset/auto-session-title.md create mode 100644 packages/agent-core-v2/src/session/sessionTitle/flag.ts create mode 100644 packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts create mode 100644 packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts create mode 100644 packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts create mode 100644 packages/agent-core/src/session/auto-title.ts create mode 100644 packages/oauth/src/managed-tools.ts create mode 100644 packages/oauth/test/managed-tools.test.ts diff --git a/.changeset/auto-session-title.md b/.changeset/auto-session-title.md new file mode 100644 index 0000000000..3ddb095412 --- /dev/null +++ b/.changeset/auto-session-title.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add experimental auto-generated session titles: with the `auto-title` experimental flag on and a Kimi For Coding login, the session title is generated from the first prompt instead of the truncated prompt text. Off by default — enable with `KIMI_CODE_EXPERIMENTAL_AUTO_TITLE=1`. diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index b29f110e9e..cdf18c2477 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -239,6 +239,10 @@ const DOMAIN_LAYER = new Map([ // L6 beside the other cross-agent coordinators. ['undo', 6], ['sessionActivity', 6], + // `sessionTitle` generates the session title through the managed platform's + // `chat_title` tool: it watches the `event` bus for the easy-title moment + // and persists through `sessionMetadata`, so it sits in L6 beside it. + ['sessionTitle', 6], ['session', 6], ['terminal', 6], // `workspaceCommand` orchestrates session-level workspace mutations diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 3f667afe1e..88bcd63f4c 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -92,6 +92,9 @@ export * from '#/session/sessionMetadata/sessionMetadata'; export * from '#/session/sessionMetadata/sessionMetadataService'; export * from '#/session/sessionActivity/sessionActivity'; export * from '#/session/sessionActivity/sessionActivityService'; +export * from '#/session/sessionTitle/sessionTitle'; +export * from '#/session/sessionTitle/sessionTitleService'; +import '#/session/sessionTitle/flag'; export * from '#/session/sessionToolPolicy/sessionToolPolicy'; export * from '#/session/sessionToolPolicy/sessionToolPolicyService'; export * from '#/app/config/config'; diff --git a/packages/agent-core-v2/src/session/sessionTitle/flag.ts b/packages/agent-core-v2/src/session/sessionTitle/flag.ts new file mode 100644 index 0000000000..1d69fbf061 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionTitle/flag.ts @@ -0,0 +1,28 @@ +/** + * `sessionTitle` domain (L6) — registers the `auto-title` experimental flag + * into `flag`. + * + * Gates generating the session title through the managed platform's + * `chat_title` tool (replacing the truncated-prompt easy title). Off by + * default; enable via `KIMI_CODE_EXPERIMENTAL_AUTO_TITLE`, the master + * `KIMI_CODE_EXPERIMENTAL_FLAG`, or the `[experimental]` config section. + * Imported for its side effect (registers the definition) from the package + * barrel. + */ + +import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; + +export const AUTO_TITLE_FLAG_ID = 'auto-title'; +export const AUTO_TITLE_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_AUTO_TITLE'; + +export const autoTitleFlag: FlagDefinitionInput = { + id: AUTO_TITLE_FLAG_ID, + title: 'Auto session title', + description: + 'Generate the session title from the first prompt through the managed chat_title tool instead of keeping the truncated prompt. Requires a managed Kimi Code OAuth login.', + env: AUTO_TITLE_FLAG_ENV, + default: false, + surface: 'both', +}; + +registerFlagDefinition(autoTitleFlag); diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts new file mode 100644 index 0000000000..9c24bcad1b --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts @@ -0,0 +1,18 @@ +/** + * `sessionTitle` domain (L6) — session title generation contract. + * + * Defines the `ISessionTitleService` that (re)generates the session's title + * from its prompt history through the managed platform's `chat_title` tool. + * Bound at Session scope — one instance per session. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ISessionTitleService { + readonly _serviceBrand: undefined; + + generateTitle(): Promise; +} + +export const ISessionTitleService: ServiceIdentifier = + createDecorator('sessionTitleService'); diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts new file mode 100644 index 0000000000..082464c2e8 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -0,0 +1,140 @@ +/** + * `sessionTitle` domain (L6) — `ISessionTitleService` implementation. + * + * Watches the global `event` bus for the session's own easy-title moment (a + * `session.meta.updated` whose patch sets a non-custom title, published by + * the `rpc` prompt-metadata flow on the first prompt) and, when the + * `auto-title` experimental flag is on and the managed Kimi Code provider + * carries an OAuth ref, generates a real title from the already-sanitized + * `lastPrompt` through the platform `/tools` `chat_title` endpoint, persists + * it through `sessionMetadata`, and rebroadcasts `session.meta.updated`. + * Provider config comes from `provider`, the bearer token from `auth`, host + * identity headers from `model`, gating from `flag`, and logs through `log`. + * The public `generateTitle()` is the manual entry point (server route); any + * failure degrades to keeping the current title, and a custom title set by + * the user is never overwritten. Bound at Session scope. + */ + +import { + KIMI_CODE_PROVIDER_NAME, + fetchChatTitle, + kimiCodeToolsUrl, +} from '@moonshot-ai/kimi-code-oauth'; + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { IOAuthService } from '#/app/auth/auth'; +import { IEventService, type DomainEvent } from '#/app/event/event'; +import { IFlagService } from '#/app/flag/flag'; +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 { AUTO_TITLE_FLAG_ID } from './flag'; +import { ISessionTitleService } from './sessionTitle'; + +const MAX_GENERATED_TITLE_LENGTH = 200; + +export class SessionTitleService extends Disposable implements ISessionTitleService { + declare readonly _serviceBrand: undefined; + + private _autoAttempted = false; + + constructor( + @ISessionContext private readonly ctx: ISessionContext, + @ISessionMetadata private readonly metadata: ISessionMetadata, + @IEventService private readonly eventService: IEventService, + @IFlagService private readonly flags: IFlagService, + @IProviderService private readonly providers: IProviderService, + @IOAuthService private readonly oauth: IOAuthService, + @IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders, + @ILogService private readonly log: ILogService, + ) { + super(); + this._register(this.eventService.subscribe((event) => this.onMetaUpdated(event))); + } + + async generateTitle(): Promise { + if (!this.flags.enabled(AUTO_TITLE_FLAG_ID)) return undefined; + const current = await this.metadata.read(); + if (current.lastPrompt === undefined) return undefined; + return this.generateAndApply(current.lastPrompt); + } + + private onMetaUpdated(event: DomainEvent): void { + if (event.type !== 'session.meta.updated' || this._autoAttempted) return; + const lastPrompt = readEasyTitleLastPrompt(event.payload, this.ctx.sessionId); + if (lastPrompt === undefined) return; + if (!this.flags.enabled(AUTO_TITLE_FLAG_ID)) return; + this._autoAttempted = true; + void this.generateAndApply(lastPrompt).catch((error: unknown) => { + this.log.warn( + `auto session title failed: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + } + + private async generateAndApply(chatContent: string): Promise { + const provider = this.providers.get(KIMI_CODE_PROVIDER_NAME); + if ( + provider === undefined || + !isOAuthCatalogVendor(provider.type) || + provider.oauth === undefined + ) { + return undefined; + } + const tokenProvider = this.oauth.resolveTokenProvider(KIMI_CODE_PROVIDER_NAME, provider.oauth); + if (tokenProvider === undefined) return undefined; + const token = await tokenProvider.getAccessToken(); + const result = await fetchChatTitle( + kimiCodeToolsUrl(provider.baseUrl), + token, + `user: ${chatContent}`, + { headers: { ...this.hostHeaders.headers, ...provider.customHeaders } }, + ); + if (result.kind !== 'ok') { + 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 current = await this.metadata.read(); + if (current.isCustomTitle === true) return undefined; + const title = result.title.slice(0, MAX_GENERATED_TITLE_LENGTH); + await this.metadata.update({ title, isCustomTitle: false }); + this.eventService.publish({ + type: 'session.meta.updated', + payload: { + agentId: 'main', + sessionId: this.ctx.sessionId, + title, + patch: { title, isCustomTitle: false }, + }, + }); + return title; + } +} + +function readEasyTitleLastPrompt(payload: unknown, sessionId: string): string | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const record = payload as Record; + if (record['sessionId'] !== sessionId) return undefined; + const patch = record['patch']; + if (typeof patch !== 'object' || patch === null) return undefined; + const patchRecord = patch as Record; + if (patchRecord['isCustomTitle'] !== false || typeof patchRecord['title'] !== 'string') { + return undefined; + } + const lastPrompt = patchRecord['lastPrompt']; + return typeof lastPrompt === 'string' && lastPrompt.length > 0 ? lastPrompt : undefined; +} + +registerScopedService( + LifecycleScope.Session, + ISessionTitleService, + SessionTitleService, + ScopeActivation.OnScopeCreated, + 'sessionTitle', +); diff --git a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts new file mode 100644 index 0000000000..2818f26ae9 --- /dev/null +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -0,0 +1,295 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import { DisposableStore, type IDisposable } from '#/_base/di/lifecycle'; +import { createServices, type TestInstantiationService } from '#/_base/di/test'; +import { Emitter } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; +import { IOAuthService } from '#/app/auth/auth'; +import { type DomainEvent, IEventService } from '#/app/event/event'; +import { IFlagService } from '#/app/flag/flag'; +import { HostRequestHeaders, IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; +import { IProviderService, type ProviderConfig } from '#/kosong/provider/provider'; +import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; +import { + ISessionMetadata, + type SessionMeta, + type SessionMetaPatch, + type SessionMetadataChangedEvent, +} from '#/session/sessionMetadata/sessionMetadata'; +import { ISessionTitleService } from '#/session/sessionTitle/sessionTitle'; +import { SessionTitleService } from '#/session/sessionTitle/sessionTitleService'; +import '#/kosong/provider/providers/kimi/kimi.contrib'; + +import { registerLogServices } from '../../_base/log/stubs'; +import { stubFlag } from '../../app/flag/stubs'; +import { stubProviderService } from '../../app/provider/stubs'; + +const SESSION_ID = 'sess-1'; +const MANAGED_PROVIDER: ProviderConfig = { + type: 'kimi', + baseUrl: 'https://api.example.test/coding/v1', + oauth: { storage: 'file', key: 'kimi-code' }, +}; + +class FakeEventService implements IEventService { + declare readonly _serviceBrand: undefined; + private readonly emitter = new Emitter(); + readonly onDidPublish = this.emitter.event; + readonly published: DomainEvent[] = []; + + publish(event: DomainEvent): void { + this.published.push(event); + this.emitter.fire(event); + } + + subscribe(handler: (event: DomainEvent) => void): IDisposable { + return this.emitter.event(handler); + } +} + +class FakeSessionMetadata implements ISessionMetadata { + declare readonly _serviceBrand: undefined; + readonly ready = Promise.resolve(); + private readonly emitter = new Emitter(); + readonly onDidChangeMetadata = this.emitter.event; + meta: SessionMeta; + + constructor() { + this.meta = { + id: SESSION_ID, + createdAt: 0, + updatedAt: 0, + archived: false, + }; + } + + read(): Promise { + return Promise.resolve(this.meta); + } + + update(patch: SessionMetaPatch): Promise { + this.meta = { ...this.meta, ...patch }; + this.emitter.fire({ changed: Object.keys(patch) as (keyof SessionMeta)[] }); + return Promise.resolve(); + } + + setTitle(title: string): Promise { + return this.update({ title, isCustomTitle: true }); + } + + setArchived(archived: boolean): Promise { + return this.update({ archived }); + } + + registerAgent(): Promise { + return Promise.resolve(); + } +} + +async function waitFor(condition: () => boolean, timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() > deadline) throw new Error('waitFor: condition not met before timeout'); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +describe('SessionTitleService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let events: FakeEventService; + let metadata: FakeSessionMetadata; + let flagEnabled: boolean; + let providers: Record; + let fetchMock: Mock<(url: string, init?: RequestInit) => Promise>; + + beforeEach(() => { + flagEnabled = true; + providers = { 'managed:kimi-code': MANAGED_PROVIDER }; + metadata = new FakeSessionMetadata(); + events = new FakeEventService(); + fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise>( + async () => + new Response(JSON.stringify({ title: '生成的标题' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + disposables = new DisposableStore(); + ix = createServices(disposables, { + base: [registerLogServices], + additionalServices: (reg) => { + reg.defineInstance( + ISessionContext, + makeSessionContext({ + sessionId: SESSION_ID, + workspaceId: 'ws-1', + sessionDir: '/tmp/sess-1', + sessionScope: 'sessions/sess-1', + cwd: '/tmp', + }), + ); + reg.defineInstance(ISessionMetadata, metadata); + reg.defineInstance(IEventService, events); + reg.defineInstance( + IFlagService, + stubFlag(() => flagEnabled), + ); + reg.defineInstance(IProviderService, stubProviderService(providers)); + reg.definePartialInstance(IOAuthService, { + resolveTokenProvider: () => ({ getAccessToken: async () => 'test-token' }), + }); + reg.defineInstance(IHostRequestHeaders, new HostRequestHeaders({ 'User-Agent': 'test' })); + reg.define(ISessionTitleService, SessionTitleService); + }, + }); + // Construct the SUT so its bus subscription is live. + ix.get(ISessionTitleService); + }); + + afterEach(() => { + disposables.dispose(); + vi.unstubAllGlobals(); + }); + + function publishEasyTitle(lastPrompt: string, sessionId = SESSION_ID): void { + events.publish({ + type: 'session.meta.updated', + payload: { + agentId: 'main', + sessionId, + title: lastPrompt.slice(0, 200), + patch: { title: lastPrompt.slice(0, 200), isCustomTitle: false, lastPrompt }, + }, + }); + } + + it('replaces the easy title with the generated one after the first prompt', async () => { + publishEasyTitle('帮我看一下这个 Go 的 nil pointer 报错'); + + await waitFor(() => metadata.meta.title === '生成的标题'); + expect(metadata.meta.isCustomTitle).toBe(false); + + const [, init] = fetchMock.mock.calls[0]!; + expect(JSON.parse(init?.body as string)).toEqual({ + method: 'chat_title', + params: { chat_content: 'user: 帮我看一下这个 Go 的 nil pointer 报错' }, + }); + expect(new Headers(init?.headers as Record).get('authorization')).toBe( + 'Bearer test-token', + ); + + const rebroadcast = events.published.find( + (event) => + event.type === 'session.meta.updated' && + (event.payload as { patch?: { title?: string } }).patch?.title === '生成的标题', + ); + expect(rebroadcast).toBeDefined(); + }); + + it('does nothing when the auto-title flag is off', async () => { + flagEnabled = false; + publishEasyTitle('hello'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(fetchMock).not.toHaveBeenCalled(); + expect(metadata.meta.title).toBeUndefined(); + }); + + it('does nothing without a managed OAuth provider', async () => { + delete providers['managed:kimi-code']; + publishEasyTitle('hello'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('ignores easy-title events from other sessions', async () => { + publishEasyTitle('hello', 'sess-other'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('ignores manual renames (custom title patches)', async () => { + events.publish({ + type: 'session.meta.updated', + payload: { + agentId: 'main', + sessionId: SESSION_ID, + title: 'mine', + patch: { title: 'mine', isCustomTitle: true }, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('attempts generation only once per session scope', async () => { + publishEasyTitle('first'); + await waitFor(() => metadata.meta.title === '生成的标题'); + + publishEasyTitle('second'); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(metadata.meta.title).toBe('生成的标题'); + }); + + it('never overwrites a custom title set while generation is in flight', async () => { + let resolveFetch: ((response: Response) => void) | undefined; + fetchMock.mockImplementationOnce( + async () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + publishEasyTitle('hello'); + await waitFor(() => fetchMock.mock.calls.length === 1); + await metadata.setTitle('user 取的标题'); + resolveFetch?.( + new Response(JSON.stringify({ title: '生成的标题' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(metadata.meta.title).toBe('user 取的标题'); + expect(metadata.meta.isCustomTitle).toBe(true); + }); + + it('keeps the current title when the backend request fails', async () => { + fetchMock.mockImplementationOnce(async () => new Response('', { status: 500 })); + + publishEasyTitle('hello'); + await waitFor(() => fetchMock.mock.calls.length === 1); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(metadata.meta.title).toBeUndefined(); + }); + + it('generateTitle() regenerates from the stored lastPrompt on demand', async () => { + await metadata.update({ lastPrompt: '帮我把这个脚本改成异步' }); + + const svc = ix.get(ISessionTitleService); + const title = await svc.generateTitle(); + + expect(title).toBe('生成的标题'); + expect(metadata.meta.title).toBe('生成的标题'); + expect(metadata.meta.isCustomTitle).toBe(false); + }); + + it('generateTitle() returns undefined when the flag is off or no prompt was seen', async () => { + const svc = ix.get(ISessionTitleService); + + flagEnabled = false; + expect(await svc.generateTitle()).toBeUndefined(); + + flagEnabled = true; + expect(await svc.generateTitle()).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/agent-core/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts index dbec75b80b..64c9f697db 100644 --- a/packages/agent-core/src/flags/registry.ts +++ b/packages/agent-core/src/flags/registry.ts @@ -32,6 +32,15 @@ export const FLAG_DEFINITIONS = [ default: false, surface: 'core', }, + { + id: 'auto-title', + title: 'Auto session title', + description: + 'Generate the session title from the first prompt through the managed chat_title tool instead of keeping the truncated prompt. Requires a managed Kimi Code OAuth login.', + env: 'KIMI_CODE_EXPERIMENTAL_AUTO_TITLE', + default: false, + surface: 'both', + }, ] as const satisfies readonly FlagDefinitionInput[]; /** Literal union of registered flag ids. */ diff --git a/packages/agent-core/src/session/auto-title.ts b/packages/agent-core/src/session/auto-title.ts new file mode 100644 index 0000000000..84b6c37174 --- /dev/null +++ b/packages/agent-core/src/session/auto-title.ts @@ -0,0 +1,44 @@ +import { + KIMI_CODE_PROVIDER_NAME, + fetchChatTitle, + kimiCodeToolsUrl, + parseKimiCodeCustomHeaders, +} from '@moonshot-ai/kimi-code-oauth'; + +import type { Logger } from '#/logging/types'; + +import type { ProviderManager } from './provider-manager'; + +const MAX_GENERATED_TITLE_LENGTH = 200; + +/** + * Generate a session title through the managed platform `/tools` `chat_title` + * endpoint, from the session's (already sanitized) first prompt. Returns + * `undefined` when the managed provider is not OAuth-backed or the request + * fails — callers keep the easy title in that case. + */ +export async function generateSessionTitle( + chatContent: string, + providerManager: ProviderManager | undefined, + log?: Logger, +): Promise { + if (providerManager === undefined) return undefined; + const provider = providerManager.getProviderConfig(KIMI_CODE_PROVIDER_NAME); + if (provider?.oauth === undefined) return undefined; + const tokenProvider = providerManager.resolveOAuthTokenProvider( + KIMI_CODE_PROVIDER_NAME, + provider.oauth, + ); + if (tokenProvider === undefined) return undefined; + const result = await fetchChatTitle( + kimiCodeToolsUrl(provider.baseUrl), + await tokenProvider.getAccessToken(), + `user: ${chatContent}`, + { headers: { ...provider.customHeaders, ...parseKimiCodeCustomHeaders() } }, + ); + if (result.kind !== 'ok') { + log?.debug('chat_title request failed', { message: result.message }); + return undefined; + } + return result.title.slice(0, MAX_GENERATED_TITLE_LENGTH); +} diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 2bdb29a425..81845d241b 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -157,6 +157,19 @@ export class ProviderManager implements ModelProvider { }; } + /** Read a provider entry from the current config (live when constructed with a config getter). */ + getProviderConfig(providerName: string): ProviderConfig | undefined { + return this.config.providers[providerName]; + } + + /** Resolve an OAuth bearer-token provider for a configured provider entry. */ + resolveOAuthTokenProvider( + providerName: string, + oauthRef?: OAuthRef, + ): BearerTokenProvider | undefined { + return this.options.resolveOAuthTokenProvider?.(providerName, oauthRef); + } + resolveAuth( model: string, options?: { readonly log?: Logger }, diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index d12b44b6ec..ff52c0356f 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -40,6 +40,7 @@ import type { import type { PromisableMethods } from '#/utils/types'; import type { Session, SessionMeta } from '.'; +import { generateSessionTitle } from './auto-title'; import { promptMetadataTextFromPayload, promptMetadataTextFromPluginCommand, @@ -349,6 +350,42 @@ export class SessionAPIImpl implements PromisableMethods { lastPrompt, }, }); + if (title !== undefined) { + void this.applyGeneratedTitle(lastPrompt); + } + } + + private async applyGeneratedTitle(lastPrompt: string): Promise { + try { + if (!this.session.experimentalFlags.enabled('auto-title')) return; + const generated = await generateSessionTitle( + lastPrompt, + this.session.options.providerManager, + this.session.log, + ); + if (generated === undefined) return; + // The user may have renamed the session while the request was in flight. + if (hasCustomTitle(this.session.metadata)) return; + this.session.metadata = { + ...this.session.metadata, + title: generated, + isCustomTitle: false, + updatedAt: new Date().toISOString(), + }; + await this.session.writeMetadata(); + await this.session.rpc.emitEvent({ + type: 'session.meta.updated', + agentId: 'main', + title: generated, + patch: { + title: generated, + isCustomTitle: false, + lastPrompt: undefined, + }, + }); + } catch (error) { + this.session.log.warn('auto session title failed', { error }); + } } } diff --git a/packages/agent-core/test/session/prompt-metadata.test.ts b/packages/agent-core/test/session/prompt-metadata.test.ts index 4af1ad01a6..1a1eb94ba0 100644 --- a/packages/agent-core/test/session/prompt-metadata.test.ts +++ b/packages/agent-core/test/session/prompt-metadata.test.ts @@ -18,6 +18,7 @@ import { join } from 'pathe'; import type { ProviderConfig } from '@moonshot-ai/kosong'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { FlagResolver } from '../../src/flags'; import type { ResolvedAgentProfile } from '../../src/profile'; import type { SDKSessionRPC } from '../../src/rpc'; import { Session } from '../../src/session'; @@ -107,6 +108,141 @@ const MOCK_PROVIDER = { model: 'mock-model', } as const satisfies ProviderConfig; +const MANAGED_PROVIDER = { + type: 'kimi', + baseUrl: 'https://api.example.test/coding/v1', + oauth: { storage: 'file', key: 'kimi-code' }, +} as const; + +describe('SessionAPIImpl auto title', () => { + it('replaces the easy title with the generated one when the flag is on', async () => { + const fetchMock = stubChatTitleFetch('生成的标题'); + const { api, session, events, agent } = await setupAutoTitleSession({ + autoTitle: true, + }); + + await api.prompt({ agentId: 'main', input: [{ type: 'text', text: '帮我看个 Go 报错' }] }); + if (agent.turn.hasActiveTurn) { + await agent.turn.waitForCurrentTurn(); + } + + await waitFor(() => + events.some((e) => e['type'] === 'session.meta.updated' && e['title'] === '生成的标题'), + ); + expect(session.metadata.isCustomTitle).toBe(false); + const [, init] = chatTitleCalls(fetchMock)[0]!; + expect(JSON.parse(init?.body as string)).toEqual({ + method: 'chat_title', + params: { chat_content: 'user: 帮我看个 Go 报错' }, + }); + const titleEvents = events.filter((e) => e['type'] === 'session.meta.updated'); + expect(titleEvents.at(-1)).toMatchObject({ title: '生成的标题' }); + }); + + it('keeps the easy title when the flag is off', async () => { + const fetchMock = stubChatTitleFetch('生成的标题'); + const { api, session, agent } = await setupAutoTitleSession({ autoTitle: false }); + + await api.prompt({ agentId: 'main', input: [{ type: 'text', text: '帮我看个 Go 报错' }] }); + if (agent.turn.hasActiveTurn) { + await agent.turn.waitForCurrentTurn(); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(chatTitleCalls(fetchMock)).toHaveLength(0); + expect(session.metadata.title).toBe('帮我看个 Go 报错'); + }); + + it('keeps the easy title when the managed provider is not OAuth-backed', async () => { + const fetchMock = stubChatTitleFetch('生成的标题'); + const { api, session, agent } = await setupAutoTitleSession({ + autoTitle: true, + managedOAuth: false, + }); + + await api.prompt({ agentId: 'main', input: [{ type: 'text', text: '帮我看个 Go 报错' }] }); + if (agent.turn.hasActiveTurn) { + await agent.turn.waitForCurrentTurn(); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(chatTitleCalls(fetchMock)).toHaveLength(0); + expect(session.metadata.title).toBe('帮我看个 Go 报错'); + }); +}); + +function chatTitleCalls(fetchMock: ReturnType): [string, RequestInit?][] { + return (fetchMock.mock.calls as unknown as [string, RequestInit?][]).filter(([url]) => + String(url).includes('/tools'), + ); +} + +function stubChatTitleFetch(title: string) { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ title }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +async function waitFor(condition: () => boolean, timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() > deadline) throw new Error('waitFor: condition not met before timeout'); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +async function setupAutoTitleSession(options: { autoTitle: boolean; managedOAuth?: boolean }) { + const sessionDir = await makeTempDir(); + const events: Array> = []; + const scripted = createScriptedGenerate(); + const flags = new FlagResolver({}); + flags.setConfigOverrides({ 'auto-title': options.autoTitle }); + const managed = + options.managedOAuth === false + ? { type: 'kimi', apiKey: 'sk-test' } + : MANAGED_PROVIDER; + const session = track( + new Session({ + id: 'auto-title', + kaos: testKaos.withCwd(sessionDir), + homedir: sessionDir, + rpc: createSessionRpc(events), + skills: { explicitDirs: [join(sessionDir, 'missing-skills')] }, + providerManager: new ProviderManager({ + config: { + providers: { + test: { type: MOCK_PROVIDER.type, apiKey: MOCK_PROVIDER.apiKey }, + 'managed:kimi-code': managed, + }, + models: { + [MOCK_PROVIDER.model]: { + provider: 'test', + model: MOCK_PROVIDER.model, + maxContextSize: 1_000_000, + }, + }, + }, + resolveOAuthTokenProvider: () => ({ getAccessToken: async () => 'test-token' }), + }), + experimentalFlags: flags, + }), + ); + const { agent } = await session.createAgent( + { type: 'main', generate: scripted.generate }, + { profile: testProfile() }, + ); + agent.config.update({ modelAlias: MOCK_PROVIDER.model, thinkingEffort: 'off' }); + agent.permission.setMode('yolo'); + const api = new SessionAPIImpl(session); + return { api, session, events, agent }; +} + const tempDirs: string[] = []; const openSessions: Session[] = []; @@ -116,6 +252,7 @@ function track(session: Session): Session { } afterEach(async () => { + vi.unstubAllGlobals(); // Close sessions first so their async metadata/wire writes settle before the // temp dirs are removed (otherwise rm races with a write -> ENOTEMPTY). await Promise.allSettled(openSessions.splice(0).map((s) => s.close())); diff --git a/packages/kap-server/src/protocol/error-codes.ts b/packages/kap-server/src/protocol/error-codes.ts index e8f033aa78..1f6af4c8b0 100644 --- a/packages/kap-server/src/protocol/error-codes.ts +++ b/packages/kap-server/src/protocol/error-codes.ts @@ -116,6 +116,8 @@ export const ErrorCode = { GOAL_UNSUPPORTED_AGENT: 40920, /** 创建时 provider_id 已存在 */ PROVIDER_ALREADY_EXISTS: 40921, + /** 会话标题生成不可用(flag 未开 / 无 managed OAuth 登录 / 还没有 prompt / 后端失败) */ + SESSION_TITLE_UNAVAILABLE: 40922, /** approval 60s 超时 */ APPROVAL_EXPIRED: 41001, diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index 1623004e09..37cb4b978f 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -8,6 +8,8 @@ * GET /sessions/{session_id} get * GET /sessions/{session_id}/profile * POST /sessions/{session_id}/profile update title / metadata / agent_config + * POST /sessions/{session_id}/title/generate + * regenerate title via chat_title * POST /sessions/{tail} action: fork / compact / undo / * abort / btw / archive / restore * GET /sessions/{session_id}/children list child sessions @@ -88,6 +90,7 @@ import { ISessionLifecycleService, ISessionMetadata, ISessionLegacyService, + ISessionTitleService, ISessionSecondaryModelWarningService, IEventService, IWorkspaceAliases, @@ -603,6 +606,52 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void updateProfileRoute.handler as Parameters[2], ); + const generateTitleRoute = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/title/generate', + params: sessionIdParamSchema, + success: { data: z.object({ title: z.string() }) }, + errors: { + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.SESSION_TITLE_UNAVAILABLE]: {}, + }, + description: 'Generate the session title via the managed chat_title tool', + tags: ['sessions'], + }, + async (req, reply) => { + try { + const { session_id } = req.params; + const handle = await core.accessor.get(ISessionLifecycleService).resume(session_id); + if (handle === undefined) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} not found`, req.id), + ); + return; + } + const title = await handle.accessor.get(ISessionTitleService).generateTitle(); + if (title === undefined) { + reply.send( + errEnvelope( + ErrorCode.SESSION_TITLE_UNAVAILABLE, + 'session title generation is unavailable (auto-title flag off, no managed OAuth login, no prompt yet, or the backend request failed)', + req.id, + ), + ); + return; + } + reply.send(okEnvelope({ title }, req.id)); + } catch (error) { + sendMappedError(reply, req, error); + } + }, + ); + app.post( + generateTitleRoute.path, + generateTitleRoute.options, + generateTitleRoute.handler as Parameters[2], + ); + const sessionActionRoute = defineRoute( { method: 'POST', diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index 2d824ef41e..7fc7dd308d 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -372,6 +372,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "POST", "/api/v1/sessions/{session_id}/terminals/{tail}", ], + [ + "POST", + "/api/v1/sessions/{session_id}/title/generate", + ], [ "POST", "/api/v1/shutdown", diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 3cea3ecef5..d4e48be42a 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -94,6 +94,13 @@ export type { UsageWindow, } from './managed-usage'; +export { fetchChatTitle, kimiCodeToolsUrl } from './managed-tools'; +export type { + FetchChatTitleError, + FetchChatTitleOk, + FetchChatTitleResult, +} from './managed-tools'; + export { fetchSubmitFeedback, kimiCodeFeedbackUrl } from './managed-feedback'; export type { FetchSubmitFeedbackError, diff --git a/packages/oauth/src/managed-tools.ts b/packages/oauth/src/managed-tools.ts new file mode 100644 index 0000000000..45cae0c966 --- /dev/null +++ b/packages/oauth/src/managed-tools.ts @@ -0,0 +1,90 @@ +/** + * Managed-platform `/tools` dispatch: POSTs `{method, params}` to + * `{kimiCodeBaseUrl}/tools` with a Bearer access token, the same wire + * shape the backend tool surface expects (see `chat_title` below). + * + * `chat_title` generates a short session title from a chat excerpt: + * + * { "method": "chat_title", "params": { "chat_content": "user: ...\nassistant: ..." } } + * → { "title": "..." } + */ + +import { readApiErrorMessage } from './api-error'; +import { kimiCodeBaseUrl } from './managed-usage'; +import { isRecord } from './utils'; + +export interface FetchChatTitleOk { + readonly kind: 'ok'; + readonly title: string; +} + +export interface FetchChatTitleError { + readonly kind: 'error'; + readonly status?: number; + readonly message: string; +} + +export type FetchChatTitleResult = FetchChatTitleOk | FetchChatTitleError; + +export function kimiCodeToolsUrl(baseUrl?: string): string { + return `${(baseUrl ?? kimiCodeBaseUrl()).replace(/\/+$/, '')}/tools`; +} + +export async function fetchChatTitle( + url: string, + accessToken: string, + chatContent: string, + opts: { timeoutMs?: number; headers?: Record } = {}, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, opts.timeoutMs ?? 8000); + try { + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + ...opts.headers, + }, + body: JSON.stringify({ + method: 'chat_title', + params: { chat_content: chatContent }, + }), + signal: controller.signal, + }); + if (!res.ok) { + return { + kind: 'error', + status: res.status, + message: await readApiErrorMessage( + res, + `Failed to generate session title: HTTP ${String(res.status)}`, + ), + }; + } + const title = parseChatTitle(await res.json()); + if (title === undefined) { + return { kind: 'error', message: 'Failed to generate session title: missing title.' }; + } + return { kind: 'ok', title }; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return { kind: 'error', message: 'Failed to generate session title: request timed out.' }; + } + const msg = error instanceof Error ? error.message : String(error); + return { kind: 'error', message: `Failed to generate session title: ${msg}` }; + } finally { + clearTimeout(timer); + } +} + +function parseChatTitle(payload: unknown): string | undefined { + if (!isRecord(payload)) return undefined; + const value = payload['title']; + if (typeof value !== 'string') return undefined; + const title = value.trim(); + return title.length > 0 ? title : undefined; +} diff --git a/packages/oauth/test/managed-tools.test.ts b/packages/oauth/test/managed-tools.test.ts new file mode 100644 index 0000000000..a4eadc66ae --- /dev/null +++ b/packages/oauth/test/managed-tools.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { fetchChatTitle, kimiCodeToolsUrl } from '../src/managed-tools'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe('kimiCodeToolsUrl', () => { + it('appends /tools to the default base URL', () => { + expect(kimiCodeToolsUrl()).toBe('https://api.kimi.com/coding/v1/tools'); + }); + + it('honours KIMI_CODE_BASE_URL and trims trailing slashes', () => { + vi.stubEnv('KIMI_CODE_BASE_URL', 'https://example.test/v9///'); + expect(kimiCodeToolsUrl()).toBe('https://example.test/v9/tools'); + }); +}); + +describe('fetchChatTitle', () => { + it('POSTs the chat_title method with bearer auth and returns the title on 200', async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ title: 'Go nil pointer 错误排查' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await fetchChatTitle( + 'https://api.example/tools', + 'access-token', + 'user: nil pointer 报错', + ); + + expect(result).toEqual({ kind: 'ok', title: 'Go nil pointer 错误排查' }); + + const calls = fetchMock.mock.calls as unknown as [string, RequestInit?][]; + const [calledUrl, init] = calls[0]!; + expect(calledUrl).toBe('https://api.example/tools'); + expect(init?.method).toBe('POST'); + + const headers = new Headers((init?.headers ?? {}) as Record); + expect(headers.get('authorization')).toBe('Bearer access-token'); + expect(headers.get('content-type')).toBe('application/json'); + + expect(JSON.parse(init?.body as string)).toEqual({ + method: 'chat_title', + params: { chat_content: 'user: nil pointer 报错' }, + }); + }); + + it('trims surrounding whitespace from the title', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ title: ' 标题 \n' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + + const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi'); + + expect(result).toEqual({ kind: 'ok', title: '标题' }); + }); + + it('returns an error when the server omits the title', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + + const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi'); + + expect(result).toEqual({ + kind: 'error', + message: 'Failed to generate session title: missing title.', + }); + }); + + it('returns an error with status when the server responds 401', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('', { status: 401 })), + ); + + const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi'); + + expect(result.kind).toBe('error'); + if (result.kind !== 'error') return; + expect(result.status).toBe(401); + expect(result.message).toMatch(/401/); + }); + + it('surfaces API error messages from failed generations', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ error: { message: 'title rejected' } }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + + const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi'); + + expect(result).toEqual({ kind: 'error', status: 400, message: 'title rejected' }); + }); + + it('returns a timeout error when the request aborts', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + (_url: string, init?: RequestInit) => + new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => { + const err = new Error('aborted'); + err.name = 'AbortError'; + reject(err); + }); + }), + ), + ); + + const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi', { + timeoutMs: 5, + }); + + expect(result.kind).toBe('error'); + if (result.kind !== 'error') return; + expect(result.status).toBeUndefined(); + expect(result.message).toMatch(/timed out/); + }); + + it('returns a generic error message on network failure', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new TypeError('network down'); + }), + ); + + const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi'); + + expect(result.kind).toBe('error'); + if (result.kind !== 'error') return; + expect(result.message).toMatch(/network down/); + }); +}); From b8c1244da55e413f63e0b9204d267a6fe0b8ee70 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 29 Jul 2026 16:15:15 +0800 Subject: [PATCH 02/47] fix: harden auto-generated session titles --- .../sessionMetadata/sessionMetadataService.ts | 12 ++- .../sessionTitle/sessionTitleService.ts | 51 ++++++++-- .../sessionMetadata/sessionMetadata.test.ts | 24 +++++ .../sessionTitle/sessionTitleService.test.ts | 92 ++++++++++++++++--- .../test/session/prompt-metadata.test.ts | 9 +- packages/kap-server/test/sessions.test.ts | 20 ++++ packages/oauth/test/managed-tools.test.ts | 5 + 7 files changed, 189 insertions(+), 24 deletions(-) diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 9b58cd0f25..0373a2527e 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -208,19 +208,29 @@ export function normalizeSessionMeta(raw: SessionMeta, sessionId: string): Sessi createdAt?: unknown; updatedAt?: unknown; workDir?: unknown; + customTitle?: unknown; }; const cwd = raw.cwd ?? (typeof legacy.workDir === 'string' && legacy.workDir.length > 0 ? legacy.workDir : undefined); + const legacyCustomTitle = + typeof legacy.customTitle === 'string' ? legacy.customTitle : undefined; + const title = raw.title ?? legacyCustomTitle; + const isCustomTitle = legacyCustomTitle === undefined ? raw.isCustomTitle : true; if (raw.version === SESSION_META_VERSION) { - return cwd === raw.cwd ? raw : { ...raw, cwd }; + if (cwd === raw.cwd && title === raw.title && isCustomTitle === raw.isCustomTitle) { + return raw; + } + return { ...raw, cwd, title, isCustomTitle }; } return { ...raw, id: sessionId, version: SESSION_META_VERSION, cwd, + title, + isCustomTitle, createdAt: toEpochMs(legacy.createdAt), updatedAt: toEpochMs(legacy.updatedAt), }; diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index 082464c2e8..aa286512b5 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -17,8 +17,10 @@ import { KIMI_CODE_PROVIDER_NAME, + OAuthUnauthorizedError, fetchChatTitle, kimiCodeToolsUrl, + parseKimiCodeCustomHeaders, } from '@moonshot-ai/kimi-code-oauth'; import { Disposable } from '#/_base/di/lifecycle'; @@ -42,6 +44,7 @@ export class SessionTitleService extends Disposable implements ISessionTitleServ declare readonly _serviceBrand: undefined; private _autoAttempted = false; + private _generation: Promise | undefined; constructor( @ISessionContext private readonly ctx: ISessionContext, @@ -54,13 +57,17 @@ export class SessionTitleService extends Disposable implements ISessionTitleServ @ILogService private readonly log: ILogService, ) { super(); - this._register(this.eventService.subscribe((event) => this.onMetaUpdated(event))); + this._register( + this.eventService.subscribe((event) => { + this.onMetaUpdated(event); + }), + ); } async generateTitle(): Promise { if (!this.flags.enabled(AUTO_TITLE_FLAG_ID)) return undefined; const current = await this.metadata.read(); - if (current.lastPrompt === undefined) return undefined; + if (current.lastPrompt === undefined || hasCustomTitle(current)) return undefined; return this.generateAndApply(current.lastPrompt); } @@ -78,6 +85,18 @@ export class SessionTitleService extends Disposable implements ISessionTitleServ } private async generateAndApply(chatContent: string): Promise { + const inFlight = this._generation; + if (inFlight !== undefined) return inFlight; + const settled = this.generateAndApplyOnce(chatContent).finally(() => { + if (this._generation === settled) this._generation = undefined; + }); + this._generation = settled; + return settled; + } + + private async generateAndApplyOnce(chatContent: string): Promise { + const current = await this.metadata.read(); + if (hasCustomTitle(current)) return undefined; const provider = this.providers.get(KIMI_CODE_PROVIDER_NAME); if ( provider === undefined || @@ -88,20 +107,33 @@ export class SessionTitleService extends Disposable implements ISessionTitleServ } const tokenProvider = this.oauth.resolveTokenProvider(KIMI_CODE_PROVIDER_NAME, provider.oauth); if (tokenProvider === undefined) return undefined; - const token = await tokenProvider.getAccessToken(); + let token: string; + try { + token = await tokenProvider.getAccessToken(); + } catch (error) { + if (!(error instanceof OAuthUnauthorizedError)) throw error; + this.log.debug(`chat_title request unavailable: ${error.message}`); + return undefined; + } const result = await fetchChatTitle( kimiCodeToolsUrl(provider.baseUrl), token, `user: ${chatContent}`, - { headers: { ...this.hostHeaders.headers, ...provider.customHeaders } }, + { + headers: { + ...parseKimiCodeCustomHeaders(), + ...this.hostHeaders.headers, + ...provider.customHeaders, + }, + }, ); if (result.kind !== 'ok') { 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 current = await this.metadata.read(); - if (current.isCustomTitle === true) return undefined; + 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 }); this.eventService.publish({ @@ -117,6 +149,13 @@ export class SessionTitleService extends Disposable implements ISessionTitleServ } } +function hasCustomTitle(metadata: { + readonly isCustomTitle?: boolean; + readonly customTitle?: unknown; +}): boolean { + return metadata.isCustomTitle === true || typeof metadata.customTitle === 'string'; +} + function readEasyTitleLastPrompt(payload: unknown, sessionId: string): string | undefined { if (typeof payload !== 'object' || payload === null) return undefined; const record = payload as Record; 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 1a3048bfa9..2fe28c919b 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -125,6 +125,30 @@ describe('SessionMetadata', () => { expect(healed.updatedAt).toBe(1700000000000); }); + it('normalizes the legacy customTitle field before callers read metadata', async () => { + const store = ix.get(IAtomicDocumentStore); + await store.set(META_SCOPE, 'state.json', { + id: 's1', + version: 2, + createdAt: 1700000000000, + updatedAt: 1700000000000, + archived: false, + customTitle: 'legacy title', + }); + + const meta = ix.get(ISessionMetadata); + await expect(meta.read()).resolves.toMatchObject({ + title: 'legacy title', + isCustomTitle: true, + }); + + const fresh = createFreshMetadata(ix); + await expect(fresh.read()).resolves.toMatchObject({ + title: 'legacy 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 2818f26ae9..8ef512c8fc 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -1,5 +1,12 @@ +/** + * Scenario: managed chat_title generation through the session-scoped service, + * including OAuth failures, legacy custom titles, request headers, and races. + */ + import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { OAuthUnauthorizedError } from '@moonshot-ai/kimi-code-oauth'; + import { DisposableStore, type IDisposable } from '#/_base/di/lifecycle'; import { createServices, type TestInstantiationService } from '#/_base/di/test'; import { Emitter } from '#/_base/event'; @@ -102,9 +109,11 @@ describe('SessionTitleService', () => { let flagEnabled: boolean; let providers: Record; let fetchMock: Mock<(url: string, init?: RequestInit) => Promise>; + let tokenError: Error | undefined; beforeEach(() => { flagEnabled = true; + tokenError = undefined; providers = { 'managed:kimi-code': MANAGED_PROVIDER }; metadata = new FakeSessionMetadata(); events = new FakeEventService(); @@ -139,7 +148,12 @@ describe('SessionTitleService', () => { ); reg.defineInstance(IProviderService, stubProviderService(providers)); reg.definePartialInstance(IOAuthService, { - resolveTokenProvider: () => ({ getAccessToken: async () => 'test-token' }), + resolveTokenProvider: () => ({ + getAccessToken: async () => { + if (tokenError !== undefined) throw tokenError; + return 'test-token'; + }, + }), }); reg.defineInstance(IHostRequestHeaders, new HostRequestHeaders({ 'User-Agent': 'test' })); reg.define(ISessionTitleService, SessionTitleService); @@ -152,6 +166,7 @@ describe('SessionTitleService', () => { afterEach(() => { disposables.dispose(); vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); function publishEasyTitle(lastPrompt: string, sessionId = SESSION_ID): void { @@ -193,23 +208,21 @@ describe('SessionTitleService', () => { flagEnabled = false; publishEasyTitle('hello'); - await new Promise((resolve) => setTimeout(resolve, 50)); expect(fetchMock).not.toHaveBeenCalled(); expect(metadata.meta.title).toBeUndefined(); }); it('does nothing without a managed OAuth provider', async () => { delete providers['managed:kimi-code']; - publishEasyTitle('hello'); + await metadata.update({ lastPrompt: 'hello' }); - await new Promise((resolve) => setTimeout(resolve, 50)); + await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); expect(fetchMock).not.toHaveBeenCalled(); }); it('ignores easy-title events from other sessions', async () => { publishEasyTitle('hello', 'sess-other'); - await new Promise((resolve) => setTimeout(resolve, 50)); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -224,7 +237,6 @@ describe('SessionTitleService', () => { }, }); - await new Promise((resolve) => setTimeout(resolve, 50)); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -233,7 +245,6 @@ describe('SessionTitleService', () => { await waitFor(() => metadata.meta.title === '生成的标题'); publishEasyTitle('second'); - await new Promise((resolve) => setTimeout(resolve, 50)); expect(fetchMock).toHaveBeenCalledTimes(1); expect(metadata.meta.title).toBe('生成的标题'); }); @@ -247,7 +258,8 @@ describe('SessionTitleService', () => { }), ); - publishEasyTitle('hello'); + await metadata.update({ lastPrompt: 'hello' }); + const generation = ix.get(ISessionTitleService).generateTitle(); await waitFor(() => fetchMock.mock.calls.length === 1); await metadata.setTitle('user 取的标题'); resolveFetch?.( @@ -257,18 +269,74 @@ describe('SessionTitleService', () => { }), ); - await new Promise((resolve) => setTimeout(resolve, 50)); + await expect(generation).resolves.toBeUndefined(); expect(metadata.meta.title).toBe('user 取的标题'); expect(metadata.meta.isCustomTitle).toBe(true); }); it('keeps the current title when the backend request fails', async () => { fetchMock.mockImplementationOnce(async () => new Response('', { status: 500 })); + await metadata.update({ title: 'hello', isCustomTitle: false, lastPrompt: 'hello' }); - publishEasyTitle('hello'); + await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); + expect(metadata.meta.title).toBe('hello'); + }); + + it('returns unavailable when the OAuth token is missing or revoked', async () => { + tokenError = new OAuthUnauthorizedError('re-login required'); + await metadata.update({ lastPrompt: 'hello' }); + + const svc = ix.get(ISessionTitleService); + await expect(svc.generateTitle()).resolves.toBeUndefined(); + 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' }); + + await ix.get(ISessionTitleService).generateTitle(); + + const [, init] = fetchMock.mock.calls[0]!; + const headers = new Headers(init?.headers as Record); + expect(headers.get('x-proxy-header')).toBe('from-env'); + expect(headers.get('user-agent')).toBe('test'); + }); + + it('does not generate over a legacy customTitle', async () => { + metadata.meta = { + ...metadata.meta, + lastPrompt: 'hello', + customTitle: 'legacy title', + } as SessionMeta; + + await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('shares an in-flight generation between automatic and manual requests', async () => { + let resolveFetch: ((response: Response) => void) | undefined; + fetchMock.mockImplementationOnce( + async () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + publishEasyTitle('first'); await waitFor(() => fetchMock.mock.calls.length === 1); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(metadata.meta.title).toBeUndefined(); + await metadata.update({ lastPrompt: 'second' }); + const manual = ix.get(ISessionTitleService).generateTitle(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + resolveFetch?.( + new Response(JSON.stringify({ title: '生成的标题' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + await expect(manual).resolves.toBe('生成的标题'); + expect(metadata.meta.title).toBe('生成的标题'); }); it('generateTitle() regenerates from the stored lastPrompt on demand', async () => { diff --git a/packages/agent-core/test/session/prompt-metadata.test.ts b/packages/agent-core/test/session/prompt-metadata.test.ts index 1a1eb94ba0..bf8595d5d7 100644 --- a/packages/agent-core/test/session/prompt-metadata.test.ts +++ b/packages/agent-core/test/session/prompt-metadata.test.ts @@ -18,6 +18,7 @@ import { join } from 'pathe'; import type { ProviderConfig } from '@moonshot-ai/kosong'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ProviderConfig as ConfigProviderConfig } from '../../src/config'; import { FlagResolver } from '../../src/flags'; import type { ResolvedAgentProfile } from '../../src/profile'; import type { SDKSessionRPC } from '../../src/rpc'; @@ -112,7 +113,7 @@ const MANAGED_PROVIDER = { type: 'kimi', baseUrl: 'https://api.example.test/coding/v1', oauth: { storage: 'file', key: 'kimi-code' }, -} as const; +} as const satisfies ConfigProviderConfig; describe('SessionAPIImpl auto title', () => { it('replaces the easy title with the generated one when the flag is on', async () => { @@ -147,7 +148,6 @@ describe('SessionAPIImpl auto title', () => { if (agent.turn.hasActiveTurn) { await agent.turn.waitForCurrentTurn(); } - await new Promise((resolve) => setTimeout(resolve, 50)); expect(chatTitleCalls(fetchMock)).toHaveLength(0); expect(session.metadata.title).toBe('帮我看个 Go 报错'); @@ -164,7 +164,6 @@ describe('SessionAPIImpl auto title', () => { if (agent.turn.hasActiveTurn) { await agent.turn.waitForCurrentTurn(); } - await new Promise((resolve) => setTimeout(resolve, 50)); expect(chatTitleCalls(fetchMock)).toHaveLength(0); expect(session.metadata.title).toBe('帮我看个 Go 报错'); @@ -173,7 +172,7 @@ describe('SessionAPIImpl auto title', () => { function chatTitleCalls(fetchMock: ReturnType): [string, RequestInit?][] { return (fetchMock.mock.calls as unknown as [string, RequestInit?][]).filter(([url]) => - String(url).includes('/tools'), + url.includes('/tools'), ); } @@ -203,7 +202,7 @@ async function setupAutoTitleSession(options: { autoTitle: boolean; managedOAuth const scripted = createScriptedGenerate(); const flags = new FlagResolver({}); flags.setConfigOverrides({ 'auto-title': options.autoTitle }); - const managed = + const managed: ConfigProviderConfig = options.managedOAuth === false ? { type: 'kimi', apiKey: 'sk-test' } : MANAGED_PROVIDER; diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index b6a2db27d9..e70d629c04 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -499,6 +499,26 @@ describe('server-v2 /api/v1/sessions', () => { expect(got.body.data.title).toBe('renamed'); }); + it('returns title-unavailable when generation cannot run', async () => { + const created = await postJson('/api/v1/sessions', { + metadata: { cwd: home as string }, + }); + + const generated = await postJson( + `/api/v1/sessions/${created.body.data.id}/title/generate`, + ); + + expect(generated.body.code).toBe(40922); + }); + + it('returns session-not-found when generating a title for a missing session', async () => { + const generated = await postJson( + '/api/v1/sessions/sess_missing_title/title/generate', + ); + + expect(generated.body.code).toBe(40401); + }); + it('returns best-effort status for a live session', async () => { const cwd = home as string; const created = await postJson('/api/v1/sessions', { metadata: { cwd } }); diff --git a/packages/oauth/test/managed-tools.test.ts b/packages/oauth/test/managed-tools.test.ts index a4eadc66ae..e33b109f52 100644 --- a/packages/oauth/test/managed-tools.test.ts +++ b/packages/oauth/test/managed-tools.test.ts @@ -1,3 +1,8 @@ +/** + * Scenario: the managed `/tools` chat_title request contract, including + * response validation, API failures, timeouts, and transport errors. + */ + import { afterEach, describe, expect, it, vi } from 'vitest'; import { fetchChatTitle, kimiCodeToolsUrl } from '../src/managed-tools'; From db79855986ea5c6ee57117c6c15fe789996e4a5b Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 29 Jul 2026 18:04:23 +0800 Subject: [PATCH 03/47] fix: preserve managed title request headers --- packages/agent-core/src/session/auto-title.ts | 3 +- .../src/session/provider-manager.ts | 26 ++++++++++---- .../test/session/prompt-metadata.test.ts | 36 +++++++++++++++++++ packages/oauth/src/managed-tools.ts | 2 +- packages/oauth/test/managed-tools.test.ts | 30 ++++++++++++++++ 5 files changed, 88 insertions(+), 9 deletions(-) diff --git a/packages/agent-core/src/session/auto-title.ts b/packages/agent-core/src/session/auto-title.ts index 84b6c37174..f9f29bb554 100644 --- a/packages/agent-core/src/session/auto-title.ts +++ b/packages/agent-core/src/session/auto-title.ts @@ -2,7 +2,6 @@ import { KIMI_CODE_PROVIDER_NAME, fetchChatTitle, kimiCodeToolsUrl, - parseKimiCodeCustomHeaders, } from '@moonshot-ai/kimi-code-oauth'; import type { Logger } from '#/logging/types'; @@ -34,7 +33,7 @@ export async function generateSessionTitle( kimiCodeToolsUrl(provider.baseUrl), await tokenProvider.getAccessToken(), `user: ${chatContent}`, - { headers: { ...provider.customHeaders, ...parseKimiCodeCustomHeaders() } }, + { headers: providerManager.resolveManagedRequestHeaders(KIMI_CODE_PROVIDER_NAME) }, ); if (result.kind !== 'ok') { log?.debug('chat_title request failed', { message: result.message }); diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 81845d241b..39335f7139 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -162,6 +162,14 @@ export class ProviderManager implements ModelProvider { return this.config.providers[providerName]; } + /** Resolve managed-request headers with the same env < host < provider precedence as model calls. */ + resolveManagedRequestHeaders(providerName: string): Record | undefined { + const provider = this.getProviderConfig(providerName); + return provider === undefined + ? undefined + : composeManagedRequestHeaders(provider, this.options.kimiRequestHeaders); + } + /** Resolve an OAuth bearer-token provider for a configured provider entry. */ resolveOAuthTokenProvider( providerName: string, @@ -321,7 +329,7 @@ function toKosongProviderConfig( // still win on conflict. ...defaultHeadersField( provider.type === 'kimi' && modelProtocol === 'anthropic' - ? { ...envCustomHeaders, ...kimiRequestHeaders, ...provider.customHeaders } + ? composeManagedRequestHeaders(provider, kimiRequestHeaders, envCustomHeaders) : { ...envCustomHeaders, ...kimiUserAgentHeader(kimiRequestHeaders), ...provider.customHeaders }, ), }; @@ -355,11 +363,9 @@ function toKosongProviderConfig( baseUrl: modelBaseUrl ?? providerValue(provider.baseUrl, provider.env, 'KIMI_BASE_URL'), apiKey: providerApiKey(provider), generationKwargs: { prompt_cache_key: promptCacheKey }, - ...defaultHeadersField({ - ...envCustomHeaders, - ...kimiRequestHeaders, - ...provider.customHeaders, - }), + ...defaultHeadersField( + composeManagedRequestHeaders(provider, kimiRequestHeaders, envCustomHeaders), + ), }; case 'google-genai': return { @@ -435,6 +441,14 @@ function defaultHeadersField( return { defaultHeaders: { ...headers } }; } +function composeManagedRequestHeaders( + provider: Pick, + kimiRequestHeaders: Record | undefined, + envCustomHeaders = parseKimiCodeCustomHeaders(), +): Record { + return { ...envCustomHeaders, ...kimiRequestHeaders, ...provider.customHeaders }; +} + // Extract just the `User-Agent` from the Kimi identity headers so non-Kimi // providers (OpenAI, Anthropic, Google, Vertex) also identify as // `kimi-code-cli/` without leaking the `X-Msh-*` device identity diff --git a/packages/agent-core/test/session/prompt-metadata.test.ts b/packages/agent-core/test/session/prompt-metadata.test.ts index bf8595d5d7..da1986ce51 100644 --- a/packages/agent-core/test/session/prompt-metadata.test.ts +++ b/packages/agent-core/test/session/prompt-metadata.test.ts @@ -9,6 +9,11 @@ * whether it is a standalone text part or merged into the user's text * - SessionAPIImpl.steer updates title/lastPrompt exactly like prompt — * a steer can launch the session's first turn (e.g. goal mode) + * - auto-title sends env < host < provider request-header layers + * + * Wiring: real Session / ProviderManager with scripted model output; only the + * external fetch boundary is stubbed. Run with: + * `pnpm exec vitest run packages/agent-core/test/session/prompt-metadata.test.ts`. */ import { mkdtemp, rm } from 'node:fs/promises'; @@ -113,6 +118,7 @@ const MANAGED_PROVIDER = { type: 'kimi', baseUrl: 'https://api.example.test/coding/v1', oauth: { storage: 'file', key: 'kimi-code' }, + customHeaders: { 'X-Msh-Platform': 'provider-platform' }, } as const satisfies ConfigProviderConfig; describe('SessionAPIImpl auto title', () => { @@ -140,6 +146,30 @@ describe('SessionAPIImpl auto title', () => { expect(titleEvents.at(-1)).toMatchObject({ title: '生成的标题' }); }); + it('sends managed request headers with the established layer precedence', async () => { + vi.stubEnv( + 'KIMI_CODE_CUSTOM_HEADERS', + 'User-Agent: env-client\nX-Msh-Platform: env-platform\nX-Environment: present', + ); + const fetchMock = stubChatTitleFetch('生成的标题'); + const { api, events, agent } = await setupAutoTitleSession({ autoTitle: true }); + + await api.prompt({ agentId: 'main', input: [{ type: 'text', text: '帮我看个 Go 报错' }] }); + if (agent.turn.hasActiveTurn) { + await agent.turn.waitForCurrentTurn(); + } + await waitFor(() => + events.some((e) => e['type'] === 'session.meta.updated' && e['title'] === '生成的标题'), + ); + + const [, init] = chatTitleCalls(fetchMock)[0]!; + const headers = new Headers(init?.headers as Record); + expect(headers.get('user-agent')).toBe('kimi-code-cli/test'); + expect(headers.get('x-msh-device-id')).toBe('device-test'); + expect(headers.get('x-msh-platform')).toBe('provider-platform'); + expect(headers.get('x-environment')).toBe('present'); + }); + it('keeps the easy title when the flag is off', async () => { const fetchMock = stubChatTitleFetch('生成的标题'); const { api, session, agent } = await setupAutoTitleSession({ autoTitle: false }); @@ -227,6 +257,11 @@ async function setupAutoTitleSession(options: { autoTitle: boolean; managedOAuth }, }, }, + kimiRequestHeaders: { + 'User-Agent': 'kimi-code-cli/test', + 'X-Msh-Platform': 'host-platform', + 'X-Msh-Device-Id': 'device-test', + }, resolveOAuthTokenProvider: () => ({ getAccessToken: async () => 'test-token' }), }), experimentalFlags: flags, @@ -252,6 +287,7 @@ function track(session: Session): Session { afterEach(async () => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); // Close sessions first so their async metadata/wire writes settle before the // temp dirs are removed (otherwise rm races with a write -> ENOTEMPTY). await Promise.allSettled(openSessions.splice(0).map((s) => s.close())); diff --git a/packages/oauth/src/managed-tools.ts b/packages/oauth/src/managed-tools.ts index 45cae0c966..546e7faa69 100644 --- a/packages/oauth/src/managed-tools.ts +++ b/packages/oauth/src/managed-tools.ts @@ -44,10 +44,10 @@ export async function fetchChatTitle( const res = await fetch(url, { method: 'POST', headers: { + ...opts.headers, Authorization: `Bearer ${accessToken}`, Accept: 'application/json', 'Content-Type': 'application/json', - ...opts.headers, }, body: JSON.stringify({ method: 'chat_title', diff --git a/packages/oauth/test/managed-tools.test.ts b/packages/oauth/test/managed-tools.test.ts index e33b109f52..ea5d71166e 100644 --- a/packages/oauth/test/managed-tools.test.ts +++ b/packages/oauth/test/managed-tools.test.ts @@ -1,6 +1,9 @@ /** * Scenario: the managed `/tools` chat_title request contract, including * response validation, API failures, timeouts, and transport errors. + * Wiring: the real request builder with only the external fetch boundary + * stubbed. Run with: + * `pnpm exec vitest run packages/oauth/test/managed-tools.test.ts`. */ import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -57,6 +60,33 @@ describe('fetchChatTitle', () => { }); }); + it('keeps protocol headers authoritative when custom headers collide', async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ title: '标题' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + await fetchChatTitle('https://api.example/tools', 'access-token', 'user: hi', { + headers: { + Authorization: 'Bearer wrong-token', + Accept: 'text/plain', + 'Content-Type': 'text/plain', + 'X-Proxy-Header': 'present', + }, + }); + + const [, init] = (fetchMock.mock.calls as unknown as [string, RequestInit?][])[0]!; + const headers = new Headers(init?.headers as Record); + expect(headers.get('authorization')).toBe('Bearer access-token'); + expect(headers.get('accept')).toBe('application/json'); + expect(headers.get('content-type')).toBe('application/json'); + expect(headers.get('x-proxy-header')).toBe('present'); + }); + it('trims surrounding whitespace from the title', async () => { vi.stubGlobal( 'fetch', From 7e09814904fdd4c1c07247a6dee1f601736ce7fb Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 29 Jul 2026 18:06:39 +0800 Subject: [PATCH 04/47] Pair auto-title endpoint overrides with matching OAuth credentials --- .../sessionTitle/sessionTitleService.ts | 12 +++++- .../sessionTitle/sessionTitleService.test.ts | 38 ++++++++++++++---- packages/agent-core/src/session/auto-title.ts | 9 ++++- .../test/session/prompt-metadata.test.ts | 39 +++++++++++++++++-- 4 files changed, 83 insertions(+), 15 deletions(-) diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index aa286512b5..2f80463e89 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -21,6 +21,7 @@ import { fetchChatTitle, kimiCodeToolsUrl, parseKimiCodeCustomHeaders, + resolveKimiCodeRuntimeAuth, } from '@moonshot-ai/kimi-code-oauth'; import { Disposable } from '#/_base/di/lifecycle'; @@ -105,7 +106,14 @@ export class SessionTitleService extends Disposable implements ISessionTitleServ ) { return undefined; } - const tokenProvider = this.oauth.resolveTokenProvider(KIMI_CODE_PROVIDER_NAME, provider.oauth); + const runtimeAuth = resolveKimiCodeRuntimeAuth({ + configuredBaseUrl: provider.baseUrl, + configuredOAuthRef: provider.oauth, + }); + const tokenProvider = this.oauth.resolveTokenProvider( + KIMI_CODE_PROVIDER_NAME, + runtimeAuth.oauthRef, + ); if (tokenProvider === undefined) return undefined; let token: string; try { @@ -116,7 +124,7 @@ export class SessionTitleService extends Disposable implements ISessionTitleServ return undefined; } const result = await fetchChatTitle( - kimiCodeToolsUrl(provider.baseUrl), + kimiCodeToolsUrl(runtimeAuth.baseUrl), token, `user: ${chatContent}`, { 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 8ef512c8fc..40af63f59a 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -15,7 +15,11 @@ import { IOAuthService } from '#/app/auth/auth'; import { type DomainEvent, IEventService } from '#/app/event/event'; import { IFlagService } from '#/app/flag/flag'; import { HostRequestHeaders, IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; -import { IProviderService, type ProviderConfig } from '#/kosong/provider/provider'; +import { + IProviderService, + type OAuthRef, + type ProviderConfig, +} from '#/kosong/provider/provider'; import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata, @@ -110,10 +114,12 @@ describe('SessionTitleService', () => { let providers: Record; let fetchMock: Mock<(url: string, init?: RequestInit) => Promise>; let tokenError: Error | undefined; + let resolvedOAuthRefs: Array; beforeEach(() => { flagEnabled = true; tokenError = undefined; + resolvedOAuthRefs = []; providers = { 'managed:kimi-code': MANAGED_PROVIDER }; metadata = new FakeSessionMetadata(); events = new FakeEventService(); @@ -148,12 +154,15 @@ describe('SessionTitleService', () => { ); reg.defineInstance(IProviderService, stubProviderService(providers)); reg.definePartialInstance(IOAuthService, { - resolveTokenProvider: () => ({ - getAccessToken: async () => { - if (tokenError !== undefined) throw tokenError; - return 'test-token'; - }, - }), + resolveTokenProvider: (_provider, oauthRef) => { + resolvedOAuthRefs.push(oauthRef); + return { + getAccessToken: async () => { + if (tokenError !== undefined) throw tokenError; + return 'test-token'; + }, + }; + }, }); reg.defineInstance(IHostRequestHeaders, new HostRequestHeaders({ 'User-Agent': 'test' })); reg.define(ISessionTitleService, SessionTitleService); @@ -303,6 +312,21 @@ describe('SessionTitleService', () => { expect(headers.get('user-agent')).toBe('test'); }); + it('pairs the environment endpoint with its credential slot when it overrides persisted config', async () => { + vi.stubEnv('KIMI_CODE_BASE_URL', 'https://api.env.example.test/coding/v1'); + vi.stubEnv('KIMI_CODE_OAUTH_HOST', 'https://auth.env.example.test'); + await metadata.update({ lastPrompt: 'hello' }); + + await ix.get(ISessionTitleService).generateTitle(); + + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://api.env.example.test/coding/v1/tools'); + expect(resolvedOAuthRefs[0]).toMatchObject({ + storage: 'file', + oauthHost: 'https://auth.env.example.test', + }); + expect(resolvedOAuthRefs[0]?.key).not.toBe(MANAGED_PROVIDER.oauth?.key); + }); + it('does not generate over a legacy customTitle', async () => { metadata.meta = { ...metadata.meta, diff --git a/packages/agent-core/src/session/auto-title.ts b/packages/agent-core/src/session/auto-title.ts index f9f29bb554..1e724059a0 100644 --- a/packages/agent-core/src/session/auto-title.ts +++ b/packages/agent-core/src/session/auto-title.ts @@ -2,6 +2,7 @@ import { KIMI_CODE_PROVIDER_NAME, fetchChatTitle, kimiCodeToolsUrl, + resolveKimiCodeRuntimeAuth, } from '@moonshot-ai/kimi-code-oauth'; import type { Logger } from '#/logging/types'; @@ -24,13 +25,17 @@ export async function generateSessionTitle( if (providerManager === undefined) return undefined; const provider = providerManager.getProviderConfig(KIMI_CODE_PROVIDER_NAME); if (provider?.oauth === undefined) return undefined; + const runtimeAuth = resolveKimiCodeRuntimeAuth({ + configuredBaseUrl: provider.baseUrl, + configuredOAuthRef: provider.oauth, + }); const tokenProvider = providerManager.resolveOAuthTokenProvider( KIMI_CODE_PROVIDER_NAME, - provider.oauth, + runtimeAuth.oauthRef, ); if (tokenProvider === undefined) return undefined; const result = await fetchChatTitle( - kimiCodeToolsUrl(provider.baseUrl), + kimiCodeToolsUrl(runtimeAuth.baseUrl), await tokenProvider.getAccessToken(), `user: ${chatContent}`, { headers: providerManager.resolveManagedRequestHeaders(KIMI_CODE_PROVIDER_NAME) }, diff --git a/packages/agent-core/test/session/prompt-metadata.test.ts b/packages/agent-core/test/session/prompt-metadata.test.ts index da1986ce51..dccbd1c3e0 100644 --- a/packages/agent-core/test/session/prompt-metadata.test.ts +++ b/packages/agent-core/test/session/prompt-metadata.test.ts @@ -9,7 +9,8 @@ * whether it is a standalone text part or merged into the user's text * - SessionAPIImpl.steer updates title/lastPrompt exactly like prompt — * a steer can launch the session's first turn (e.g. goal mode) - * - auto-title sends env < host < provider request-header layers + * - auto-title sends the effective managed endpoint, credentials, and + * env < host < provider request-header layers * * Wiring: real Session / ProviderManager with scripted model output; only the * external fetch boundary is stubbed. Run with: @@ -23,7 +24,7 @@ import { join } from 'pathe'; import type { ProviderConfig } from '@moonshot-ai/kosong'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { ProviderConfig as ConfigProviderConfig } from '../../src/config'; +import type { OAuthRef, ProviderConfig as ConfigProviderConfig } from '../../src/config'; import { FlagResolver } from '../../src/flags'; import type { ResolvedAgentProfile } from '../../src/profile'; import type { SDKSessionRPC } from '../../src/rpc'; @@ -198,6 +199,32 @@ describe('SessionAPIImpl auto title', () => { expect(chatTitleCalls(fetchMock)).toHaveLength(0); expect(session.metadata.title).toBe('帮我看个 Go 报错'); }); + + it('pairs the environment endpoint with its credential slot when it overrides persisted config', async () => { + vi.stubEnv('KIMI_CODE_BASE_URL', 'https://api.env.example.test/coding/v1'); + vi.stubEnv('KIMI_CODE_OAUTH_HOST', 'https://auth.env.example.test'); + const fetchMock = stubChatTitleFetch('生成的标题'); + const { api, events, agent, resolvedOAuthRefs } = await setupAutoTitleSession({ + autoTitle: true, + }); + + await api.prompt({ agentId: 'main', input: [{ type: 'text', text: '帮我看个 Go 报错' }] }); + if (agent.turn.hasActiveTurn) { + await agent.turn.waitForCurrentTurn(); + } + await waitFor(() => + events.some((e) => e['type'] === 'session.meta.updated' && e['title'] === '生成的标题'), + ); + + expect(chatTitleCalls(fetchMock)[0]?.[0]).toBe( + 'https://api.env.example.test/coding/v1/tools', + ); + expect(resolvedOAuthRefs[0]).toMatchObject({ + storage: 'file', + oauthHost: 'https://auth.env.example.test', + }); + expect(resolvedOAuthRefs[0]?.key).not.toBe(MANAGED_PROVIDER.oauth.key); + }); }); function chatTitleCalls(fetchMock: ReturnType): [string, RequestInit?][] { @@ -231,6 +258,7 @@ async function setupAutoTitleSession(options: { autoTitle: boolean; managedOAuth const events: Array> = []; const scripted = createScriptedGenerate(); const flags = new FlagResolver({}); + const resolvedOAuthRefs: Array = []; flags.setConfigOverrides({ 'auto-title': options.autoTitle }); const managed: ConfigProviderConfig = options.managedOAuth === false @@ -262,7 +290,10 @@ async function setupAutoTitleSession(options: { autoTitle: boolean; managedOAuth 'X-Msh-Platform': 'host-platform', 'X-Msh-Device-Id': 'device-test', }, - resolveOAuthTokenProvider: () => ({ getAccessToken: async () => 'test-token' }), + resolveOAuthTokenProvider: (_providerName, oauthRef) => { + resolvedOAuthRefs.push(oauthRef); + return { getAccessToken: async () => 'test-token' }; + }, }), experimentalFlags: flags, }), @@ -274,7 +305,7 @@ async function setupAutoTitleSession(options: { autoTitle: boolean; managedOAuth agent.config.update({ modelAlias: MOCK_PROVIDER.model, thinkingEffort: 'off' }); agent.permission.setMode('yolo'); const api = new SessionAPIImpl(session); - return { api, session, events, agent }; + return { api, session, events, agent, resolvedOAuthRefs }; } const tempDirs: string[] = []; From 6ef7c260b81704eec921211e49789f0f2f48cb23 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 29 Jul 2026 18:51:12 +0800 Subject: [PATCH 05/47] fix: preserve legacy custom session titles --- .../sessionMetadata/sessionMetadataService.ts | 2 +- .../sessionMetadata/sessionMetadata.test.ts | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 0373a2527e..744bc1f7ef 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -216,7 +216,7 @@ export function normalizeSessionMeta(raw: SessionMeta, sessionId: string): Sessi : undefined); const legacyCustomTitle = typeof legacy.customTitle === 'string' ? legacy.customTitle : undefined; - const title = raw.title ?? legacyCustomTitle; + const title = legacyCustomTitle ?? raw.title; const isCustomTitle = legacyCustomTitle === undefined ? raw.isCustomTitle : true; if (raw.version === SESSION_META_VERSION) { if (cwd === raw.cwd && title === raw.title && isCustomTitle === raw.isCustomTitle) { 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 2fe28c919b..28060aa740 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -149,6 +149,33 @@ describe('SessionMetadata', () => { }); }); + it('preserves a legacy customTitle when title is also present', async () => { + const store = ix.get(IAtomicDocumentStore); + await store.set(META_SCOPE, 'state.json', { + id: 's1', + version: 2, + createdAt: 1700000000000, + updatedAt: 1700000000000, + archived: false, + title: 'base title', + customTitle: 'legacy custom title', + }); + + const meta = ix.get(ISessionMetadata); + await expect(meta.read()).resolves.toMatchObject({ + title: 'legacy custom title', + isCustomTitle: true, + }); + + await meta.update({ archived: true }); + const fresh = createFreshMetadata(ix); + await expect(fresh.read()).resolves.toMatchObject({ + title: 'legacy custom title', + isCustomTitle: true, + archived: true, + }); + }); + it('leaves existing agents/custom maps untouched', async () => { const store = ix.get(IAtomicDocumentStore); await store.set(META_SCOPE, 'state.json', { From 2162ec97dac75878bc5b9547de804fab230b631f Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 29 Jul 2026 19:44:32 +0800 Subject: [PATCH 06/47] fix: preserve automatic session title invariants --- .../sessionMetadata/sessionMetadata.ts | 1 + .../sessionMetadata/sessionMetadataService.ts | 26 ++++++-- .../sessionTitle/sessionTitleService.ts | 16 +++-- .../app/sessionExport/sessionExport.test.ts | 1 + .../sessionLifecycle/sessionLifecycle.test.ts | 1 + .../sessionMetadata/sessionMetadata.test.ts | 64 +++++++++++++++++-- .../sessionTitle/sessionTitleService.test.ts | 40 +++++++++++- packages/agent-core-v2/test/tool/tool.test.ts | 1 + packages/agent-core/src/session/rpc.ts | 9 ++- .../test/session/prompt-metadata.test.ts | 25 ++++++++ packages/oauth/src/managed-tools.ts | 11 ++-- packages/oauth/test/managed-tools.test.ts | 8 +-- 12 files changed, 172 insertions(+), 31 deletions(-) diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts index 28c14ad24b..161c520d7a 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 744bc1f7ef..3951239a88 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 2f80463e89..25d29bf8e8 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 7cfdab0be9..bfeebba161 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 40766015e6..eca1bd241a 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 28060aa740..a4ed327be3 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 40af63f59a..1b7cfc0393 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 fa98629ac1..ca7df7c49f 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 ff52c0356f..4a704ee9bc 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 dccbd1c3e0..d3ef3b58ca 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 546e7faa69..c859239c84 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 ea5d71166e..fd74296035 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', }, }); From f66a0c592fd5ed65e3a8e534f0036714061e2e5f Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 29 Jul 2026 20:46:34 +0800 Subject: [PATCH 07/47] refactor: keep only the on-demand session title generation interface Drop the automatic wiring on both engines: the v1 (TUI) first-prompt trigger and the v2 easy-title event watcher. SessionTitleService's generateTitle() stays as the single on-demand entry point behind the auto-title flag, backing the kap-server title/generate route. The changeset goes away too: with no shipped consumer, the remaining surface is not user-perceivable. --- .changeset/auto-session-title.md | 5 - .../scripts/check-domain-layers.mjs | 6 +- .../src/session/sessionTitle/flag.ts | 8 +- .../src/session/sessionTitle/sessionTitle.ts | 4 +- .../sessionTitle/sessionTitleService.ts | 63 +---- .../sessionTitle/sessionTitleService.test.ts | 87 ++----- packages/agent-core/src/flags/registry.ts | 9 - packages/agent-core/src/session/auto-title.ts | 48 ---- .../src/session/provider-manager.ts | 39 +-- packages/agent-core/src/session/rpc.ts | 46 +--- .../test/session/prompt-metadata.test.ts | 228 ------------------ 11 files changed, 45 insertions(+), 498 deletions(-) delete mode 100644 .changeset/auto-session-title.md delete mode 100644 packages/agent-core/src/session/auto-title.ts diff --git a/.changeset/auto-session-title.md b/.changeset/auto-session-title.md deleted file mode 100644 index 3ddb095412..0000000000 --- a/.changeset/auto-session-title.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Add experimental auto-generated session titles: with the `auto-title` experimental flag on and a Kimi For Coding login, the session title is generated from the first prompt instead of the truncated prompt text. Off by default — enable with `KIMI_CODE_EXPERIMENTAL_AUTO_TITLE=1`. diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index cdf18c2477..24ed08caa5 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -239,9 +239,9 @@ const DOMAIN_LAYER = new Map([ // L6 beside the other cross-agent coordinators. ['undo', 6], ['sessionActivity', 6], - // `sessionTitle` generates the session title through the managed platform's - // `chat_title` tool: it watches the `event` bus for the easy-title moment - // and persists through `sessionMetadata`, so it sits in L6 beside it. + // `sessionTitle` generates the session title on demand through the managed + // platform's `chat_title` tool and persists through `sessionMetadata`, so + // it sits in L6 beside it. ['sessionTitle', 6], ['session', 6], ['terminal', 6], diff --git a/packages/agent-core-v2/src/session/sessionTitle/flag.ts b/packages/agent-core-v2/src/session/sessionTitle/flag.ts index 1d69fbf061..8cbdfc5d69 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/flag.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/flag.ts @@ -2,9 +2,9 @@ * `sessionTitle` domain (L6) — registers the `auto-title` experimental flag * into `flag`. * - * Gates generating the session title through the managed platform's - * `chat_title` tool (replacing the truncated-prompt easy title). Off by - * default; enable via `KIMI_CODE_EXPERIMENTAL_AUTO_TITLE`, the master + * Gates generating the session title on demand through the managed + * platform's `chat_title` tool. Off by default; enable via + * `KIMI_CODE_EXPERIMENTAL_AUTO_TITLE`, the master * `KIMI_CODE_EXPERIMENTAL_FLAG`, or the `[experimental]` config section. * Imported for its side effect (registers the definition) from the package * barrel. @@ -19,7 +19,7 @@ export const autoTitleFlag: FlagDefinitionInput = { id: AUTO_TITLE_FLAG_ID, title: 'Auto session title', description: - 'Generate the session title from the first prompt through the managed chat_title tool instead of keeping the truncated prompt. Requires a managed Kimi Code OAuth login.', + 'Generate the session title on demand from the first prompt through the managed chat_title tool. Requires a managed Kimi Code OAuth login.', env: AUTO_TITLE_FLAG_ENV, default: false, surface: 'both', diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts index 9c24bcad1b..26b0186c6a 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts @@ -2,8 +2,8 @@ * `sessionTitle` domain (L6) — session title generation contract. * * Defines the `ISessionTitleService` that (re)generates the session's title - * from its prompt history through the managed platform's `chat_title` tool. - * Bound at Session scope — one instance per session. + * on demand from its first prompt through the managed platform's `chat_title` + * tool. Bound at Session scope — one instance per session. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index 25d29bf8e8..b686ec1aa0 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -1,18 +1,17 @@ /** * `sessionTitle` domain (L6) — `ISessionTitleService` implementation. * - * Watches the global `event` bus for the session's own easy-title moment (a - * `session.meta.updated` whose patch sets a non-custom title, published by - * the `rpc` prompt-metadata flow on the first prompt) and, when the - * `auto-title` experimental flag is on and the managed Kimi Code provider - * carries an OAuth ref, generates a real title from the already-sanitized - * `lastPrompt` through the platform `/tools` `chat_title` endpoint, persists + * 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 * it through `sessionMetadata`, and rebroadcasts `session.meta.updated`. - * Provider config comes from `provider`, the bearer token from `auth`, host - * identity headers from `model`, gating from `flag`, and logs through `log`. - * The public `generateTitle()` is the manual entry point (server route); any - * failure degrades to keeping the current title, and a custom title set by - * the user is never overwritten. Bound at Session scope. + * Generation is on demand only: `generateTitle()` is the single entry point + * (the kap-server route), gated by the `auto-title` experimental flag and a + * managed Kimi Code OAuth login; any failure degrades to keeping the current + * title, and a custom title set by the user is never overwritten. Provider + * config comes from `provider`, the bearer token from `auth`, host identity + * headers from `model`, gating from `flag`, and logs through `log`. Bound at + * Session scope. */ import { @@ -24,11 +23,10 @@ import { resolveKimiCodeRuntimeAuth, } from '@moonshot-ai/kimi-code-oauth'; -import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { IOAuthService } from '#/app/auth/auth'; -import { IEventService, type DomainEvent } from '#/app/event/event'; +import { IEventService } from '#/app/event/event'; import { IFlagService } from '#/app/flag/flag'; import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; import { IProviderService } from '#/kosong/provider/provider'; @@ -41,10 +39,9 @@ import { ISessionTitleService } from './sessionTitle'; const MAX_GENERATED_TITLE_LENGTH = 200; -export class SessionTitleService extends Disposable implements ISessionTitleService { +export class SessionTitleService implements ISessionTitleService { declare readonly _serviceBrand: undefined; - private _autoAttempted = false; private _generation: Promise | undefined; constructor( @@ -56,14 +53,7 @@ export class SessionTitleService extends Disposable implements ISessionTitleServ @IOAuthService private readonly oauth: IOAuthService, @IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders, @ILogService private readonly log: ILogService, - ) { - super(); - this._register( - this.eventService.subscribe((event) => { - this.onMetaUpdated(event); - }), - ); - } + ) {} async generateTitle(): Promise { if (!this.flags.enabled(AUTO_TITLE_FLAG_ID)) return undefined; @@ -72,19 +62,6 @@ export class SessionTitleService extends Disposable implements ISessionTitleServ return this.generateAndApply(current.lastPrompt); } - private onMetaUpdated(event: DomainEvent): void { - if (event.type !== 'session.meta.updated' || this._autoAttempted) return; - const lastPrompt = readEasyTitleLastPrompt(event.payload, this.ctx.sessionId); - if (lastPrompt === undefined) return; - if (!this.flags.enabled(AUTO_TITLE_FLAG_ID)) return; - this._autoAttempted = true; - void this.generateAndApply(lastPrompt).catch((error: unknown) => { - this.log.warn( - `auto session title failed: ${error instanceof Error ? error.message : String(error)}`, - ); - }); - } - private async generateAndApply(chatContent: string): Promise { const inFlight = this._generation; if (inFlight !== undefined) return inFlight; @@ -166,20 +143,6 @@ function hasCustomTitle(metadata: { return metadata.isCustomTitle === true || typeof metadata.customTitle === 'string'; } -function readEasyTitleLastPrompt(payload: unknown, sessionId: string): string | undefined { - if (typeof payload !== 'object' || payload === null) return undefined; - const record = payload as Record; - if (record['sessionId'] !== sessionId) return undefined; - const patch = record['patch']; - if (typeof patch !== 'object' || patch === null) return undefined; - const patchRecord = patch as Record; - if (patchRecord['isCustomTitle'] !== false || typeof patchRecord['title'] !== 'string') { - return undefined; - } - const lastPrompt = patchRecord['lastPrompt']; - return typeof lastPrompt === 'string' && lastPrompt.length > 0 ? lastPrompt : undefined; -} - registerScopedService( LifecycleScope.Session, ISessionTitleService, 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 1b7cfc0393..b719f5951c 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -1,6 +1,7 @@ /** - * Scenario: managed chat_title generation through the session-scoped service, - * including OAuth failures, legacy custom titles, request headers, and races. + * Scenario: on-demand managed chat_title generation through the session-scoped + * service, including OAuth failures, legacy custom titles, request headers, + * and races. */ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; @@ -10,7 +11,6 @@ import { OAuthConnectionError, OAuthUnauthorizedError } from '@moonshot-ai/kimi- import { DisposableStore, type IDisposable } from '#/_base/di/lifecycle'; import { createServices, type TestInstantiationService } from '#/_base/di/test'; import { Emitter } from '#/_base/event'; -import { ILogService } from '#/_base/log/log'; import { IOAuthService } from '#/app/auth/auth'; import { type DomainEvent, IEventService } from '#/app/event/event'; import { IFlagService } from '#/app/flag/flag'; @@ -183,22 +183,13 @@ describe('SessionTitleService', () => { vi.unstubAllEnvs(); }); - function publishEasyTitle(lastPrompt: string, sessionId = SESSION_ID): void { - events.publish({ - type: 'session.meta.updated', - payload: { - agentId: 'main', - sessionId, - title: lastPrompt.slice(0, 200), - patch: { title: lastPrompt.slice(0, 200), isCustomTitle: false, lastPrompt }, - }, - }); - } + it('replaces the easy title with the generated one', async () => { + await metadata.update({ lastPrompt: '帮我看一下这个 Go 的 nil pointer 报错' }); - it('replaces the easy title with the generated one after the first prompt', async () => { - publishEasyTitle('帮我看一下这个 Go 的 nil pointer 报错'); + const title = await ix.get(ISessionTitleService).generateTitle(); - await waitFor(() => metadata.meta.title === '生成的标题'); + expect(title).toBe('生成的标题'); + expect(metadata.meta.title).toBe('生成的标题'); expect(metadata.meta.isCustomTitle).toBe(false); const [, init] = fetchMock.mock.calls[0]!; @@ -218,14 +209,6 @@ describe('SessionTitleService', () => { expect(rebroadcast).toBeDefined(); }); - it('does nothing when the auto-title flag is off', async () => { - flagEnabled = false; - publishEasyTitle('hello'); - - expect(fetchMock).not.toHaveBeenCalled(); - expect(metadata.meta.title).toBeUndefined(); - }); - it('does nothing without a managed OAuth provider', async () => { delete providers['managed:kimi-code']; await metadata.update({ lastPrompt: 'hello' }); @@ -234,35 +217,6 @@ describe('SessionTitleService', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('ignores easy-title events from other sessions', async () => { - publishEasyTitle('hello', 'sess-other'); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('ignores manual renames (custom title patches)', async () => { - events.publish({ - type: 'session.meta.updated', - payload: { - agentId: 'main', - sessionId: SESSION_ID, - title: 'mine', - patch: { title: 'mine', isCustomTitle: true }, - }, - }); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('attempts generation only once per session scope', async () => { - publishEasyTitle('first'); - await waitFor(() => metadata.meta.title === '生成的标题'); - - publishEasyTitle('second'); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(metadata.meta.title).toBe('生成的标题'); - }); - it('never overwrites a custom title set while generation is in flight', async () => { let resolveFetch: ((response: Response) => void) | undefined; fetchMock.mockImplementationOnce( @@ -374,7 +328,7 @@ describe('SessionTitleService', () => { expect(metadata.meta.title).toBe('生成的标题'); }); - it('shares an in-flight generation between automatic and manual requests', async () => { + it('shares an in-flight generation between concurrent requests', async () => { let resolveFetch: ((response: Response) => void) | undefined; fetchMock.mockImplementationOnce( async () => @@ -383,11 +337,10 @@ describe('SessionTitleService', () => { }), ); - publishEasyTitle('first'); + await metadata.update({ lastPrompt: 'hello' }); + const first = ix.get(ISessionTitleService).generateTitle(); + const second = ix.get(ISessionTitleService).generateTitle(); await waitFor(() => fetchMock.mock.calls.length === 1); - await metadata.update({ lastPrompt: 'second' }); - const manual = ix.get(ISessionTitleService).generateTitle(); - expect(fetchMock).toHaveBeenCalledTimes(1); resolveFetch?.( new Response(JSON.stringify({ title: '生成的标题' }), { @@ -395,19 +348,9 @@ describe('SessionTitleService', () => { headers: { 'Content-Type': 'application/json' }, }), ); - await expect(manual).resolves.toBe('生成的标题'); - expect(metadata.meta.title).toBe('生成的标题'); - }); - - it('generateTitle() regenerates from the stored lastPrompt on demand', async () => { - await metadata.update({ lastPrompt: '帮我把这个脚本改成异步' }); - - const svc = ix.get(ISessionTitleService); - const title = await svc.generateTitle(); - - expect(title).toBe('生成的标题'); - expect(metadata.meta.title).toBe('生成的标题'); - expect(metadata.meta.isCustomTitle).toBe(false); + await expect(first).resolves.toBe('生成的标题'); + await expect(second).resolves.toBe('生成的标题'); + expect(fetchMock).toHaveBeenCalledTimes(1); }); it('generateTitle() returns undefined when the flag is off or no prompt was seen', async () => { diff --git a/packages/agent-core/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts index 64c9f697db..dbec75b80b 100644 --- a/packages/agent-core/src/flags/registry.ts +++ b/packages/agent-core/src/flags/registry.ts @@ -32,15 +32,6 @@ export const FLAG_DEFINITIONS = [ default: false, surface: 'core', }, - { - id: 'auto-title', - title: 'Auto session title', - description: - 'Generate the session title from the first prompt through the managed chat_title tool instead of keeping the truncated prompt. Requires a managed Kimi Code OAuth login.', - env: 'KIMI_CODE_EXPERIMENTAL_AUTO_TITLE', - default: false, - surface: 'both', - }, ] as const satisfies readonly FlagDefinitionInput[]; /** Literal union of registered flag ids. */ diff --git a/packages/agent-core/src/session/auto-title.ts b/packages/agent-core/src/session/auto-title.ts deleted file mode 100644 index 1e724059a0..0000000000 --- a/packages/agent-core/src/session/auto-title.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - KIMI_CODE_PROVIDER_NAME, - fetchChatTitle, - kimiCodeToolsUrl, - resolveKimiCodeRuntimeAuth, -} from '@moonshot-ai/kimi-code-oauth'; - -import type { Logger } from '#/logging/types'; - -import type { ProviderManager } from './provider-manager'; - -const MAX_GENERATED_TITLE_LENGTH = 200; - -/** - * Generate a session title through the managed platform `/tools` `chat_title` - * endpoint, from the session's (already sanitized) first prompt. Returns - * `undefined` when the managed provider is not OAuth-backed or the request - * fails — callers keep the easy title in that case. - */ -export async function generateSessionTitle( - chatContent: string, - providerManager: ProviderManager | undefined, - log?: Logger, -): Promise { - if (providerManager === undefined) return undefined; - const provider = providerManager.getProviderConfig(KIMI_CODE_PROVIDER_NAME); - if (provider?.oauth === undefined) return undefined; - const runtimeAuth = resolveKimiCodeRuntimeAuth({ - configuredBaseUrl: provider.baseUrl, - configuredOAuthRef: provider.oauth, - }); - const tokenProvider = providerManager.resolveOAuthTokenProvider( - KIMI_CODE_PROVIDER_NAME, - runtimeAuth.oauthRef, - ); - if (tokenProvider === undefined) return undefined; - const result = await fetchChatTitle( - kimiCodeToolsUrl(runtimeAuth.baseUrl), - await tokenProvider.getAccessToken(), - `user: ${chatContent}`, - { headers: providerManager.resolveManagedRequestHeaders(KIMI_CODE_PROVIDER_NAME) }, - ); - if (result.kind !== 'ok') { - log?.debug('chat_title request failed', { message: result.message }); - return undefined; - } - return result.title.slice(0, MAX_GENERATED_TITLE_LENGTH); -} diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 39335f7139..2bdb29a425 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -157,27 +157,6 @@ export class ProviderManager implements ModelProvider { }; } - /** Read a provider entry from the current config (live when constructed with a config getter). */ - getProviderConfig(providerName: string): ProviderConfig | undefined { - return this.config.providers[providerName]; - } - - /** Resolve managed-request headers with the same env < host < provider precedence as model calls. */ - resolveManagedRequestHeaders(providerName: string): Record | undefined { - const provider = this.getProviderConfig(providerName); - return provider === undefined - ? undefined - : composeManagedRequestHeaders(provider, this.options.kimiRequestHeaders); - } - - /** Resolve an OAuth bearer-token provider for a configured provider entry. */ - resolveOAuthTokenProvider( - providerName: string, - oauthRef?: OAuthRef, - ): BearerTokenProvider | undefined { - return this.options.resolveOAuthTokenProvider?.(providerName, oauthRef); - } - resolveAuth( model: string, options?: { readonly log?: Logger }, @@ -329,7 +308,7 @@ function toKosongProviderConfig( // still win on conflict. ...defaultHeadersField( provider.type === 'kimi' && modelProtocol === 'anthropic' - ? composeManagedRequestHeaders(provider, kimiRequestHeaders, envCustomHeaders) + ? { ...envCustomHeaders, ...kimiRequestHeaders, ...provider.customHeaders } : { ...envCustomHeaders, ...kimiUserAgentHeader(kimiRequestHeaders), ...provider.customHeaders }, ), }; @@ -363,9 +342,11 @@ function toKosongProviderConfig( baseUrl: modelBaseUrl ?? providerValue(provider.baseUrl, provider.env, 'KIMI_BASE_URL'), apiKey: providerApiKey(provider), generationKwargs: { prompt_cache_key: promptCacheKey }, - ...defaultHeadersField( - composeManagedRequestHeaders(provider, kimiRequestHeaders, envCustomHeaders), - ), + ...defaultHeadersField({ + ...envCustomHeaders, + ...kimiRequestHeaders, + ...provider.customHeaders, + }), }; case 'google-genai': return { @@ -441,14 +422,6 @@ function defaultHeadersField( return { defaultHeaders: { ...headers } }; } -function composeManagedRequestHeaders( - provider: Pick, - kimiRequestHeaders: Record | undefined, - envCustomHeaders = parseKimiCodeCustomHeaders(), -): Record { - return { ...envCustomHeaders, ...kimiRequestHeaders, ...provider.customHeaders }; -} - // Extract just the `User-Agent` from the Kimi identity headers so non-Kimi // providers (OpenAI, Anthropic, Google, Vertex) also identify as // `kimi-code-cli/` without leaking the `X-Msh-*` device identity diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index 4a704ee9bc..d12b44b6ec 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -40,7 +40,6 @@ import type { import type { PromisableMethods } from '#/utils/types'; import type { Session, SessionMeta } from '.'; -import { generateSessionTitle } from './auto-title'; import { promptMetadataTextFromPayload, promptMetadataTextFromPluginCommand, @@ -350,42 +349,6 @@ export class SessionAPIImpl implements PromisableMethods { lastPrompt, }, }); - if (title !== undefined) { - void this.applyGeneratedTitle(lastPrompt); - } - } - - private async applyGeneratedTitle(lastPrompt: string): Promise { - try { - if (!this.session.experimentalFlags.enabled('auto-title')) return; - const generated = await generateSessionTitle( - lastPrompt, - this.session.options.providerManager, - this.session.log, - ); - if (generated === undefined) return; - // The user may have renamed the session while the request was in flight. - if (hasCustomTitle(this.session.metadata)) return; - this.session.metadata = { - ...this.session.metadata, - title: generated, - isCustomTitle: false, - updatedAt: new Date().toISOString(), - }; - await this.session.writeMetadata(); - await this.session.rpc.emitEvent({ - type: 'session.meta.updated', - agentId: 'main', - title: generated, - patch: { - title: generated, - isCustomTitle: false, - lastPrompt: undefined, - }, - }); - } catch (error) { - this.session.log.warn('auto session title failed', { error }); - } } } @@ -394,11 +357,6 @@ function isUntitled(title: unknown): boolean { } function hasCustomTitle(metadata: SessionMeta): boolean { - if (typeof metadata.title === 'string' && typeof metadata.isCustomTitle === 'boolean') { - return metadata.isCustomTitle; - } - return ( - metadata.isCustomTitle || - typeof (metadata as SessionMeta & { customTitle?: unknown }).customTitle === 'string' - ); + if (metadata.isCustomTitle) return true; + return 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 d3ef3b58ca..4af1ad01a6 100644 --- a/packages/agent-core/test/session/prompt-metadata.test.ts +++ b/packages/agent-core/test/session/prompt-metadata.test.ts @@ -9,12 +9,6 @@ * whether it is a standalone text part or merged into the user's text * - SessionAPIImpl.steer updates title/lastPrompt exactly like prompt — * a steer can launch the session's first turn (e.g. goal mode) - * - auto-title sends the effective managed endpoint, credentials, and - * env < host < provider request-header layers - * - * Wiring: real Session / ProviderManager with scripted model output; only the - * external fetch boundary is stubbed. Run with: - * `pnpm exec vitest run packages/agent-core/test/session/prompt-metadata.test.ts`. */ import { mkdtemp, rm } from 'node:fs/promises'; @@ -24,8 +18,6 @@ import { join } from 'pathe'; import type { ProviderConfig } from '@moonshot-ai/kosong'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { OAuthRef, ProviderConfig as ConfigProviderConfig } from '../../src/config'; -import { FlagResolver } from '../../src/flags'; import type { ResolvedAgentProfile } from '../../src/profile'; import type { SDKSessionRPC } from '../../src/rpc'; import { Session } from '../../src/session'; @@ -115,224 +107,6 @@ const MOCK_PROVIDER = { model: 'mock-model', } as const satisfies ProviderConfig; -const MANAGED_PROVIDER = { - type: 'kimi', - baseUrl: 'https://api.example.test/coding/v1', - oauth: { storage: 'file', key: 'kimi-code' }, - customHeaders: { 'X-Msh-Platform': 'provider-platform' }, -} as const satisfies ConfigProviderConfig; - -describe('SessionAPIImpl auto title', () => { - it('replaces the easy title with the generated one when the flag is on', async () => { - const fetchMock = stubChatTitleFetch('生成的标题'); - const { api, session, events, agent } = await setupAutoTitleSession({ - autoTitle: true, - }); - - await api.prompt({ agentId: 'main', input: [{ type: 'text', text: '帮我看个 Go 报错' }] }); - if (agent.turn.hasActiveTurn) { - await agent.turn.waitForCurrentTurn(); - } - - await waitFor(() => - events.some((e) => e['type'] === 'session.meta.updated' && e['title'] === '生成的标题'), - ); - expect(session.metadata.isCustomTitle).toBe(false); - const [, init] = chatTitleCalls(fetchMock)[0]!; - expect(JSON.parse(init?.body as string)).toEqual({ - method: 'chat_title', - params: { chat_content: 'user: 帮我看个 Go 报错' }, - }); - const titleEvents = events.filter((e) => e['type'] === 'session.meta.updated'); - 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', - 'User-Agent: env-client\nX-Msh-Platform: env-platform\nX-Environment: present', - ); - const fetchMock = stubChatTitleFetch('生成的标题'); - const { api, events, agent } = await setupAutoTitleSession({ autoTitle: true }); - - await api.prompt({ agentId: 'main', input: [{ type: 'text', text: '帮我看个 Go 报错' }] }); - if (agent.turn.hasActiveTurn) { - await agent.turn.waitForCurrentTurn(); - } - await waitFor(() => - events.some((e) => e['type'] === 'session.meta.updated' && e['title'] === '生成的标题'), - ); - - const [, init] = chatTitleCalls(fetchMock)[0]!; - const headers = new Headers(init?.headers as Record); - expect(headers.get('user-agent')).toBe('kimi-code-cli/test'); - expect(headers.get('x-msh-device-id')).toBe('device-test'); - expect(headers.get('x-msh-platform')).toBe('provider-platform'); - expect(headers.get('x-environment')).toBe('present'); - }); - - it('keeps the easy title when the flag is off', async () => { - const fetchMock = stubChatTitleFetch('生成的标题'); - const { api, session, agent } = await setupAutoTitleSession({ autoTitle: false }); - - await api.prompt({ agentId: 'main', input: [{ type: 'text', text: '帮我看个 Go 报错' }] }); - if (agent.turn.hasActiveTurn) { - await agent.turn.waitForCurrentTurn(); - } - - expect(chatTitleCalls(fetchMock)).toHaveLength(0); - expect(session.metadata.title).toBe('帮我看个 Go 报错'); - }); - - it('keeps the easy title when the managed provider is not OAuth-backed', async () => { - const fetchMock = stubChatTitleFetch('生成的标题'); - const { api, session, agent } = await setupAutoTitleSession({ - autoTitle: true, - managedOAuth: false, - }); - - await api.prompt({ agentId: 'main', input: [{ type: 'text', text: '帮我看个 Go 报错' }] }); - if (agent.turn.hasActiveTurn) { - await agent.turn.waitForCurrentTurn(); - } - - expect(chatTitleCalls(fetchMock)).toHaveLength(0); - expect(session.metadata.title).toBe('帮我看个 Go 报错'); - }); - - it('pairs the environment endpoint with its credential slot when it overrides persisted config', async () => { - vi.stubEnv('KIMI_CODE_BASE_URL', 'https://api.env.example.test/coding/v1'); - vi.stubEnv('KIMI_CODE_OAUTH_HOST', 'https://auth.env.example.test'); - const fetchMock = stubChatTitleFetch('生成的标题'); - const { api, events, agent, resolvedOAuthRefs } = await setupAutoTitleSession({ - autoTitle: true, - }); - - await api.prompt({ agentId: 'main', input: [{ type: 'text', text: '帮我看个 Go 报错' }] }); - if (agent.turn.hasActiveTurn) { - await agent.turn.waitForCurrentTurn(); - } - await waitFor(() => - events.some((e) => e['type'] === 'session.meta.updated' && e['title'] === '生成的标题'), - ); - - expect(chatTitleCalls(fetchMock)[0]?.[0]).toBe( - 'https://api.env.example.test/coding/v1/tools', - ); - expect(resolvedOAuthRefs[0]).toMatchObject({ - storage: 'file', - oauthHost: 'https://auth.env.example.test', - }); - expect(resolvedOAuthRefs[0]?.key).not.toBe(MANAGED_PROVIDER.oauth.key); - }); -}); - -function chatTitleCalls(fetchMock: ReturnType): [string, RequestInit?][] { - return (fetchMock.mock.calls as unknown as [string, RequestInit?][]).filter(([url]) => - url.includes('/tools'), - ); -} - -function stubChatTitleFetch(title: string) { - const fetchMock = vi.fn( - async () => - new Response(JSON.stringify({ title }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - vi.stubGlobal('fetch', fetchMock); - return fetchMock; -} - -async function waitFor(condition: () => boolean, timeoutMs = 2000): Promise { - const deadline = Date.now() + timeoutMs; - while (!condition()) { - if (Date.now() > deadline) throw new Error('waitFor: condition not met before timeout'); - await new Promise((resolve) => setTimeout(resolve, 5)); - } -} - -async function setupAutoTitleSession(options: { autoTitle: boolean; managedOAuth?: boolean }) { - const sessionDir = await makeTempDir(); - const events: Array> = []; - const scripted = createScriptedGenerate(); - const flags = new FlagResolver({}); - const resolvedOAuthRefs: Array = []; - flags.setConfigOverrides({ 'auto-title': options.autoTitle }); - const managed: ConfigProviderConfig = - options.managedOAuth === false - ? { type: 'kimi', apiKey: 'sk-test' } - : MANAGED_PROVIDER; - const session = track( - new Session({ - id: 'auto-title', - kaos: testKaos.withCwd(sessionDir), - homedir: sessionDir, - rpc: createSessionRpc(events), - skills: { explicitDirs: [join(sessionDir, 'missing-skills')] }, - providerManager: new ProviderManager({ - config: { - providers: { - test: { type: MOCK_PROVIDER.type, apiKey: MOCK_PROVIDER.apiKey }, - 'managed:kimi-code': managed, - }, - models: { - [MOCK_PROVIDER.model]: { - provider: 'test', - model: MOCK_PROVIDER.model, - maxContextSize: 1_000_000, - }, - }, - }, - kimiRequestHeaders: { - 'User-Agent': 'kimi-code-cli/test', - 'X-Msh-Platform': 'host-platform', - 'X-Msh-Device-Id': 'device-test', - }, - resolveOAuthTokenProvider: (_providerName, oauthRef) => { - resolvedOAuthRefs.push(oauthRef); - return { getAccessToken: async () => 'test-token' }; - }, - }), - experimentalFlags: flags, - }), - ); - const { agent } = await session.createAgent( - { type: 'main', generate: scripted.generate }, - { profile: testProfile() }, - ); - agent.config.update({ modelAlias: MOCK_PROVIDER.model, thinkingEffort: 'off' }); - agent.permission.setMode('yolo'); - const api = new SessionAPIImpl(session); - return { api, session, events, agent, resolvedOAuthRefs }; -} - const tempDirs: string[] = []; const openSessions: Session[] = []; @@ -342,8 +116,6 @@ function track(session: Session): Session { } afterEach(async () => { - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); // Close sessions first so their async metadata/wire writes settle before the // temp dirs are removed (otherwise rm races with a write -> ENOTEMPTY). await Promise.allSettled(openSessions.splice(0).map((s) => s.close())); From 42fe810d63abf5e7b0e159c0fc4fa7714cdb36a2 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 30 Jul 2026 12:10:13 +0800 Subject: [PATCH 08/47] 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. --- .../agent-core-v2/docs/state-manifest.d.ts | 3 +- .../src/agent/rpc/prompt-metadata.ts | 26 +++++-- .../agent-core-v2/src/agent/rpc/rpcService.ts | 8 ++- .../sessionMetadata/sessionMetadata.ts | 7 ++ .../src/session/sessionTitle/flag.ts | 2 +- .../src/session/sessionTitle/sessionTitle.ts | 4 +- .../sessionTitle/sessionTitleService.ts | 41 ++++++++++-- .../test/agent/rpc/prompt-metadata.test.ts | 67 ++++++++++++++++++- .../sessionTitle/sessionTitleService.test.ts | 43 +++++++++++- 9 files changed, 181 insertions(+), 20 deletions(-) diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 4f6bded88c..1ab99674cf 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -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; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map { 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', diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index bb7ffef1d4..f603a65a4c 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -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 { + private async updatePromptMetadata( + text: string | undefined, + recordPrompt = false, + ): Promise { await applyPromptMetadataUpdate( { metadata: this.metadata, @@ -212,6 +215,7 @@ export class AgentRPCService implements IAgentRPCService { sessionId: this.sessionContext.sessionId, }, text, + recordPrompt, ); } diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts index 161c520d7a..d40b88a55b 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts @@ -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; diff --git a/packages/agent-core-v2/src/session/sessionTitle/flag.ts b/packages/agent-core-v2/src/session/sessionTitle/flag.ts index 8cbdfc5d69..a211a3a607 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/flag.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/flag.ts @@ -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', diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts index 26b0186c6a..edd0c55d53 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts @@ -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'; diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index b686ec1aa0..cdd7f0486a 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -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 { 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 { @@ -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, diff --git a/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts b/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts index 7e1527c443..88ebbc9fe6 100644 --- a/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts +++ b/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts @@ -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 = {}) { + 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('第四条'); + }); +}); 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 b719f5951c..b73d80bf0e 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -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).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' }); From 159d4447b005ab27a208729045292bc43ca7c2bd Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 30 Jul 2026 12:33:03 +0800 Subject: [PATCH 09/47] test: make session title race tests deterministic --- .../sessionTitle/sessionTitleService.test.ts | 66 ++++++++++--------- 1 file changed, 36 insertions(+), 30 deletions(-) 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 b73d80bf0e..244ad58d3b 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -2,6 +2,9 @@ * Scenario: on-demand managed chat_title generation through the session-scoped * service, including OAuth failures, legacy custom titles, request headers, * and races. + * Wiring: the real title service with contract fakes; only fetch crosses the + * external boundary. Run with `pnpm --filter @moonshot-ai/agent-core-v2 exec + * vitest run test/session/sessionTitle/sessionTitleService.test.ts`. */ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; @@ -103,12 +106,23 @@ class FakeSessionMetadata implements ISessionMetadata { } } -async function waitFor(condition: () => boolean, timeoutMs = 2000): Promise { - const deadline = Date.now() + timeoutMs; - while (!condition()) { - if (Date.now() > deadline) throw new Error('waitFor: condition not met before timeout'); - await new Promise((resolve) => setTimeout(resolve, 5)); - } +function createPendingFetch() { + let markStarted!: () => void; + let resolveResponse!: (response: Response) => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const response = new Promise((resolve) => { + resolveResponse = resolve; + }); + return { + fetch: async () => { + markStarted(); + return response; + }, + started, + resolve: resolveResponse, + }; } describe('SessionTitleService', () => { @@ -257,19 +271,14 @@ describe('SessionTitleService', () => { }); it('never overwrites a custom title set while generation is in flight', async () => { - let resolveFetch: ((response: Response) => void) | undefined; - fetchMock.mockImplementationOnce( - async () => - new Promise((resolve) => { - resolveFetch = resolve; - }), - ); + const pendingFetch = createPendingFetch(); + fetchMock.mockImplementationOnce(pendingFetch.fetch); await metadata.update({ lastPrompt: 'hello' }); const generation = ix.get(ISessionTitleService).generateTitle(); - await waitFor(() => fetchMock.mock.calls.length === 1); + await pendingFetch.started; await metadata.setTitle('user 取的标题'); - resolveFetch?.( + pendingFetch.resolve( new Response(JSON.stringify({ title: '生成的标题' }), { status: 200, headers: { 'Content-Type': 'application/json' }, @@ -368,20 +377,15 @@ describe('SessionTitleService', () => { }); it('shares an in-flight generation between concurrent requests', async () => { - let resolveFetch: ((response: Response) => void) | undefined; - fetchMock.mockImplementationOnce( - async () => - new Promise((resolve) => { - resolveFetch = resolve; - }), - ); + const pendingFetch = createPendingFetch(); + fetchMock.mockImplementationOnce(pendingFetch.fetch); await metadata.update({ lastPrompt: 'hello' }); const first = ix.get(ISessionTitleService).generateTitle(); const second = ix.get(ISessionTitleService).generateTitle(); - await waitFor(() => fetchMock.mock.calls.length === 1); + await pendingFetch.started; - resolveFetch?.( + pendingFetch.resolve( new Response(JSON.stringify({ title: '生成的标题' }), { status: 200, headers: { 'Content-Type': 'application/json' }, @@ -392,14 +396,16 @@ describe('SessionTitleService', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); - it('generateTitle() returns undefined when the flag is off or no prompt was seen', async () => { - const svc = ix.get(ISessionTitleService); - + it('returns unavailable without calling the backend when the flag is off', async () => { flagEnabled = false; - expect(await svc.generateTitle()).toBeUndefined(); + await metadata.update({ lastPrompt: 'hello' }); - flagEnabled = true; - expect(await svc.generateTitle()).toBeUndefined(); + await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns unavailable without calling the backend when no prompt was seen', async () => { + await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); expect(fetchMock).not.toHaveBeenCalled(); }); }); From 81c6c64c5f6af76fc44b14573d4c64d19ea1433c Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 30 Jul 2026 13:11:12 +0800 Subject: [PATCH 10/47] Generate session titles from agent conversation history --- .../agent-core-v2/docs/state-manifest.d.ts | 3 +- .../agent/contextMemory/contextTranscript.ts | 17 +++ .../src/agent/rpc/prompt-metadata.ts | 26 +--- .../agent-core-v2/src/agent/rpc/rpcService.ts | 8 +- .../app/messageLegacy/messageLegacyService.ts | 20 +-- packages/agent-core-v2/src/index.ts | 2 + .../sessionMetadata/sessionMetadata.ts | 7 - .../sessionMetadata/sessionMetadataService.ts | 29 ++-- .../sessionTitle/agentTitlePromptSource.ts | 17 +++ .../agentTitlePromptSourceService.ts | 82 +++++++++++ .../src/session/sessionTitle/sessionTitle.ts | 5 +- .../sessionTitle/sessionTitleService.ts | 53 +++---- .../test/agent/rpc/prompt-metadata.test.ts | 24 +--- .../sessionMetadata/sessionMetadata.test.ts | 20 +++ .../agentTitlePromptSourceService.test.ts | 130 ++++++++++++++++++ .../sessionTitle/sessionTitleService.test.ts | 35 ++++- packages/kap-server/test/prompts.test.ts | 20 +++ 17 files changed, 380 insertions(+), 118 deletions(-) create mode 100644 packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts create mode 100644 packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts create mode 100644 packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 1ab99674cf..4f6bded88c 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -336,7 +336,6 @@ 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; @@ -1011,7 +1010,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map undefined)], + }; +} + interface MutableMessage { id?: string; role: ContextMessage['role']; diff --git a/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts b/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts index a119f35144..0bf80ce6a0 100644 --- a/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts +++ b/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts @@ -3,18 +3,12 @@ * * Derives title and last-prompt text from native and legacy prompt payloads, * persists metadata through `sessionMetadata`, and publishes live updates - * 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. + * through `event`. 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, - SESSION_META_PROMPT_LIMIT, -} from '#/session/sessionMetadata/sessionMetadata'; +import type { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { promptMetadataTextFromContentParts, @@ -64,28 +58,16 @@ export interface PromptMetadataUpdateTarget { export async function applyPromptMetadataUpdate( target: PromptMetadataUpdateTarget, text: string | undefined, - recordPrompt = false, ): Promise { if (text === undefined) return; const current = await target.metadata.read(); - const patch: { - lastPrompt: string; - title?: string; - isCustomTitle?: boolean; - prompts?: readonly string[]; - } = { + const patch: { lastPrompt: string; title?: string; isCustomTitle?: boolean } = { 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', diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index f603a65a4c..bb7ffef1d4 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -95,7 +95,7 @@ export class AgentRPCService implements IAgentRPCService { throw error; } } - await this.updatePromptMetadata(promptMetadataTextFromPayload(payload), true); + await this.updatePromptMetadata(promptMetadataTextFromPayload(payload)); const handle = await this.promptService.enqueue({ message: { role: 'user', content: [...payload.input], @@ -204,10 +204,7 @@ export class AgentRPCService implements IAgentRPCService { await this.updatePromptMetadata(promptMetadataTextFromPluginCommand(payload)); } - private async updatePromptMetadata( - text: string | undefined, - recordPrompt = false, - ): Promise { + private async updatePromptMetadata(text: string | undefined): Promise { await applyPromptMetadataUpdate( { metadata: this.metadata, @@ -215,7 +212,6 @@ export class AgentRPCService implements IAgentRPCService { sessionId: this.sessionContext.sessionId, }, text, - recordPrompt, ); } diff --git a/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts b/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts index 3fb648cfc0..f01cce1cf3 100644 --- a/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts +++ b/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts @@ -32,6 +32,7 @@ import { IAgentBlobService } from '#/agent/blob/agentBlobService'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { createContextTranscriptReducer, + mergeContextTranscriptWithLive, type ContextTranscript, } from '#/agent/contextMemory/contextTranscript'; import { toProtocolMessage } from '#/agent/contextMemory/messageProjection'; @@ -113,7 +114,7 @@ export class MessageLegacyService implements IMessageLegacyService { const transcript = await this.readTranscript(agent); const contextMessages = agent.accessor.get(IAgentContextMemoryService).get(); - const merged = mergeLiveTail(transcript, contextMessages); + const merged = mergeContextTranscriptWithLive(transcript, contextMessages); const entries = await this.rehydrate(agent, merged.messages); let previousMs = Number.NEGATIVE_INFINITY; @@ -160,23 +161,6 @@ export class MessageLegacyService implements IMessageLegacyService { } } -function mergeLiveTail( - transcript: ContextTranscript, - contextMessages: readonly ContextMessage[], -): { - readonly messages: readonly ContextMessage[]; - readonly times: readonly (number | undefined)[]; -} { - if (contextMessages.length <= transcript.foldedLength) { - return { messages: transcript.entries, times: transcript.times }; - } - const tail = contextMessages.slice(transcript.foldedLength); - return { - messages: [...transcript.entries, ...tail], - times: [...transcript.times, ...tail.map(() => undefined)], - }; -} - registerScopedService( LifecycleScope.App, IMessageLegacyService, diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 88bcd63f4c..6b07b358d5 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -92,6 +92,8 @@ export * from '#/session/sessionMetadata/sessionMetadata'; export * from '#/session/sessionMetadata/sessionMetadataService'; export * from '#/session/sessionActivity/sessionActivity'; export * from '#/session/sessionActivity/sessionActivityService'; +export * from '#/session/sessionTitle/agentTitlePromptSource'; +import '#/session/sessionTitle/agentTitlePromptSourceService'; export * from '#/session/sessionTitle/sessionTitle'; export * from '#/session/sessionTitle/sessionTitleService'; import '#/session/sessionTitle/flag'; diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts index d40b88a55b..161c520d7a 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts @@ -25,19 +25,12 @@ 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; diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 3951239a88..e52d142c62 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -170,7 +170,11 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { const existing = await this.store.get(this.scope, META_KEY); if (existing !== undefined) { this.data = normalizeSessionMeta(existing, this.ctx.sessionId); - if (this.data.agents === undefined || this.data.custom === undefined) { + if ( + this.data.agents === undefined || + this.data.custom === undefined || + Object.prototype.hasOwnProperty.call(existing, 'prompts') + ) { this.data = { ...this.data, agents: this.data.agents ?? {}, @@ -216,6 +220,9 @@ function recordEquals(a: AgentMeta['labels'], b: AgentMeta['labels']): boolean { } export function normalizeSessionMeta(raw: SessionMeta, sessionId: string): SessionMeta { + const normalized = { ...raw } as SessionMeta & { prompts?: unknown }; + delete normalized.prompts; + const clean = normalized as SessionMeta; const legacy = raw as unknown as { createdAt?: unknown; updatedAt?: unknown; @@ -223,27 +230,27 @@ export function normalizeSessionMeta(raw: SessionMeta, sessionId: string): Sessi customTitle?: unknown; }; const cwd = - raw.cwd ?? (typeof legacy.workDir === 'string' && legacy.workDir.length > 0 + clean.cwd ?? (typeof legacy.workDir === 'string' && legacy.workDir.length > 0 ? legacy.workDir : undefined); const legacyCustomTitle = typeof legacy.customTitle === 'string' ? legacy.customTitle : undefined; const hasModernTitleState = - typeof raw.title === 'string' && typeof raw.isCustomTitle === 'boolean'; - const title = hasModernTitleState ? raw.title : (legacyCustomTitle ?? raw.title); + typeof clean.title === 'string' && typeof clean.isCustomTitle === 'boolean'; + const title = hasModernTitleState ? clean.title : (legacyCustomTitle ?? clean.title); const isCustomTitle = hasModernTitleState - ? raw.isCustomTitle + ? clean.isCustomTitle : legacyCustomTitle === undefined - ? raw.isCustomTitle + ? clean.isCustomTitle : true; - if (raw.version === SESSION_META_VERSION) { - if (cwd === raw.cwd && title === raw.title && isCustomTitle === raw.isCustomTitle) { - return raw; + if (clean.version === SESSION_META_VERSION) { + if (cwd === clean.cwd && title === clean.title && isCustomTitle === clean.isCustomTitle) { + return clean; } - return { ...raw, cwd, title, isCustomTitle }; + return { ...clean, cwd, title, isCustomTitle }; } return { - ...raw, + ...clean, id: sessionId, version: SESSION_META_VERSION, cwd, diff --git a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts new file mode 100644 index 0000000000..00c8870e32 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts @@ -0,0 +1,17 @@ +/** + * `sessionTitle` domain (L6) — title prompt projection contract. + * + * Defines the Agent-scoped `IAgentTitlePromptSource` used to read the first + * active natural-language prompts from authoritative conversation history. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentTitlePromptSource { + readonly _serviceBrand: undefined; + + firstUserPrompts(limit: number): Promise; +} + +export const IAgentTitlePromptSource: ServiceIdentifier = + createDecorator('agentTitlePromptSource'); diff --git a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts new file mode 100644 index 0000000000..e327825e85 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts @@ -0,0 +1,82 @@ +/** + * `sessionTitle` domain (L6) — `IAgentTitlePromptSource` implementation. + * + * Projects the first active natural-language prompts from the Agent's + * authoritative `wire` journal through `storage`, merging the live + * `contextMemory` tail and `prompt` queue so submissions waiting behind an + * active turn are visible. Bound at Agent scope. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { + createContextTranscriptReducer, + mergeContextTranscriptWithLive, +} from '#/agent/contextMemory/contextTranscript'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { IAgentPromptService } from '#/agent/prompt/prompt'; +import { promptMetadataTextFromContentParts } from '#/agent/prompt/promptMetadataText'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; +import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record'; +import { IWireService } from '#/wire/wire'; + +import { IAgentTitlePromptSource } from './agentTitlePromptSource'; + +export class AgentTitlePromptSourceService implements IAgentTitlePromptSource { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IAgentPromptService private readonly prompt: IAgentPromptService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAppendLogStore private readonly appendLog: IAppendLogStore, + @IWireService private readonly wire: IWireService, + ) {} + + async firstUserPrompts(limit: number): Promise { + if (!Number.isSafeInteger(limit) || limit <= 0) return []; + + await this.wire.flush(); + const reducer = createContextTranscriptReducer(); + for await (const record of this.appendLog.read( + this.scopeContext.scope(), + AGENT_WIRE_RECORD_KEY, + )) { + reducer.add(record); + } + const transcript = mergeContextTranscriptWithLive(reducer.result(), this.context.get()); + const queue = this.prompt.list(); + const result: string[] = []; + const seenMessageIds = new Set(); + + const add = (message: ContextMessage): void => { + if (result.length >= limit || !isNaturalLanguagePrompt(message)) return; + if (message.id !== undefined) { + if (seenMessageIds.has(message.id)) return; + seenMessageIds.add(message.id); + } + const text = promptMetadataTextFromContentParts(message.content); + if (text !== undefined) result.push(text); + }; + + for (const message of transcript.messages) add(message); + if (queue.active !== undefined) add(queue.active.message); + for (const item of queue.pending) add(item.message); + return result; + } +} + +function isNaturalLanguagePrompt(message: ContextMessage): boolean { + if (message.role !== 'user') return false; + const origin = message.origin; + return origin === undefined || origin.kind === 'user'; +} + +registerScopedService( + LifecycleScope.Agent, + IAgentTitlePromptSource, + AgentTitlePromptSourceService, + ScopeActivation.OnDemand, + 'sessionTitle', +); diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts index edd0c55d53..6b93c43c6d 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts @@ -1,9 +1,8 @@ /** * `sessionTitle` domain (L6) — session title generation contract. * - * Defines the `ISessionTitleService` that (re)generates the session's title - * on demand from its first prompts through the managed platform's - * `chat_title` tool. Bound at Session scope — one instance per session. + * Defines the Session-scoped `ISessionTitleService` that (re)generates a + * session title from the main Agent's conversation history. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index cdd7f0486a..efe7b51bff 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -1,18 +1,18 @@ /** * `sessionTitle` domain (L6) — `ISessionTitleService` implementation. * - * 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`. + * Generates the session's title from the first active prompts projected by + * the main Agent's authoritative conversation journal 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 * managed Kimi Code OAuth login; any failure degrades to keeping the current * title, and a custom title set by the user is never overwritten. Provider * config comes from `provider`, the bearer token from `auth`, host identity - * headers from `model`, gating from `flag`, and logs through `log`. Bound at - * Session scope. + * headers from `model`, gating from `flag`, prompt history from + * `agentLifecycle`/`sessionTitle`, and logs through `log`. Bound at Session + * scope. */ import { @@ -29,15 +29,14 @@ import { ILogService } from '#/_base/log/log'; import { IOAuthService } from '#/app/auth/auth'; import { IEventService } from '#/app/event/event'; import { IFlagService } from '#/app/flag/flag'; +import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; 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, - type SessionMeta, -} from '#/session/sessionMetadata/sessionMetadata'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { IAgentTitlePromptSource } from './agentTitlePromptSource'; import { AUTO_TITLE_FLAG_ID } from './flag'; import { ISessionTitleService } from './sessionTitle'; @@ -46,6 +45,8 @@ const MAX_GENERATED_TITLE_LENGTH = 200; /** Total budget for the composed prompt texts sent to `chat_title`. */ const MAX_TITLE_INPUT_LENGTH = 1000; +const MAX_TITLE_PROMPTS = 3; + export class SessionTitleService implements ISessionTitleService { declare readonly _serviceBrand: undefined; @@ -54,6 +55,7 @@ export class SessionTitleService implements ISessionTitleService { constructor( @ISessionContext private readonly ctx: ISessionContext, @ISessionMetadata private readonly metadata: ISessionMetadata, + @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, @IEventService private readonly eventService: IEventService, @IFlagService private readonly flags: IFlagService, @IProviderService private readonly providers: IProviderService, @@ -65,8 +67,20 @@ export class SessionTitleService implements ISessionTitleService { async generateTitle(): Promise { if (!this.flags.enabled(AUTO_TITLE_FLAG_ID)) return undefined; const current = await this.metadata.read(); - const input = titleInputFromMeta(current); - if (input === undefined || hasCustomTitle(current)) return undefined; + if (hasCustomTitle(current)) return undefined; + const main = this.agentLifecycle.get(MAIN_AGENT_ID); + const prompts = + main === undefined + ? [] + : await main.accessor.get(IAgentTitlePromptSource).firstUserPrompts(MAX_TITLE_PROMPTS); + const input = titleInputFromPrompts( + prompts.length > 0 + ? prompts + : current.lastPrompt === undefined + ? undefined + : [current.lastPrompt], + ); + if (input === undefined) return undefined; return this.generateAndApply(input); } @@ -151,18 +165,7 @@ 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; +function titleInputFromPrompts(prompts: readonly string[] | undefined): string | undefined { if (prompts === undefined) return undefined; return prompts .map((prompt, index) => `user ${index + 1}: ${prompt}`) diff --git a/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts b/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts index 88ebbc9fe6..bfcea32703 100644 --- a/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts +++ b/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts @@ -7,8 +7,8 @@ * - 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 + * - prompt metadata updates retain the latest sanitized prompt and derive + * the easy title */ import { describe, expect, it } from 'vitest'; @@ -89,32 +89,22 @@ describe('applyPromptMetadataUpdate', () => { return { target, readMeta: () => meta }; } - it('records natural-language prompts into the bounded prompts list', async () => { + it('updates the latest prompt and derives the easy title', async () => { const { target, readMeta } = createTarget(); - await applyPromptMetadataUpdate(target, '第一条', true); - await applyPromptMetadataUpdate(target, '第二条', true); + await applyPromptMetadataUpdate(target, '第一条'); + await applyPromptMetadataUpdate(target, '第二条'); - expect(readMeta().prompts).toEqual(['第一条', '第二条']); expect(readMeta().lastPrompt).toBe('第二条'); expect(readMeta().title).toBe('第一条'); }); - it('does not record skill / plugin activations', async () => { + it('updates metadata for slash 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('第四条'); + expect(readMeta().title).toBe('/compact'); }); }); 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 a4ed327be3..638a2ef5da 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -138,6 +138,26 @@ describe('SessionMetadata', () => { expect(healed.updatedAt).toBe(1700000000000); }); + it('removes the retired prompts field from persisted metadata', async () => { + const store = ix.get(IAtomicDocumentStore); + await store.set(META_SCOPE, 'state.json', { + id: 's1', + version: 2, + createdAt: 1700000000000, + updatedAt: 1700000000000, + archived: false, + agents: {}, + custom: {}, + prompts: ['第一条', '第二条'], + }); + + const meta = ix.get(ISessionMetadata); + expect(await meta.read()).not.toHaveProperty('prompts'); + + const fresh = createFreshMetadata(ix); + expect(await fresh.read()).not.toHaveProperty('prompts'); + }); + it('normalizes the legacy customTitle field before callers read metadata', async () => { const store = ix.get(IAtomicDocumentStore); await store.set(META_SCOPE, 'state.json', { diff --git a/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts b/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts new file mode 100644 index 0000000000..8e6da417a1 --- /dev/null +++ b/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts @@ -0,0 +1,130 @@ +/** + * Scenario: the Agent-scoped title prompt projection reads the durable wire + * journal, follows conversation undo, and includes prompts still waiting in + * the live prompt queue. Wiring: the real source with contract-level fakes + * for storage, wire flush, context, and prompt queue. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices, type TestInstantiationService } from '#/_base/di/test'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { IAgentPromptService } from '#/agent/prompt/prompt'; +import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; +import { IAgentTitlePromptSource } from '#/session/sessionTitle/agentTitlePromptSource'; +import { AgentTitlePromptSourceService } from '#/session/sessionTitle/agentTitlePromptSourceService'; +import { IWireService } from '#/wire/wire'; +import type { WireRecord } from '#/wire/record'; + +function promptRecord(id: string, text: string): WireRecord { + return { + type: 'context.append_message', + message: { + id, + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }; +} + +function undoRecord(count: number): WireRecord { + return { type: 'context.undo', count }; +} + +describe('AgentTitlePromptSource', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let records: WireRecord[]; + let liveMessages: readonly ContextMessage[]; + let queue: ReturnType; + + beforeEach(() => { + records = []; + liveMessages = []; + queue = { active: undefined, pending: [] }; + disposables = new DisposableStore(); + ix = createServices(disposables, { + additionalServices: (reg) => { + reg.definePartialInstance(IAgentContextMemoryService, { get: () => liveMessages }); + reg.definePartialInstance(IAgentPromptService, { list: () => queue }); + reg.defineInstance( + IAgentScopeContext, + makeAgentScopeContext({ agentId: 'main', agentScope: 'sessions/sess-1/agents/main' }), + ); + reg.definePartialInstance(IAppendLogStore, { + read: () => (async function* () { + yield* records as R[]; + })(), + }); + reg.definePartialInstance(IWireService, { flush: async () => undefined }); + reg.define(IAgentTitlePromptSource, AgentTitlePromptSourceService); + }, + }); + }); + + afterEach(() => { + disposables.dispose(); + }); + + it('returns the first three prompts from the journal and live queue in order', async () => { + records = [promptRecord('one', '第一条')]; + queue = { + active: undefined, + pending: [ + { + id: 'two', + userMessageId: 'two', + createdAt: '2026-01-01T00:00:00.000Z', + state: 'pending', + message: { + id: 'two', + role: 'user', + content: [{ type: 'text', text: '第二条' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + { + id: 'three', + userMessageId: 'three', + createdAt: '2026-01-01T00:00:01.000Z', + state: 'pending', + message: { + id: 'three', + role: 'user', + content: [{ type: 'text', text: '第三条' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + ], + }; + + await expect(ix.get(IAgentTitlePromptSource).firstUserPrompts(3)).resolves.toEqual([ + '第一条', + '第二条', + '第三条', + ]); + }); + + it('excludes a prompt removed by conversation undo', async () => { + records = [promptRecord('one', 'A'), undoRecord(1), promptRecord('two', 'B')]; + + await expect(ix.get(IAgentTitlePromptSource).firstUserPrompts(3)).resolves.toEqual(['B']); + }); + + it('rebuilds the same prompt list from persisted records without a live context', async () => { + records = [promptRecord('one', '第一条'), promptRecord('two', '第二条')]; + liveMessages = []; + + await expect(ix.get(IAgentTitlePromptSource).firstUserPrompts(3)).resolves.toEqual([ + '第一条', + '第二条', + ]); + }); +}); 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 244ad58d3b..fbd3dbff9d 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite import { OAuthConnectionError, OAuthUnauthorizedError } from '@moonshot-ai/kimi-code-oauth'; import { DisposableStore, type IDisposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, type IAgentScopeHandle } from '#/_base/di/scope'; import { createServices, type TestInstantiationService } from '#/_base/di/test'; import { Emitter } from '#/_base/event'; import { IOAuthService } from '#/app/auth/auth'; @@ -24,14 +25,21 @@ import { type ProviderConfig, } from '#/kosong/provider/provider'; import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; +import { + IAgentLifecycleService, + MAIN_AGENT_ID, +} from '#/session/agentLifecycle/agentLifecycle'; +import { + IAgentTitlePromptSource, +} from '#/session/sessionTitle/agentTitlePromptSource'; +import { ISessionTitleService } from '#/session/sessionTitle/sessionTitle'; +import { SessionTitleService } from '#/session/sessionTitle/sessionTitleService'; import { ISessionMetadata, type SessionMeta, type SessionMetaPatch, type SessionMetadataChangedEvent, } from '#/session/sessionMetadata/sessionMetadata'; -import { ISessionTitleService } from '#/session/sessionTitle/sessionTitle'; -import { SessionTitleService } from '#/session/sessionTitle/sessionTitleService'; import '#/kosong/provider/providers/kimi/kimi.contrib'; import { registerLogServices } from '../../_base/log/stubs'; @@ -135,11 +143,13 @@ describe('SessionTitleService', () => { let fetchMock: Mock<(url: string, init?: RequestInit) => Promise>; let tokenError: Error | undefined; let resolvedOAuthRefs: Array; + let titlePrompts: readonly string[]; beforeEach(() => { flagEnabled = true; tokenError = undefined; resolvedOAuthRefs = []; + titlePrompts = []; providers = { 'managed:kimi-code': MANAGED_PROVIDER }; metadata = new FakeSessionMetadata(); events = new FakeEventService(); @@ -167,6 +177,19 @@ describe('SessionTitleService', () => { }), ); reg.defineInstance(ISessionMetadata, metadata); + const promptSource: IAgentTitlePromptSource = { + _serviceBrand: undefined, + firstUserPrompts: async (limit) => titlePrompts.slice(0, limit), + }; + const mainAgent: IAgentScopeHandle = { + id: MAIN_AGENT_ID, + kind: LifecycleScope.Agent, + accessor: { get: () => promptSource as T }, + dispose: () => undefined, + }; + reg.definePartialInstance(IAgentLifecycleService, { + get: () => mainAgent, + }); reg.defineInstance(IEventService, events); reg.defineInstance( IFlagService, @@ -198,7 +221,7 @@ describe('SessionTitleService', () => { }); it('replaces the easy title with the generated one', async () => { - await metadata.update({ prompts: ['帮我看一下这个 Go 的 nil pointer 报错'] }); + titlePrompts = ['帮我看一下这个 Go 的 nil pointer 报错']; const title = await ix.get(ISessionTitleService).generateTitle(); @@ -224,9 +247,7 @@ describe('SessionTitleService', () => { }); it('composes the title input from the recorded prompts in order', async () => { - await metadata.update({ - prompts: ['先帮我搭一个 Vite 项目', '加上路由', '现在配一下 ESLint'], - }); + titlePrompts = ['先帮我搭一个 Vite 项目', '加上路由', '现在配一下 ESLint']; await ix.get(ISessionTitleService).generateTitle(); @@ -240,7 +261,7 @@ describe('SessionTitleService', () => { }); it('truncates the composed title input to the total budget, keeping the head', async () => { - await metadata.update({ prompts: ['很长的输入'.repeat(400), '第二条'] }); + titlePrompts = ['很长的输入'.repeat(400), '第二条']; await ix.get(ISessionTitleService).generateTitle(); diff --git a/packages/kap-server/test/prompts.test.ts b/packages/kap-server/test/prompts.test.ts index 0db4a13480..0faef7e06f 100644 --- a/packages/kap-server/test/prompts.test.ts +++ b/packages/kap-server/test/prompts.test.ts @@ -4,6 +4,7 @@ import { dirname, join } from 'node:path'; import { deflateSync } from 'node:zlib'; import { + IAgentTitlePromptSource, IAgentContextMemoryService, IAgentLifecycleService, IAgentProfileService, @@ -213,6 +214,25 @@ describe('server-v2 /api/v1 prompts', () => { expect(Array.isArray(list.body.data.queued)).toBe(true); }); + it('makes the first three REST prompts available to title generation', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + + const prompts = ['先搭一个 Vite 项目', '加上路由', '现在配一下 ESLint']; + for (const text of prompts) { + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text }], + }); + expect(submitted.body.code).toBe(0); + } + + const session = server!.core.accessor.get(ISessionLifecycleService).get(id); + const agent = session?.accessor.get(IAgentLifecycleService).get('main'); + const source = agent?.accessor.get(IAgentTitlePromptSource); + expect(source).toBeDefined(); + await expect(source!.firstUserPrompts(3)).resolves.toEqual(prompts); + }); + it('rejects a stale file reference without creating the agent or mutating the model', async () => { const id = await createSession(home as string); const session = server!.core.accessor.get(ISessionLifecycleService).get(id); From 1b6655754ee53ef1226ccafff91f7e1c9bfaac90 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 30 Jul 2026 14:36:43 +0800 Subject: [PATCH 11/47] fix: reject title generation without user prompts --- .../sessionTitle/sessionTitleService.ts | 13 ++----- .../agentTitlePromptSourceService.test.ts | 28 +++++++++++++-- .../sessionTitle/sessionTitleService.test.ts | 36 ++++++++----------- 3 files changed, 44 insertions(+), 33 deletions(-) diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index efe7b51bff..97e98ff10f 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -42,7 +42,6 @@ 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; const MAX_TITLE_PROMPTS = 3; @@ -73,13 +72,7 @@ export class SessionTitleService implements ISessionTitleService { main === undefined ? [] : await main.accessor.get(IAgentTitlePromptSource).firstUserPrompts(MAX_TITLE_PROMPTS); - const input = titleInputFromPrompts( - prompts.length > 0 - ? prompts - : current.lastPrompt === undefined - ? undefined - : [current.lastPrompt], - ); + const input = titleInputFromPrompts(prompts); if (input === undefined) return undefined; return this.generateAndApply(input); } @@ -165,8 +158,8 @@ function hasCustomTitle(metadata: { return metadata.isCustomTitle === true || typeof metadata.customTitle === 'string'; } -function titleInputFromPrompts(prompts: readonly string[] | undefined): string | undefined { - if (prompts === undefined) return undefined; +function titleInputFromPrompts(prompts: readonly string[]): string | undefined { + if (prompts.length === 0) return undefined; return prompts .map((prompt, index) => `user ${index + 1}: ${prompt}`) .join('\n') diff --git a/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts b/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts index 8e6da417a1..dad4b51212 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts @@ -19,7 +19,11 @@ import { AgentTitlePromptSourceService } from '#/session/sessionTitle/agentTitle import { IWireService } from '#/wire/wire'; import type { WireRecord } from '#/wire/record'; -function promptRecord(id: string, text: string): WireRecord { +function promptRecord( + id: string, + text: string, + origin: ContextMessage['origin'] = { kind: 'user' }, +): WireRecord { return { type: 'context.append_message', message: { @@ -27,7 +31,7 @@ function promptRecord(id: string, text: string): WireRecord { role: 'user', content: [{ type: 'text', text }], toolCalls: [], - origin: { kind: 'user' }, + origin, }, }; } @@ -127,4 +131,24 @@ describe('AgentTitlePromptSource', () => { '第二条', ]); }); + + it('returns no title prompts when history contains only slash activations', async () => { + records = [ + promptRecord('skill', 'expanded skill instructions', { + kind: 'skill_activation', + activationId: 'skill-1', + skillName: 'compact', + trigger: 'user-slash', + }), + promptRecord('plugin', 'expanded plugin instructions', { + kind: 'plugin_command', + activationId: 'plugin-1', + pluginId: 'example-plugin', + commandName: 'run', + trigger: 'user-slash', + }), + ]; + + await expect(ix.get(IAgentTitlePromptSource).firstUserPrompts(3)).resolves.toEqual([]); + }); }); 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 fbd3dbff9d..4d68233026 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -271,21 +271,16 @@ describe('SessionTitleService', () => { 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 报错' }); + it('returns unavailable when only a slash activation updated lastPrompt', async () => { + await metadata.update({ lastPrompt: '/compact' }); - 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 报错' }, - }); + await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); }); it('does nothing without a managed OAuth provider', async () => { delete providers['managed:kimi-code']; - await metadata.update({ lastPrompt: 'hello' }); + titlePrompts = ['hello']; await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); expect(fetchMock).not.toHaveBeenCalled(); @@ -295,7 +290,7 @@ describe('SessionTitleService', () => { const pendingFetch = createPendingFetch(); fetchMock.mockImplementationOnce(pendingFetch.fetch); - await metadata.update({ lastPrompt: 'hello' }); + titlePrompts = ['hello']; const generation = ix.get(ISessionTitleService).generateTitle(); await pendingFetch.started; await metadata.setTitle('user 取的标题'); @@ -313,7 +308,8 @@ describe('SessionTitleService', () => { it('keeps the current title when the backend request fails', async () => { fetchMock.mockImplementationOnce(async () => new Response('', { status: 500 })); - await metadata.update({ title: 'hello', isCustomTitle: false, lastPrompt: 'hello' }); + titlePrompts = ['hello']; + await metadata.update({ title: 'hello', isCustomTitle: false }); await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); expect(metadata.meta.title).toBe('hello'); @@ -321,7 +317,7 @@ describe('SessionTitleService', () => { it('returns unavailable when the OAuth token is missing or revoked', async () => { tokenError = new OAuthUnauthorizedError('re-login required'); - await metadata.update({ lastPrompt: 'hello' }); + titlePrompts = ['hello']; const svc = ix.get(ISessionTitleService); await expect(svc.generateTitle()).resolves.toBeUndefined(); @@ -330,7 +326,7 @@ describe('SessionTitleService', () => { it('returns unavailable when OAuth token retrieval has an operational failure', async () => { tokenError = new OAuthConnectionError('connection failed'); - await metadata.update({ lastPrompt: 'hello' }); + titlePrompts = ['hello']; await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); expect(fetchMock).not.toHaveBeenCalled(); @@ -338,7 +334,7 @@ describe('SessionTitleService', () => { it('propagates unexpected token provider failures', async () => { tokenError = new Error('unexpected failure'); - await metadata.update({ lastPrompt: 'hello' }); + titlePrompts = ['hello']; await expect(ix.get(ISessionTitleService).generateTitle()).rejects.toThrow( 'unexpected failure', @@ -348,7 +344,7 @@ describe('SessionTitleService', () => { it('includes environment custom headers', async () => { vi.stubEnv('KIMI_CODE_CUSTOM_HEADERS', 'X-Proxy-Header: from-env\n'); - await metadata.update({ lastPrompt: 'hello' }); + titlePrompts = ['hello']; await ix.get(ISessionTitleService).generateTitle(); @@ -361,7 +357,7 @@ describe('SessionTitleService', () => { it('pairs the environment endpoint with its credential slot when it overrides persisted config', async () => { vi.stubEnv('KIMI_CODE_BASE_URL', 'https://api.env.example.test/coding/v1'); vi.stubEnv('KIMI_CODE_OAUTH_HOST', 'https://auth.env.example.test'); - await metadata.update({ lastPrompt: 'hello' }); + titlePrompts = ['hello']; await ix.get(ISessionTitleService).generateTitle(); @@ -376,7 +372,6 @@ describe('SessionTitleService', () => { it('does not generate over a legacy customTitle', async () => { metadata.meta = { ...metadata.meta, - lastPrompt: 'hello', customTitle: 'legacy title', } as SessionMeta; @@ -389,9 +384,9 @@ describe('SessionTitleService', () => { ...metadata.meta, title: 'easy title', isCustomTitle: false, - lastPrompt: 'hello', customTitle: 'stale legacy title', } as SessionMeta; + titlePrompts = ['hello']; await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBe('生成的标题'); expect(metadata.meta.title).toBe('生成的标题'); @@ -401,7 +396,7 @@ describe('SessionTitleService', () => { const pendingFetch = createPendingFetch(); fetchMock.mockImplementationOnce(pendingFetch.fetch); - await metadata.update({ lastPrompt: 'hello' }); + titlePrompts = ['hello']; const first = ix.get(ISessionTitleService).generateTitle(); const second = ix.get(ISessionTitleService).generateTitle(); await pendingFetch.started; @@ -419,7 +414,6 @@ describe('SessionTitleService', () => { it('returns unavailable without calling the backend when the flag is off', async () => { flagEnabled = false; - await metadata.update({ lastPrompt: 'hello' }); await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); expect(fetchMock).not.toHaveBeenCalled(); From c4f5f3fa44e8fc080f7c081d5bfaf2eb56d35575 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 30 Jul 2026 14:51:22 +0800 Subject: [PATCH 12/47] fix: bound session title prompt history --- .../sessionTitle/agentTitlePromptSource.ts | 2 +- .../agentTitlePromptSourceService.ts | 31 +---- .../sessionTitle/sessionTitleService.ts | 6 +- .../agentTitlePromptSourceService.test.ts | 110 +++++++----------- 4 files changed, 55 insertions(+), 94 deletions(-) diff --git a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts index 00c8870e32..87a5f62698 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts @@ -2,7 +2,7 @@ * `sessionTitle` domain (L6) — title prompt projection contract. * * Defines the Agent-scoped `IAgentTitlePromptSource` used to read the first - * active natural-language prompts from authoritative conversation history. + * active natural-language prompts from the live conversation context. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; diff --git a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts index e327825e85..03239b51e1 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts @@ -1,25 +1,18 @@ /** * `sessionTitle` domain (L6) — `IAgentTitlePromptSource` implementation. * - * Projects the first active natural-language prompts from the Agent's - * authoritative `wire` journal through `storage`, merging the live - * `contextMemory` tail and `prompt` queue so submissions waiting behind an - * active turn are visible. Bound at Agent scope. + * Reads the first active natural-language prompts from the live `contextMemory` + * window, merging the `prompt` queue so submissions waiting behind an active + * turn are visible. The window may be post-compaction — acceptable for title + * generation: compaction keeps the head user messages, and a title derived + * from the surviving tail is a fine degradation. Bound at Agent scope. */ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { - createContextTranscriptReducer, - mergeContextTranscriptWithLive, -} from '#/agent/contextMemory/contextTranscript'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import { promptMetadataTextFromContentParts } from '#/agent/prompt/promptMetadataText'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; -import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record'; -import { IWireService } from '#/wire/wire'; import { IAgentTitlePromptSource } from './agentTitlePromptSource'; @@ -29,23 +22,11 @@ export class AgentTitlePromptSourceService implements IAgentTitlePromptSource { constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentPromptService private readonly prompt: IAgentPromptService, - @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, - @IAppendLogStore private readonly appendLog: IAppendLogStore, - @IWireService private readonly wire: IWireService, ) {} async firstUserPrompts(limit: number): Promise { if (!Number.isSafeInteger(limit) || limit <= 0) return []; - await this.wire.flush(); - const reducer = createContextTranscriptReducer(); - for await (const record of this.appendLog.read( - this.scopeContext.scope(), - AGENT_WIRE_RECORD_KEY, - )) { - reducer.add(record); - } - const transcript = mergeContextTranscriptWithLive(reducer.result(), this.context.get()); const queue = this.prompt.list(); const result: string[] = []; const seenMessageIds = new Set(); @@ -60,7 +41,7 @@ export class AgentTitlePromptSourceService implements IAgentTitlePromptSource { if (text !== undefined) result.push(text); }; - for (const message of transcript.messages) add(message); + for (const message of this.context.get()) add(message); if (queue.active !== undefined) add(queue.active.message); for (const item of queue.pending) add(item.message); return result; diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index 97e98ff10f..6ce28b58e8 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -1,9 +1,9 @@ /** * `sessionTitle` domain (L6) — `ISessionTitleService` implementation. * - * Generates the session's title from the first active prompts projected by - * the main Agent's authoritative conversation journal through the managed - * platform `/tools` `chat_title` endpoint, persists it through + * Generates the session's title from the first active prompts in the main + * Agent's live conversation context 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 diff --git a/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts b/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts index dad4b51212..174f864969 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts @@ -1,8 +1,7 @@ /** - * Scenario: the Agent-scoped title prompt projection reads the durable wire - * journal, follows conversation undo, and includes prompts still waiting in - * the live prompt queue. Wiring: the real source with contract-level fakes - * for storage, wire flush, context, and prompt queue. + * Scenario: the Agent-scoped title prompt projection reads the live context + * window and includes prompts still waiting in the live prompt queue. Wiring: + * the real source with contract-level fakes for context and prompt queue. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -12,43 +11,32 @@ import { createServices, type TestInstantiationService } from '#/_base/di/test'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentPromptService } from '#/agent/prompt/prompt'; -import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAgentTitlePromptSource } from '#/session/sessionTitle/agentTitlePromptSource'; import { AgentTitlePromptSourceService } from '#/session/sessionTitle/agentTitlePromptSourceService'; -import { IWireService } from '#/wire/wire'; -import type { WireRecord } from '#/wire/record'; -function promptRecord( +const USER_ORIGIN: ContextMessage['origin'] = { kind: 'user' }; + +function userMessage( id: string, text: string, - origin: ContextMessage['origin'] = { kind: 'user' }, -): WireRecord { + origin: ContextMessage['origin'] = USER_ORIGIN, +): ContextMessage { return { - type: 'context.append_message', - message: { - id, - role: 'user', - content: [{ type: 'text', text }], - toolCalls: [], - origin, - }, + id, + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], + origin, }; } -function undoRecord(count: number): WireRecord { - return { type: 'context.undo', count }; -} - describe('AgentTitlePromptSource', () => { let disposables: DisposableStore; let ix: TestInstantiationService; - let records: WireRecord[]; let liveMessages: readonly ContextMessage[]; let queue: ReturnType; beforeEach(() => { - records = []; liveMessages = []; queue = { active: undefined, pending: [] }; disposables = new DisposableStore(); @@ -56,16 +44,6 @@ describe('AgentTitlePromptSource', () => { additionalServices: (reg) => { reg.definePartialInstance(IAgentContextMemoryService, { get: () => liveMessages }); reg.definePartialInstance(IAgentPromptService, { list: () => queue }); - reg.defineInstance( - IAgentScopeContext, - makeAgentScopeContext({ agentId: 'main', agentScope: 'sessions/sess-1/agents/main' }), - ); - reg.definePartialInstance(IAppendLogStore, { - read: () => (async function* () { - yield* records as R[]; - })(), - }); - reg.definePartialInstance(IWireService, { flush: async () => undefined }); reg.define(IAgentTitlePromptSource, AgentTitlePromptSourceService); }, }); @@ -75,8 +53,8 @@ describe('AgentTitlePromptSource', () => { disposables.dispose(); }); - it('returns the first three prompts from the journal and live queue in order', async () => { - records = [promptRecord('one', '第一条')]; + it('returns the first three prompts from the live context and queue in order', async () => { + liveMessages = [userMessage('one', '第一条')]; queue = { active: undefined, pending: [ @@ -85,26 +63,14 @@ describe('AgentTitlePromptSource', () => { userMessageId: 'two', createdAt: '2026-01-01T00:00:00.000Z', state: 'pending', - message: { - id: 'two', - role: 'user', - content: [{ type: 'text', text: '第二条' }], - toolCalls: [], - origin: { kind: 'user' }, - }, + message: userMessage('two', '第二条'), }, { id: 'three', userMessageId: 'three', createdAt: '2026-01-01T00:00:01.000Z', state: 'pending', - message: { - id: 'three', - role: 'user', - content: [{ type: 'text', text: '第三条' }], - toolCalls: [], - origin: { kind: 'user' }, - }, + message: userMessage('three', '第三条'), }, ], }; @@ -116,31 +82,29 @@ describe('AgentTitlePromptSource', () => { ]); }); - it('excludes a prompt removed by conversation undo', async () => { - records = [promptRecord('one', 'A'), undoRecord(1), promptRecord('two', 'B')]; - - await expect(ix.get(IAgentTitlePromptSource).firstUserPrompts(3)).resolves.toEqual(['B']); - }); - - it('rebuilds the same prompt list from persisted records without a live context', async () => { - records = [promptRecord('one', '第一条'), promptRecord('two', '第二条')]; - liveMessages = []; + it('keeps the head user messages of a compacted window, skipping elision and summary', async () => { + liveMessages = [ + userMessage('head', '开场提问'), + userMessage('elision', '... omitted ...', { kind: 'injection', variant: 'compaction_elision' }), + userMessage('tail', '最近的追问'), + userMessage('summary', ' compaction summary ', { kind: 'compaction_summary' }), + ]; await expect(ix.get(IAgentTitlePromptSource).firstUserPrompts(3)).resolves.toEqual([ - '第一条', - '第二条', + '开场提问', + '最近的追问', ]); }); it('returns no title prompts when history contains only slash activations', async () => { - records = [ - promptRecord('skill', 'expanded skill instructions', { + liveMessages = [ + userMessage('skill', 'expanded skill instructions', { kind: 'skill_activation', activationId: 'skill-1', skillName: 'compact', trigger: 'user-slash', }), - promptRecord('plugin', 'expanded plugin instructions', { + userMessage('plugin', 'expanded plugin instructions', { kind: 'plugin_command', activationId: 'plugin-1', pluginId: 'example-plugin', @@ -151,4 +115,20 @@ describe('AgentTitlePromptSource', () => { await expect(ix.get(IAgentTitlePromptSource).firstUserPrompts(3)).resolves.toEqual([]); }); + + it('counts a queued prompt already appended to the context only once', async () => { + liveMessages = [userMessage('one', '同一条')]; + queue = { + active: { + id: 'one', + userMessageId: 'one', + createdAt: '2026-01-01T00:00:00.000Z', + state: 'running', + message: userMessage('one', '同一条'), + }, + pending: [], + }; + + await expect(ix.get(IAgentTitlePromptSource).firstUserPrompts(3)).resolves.toEqual(['同一条']); + }); }); From d22a434ffc6d501f54cbe7f92d0f92f664153614 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 30 Jul 2026 15:12:19 +0800 Subject: [PATCH 13/47] feat: enable session title generation without an experimental flag --- packages/agent-core-v2/src/index.ts | 1 - .../src/session/sessionTitle/flag.ts | 28 ------------------- .../sessionTitle/sessionTitleService.ts | 17 ++++------- .../sessionTitle/sessionTitleService.test.ts | 15 ---------- packages/kap-server/src/routes/sessions.ts | 2 +- 5 files changed, 7 insertions(+), 56 deletions(-) delete mode 100644 packages/agent-core-v2/src/session/sessionTitle/flag.ts diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 6b07b358d5..7b988f0850 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -96,7 +96,6 @@ export * from '#/session/sessionTitle/agentTitlePromptSource'; import '#/session/sessionTitle/agentTitlePromptSourceService'; export * from '#/session/sessionTitle/sessionTitle'; export * from '#/session/sessionTitle/sessionTitleService'; -import '#/session/sessionTitle/flag'; export * from '#/session/sessionToolPolicy/sessionToolPolicy'; export * from '#/session/sessionToolPolicy/sessionToolPolicyService'; export * from '#/app/config/config'; diff --git a/packages/agent-core-v2/src/session/sessionTitle/flag.ts b/packages/agent-core-v2/src/session/sessionTitle/flag.ts deleted file mode 100644 index a211a3a607..0000000000 --- a/packages/agent-core-v2/src/session/sessionTitle/flag.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * `sessionTitle` domain (L6) — registers the `auto-title` experimental flag - * into `flag`. - * - * Gates generating the session title on demand through the managed - * platform's `chat_title` tool. Off by default; enable via - * `KIMI_CODE_EXPERIMENTAL_AUTO_TITLE`, the master - * `KIMI_CODE_EXPERIMENTAL_FLAG`, or the `[experimental]` config section. - * Imported for its side effect (registers the definition) from the package - * barrel. - */ - -import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; - -export const AUTO_TITLE_FLAG_ID = 'auto-title'; -export const AUTO_TITLE_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_AUTO_TITLE'; - -export const autoTitleFlag: FlagDefinitionInput = { - id: AUTO_TITLE_FLAG_ID, - title: 'Auto session title', - description: - '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', -}; - -registerFlagDefinition(autoTitleFlag); diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index 6ce28b58e8..e10ba17b3c 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -6,13 +6,12 @@ * `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 - * managed Kimi Code OAuth login; any failure degrades to keeping the current - * title, and a custom title set by the user is never overwritten. Provider - * config comes from `provider`, the bearer token from `auth`, host identity - * headers from `model`, gating from `flag`, prompt history from - * `agentLifecycle`/`sessionTitle`, and logs through `log`. Bound at Session - * scope. + * (the kap-server route), gated by a managed Kimi Code OAuth login; any + * failure degrades to keeping the current title, and a custom title set by + * the user is never overwritten. Provider config comes from `provider`, the + * bearer token from `auth`, host identity headers from `model`, prompt + * history from `agentLifecycle`/`sessionTitle`, and logs through `log`. + * Bound at Session scope. */ import { @@ -28,7 +27,6 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/ import { ILogService } from '#/_base/log/log'; import { IOAuthService } from '#/app/auth/auth'; import { IEventService } from '#/app/event/event'; -import { IFlagService } from '#/app/flag/flag'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; import { IProviderService } from '#/kosong/provider/provider'; @@ -37,7 +35,6 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { IAgentTitlePromptSource } from './agentTitlePromptSource'; -import { AUTO_TITLE_FLAG_ID } from './flag'; import { ISessionTitleService } from './sessionTitle'; const MAX_GENERATED_TITLE_LENGTH = 200; @@ -56,7 +53,6 @@ export class SessionTitleService implements ISessionTitleService { @ISessionMetadata private readonly metadata: ISessionMetadata, @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, @IEventService private readonly eventService: IEventService, - @IFlagService private readonly flags: IFlagService, @IProviderService private readonly providers: IProviderService, @IOAuthService private readonly oauth: IOAuthService, @IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders, @@ -64,7 +60,6 @@ export class SessionTitleService implements ISessionTitleService { ) {} async generateTitle(): Promise { - if (!this.flags.enabled(AUTO_TITLE_FLAG_ID)) return undefined; const current = await this.metadata.read(); if (hasCustomTitle(current)) return undefined; const main = this.agentLifecycle.get(MAIN_AGENT_ID); 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 4d68233026..cb468fbe4e 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -17,7 +17,6 @@ import { createServices, type TestInstantiationService } from '#/_base/di/test'; import { Emitter } from '#/_base/event'; import { IOAuthService } from '#/app/auth/auth'; import { type DomainEvent, IEventService } from '#/app/event/event'; -import { IFlagService } from '#/app/flag/flag'; import { HostRequestHeaders, IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; import { IProviderService, @@ -43,7 +42,6 @@ import { import '#/kosong/provider/providers/kimi/kimi.contrib'; import { registerLogServices } from '../../_base/log/stubs'; -import { stubFlag } from '../../app/flag/stubs'; import { stubProviderService } from '../../app/provider/stubs'; const SESSION_ID = 'sess-1'; @@ -138,7 +136,6 @@ describe('SessionTitleService', () => { let ix: TestInstantiationService; let events: FakeEventService; let metadata: FakeSessionMetadata; - let flagEnabled: boolean; let providers: Record; let fetchMock: Mock<(url: string, init?: RequestInit) => Promise>; let tokenError: Error | undefined; @@ -146,7 +143,6 @@ describe('SessionTitleService', () => { let titlePrompts: readonly string[]; beforeEach(() => { - flagEnabled = true; tokenError = undefined; resolvedOAuthRefs = []; titlePrompts = []; @@ -191,10 +187,6 @@ describe('SessionTitleService', () => { get: () => mainAgent, }); reg.defineInstance(IEventService, events); - reg.defineInstance( - IFlagService, - stubFlag(() => flagEnabled), - ); reg.defineInstance(IProviderService, stubProviderService(providers)); reg.definePartialInstance(IOAuthService, { resolveTokenProvider: (_provider, oauthRef) => { @@ -412,13 +404,6 @@ describe('SessionTitleService', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); - it('returns unavailable without calling the backend when the flag is off', async () => { - flagEnabled = false; - - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - it('returns unavailable without calling the backend when no prompt was seen', async () => { await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); expect(fetchMock).not.toHaveBeenCalled(); diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index 37cb4b978f..62a6d55d99 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -634,7 +634,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void reply.send( errEnvelope( ErrorCode.SESSION_TITLE_UNAVAILABLE, - 'session title generation is unavailable (auto-title flag off, no managed OAuth login, no prompt yet, or the backend request failed)', + 'session title generation is unavailable (no managed OAuth login, no prompt yet, or the backend request failed)', req.id, ), ); From 86b11635ac999c2ddf2f8e45ec9cc7c6fa5c84b6 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 30 Jul 2026 15:29:38 +0800 Subject: [PATCH 14/47] test: cover session title generation through the public REST path --- packages/kap-server/test/sessions.test.ts | 110 ++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index cb0821fd26..f526dff647 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -16,7 +16,9 @@ import { Error2, ErrorCodes, IBootstrapService, + IOAuthService, type DomainEvent, + type IOAuthService as IOAuthServiceType, IAgentConversationUndoService, IAgentGoalService, IAgentLifecycleService, @@ -25,6 +27,7 @@ import { ISessionLifecycleService, MAIN_AGENT_ID, type ServiceIdentifier, + type ScopeSeed, } from '@moonshot-ai/agent-core-v2'; import { sessionWarningsResponseSchema } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug'; @@ -102,6 +105,8 @@ describe('server-v2 /api/v1/sessions', () => { }); afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); if (server !== undefined) { await server.close(); server = undefined; @@ -523,6 +528,111 @@ describe('server-v2 /api/v1/sessions', () => { expect(generated.body.code).toBe(40922); }); + it('generates and persists a title through the public REST path', async () => { + await server?.close(); + server = undefined; + await writeFile( + join(home as string, 'config.toml'), + [ + 'default_model = "stub"', + '', + '[providers.stub]', + 'type = "openai"', + 'base_url = "http://127.0.0.1:9999"', + 'api_key = "stub"', + '', + '[models.stub]', + 'provider = "stub"', + 'model = "stub"', + 'max_context_size = 1000', + '', + '[providers."managed:kimi-code"]', + 'type = "kimi"', + 'base_url = "https://api.example.test/coding/v1"', + '', + '[providers."managed:kimi-code".oauth]', + 'storage = "file"', + 'key = "kimi-code"', + '', + ].join('\n'), + 'utf-8', + ); + + const oauth: IOAuthServiceType = { + _serviceBrand: undefined, + startLogin: async () => { + throw new Error('unused'); + }, + getFlow: () => undefined, + cancelLogin: async () => { + throw new Error('unused'); + }, + logout: async () => { + throw new Error('unused'); + }, + status: async () => ({ loggedIn: true, provider: 'managed:kimi-code' }), + refreshOAuthProviderModels: async () => ({ changed: [], unchanged: [], failed: [] }), + getManagedUsage: async () => ({ kind: 'error', message: 'unused' }), + getManagedUserInfo: async () => ({ kind: 'error', message: 'unused' }), + resolveTokenProvider: () => ({ getAccessToken: async () => 'test-token' }), + getCachedAccessToken: async () => 'test-token', + }; + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + seeds: [[IOAuthService, oauth]] as ScopeSeed, + }); + base = `http://127.0.0.1:${server.port}`; + + let toolsRequest: { method: string; params: { chat_content: string } } | undefined; + const actualFetch = globalThis.fetch.bind(globalThis); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url === 'https://api.example.test/coding/v1/tools') { + const body = init?.body; + if (typeof body !== 'string') { + throw new TypeError('expected a string request body'); + } + toolsRequest = JSON.parse(body) as typeof toolsRequest; + return new Response(JSON.stringify({ title: 'generated from REST' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + return actualFetch(input, init); + }); + + const created = await postJson('/api/v1/sessions', { + metadata: { cwd: home as string }, + }); + const id = created.body.data.id; + for (const text of ['first REST prompt', 'second REST prompt', 'third REST prompt']) { + const submitted = await postJson<{ prompt_id: string }>( + `/api/v1/sessions/${id}/prompts`, + { content: [{ type: 'text', text }] }, + ); + expect(submitted.body.code).toBe(0); + } + + const generated = await postJson<{ title: string }>( + `/api/v1/sessions/${id}/title/generate`, + ); + expect(generated.body).toMatchObject({ code: 0, data: { title: 'generated from REST' } }); + expect(toolsRequest).toEqual({ + method: 'chat_title', + params: { + chat_content: + 'user 1: first REST prompt\nuser 2: second REST prompt\nuser 3: third REST prompt', + }, + }); + + const got = await getJson(`/api/v1/sessions/${id}`); + expect(got.body).toMatchObject({ code: 0, data: { title: 'generated from REST' } }); + }); + it('returns session-not-found when generating a title for a missing session', async () => { const generated = await postJson( '/api/v1/sessions/sess_missing_title/title/generate', From 68ed6235618350bfac7dd7769f905688446d2f56 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 30 Jul 2026 15:56:35 +0800 Subject: [PATCH 15/47] feat: request session title generation from the TUI after each turn --- .../tui/controllers/session-event-handler.ts | 24 ++++ .../session-event-handler-goal-queue.test.ts | 1 + ...ssion-event-handler-plugin-updates.test.ts | 1 + .../session-event-handler-title.test.ts | 124 ++++++++++++++++++ .../test/tui/kimi-tui-message-flow.test.ts | 1 + packages/klient/src/contract/index.ts | 2 + packages/klient/src/contract/session/title.ts | 13 ++ packages/klient/src/core/facade/session.ts | 8 ++ .../src/transports/memory/serviceRegistry.ts | 2 + packages/node-sdk/src/kimi-harness.ts | 14 ++ packages/node-sdk/src/rpc.ts | 14 ++ packages/node-sdk/src/sdk-rpc-client-v2.ts | 23 ++++ packages/node-sdk/src/types.ts | 4 + 13 files changed, 231 insertions(+) create mode 100644 apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts create mode 100644 packages/klient/src/contract/session/title.ts diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index b82f919610..ff30b5e501 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -14,6 +14,7 @@ import type { GoalChange, GoalUpdatedEvent, HookResultEvent, + KimiHarness, Session, SessionMetaUpdatedEvent, SkillActivatedEvent, @@ -94,6 +95,7 @@ export interface SessionEventHost { aborted: boolean; sessionEventUnsubscribe: (() => void) | undefined; readonly streamingUI: StreamingUIController; + readonly harness: KimiHarness; requireSession(): Session; setAppState(patch: Partial): void; @@ -162,6 +164,7 @@ export class SessionEventHandler { private queuedGoalPromotionPending = false; private queuedGoalPromotionInFlight = false; private queuedGoalPromotionTimer: ReturnType | undefined; + private titleGenerationDisabled = false; resetRuntimeState(): void { this.backgroundTasks.clear(); @@ -180,6 +183,7 @@ export class SessionEventHandler { this.queuedGoalPromotionPending = false; this.queuedGoalPromotionInFlight = false; this.clearQueuedGoalPromotionTimer(); + this.titleGenerationDisabled = false; this.stopAllMcpServerStatusSpinners(); } @@ -384,9 +388,29 @@ export class SessionEventHandler { } } this.pluginMcpToolsUsedInTurn.clear(); + this.requestSessionTitleGeneration(); this.scheduleQueuedGoalPromotion(); } + /** + * Best-effort auto title: while the session has no title yet, ask the + * engine to generate one from the first prompts after each completed turn. + * The engine keeps custom titles untouched and dedupes in-flight requests; + * a resolved `undefined` (no managed login / no prompt yet) just retries on + * the next turn, while a rejection (v1 engine, dead RPC) disables further + * attempts for this session. The generated title lands through the regular + * `session.meta.updated` event. + */ + private requestSessionTitleGeneration(): void { + if (this.titleGenerationDisabled) return; + const { sessionId, sessionTitle } = this.host.state.appState; + if (sessionId.length === 0) return; + if (typeof sessionTitle === 'string' && sessionTitle.trim().length > 0) return; + void this.host.harness.generateSessionTitle({ id: sessionId }).catch(() => { + this.titleGenerationDisabled = true; + }); + } + private handleStepBegin(event: TurnStepStartedEvent): void { this.host.streamingUI.flushNow(); this.host.streamingUI.setStep(event.step); diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index 8b9d5fbdfc..fea52b8353 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -95,6 +95,7 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, tasksBrowserController: {}, + harness: { generateSessionTitle: vi.fn(async () => undefined) }, }; host.setAppState.mockImplementation((patch: Record) => { Object.assign(host.state.appState, patch); diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts index 3220d1b916..9a6d6c8fdd 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts @@ -56,6 +56,7 @@ function makeHost() { shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, tasksBrowserController: {}, + harness: { generateSessionTitle: vi.fn(async () => undefined) }, }; return { host: host as never, streamingUI }; } diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts new file mode 100644 index 0000000000..7fb56e5ff3 --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; +import { getBuiltInPalette } from '#/tui/theme'; + +function makeHost(options: { sessionTitle?: string | null; generateTitle?: () => Promise } = {}) { + const harness = { + generateSessionTitle: vi.fn(options.generateTitle ?? (async () => undefined)), + }; + const host = { + state: { + appState: { + sessionId: 's1', + sessionTitle: options.sessionTitle ?? null, + workDir: '/tmp/work', + streamingPhase: 'waiting', + model: 'kimi-model', + permissionMode: 'auto', + }, + queuedMessages: [], + queuedMessageDispatchPending: false, + theme: { palette: getBuiltInPalette('dark') }, + toolOutputExpanded: false, + todoPanel: { getTodos: vi.fn(() => []) }, + transcriptContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: undefined, + aborted: false, + sessionEventUnsubscribe: undefined, + streamingUI: { + setTurnId: vi.fn(), + flushNow: vi.fn(), + resetToolUi: vi.fn(), + finalizeTurn: vi.fn(), + }, + harness, + requireSession: vi.fn(), + setAppState: vi.fn(), + patchLivePane: vi.fn(), + resetLivePane: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + track: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + appendTranscriptEntry: vi.fn(), + sendNormalUserInput: vi.fn(), + sendQueuedMessage: vi.fn(), + shiftQueuedMessage: vi.fn(), + updateActivityPane: vi.fn(), + updateTerminalTitle: vi.fn(), + handleShellOutput: vi.fn(), + handleShellStarted: vi.fn(), + btwPanelController: { routeEvent: vi.fn(() => false) }, + tasksBrowserController: {}, + }; + return { host: host as any, harness }; +} + +function turnEndedEvent() { + return { + type: 'turn.ended', + sessionId: 's1', + agentId: 'main', + turnId: 1, + reason: 'completed', + } as const; +} + +async function flushMicrotasks() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('session auto title generation', () => { + it('requests a title after a turn ends while the session has none', () => { + const { host, harness } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); + }); + + it('does not request a title when the session already has one', () => { + const { host, harness } = makeHost({ sessionTitle: 'custom title' }); + const handler = new SessionEventHandler(host); + + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(harness.generateSessionTitle).not.toHaveBeenCalled(); + }); + + it('keeps requesting after an unavailable (undefined) result', async () => { + const { host, harness } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent(turnEndedEvent(), vi.fn()); + await flushMicrotasks(); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2); + }); + + it('stops requesting after the harness rejects, and resetRuntimeState re-enables', async () => { + const { host, harness } = makeHost({ + generateTitle: async () => { + throw new Error('not implemented'); + }, + }); + const handler = new SessionEventHandler(host); + + handler.handleEvent(turnEndedEvent(), vi.fn()); + await flushMicrotasks(); + handler.handleEvent(turnEndedEvent(), vi.fn()); + expect(harness.generateSessionTitle).toHaveBeenCalledTimes(1); + + handler.resetRuntimeState(); + handler.handleEvent(turnEndedEvent(), vi.fn()); + expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index b46eab17bf..d6a509e546 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -282,6 +282,7 @@ function makeHarness(session = makeSession(), overrides: Record return interactiveAgentScope.run(agentId, fn); }), getExperimentalFeatures: vi.fn(async () => []), + generateSessionTitle: vi.fn(async () => undefined), auth: { status: vi.fn(), login: vi.fn(), diff --git a/packages/klient/src/contract/index.ts b/packages/klient/src/contract/index.ts index 39065bf553..78e337358c 100644 --- a/packages/klient/src/contract/index.ts +++ b/packages/klient/src/contract/index.ts @@ -33,6 +33,7 @@ import { sessionInteractionContract } from './session/interaction.js'; import { sessionLifecycleContract } from './session/lifecycle.js'; import { sessionMetadataContract } from './session/metadata.js'; import { sessionQuestionContract } from './session/question.js'; +import { sessionTitleContract } from './session/title.js'; export const globalContract: KlientContract = { // core (app scope) @@ -55,6 +56,7 @@ export const globalContract: KlientContract = { sessionInteractionService: sessionInteractionContract, sessionApprovalService: sessionApprovalContract, sessionQuestionService: sessionQuestionContract, + sessionTitleService: sessionTitleContract, // agent scope agentRPCService: agentRpcContract, agentActivityView: agentActivityViewContract, diff --git a/packages/klient/src/contract/session/title.ts b/packages/klient/src/contract/session/title.ts new file mode 100644 index 0000000000..cf936bc6ca --- /dev/null +++ b/packages/klient/src/contract/session/title.ts @@ -0,0 +1,13 @@ +/** + * `sessionTitleService` — on-demand session title generation. Mirrors + * `agent-core-v2/session/sessionTitle/sessionTitle.ts`. + */ + +import { z } from 'zod'; + +import { maybe } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const sessionTitleContract = { + generateTitle: { input: z.tuple([]), output: maybe(z.string()) }, +} satisfies ServiceContract; diff --git a/packages/klient/src/core/facade/session.ts b/packages/klient/src/core/facade/session.ts index cce957aa60..3f14476280 100644 --- a/packages/klient/src/core/facade/session.ts +++ b/packages/klient/src/core/facade/session.ts @@ -63,6 +63,12 @@ export type SessionStatus = 'running' | 'idle' | 'awaiting_approval' | 'awaiting export interface SessionFacade { get(): Promise; setTitle(title: string): Promise; + /** + * Generate and apply a title from the main agent's first prompts via the + * managed `chat_title` tool. `undefined` when generation is unavailable + * (no managed OAuth login, no prompt yet, or a custom title is set). + */ + generateTitle(): Promise; update(patch: SessionMetaPatch): Promise; setArchived(archived: boolean): Promise; status(): Promise; @@ -99,6 +105,8 @@ export function createSessionFacade(call: ScopedCaller, sessionId: string): Sess return { get: read, setTitle: (title) => call(scope, 'sessionMetadata', 'setTitle', [title]) as Promise, + generateTitle: () => + call(scope, 'sessionTitleService', 'generateTitle', []) as Promise, update: (patch) => call(scope, 'sessionMetadata', 'update', [patch]) as Promise, setArchived: (archived) => call(scope, 'sessionMetadata', 'setArchived', [archived]) as Promise, diff --git a/packages/klient/src/transports/memory/serviceRegistry.ts b/packages/klient/src/transports/memory/serviceRegistry.ts index 354089a171..e56dfe17a5 100644 --- a/packages/klient/src/transports/memory/serviceRegistry.ts +++ b/packages/klient/src/transports/memory/serviceRegistry.ts @@ -27,6 +27,7 @@ import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMeta import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/interaction/interaction'; import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval'; import { ISessionQuestionService } from '@moonshot-ai/agent-core-v2/session/question/question'; +import { ISessionTitleService } from '@moonshot-ai/agent-core-v2/session/sessionTitle/sessionTitle'; import { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc'; import { IAgentActivityView } from '@moonshot-ai/agent-core-v2/agent/activityView/activityView'; import { IAgentPlanService } from '@moonshot-ai/agent-core-v2/agent/plan/plan'; @@ -55,6 +56,7 @@ export const serviceTokens: Readonly>> sessionInteractionService: ISessionInteractionService, sessionApprovalService: ISessionApprovalService, sessionQuestionService: ISessionQuestionService, + sessionTitleService: ISessionTitleService, agentRPCService: IAgentRPCService, agentActivityView: IAgentActivityView, agentShellCommandService: IAgentShellCommandService, diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 8197d9bac0..8951677f5a 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -17,6 +17,7 @@ import type { ExportSessionInput, ExportSessionResult, ForkSessionInput, + GenerateSessionTitleInput, GetConfigOptions, KimiConfig, KimiConfigPatch, @@ -237,6 +238,19 @@ export class KimiHarness { this.activeSessions.get(input.id)?.emitMetaUpdated({ title: input.title }); } + /** + * Generate and apply a session title from the main agent's first prompts + * (v2 engine only). Resolves to `undefined` when generation is unavailable + * and the current title is kept. + */ + async generateSessionTitle(input: GenerateSessionTitleInput): Promise { + const title = await this.rpc.generateSessionTitle(input); + if (title !== undefined) { + this.activeSessions.get(input.id)?.emitMetaUpdated({ title }); + } + return title; + } + async exportSession(input: ExportSessionInput): Promise { const result = await this.rpc.exportSession({ ...input, diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 42fc747abd..7d3e96be4c 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -2,6 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { ErrorCodes, + KimiError, makeErrorPayload, type AgentContextData, type ApprovalRequest, @@ -32,6 +33,7 @@ import type { ExportSessionResult, CreateGoalInput, ForkSessionInput, + GenerateSessionTitleInput, GetConfigOptions, McpServerConfig, GoalSnapshot, @@ -228,6 +230,18 @@ export abstract class SDKRpcClientBase { }); } + /** + * v2-only capability (`ISessionTitleService`); the v1 engine has no title + * generation, so the base fails loudly and `SDKRpcClientV2` overrides it. + */ + async generateSessionTitle(input: GenerateSessionTitleInput): Promise { + void input; + throw new KimiError( + ErrorCodes.NOT_IMPLEMENTED, + 'generateSessionTitle is only available on the agent-core-v2 engine.', + ); + } + async exportSession(input: ExportSessionInput): Promise { const rpc = await this.getRpc(); return rpc.exportSession({ diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 31f5c64d10..fc021722b6 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -256,6 +256,7 @@ import type { ExportSessionInput, ExportSessionResult, ForkSessionInput, + GenerateSessionTitleInput, GetConfigOptions, GetCronTasksResult, GoalSnapshot, @@ -995,6 +996,28 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { } } + /** + * v2-only (`ISessionTitleService`, session scope). Like `renameSession`, a + * closed session is resumed, titled, and closed again so generation does + * not leak a live session. `undefined` means generation was unavailable + * (no managed OAuth login, no prompt yet, or a custom title is set) — the + * current title is kept. + */ + override async generateSessionTitle( + input: GenerateSessionTitleInput, + ): Promise { + if (this.sessionLifecycle.get(input.id) !== undefined) { + return this.klient.session(input.id).generateTitle(); + } + const handle = await this.sessionLifecycle.resume(input.id); + if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); + try { + return await this.klient.session(input.id).generateTitle(); + } finally { + await this.sessionLifecycle.close(input.id); + } + } + /** * Through `engineAccessor` (`ISessionLifecycleService.fork`) because the * klient facade fork takes no explicit target id. Known gaps vs v1: the diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index d65e5d30c6..3f8462a624 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -133,6 +133,10 @@ export interface RenameSessionInput { readonly title: string; } +export interface GenerateSessionTitleInput { + readonly id: string; +} + export interface ResumeSessionInput { readonly id: string; readonly kaos?: Kaos | undefined; From cca5387e7c82f81d0c8604fe5772045cfb7ee3ee Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 30 Jul 2026 16:47:57 +0800 Subject: [PATCH 16/47] Retry auto title generation for prompt-derived session titles --- .changeset/auto-session-title.md | 5 ++++ .../tui/controllers/session-event-handler.ts | 30 +++++++++++-------- .../session-event-handler-title.test.ts | 17 +++++++++-- 3 files changed, 37 insertions(+), 15 deletions(-) create mode 100644 .changeset/auto-session-title.md diff --git a/.changeset/auto-session-title.md b/.changeset/auto-session-title.md new file mode 100644 index 0000000000..e7715103d5 --- /dev/null +++ b/.changeset/auto-session-title.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Automatically generate concise session titles after the first turn without overwriting custom titles. diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index ff30b5e501..9feb7cf175 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -393,22 +393,28 @@ export class SessionEventHandler { } /** - * Best-effort auto title: while the session has no title yet, ask the - * engine to generate one from the first prompts after each completed turn. - * The engine keeps custom titles untouched and dedupes in-flight requests; - * a resolved `undefined` (no managed login / no prompt yet) just retries on - * the next turn, while a rejection (v1 engine, dead RPC) disables further - * attempts for this session. The generated title lands through the regular - * `session.meta.updated` event. + * Best-effort auto title: after each completed turn, ask the engine to + * generate a title from the first prompts until one lands. The engine + * overwrites the prompt-derived easy title but never a custom title + * (enforced server-side), and dedupes in-flight requests. A resolved string + * means a title was applied — stop asking so later turns don't regenerate + * over it; a resolved `undefined` (no managed login / no prompt yet / a + * custom title) just retries on the next turn; a rejection (v1 engine, + * dead RPC) disables further attempts for this session. The generated + * title lands through the regular `session.meta.updated` event. */ private requestSessionTitleGeneration(): void { if (this.titleGenerationDisabled) return; - const { sessionId, sessionTitle } = this.host.state.appState; + const { sessionId } = this.host.state.appState; if (sessionId.length === 0) return; - if (typeof sessionTitle === 'string' && sessionTitle.trim().length > 0) return; - void this.host.harness.generateSessionTitle({ id: sessionId }).catch(() => { - this.titleGenerationDisabled = true; - }); + void this.host.harness.generateSessionTitle({ id: sessionId }).then( + (title) => { + if (title !== undefined) this.titleGenerationDisabled = true; + }, + () => { + this.titleGenerationDisabled = true; + }, + ); } private handleStepBegin(event: TurnStepStartedEvent): void { diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts index 7fb56e5ff3..c5de0435d0 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts @@ -84,13 +84,24 @@ describe('session auto title generation', () => { expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); }); - it('does not request a title when the session already has one', () => { - const { host, harness } = makeHost({ sessionTitle: 'custom title' }); + it('requests a title after a turn ends even when an easy title exists', () => { + const { host, harness } = makeHost({ sessionTitle: '首条 prompt 的截断标题' }); const handler = new SessionEventHandler(host); handler.handleEvent(turnEndedEvent(), vi.fn()); - expect(harness.generateSessionTitle).not.toHaveBeenCalled(); + expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); + }); + + it('stops requesting after a title was generated', async () => { + const { host, harness } = makeHost({ generateTitle: async () => '生成的标题' }); + const handler = new SessionEventHandler(host); + + handler.handleEvent(turnEndedEvent(), vi.fn()); + await flushMicrotasks(); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(harness.generateSessionTitle).toHaveBeenCalledTimes(1); }); it('keeps requesting after an unavailable (undefined) result', async () => { From 87408d0badcd0f1a7103e62d89529db6215ceeb2 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 30 Jul 2026 21:18:23 +0800 Subject: [PATCH 17/47] feat: record session title source and harden the generation lifecycle - persist titleSource (prompt/generated/custom); skip auto-generation over an already-generated title unless forced, and never over a custom one - plumb the force option from the core through klient and node-sdk to the REST title/generate endpoint - drop the title write-back when the session scope was superseded mid-flight, and retry once with a force-refreshed token on a 401 - stop closing sessions a concurrent public resume has handed out in the temporary resume paths (generateSessionTitle, renameSession) - accept session.meta.updated patches without lastPrompt in klient event validation, and emit exactly one metadata event per applied title - remove the retired prompts field heal and drop the changeset (the behavior is only perceivable on the experimental v2 engine) --- .changeset/auto-session-title.md | 5 - .../agent-core-v2/docs/state-manifest.d.ts | 3 +- .../sessionMetadata/sessionMetadata.ts | 1 + .../sessionMetadata/sessionMetadataService.ts | 14 +- .../src/session/sessionTitle/sessionTitle.ts | 6 +- .../sessionTitle/sessionTitleService.ts | 43 ++++-- .../sessionMetadata/sessionMetadata.test.ts | 23 +--- .../sessionTitle/sessionTitleService.test.ts | 126 +++++++++++++++++- .../kap-server/src/protocol/rest-session.ts | 8 ++ packages/kap-server/src/routes/sessions.ts | 6 +- packages/klient/src/contract/global/events.ts | 2 +- .../klient/src/contract/session/metadata.ts | 3 + packages/klient/src/contract/session/title.ts | 7 +- packages/klient/src/core/facade/session.ts | 9 +- packages/klient/test/contract-parity.ts | 15 +++ packages/klient/test/facade.test.ts | 31 +++++ packages/node-sdk/src/kimi-harness.ts | 6 +- packages/node-sdk/src/sdk-rpc-client-v2.ts | 23 +++- packages/node-sdk/src/types.ts | 1 + .../node-sdk/test/sdk-rpc-client-v2.test.ts | 122 ++++++++++++++++- 20 files changed, 381 insertions(+), 73 deletions(-) delete mode 100644 .changeset/auto-session-title.md diff --git a/.changeset/auto-session-title.md b/.changeset/auto-session-title.md deleted file mode 100644 index e7715103d5..0000000000 --- a/.changeset/auto-session-title.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Automatically generate concise session titles after the first turn without overwriting custom titles. diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index f1027e72b0..a83e5a269c 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -335,6 +335,7 @@ export interface SessionStateSnapshot { readonly id: string; readonly version?: number; readonly title?: string; + readonly titleSource?: 'prompt' | 'generated' | 'custom'; readonly isCustomTitle?: boolean; readonly lastPrompt?: string; readonly createdAt: number; @@ -1008,7 +1009,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map { - await this.update({ title, isCustomTitle: true }); + await this.update({ title, titleSource: 'custom', 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 }); + await this.applyUpdate({ title, titleSource: 'generated', isCustomTitle: false }); return true; }); } @@ -174,11 +174,7 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { const existing = await this.store.get(this.scope, META_KEY); if (existing !== undefined) { this.data = normalizeSessionMeta(existing, this.ctx.sessionId); - if ( - this.data.agents === undefined || - this.data.custom === undefined || - Object.prototype.hasOwnProperty.call(existing, 'prompts') - ) { + if (this.data.agents === undefined || this.data.custom === undefined) { this.data = { ...this.data, agents: this.data.agents ?? {}, @@ -224,9 +220,7 @@ function recordEquals(a: AgentMeta['labels'], b: AgentMeta['labels']): boolean { } export function normalizeSessionMeta(raw: SessionMeta, sessionId: string): SessionMeta { - const normalized = { ...raw } as SessionMeta & { prompts?: unknown }; - delete normalized.prompts; - const clean = normalized as SessionMeta; + const clean = { ...raw }; const legacy = raw as unknown as { createdAt?: unknown; updatedAt?: unknown; diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts index 6b93c43c6d..b3f243dd0a 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts @@ -2,7 +2,9 @@ * `sessionTitle` domain (L6) — session title generation contract. * * Defines the Session-scoped `ISessionTitleService` that (re)generates a - * session title from the main Agent's conversation history. + * session title from the main Agent's conversation history. A title that was + * already generated is not regenerated unless `force` is set; a custom title + * is never overwritten. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -10,7 +12,7 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio export interface ISessionTitleService { readonly _serviceBrand: undefined; - generateTitle(): Promise; + generateTitle(options?: { readonly force?: boolean }): Promise; } export const ISessionTitleService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index e10ba17b3c..bc4b56ab45 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -8,10 +8,12 @@ * Generation is on demand only: `generateTitle()` is the single entry point * (the kap-server route), gated by a managed Kimi Code OAuth login; any * failure degrades to keeping the current title, and a custom title set by - * the user is never overwritten. Provider config comes from `provider`, the - * bearer token from `auth`, host identity headers from `model`, prompt - * history from `agentLifecycle`/`sessionTitle`, and logs through `log`. - * Bound at Session scope. + * the user is never overwritten. An already-generated title is not + * regenerated unless forced, and the write-back is dropped when this scope + * is no longer the live session in `sessionLifecycle`. Provider config comes + * from `provider`, the bearer token from `auth`, host identity headers from + * `model`, prompt history from `agentLifecycle`/`sessionTitle`, and logs + * through `log`. Bound at Session scope. */ import { @@ -27,6 +29,7 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/ import { ILogService } from '#/_base/log/log'; import { IOAuthService } from '#/app/auth/auth'; import { IEventService } from '#/app/event/event'; +import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; import { IProviderService } from '#/kosong/provider/provider'; @@ -53,15 +56,17 @@ export class SessionTitleService implements ISessionTitleService { @ISessionMetadata private readonly metadata: ISessionMetadata, @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, @IEventService private readonly eventService: IEventService, + @ISessionLifecycleService private readonly sessionLifecycle: ISessionLifecycleService, @IProviderService private readonly providers: IProviderService, @IOAuthService private readonly oauth: IOAuthService, @IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders, @ILogService private readonly log: ILogService, ) {} - async generateTitle(): Promise { + async generateTitle(options?: { readonly force?: boolean }): Promise { const current = await this.metadata.read(); if (hasCustomTitle(current)) return undefined; + if (current.titleSource === 'generated' && options?.force !== true) return undefined; const main = this.agentLifecycle.get(MAIN_AGENT_ID); const prompts = main === undefined @@ -110,22 +115,36 @@ export class SessionTitleService implements ISessionTitleService { this.log.debug(`chat_title request unavailable: ${error.message}`); return undefined; } - const result = await fetchChatTitle( - kimiCodeToolsUrl(runtimeAuth.baseUrl), - token, - chatContent, - { + const requestTitle = (accessToken: string) => + fetchChatTitle(kimiCodeToolsUrl(runtimeAuth.baseUrl), accessToken, chatContent, { headers: { ...parseKimiCodeCustomHeaders(), ...this.hostHeaders.headers, ...provider.customHeaders, }, - }, - ); + }); + let result = await requestTitle(token); + if (result.kind === 'error' && result.status === 401) { + // The cached token may be revoked server-side: force-refresh and retry once. + try { + token = await tokenProvider.getAccessToken({ force: true }); + } catch (error) { + if (!(error instanceof OAuthError)) throw error; + this.log.debug(`chat_title request unavailable: ${error.message}`); + return undefined; + } + result = await requestTitle(token); + } if (result.kind !== 'ok') { this.log.debug(`chat_title request failed: ${result.message}`); return undefined; } + // A stale scope (the session was resumed or closed while the request was + // in flight) must not write its title back. + const live = this.sessionLifecycle.get(this.ctx.sessionId); + if (live === undefined || live.accessor.get(ISessionMetadata) !== this.metadata) { + return undefined; + } const title = result.title.slice(0, MAX_GENERATED_TITLE_LENGTH); const applied = await this.metadata.setGeneratedTitleIfUncustomized(title); if (!applied) return undefined; 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 475d0ab258..c00a352bd0 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -93,7 +93,7 @@ describe('SessionMetadata', () => { const meta = ix.get(ISessionMetadata); await meta.setTitle('t'); await meta.setArchived(true); - expect(await meta.read()).toMatchObject({ title: 't', archived: true }); + expect(await meta.read()).toMatchObject({ title: 't', titleSource: 'custom', archived: true }); }); it('sets a generated title while the metadata remains uncustomized', async () => { @@ -102,6 +102,7 @@ describe('SessionMetadata', () => { await expect(meta.setGeneratedTitleIfUncustomized('generated title')).resolves.toBe(true); await expect(meta.read()).resolves.toMatchObject({ title: 'generated title', + titleSource: 'generated', isCustomTitle: false, }); }); @@ -168,26 +169,6 @@ describe('SessionMetadata', () => { expect(healed.updatedAt).toBe(1700000000000); }); - it('removes the retired prompts field from persisted metadata', async () => { - const store = ix.get(IAtomicDocumentStore); - await store.set(META_SCOPE, 'state.json', { - id: 's1', - version: 2, - createdAt: 1700000000000, - updatedAt: 1700000000000, - archived: false, - agents: {}, - custom: {}, - prompts: ['第一条', '第二条'], - }); - - const meta = ix.get(ISessionMetadata); - expect(await meta.read()).not.toHaveProperty('prompts'); - - const fresh = createFreshMetadata(ix); - expect(await fresh.read()).not.toHaveProperty('prompts'); - }); - it('normalizes the legacy customTitle field before callers read metadata', 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 cb468fbe4e..71cd9d7119 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -12,11 +12,17 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite import { OAuthConnectionError, OAuthUnauthorizedError } from '@moonshot-ai/kimi-code-oauth'; import { DisposableStore, type IDisposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, type IAgentScopeHandle } from '#/_base/di/scope'; +import type { ServiceIdentifier } from '#/_base/di/instantiation'; +import { + LifecycleScope, + type IAgentScopeHandle, + type ISessionScopeHandle, +} from '#/_base/di/scope'; import { createServices, type TestInstantiationService } from '#/_base/di/test'; import { Emitter } from '#/_base/event'; import { IOAuthService } from '#/app/auth/auth'; import { type DomainEvent, IEventService } from '#/app/event/event'; +import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; import { HostRequestHeaders, IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; import { IProviderService, @@ -94,12 +100,12 @@ class FakeSessionMetadata implements ISessionMetadata { } setTitle(title: string): Promise { - return this.update({ title, isCustomTitle: true }); + return this.update({ title, titleSource: 'custom', isCustomTitle: true }); } async setGeneratedTitleIfUncustomized(title: string): Promise { if (this.meta.isCustomTitle === true) return false; - await this.update({ title, isCustomTitle: false }); + await this.update({ title, titleSource: 'generated', isCustomTitle: false }); return true; } @@ -131,6 +137,20 @@ function createPendingFetch() { }; } +function makeLiveSessionHandle(meta: ISessionMetadata): ISessionScopeHandle { + return { + id: SESSION_ID, + kind: LifecycleScope.Session, + accessor: { + get: (id: ServiceIdentifier) => { + if (id === ISessionMetadata) return meta as T; + throw new Error(`unexpected service ${String(id)}`); + }, + }, + dispose: () => undefined, + }; +} + describe('SessionTitleService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; @@ -139,15 +159,21 @@ describe('SessionTitleService', () => { let providers: Record; let fetchMock: Mock<(url: string, init?: RequestInit) => Promise>; let tokenError: Error | undefined; + let forceTokenError: Error | undefined; let resolvedOAuthRefs: Array; let titlePrompts: readonly string[]; + let liveSession: ISessionScopeHandle | undefined; + let tokenCalls: boolean[]; beforeEach(() => { tokenError = undefined; + forceTokenError = undefined; resolvedOAuthRefs = []; titlePrompts = []; + tokenCalls = []; providers = { 'managed:kimi-code': MANAGED_PROVIDER }; metadata = new FakeSessionMetadata(); + liveSession = makeLiveSessionHandle(metadata); events = new FakeEventService(); fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise>( async () => @@ -187,13 +213,20 @@ describe('SessionTitleService', () => { get: () => mainAgent, }); reg.defineInstance(IEventService, events); + reg.definePartialInstance(ISessionLifecycleService, { + get: () => liveSession, + }); reg.defineInstance(IProviderService, stubProviderService(providers)); reg.definePartialInstance(IOAuthService, { resolveTokenProvider: (_provider, oauthRef) => { resolvedOAuthRefs.push(oauthRef); return { - getAccessToken: async () => { + getAccessToken: async (options) => { + tokenCalls.push(options?.force === true); if (tokenError !== undefined) throw tokenError; + if (options?.force === true && forceTokenError !== undefined) { + throw forceTokenError; + } return 'test-token'; }, }; @@ -220,6 +253,7 @@ describe('SessionTitleService', () => { expect(title).toBe('生成的标题'); expect(metadata.meta.title).toBe('生成的标题'); expect(metadata.meta.isCustomTitle).toBe(false); + expect(metadata.meta.titleSource).toBe('generated'); const [, init] = fetchMock.mock.calls[0]!; expect(JSON.parse(init?.body as string)).toEqual({ @@ -298,6 +332,58 @@ describe('SessionTitleService', () => { expect(metadata.meta.isCustomTitle).toBe(true); }); + it('skips generation when the current title was already generated', async () => { + await metadata.setGeneratedTitleIfUncustomized('已生成的标题'); + titlePrompts = ['hello']; + + await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(metadata.meta.title).toBe('已生成的标题'); + }); + + it('regenerates a generated title when forced', async () => { + await metadata.setGeneratedTitleIfUncustomized('已生成的标题'); + titlePrompts = ['hello']; + + await expect(ix.get(ISessionTitleService).generateTitle({ force: true })).resolves.toBe( + '生成的标题', + ); + expect(metadata.meta.title).toBe('生成的标题'); + expect(metadata.meta.titleSource).toBe('generated'); + }); + + it('never overwrites a custom title even when forced', async () => { + await metadata.setTitle('user 取的标题'); + titlePrompts = ['hello']; + + await expect( + ix.get(ISessionTitleService).generateTitle({ force: true }), + ).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(metadata.meta.title).toBe('user 取的标题'); + }); + + it('drops the write-back when the session scope was superseded mid-flight', async () => { + const applySpy = vi.spyOn(metadata, 'setGeneratedTitleIfUncustomized'); + titlePrompts = ['hello']; + liveSession = makeLiveSessionHandle(new FakeSessionMetadata()); + + await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(applySpy).not.toHaveBeenCalled(); + expect(metadata.meta.title).toBeUndefined(); + }); + + it('drops the write-back while the session is still resuming', async () => { + const applySpy = vi.spyOn(metadata, 'setGeneratedTitleIfUncustomized'); + titlePrompts = ['hello']; + liveSession = undefined; + + await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); + expect(applySpy).not.toHaveBeenCalled(); + expect(metadata.meta.title).toBeUndefined(); + }); + it('keeps the current title when the backend request fails', async () => { fetchMock.mockImplementationOnce(async () => new Response('', { status: 500 })); titlePrompts = ['hello']; @@ -305,6 +391,38 @@ describe('SessionTitleService', () => { await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); expect(metadata.meta.title).toBe('hello'); + expect(tokenCalls).toEqual([false]); + }); + + it('retries once with a force-refreshed token on a 401', async () => { + fetchMock.mockImplementationOnce(async () => new Response('', { status: 401 })); + titlePrompts = ['hello']; + + await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBe('生成的标题'); + expect(metadata.meta.title).toBe('生成的标题'); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(tokenCalls).toEqual([false, true]); + }); + + it('gives up when the 401 persists after the force refresh', async () => { + fetchMock.mockImplementation(async () => new Response('', { status: 401 })); + titlePrompts = ['hello']; + + await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); + expect(metadata.meta.title).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(tokenCalls).toEqual([false, true]); + }); + + it('degrades when the force refresh after a 401 fails', async () => { + fetchMock.mockImplementationOnce(async () => new Response('', { status: 401 })); + forceTokenError = new OAuthUnauthorizedError('refresh rejected'); + titlePrompts = ['hello']; + + await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); + expect(metadata.meta.title).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(tokenCalls).toEqual([false, true]); }); it('returns unavailable when the OAuth token is missing or revoked', async () => { diff --git a/packages/kap-server/src/protocol/rest-session.ts b/packages/kap-server/src/protocol/rest-session.ts index 4cf33af6a7..b53d772db7 100644 --- a/packages/kap-server/src/protocol/rest-session.ts +++ b/packages/kap-server/src/protocol/rest-session.ts @@ -164,6 +164,14 @@ export type CompactSessionRequest = z.infer; export const compactSessionResponseSchema = z.object({}); export type CompactSessionResponse = z.infer; +export const generateSessionTitleRequestSchema = z.preprocess( + (value) => (value === undefined ? {} : value), + z.object({ + force: z.boolean().optional(), + }), +); +export type GenerateSessionTitleRequest = z.infer; + export const undoSessionRequestSchema = z.preprocess( (value) => value === undefined ? {} : value, z.object({ diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index 62a6d55d99..ac0810ac74 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -111,6 +111,7 @@ import { createSessionChildRequestSchema, createSessionRequestSchema, forkSessionRequestSchema, + generateSessionTitleRequestSchema, getSessionGoalResponseSchema, listSessionChildrenResponseSchema, sessionAbortResponseSchema, @@ -611,6 +612,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void method: 'POST', path: '/sessions/{session_id}/title/generate', params: sessionIdParamSchema, + body: generateSessionTitleRequestSchema, success: { data: z.object({ title: z.string() }) }, errors: { [ErrorCode.SESSION_NOT_FOUND]: {}, @@ -629,7 +631,9 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void ); return; } - const title = await handle.accessor.get(ISessionTitleService).generateTitle(); + const title = await handle.accessor + .get(ISessionTitleService) + .generateTitle({ force: req.body.force }); if (title === undefined) { reply.send( errEnvelope( diff --git a/packages/klient/src/contract/global/events.ts b/packages/klient/src/contract/global/events.ts index 599a9e96fd..cdf0a9594a 100644 --- a/packages/klient/src/contract/global/events.ts +++ b/packages/klient/src/contract/global/events.ts @@ -73,7 +73,7 @@ const sessionMetaUpdatedSchema = z.object({ patch: z.object({ title: z.string().optional(), isCustomTitle: z.boolean().optional(), - lastPrompt: z.string(), + lastPrompt: z.string().optional(), }), }); diff --git a/packages/klient/src/contract/session/metadata.ts b/packages/klient/src/contract/session/metadata.ts index 13eaabc023..9ea5c62a8d 100644 --- a/packages/klient/src/contract/session/metadata.ts +++ b/packages/klient/src/contract/session/metadata.ts @@ -22,6 +22,7 @@ export const sessionMetaSchema = z.object({ id: z.string(), version: z.number().optional(), title: z.string().optional(), + titleSource: z.enum(['prompt', 'generated', 'custom']).optional(), isCustomTitle: z.boolean().optional(), lastPrompt: z.string().optional(), createdAt: z.number(), @@ -37,6 +38,7 @@ export const sessionMetaSchema = z.object({ export const sessionMetaPatchSchema = z.object({ version: z.number().optional(), title: z.string().optional(), + titleSource: z.enum(['prompt', 'generated', 'custom']).optional(), isCustomTitle: z.boolean().optional(), lastPrompt: z.string().optional(), updatedAt: z.number().optional(), @@ -52,6 +54,7 @@ export const sessionMetaKeySchema = z.enum([ 'id', 'version', 'title', + 'titleSource', 'isCustomTitle', 'lastPrompt', 'createdAt', diff --git a/packages/klient/src/contract/session/title.ts b/packages/klient/src/contract/session/title.ts index cf936bc6ca..ae1691c474 100644 --- a/packages/klient/src/contract/session/title.ts +++ b/packages/klient/src/contract/session/title.ts @@ -8,6 +8,11 @@ import { z } from 'zod'; import { maybe } from '../helpers.js'; import type { ServiceContract } from '../types.js'; +export const generateTitleOptionsSchema = z.object({ force: z.boolean().optional() }); + export const sessionTitleContract = { - generateTitle: { input: z.tuple([]), output: maybe(z.string()) }, + generateTitle: { + input: z.tuple([generateTitleOptionsSchema.optional()]), + output: maybe(z.string()), + }, } satisfies ServiceContract; diff --git a/packages/klient/src/core/facade/session.ts b/packages/klient/src/core/facade/session.ts index 3f14476280..d5fdf2abdc 100644 --- a/packages/klient/src/core/facade/session.ts +++ b/packages/klient/src/core/facade/session.ts @@ -67,8 +67,9 @@ export interface SessionFacade { * Generate and apply a title from the main agent's first prompts via the * managed `chat_title` tool. `undefined` when generation is unavailable * (no managed OAuth login, no prompt yet, or a custom title is set). + * `force` regenerates over an already-generated title. */ - generateTitle(): Promise; + generateTitle(options?: { readonly force?: boolean }): Promise; update(patch: SessionMetaPatch): Promise; setArchived(archived: boolean): Promise; status(): Promise; @@ -105,8 +106,10 @@ export function createSessionFacade(call: ScopedCaller, sessionId: string): Sess return { get: read, setTitle: (title) => call(scope, 'sessionMetadata', 'setTitle', [title]) as Promise, - generateTitle: () => - call(scope, 'sessionTitleService', 'generateTitle', []) as Promise, + generateTitle: (options) => + call(scope, 'sessionTitleService', 'generateTitle', [options]) as Promise< + string | undefined + >, update: (patch) => call(scope, 'sessionMetadata', 'update', [patch]) as Promise, setArchived: (archived) => call(scope, 'sessionMetadata', 'setArchived', [archived]) as Promise, diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts index 70a6ba3bc2..c396356763 100644 --- a/packages/klient/test/contract-parity.ts +++ b/packages/klient/test/contract-parity.ts @@ -67,6 +67,7 @@ import type { SessionMetadataChangedEvent, SessionMetaPatch, } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; +import type { ISessionTitleService } from '@moonshot-ai/agent-core-v2/session/sessionTitle/sessionTitle'; import type { AuthStatus, IOAuthService, @@ -201,6 +202,10 @@ import { questionResponseSchema, questionResultSchema, } from '../src/contract/session/question.js'; +import { + generateTitleOptionsSchema, + sessionTitleContract, +} from '../src/contract/session/title.js'; import { authStatusSchema, @@ -452,6 +457,16 @@ const _questionAnswers: AssertWire = true; const _questionResult: AssertWire = true; +// session/title.ts +const _generateTitleOptions: AssertWire< + typeof generateTitleOptionsSchema, + NonNullable[0]> +> = true; +const _generateTitleOutput: AssertWire< + (typeof sessionTitleContract)['generateTitle']['output'], + Awaited> +> = true; + // agent/activity.ts const _turnPhase: AssertWire = true; const _approvalRef: AssertWire = true; diff --git a/packages/klient/test/facade.test.ts b/packages/klient/test/facade.test.ts index c7cb911a8a..c8dbb5cd09 100644 --- a/packages/klient/test/facade.test.ts +++ b/packages/klient/test/facade.test.ts @@ -210,6 +210,37 @@ describe('event hub', () => { expect(channel.subscriptions[0]?.dispose).toHaveBeenCalledTimes(1); }); + it('delivers session.metaUpdated when the patch carries no lastPrompt', async () => { + const channel = new FakeChannel(); + const klient = createKlientFromChannel(channel); + const seen: unknown[] = []; + const errors: Error[] = []; + klient.events.onError((error) => { + errors.push(error); + }); + + klient.events.on('session.metaUpdated', (event) => seen.push(event)); + channel.emit(0, { + type: 'session.meta.updated', + payload: { + agentId: 'main', + sessionId: 's1', + title: 'generated title', + patch: { title: 'generated title', isCustomTitle: false }, + }, + }); + await tick(); + expect(seen).toEqual([ + { + agentId: 'main', + sessionId: 's1', + title: 'generated title', + patch: { title: 'generated title', isCustomTitle: false }, + }, + ]); + expect(errors).toHaveLength(0); + }); + it('disposes the emitter subscription when the last listener detaches', async () => { const channel = new FakeChannel(); const klient = createKlientFromChannel(channel); diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 8951677f5a..f4a654989b 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -244,11 +244,7 @@ export class KimiHarness { * and the current title is kept. */ async generateSessionTitle(input: GenerateSessionTitleInput): Promise { - const title = await this.rpc.generateSessionTitle(input); - if (title !== undefined) { - this.activeSessions.get(input.id)?.emitMetaUpdated({ title }); - } - return title; + return this.rpc.generateSessionTitle(input); } async exportSession(input: ExportSessionInput): Promise { diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index fc021722b6..9f72f691c5 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -393,6 +393,15 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * registered handlers. */ private readonly sessionWirings = new Map(); + /** + * Sessions a public `resumeSession` handed to a caller and has not closed + * yet. The temporary resume→act→close paths (`generateSessionTitle`, + * `renameSession`) skip their cleanup close for these, even though their + * own resume merged into the same live handle — the residual + * sub-millisecond window (a public resume landing between the check and + * the close) is accepted deliberately. + */ + private readonly externallyHeldSessions = new Set(); /** App-scope subscriptions (global event forwarding, lifecycle tracking), disposed in {@link close}. */ private readonly appSubscriptions: IDisposable[] = []; @@ -992,7 +1001,9 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { try { await this.klient.session(input.id).setTitle(title); } finally { - await this.sessionLifecycle.close(input.id); + if (!this.externallyHeldSessions.has(input.id)) { + await this.sessionLifecycle.close(input.id); + } } } @@ -1007,14 +1018,16 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { input: GenerateSessionTitleInput, ): Promise { if (this.sessionLifecycle.get(input.id) !== undefined) { - return this.klient.session(input.id).generateTitle(); + return this.klient.session(input.id).generateTitle({ force: input.force }); } const handle = await this.sessionLifecycle.resume(input.id); if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); try { - return await this.klient.session(input.id).generateTitle(); + return await this.klient.session(input.id).generateTitle({ force: input.force }); } finally { - await this.sessionLifecycle.close(input.id); + if (!this.externallyHeldSessions.has(input.id)) { + await this.sessionLifecycle.close(input.id); + } } } @@ -1047,6 +1060,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { override async closeSession(input: SessionIdRpcInput): Promise { // v1's print-steer counters die with the Session object; drop ours too. this.printSteerStates.delete(input.sessionId); + this.externallyHeldSessions.delete(input.sessionId); await this.klient.session(input.sessionId).close(); } @@ -1063,6 +1077,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { const handle = await this.sessionLifecycle.resume(input.id); if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); this.wireSession(handle); + this.externallyHeldSessions.add(input.id); // v1 re-resolves caller-provided additional dirs on every resume and // merges them over the workspace-local set; the engine's own resume only // restores the workspace-local ones. diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 3f8462a624..ba5f5fa922 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -135,6 +135,7 @@ export interface RenameSessionInput { export interface GenerateSessionTitleInput { readonly id: string; + readonly force?: boolean; } export interface ResumeSessionInput { diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index 88a23c50c6..e1ef646515 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -4,16 +4,28 @@ * Responsibilities: `getExperimentalFeatures` is migrated end-to-end; every * not-yet-migrated method fails loudly with `not_implemented` instead of * silently hitting a v1 core. - * Wiring: real v2 engine bootstrapped on a temp KIMI_CODE_HOME; no provider calls. + * Wiring: real v2 engine bootstrapped on a temp KIMI_CODE_HOME; remote provider calls are stubbed. * Run: pnpm exec vitest run test/sdk-rpc-client-v2.test.ts */ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { + FileTokenStorage, + resolveKimiCodeOAuthRef, + resolveKimiTokenStorageName, +} from '@moonshot-ai/kimi-code-oauth'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createKimiHarnessV2, ErrorCodes, KimiError, KimiHarness, SDKRpcClientV2 } from '#/index'; +import { + createKimiHarnessV2, + ErrorCodes, + KimiError, + KimiHarness, + SDKRpcClientV2, + type Event, +} from '#/index'; import { foldAgentWireReplay } from '#/v2/resume-replay'; import { IHostRequestHeaders } from '@moonshot-ai/agent-core-v2'; @@ -70,6 +82,110 @@ describe('SDKRpcClientV2 (agent-core-v2 wiring MVP)', () => { } }); + it('emits one complete metadata event when a generated title is applied', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + const titleBaseUrl = 'https://api.example.test/coding/v1'; + const titleOAuthRef = resolveKimiCodeOAuthRef({ baseUrl: titleBaseUrl }); + // Storage names strip the `oauth/` prefix (FileTokenStorage rejects + // namespaced keys); the engine resolves the same name when reading. + await new FileTokenStorage(join(homeDir, 'credentials')).save( + resolveKimiTokenStorageName({ oauthKey: titleOAuthRef.key }), + { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + expiresAt: Math.floor(Date.now() / 1000) + 3600, + scope: '', + tokenType: 'Bearer', + expiresIn: 3600, + }, + ); + await writeFile( + join(homeDir, 'config.toml'), + ` +default_model = "stub" + +[providers.stub] +type = "openai" +base_url = "https://model.example.test/v1" +api_key = "stub" + +[models.stub] +provider = "stub" +model = "stub" +max_context_size = 1000 + +[providers."managed:kimi-code"] +type = "kimi" +base_url = "${titleBaseUrl}" + +[providers."managed:kimi-code".oauth] +storage = "file" +key = "${titleOAuthRef.key}" +`, + 'utf-8', + ); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url === 'https://api.example.test/coding/v1/tools') { + return new Response(JSON.stringify({ title: 'Generated title' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + const harness = createKimiHarnessV2({ homeDir, identity: TEST_IDENTITY }); + + try { + const session = await harness.createSession({ id: 'ses_generated_title_event', workDir }); + await session.importContext( + 'Generate a concise title for this session', + "session 'source-session'", + ); + await expect( + harness.auth.getCachedAccessToken('managed:kimi-code', { + storage: titleOAuthRef.storage, + key: titleOAuthRef.key, + }), + ).resolves.toBe('test-access-token'); + await expect(session.getContext()).resolves.toMatchObject({ + history: [ + expect.objectContaining({ + role: 'user', + origin: { kind: 'user' }, + }), + ], + }); + const events: Event[] = []; + const unsubscribe = session.onEvent((event) => { + if (event.type === 'session.meta.updated' && event.title === 'Generated title') { + events.push(event); + } + }); + + await expect(harness.generateSessionTitle({ id: session.id })).resolves.toBe( + 'Generated title', + ); + unsubscribe(); + + expect(events).toEqual([ + expect.objectContaining({ + type: 'session.meta.updated', + sessionId: session.id, + agentId: 'main', + title: 'Generated title', + patch: { title: 'Generated title', isCustomTitle: false }, + }), + ]); + } finally { + await harness.close(); + fetchSpy.mockRestore(); + } + }); + it('serves listWorkspaceSkills through the engineAccessor escape hatch', async () => { const { harness, homeDir } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); From 17271c98f46c4e83fd4d49d8d6a61069cf3f9e33 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 30 Jul 2026 21:38:15 +0800 Subject: [PATCH 18/47] chore: follow agent-core comment convention --- .../src/session/sessionTitle/sessionTitleService.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index bc4b56ab45..a0d7d97d8b 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -125,7 +125,6 @@ export class SessionTitleService implements ISessionTitleService { }); let result = await requestTitle(token); if (result.kind === 'error' && result.status === 401) { - // The cached token may be revoked server-side: force-refresh and retry once. try { token = await tokenProvider.getAccessToken({ force: true }); } catch (error) { @@ -139,8 +138,6 @@ export class SessionTitleService implements ISessionTitleService { this.log.debug(`chat_title request failed: ${result.message}`); return undefined; } - // A stale scope (the session was resumed or closed while the request was - // in flight) must not write its title back. const live = this.sessionLifecycle.get(this.ctx.sessionId); if (live === undefined || live.accessor.get(ISessionMetadata) !== this.metadata) { return undefined; From 30cd334c49bfc1049b5d56f0d180c4cc4ea262b1 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 30 Jul 2026 21:44:03 +0800 Subject: [PATCH 19/47] fix: ignore stale session title callbacks --- .../tui/controllers/session-event-handler.ts | 7 ++++ .../session-event-handler-title.test.ts | 42 ++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 9feb7cf175..b417c0d480 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -165,6 +165,7 @@ export class SessionEventHandler { private queuedGoalPromotionInFlight = false; private queuedGoalPromotionTimer: ReturnType | undefined; private titleGenerationDisabled = false; + private titleGenerationEpoch = 0; resetRuntimeState(): void { this.backgroundTasks.clear(); @@ -183,6 +184,7 @@ export class SessionEventHandler { this.queuedGoalPromotionPending = false; this.queuedGoalPromotionInFlight = false; this.clearQueuedGoalPromotionTimer(); + this.titleGenerationEpoch += 1; this.titleGenerationDisabled = false; this.stopAllMcpServerStatusSpinners(); } @@ -407,11 +409,16 @@ export class SessionEventHandler { if (this.titleGenerationDisabled) return; const { sessionId } = this.host.state.appState; if (sessionId.length === 0) return; + const epoch = this.titleGenerationEpoch; + const isCurrentGeneration = () => + epoch === this.titleGenerationEpoch && sessionId === this.host.state.appState.sessionId; void this.host.harness.generateSessionTitle({ id: sessionId }).then( (title) => { + if (!isCurrentGeneration()) return; if (title !== undefined) this.titleGenerationDisabled = true; }, () => { + if (!isCurrentGeneration()) return; this.titleGenerationDisabled = true; }, ); diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts index c5de0435d0..22640cc4c0 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts @@ -60,10 +60,10 @@ function makeHost(options: { sessionTitle?: string | null; generateTitle?: () => return { host: host as any, harness }; } -function turnEndedEvent() { +function turnEndedEvent(sessionId = 's1') { return { type: 'turn.ended', - sessionId: 's1', + sessionId, agentId: 'main', turnId: 1, reason: 'completed', @@ -132,4 +132,42 @@ describe('session auto title generation', () => { handler.handleEvent(turnEndedEvent(), vi.fn()); expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2); }); + + it('ignores a generated title from the previous session after runtime reset', async () => { + let resolveTitle: ((title: string | undefined) => void) | undefined; + const pendingTitle = new Promise((resolve) => { + resolveTitle = resolve; + }); + const { host, harness } = makeHost(); + harness.generateSessionTitle.mockReturnValueOnce(pendingTitle); + const handler = new SessionEventHandler(host); + + handler.handleEvent(turnEndedEvent('s1'), vi.fn()); + host.state.appState.sessionId = 's2'; + handler.resetRuntimeState(); + resolveTitle?.('s1 generated title'); + await flushMicrotasks(); + handler.handleEvent(turnEndedEvent('s2'), vi.fn()); + + expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2); + expect(harness.generateSessionTitle).toHaveBeenLastCalledWith({ id: 's2' }); + }); + + it('ignores a rejection from the previous runtime after same-session reset', async () => { + let rejectTitle: ((error: Error) => void) | undefined; + const pendingTitle = new Promise((_resolve, reject) => { + rejectTitle = reject; + }); + const { host, harness } = makeHost(); + harness.generateSessionTitle.mockReturnValueOnce(pendingTitle); + const handler = new SessionEventHandler(host); + + handler.handleEvent(turnEndedEvent(), vi.fn()); + handler.resetRuntimeState(); + rejectTitle?.(new Error('stale request failed')); + await flushMicrotasks(); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2); + }); }); From a108338ce54d6babef429c8f286db8a32c19e0fa Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 11:38:12 +0800 Subject: [PATCH 20/47] fix: preserve session title state invariants --- .../agent-core-v2/docs/state-manifest.d.ts | 5 +- .../src/agent/rpc/prompt-metadata.ts | 13 ++- .../sessionLifecycleService.ts | 2 +- .../sessionMetadata/sessionMetadata.ts | 5 +- .../sessionMetadata/sessionMetadataService.ts | 100 ++++++++++++------ .../sessionTitle/sessionTitleService.ts | 17 +-- .../test/agent/rpc/prompt-metadata.test.ts | 1 + .../sessionLifecycle/sessionLifecycle.test.ts | 54 +++++++++- .../sessionMetadata/sessionMetadata.test.ts | 43 ++++++-- .../sessionTitle/sessionTitleService.test.ts | 40 ++----- packages/klient/src/contract/global/events.ts | 4 +- .../klient/src/contract/session/metadata.ts | 9 +- packages/node-sdk/src/v2/session-mapper.ts | 2 +- 13 files changed, 187 insertions(+), 108 deletions(-) diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index a83e5a269c..998e6ea160 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -335,8 +335,7 @@ export interface SessionStateSnapshot { readonly id: string; readonly version?: number; readonly title?: string; - readonly titleSource?: 'prompt' | 'generated' | 'custom'; - readonly isCustomTitle?: boolean; + readonly titleKind?: 'replaceable' | 'generated' | 'custom'; readonly lastPrompt?: string; readonly createdAt: number; readonly updatedAt: number; @@ -1009,7 +1008,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map { if (text === undefined) return; const current = await target.metadata.read(); - const patch: { lastPrompt: string; title?: string; isCustomTitle?: boolean } = { + const patch: { lastPrompt: string; title?: string; titleKind?: SessionTitleKind } = { lastPrompt: text, }; - if (!current.isCustomTitle && isUntitled(current.title)) { + if (current.titleKind !== 'custom' && isUntitled(current.title)) { patch.title = titleFromPromptMetadataText(text); - patch.isCustomTitle = false; + patch.titleKind = 'replaceable'; } await target.metadata.update(patch); target.eventService.publish({ @@ -77,7 +80,7 @@ export async function applyPromptMetadataUpdate( title: patch.title, patch: { title: patch.title, - isCustomTitle: patch.isCustomTitle, + isCustomTitle: patch.titleKind === undefined ? undefined : false, lastPrompt: text, }, }, diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index accd49cfd9..5e18f63730 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -428,7 +428,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const title = opts.title ?? `Fork: ${sourceMeta?.title || sourceId}`; await targetMeta.update({ title, - isCustomTitle: opts.title !== undefined ? true : sourceMeta?.isCustomTitle === true, + titleKind: opts.title === undefined ? 'replaceable' : 'custom', forkedFrom: sourceId, archived: false, lastPrompt: sourceMeta?.lastPrompt, diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts index 223fc87212..49df4c8983 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts @@ -25,12 +25,13 @@ export interface AgentMeta { export const SESSION_META_VERSION = 2; +export type SessionTitleKind = 'replaceable' | 'generated' | 'custom'; + export interface SessionMeta { readonly id: string; readonly version?: number; readonly title?: string; - readonly titleSource?: 'prompt' | 'generated' | 'custom'; - readonly isCustomTitle?: boolean; + readonly titleKind?: SessionTitleKind; readonly lastPrompt?: string; readonly createdAt: number; readonly updatedAt: number; diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 24010c5e29..4eb80b68e7 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -44,6 +44,7 @@ import { type SessionMeta, type SessionMetadataChangedEvent, type SessionMetaPatch, + type SessionTitleKind, } from './sessionMetadata'; const META_KEY = 'state.json'; @@ -109,14 +110,14 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { } async setTitle(title: string): Promise { - await this.update({ title, titleSource: 'custom', isCustomTitle: true }); + await this.update({ title, titleKind: 'custom' }); } async setGeneratedTitleIfUncustomized(title: string): Promise { return this.enqueueUpdate(async () => { await this.ready; - if (this.data.isCustomTitle === true) return false; - await this.applyUpdate({ title, titleSource: 'generated', isCustomTitle: false }); + if (this.data.titleKind === 'custom') return false; + await this.applyUpdate({ title, titleKind: 'generated' }); return true; }); } @@ -174,7 +175,11 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { const existing = await this.store.get(this.scope, META_KEY); if (existing !== undefined) { this.data = normalizeSessionMeta(existing, this.ctx.sessionId); - if (this.data.agents === undefined || this.data.custom === undefined) { + if ( + this.data.agents === undefined || + this.data.custom === undefined || + sessionMetaTitleNeedsMigration(existing, this.data) + ) { this.data = { ...this.data, agents: this.data.agents ?? {}, @@ -220,45 +225,74 @@ function recordEquals(a: AgentMeta['labels'], b: AgentMeta['labels']): boolean { } export function normalizeSessionMeta(raw: SessionMeta, sessionId: string): SessionMeta { - const clean = { ...raw }; - const legacy = raw as unknown as { - createdAt?: unknown; - updatedAt?: unknown; - workDir?: unknown; - customTitle?: unknown; - }; + const legacy = raw as unknown as LegacySessionMeta; + const normalizedTitle = normalizeSessionTitle(legacy); + const { + createdAt: legacyCreatedAt, + updatedAt: legacyUpdatedAt, + workDir: legacyWorkDir, + titleSource: _legacyTitleSource, + isCustomTitle: _legacyIsCustomTitle, + customTitle: _legacyCustomTitle, + ...clean + } = legacy; const cwd = - clean.cwd ?? (typeof legacy.workDir === 'string' && legacy.workDir.length > 0 - ? legacy.workDir + clean.cwd ?? (typeof legacyWorkDir === 'string' && legacyWorkDir.length > 0 + ? legacyWorkDir : undefined); - const legacyCustomTitle = - typeof legacy.customTitle === 'string' ? legacy.customTitle : undefined; - const hasModernTitleState = - typeof clean.title === 'string' && typeof clean.isCustomTitle === 'boolean'; - const title = hasModernTitleState ? clean.title : (legacyCustomTitle ?? clean.title); - const isCustomTitle = hasModernTitleState - ? clean.isCustomTitle - : legacyCustomTitle === undefined - ? clean.isCustomTitle - : true; - if (clean.version === SESSION_META_VERSION) { - if (cwd === clean.cwd && title === clean.title && isCustomTitle === clean.isCustomTitle) { - return clean; - } - return { ...clean, cwd, title, isCustomTitle }; - } + const { title, titleKind } = normalizedTitle; return { ...clean, - id: sessionId, + id: clean.version === SESSION_META_VERSION ? clean.id : sessionId, version: SESSION_META_VERSION, cwd, title, - isCustomTitle, - createdAt: toEpochMs(legacy.createdAt), - updatedAt: toEpochMs(legacy.updatedAt), + titleKind, + createdAt: toEpochMs(legacyCreatedAt), + updatedAt: toEpochMs(legacyUpdatedAt), }; } +type LegacySessionMeta = Omit & { + readonly createdAt?: unknown; + readonly updatedAt?: unknown; + readonly workDir?: unknown; + readonly titleSource?: unknown; + readonly isCustomTitle?: unknown; + readonly customTitle?: unknown; +}; + +function normalizeSessionTitle( + raw: LegacySessionMeta, +): Pick { + const title = typeof raw.title === 'string' ? raw.title : undefined; + if (title !== undefined && isSessionTitleKind(raw.titleKind)) { + return { title, titleKind: raw.titleKind }; + } + if (title !== undefined && typeof raw.isCustomTitle === 'boolean') { + return { title, titleKind: raw.isCustomTitle ? 'custom' : 'replaceable' }; + } + if (typeof raw.customTitle === 'string') { + return { title: raw.customTitle, titleKind: 'custom' }; + } + return title === undefined ? {} : { title, titleKind: 'replaceable' }; +} + +function isSessionTitleKind(value: unknown): value is SessionTitleKind { + return value === 'replaceable' || value === 'generated' || value === 'custom'; +} + +function sessionMetaTitleNeedsMigration(raw: SessionMeta, normalized: SessionMeta): boolean { + const record = raw as unknown as Record; + return ( + raw.title !== normalized.title || + raw.titleKind !== normalized.titleKind || + Object.hasOwn(record, 'titleSource') || + Object.hasOwn(record, 'isCustomTitle') || + Object.hasOwn(record, 'customTitle') + ); +} + export function toEpochMs(value: unknown): number { if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string') { diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index a0d7d97d8b..32d4ca9f5f 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -65,8 +65,8 @@ export class SessionTitleService implements ISessionTitleService { async generateTitle(options?: { readonly force?: boolean }): Promise { const current = await this.metadata.read(); - if (hasCustomTitle(current)) return undefined; - if (current.titleSource === 'generated' && options?.force !== true) return undefined; + if (current.titleKind === 'custom') return undefined; + if (current.titleKind === 'generated' && options?.force !== true) return undefined; const main = this.agentLifecycle.get(MAIN_AGENT_ID); const prompts = main === undefined @@ -89,7 +89,7 @@ export class SessionTitleService implements ISessionTitleService { private async generateAndApplyOnce(chatContent: string): Promise { const current = await this.metadata.read(); - if (hasCustomTitle(current)) return undefined; + if (current.titleKind === 'custom') return undefined; const provider = this.providers.get(KIMI_CODE_PROVIDER_NAME); if ( provider === undefined || @@ -158,17 +158,6 @@ export class SessionTitleService implements ISessionTitleService { } } -function hasCustomTitle(metadata: { - 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'; -} - function titleInputFromPrompts(prompts: readonly string[]): string | undefined { if (prompts.length === 0) return undefined; return prompts diff --git a/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts b/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts index bfcea32703..2fd05e8d4d 100644 --- a/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts +++ b/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts @@ -97,6 +97,7 @@ describe('applyPromptMetadataUpdate', () => { expect(readMeta().lastPrompt).toBe('第二条'); expect(readMeta().title).toBe('第一条'); + expect(readMeta().titleKind).toBe('replaceable'); }); it('updates metadata for slash activations', 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 eca1bd241a..3c3c91daaa 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -34,7 +34,10 @@ import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycl import { SessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycleService'; import { IAgentActivityView } from '#/agent/activityView/activityView'; import { ISessionExternalHooksService } from '#/session/externalHooks/externalHooks'; -import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { + ISessionMetadata, + type SessionMetaPatch, +} from '#/session/sessionMetadata/sessionMetadata'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; @@ -1181,6 +1184,55 @@ describe('SessionLifecycleService', () => { }); } + it('marks the default fork title as replaceable', async () => { + const updates: SessionMetaPatch[] = []; + const svc = build([ + workspaceGetStub(), + stubPair(ISessionMetadata, { + ...metadataStub(), + read: () => + Promise.resolve({ + title: 'generated source', + titleKind: 'generated', + agents: {}, + } as never), + update: (patch) => { + updates.push(patch); + return Promise.resolve(); + }, + }), + ]); + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + + await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }); + + expect(updates).toContainEqual( + expect.objectContaining({ title: 'Fork: generated source', titleKind: 'replaceable' }), + ); + }); + + it('marks an explicit fork title as custom', async () => { + const updates: SessionMetaPatch[] = []; + const svc = build([ + workspaceGetStub(), + stubPair(ISessionMetadata, { + ...metadataStub(), + read: () => Promise.resolve({ title: 'source', agents: {} } as never), + update: (patch) => { + updates.push(patch); + return Promise.resolve(); + }, + }), + ]); + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + + await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst', title: 'user title' }); + + expect(updates).toContainEqual( + expect.objectContaining({ title: 'user title', titleKind: 'custom' }), + ); + }); + it('copies blobs, plans, background tasks, and media originals into the fork', async () => { const root = await makeTmpRoot(); const svc = build([ 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 c00a352bd0..69575d3e00 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -93,7 +93,7 @@ describe('SessionMetadata', () => { const meta = ix.get(ISessionMetadata); await meta.setTitle('t'); await meta.setArchived(true); - expect(await meta.read()).toMatchObject({ title: 't', titleSource: 'custom', archived: true }); + expect(await meta.read()).toMatchObject({ title: 't', titleKind: 'custom', archived: true }); }); it('sets a generated title while the metadata remains uncustomized', async () => { @@ -102,8 +102,7 @@ describe('SessionMetadata', () => { await expect(meta.setGeneratedTitleIfUncustomized('generated title')).resolves.toBe(true); await expect(meta.read()).resolves.toMatchObject({ title: 'generated title', - titleSource: 'generated', - isCustomTitle: false, + titleKind: 'generated', }); }); @@ -183,13 +182,13 @@ describe('SessionMetadata', () => { const meta = ix.get(ISessionMetadata); await expect(meta.read()).resolves.toMatchObject({ title: 'legacy title', - isCustomTitle: true, + titleKind: 'custom', }); const fresh = createFreshMetadata(ix); await expect(fresh.read()).resolves.toMatchObject({ title: 'legacy title', - isCustomTitle: true, + titleKind: 'custom', }); }); @@ -209,16 +208,44 @@ describe('SessionMetadata', () => { const meta = ix.get(ISessionMetadata); await expect(meta.read()).resolves.toMatchObject({ title: 'renamed title', - isCustomTitle: true, + titleKind: 'custom', }); await meta.update({ archived: true }); const fresh = createFreshMetadata(ix); await expect(fresh.read()).resolves.toMatchObject({ title: 'renamed title', - isCustomTitle: true, + titleKind: 'custom', archived: true, }); + const persisted = await store.get>(META_SCOPE, 'state.json'); + expect(persisted).not.toHaveProperty('isCustomTitle'); + expect(persisted).not.toHaveProperty('customTitle'); + }); + + it('migrates a legacy non-custom title to replaceable title state', async () => { + const store = ix.get(IAtomicDocumentStore); + await store.set(META_SCOPE, 'state.json', { + id: 's1', + version: 2, + createdAt: 1700000000000, + updatedAt: 1700000000000, + archived: false, + title: 'prompt title', + isCustomTitle: false, + agents: {}, + custom: {}, + }); + + const meta = ix.get(ISessionMetadata); + + await expect(meta.read()).resolves.toMatchObject({ + title: 'prompt title', + titleKind: 'replaceable', + }); + const persisted = await store.get>(META_SCOPE, 'state.json'); + expect(persisted).toMatchObject({ title: 'prompt title', titleKind: 'replaceable' }); + expect(persisted).not.toHaveProperty('isCustomTitle'); }); it('keeps a queued custom title when a generated title is enqueued afterward', async () => { @@ -255,7 +282,7 @@ describe('SessionMetadata', () => { await expect(generated).resolves.toBe(false); await expect(meta.read()).resolves.toMatchObject({ title: 'user title', - isCustomTitle: true, + titleKind: 'custom', }); }); 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 71cd9d7119..95cd6ffa85 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -1,6 +1,6 @@ /** * Scenario: on-demand managed chat_title generation through the session-scoped - * service, including OAuth failures, legacy custom titles, request headers, + * service, including OAuth failures, title-state transitions, request headers, * and races. * Wiring: the real title service with contract fakes; only fetch crosses the * external boundary. Run with `pnpm --filter @moonshot-ai/agent-core-v2 exec @@ -100,12 +100,12 @@ class FakeSessionMetadata implements ISessionMetadata { } setTitle(title: string): Promise { - return this.update({ title, titleSource: 'custom', isCustomTitle: true }); + return this.update({ title, titleKind: 'custom' }); } async setGeneratedTitleIfUncustomized(title: string): Promise { - if (this.meta.isCustomTitle === true) return false; - await this.update({ title, titleSource: 'generated', isCustomTitle: false }); + if (this.meta.titleKind === 'custom') return false; + await this.update({ title, titleKind: 'generated' }); return true; } @@ -252,8 +252,7 @@ describe('SessionTitleService', () => { expect(title).toBe('生成的标题'); expect(metadata.meta.title).toBe('生成的标题'); - expect(metadata.meta.isCustomTitle).toBe(false); - expect(metadata.meta.titleSource).toBe('generated'); + expect(metadata.meta.titleKind).toBe('generated'); const [, init] = fetchMock.mock.calls[0]!; expect(JSON.parse(init?.body as string)).toEqual({ @@ -329,7 +328,7 @@ describe('SessionTitleService', () => { await expect(generation).resolves.toBeUndefined(); expect(metadata.meta.title).toBe('user 取的标题'); - expect(metadata.meta.isCustomTitle).toBe(true); + expect(metadata.meta.titleKind).toBe('custom'); }); it('skips generation when the current title was already generated', async () => { @@ -349,7 +348,7 @@ describe('SessionTitleService', () => { '生成的标题', ); expect(metadata.meta.title).toBe('生成的标题'); - expect(metadata.meta.titleSource).toBe('generated'); + expect(metadata.meta.titleKind).toBe('generated'); }); it('never overwrites a custom title even when forced', async () => { @@ -387,7 +386,7 @@ describe('SessionTitleService', () => { it('keeps the current title when the backend request fails', async () => { fetchMock.mockImplementationOnce(async () => new Response('', { status: 500 })); titlePrompts = ['hello']; - await metadata.update({ title: 'hello', isCustomTitle: false }); + await metadata.update({ title: 'hello', titleKind: 'replaceable' }); await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); expect(metadata.meta.title).toBe('hello'); @@ -479,29 +478,6 @@ describe('SessionTitleService', () => { expect(resolvedOAuthRefs[0]?.key).not.toBe(MANAGED_PROVIDER.oauth?.key); }); - it('does not generate over a legacy customTitle', async () => { - metadata.meta = { - ...metadata.meta, - customTitle: 'legacy title', - } as SessionMeta; - - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - 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, - customTitle: 'stale legacy title', - } as SessionMeta; - titlePrompts = ['hello']; - - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBe('生成的标题'); - expect(metadata.meta.title).toBe('生成的标题'); - }); - it('shares an in-flight generation between concurrent requests', async () => { const pendingFetch = createPendingFetch(); fetchMock.mockImplementationOnce(pendingFetch.fetch); diff --git a/packages/klient/src/contract/global/events.ts b/packages/klient/src/contract/global/events.ts index cdf0a9594a..20ba36f07a 100644 --- a/packages/klient/src/contract/global/events.ts +++ b/packages/klient/src/contract/global/events.ts @@ -30,7 +30,7 @@ export interface SessionMetaUpdatedPayload { readonly patch: { readonly title?: string; readonly isCustomTitle?: boolean; - readonly lastPrompt: string; + readonly lastPrompt?: string; }; } @@ -75,7 +75,7 @@ const sessionMetaUpdatedSchema = z.object({ isCustomTitle: z.boolean().optional(), lastPrompt: z.string().optional(), }), -}); +}) satisfies z.ZodType; export const catalogChangedSchema = z.object({ changed: z.array( diff --git a/packages/klient/src/contract/session/metadata.ts b/packages/klient/src/contract/session/metadata.ts index 9ea5c62a8d..ae0112c2e6 100644 --- a/packages/klient/src/contract/session/metadata.ts +++ b/packages/klient/src/contract/session/metadata.ts @@ -22,8 +22,7 @@ export const sessionMetaSchema = z.object({ id: z.string(), version: z.number().optional(), title: z.string().optional(), - titleSource: z.enum(['prompt', 'generated', 'custom']).optional(), - isCustomTitle: z.boolean().optional(), + titleKind: z.enum(['replaceable', 'generated', 'custom']).optional(), lastPrompt: z.string().optional(), createdAt: z.number(), updatedAt: z.number(), @@ -38,8 +37,7 @@ export const sessionMetaSchema = z.object({ export const sessionMetaPatchSchema = z.object({ version: z.number().optional(), title: z.string().optional(), - titleSource: z.enum(['prompt', 'generated', 'custom']).optional(), - isCustomTitle: z.boolean().optional(), + titleKind: z.enum(['replaceable', 'generated', 'custom']).optional(), lastPrompt: z.string().optional(), updatedAt: z.number().optional(), archived: z.boolean().optional(), @@ -54,8 +52,7 @@ export const sessionMetaKeySchema = z.enum([ 'id', 'version', 'title', - 'titleSource', - 'isCustomTitle', + 'titleKind', 'lastPrompt', 'createdAt', 'updatedAt', diff --git a/packages/node-sdk/src/v2/session-mapper.ts b/packages/node-sdk/src/v2/session-mapper.ts index 293f67a557..8e70977b6f 100644 --- a/packages/node-sdk/src/v2/session-mapper.ts +++ b/packages/node-sdk/src/v2/session-mapper.ts @@ -69,7 +69,7 @@ export function v2MetaToSessionMeta(meta: V2SessionMeta): SessionMeta { createdAt: new Date(meta.createdAt).toISOString(), updatedAt: new Date(meta.updatedAt).toISOString(), title: meta.title ?? '', - isCustomTitle: meta.isCustomTitle ?? false, + isCustomTitle: meta.titleKind === 'custom', lastPrompt: meta.lastPrompt, forkedFrom: meta.forkedFrom, workDir: meta.cwd, From 5e16893b4c291b739d4c17fc03e471f0ff8f10ed Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 12:49:53 +0800 Subject: [PATCH 21/47] refactor: seed session lifetime instead of querying the workspace handler The session title service must not depend on the Workspace-tier handler registry. The handler now seeds each session scope with an abort signal, fires it synchronously when a close begins, and the title service carries the signal on its request, drops the write-back once aborted, and drains an in-flight generation through the onWillCloseSession hook. --- .../scripts/check-domain-layers.mjs | 5 + packages/agent-core-v2/src/index.ts | 1 + .../sessionLifetime/sessionLifetime.ts | 28 +++++ .../sessionTitle/sessionTitleService.ts | 37 ++++-- .../workspaceHandlerService.ts | 29 ++++- packages/agent-core-v2/test/harness/agent.ts | 5 + .../sessionTitle/sessionTitleService.test.ts | 110 +++++++++++++----- packages/oauth/src/managed-tools.ts | 11 +- 8 files changed, 184 insertions(+), 42 deletions(-) create mode 100644 packages/agent-core-v2/src/session/sessionLifetime/sessionLifetime.ts diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index c66ae8b4a4..12a6909d96 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -91,6 +91,11 @@ const DOMAIN_LAYER = new Map([ // create-source/close-reason vocabulary; a pure contract with no IO, so it // sits in L1 beside `sessionContext`. ['sessionLifecycleHooks', 1], + // `sessionLifetime` is the per-session liveness seed (`AbortSignal` + // aborted by the Workspace-scope `workspaceHandler` when the session's + // close begins); a pure contract with no IO, so it sits in L1 beside + // `sessionLifecycleHooks`. + ['sessionLifetime', 1], // `scopeContext` is the Agent-scope seeded immutable facts value // (`agentId` plus a persistence scope helper); a pure seed with no IO, so it // sits in L1 beside `sessionContext`. diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 8746eebad3..3e1591a2ab 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -366,6 +366,7 @@ export * from '#/workspace/workspaceHandler/workspaceHandler'; export * from '#/workspace/workspaceHandler/workspaceHandlerService'; export * from '#/workspace/workspaceHandler/addressing'; export * from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; +export * from '#/session/sessionLifetime/sessionLifetime'; export * from '#/session/externalHooks/externalHooks'; export * from '#/session/externalHooks/externalHooksService'; import '#/app/sessionExport/errors'; diff --git a/packages/agent-core-v2/src/session/sessionLifetime/sessionLifetime.ts b/packages/agent-core-v2/src/session/sessionLifetime/sessionLifetime.ts new file mode 100644 index 0000000000..b209420b29 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionLifetime/sessionLifetime.ts @@ -0,0 +1,28 @@ +/** + * `sessionLifetime` domain (L1) — per-session liveness seed. + * + * Defines the `ISessionLifetime` carrying the session scope's liveness + * `AbortSignal`: the Workspace-scope `workspaceHandler` creates one + * `AbortController` per materialized session, seeds the signal into the + * Session scope, and aborts it synchronously when the session's close + * begins — before any async `onWillCloseSession` hook runs. Session-scope + * consumers with in-flight async work (e.g. `sessionTitle`) treat an + * aborted signal as "this scope is being torn down": cancel the work and + * drop its write-back. Pure contract — no IO, no store. Session-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ScopeSeed } from '#/_base/di/scope'; + +export interface ISessionLifetime { + readonly _serviceBrand: undefined; + + readonly signal: AbortSignal; +} + +export const ISessionLifetime: ServiceIdentifier = + createDecorator('sessionLifetime'); + +export function sessionLifetimeSeed(lifetime: ISessionLifetime): ScopeSeed { + return [[ISessionLifetime as ServiceIdentifier, lifetime]]; +} diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index a952346365..a186b069bc 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -9,8 +9,11 @@ * (the kap-server route), gated by a managed Kimi Code OAuth login; any * failure degrades to keeping the current title, and a custom title set by * the user is never overwritten. An already-generated title is not - * regenerated unless forced, and the write-back is dropped when this scope - * is no longer the live session in `workspaceHandler`. Provider config comes + * regenerated unless forced, and the write-back is dropped once this + * scope's `sessionLifetime` signal fires — the in-flight request carries + * the signal, and a close drains the pending generation through the + * `sessionLifecycleHooks` `onWillCloseSession` slot before the scope is + * disposed. Provider config comes * from `provider`, the bearer token from `auth`, host identity headers from * `model`, prompt history from `agentLifecycle`/`sessionTitle`, and logs * through `log`. Bound at Session scope. @@ -29,13 +32,18 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/ import { ILogService } from '#/_base/log/log'; import { IOAuthService } from '#/app/auth/auth'; import { IEventService } from '#/app/event/event'; +import type { Hooks } from '#/hooks'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; 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 { + ISessionLifecycleHooks, + type SessionLifecycleHookSlots, +} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; +import { ISessionLifetime } from '#/session/sessionLifetime/sessionLifetime'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; import { IAgentTitlePromptSource } from './agentTitlePromptSource'; import { ISessionTitleService } from './sessionTitle'; @@ -54,16 +62,27 @@ export class SessionTitleService implements ISessionTitleService { constructor( @ISessionContext private readonly ctx: ISessionContext, @ISessionMetadata private readonly metadata: ISessionMetadata, + @ISessionLifetime private readonly lifetime: ISessionLifetime, + @ISessionLifecycleHooks + lifecycleHooks: Hooks, @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, @IEventService private readonly eventService: IEventService, - @IWorkspaceHandlerService private readonly workspaceHandler: IWorkspaceHandlerService, @IProviderService private readonly providers: IProviderService, @IOAuthService private readonly oauth: IOAuthService, @IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders, @ILogService private readonly log: ILogService, - ) {} + ) { + // A close aborts the lifetime signal first, so the in-flight generation + // settles fast; draining it here keeps its metadata write ordered before + // the scope's disposal (and any later resume re-reading the document). + lifecycleHooks.onWillCloseSession.register('sessionTitle', async (_event, next) => { + await this._generation?.catch(() => undefined); + await next(); + }); + } async generateTitle(options?: { readonly force?: boolean }): Promise { + if (this.lifetime.signal.aborted) return undefined; const current = await this.metadata.read(); if (current.titleKind === 'custom') return undefined; if (current.titleKind === 'generated' && options?.force !== true) return undefined; @@ -122,9 +141,10 @@ export class SessionTitleService implements ISessionTitleService { ...this.hostHeaders.headers, ...provider.customHeaders, }, + signal: this.lifetime.signal, }); let result = await requestTitle(token); - if (result.kind === 'error' && result.status === 401) { + if (result.kind === 'error' && result.status === 401 && !this.lifetime.signal.aborted) { try { token = await tokenProvider.getAccessToken({ force: true }); } catch (error) { @@ -138,10 +158,7 @@ export class SessionTitleService implements ISessionTitleService { this.log.debug(`chat_title request failed: ${result.message}`); return undefined; } - const live = this.workspaceHandler.get(this.ctx.sessionId); - if (live === undefined || live.accessor.get(ISessionMetadata) !== this.metadata) { - return undefined; - } + if (this.lifetime.signal.aborted) return undefined; const title = result.title.slice(0, MAX_GENERATED_TITLE_LENGTH); const applied = await this.metadata.setGeneratedTitleIfUncustomized(title); if (!applied) return undefined; diff --git a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts index 9004bdd9f9..4968b1a8e1 100644 --- a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts @@ -4,8 +4,11 @@ * Owns the registry of THIS handler's open Session child scopes, creating * them through the DI scope tree (children of the handler's Workspace * scope) and seeding each with its identity, storage addressing derived - * from the handler's `workspaceContext.persistenceScope`, and a per-session - * `sessionLifecycleHooks` slots instance it runs around create/close, + * from the handler's `workspaceContext.persistenceScope`, a per-session + * `sessionLifecycleHooks` slots instance it runs around create/close, and + * a per-session `sessionLifetime` abort signal it fires synchronously when + * a close/archive begins — before any async close hook — so in-flight + * session work can cancel itself and drop its write-back, * tearing sessions down on close/archive — archiving flags the session's * `sessionMetadata`, removes its `agentLifecycle` agents, restoring clears * the archived flag, and broadcasts through `event`; session start and @@ -106,6 +109,7 @@ import { sessionLifecycleHooksSeed, type SessionLifecycleHookSlots, } from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; +import { sessionLifetimeSeed } from '#/session/sessionLifetime/sessionLifetime'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionProcessRunner } from '#/session/process/processRunner'; import { sessionSkillCatalogDataSeed } from '#/session/sessionSkillCatalog/skillCatalogData'; @@ -165,6 +169,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan private readonly _onDidForkSession = this._register(new Emitter()); readonly onDidForkSession: Event = this._onDidForkSession.event; private readonly resuming = new Map>(); + private readonly sessionLifetimes = new Map(); constructor( @IInstantiationService private readonly instantiation: IInstantiationService, @@ -230,6 +235,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan // Roll back ONLY the session directory — the handler stays live. const sessionDir = handle.accessor.get(ISessionContext).sessionDir; this.sessions.delete(sessionId); + this.invalidateSessionLifetime(sessionId); await this.drainAgents(handle).catch(() => {}); handle.dispose(); await this.hostFs.remove(sessionDir).catch(() => {}); @@ -266,6 +272,8 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan 'onDidCreateSession', 'onWillCloseSession', ]); + const lifetime = new AbortController(); + this.sessionLifetimes.set(opts.sessionId, lifetime); await this.hostEnv.ready; const handle = createScopedChildHandle( this.instantiation, @@ -275,6 +283,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan extra: [ ...sessionContextSeed(ctx), ...sessionLifecycleHooksSeed(hooks), + ...sessionLifetimeSeed({ _serviceBrand: undefined, signal: lifetime.signal }), [ITelemetryService, this.telemetry.withContext({ sessionId: opts.sessionId })], // Workspace resource seeds (the §3.5 injection contracts): the // handler's shared skill / agent-profile catalogs, AGENTS.md @@ -309,6 +318,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan ]); await this.mcp.ready; } catch (error) { + this.invalidateSessionLifetime(opts.sessionId); handle.dispose(); // Re-arm the explicit agent-profile loader after a fatal rejection: // its `ready` tracks the latest load pass, so without a reload the @@ -409,6 +419,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan async close(sessionId: string): Promise { const handle = this.sessions.get(sessionId); if (handle === undefined) return; + this.invalidateSessionLifetime(sessionId); await this.announceWillClose({ sessionId, handle, reason: 'exit' }); this.sessions.delete(sessionId); await this.drainAgents(handle); @@ -419,6 +430,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan async archive(sessionId: string): Promise { const handle = this.sessions.get(sessionId); if (handle === undefined) return; + this.invalidateSessionLifetime(sessionId); const meta = handle.accessor.get(ISessionMetadata); await meta.setArchived(true); await this.drainAgents(handle); @@ -445,6 +457,18 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan .onWillCloseSession.run({ reason: event.reason }); } + /** + * Synchronously fires the session's liveness abort signal. Runs before any + * async close work (hooks, agent drain) so in-flight session services see + * the teardown immediately; idempotent — later calls are no-ops. + */ + private invalidateSessionLifetime(sessionId: string): void { + const lifetime = this.sessionLifetimes.get(sessionId); + if (lifetime === undefined) return; + this.sessionLifetimes.delete(sessionId); + lifetime.abort(); + } + private async drainAgents(handle: ISessionScopeHandle): Promise { const agentLifecycle = handle.accessor.get(IAgentLifecycleService); for (const agent of agentLifecycle.list()) { @@ -545,6 +569,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan } catch (error) { if (targetId !== undefined) { this.sessions.delete(targetId); + this.invalidateSessionLifetime(targetId); } if (target !== undefined) { try { diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 1faa14ee23..dc364a6b68 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -153,6 +153,7 @@ import { ISessionLifecycleHooks, type SessionLifecycleHookSlots, } from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; +import { ISessionLifetime } from '#/session/sessionLifetime/sessionLifetime'; import { IEventBus } from '#/app/event/eventBus'; import { IWireService } from '#/wire/wire'; import { WireService } from '#/wire/wireService'; @@ -1177,6 +1178,10 @@ export class AgentTestContext { 'onWillCloseSession', ]), ); + reg.defineInstance(ISessionLifetime, { + _serviceBrand: undefined, + signal: new AbortController().signal, + }); reg.defineInstance(ISessionInteractionService, this.createInteractionService()); reg.defineInstance(ISessionApprovalService, this.createApprovalService()); reg.defineInstance(ISessionQuestionService, this.createQuestionService()); 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 bd74dd8bdf..7ca4a6f849 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -12,17 +12,15 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite import { OAuthConnectionError, OAuthUnauthorizedError } from '@moonshot-ai/kimi-code-oauth'; import { DisposableStore, type IDisposable } from '#/_base/di/lifecycle'; -import type { ServiceIdentifier } from '#/_base/di/instantiation'; import { LifecycleScope, type IAgentScopeHandle, - type ISessionScopeHandle, } from '#/_base/di/scope'; import { createServices, type TestInstantiationService } from '#/_base/di/test'; import { Emitter } from '#/_base/event'; import { IOAuthService } from '#/app/auth/auth'; import { type DomainEvent, IEventService } from '#/app/event/event'; -import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler'; +import { createHooks, type Hooks } from '#/hooks'; import { HostRequestHeaders, IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; import { IProviderService, @@ -34,6 +32,11 @@ import { IAgentLifecycleService, MAIN_AGENT_ID, } from '#/session/agentLifecycle/agentLifecycle'; +import { + ISessionLifecycleHooks, + type SessionLifecycleHookSlots, +} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; +import { ISessionLifetime } from '#/session/sessionLifetime/sessionLifetime'; import { IAgentTitlePromptSource, } from '#/session/sessionTitle/agentTitlePromptSource'; @@ -137,20 +140,6 @@ function createPendingFetch() { }; } -function makeLiveSessionHandle(meta: ISessionMetadata): ISessionScopeHandle { - return { - id: SESSION_ID, - kind: LifecycleScope.Session, - accessor: { - get: (id: ServiceIdentifier) => { - if (id === ISessionMetadata) return meta as T; - throw new Error(`unexpected service ${String(id)}`); - }, - }, - dispose: () => undefined, - }; -} - describe('SessionTitleService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; @@ -162,7 +151,8 @@ describe('SessionTitleService', () => { let forceTokenError: Error | undefined; let resolvedOAuthRefs: Array; let titlePrompts: readonly string[]; - let liveSession: ISessionScopeHandle | undefined; + let lifetimeController: AbortController; + let lifecycleHooks: Hooks; let tokenCalls: boolean[]; beforeEach(() => { @@ -173,7 +163,11 @@ describe('SessionTitleService', () => { tokenCalls = []; providers = { 'managed:kimi-code': MANAGED_PROVIDER }; metadata = new FakeSessionMetadata(); - liveSession = makeLiveSessionHandle(metadata); + lifetimeController = new AbortController(); + lifecycleHooks = createHooks([ + 'onDidCreateSession', + 'onWillCloseSession', + ]); events = new FakeEventService(); fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise>( async () => @@ -213,9 +207,11 @@ describe('SessionTitleService', () => { get: () => mainAgent, }); reg.defineInstance(IEventService, events); - reg.definePartialInstance(IWorkspaceHandlerService, { - get: () => liveSession, + reg.defineInstance(ISessionLifetime, { + _serviceBrand: undefined, + signal: lifetimeController.signal, }); + reg.defineInstance(ISessionLifecycleHooks, lifecycleHooks); reg.defineInstance(IProviderService, stubProviderService(providers)); reg.definePartialInstance(IOAuthService, { resolveTokenProvider: (_provider, oauthRef) => { @@ -362,27 +358,85 @@ describe('SessionTitleService', () => { expect(metadata.meta.title).toBe('user 取的标题'); }); - it('drops the write-back when the session scope was superseded mid-flight', async () => { + it('drops the write-back when the lifetime signal aborts after the response arrived', async () => { + const pendingFetch = createPendingFetch(); + fetchMock.mockImplementationOnce(pendingFetch.fetch); const applySpy = vi.spyOn(metadata, 'setGeneratedTitleIfUncustomized'); titlePrompts = ['hello']; - liveSession = makeLiveSessionHandle(new FakeSessionMetadata()); - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(fetchMock).toHaveBeenCalledTimes(1); + const generation = ix.get(ISessionTitleService).generateTitle(); + await pendingFetch.started; + lifetimeController.abort(); + pendingFetch.resolve( + new Response(JSON.stringify({ title: '生成的标题' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + await expect(generation).resolves.toBeUndefined(); expect(applySpy).not.toHaveBeenCalled(); expect(metadata.meta.title).toBeUndefined(); }); - it('drops the write-back while the session is still resuming', async () => { + it('cancels the in-flight request when the lifetime signal aborts', async () => { + fetchMock.mockImplementationOnce( + async (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('The operation was aborted.', 'AbortError')); + }); + }), + ); const applySpy = vi.spyOn(metadata, 'setGeneratedTitleIfUncustomized'); titlePrompts = ['hello']; - liveSession = undefined; - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); + const generation = ix.get(ISessionTitleService).generateTitle(); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + lifetimeController.abort(); + + await expect(generation).resolves.toBeUndefined(); expect(applySpy).not.toHaveBeenCalled(); expect(metadata.meta.title).toBeUndefined(); }); + it('returns unavailable without fetching when the scope is already closing', async () => { + lifetimeController.abort(); + titlePrompts = ['hello']; + + await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('drains an in-flight generation through onWillCloseSession', async () => { + const pendingFetch = createPendingFetch(); + fetchMock.mockImplementationOnce(pendingFetch.fetch); + titlePrompts = ['hello']; + + const generation = ix.get(ISessionTitleService).generateTitle(); + await pendingFetch.started; + + let drained = false; + const closeRun = lifecycleHooks.onWillCloseSession.run({ reason: 'exit' }).then(() => { + drained = true; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(drained).toBe(false); + + pendingFetch.resolve( + new Response(JSON.stringify({ title: '生成的标题' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + await closeRun; + expect(drained).toBe(true); + await expect(generation).resolves.toBe('生成的标题'); + expect(metadata.meta.title).toBe('生成的标题'); + }); + it('keeps the current title when the backend request fails', async () => { fetchMock.mockImplementationOnce(async () => new Response('', { status: 500 })); titlePrompts = ['hello']; diff --git a/packages/oauth/src/managed-tools.ts b/packages/oauth/src/managed-tools.ts index c859239c84..5c6b63b376 100644 --- a/packages/oauth/src/managed-tools.ts +++ b/packages/oauth/src/managed-tools.ts @@ -34,9 +34,14 @@ export async function fetchChatTitle( url: string, accessToken: string, chatContent: string, - opts: { timeoutMs?: number; headers?: Record } = {}, + opts: { timeoutMs?: number; headers?: Record; signal?: AbortSignal } = {}, ): Promise { const controller = new AbortController(); + if (opts.signal !== undefined) { + const external = opts.signal; + if (external.aborted) controller.abort(); + else external.addEventListener('abort', () => { controller.abort(); }, { once: true }); + } const timer = setTimeout(() => { controller.abort(); }, opts.timeoutMs ?? 8000); @@ -71,7 +76,9 @@ export async function fetchChatTitle( return { kind: 'ok', title }; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { - return { kind: 'error', message: 'Failed to generate session title: request timed out.' }; + const reason = + opts.signal?.aborted === true ? 'request aborted.' : 'request timed out.'; + return { kind: 'error', message: `Failed to generate session title: ${reason}` }; } const msg = error instanceof Error ? error.message : String(error); return { kind: 'error', message: `Failed to generate session title: ${msg}` }; From b68c993c3fa65b1e1134c3fd79d60b4f72a7bdd6 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 12:49:59 +0800 Subject: [PATCH 22/47] fix: honor the legacy custom title marker over a stale titleKind A v1 rename spreads the original state.json document, so an explicit isCustomTitle: true can travel with a stale titleKind. The explicit marker now wins on load, and every persist double-writes the derived isCustomTitle so released v1 builds keep recognizing the custom title. --- .../sessionMetadata/sessionMetadataService.ts | 37 ++++++-- .../sessionMetadata/sessionMetadata.test.ts | 89 ++++++++++++++++++- 2 files changed, 116 insertions(+), 10 deletions(-) diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 4eb80b68e7..51563e49e3 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -12,7 +12,11 @@ * and persisted on load for documents written before the seeding existed * (without touching `updatedAt`, so a format heal never reorders session * listings) — keeping sessions on a shared `KIMI_CODE_HOME` resumable by - * released v1 builds. Re-registering an agent whose metadata is unchanged is + * released v1 builds. The canonical title state is `titleKind`; every + * persist additionally double-writes the v1-readable `isCustomTitle` + * marker derived from it, and on load an explicit `isCustomTitle: true` + * outranks a stale `titleKind` (a v1 rename spreads the original document, + * so the two can disagree). Re-registering an agent whose metadata is unchanged is * a no-op (no write, no mirror, no event), so resuming a session — which * re-registers its agents as they materialize — never bumps `updatedAt` and * never reorders session listings. Bound at Session scope. @@ -102,7 +106,7 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { private async applyUpdate(patch: SessionMetaPatch): Promise { await this.ready; this.data = { ...this.data, ...patch, updatedAt: Date.now() }; - await this.store.set(this.scope, META_KEY, this.data); + await this.store.set(this.scope, META_KEY, encodeSessionMeta(this.data)); await this.mirrorToReadModel(); this._onDidChangeMetadata.fire({ changed: Object.keys(patch) as (keyof SessionMeta)[], @@ -185,7 +189,7 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { agents: this.data.agents ?? {}, custom: this.data.custom ?? {}, }; - await this.store.set(this.scope, META_KEY, this.data); + await this.store.set(this.scope, META_KEY, encodeSessionMeta(this.data)); } return; } @@ -200,7 +204,7 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { agents: {}, custom: {}, }; - await this.store.set(this.scope, META_KEY, this.data); + await this.store.set(this.scope, META_KEY, encodeSessionMeta(this.data)); this.log.debug('session metadata created', { sessionId: this.ctx.sessionId }); } } @@ -266,11 +270,19 @@ function normalizeSessionTitle( raw: LegacySessionMeta, ): Pick { const title = typeof raw.title === 'string' ? raw.title : undefined; + // A legacy writer's explicit custom marker outranks the `titleKind` it + // left behind: v1's rename spreads the original document, so a stale + // 'replaceable' / 'generated' travels along with `isCustomTitle: true`. + if (title !== undefined && raw.isCustomTitle === true) { + return { title, titleKind: 'custom' }; + } if (title !== undefined && isSessionTitleKind(raw.titleKind)) { return { title, titleKind: raw.titleKind }; } - if (title !== undefined && typeof raw.isCustomTitle === 'boolean') { - return { title, titleKind: raw.isCustomTitle ? 'custom' : 'replaceable' }; + // The `false` marker only authors legacy documents without a titleKind — + // it never downgrades a modern generated/custom state (handled above). + if (title !== undefined && raw.isCustomTitle === false) { + return { title, titleKind: 'replaceable' }; } if (typeof raw.customTitle === 'string') { return { title: raw.customTitle, titleKind: 'custom' }; @@ -282,13 +294,24 @@ function isSessionTitleKind(value: unknown): value is SessionTitleKind { return value === 'replaceable' || value === 'generated' || value === 'custom'; } +/** + * The persisted `state.json` shape: the canonical `titleKind` plus the + * v1-readable `isCustomTitle` marker derived from it, so released v1 builds + * keep honoring a custom title on sessions shared through one home. + */ +type PersistedSessionMeta = SessionMeta & { readonly isCustomTitle: boolean }; + +function encodeSessionMeta(meta: SessionMeta): PersistedSessionMeta { + return { ...meta, isCustomTitle: meta.titleKind === 'custom' }; +} + function sessionMetaTitleNeedsMigration(raw: SessionMeta, normalized: SessionMeta): boolean { const record = raw as unknown as Record; return ( raw.title !== normalized.title || raw.titleKind !== normalized.titleKind || + record['isCustomTitle'] !== (normalized.titleKind === 'custom') || Object.hasOwn(record, 'titleSource') || - Object.hasOwn(record, 'isCustomTitle') || Object.hasOwn(record, 'customTitle') ); } 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 69575d3e00..6ce68711f7 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -219,7 +219,9 @@ describe('SessionMetadata', () => { archived: true, }); const persisted = await store.get>(META_SCOPE, 'state.json'); - expect(persisted).not.toHaveProperty('isCustomTitle'); + // The v1-readable marker is double-written (derived from titleKind); + // only the pre-`isCustomTitle` legacy field is stripped. + expect(persisted).toMatchObject({ isCustomTitle: true }); expect(persisted).not.toHaveProperty('customTitle'); }); @@ -244,8 +246,89 @@ describe('SessionMetadata', () => { titleKind: 'replaceable', }); const persisted = await store.get>(META_SCOPE, 'state.json'); - expect(persisted).toMatchObject({ title: 'prompt title', titleKind: 'replaceable' }); - expect(persisted).not.toHaveProperty('isCustomTitle'); + expect(persisted).toMatchObject({ + title: 'prompt title', + titleKind: 'replaceable', + isCustomTitle: false, + }); + }); + + it('honors a legacy writer custom marker over the stale titleKind it left behind', async () => { + // The mixed-version round trip: v2 persists a replaceable title, then a + // released v1 build renames the session — its writer spreads the original + // document, so `isCustomTitle: true` lands next to the stale + // `titleKind: 'replaceable'`. The explicit custom marker must win, or the + // next auto generation would overwrite the user's title. + const store = ix.get(IAtomicDocumentStore); + await store.set(META_SCOPE, 'state.json', { + id: 's1', + version: 2, + createdAt: 1700000000000, + updatedAt: 1700000000000, + archived: false, + title: '用户手工标题', + titleKind: 'replaceable', + isCustomTitle: true, + agents: {}, + custom: {}, + }); + + const meta = ix.get(ISessionMetadata); + await expect(meta.read()).resolves.toMatchObject({ + title: '用户手工标题', + titleKind: 'custom', + }); + + // The heal persists the upgraded state — v1 keeps reading it as custom. + const persisted = await store.get>(META_SCOPE, 'state.json'); + expect(persisted).toMatchObject({ titleKind: 'custom', isCustomTitle: true }); + + const fresh = createFreshMetadata(ix); + await expect(fresh.read()).resolves.toMatchObject({ + title: '用户手工标题', + titleKind: 'custom', + }); + // A generated title must not replace the upgraded custom title. + await expect(fresh.setGeneratedTitleIfUncustomized('generated title')).resolves.toBe(false); + }); + + it('double-writes the derived isCustomTitle marker for v1 readers', async () => { + const store = ix.get(IAtomicDocumentStore); + const meta = ix.get(ISessionMetadata); + + await meta.setGeneratedTitleIfUncustomized('generated title'); + await expect(store.get>(META_SCOPE, 'state.json')).resolves.toMatchObject( + { titleKind: 'generated', isCustomTitle: false }, + ); + + await meta.setTitle('user title'); + await expect(store.get>(META_SCOPE, 'state.json')).resolves.toMatchObject( + { titleKind: 'custom', isCustomTitle: true }, + ); + }); + + it('does not downgrade a modern titleKind on a legacy false marker', async () => { + // The double-written pair as this build persists it: the `false` marker + // is informational and must not demote the generated state. + const store = ix.get(IAtomicDocumentStore); + await store.set(META_SCOPE, 'state.json', { + id: 's1', + version: 2, + createdAt: 1700000000000, + updatedAt: 1700000000000, + archived: false, + title: 'generated title', + titleKind: 'generated', + isCustomTitle: false, + agents: {}, + custom: {}, + }); + + const meta = ix.get(ISessionMetadata); + await expect(meta.read()).resolves.toMatchObject({ + title: 'generated title', + titleKind: 'generated', + }); }); it('keeps a queued custom title when a generated title is enqueued afterward', async () => { From 4196abf3afb31bcaece7b95487c3adc093fcce4e Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 12:50:07 +0800 Subject: [PATCH 23/47] fix: serialize session access and expose the session title state The temporary resume/rename/close paths and the public lifecycle operations now share a per-session queue, so a public resume can never receive a handle whose cleanup close is already in flight. Session summaries carry the canonical title state, letting the TUI skip title generation for sessions whose title was already generated or customized instead of re-asking after every turn. --- .../tui/controllers/session-event-handler.ts | 19 ++ apps/kimi-code/src/tui/kimi-tui.ts | 1 + .../session-event-handler-title.test.ts | 56 ++++++ packages/node-sdk/src/sdk-rpc-client-v2.ts | 171 +++++++++++------- packages/node-sdk/src/types.ts | 9 + .../node-sdk/test/sdk-rpc-client-v2.test.ts | 138 +++++++++++++- packages/node-sdk/test/v1-v2-parity.test.ts | 5 +- 7 files changed, 328 insertions(+), 71 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index b417c0d480..34c371303e 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -17,6 +17,7 @@ import type { KimiHarness, Session, SessionMetaUpdatedEvent, + SessionTitleKind, SkillActivatedEvent, PluginCommandActivatedEvent, ThinkingDeltaEvent, @@ -394,6 +395,19 @@ export class SessionEventHandler { this.scheduleQueuedGoalPromotion(); } + /** + * Seeds the title-generation gate from the persisted title state (read off + * the resumed session's summary): a session whose title was already + * generated or customized has nothing left to ask for, so later turns skip + * the internally no-op generation call. Only ever closes the gate — + * reopening stays with `resetRuntimeState` on a session switch. + */ + syncTitleGenerationGate(titleKind: SessionTitleKind | undefined): void { + if (titleKind === 'generated' || titleKind === 'custom') { + this.titleGenerationDisabled = true; + } + } + /** * Best-effort auto title: after each completed turn, ask the engine to * generate a title from the first prompts until one lands. The engine @@ -928,6 +942,11 @@ export class SessionEventHandler { this.host.setAppState({ sessionTitle: title }); this.host.updateTerminalTitle(); } + // A custom rename (here or by another client) settles title generation: + // the engine would only keep returning undefined for it. + if (event.patch?.['isCustomTitle'] === true) { + this.titleGenerationDisabled = true; + } } private handleSessionError(event: ErrorEvent): void { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 59807b835e..ed12746d64 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1591,6 +1591,7 @@ export class KimiTUI { sessionTitle: session.summary?.title ?? null, goal: goalResult.goal, }); + this.sessionEventHandler.syncTitleGenerationGate(session.summary?.titleKind); this.syncAdditionalDirs(session); } diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts index 22640cc4c0..36d7f956a4 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts @@ -170,4 +170,60 @@ describe('session auto title generation', () => { expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2); }); + + it.each(['generated', 'custom'] as const)( + 'stops requesting when the resumed session already has a %s title', + (titleKind) => { + const { host, harness } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.syncTitleGenerationGate(titleKind); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(harness.generateSessionTitle).not.toHaveBeenCalled(); + }, + ); + + it.each(['replaceable', undefined] as const)( + 'keeps requesting when the resumed title state is %s', + (titleKind) => { + const { host, harness } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.syncTitleGenerationGate(titleKind); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); + }, + ); + + it('re-opens the gate on runtime reset for a session seeded as settled', () => { + const { host, harness } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.syncTitleGenerationGate('generated'); + handler.resetRuntimeState(); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); + }); + + it('stops requesting after a custom rename event arrives', () => { + const { host, harness } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent( + { + type: 'session.meta.updated', + agentId: 'main', + sessionId: 's1', + title: '用户手工标题', + patch: { title: '用户手工标题', isCustomTitle: true }, + } as const, + vi.fn(), + ); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(harness.generateSessionTitle).not.toHaveBeenCalled(); + }); }); diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index dfba5b57f1..d44ac11b9e 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -402,14 +402,16 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { */ private readonly sessionWirings = new Map(); /** - * Sessions a public `resumeSession` handed to a caller and has not closed - * yet. The temporary resume→act→close paths (`generateSessionTitle`, - * `renameSession`) skip their cleanup close for these, even though their - * own resume merged into the same live handle — the residual - * sub-millisecond window (a public resume landing between the check and - * the close) is accepted deliberately. - */ - private readonly externallyHeldSessions = new Set(); + * Per-session serialization for the operations that change a session's + * live ownership: the temporary resume→act→close paths (`renameSession`, + * `generateSessionTitle`) and the public `resumeSession` / `closeSession` + * / `reloadSession`. Chaining them through one queue per session id makes + * the handoff atomic — a public resume either lands first (the temporary + * path then reuses the live handle and leaves it open) or waits for the + * temporary close to finish and materializes a fresh scope, so a caller + * can never receive a handle whose close is already in flight. + */ + private readonly sessionAccessQueues = new Map>(); /** App-scope subscriptions (global event forwarding, lifecycle tracking), disposed in {@link close}. */ private readonly appSubscriptions: IDisposable[] = []; @@ -725,6 +727,49 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { return getLiveSessionById(this.engineAccessor, sessionId); } + /** + * Runs `work` after every previously queued operation on the same session + * settles; different sessions still run in parallel. The map entry drops + * itself once the queue drains. + */ + private runSessionAccess(sessionId: string, work: () => Promise): Promise { + const previous = this.sessionAccessQueues.get(sessionId) ?? Promise.resolve(); + const run = previous.then(work, work); + const tail = run.then( + () => undefined, + () => undefined, + ); + this.sessionAccessQueues.set(sessionId, tail); + void tail.then(() => { + if (this.sessionAccessQueues.get(sessionId) === tail) { + this.sessionAccessQueues.delete(sessionId); + } + }); + return run; + } + + /** + * Runs `action` against the session without changing its live footprint: a + * session that is already live (publicly resumed or created through this + * client) is used in place and left open, while a cold session is resumed + * for the duration of the action and closed again. Only safe inside + * {@link runSessionAccess} — the queue is what makes the resume/close pair + * atomic against the public lifecycle operations. + */ + private async withTemporarySession( + sessionId: string, + action: () => Promise, + ): Promise { + if (this.liveSession(sessionId) !== undefined) return action(); + const handle = await resumeSessionById(this.engineAccessor, sessionId); + if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(sessionId); + try { + return await action(); + } finally { + await closeSessionById(this.engineAccessor, sessionId); + } + } + /** v1's `requireSession` / store lookup failure shape. */ private static sessionNotFound(sessionId: string): KimiError { return new KimiError(ErrorCodes.SESSION_NOT_FOUND, `Session "${sessionId}" was not found`, { @@ -770,6 +815,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { return { id: meta.id, title: meta.title, + titleKind: meta.titleKind, lastPrompt: meta.lastPrompt, workDir: ctx.cwd, sessionDir: ctx.sessionDir, @@ -1012,19 +1058,9 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { if (title.length === 0) { throw new KimiError(ErrorCodes.SESSION_TITLE_EMPTY, 'Session title cannot be empty'); } - if (this.liveSession(input.id) !== undefined) { - await this.klient.session(input.id).setTitle(title); - return; - } - const handle = await resumeSessionById(this.engineAccessor, input.id); - if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); - try { - await this.klient.session(input.id).setTitle(title); - } finally { - if (!this.externallyHeldSessions.has(input.id)) { - await closeSessionById(this.engineAccessor, input.id); - } - } + await this.runSessionAccess(input.id, () => + this.withTemporarySession(input.id, () => this.klient.session(input.id).setTitle(title)), + ); } /** @@ -1037,18 +1073,11 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { override async generateSessionTitle( input: GenerateSessionTitleInput, ): Promise { - if (this.liveSession(input.id) !== undefined) { - return this.klient.session(input.id).generateTitle({ force: input.force }); - } - const handle = await resumeSessionById(this.engineAccessor, input.id); - if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); - try { - return await this.klient.session(input.id).generateTitle({ force: input.force }); - } finally { - if (!this.externallyHeldSessions.has(input.id)) { - await closeSessionById(this.engineAccessor, input.id); - } - } + return this.runSessionAccess(input.id, () => + this.withTemporarySession(input.id, () => + this.klient.session(input.id).generateTitle({ force: input.force }), + ), + ); } /** @@ -1082,8 +1111,9 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { override async closeSession(input: SessionIdRpcInput): Promise { // v1's print-steer counters die with the Session object; drop ours too. this.printSteerStates.delete(input.sessionId); - this.externallyHeldSessions.delete(input.sessionId); - await this.klient.session(input.sessionId).close(); + await this.runSessionAccess(input.sessionId, () => + this.klient.session(input.sessionId).close(), + ); } /** @@ -1102,15 +1132,16 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { // scope is materialized. Unlike v1, the v2 // engine has no caller `mcpServers` channel on create/resume (caller // servers are an ACP-side concern to be designed separately). - const handle = await resumeSessionById(this.engineAccessor, input.id, { - additionalDirs: input.additionalDirs, - }); - if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); - this.wireSession(handle); - this.externallyHeldSessions.add(input.id); - return this.resumedSessionSummary(handle, { - includeSubagents: input.includeSubagents, - replayTurnLimit: input.replayTurnLimit, + return this.runSessionAccess(input.id, async () => { + const handle = await resumeSessionById(this.engineAccessor, input.id, { + additionalDirs: input.additionalDirs, + }); + if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); + this.wireSession(handle); + return this.resumedSessionSummary(handle, { + includeSubagents: input.includeSubagents, + replayTurnLimit: input.replayTurnLimit, + }); }); } @@ -1124,33 +1155,35 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { */ override async reloadSession(input: ReloadSessionRpcInput): Promise { const sessionId = input.sessionId; - const live = this.liveSession(sessionId); - if (live !== undefined) { - for (const agent of live.accessor.get(IAgentLifecycleService).list()) { - if (agent.accessor.get(IAgentActivityView).state().turn !== undefined) { - throw new KimiError( - ErrorCodes.TURN_AGENT_BUSY, - `Session "${sessionId}" cannot be reloaded while a turn is running`, - { details: { sessionId } }, - ); + return this.runSessionAccess(sessionId, async () => { + const live = this.liveSession(sessionId); + if (live !== undefined) { + for (const agent of live.accessor.get(IAgentLifecycleService).list()) { + if (agent.accessor.get(IAgentActivityView).state().turn !== undefined) { + throw new KimiError( + ErrorCodes.TURN_AGENT_BUSY, + `Session "${sessionId}" cannot be reloaded while a turn is running`, + { details: { sessionId } }, + ); + } } + } else if ((await this.engineAccessor.get(ISessionIndex).get(sessionId)) === undefined) { + throw SDKRpcClientV2.sessionNotFound(sessionId); } - } else if ((await this.engineAccessor.get(ISessionIndex).get(sessionId)) === undefined) { - throw SDKRpcClientV2.sessionNotFound(sessionId); - } - await this.configReady; - await this.klient.global.config.reload(); - await this.klient.global.plugins.reload(); - if (live !== undefined) { - await closeSessionById(this.engineAccessor, sessionId); - } - // Same print-steer reset as closeSession: v1's reload rebuilds the - // Session, and with it the counters. - this.printSteerStates.delete(sessionId); - const handle = await resumeSessionById(this.engineAccessor, sessionId); - if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(sessionId); - this.wireSession(handle); - return this.resumedSessionSummary(handle); + await this.configReady; + await this.klient.global.config.reload(); + await this.klient.global.plugins.reload(); + if (live !== undefined) { + await closeSessionById(this.engineAccessor, sessionId); + } + // Same print-steer reset as closeSession: v1's reload rebuilds the + // Session, and with it the counters. + this.printSteerStates.delete(sessionId); + const handle = await resumeSessionById(this.engineAccessor, sessionId); + if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(sessionId); + this.wireSession(handle); + return this.resumedSessionSummary(handle); + }); } /** diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index ba5f5fa922..b1a480043f 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -264,9 +264,18 @@ export interface SessionStatus { readonly usage?: SessionUsage; } +/** + * The engine's canonical title state: `replaceable` (a prompt-derived easy + * title auto generation may overwrite), `generated` (an auto-generated title + * already landed), `custom` (a user-set title that is never overwritten). + * Only populated by the v2 engine; v1 backends leave it undefined. + */ +export type SessionTitleKind = 'replaceable' | 'generated' | 'custom'; + export interface SessionSummary { readonly id: string; readonly title?: string | undefined; + readonly titleKind?: SessionTitleKind | undefined; readonly lastPrompt?: string; readonly workDir: string; readonly sessionDir: string; diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index e1ef646515..9b0489e314 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -27,7 +27,12 @@ import { type Event, } from '#/index'; import { foldAgentWireReplay } from '#/v2/resume-replay'; -import { IHostRequestHeaders } from '@moonshot-ai/agent-core-v2'; +import { + IHostRequestHeaders, + ISessionLifecycleHooks, + IWorkspaceHandlerService, + IWorkspaceLifecycleService, +} from '@moonshot-ai/agent-core-v2'; import { TEST_IDENTITY } from './test-identity'; import { recordingTelemetry, type TelemetryRecord } from './telemetry'; @@ -186,6 +191,137 @@ key = "${titleOAuthRef.key}" } }); + it('serializes a temporary title-generation close against a public resume', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + const titleBaseUrl = 'https://api.example.test/coding/v1'; + const titleOAuthRef = resolveKimiCodeOAuthRef({ baseUrl: titleBaseUrl }); + await new FileTokenStorage(join(homeDir, 'credentials')).save( + resolveKimiTokenStorageName({ oauthKey: titleOAuthRef.key }), + { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + expiresAt: Math.floor(Date.now() / 1000) + 3600, + scope: '', + tokenType: 'Bearer', + expiresIn: 3600, + }, + ); + await writeFile( + join(homeDir, 'config.toml'), + ` +default_model = "stub" + +[providers.stub] +type = "openai" +base_url = "https://model.example.test/v1" +api_key = "stub" + +[models.stub] +provider = "stub" +model = "stub" +max_context_size = 1000 + +[providers."managed:kimi-code"] +type = "kimi" +base_url = "${titleBaseUrl}" + +[providers."managed:kimi-code".oauth] +storage = "file" +key = "${titleOAuthRef.key}" +`, + 'utf-8', + ); + let markFetchStarted!: () => void; + let resolveFetch!: (response: Response) => void; + const fetchStarted = new Promise((resolve) => { + markFetchStarted = resolve; + }); + const fetchResponse = new Promise((resolve) => { + resolveFetch = resolve; + }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url === 'https://api.example.test/coding/v1/tools') { + markFetchStarted(); + return fetchResponse; + } + throw new Error(`Unexpected fetch: ${url}`); + }); + const client = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + + try { + await client.createSession({ id: 'ses_title_race', workDir }); + await client.importContext({ + sessionId: 'ses_title_race', + content: 'Generate a concise title for this session', + source: "session 'source-session'", + }); + await client.closeSession({ sessionId: 'ses_title_race' }); + + // The cold session is temporarily resumed for generation; block its + // cleanup close inside the will-close hooks so the public resume below + // lands while the close is still in flight. + const titlePromise = client.generateSessionTitle({ id: 'ses_title_race' }); + await fetchStarted; + const handler = await client.engineAccessor + .get(IWorkspaceLifecycleService) + .handlerFor({ root: workDir }); + const tempHandle = handler.accessor.get(IWorkspaceHandlerService).get('ses_title_race'); + expect(tempHandle).toBeDefined(); + let markCloseStarted!: () => void; + let openCloseGate!: () => void; + const closeStarted = new Promise((resolve) => { + markCloseStarted = resolve; + }); + const closeGate = new Promise((resolve) => { + openCloseGate = resolve; + }); + tempHandle!.accessor + .get(ISessionLifecycleHooks) + .onWillCloseSession.register('test-block', async (_event, next) => { + markCloseStarted(); + await closeGate; + await next(); + }); + + resolveFetch( + new Response(JSON.stringify({ title: 'Generated title' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + await closeStarted; + + // The resume must queue behind the in-flight close instead of merging + // into the handle that is being torn down. + const order: string[] = []; + const resumePromise = client.resumeSession({ id: 'ses_title_race' }).then((summary) => { + order.push('resumed'); + return summary; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(order).toEqual([]); + + openCloseGate(); + await expect(titlePromise).resolves.toBe('Generated title'); + const summary = await resumePromise; + expect(summary.id).toBe('ses_title_race'); + expect(order).toEqual(['resumed']); + + // The resumed session is a fresh, fully usable scope — not the handle + // the temporary path just tore down. + await client.renameSession({ id: 'ses_title_race', title: 'Resumed title' }); + const sessions = await client.listSessions({ workDir }); + expect(sessions.find((item) => item.id === 'ses_title_race')?.title).toBe('Resumed title'); + } finally { + await client.close(); + fetchSpy.mockRestore(); + } + }); + it('serves listWorkspaceSkills through the engineAccessor escape hatch', async () => { const { harness, homeDir } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts index 2cc2499a75..18ce08d82b 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -218,7 +218,9 @@ const KNOWN_DIFFS = { // default title 'New Session' into state.json and reports it for // never-titled sessions where v2 leaves the title unset; only that // materialized default is projected away (explicit titles compare in - // full). Per-home paths (sessionDir, agent homedirs) compare after the + // full). `titleKind` is v2-only (the v1 wire has no canonical title-state + // field, only the `isCustomTitle` boolean inside `sessionMetadata`) — + // deleted. Per-home paths (sessionDir, agent homedirs) compare after the // home-prefix scrub — both engines lay sessions out as // `/sessions//` with the same key derivation. listSessions: (summaries: readonly SessionSummary[], home: HomePair): unknown => @@ -343,6 +345,7 @@ function projectSessionSummary(summary: SessionSummary, home: HomePair): unknown const projected = scrubHomePrefixes(summary, home) as Record; delete projected['createdAt']; delete projected['updatedAt']; + delete projected['titleKind']; if (projected['title'] === 'New Session') delete projected['title']; return projected; } From c63c5119eaca71431a7d3c4cc84aa613dd4b39ef Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 12:50:14 +0800 Subject: [PATCH 24/47] chore: add session title changesets --- .changeset/auto-session-title.md | 5 +++++ .changeset/sdk-session-title-kind.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/auto-session-title.md create mode 100644 .changeset/sdk-session-title-kind.md diff --git a/.changeset/auto-session-title.md b/.changeset/auto-session-title.md new file mode 100644 index 0000000000..fbd25aa180 --- /dev/null +++ b/.changeset/auto-session-title.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Generate a concise session title automatically after the first turn when signed in with a managed Kimi account. A title you set yourself is never overwritten. diff --git a/.changeset/sdk-session-title-kind.md b/.changeset/sdk-session-title-kind.md new file mode 100644 index 0000000000..8e44917e83 --- /dev/null +++ b/.changeset/sdk-session-title-kind.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": patch +--- + +Report the session's title state (replaceable, generated, or custom) in session summaries. From 4ffb1b732bb094952e1ec094a6a4b1b860be1e49 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 13:34:04 +0800 Subject: [PATCH 25/47] fix: close the session lifecycle races around close and title generation A close/archive is now tracked in a closing registry from its first synchronous step until disposal: get/list hide the closing session and resume waits the close out instead of returning the doomed handle, and fork waits out an in-flight source close. The title service tracks the whole generateTitle call as the unit the close hook drains, and the generated-title write re-checks the lifetime signal inside the serialized metadata update so an abort landing while the update is queued still vetoes the write-back. --- .../sessionMetadata/sessionMetadata.ts | 8 +- .../sessionMetadata/sessionMetadataService.ts | 22 ++--- .../sessionTitle/sessionTitleService.ts | 42 ++++---- .../workspaceHandlerService.ts | 47 +++++++-- .../sessionMetadata/sessionMetadata.test.ts | 97 +++++++++++++++++++ .../sessionTitle/sessionTitleService.test.ts | 51 ++++++++-- 6 files changed, 217 insertions(+), 50 deletions(-) diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts index 49df4c8983..aaff7b6444 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts @@ -56,7 +56,13 @@ export interface ISessionMetadata { read(): Promise; update(patch: SessionMetaPatch): Promise; setTitle(title: string): Promise; - setGeneratedTitleIfUncustomized(title: string): Promise; + /** + * Applies a generated title unless the user customized theirs. `allowWhen` + * is re-evaluated inside the serialized update, right before the write — + * a `false` there drops the write-back even if the update sat queued while + * the condition flipped (e.g. the session's close began). + */ + setGeneratedTitleIfUncustomized(title: string, allowWhen?: () => boolean): 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 51563e49e3..c6c68467f8 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -16,7 +16,13 @@ * persist additionally double-writes the v1-readable `isCustomTitle` * marker derived from it, and on load an explicit `isCustomTitle: true` * outranks a stale `titleKind` (a v1 rename spreads the original document, - * so the two can disagree). Re-registering an agent whose metadata is unchanged is + * so the two can disagree) while a `false` marker never downgrades a + * modern generated/custom state. The generated-title write path + * (`setGeneratedTitleIfUncustomized`) serializes through the same update + * queue as everything else and re-evaluates its caller-supplied veto + * (`allowWhen`) inside the queued write, so a session close landing while + * the update waits still drops the write-back. + * Re-registering an agent whose metadata is unchanged is * a no-op (no write, no mirror, no event), so resuming a session — which * re-registers its agents as they materialize — never bumps `updatedAt` and * never reorders session listings. Bound at Session scope. @@ -117,10 +123,11 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { await this.update({ title, titleKind: 'custom' }); } - async setGeneratedTitleIfUncustomized(title: string): Promise { + async setGeneratedTitleIfUncustomized(title: string, allowWhen?: () => boolean): Promise { return this.enqueueUpdate(async () => { await this.ready; if (this.data.titleKind === 'custom') return false; + if (allowWhen?.() === false) return false; await this.applyUpdate({ title, titleKind: 'generated' }); return true; }); @@ -157,6 +164,7 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { workspaceId: this.ctx.workspaceId, cwd: this.ctx.cwd, title: this.data.title, + titleKind: this.data.titleKind, lastPrompt: this.data.lastPrompt, createdAt: this.data.createdAt, updatedAt: this.data.updatedAt, @@ -270,17 +278,12 @@ function normalizeSessionTitle( raw: LegacySessionMeta, ): Pick { const title = typeof raw.title === 'string' ? raw.title : undefined; - // A legacy writer's explicit custom marker outranks the `titleKind` it - // left behind: v1's rename spreads the original document, so a stale - // 'replaceable' / 'generated' travels along with `isCustomTitle: true`. if (title !== undefined && raw.isCustomTitle === true) { return { title, titleKind: 'custom' }; } if (title !== undefined && isSessionTitleKind(raw.titleKind)) { return { title, titleKind: raw.titleKind }; } - // The `false` marker only authors legacy documents without a titleKind — - // it never downgrades a modern generated/custom state (handled above). if (title !== undefined && raw.isCustomTitle === false) { return { title, titleKind: 'replaceable' }; } @@ -294,11 +297,6 @@ function isSessionTitleKind(value: unknown): value is SessionTitleKind { return value === 'replaceable' || value === 'generated' || value === 'custom'; } -/** - * The persisted `state.json` shape: the canonical `titleKind` plus the - * v1-readable `isCustomTitle` marker derived from it, so released v1 builds - * keep honoring a custom title on sessions shared through one home. - */ type PersistedSessionMeta = SessionMeta & { readonly isCustomTitle: boolean }; function encodeSessionMeta(meta: SessionMeta): PersistedSessionMeta { diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index a186b069bc..5892e87a8d 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -9,11 +9,13 @@ * (the kap-server route), gated by a managed Kimi Code OAuth login; any * failure degrades to keeping the current title, and a custom title set by * the user is never overwritten. An already-generated title is not - * regenerated unless forced, and the write-back is dropped once this - * scope's `sessionLifetime` signal fires — the in-flight request carries - * the signal, and a close drains the pending generation through the - * `sessionLifecycleHooks` `onWillCloseSession` slot before the scope is - * disposed. Provider config comes + * regenerated unless forced. The whole `generateTitle()` call is the + * coalesced unit the `sessionLifecycleHooks` `onWillCloseSession` slot + * drains before the scope is disposed, and the write-back is dropped once + * this scope's `sessionLifetime` signal fires — the in-flight request + * carries the signal, and the metadata update re-checks it inside the + * serialized write so an abort landing while the update sits queued still + * vetoes the write. Provider config comes * from `provider`, the bearer token from `auth`, host identity headers from * `model`, prompt history from `agentLifecycle`/`sessionTitle`, and logs * through `log`. Bound at Session scope. @@ -72,9 +74,6 @@ export class SessionTitleService implements ISessionTitleService { @IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders, @ILogService private readonly log: ILogService, ) { - // A close aborts the lifetime signal first, so the in-flight generation - // settles fast; draining it here keeps its metadata write ordered before - // the scope's disposal (and any later resume re-reading the document). lifecycleHooks.onWillCloseSession.register('sessionTitle', async (_event, next) => { await this._generation?.catch(() => undefined); await next(); @@ -83,6 +82,18 @@ export class SessionTitleService implements ISessionTitleService { async generateTitle(options?: { readonly force?: boolean }): Promise { if (this.lifetime.signal.aborted) return undefined; + const inFlight = this._generation; + if (inFlight !== undefined) return inFlight; + const settled = this.generateTitleOnce(options).finally(() => { + if (this._generation === settled) this._generation = undefined; + }); + this._generation = settled; + return settled; + } + + private async generateTitleOnce(options?: { + readonly force?: boolean; + }): Promise { const current = await this.metadata.read(); if (current.titleKind === 'custom') return undefined; if (current.titleKind === 'generated' && options?.force !== true) return undefined; @@ -97,16 +108,6 @@ export class SessionTitleService implements ISessionTitleService { } private async generateAndApply(chatContent: string): Promise { - const inFlight = this._generation; - if (inFlight !== undefined) return inFlight; - const settled = this.generateAndApplyOnce(chatContent).finally(() => { - if (this._generation === settled) this._generation = undefined; - }); - this._generation = settled; - return settled; - } - - private async generateAndApplyOnce(chatContent: string): Promise { const current = await this.metadata.read(); if (current.titleKind === 'custom') return undefined; const provider = this.providers.get(KIMI_CODE_PROVIDER_NAME); @@ -160,7 +161,10 @@ export class SessionTitleService implements ISessionTitleService { } if (this.lifetime.signal.aborted) return undefined; const title = result.title.slice(0, MAX_GENERATED_TITLE_LENGTH); - const applied = await this.metadata.setGeneratedTitleIfUncustomized(title); + const applied = await this.metadata.setGeneratedTitleIfUncustomized( + title, + () => !this.lifetime.signal.aborted, + ); if (!applied) return undefined; this.eventService.publish({ type: 'session.meta.updated', diff --git a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts index 4968b1a8e1..52fc5a2ef1 100644 --- a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts @@ -8,7 +8,11 @@ * `sessionLifecycleHooks` slots instance it runs around create/close, and * a per-session `sessionLifetime` abort signal it fires synchronously when * a close/archive begins — before any async close hook — so in-flight - * session work can cancel itself and drop its write-back, + * session work can cancel itself and drop its write-back. A close/archive + * is tracked in a closing registry from that first synchronous step until + * the scope is disposed: `get`/`list` hide the session while it is + * closing, and `resume` waits the close out and re-materializes instead + * of returning the doomed handle, * tearing sessions down on close/archive — archiving flags the session's * `sessionMetadata`, removes its `agentLifecycle` agents, restoring clears * the archived flag, and broadcasts through `event`; session start and @@ -169,6 +173,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan private readonly _onDidForkSession = this._register(new Emitter()); readonly onDidForkSession: Event = this._onDidForkSession.event; private readonly resuming = new Map>(); + private readonly closing = new Map>(); private readonly sessionLifetimes = new Map(); constructor( @@ -360,13 +365,19 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan } get(sessionId: string): ISessionScopeHandle | undefined { - if (this.resuming.has(sessionId)) return undefined; + if (this.resuming.has(sessionId) || this.closing.has(sessionId)) return undefined; return this.sessions.get(sessionId); } resume(sessionId: string, opts?: ResumeSessionOptions): Promise { const inflight = this.resuming.get(sessionId); if (inflight !== undefined) return inflight; + const closing = this.closing.get(sessionId); + if (closing !== undefined) { + // A close is already in flight — wait it out and re-materialize + // instead of handing back the doomed handle. + return closing.then(() => this.resume(sessionId, opts)); + } const live = this.sessions.get(sessionId); if (live !== undefined) return Promise.resolve(live); const promise = this.doResume(sessionId, opts) @@ -411,14 +422,20 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan list(): readonly ISessionScopeHandle[] { const ready: ISessionScopeHandle[] = []; for (const [id, handle] of this.sessions) { - if (!this.resuming.has(id)) ready.push(handle); + if (!this.resuming.has(id) && !this.closing.has(id)) ready.push(handle); } return ready; } async close(sessionId: string): Promise { + const inflight = this.closing.get(sessionId); + if (inflight !== undefined) return inflight; const handle = this.sessions.get(sessionId); if (handle === undefined) return; + return this.trackClosing(sessionId, this.doClose(sessionId, handle)); + } + + private async doClose(sessionId: string, handle: ISessionScopeHandle): Promise { this.invalidateSessionLifetime(sessionId); await this.announceWillClose({ sessionId, handle, reason: 'exit' }); this.sessions.delete(sessionId); @@ -428,8 +445,14 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan } async archive(sessionId: string): Promise { + const inflight = this.closing.get(sessionId); + if (inflight !== undefined) return inflight; const handle = this.sessions.get(sessionId); if (handle === undefined) return; + return this.trackClosing(sessionId, this.doArchive(sessionId, handle)); + } + + private async doArchive(sessionId: string, handle: ISessionScopeHandle): Promise { this.invalidateSessionLifetime(sessionId); const meta = handle.accessor.get(ISessionMetadata); await meta.setArchived(true); @@ -444,6 +467,15 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan this._onDidArchiveSession.fire({ sessionId }); } + private async trackClosing(sessionId: string, run: Promise): Promise { + this.closing.set(sessionId, run); + try { + await run; + } finally { + if (this.closing.get(sessionId) === run) this.closing.delete(sessionId); + } + } + async restore(sessionId: string): Promise { const handle = await this.resume(sessionId); if (handle === undefined) return undefined; @@ -457,11 +489,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan .onWillCloseSession.run({ reason: event.reason }); } - /** - * Synchronously fires the session's liveness abort signal. Runs before any - * async close work (hooks, agent drain) so in-flight session services see - * the teardown immediately; idempotent — later calls are no-ops. - */ private invalidateSessionLifetime(sessionId: string): void { const lifetime = this.sessionLifetimes.get(sessionId); if (lifetime === undefined) return; @@ -479,6 +506,10 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan async fork(opts: ForkSessionOptions): Promise { const sourceId = opts.sourceSessionId; + // Wait out an in-flight close of the source instead of capturing its + // doomed handle (a close starting mid-fork remains an accepted, + // pre-existing window — fork never quiesces the source). + await this.closing.get(sourceId); const sourceHandle = this.sessions.get(sourceId); const indexSummary = await this.index.get(sourceId); if ( 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 6ce68711f7..7d5c079c4d 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -331,6 +331,63 @@ describe('SessionMetadata', () => { }); }); + it.each([ + // [document title fields, expected titleKind] — the mixed-version matrix. + [{ isCustomTitle: true, titleKind: 'generated' as const }, 'custom'], + [{ isCustomTitle: true, titleKind: 'replaceable' as const }, 'custom'], + [{ isCustomTitle: true }, 'custom'], + [{ isCustomTitle: false, titleKind: 'custom' as const }, 'custom'], + [{ isCustomTitle: false, titleKind: 'generated' as const }, 'generated'], + [{ isCustomTitle: false }, 'replaceable'], + [{ titleKind: 'generated' as const }, 'generated'], + [{ customTitle: 'legacy title' }, 'custom'], + [{}, 'replaceable'], + ])('normalizes title state %j to titleKind %s', async (fields, expectedKind) => { + const store = ix.get(IAtomicDocumentStore); + const title = 'customTitle' in fields ? undefined : 'some title'; + await store.set(META_SCOPE, 'state.json', { + id: 's1', + version: 2, + createdAt: 1700000000000, + updatedAt: 1700000000000, + archived: false, + ...(title === undefined ? {} : { title }), + ...fields, + agents: {}, + custom: {}, + }); + + const meta = ix.get(ISessionMetadata); + expect((await meta.read()).titleKind).toBe(expectedKind); + }); + + it('migrates the title state once, not on every load', async () => { + const store = ix.get(IAtomicDocumentStore); + await store.set(META_SCOPE, 'state.json', { + id: 's1', + version: 2, + createdAt: 1700000000000, + updatedAt: 1700000000000, + archived: false, + title: '用户手工标题', + titleKind: 'replaceable', + isCustomTitle: true, + agents: {}, + custom: {}, + }); + + const first = ix.get(ISessionMetadata); + await first.ready; + const setSpy = vi.spyOn(store, 'set'); + const fresh = createFreshMetadata(ix); + await fresh.ready; + + // The first load already healed the document; the second load sees a + // consistent pair and must not write again. + expect(setSpy).not.toHaveBeenCalled(); + expect((await fresh.read()).titleKind).toBe('custom'); + }); + it('keeps a queued custom title when a generated title is enqueued afterward', async () => { const meta = ix.get(ISessionMetadata); await meta.ready; @@ -369,6 +426,46 @@ describe('SessionMetadata', () => { }); }); + it('vetoes a queued generated title when the gate flips before the write runs', async () => { + // The lifetime-abort window behind `allowWhen`: the generated-title + // update sits in the metadata queue behind a blocked write, the + // session's close fires before the update executes, and the queued + // write must be dropped rather than land after the abort. + 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; + let allowed = true; + const generated = meta.setGeneratedTitleIfUncustomized('generated title', () => allowed); + allowed = false; + releaseWrite?.(); + + await priorWrite; + await expect(generated).resolves.toBe(false); + expect((await meta.read()).title).toBeUndefined(); + expect((await meta.read()).titleKind).toBeUndefined(); + }); + 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 7ca4a6f849..2b1ef17d6b 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -106,8 +106,9 @@ class FakeSessionMetadata implements ISessionMetadata { return this.update({ title, titleKind: 'custom' }); } - async setGeneratedTitleIfUncustomized(title: string): Promise { + async setGeneratedTitleIfUncustomized(title: string, allowWhen?: () => boolean): Promise { if (this.meta.titleKind === 'custom') return false; + if (allowWhen?.() === false) return false; await this.update({ title, titleKind: 'generated' }); return true; } @@ -151,6 +152,7 @@ describe('SessionTitleService', () => { let forceTokenError: Error | undefined; let resolvedOAuthRefs: Array; let titlePrompts: readonly string[]; + let promptSourceImpl: (limit: number) => Promise; let lifetimeController: AbortController; let lifecycleHooks: Hooks; let tokenCalls: boolean[]; @@ -160,6 +162,7 @@ describe('SessionTitleService', () => { forceTokenError = undefined; resolvedOAuthRefs = []; titlePrompts = []; + promptSourceImpl = async (limit) => titlePrompts.slice(0, limit); tokenCalls = []; providers = { 'managed:kimi-code': MANAGED_PROVIDER }; metadata = new FakeSessionMetadata(); @@ -195,7 +198,7 @@ describe('SessionTitleService', () => { reg.defineInstance(ISessionMetadata, metadata); const promptSource: IAgentTitlePromptSource = { _serviceBrand: undefined, - firstUserPrompts: async (limit) => titlePrompts.slice(0, limit), + firstUserPrompts: (limit) => promptSourceImpl(limit), }; const mainAgent: IAgentScopeHandle = { id: MAIN_AGENT_ID, @@ -418,12 +421,18 @@ describe('SessionTitleService', () => { const generation = ix.get(ISessionTitleService).generateTitle(); await pendingFetch.started; - let drained = false; - const closeRun = lifecycleHooks.onWillCloseSession.run({ reason: 'exit' }).then(() => { - drained = true; - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(drained).toBe(false); + let reachedTerminal = false; + const closeRun = lifecycleHooks.onWillCloseSession + .run({ reason: 'exit' }, async () => { + reachedTerminal = true; + }) + .then(() => reachedTerminal); + // The hook chain must be parked on the pending generation, microtask- + // deterministically: the terminal callback cannot run while the fetch + // is unresolved. + await Promise.resolve(); + await Promise.resolve(); + expect(reachedTerminal).toBe(false); pendingFetch.resolve( new Response(JSON.stringify({ title: '生成的标题' }), { @@ -431,12 +440,34 @@ describe('SessionTitleService', () => { headers: { 'Content-Type': 'application/json' }, }), ); - await closeRun; - expect(drained).toBe(true); + await expect(closeRun).resolves.toBe(true); await expect(generation).resolves.toBe('生成的标题'); expect(metadata.meta.title).toBe('生成的标题'); }); + it('drains a generation that is still collecting prompts through onWillCloseSession', async () => { + let resolvePrompts!: (prompts: readonly string[]) => void; + promptSourceImpl = async (limit) => + new Promise((resolve) => { + resolvePrompts = (prompts) => resolve(prompts.slice(0, limit)); + }); + + const generation = ix.get(ISessionTitleService).generateTitle(); + let reachedTerminal = false; + const closeRun = lifecycleHooks.onWillCloseSession + .run({ reason: 'exit' }, async () => { + reachedTerminal = true; + }) + .then(() => reachedTerminal); + await Promise.resolve(); + await Promise.resolve(); + expect(reachedTerminal).toBe(false); + + resolvePrompts(['hello']); + await expect(closeRun).resolves.toBe(true); + await expect(generation).resolves.toBe('生成的标题'); + }); + it('keeps the current title when the backend request fails', async () => { fetchMock.mockImplementationOnce(async () => new Response('', { status: 500 })); titlePrompts = ['hello']; From 46a77971fe3b4529e5bf32d43c65187d5ad9776f Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 13:34:11 +0800 Subject: [PATCH 26/47] feat: project the session title state through the session index readSummary and the read-model mirror carry titleKind, so listSessions reports the same canonical title state as a resumed session's summary. --- .../agent-core-v2/src/app/sessionIndex/sessionIndex.ts | 4 ++++ .../src/app/sessionIndex/sessionIndexService.ts | 7 +++++++ packages/node-sdk/src/v2/session-mapper.ts | 1 + 3 files changed, 12 insertions(+) diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts index 743e85717c..646d3bc23b 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts @@ -5,6 +5,9 @@ * query facade over the set of persisted sessions (open or closed). It * enumerates sessions and derives session identity (`workspaceId`), returning * data (`SessionSummary`) or counts — never filesystem paths or live handles. + * The summary mirrors `sessionMetadata`'s title state (`title` / `titleKind`); + * the kind union is re-declared inline because this L2 contract cannot + * import the L6 metadata domain that canonically owns it. * Writes (create / archive) live in `workspaceHandler` / `session`; the index * is a read model. Backends are deployment-specific (local filesystem today; * database / query store on a server). @@ -24,6 +27,7 @@ export interface SessionSummary { readonly workspaceId: string; readonly cwd?: string; readonly title?: string; + readonly titleKind?: 'replaceable' | 'generated' | 'custom'; readonly lastPrompt?: string; readonly createdAt: number; readonly updatedAt: number; diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index b58f9ed590..2ebf490c70 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -71,6 +71,12 @@ function parseTime(value: unknown): number { return 0; } +function toTitleKind(value: unknown): SessionSummary['titleKind'] { + return value === 'replaceable' || value === 'generated' || value === 'custom' + ? value + : undefined; +} + function recoverCwd(meta: Record): string | undefined { if (typeof meta['cwd'] === 'string' && meta['cwd'].length > 0) return meta['cwd']; if (typeof meta['workDir'] === 'string' && meta['workDir'].length > 0) { @@ -334,6 +340,7 @@ export class FileSessionIndex implements ISessionIndex { workspaceId, cwd: recoverCwd(meta), title: typeof meta['title'] === 'string' ? meta['title'] : undefined, + titleKind: toTitleKind(meta['titleKind']), lastPrompt: typeof meta['lastPrompt'] === 'string' ? meta['lastPrompt'] : undefined, createdAt: parseTime(meta['createdAt']), updatedAt: parseTime(meta['updatedAt']), diff --git a/packages/node-sdk/src/v2/session-mapper.ts b/packages/node-sdk/src/v2/session-mapper.ts index 8e70977b6f..08e9cb830e 100644 --- a/packages/node-sdk/src/v2/session-mapper.ts +++ b/packages/node-sdk/src/v2/session-mapper.ts @@ -48,6 +48,7 @@ export function v2SummaryToSessionSummary( return { id: summary.id, title: summary.title, + titleKind: summary.titleKind, lastPrompt: summary.lastPrompt, workDir: facts.workDir, sessionDir: facts.sessionDir, From add1f26eb5c70384f30b464e4bb09617015fd1cc Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 13:34:11 +0800 Subject: [PATCH 27/47] fix: serialize the remaining session access paths in the SDK forkSession and explicit-id createSession join the per-session queue, and the harness resume fast path skips a session whose close is in flight instead of returning the closing facade (which then failed every call with session.closed); its late onClose no longer evicts the fresh session either. The harness rename event now carries isCustomTitle so the TUI stops asking for a generated title after a local rename. --- packages/node-sdk/src/kimi-harness.ts | 25 +++++++--- packages/node-sdk/src/sdk-rpc-client-v2.ts | 32 ++++++++---- packages/node-sdk/src/session.ts | 10 +++- packages/node-sdk/src/types.ts | 2 +- .../node-sdk/test/sdk-rpc-client-v2.test.ts | 50 +++++++++++++++++++ 5 files changed, 102 insertions(+), 17 deletions(-) diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index f4a654989b..6ed218120a 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -122,7 +122,9 @@ export class KimiHarness { summary, rpc: this.rpc, onClose: () => { - this.activeSessions.delete(summary.id); + if (this.activeSessions.get(summary.id) === session) { + this.activeSessions.delete(summary.id); + } }, }); this.activeSessions.set(session.id, session); @@ -138,7 +140,10 @@ export class KimiHarness { const id = normalizeSessionId(input.id); const active = this.activeSessions.get(id); const { kaos, persistenceKaos, sessionStartedProperties, ...resumeInput } = input; - if (active !== undefined) { + // A session whose close is in flight (`isClosed` but not yet unmapped) + // is not a valid resume target — fall through and re-resume fresh, which + // the engine serializes behind that close. + if (active !== undefined && !active.isClosed) { if (kaos !== undefined || persistenceKaos !== undefined) { await this.rpc.resumeSessionWithKaos({ ...resumeInput, id }, kaos ?? persistenceKaos as Kaos, persistenceKaos); } else if (input.agentProfile !== undefined) { @@ -157,7 +162,9 @@ export class KimiHarness { summary, rpc: this.rpc, onClose: () => { - this.activeSessions.delete(summary.id); + if (this.activeSessions.get(summary.id) === session) { + this.activeSessions.delete(summary.id); + } }, }); this.activeSessions.set(session.id, session); @@ -187,7 +194,9 @@ export class KimiHarness { summary, rpc: this.rpc, onClose: () => { - this.activeSessions.delete(summary.id); + if (this.activeSessions.get(summary.id) === session) { + this.activeSessions.delete(summary.id); + } }, }); this.activeSessions.set(session.id, session); @@ -210,7 +219,9 @@ export class KimiHarness { summary, rpc: this.rpc, onClose: () => { - this.activeSessions.delete(summary.id); + if (this.activeSessions.get(summary.id) === session) { + this.activeSessions.delete(summary.id); + } }, }); this.activeSessions.set(session.id, session); @@ -235,7 +246,9 @@ export class KimiHarness { async renameSession(input: RenameSessionInput): Promise { await this.rpc.renameSession(input); - this.activeSessions.get(input.id)?.emitMetaUpdated({ title: input.title }); + this.activeSessions + .get(input.id) + ?.emitMetaUpdated({ title: input.title, isCustomTitle: true }); } /** diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index d44ac11b9e..6a1c417da4 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -1001,6 +1001,16 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * default model → `model.not_configured`) are pinned in the parity tests. */ override async createSession(input: CreateSessionOptions): Promise { + // An explicit id takes the per-session queue so the check-then-create + // below is atomic against another create/close of the same id; a random + // id has no contenders and needs no serialization. + if (input.id !== undefined) { + return this.runSessionAccess(input.id, () => this.doCreateSession(input)); + } + return this.doCreateSession(input); + } + + private async doCreateSession(input: CreateSessionOptions): Promise { const workDir = normalizeRequiredWorkDir('createSession', input.workDir); if (input.id !== undefined) { const existing = @@ -1096,16 +1106,20 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { 'forkSession turnIndex truncation is not wired to agent-core-v2 yet.', ); } - const forkHandler = await handlerForSession(this.engineAccessor, input.id); - if (forkHandler === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); - const handle = await forkHandler.accessor.get(IWorkspaceHandlerService).fork({ - sourceSessionId: input.id, - newSessionId: input.forkId, - title: input.title, - metadata: input.metadata, + // The source session's reads (metadata, wire flush) stay atomic against + // its close/reload through the per-session queue. + return this.runSessionAccess(input.id, async () => { + const forkHandler = await handlerForSession(this.engineAccessor, input.id); + if (forkHandler === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); + const handle = await forkHandler.accessor.get(IWorkspaceHandlerService).fork({ + sourceSessionId: input.id, + newSessionId: input.forkId, + title: input.title, + metadata: input.metadata, + }); + this.wireSession(handle); + return this.resumedSessionSummary(handle); }); - this.wireSession(handle); - return this.resumedSessionSummary(handle); } override async closeSession(input: SessionIdRpcInput): Promise { diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 9222112832..bf5f60e34f 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -68,6 +68,11 @@ export class Session { this.onClose = options.onClose; } + /** True once {@link close} began — the session may still be closing in the engine. */ + get isClosed(): boolean { + return this.closed; + } + getResumeState(): ResumedSessionState | undefined { this.ensureOpen(); return this.resumeState; @@ -598,7 +603,10 @@ export class Session { } /** @internal */ - emitMetaUpdated(patch: { readonly title?: string | undefined }): void { + emitMetaUpdated(patch: { + readonly title?: string | undefined; + readonly isCustomTitle?: boolean | undefined; + }): void { this.emit({ type: 'session.meta.updated', sessionId: this.id, diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index b1a480043f..2e6c29ec80 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -275,7 +275,7 @@ export type SessionTitleKind = 'replaceable' | 'generated' | 'custom'; export interface SessionSummary { readonly id: string; readonly title?: string | undefined; - readonly titleKind?: SessionTitleKind | undefined; + readonly titleKind?: SessionTitleKind; readonly lastPrompt?: string; readonly workDir: string; readonly sessionDir: string; diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index 9b0489e314..3c3cfb028d 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -322,6 +322,56 @@ key = "${titleOAuthRef.key}" } }); + it('re-resumes a fresh session facade while the public close is in flight', async () => { + const { harness } = await makeHarness(); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + + try { + const session = await harness.createSession({ id: 'ses_resume_race', workDir }); + // close() flips `isClosed` synchronously; the engine close settles + // asynchronously. The public resume must not hand back the closing + // facade — it queues behind the close and materializes a fresh one. + const closing = session.close(); + const resumed = await harness.resumeSession({ id: 'ses_resume_race' }); + await closing; + + expect(resumed).not.toBe(session); + expect(session.isClosed).toBe(true); + expect(resumed.isClosed).toBe(false); + expect(resumed.getResumeState()).toBeTruthy(); + // The stale facade's late onClose must not evict the live session. + expect(harness.getSession('ses_resume_race')).toBe(resumed); + } finally { + await harness.close(); + } + }); + + it('rejects one of two concurrent creates with the same explicit session id', async () => { + const { harness } = await makeHarness(); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + + try { + const [first, second] = await Promise.allSettled([ + harness.createSession({ id: 'ses_same_id', workDir }), + harness.createSession({ id: 'ses_same_id', workDir }), + ]); + + const outcomes = [first, second].map((result) => result.status); + expect(outcomes.sort()).toEqual(['fulfilled', 'rejected']); + const rejection = [first, second].find((result) => result.status === 'rejected'); + expect((rejection as PromiseRejectedResult).reason).toMatchObject({ + code: 'session.already_exists', + }); + await expect(harness.resumeSession({ id: 'ses_same_id' })).resolves.toMatchObject({ + id: 'ses_same_id', + }); + } finally { + await harness.close(); + } + }); + it('serves listWorkspaceSkills through the engineAccessor escape hatch', async () => { const { harness, homeDir } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); From 9a95e1c02ff03be94eecf8ed811a853b0960fdb4 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 13:34:11 +0800 Subject: [PATCH 28/47] fix: detach the external abort listener once the chat title request settles --- packages/oauth/src/managed-tools.ts | 9 ++- packages/oauth/test/managed-tools.test.ts | 75 +++++++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/packages/oauth/src/managed-tools.ts b/packages/oauth/src/managed-tools.ts index 5c6b63b376..46bf05f65a 100644 --- a/packages/oauth/src/managed-tools.ts +++ b/packages/oauth/src/managed-tools.ts @@ -37,10 +37,12 @@ export async function fetchChatTitle( opts: { timeoutMs?: number; headers?: Record; signal?: AbortSignal } = {}, ): Promise { const controller = new AbortController(); + const onExternalAbort = () => { + controller.abort(); + }; if (opts.signal !== undefined) { - const external = opts.signal; - if (external.aborted) controller.abort(); - else external.addEventListener('abort', () => { controller.abort(); }, { once: true }); + if (opts.signal.aborted) controller.abort(); + else opts.signal.addEventListener('abort', onExternalAbort, { once: true }); } const timer = setTimeout(() => { controller.abort(); @@ -84,6 +86,7 @@ export async function fetchChatTitle( return { kind: 'error', message: `Failed to generate session title: ${msg}` }; } finally { clearTimeout(timer); + opts.signal?.removeEventListener('abort', onExternalAbort); } } diff --git a/packages/oauth/test/managed-tools.test.ts b/packages/oauth/test/managed-tools.test.ts index fd74296035..53de6cc6f4 100644 --- a/packages/oauth/test/managed-tools.test.ts +++ b/packages/oauth/test/managed-tools.test.ts @@ -194,4 +194,79 @@ describe('fetchChatTitle', () => { if (result.kind !== 'error') return; expect(result.message).toMatch(/network down/); }); + + it('reports an external abort as an abort, not a timeout', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + (_url: string, init?: RequestInit) => + new Promise((_, reject) => { + const rejectAbort = () => { + const err = new Error('aborted'); + err.name = 'AbortError'; + reject(err); + }; + if (init?.signal?.aborted === true) { + rejectAbort(); + return; + } + init?.signal?.addEventListener('abort', rejectAbort); + }), + ), + ); + const external = new AbortController(); + + const resultPromise = fetchChatTitle('https://api.example/tools', 'tok', 'user: hi', { + signal: external.signal, + timeoutMs: 60_000, + }); + external.abort(); + const result = await resultPromise; + + expect(result.kind).toBe('error'); + if (result.kind !== 'error') return; + expect(result.message).toMatch(/aborted/); + expect(result.message).not.toMatch(/timed out/); + }); + + it('fails fast on an already-aborted external signal', async () => { + const fetchMock = vi.fn(async () => { + throw new Error('fetch should not start when the signal is pre-aborted'); + }); + vi.stubGlobal('fetch', fetchMock); + const external = new AbortController(); + external.abort(); + + const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi', { + signal: external.signal, + }); + + expect(result.kind).toBe('error'); + if (result.kind !== 'error') return; + expect(result.message).toMatch(/aborted/); + }); + + it('removes the external abort listener once the request settles', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ title: '标题' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + const external = new AbortController(); + const addSpy = vi.spyOn(external.signal, 'addEventListener'); + const removeSpy = vi.spyOn(external.signal, 'removeEventListener'); + + await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi', { + signal: external.signal, + }); + + expect(addSpy).toHaveBeenCalledTimes(1); + expect(removeSpy).toHaveBeenCalledTimes(1); + expect(removeSpy.mock.calls[0]?.[1]).toBe(addSpy.mock.calls[0]?.[1]); + }); }); From e7c397a7c8ac6694c991fc7ea76afb4459642a36 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 14:20:34 +0800 Subject: [PATCH 29/47] fix: harden the session close/archive and create/fork lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The closing registry now records the operation kind: an archive arriving during a plain close waits it out and lands the archived flag on the persisted document instead of riding the close to success, and a failing close hook no longer strands a half-closed session — the teardown always completes while the hook error still reaches the caller. create and fork reserve their target id synchronously with the existence check, so a concurrent create/fork of the same id loses up front and can never tear down the winner's scope or directory. --- .../workspaceHandlerService.ts | 118 ++++++++++++--- .../workspaceHandler/workspaceHandler.test.ts | 134 ++++++++++++++++++ 2 files changed, 230 insertions(+), 22 deletions(-) diff --git a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts index 52fc5a2ef1..013d2a2497 100644 --- a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts @@ -11,8 +11,12 @@ * session work can cancel itself and drop its write-back. A close/archive * is tracked in a closing registry from that first synchronous step until * the scope is disposed: `get`/`list` hide the session while it is - * closing, and `resume` waits the close out and re-materializes instead - * of returning the doomed handle, + * closing, `resume` waits the close out and re-materializes instead of + * returning the doomed handle, and a failing close hook never strands the + * teardown. A duplicate close joins the in-flight one, while an archive + * arriving during a plain close waits it out and then lands the archived + * flag directly on the persisted document (it never rides the close to + * success without archiving), * tearing sessions down on close/archive — archiving flags the session's * `sessionMetadata`, removes its `agentLifecycle` agents, restoring clears * the archived flag, and broadcasts through `event`; session start and @@ -161,6 +165,11 @@ type MaterializeSessionOptions = Omit & { readonly sessionId: string; }; +type ClosingEntry = { + readonly kind: 'close' | 'archive'; + readonly run: Promise; +}; + export class WorkspaceHandlerService extends Disposable implements IWorkspaceHandlerService { declare readonly _serviceBrand: undefined; private readonly sessions = new Map(); @@ -173,8 +182,9 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan private readonly _onDidForkSession = this._register(new Emitter()); readonly onDidForkSession: Event = this._onDidForkSession.event; private readonly resuming = new Map>(); - private readonly closing = new Map>(); + private readonly closing = new Map(); private readonly sessionLifetimes = new Map(); + private readonly reservedTargets = new Set(); constructor( @IInstantiationService private readonly instantiation: IInstantiationService, @@ -219,6 +229,24 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan async create(opts: CreateSessionOptions): Promise { const sessionId = opts.sessionId ?? createSessionId(); + if (this.sessions.has(sessionId) || this.reservedTargets.has(sessionId)) { + throw new Error2( + ErrorCodes.SESSION_ALREADY_EXISTS, + `Session "${sessionId}" already exists`, + ); + } + this.reservedTargets.add(sessionId); + try { + return await this.createReserved(opts, sessionId); + } finally { + this.reservedTargets.delete(sessionId); + } + } + + private async createReserved( + opts: CreateSessionOptions, + sessionId: string, + ): Promise { const handle = await this.materializeSession({ ...opts, sessionId }); try { const main = @@ -376,7 +404,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan if (closing !== undefined) { // A close is already in flight — wait it out and re-materialize // instead of handing back the doomed handle. - return closing.then(() => this.resume(sessionId, opts)); + return closing.run.then(() => this.resume(sessionId, opts)); } const live = this.sessions.get(sessionId); if (live !== undefined) return Promise.resolve(live); @@ -429,27 +457,43 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan async close(sessionId: string): Promise { const inflight = this.closing.get(sessionId); - if (inflight !== undefined) return inflight; + if (inflight !== undefined) return inflight.run; const handle = this.sessions.get(sessionId); if (handle === undefined) return; - return this.trackClosing(sessionId, this.doClose(sessionId, handle)); + return this.trackClosing(sessionId, 'close', this.doClose(sessionId, handle)); } private async doClose(sessionId: string, handle: ISessionScopeHandle): Promise { this.invalidateSessionLifetime(sessionId); - await this.announceWillClose({ sessionId, handle, reason: 'exit' }); - this.sessions.delete(sessionId); - await this.drainAgents(handle); - handle.dispose(); - this._onDidCloseSession.fire({ sessionId }); + try { + await this.announceWillClose({ sessionId, handle, reason: 'exit' }); + } finally { + // A failing close hook must not strand the session half-open: the + // teardown always completes; the hook error still reaches the caller. + this.sessions.delete(sessionId); + await this.drainAgents(handle); + handle.dispose(); + this._onDidCloseSession.fire({ sessionId }); + } } async archive(sessionId: string): Promise { const inflight = this.closing.get(sessionId); - if (inflight !== undefined) return inflight; + if (inflight !== undefined) { + if (inflight.kind === 'archive') return inflight.run; + // A plain close is already tearing the session down — the archive + // must not ride it to success. Wait it out (tracked, so a resume + // queues behind the marker write), then land the archived flag + // directly on the persisted document of the now-closed session. + return this.trackClosing( + sessionId, + 'archive', + inflight.run.then(() => this.markArchivedOnDisk(sessionId)), + ); + } const handle = this.sessions.get(sessionId); if (handle === undefined) return; - return this.trackClosing(sessionId, this.doArchive(sessionId, handle)); + return this.trackClosing(sessionId, 'archive', this.doArchive(sessionId, handle)); } private async doArchive(sessionId: string, handle: ISessionScopeHandle): Promise { @@ -461,18 +505,32 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan type: 'event.session.archived', payload: { sessionId }, }); - await this.announceWillClose({ sessionId, handle, reason: 'exit' }); - this.sessions.delete(sessionId); - handle.dispose(); - this._onDidArchiveSession.fire({ sessionId }); + try { + await this.announceWillClose({ sessionId, handle, reason: 'exit' }); + } finally { + this.sessions.delete(sessionId); + handle.dispose(); + this._onDidArchiveSession.fire({ sessionId }); + } + } + + private async markArchivedOnDisk(sessionId: string): Promise { + const scope = sessionScopeOf(this.handlerScope, sessionId); + const meta = await this.docs.get(scope, 'state.json'); + if (meta === undefined) return; + await this.docs.set(scope, 'state.json', { ...meta, archived: true, updatedAt: Date.now() }); } - private async trackClosing(sessionId: string, run: Promise): Promise { - this.closing.set(sessionId, run); + private async trackClosing( + sessionId: string, + kind: ClosingEntry['kind'], + run: Promise, + ): Promise { + this.closing.set(sessionId, { kind, run }); try { await run; } finally { - if (this.closing.get(sessionId) === run) this.closing.delete(sessionId); + if (this.closing.get(sessionId)?.run === run) this.closing.delete(sessionId); } } @@ -509,7 +567,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan // Wait out an in-flight close of the source instead of capturing its // doomed handle (a close starting mid-fork remains an accepted, // pre-existing window — fork never quiesces the source). - await this.closing.get(sourceId); + await this.closing.get(sourceId)?.run; const sourceHandle = this.sessions.get(sourceId); const indexSummary = await this.index.get(sourceId); if ( @@ -530,6 +588,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan let targetId: string | undefined; let target: ISessionScopeHandle | undefined; let targetSessionDir: string | undefined; + let targetReserved = false; try { const sourceMeta = sourceHandle !== undefined @@ -537,7 +596,18 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan : await this.readMetaFromDisk(sourceId); targetId = opts.newSessionId ?? createSessionId(); - if (this.sessions.has(targetId) || (await this.index.get(targetId)) !== undefined) { + if (this.sessions.has(targetId) || this.reservedTargets.has(targetId)) { + throw new Error2( + ErrorCodes.SESSION_ALREADY_EXISTS, + `Session "${targetId}" already exists`, + ); + } + // The check and the reservation are one synchronous step: a concurrent + // create/fork of the same target id loses before either materializes, + // so the loser can never tear down the winner's scope or directory. + this.reservedTargets.add(targetId); + targetReserved = true; + if ((await this.index.get(targetId)) !== undefined) { throw new Error2( ErrorCodes.SESSION_ALREADY_EXISTS, `Session "${targetId}" already exists`, @@ -612,6 +682,10 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan await this.hostFs.remove(targetSessionDir).catch(() => {}); } throw error; + } finally { + if (targetReserved && targetId !== undefined) { + this.reservedTargets.delete(targetId); + } } } diff --git a/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts b/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts index 384168af50..d7982e558b 100644 --- a/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts @@ -602,6 +602,140 @@ describe('WorkspaceHandlerService', () => { expect(svc.get('s1')).toBeUndefined(); }); + it('resume waits out an in-flight close and returns a fresh scope', async () => { + const svc = await build([ + stubPair(IWorkspaceService, persistentWorkspaceStub()), + stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj')), + stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), + ]); + const original = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + + let closeStarted!: () => void; + let releaseClose!: () => void; + const closeBegan = new Promise((resolve) => { + closeStarted = resolve; + }); + const closeGate = new Promise((resolve) => { + releaseClose = resolve; + }); + original.accessor + .get(ISessionLifecycleHooks) + .onWillCloseSession.register('test-block', async (_event, next) => { + closeStarted(); + await closeGate; + await next(); + }); + + const closing = svc.close('s1'); + await closeBegan; + // The closing session is hidden from get/list immediately. + expect(svc.get('s1')).toBeUndefined(); + expect(svc.list()).toEqual([]); + + let settled = false; + const resumed = svc.resume('s1').then((handle) => { + settled = true; + return handle; + }); + await tick(); + expect(settled).toBe(false); + + releaseClose(); + await closing; + const fresh = await resumed; + expect(fresh).toBeDefined(); + expect(fresh).not.toBe(original); + expect(svc.get('s1')).toBe(fresh); + }); + + it('archive during a plain close waits it out and lands the archived flag on disk', async () => { + const docs = new Map(); + const docStore = { + _serviceBrand: undefined, + get: (scope: string, key: string) => Promise.resolve(docs.get(`${scope}/${key}`)), + set: (scope: string, key: string, value: unknown) => { + docs.set(`${scope}/${key}`, value); + return Promise.resolve(); + }, + delete: () => Promise.resolve(), + list: () => Promise.resolve([]), + watch: () => (_listener: unknown) => ({ dispose: () => {} }), + acquire: () => ({ dispose: () => {} }), + } as unknown as IAtomicDocumentStore; + const svc = await build([stubPair(IAtomicDocumentStore, docStore)]); + const original = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + const metaKey = `${original.accessor.get(ISessionContext).metaScope}/state.json`; + docs.set(metaKey, { id: 's1', version: 2, createdAt: 1, updatedAt: 1, archived: false }); + + let releaseClose!: () => void; + const closeGate = new Promise((resolve) => { + releaseClose = resolve; + }); + original.accessor + .get(ISessionLifecycleHooks) + .onWillCloseSession.register('test-block', async (_event, next) => { + await closeGate; + await next(); + }); + + const closing = svc.close('s1'); + await tick(); + const archived = svc.archive('s1'); + releaseClose(); + await closing; + await archived; + + // The archive must not ride the close: the marker lands on the persisted + // document after the close completes. + expect(docs.get(metaKey)).toMatchObject({ archived: true }); + }); + + it('still completes the teardown when a close hook fails', async () => { + const svc = await build(); + const original = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + original.accessor + .get(ISessionLifecycleHooks) + .onWillCloseSession.register('test-boom', async () => { + throw new Error('hook boom'); + }); + + await expect(svc.close('s1')).rejects.toThrow('hook boom'); + // The hook error reaches the caller, but the session is gone, not + // stranded as a half-closed zombie. + expect(svc.get('s1')).toBeUndefined(); + }); + + it('rejects a concurrent create with the same reserved session id', async () => { + const svc = await build(); + + const [first, second] = await Promise.allSettled([ + svc.create({ sessionId: 's1', workDir: '/tmp/proj' }), + svc.create({ sessionId: 's1', workDir: '/tmp/proj' }), + ]); + + expect([first.status, second.status].sort()).toEqual(['fulfilled', 'rejected']); + const rejection = [first, second].find((result) => result.status === 'rejected'); + expect((rejection as PromiseRejectedResult).reason).toMatchObject({ + code: ErrorCodes.SESSION_ALREADY_EXISTS, + }); + }); + + it('rejects a concurrent fork racing for the same reserved target id', async () => { + const svc = await build(); + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + + const [first, second] = await Promise.allSettled([ + svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }), + svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }), + ]); + + expect([first.status, second.status].sort()).toEqual(['fulfilled', 'rejected']); + const rejection = [first, second].find((result) => result.status === 'rejected'); + expect((rejection as PromiseRejectedResult).reason).toMatchObject({ + code: ErrorCodes.SESSION_ALREADY_EXISTS, + }); + }); + it('create seeds identity and materializes metadata', async () => { const svc = await build(); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); From 939627c3bfc46535222ca505e0fcef0df6bca9c7 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 14:20:34 +0800 Subject: [PATCH 30/47] fix: keep forced title regeneration independent and veto queued title writes atomically Plain generateTitle calls still coalesce onto one shared in-flight generation, but a forced regeneration always runs on its own so it is neither swallowed by a plain call's early exit nor shares its result; the close hook drains every active generation. The allowWhen veto now runs inside applyUpdate with no await between the check and the mutation, so an abort cannot slip into the gap. --- .../sessionMetadata/sessionMetadataService.ts | 10 ++--- .../sessionTitle/sessionTitleService.ts | 29 ++++++++----- .../sessionTitle/sessionTitleService.test.ts | 43 +++++++++++++++++++ 3 files changed, 66 insertions(+), 16 deletions(-) diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index c6c68467f8..5a44f797d3 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -106,17 +106,19 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { } async update(patch: SessionMetaPatch): Promise { - return this.enqueueUpdate(() => this.applyUpdate(patch)); + await this.enqueueUpdate(() => this.applyUpdate(patch)); } - private async applyUpdate(patch: SessionMetaPatch): Promise { + private async applyUpdate(patch: SessionMetaPatch, allowWhen?: () => boolean): Promise { await this.ready; + if (allowWhen?.() === false) return false; this.data = { ...this.data, ...patch, updatedAt: Date.now() }; await this.store.set(this.scope, META_KEY, encodeSessionMeta(this.data)); await this.mirrorToReadModel(); this._onDidChangeMetadata.fire({ changed: Object.keys(patch) as (keyof SessionMeta)[], }); + return true; } async setTitle(title: string): Promise { @@ -127,9 +129,7 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { return this.enqueueUpdate(async () => { await this.ready; if (this.data.titleKind === 'custom') return false; - if (allowWhen?.() === false) return false; - await this.applyUpdate({ title, titleKind: 'generated' }); - return true; + return this.applyUpdate({ title, titleKind: 'generated' }, allowWhen); }); } diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index 5892e87a8d..5f90e014b5 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -9,9 +9,11 @@ * (the kap-server route), gated by a managed Kimi Code OAuth login; any * failure degrades to keeping the current title, and a custom title set by * the user is never overwritten. An already-generated title is not - * regenerated unless forced. The whole `generateTitle()` call is the - * coalesced unit the `sessionLifecycleHooks` `onWillCloseSession` slot - * drains before the scope is disposed, and the write-back is dropped once + * regenerated unless forced. Plain calls coalesce onto one shared + * in-flight generation while a forced regeneration always runs on its + * own; every active generation is drained through the + * `sessionLifecycleHooks` `onWillCloseSession` slot before the scope is + * disposed, and the write-back is dropped once * this scope's `sessionLifetime` signal fires — the in-flight request * carries the signal, and the metadata update re-checks it inside the * serialized write so an abort landing while the update sits queued still @@ -59,7 +61,8 @@ const MAX_TITLE_PROMPTS = 3; export class SessionTitleService implements ISessionTitleService { declare readonly _serviceBrand: undefined; - private _generation: Promise | undefined; + private _shared: Promise | undefined; + private readonly _active = new Set>(); constructor( @ISessionContext private readonly ctx: ISessionContext, @@ -75,20 +78,24 @@ export class SessionTitleService implements ISessionTitleService { @ILogService private readonly log: ILogService, ) { lifecycleHooks.onWillCloseSession.register('sessionTitle', async (_event, next) => { - await this._generation?.catch(() => undefined); + await Promise.allSettled([...this._active]); await next(); }); } async generateTitle(options?: { readonly force?: boolean }): Promise { if (this.lifetime.signal.aborted) return undefined; - const inFlight = this._generation; - if (inFlight !== undefined) return inFlight; - const settled = this.generateTitleOnce(options).finally(() => { - if (this._generation === settled) this._generation = undefined; + // Plain calls coalesce onto the shared in-flight slot; a forced + // regeneration always runs on its own so it is neither swallowed by a + // plain call's early exit nor shares its result with one. + if (options?.force !== true && this._shared !== undefined) return this._shared; + const tracked = this.generateTitleOnce(options).finally(() => { + this._active.delete(tracked); + if (this._shared === tracked) this._shared = undefined; }); - this._generation = settled; - return settled; + this._active.add(tracked); + if (options?.force !== true) this._shared = tracked; + return tracked; } private async generateTitleOnce(options?: { 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 2b1ef17d6b..35d789352d 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -583,6 +583,49 @@ describe('SessionTitleService', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it('runs a forced regeneration independently of a plain in-flight call', async () => { + const pendingFetch = createPendingFetch(); + fetchMock.mockImplementationOnce(pendingFetch.fetch); + titlePrompts = ['hello']; + + const plain = ix.get(ISessionTitleService).generateTitle(); + await pendingFetch.started; + // The forced call neither joins the plain call's slot nor waits for it. + const forced = ix.get(ISessionTitleService).generateTitle({ force: true }); + await expect(forced).resolves.toBe('生成的标题'); + + pendingFetch.resolve( + new Response(JSON.stringify({ title: '普通标题' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + await expect(plain).resolves.toBe('普通标题'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('does not share a forced in-flight result with a plain call', async () => { + await metadata.setGeneratedTitleIfUncustomized('已生成的标题'); + const pendingFetch = createPendingFetch(); + fetchMock.mockImplementationOnce(pendingFetch.fetch); + titlePrompts = ['hello']; + + const forced = ix.get(ISessionTitleService).generateTitle({ force: true }); + await pendingFetch.started; + // An already-generated title makes the plain call exit early on its own + // instead of riding the forced regeneration's result. + await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + pendingFetch.resolve( + new Response(JSON.stringify({ title: '强制标题' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + await expect(forced).resolves.toBe('强制标题'); + }); + it('returns unavailable without calling the backend when no prompt was seen', async () => { await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); expect(fetchMock).not.toHaveBeenCalled(); From a9727a019ca3485a84c91faa0c8a3a9a1d48e770 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 14:20:34 +0800 Subject: [PATCH 31/47] fix: carry the title state through the session index and klient contract The klient session summary schema no longer strips titleKind, and the index readSummary honors a legacy isCustomTitle marker over a stale titleKind, so listSessions reports the same canonical title state as a resumed session. --- .../app/sessionIndex/sessionIndexService.ts | 11 +++++++---- .../app/sessionIndex/sessionIndex.test.ts | 19 +++++++++++++++++++ .../klient/src/contract/global/sessions.ts | 1 + 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index 2ebf490c70..22ea7d968e 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -71,9 +71,12 @@ function parseTime(value: unknown): number { return 0; } -function toTitleKind(value: unknown): SessionSummary['titleKind'] { - return value === 'replaceable' || value === 'generated' || value === 'custom' - ? value +function toTitleKind(titleKind: unknown, isCustomTitle: unknown): SessionSummary['titleKind'] { + // Same priority as the metadata document's canonical normalization: a + // legacy writer's explicit custom marker outranks a stale titleKind. + if (isCustomTitle === true) return 'custom'; + return titleKind === 'replaceable' || titleKind === 'generated' || titleKind === 'custom' + ? titleKind : undefined; } @@ -340,7 +343,7 @@ export class FileSessionIndex implements ISessionIndex { workspaceId, cwd: recoverCwd(meta), title: typeof meta['title'] === 'string' ? meta['title'] : undefined, - titleKind: toTitleKind(meta['titleKind']), + titleKind: toTitleKind(meta['titleKind'], meta['isCustomTitle']), lastPrompt: typeof meta['lastPrompt'] === 'string' ? meta['lastPrompt'] : undefined, createdAt: parseTime(meta['createdAt']), updatedAt: parseTime(meta['updatedAt']), diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index a0b35cceef..ba023f11d9 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -132,6 +132,25 @@ describe('FileSessionIndex (legacy)', () => { expect((await store.get('none'))?.cwd).toBeUndefined(); }); + it('projects the title state, honoring a legacy custom marker over a stale titleKind', async () => { + await seedSession('generated', { + title: 'gen', + titleKind: 'generated', + isCustomTitle: false, + }); + await seedSession('stale-mixed', { + title: '用户标题', + titleKind: 'replaceable', + isCustomTitle: true, + }); + await seedSession('plain', { title: 'plain' }); + + const store = build(); + expect((await store.get('generated'))?.titleKind).toBe('generated'); + expect((await store.get('stale-mixed'))?.titleKind).toBe('custom'); + expect((await store.get('plain'))?.titleKind).toBeUndefined(); + }); + it('list filters by sessionId without enumerating all sessions', async () => { await seedSession('active', { title: 'hello' }); await seedSession('archived', { archived: true }); diff --git a/packages/klient/src/contract/global/sessions.ts b/packages/klient/src/contract/global/sessions.ts index 27b6b6cca7..7544d26d4a 100644 --- a/packages/klient/src/contract/global/sessions.ts +++ b/packages/klient/src/contract/global/sessions.ts @@ -13,6 +13,7 @@ export const sessionSummarySchema = z.object({ workspaceId: z.string(), cwd: z.string().optional(), title: z.string().optional(), + titleKind: z.enum(['replaceable', 'generated', 'custom']).optional(), lastPrompt: z.string().optional(), createdAt: z.number(), updatedAt: z.number(), From f4445aaf9e5b36a23135c69f8696a2756b1fe4c0 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 14:20:41 +0800 Subject: [PATCH 32/47] fix: coalesce harness resumes, lock fork targets, and cover the title state end to end Concurrent public resumeSession calls now share one in-flight resume and one facade instead of building parallel facades over the same engine handle (a close on either would strand the other). forkSession takes the source and target queues in sorted order, so fork(A->X) is atomic against create(X) and fork(B->X) without an ABBA deadlock. The emitMetaUpdated patch type drops the redundant undefined union, and the SDK tests now cover facade coalescing and the title state across list and resume. --- packages/node-sdk/src/kimi-harness.ts | 18 ++++++++ packages/node-sdk/src/sdk-rpc-client-v2.ts | 46 +++++++++++++------ packages/node-sdk/src/session.ts | 5 +- .../node-sdk/test/sdk-rpc-client-v2.test.ts | 44 ++++++++++++++++++ 4 files changed, 96 insertions(+), 17 deletions(-) diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 6ed218120a..7c6d9a1214 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -64,6 +64,7 @@ export class KimiHarness { private readonly uiMode: string; private readonly telemetry: TelemetryClient; private readonly activeSessions = new Map(); + private readonly resumeInflight = new Map>(); private readonly ensureConfigFileImpl: () => Promise; private readonly closeImpl: () => void | Promise; private readonly sessionStartedProperties: TelemetryProperties; @@ -152,6 +153,23 @@ export class KimiHarness { return active; } + // Coalesce concurrent resumes of the same id onto one facade; without + // this, parallel callers each build their own Session over the shared + // engine handle, and one facade's close kills the engine handle under + // the other. + const inflight = this.resumeInflight.get(id); + if (inflight !== undefined) return inflight; + const run = this.doResumeSession(input, id); + this.resumeInflight.set(id, run); + try { + return await run; + } finally { + if (this.resumeInflight.get(id) === run) this.resumeInflight.delete(id); + } + } + + private async doResumeSession(input: ResumeSessionInput, id: string): Promise { + const { kaos, persistenceKaos, sessionStartedProperties, ...resumeInput } = input; const summary = kaos === undefined && persistenceKaos === undefined ? await this.rpc.resumeSession({ ...resumeInput, id }) diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 6a1c417da4..cef31b116f 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -748,6 +748,21 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { return run; } + /** + * Multi-key variant of {@link runSessionAccess}: acquires the queues in + * sorted order so concurrent multi-key operations (fork A→B vs fork B→A) + * cannot deadlock. + */ + private runSessionAccessAll(sessionIds: readonly string[], work: () => Promise): Promise { + const keys = [...new Set(sessionIds)].sort(); + let chained: () => Promise = work; + for (const key of [...keys].reverse()) { + const inner = chained; + chained = () => this.runSessionAccess(key, inner); + } + return chained(); + } + /** * Runs `action` against the session without changing its live footprint: a * session that is already live (publicly resumed or created through this @@ -1107,19 +1122,24 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { ); } // The source session's reads (metadata, wire flush) stay atomic against - // its close/reload through the per-session queue. - return this.runSessionAccess(input.id, async () => { - const forkHandler = await handlerForSession(this.engineAccessor, input.id); - if (forkHandler === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); - const handle = await forkHandler.accessor.get(IWorkspaceHandlerService).fork({ - sourceSessionId: input.id, - newSessionId: input.forkId, - title: input.title, - metadata: input.metadata, - }); - this.wireSession(handle); - return this.resumedSessionSummary(handle); - }); + // its close/reload through the per-session queue; an explicit target id + // takes a second (sorted) queue so fork(A→X) is also atomic against + // create(X) / fork(B→X). + return this.runSessionAccessAll( + input.forkId === undefined ? [input.id] : [input.id, input.forkId], + async () => { + const forkHandler = await handlerForSession(this.engineAccessor, input.id); + if (forkHandler === undefined) throw SDKRpcClientV2.sessionNotFound(input.id); + const handle = await forkHandler.accessor.get(IWorkspaceHandlerService).fork({ + sourceSessionId: input.id, + newSessionId: input.forkId, + title: input.title, + metadata: input.metadata, + }); + this.wireSession(handle); + return this.resumedSessionSummary(handle); + }, + ); } override async closeSession(input: SessionIdRpcInput): Promise { diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index bf5f60e34f..eacc225167 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -603,10 +603,7 @@ export class Session { } /** @internal */ - emitMetaUpdated(patch: { - readonly title?: string | undefined; - readonly isCustomTitle?: boolean | undefined; - }): void { + emitMetaUpdated(patch: { readonly title?: string; readonly isCustomTitle?: boolean }): void { this.emit({ type: 'session.meta.updated', sessionId: this.id, diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index 3c3cfb028d..d8a934a27b 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -372,6 +372,50 @@ key = "${titleOAuthRef.key}" } }); + it('coalesces concurrent public resumes onto one session facade', async () => { + const { harness } = await makeHarness(); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + + try { + const session = await harness.createSession({ id: 'ses_coalesce', workDir }); + await session.close(); + + const [first, second] = await Promise.all([ + harness.resumeSession({ id: 'ses_coalesce' }), + harness.resumeSession({ id: 'ses_coalesce' }), + ]); + + // One engine handle, one facade: a later close on either reference + // must not strand a second live facade over the same handle. + expect(first).toBe(second); + expect(harness.getSession('ses_coalesce')).toBe(first); + } finally { + await harness.close(); + } + }); + + it('reports the title state in listSessions as well as in the resumed summary', async () => { + const { harness } = await makeHarness(); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + + try { + const session = await harness.createSession({ id: 'ses_title_kind', workDir }); + await harness.renameSession({ id: session.id, title: '我的标题' }); + + // The list path (index → klient contract → mapper) must project the + // same canonical title state the resume path serves. + const listed = await harness.listSessions({ workDir }); + expect(listed.find((item) => item.id === session.id)?.titleKind).toBe('custom'); + await session.close(); + const resumed = await harness.resumeSession({ id: session.id }); + expect(resumed.summary?.titleKind).toBe('custom'); + } finally { + await harness.close(); + } + }); + it('serves listWorkspaceSkills through the engineAccessor escape hatch', async () => { const { harness, homeDir } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); From 0e89f70bf68fe67f2dc047ec18b1b5f33121dfde Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 15:16:48 +0800 Subject: [PATCH 33/47] fix: serve the canonical title state from the session index and version the read-model cache readSummary now derives the title state with the same priority chain as the metadata document's canonical normalization (explicit custom marker, valid titleKind, legacy false marker, customTitle, plain title), so list and resume agree on legacy documents too. Read-model cache entries carry a summary version stamp and older-stamped entries are treated as cold misses, so an upgraded reader never serves a stale-shaped summary. --- .../agent-core-v2/docs/state-manifest.d.ts | 2 +- .../src/app/sessionIndex/sessionIndex.ts | 7 +++- .../app/sessionIndex/sessionIndexService.ts | 38 +++++++++++++------ .../sessionMetadata/sessionMetadataService.ts | 2 + .../app/sessionIndex/sessionIndex.test.ts | 36 ++++++++++++++++-- 5 files changed, 69 insertions(+), 16 deletions(-) diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 109782ff9a..3ddcbc8585 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -740,7 +740,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map): SessionSummary['titleKind'] { + const title = typeof meta['title'] === 'string' ? meta['title'] : undefined; + const titleKind = meta['titleKind']; + if (title !== undefined && meta['isCustomTitle'] === true) return 'custom'; + if ( + title !== undefined && + (titleKind === 'replaceable' || titleKind === 'generated' || titleKind === 'custom') + ) { + return titleKind; + } + if (title !== undefined && meta['isCustomTitle'] === false) return 'replaceable'; + if (typeof meta['customTitle'] === 'string') return 'custom'; + return title === undefined ? undefined : 'replaceable'; } function recoverCwd(meta: Record): string | undefined { @@ -110,10 +119,11 @@ function matchesChildOf(summary: SessionSummary, parentId: string | undefined): * fields the session-summary contract requires; anything else is treated as a * cold miss and rebuilt from disk. */ -function isSessionSummaryShape(value: unknown): value is SessionSummary { +function isSessionSummaryShape(value: unknown): value is CachedSessionSummary { if (value === null || typeof value !== 'object') return false; const summary = value as Record; return ( + summary['v'] === READ_MODEL_SUMMARY_VERSION && typeof summary['id'] === 'string' && typeof summary['workspaceId'] === 'string' && typeof summary['createdAt'] === 'number' && @@ -247,11 +257,17 @@ export class FileSessionIndex implements ISessionIndex { sessionId: string, ): Promise { const cached: unknown = await this.queryStore.get(SESSION_COLLECTION, sessionId); - if (isSessionSummaryShape(cached)) return cached; + if (isSessionSummaryShape(cached)) { + const { v: _version, ...summary } = cached; + return summary; + } const summary = await this.readSummary(workspaceId, sessionId); if (summary !== undefined) { // Also overwrites a cache entry that failed the shape check above. - await this.queryStore.put(SESSION_COLLECTION, sessionId, summary); + await this.queryStore.put(SESSION_COLLECTION, sessionId, { + ...summary, + v: READ_MODEL_SUMMARY_VERSION, + }); } return summary; } @@ -343,7 +359,7 @@ export class FileSessionIndex implements ISessionIndex { workspaceId, cwd: recoverCwd(meta), title: typeof meta['title'] === 'string' ? meta['title'] : undefined, - titleKind: toTitleKind(meta['titleKind'], meta['isCustomTitle']), + titleKind: toTitleKind(meta), lastPrompt: typeof meta['lastPrompt'] === 'string' ? meta['lastPrompt'] : undefined, createdAt: parseTime(meta['createdAt']), updatedAt: parseTime(meta['updatedAt']), diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 5a44f797d3..cf0c62fd15 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -42,6 +42,7 @@ import { Emitter, type Event } from '#/_base/event'; import { ILogService } from '#/_base/log/log'; import { defineState } from '#/_base/state/stateRegistry'; import { IFlagService } from '#/app/flag/flag'; +import { READ_MODEL_SUMMARY_VERSION } from '#/app/sessionIndex/sessionIndex'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IQueryStore } from '#/persistence/interface/queryStore'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -174,6 +175,7 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { // poison the cache entry and fail contract validation on reads. archived: this.data.archived === true, custom: this.data.custom, + v: READ_MODEL_SUMMARY_VERSION, }); } catch (error) { this.log.warn('failed to mirror session metadata to read model', { diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index ba023f11d9..56fc7fd611 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -15,7 +15,11 @@ import { ILogService } from '#/_base/log/log'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IFlagService } from '#/app/flag/flag'; -import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; +import { + ISessionIndex, + READ_MODEL_SUMMARY_VERSION, + type SessionSummary, +} from '#/app/sessionIndex/sessionIndex'; import { FileSessionIndex } from '#/app/sessionIndex/sessionIndexService'; import { MiniDbQueryStore } from '#/persistence/backends/minidb/miniDbQueryStore'; import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; @@ -144,11 +148,13 @@ describe('FileSessionIndex (legacy)', () => { isCustomTitle: true, }); await seedSession('plain', { title: 'plain' }); + await seedSession('legacy-custom', { customTitle: 'legacy title' }); const store = build(); expect((await store.get('generated'))?.titleKind).toBe('generated'); expect((await store.get('stale-mixed'))?.titleKind).toBe('custom'); - expect((await store.get('plain'))?.titleKind).toBeUndefined(); + expect((await store.get('plain'))?.titleKind).toBe('replaceable'); + expect((await store.get('legacy-custom'))?.titleKind).toBe('custom'); }); it('list filters by sessionId without enumerating all sessions', async () => { @@ -314,13 +320,14 @@ describe('FileSessionIndex (read model)', () => { await fsp.writeFile(join(dir, 'state.json'), JSON.stringify(meta)); } - function summary(id: string, overrides: Partial = {}): SessionSummary { + function summary(id: string, overrides: Partial = {}) { return { id, workspaceId, createdAt: 1, updatedAt: 2, archived: false, + v: READ_MODEL_SUMMARY_VERSION, ...overrides, }; } @@ -373,6 +380,29 @@ describe('FileSessionIndex (read model)', () => { expect(cached?.archived).toBe(false); }); + it('treats a cache entry stamped with an older summary version as a cold miss', async () => { + await seedSession('s1', { + title: '用户标题', + titleKind: 'replaceable', + isCustomTitle: true, + createdAt: 1, + updatedAt: 2, + }); + const store = build(); + await queryStore.put(SESSION_COLLECTION, 's1', { + ...summary('s1', { title: '用户标题', titleKind: 'replaceable' }), + v: 1, + }); + + const got = await store.get('s1'); + // The stale-stamped entry is ignored and backfilled from disk, which + // honors the legacy custom marker over the stale titleKind. + expect(got?.titleKind).toBe('custom'); + const cached = await queryStore.get>(SESSION_COLLECTION, 's1'); + expect(cached?.['v']).toBe(READ_MODEL_SUMMARY_VERSION); + expect(cached?.['titleKind']).toBe('custom'); + }); + it('get falls back to disk when the cached entry fails the shape check', async () => { await seedSession('s1', { title: 'on-disk', createdAt: 1, updatedAt: 2 }); const store = build(); From c2d721781d69e806b59290adb532d10d00dfa499 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 15:16:49 +0800 Subject: [PATCH 34/47] fix: let the newest title generation request win the write-back A forced regeneration could be followed on disk by an earlier plain call's slower backend response. Each generation now carries a monotonically increasing sequence (assigned only once a request actually proceeds to generation), and the serialized metadata write is vetoed unless the writer is still the newest request. --- .../sessionTitle/sessionTitleService.ts | 24 ++++++++-------- .../sessionTitle/sessionTitleService.test.ts | 28 +++++++++++++++++-- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index 5f90e014b5..a4cf12c7b4 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -11,13 +11,12 @@ * the user is never overwritten. An already-generated title is not * regenerated unless forced. Plain calls coalesce onto one shared * in-flight generation while a forced regeneration always runs on its - * own; every active generation is drained through the - * `sessionLifecycleHooks` `onWillCloseSession` slot before the scope is - * disposed, and the write-back is dropped once - * this scope's `sessionLifetime` signal fires — the in-flight request - * carries the signal, and the metadata update re-checks it inside the - * serialized write so an abort landing while the update sits queued still - * vetoes the write. Provider config comes + * own; the newest request always wins the write-back, which the + * serialized metadata update drops once the scope's `sessionLifetime` + * signal fires or a newer request supersedes it. Every active generation + * is drained through the `sessionLifecycleHooks` `onWillCloseSession` + * slot before the scope is disposed. + * Provider config comes * from `provider`, the bearer token from `auth`, host identity headers from * `model`, prompt history from `agentLifecycle`/`sessionTitle`, and logs * through `log`. Bound at Session scope. @@ -63,6 +62,7 @@ export class SessionTitleService implements ISessionTitleService { private _shared: Promise | undefined; private readonly _active = new Set>(); + private _generationSeq = 0; constructor( @ISessionContext private readonly ctx: ISessionContext, @@ -85,9 +85,6 @@ export class SessionTitleService implements ISessionTitleService { async generateTitle(options?: { readonly force?: boolean }): Promise { if (this.lifetime.signal.aborted) return undefined; - // Plain calls coalesce onto the shared in-flight slot; a forced - // regeneration always runs on its own so it is neither swallowed by a - // plain call's early exit nor shares its result with one. if (options?.force !== true && this._shared !== undefined) return this._shared; const tracked = this.generateTitleOnce(options).finally(() => { this._active.delete(tracked); @@ -111,10 +108,11 @@ export class SessionTitleService implements ISessionTitleService { : await main.accessor.get(IAgentTitlePromptSource).firstUserPrompts(MAX_TITLE_PROMPTS); const input = titleInputFromPrompts(prompts); if (input === undefined) return undefined; - return this.generateAndApply(input); + const seq = ++this._generationSeq; + return this.generateAndApply(input, seq); } - private async generateAndApply(chatContent: string): Promise { + private async generateAndApply(chatContent: string, seq: number): Promise { const current = await this.metadata.read(); if (current.titleKind === 'custom') return undefined; const provider = this.providers.get(KIMI_CODE_PROVIDER_NAME); @@ -170,7 +168,7 @@ export class SessionTitleService implements ISessionTitleService { const title = result.title.slice(0, MAX_GENERATED_TITLE_LENGTH); const applied = await this.metadata.setGeneratedTitleIfUncustomized( title, - () => !this.lifetime.signal.aborted, + () => seq === this._generationSeq && !this.lifetime.signal.aborted, ); if (!applied) return undefined; this.eventService.publish({ 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 35d789352d..a5fe45d2db 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -583,7 +583,7 @@ describe('SessionTitleService', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); - it('runs a forced regeneration independently of a plain in-flight call', async () => { + it('lets the later forced request win over a slower plain call', async () => { const pendingFetch = createPendingFetch(); fetchMock.mockImplementationOnce(pendingFetch.fetch); titlePrompts = ['hello']; @@ -600,8 +600,32 @@ describe('SessionTitleService', () => { headers: { 'Content-Type': 'application/json' }, }), ); - await expect(plain).resolves.toBe('普通标题'); + // The plain call started earlier, so its slower response is superseded: + // its write-back is vetoed and the forced title stays. + await expect(plain).resolves.toBeUndefined(); expect(fetchMock).toHaveBeenCalledTimes(2); + expect(metadata.meta.title).toBe('生成的标题'); + expect(metadata.meta.titleKind).toBe('generated'); + }); + + it('lets the later plain request win over a slower forced call', async () => { + const pendingFetch = createPendingFetch(); + fetchMock.mockImplementationOnce(pendingFetch.fetch); + titlePrompts = ['hello']; + + const forced = ix.get(ISessionTitleService).generateTitle({ force: true }); + await pendingFetch.started; + const plain = ix.get(ISessionTitleService).generateTitle(); + await expect(plain).resolves.toBe('生成的标题'); + + pendingFetch.resolve( + new Response(JSON.stringify({ title: '强制标题' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + await expect(forced).resolves.toBeUndefined(); + expect(metadata.meta.title).toBe('生成的标题'); }); it('does not share a forced in-flight result with a plain call', async () => { From cd1cea0fd5f24a0688762cd73316478e9ddca025 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 15:16:49 +0800 Subject: [PATCH 35/47] fix: fold archive into close and own the create/fork rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An archive requested during a plain close is applied through the live metadata during the teardown (or lands on the persisted document when it arrives too late or the close fails), publishes the archived event, and works on cold sessions too. A resume waiting on a failed close retries instead of propagating the hook error, the teardown completes even when the agent drain fails, and the create/fork rollback only ever removes its own handle — a loser of the reservation race can no longer tear down the winner's live scope. --- .../workspaceHandlerService.ts | 91 +++++++++----- .../workspaceHandler/workspaceHandler.test.ts | 117 ++++++++++++++++-- 2 files changed, 168 insertions(+), 40 deletions(-) diff --git a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts index 013d2a2497..c21d04785b 100644 --- a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts @@ -12,11 +12,16 @@ * is tracked in a closing registry from that first synchronous step until * the scope is disposed: `get`/`list` hide the session while it is * closing, `resume` waits the close out and re-materializes instead of - * returning the doomed handle, and a failing close hook never strands the - * teardown. A duplicate close joins the in-flight one, while an archive - * arriving during a plain close waits it out and then lands the archived - * flag directly on the persisted document (it never rides the close to - * success without archiving), + * returning the doomed handle (retrying the same way when the close + * itself failed), and the teardown always completes even when a close + * hook or the agent drain fails. An archive requested during a plain + * close is folded into it: the live metadata applies the archived flag + * before the teardown when reached in time, otherwise the flag lands + * directly on the persisted document (a cold archive takes that direct + * path as well), and the archived event is published either way. Create + * and fork reserve their target id synchronously with the existence + * check, so a loser of that race is rejected before it materializes and + * its rollback can only ever tear down its own scope, * tearing sessions down on close/archive — archiving flags the session's * `sessionMetadata`, removes its `agentLifecycle` agents, restoring clears * the archived flag, and broadcasts through `event`; session start and @@ -185,6 +190,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan private readonly closing = new Map(); private readonly sessionLifetimes = new Map(); private readonly reservedTargets = new Set(); + private readonly pendingArchive = new Set(); constructor( @IInstantiationService private readonly instantiation: IInstantiationService, @@ -267,8 +273,10 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan } catch (error) { // Roll back ONLY the session directory — the handler stays live. const sessionDir = handle.accessor.get(ISessionContext).sessionDir; - this.sessions.delete(sessionId); - this.invalidateSessionLifetime(sessionId); + if (this.sessions.get(sessionId) === handle) { + this.sessions.delete(sessionId); + this.invalidateSessionLifetime(sessionId); + } await this.drainAgents(handle).catch(() => {}); handle.dispose(); await this.hostFs.remove(sessionDir).catch(() => {}); @@ -402,9 +410,10 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan if (inflight !== undefined) return inflight; const closing = this.closing.get(sessionId); if (closing !== undefined) { - // A close is already in flight — wait it out and re-materialize - // instead of handing back the doomed handle. - return closing.run.then(() => this.resume(sessionId, opts)); + return closing.run.then( + () => this.resume(sessionId, opts), + () => this.resume(sessionId, opts), + ); } const live = this.sessions.get(sessionId); if (live !== undefined) return Promise.resolve(live); @@ -468,10 +477,24 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan try { await this.announceWillClose({ sessionId, handle, reason: 'exit' }); } finally { - // A failing close hook must not strand the session half-open: the - // teardown always completes; the hook error still reaches the caller. + if (this.pendingArchive.has(sessionId)) { + const archived = await handle.accessor + .get(ISessionMetadata) + .setArchived(true) + .then( + () => true, + () => false, + ); + if (archived) { + this.pendingArchive.delete(sessionId); + this.event.publish({ + type: 'event.session.archived', + payload: { sessionId }, + }); + } + } this.sessions.delete(sessionId); - await this.drainAgents(handle); + await this.drainAgents(handle).catch(() => {}); handle.dispose(); this._onDidCloseSession.fire({ sessionId }); } @@ -481,26 +504,30 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan const inflight = this.closing.get(sessionId); if (inflight !== undefined) { if (inflight.kind === 'archive') return inflight.run; - // A plain close is already tearing the session down — the archive - // must not ride it to success. Wait it out (tracked, so a resume - // queues behind the marker write), then land the archived flag - // directly on the persisted document of the now-closed session. - return this.trackClosing( - sessionId, - 'archive', - inflight.run.then(() => this.markArchivedOnDisk(sessionId)), + this.pendingArchive.add(sessionId); + return inflight.run.then( + () => this.consumePendingArchive(sessionId), + () => this.consumePendingArchive(sessionId), ); } const handle = this.sessions.get(sessionId); - if (handle === undefined) return; + if (handle === undefined) { + await this.markArchivedOnDisk(sessionId); + return; + } return this.trackClosing(sessionId, 'archive', this.doArchive(sessionId, handle)); } + private async consumePendingArchive(sessionId: string): Promise { + if (this.pendingArchive.delete(sessionId)) { + await this.markArchivedOnDisk(sessionId); + } + } + private async doArchive(sessionId: string, handle: ISessionScopeHandle): Promise { this.invalidateSessionLifetime(sessionId); const meta = handle.accessor.get(ISessionMetadata); await meta.setArchived(true); - await this.drainAgents(handle); this.event.publish({ type: 'event.session.archived', payload: { sessionId }, @@ -509,6 +536,7 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan await this.announceWillClose({ sessionId, handle, reason: 'exit' }); } finally { this.sessions.delete(sessionId); + await this.drainAgents(handle).catch(() => {}); handle.dispose(); this._onDidArchiveSession.fire({ sessionId }); } @@ -519,6 +547,11 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan const meta = await this.docs.get(scope, 'state.json'); if (meta === undefined) return; await this.docs.set(scope, 'state.json', { ...meta, archived: true, updatedAt: Date.now() }); + this.event.publish({ + type: 'event.session.archived', + payload: { sessionId }, + }); + this._onDidArchiveSession.fire({ sessionId }); } private async trackClosing( @@ -564,9 +597,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan async fork(opts: ForkSessionOptions): Promise { const sourceId = opts.sourceSessionId; - // Wait out an in-flight close of the source instead of capturing its - // doomed handle (a close starting mid-fork remains an accepted, - // pre-existing window — fork never quiesces the source). await this.closing.get(sourceId)?.run; const sourceHandle = this.sessions.get(sourceId); const indexSummary = await this.index.get(sourceId); @@ -602,9 +632,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan `Session "${targetId}" already exists`, ); } - // The check and the reservation are one synchronous step: a concurrent - // create/fork of the same target id loses before either materializes, - // so the loser can never tear down the winner's scope or directory. this.reservedTargets.add(targetId); targetReserved = true; if ((await this.index.get(targetId)) !== undefined) { @@ -668,7 +695,11 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan await this.announceCreated({ sessionId: targetId, handle: target, source: 'fork' }); return target; } catch (error) { - if (targetId !== undefined) { + if ( + targetId !== undefined && + target !== undefined && + this.sessions.get(targetId) === target + ) { this.sessions.delete(targetId); this.invalidateSessionLifetime(targetId); } diff --git a/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts b/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts index d7982e558b..a1d58e4cd8 100644 --- a/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts @@ -648,7 +648,45 @@ describe('WorkspaceHandlerService', () => { expect(svc.get('s1')).toBe(fresh); }); - it('archive during a plain close waits it out and lands the archived flag on disk', async () => { + it('archive during a plain close is folded into the close through the live metadata', async () => { + const setArchived = vi.fn(() => Promise.resolve()); + const published: string[] = []; + const svc = await build([ + stubPair(ISessionMetadata, { ...metadataStub(), setArchived }), + stubPair(IEventService, { + ...eventStub(), + publish: (event: { readonly type: string }) => { + published.push(event.type); + }, + } as IEventService), + ]); + const original = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + + let releaseClose!: () => void; + const closeGate = new Promise((resolve) => { + releaseClose = resolve; + }); + original.accessor + .get(ISessionLifecycleHooks) + .onWillCloseSession.register('test-block', async (_event, next) => { + await closeGate; + await next(); + }); + + const closing = svc.close('s1'); + await tick(); + const archived = svc.archive('s1'); + releaseClose(); + await closing; + await archived; + + // The archive rides the close's own teardown through the live metadata + // — it never becomes a detached on-disk write. + expect(setArchived).toHaveBeenCalledWith(true); + expect(published).toContain('event.session.archived'); + }); + + it('archives a cold session by writing the persisted document and emitting the event', async () => { const docs = new Map(); const docStore = { _serviceBrand: undefined, @@ -662,10 +700,34 @@ describe('WorkspaceHandlerService', () => { watch: () => (_listener: unknown) => ({ dispose: () => {} }), acquire: () => ({ dispose: () => {} }), } as unknown as IAtomicDocumentStore; - const svc = await build([stubPair(IAtomicDocumentStore, docStore)]); + const published: string[] = []; + const svc = await build([ + stubPair(IAtomicDocumentStore, docStore), + stubPair(IEventService, { + ...eventStub(), + publish: (event: { readonly type: string }) => { + published.push(event.type); + }, + } as IEventService), + ]); const original = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); const metaKey = `${original.accessor.get(ISessionContext).metaScope}/state.json`; docs.set(metaKey, { id: 's1', version: 2, createdAt: 1, updatedAt: 1, archived: false }); + await svc.close('s1'); + + await svc.archive('s1'); + + expect(docs.get(metaKey)).toMatchObject({ archived: true }); + expect(published).toContain('event.session.archived'); + }); + + it('resume retries and succeeds after a close whose hook failed', async () => { + const svc = await build([ + stubPair(IWorkspaceService, persistentWorkspaceStub()), + stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj')), + stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), + ]); + const original = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); let releaseClose!: () => void; const closeGate = new Promise((resolve) => { @@ -673,21 +735,56 @@ describe('WorkspaceHandlerService', () => { }); original.accessor .get(ISessionLifecycleHooks) - .onWillCloseSession.register('test-block', async (_event, next) => { + .onWillCloseSession.register('test-boom', async (_event, next) => { await closeGate; await next(); + throw new Error('hook boom'); }); const closing = svc.close('s1'); - await tick(); - const archived = svc.archive('s1'); + const resumed = svc.resume('s1'); releaseClose(); - await closing; - await archived; - // The archive must not ride the close: the marker lands on the persisted - // document after the close completes. - expect(docs.get(metaKey)).toMatchObject({ archived: true }); + await expect(closing).rejects.toThrow('hook boom'); + // The resume waited on the failed close and retried into a cold restore. + await expect(resumed).resolves.toBeDefined(); + expect(svc.get('s1')).toBe(await resumed); + }); + + it('completes the teardown even when the agent drain fails', async () => { + const stuckAgent = { + id: 'a1', + kind: LifecycleScope.Agent, + accessor: { + get: () => { + throw new Error('unexpected service access'); + }, + }, + dispose: () => {}, + } as unknown as IAgentScopeHandle; + const svc = await build([ + stubPair(IAgentLifecycleService, { + ...agentLifecycleStub(), + list: () => [stuckAgent], + remove: () => Promise.reject(new Error('drain boom')), + }), + ]); + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + + await svc.close('s1'); + expect(svc.get('s1')).toBeUndefined(); + }); + + it('a rejected same-target fork leaves the earlier fork handle live', async () => { + const svc = await build(); + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + const first = await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }); + + await expect(svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' })).rejects.toMatchObject({ + code: ErrorCodes.SESSION_ALREADY_EXISTS, + }); + + expect(svc.get('dst')).toBe(first); }); it('still completes the teardown when a close hook fails', async () => { From 7292ccc018124c85792039715f4f143e2b2daf53 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 15:16:49 +0800 Subject: [PATCH 36/47] fix: key harness resume coalescing by the full input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concurrent resumes only share a facade when their inputs match — a caller passing different dirs, replay, profile, or kaos options gets its own resume instead of having its options silently dropped. --- packages/node-sdk/src/kimi-harness.ts | 27 ++++++++++++++----- .../node-sdk/test/sdk-rpc-client-v2.test.ts | 22 +++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 7c6d9a1214..00ce84892e 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -153,18 +153,21 @@ export class KimiHarness { return active; } - // Coalesce concurrent resumes of the same id onto one facade; without - // this, parallel callers each build their own Session over the shared - // engine handle, and one facade's close kills the engine handle under - // the other. - const inflight = this.resumeInflight.get(id); + // Coalesce concurrent resumes of the same id onto one facade, keyed by + // the full input so a caller with different options (dirs, replay, + // profile, kaos) never has them silently dropped; without this, + // parallel identical callers each build their own Session over the + // shared engine handle, and one facade's close kills the engine handle + // under the other. + const key = resumeCoalesceKey(id, input); + const inflight = this.resumeInflight.get(key); if (inflight !== undefined) return inflight; const run = this.doResumeSession(input, id); - this.resumeInflight.set(id, run); + this.resumeInflight.set(key, run); try { return await run; } finally { - if (this.resumeInflight.get(id) === run) this.resumeInflight.delete(id); + if (this.resumeInflight.get(key) === run) this.resumeInflight.delete(key); } } @@ -404,6 +407,16 @@ export class KimiHarness { const DEFAULT_SESSION_STARTED_UI_MODE = 'shell'; +function resumeCoalesceKey(id: string, input: ResumeSessionInput): string { + const { kaos, persistenceKaos, ...rest } = input; + return JSON.stringify({ + ...rest, + id, + kaos: kaos !== undefined, + persistenceKaos: persistenceKaos !== undefined, + }); +} + function normalizeSessionId(value: string): string { if (typeof value !== 'string') { throw new KimiError(ErrorCodes.SESSION_ID_REQUIRED, 'Session id is required.'); diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index d8a934a27b..d80a9d59bb 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -395,6 +395,28 @@ key = "${titleOAuthRef.key}" } }); + it('does not coalesce resumes with different options onto one facade', async () => { + const { harness } = await makeHarness(); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + + try { + const session = await harness.createSession({ id: 'ses_no_coalesce', workDir }); + await session.close(); + + const [plain, withReplay] = await Promise.all([ + harness.resumeSession({ id: 'ses_no_coalesce' }), + harness.resumeSession({ id: 'ses_no_coalesce', replayTurnLimit: 3 }), + ]); + + // Different options must not be silently dropped onto the first + // caller's facade — each gets its own resume. + expect(plain).not.toBe(withReplay); + } finally { + await harness.close(); + } + }); + it('reports the title state in listSessions as well as in the resumed summary', async () => { const { harness } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); From 1a237972264a02975f8d596d8580e624dbd9c3db Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 15:28:05 +0800 Subject: [PATCH 37/47] refactor(agent-core-v2): drop session close-awareness from title generation Auto title is best-effort: a generation racing session close no longer cancels its fetch or guards its write-back, so the per-session sessionLifetime AbortSignal seed, the onWillCloseSession drain, and the close-time invalidation go away. The newest-request-wins write-back predicate stays. --- .../scripts/check-domain-layers.mjs | 5 - packages/agent-core-v2/src/index.ts | 1 - .../sessionLifetime/sessionLifetime.ts | 28 ---- .../sessionTitle/sessionTitleService.ts | 32 +---- .../workspaceHandlerService.ts | 25 +--- packages/agent-core-v2/test/harness/agent.ts | 5 - .../sessionTitle/sessionTitleService.test.ts | 125 ------------------ 7 files changed, 7 insertions(+), 214 deletions(-) delete mode 100644 packages/agent-core-v2/src/session/sessionLifetime/sessionLifetime.ts diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 12a6909d96..c66ae8b4a4 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -91,11 +91,6 @@ const DOMAIN_LAYER = new Map([ // create-source/close-reason vocabulary; a pure contract with no IO, so it // sits in L1 beside `sessionContext`. ['sessionLifecycleHooks', 1], - // `sessionLifetime` is the per-session liveness seed (`AbortSignal` - // aborted by the Workspace-scope `workspaceHandler` when the session's - // close begins); a pure contract with no IO, so it sits in L1 beside - // `sessionLifecycleHooks`. - ['sessionLifetime', 1], // `scopeContext` is the Agent-scope seeded immutable facts value // (`agentId` plus a persistence scope helper); a pure seed with no IO, so it // sits in L1 beside `sessionContext`. diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 3e1591a2ab..8746eebad3 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -366,7 +366,6 @@ export * from '#/workspace/workspaceHandler/workspaceHandler'; export * from '#/workspace/workspaceHandler/workspaceHandlerService'; export * from '#/workspace/workspaceHandler/addressing'; export * from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; -export * from '#/session/sessionLifetime/sessionLifetime'; export * from '#/session/externalHooks/externalHooks'; export * from '#/session/externalHooks/externalHooksService'; import '#/app/sessionExport/errors'; diff --git a/packages/agent-core-v2/src/session/sessionLifetime/sessionLifetime.ts b/packages/agent-core-v2/src/session/sessionLifetime/sessionLifetime.ts deleted file mode 100644 index b209420b29..0000000000 --- a/packages/agent-core-v2/src/session/sessionLifetime/sessionLifetime.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * `sessionLifetime` domain (L1) — per-session liveness seed. - * - * Defines the `ISessionLifetime` carrying the session scope's liveness - * `AbortSignal`: the Workspace-scope `workspaceHandler` creates one - * `AbortController` per materialized session, seeds the signal into the - * Session scope, and aborts it synchronously when the session's close - * begins — before any async `onWillCloseSession` hook runs. Session-scope - * consumers with in-flight async work (e.g. `sessionTitle`) treat an - * aborted signal as "this scope is being torn down": cancel the work and - * drop its write-back. Pure contract — no IO, no store. Session-scoped. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { ScopeSeed } from '#/_base/di/scope'; - -export interface ISessionLifetime { - readonly _serviceBrand: undefined; - - readonly signal: AbortSignal; -} - -export const ISessionLifetime: ServiceIdentifier = - createDecorator('sessionLifetime'); - -export function sessionLifetimeSeed(lifetime: ISessionLifetime): ScopeSeed { - return [[ISessionLifetime as ServiceIdentifier, lifetime]]; -} diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index a4cf12c7b4..d443cb7f5e 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -11,11 +11,7 @@ * the user is never overwritten. An already-generated title is not * regenerated unless forced. Plain calls coalesce onto one shared * in-flight generation while a forced regeneration always runs on its - * own; the newest request always wins the write-back, which the - * serialized metadata update drops once the scope's `sessionLifetime` - * signal fires or a newer request supersedes it. Every active generation - * is drained through the `sessionLifecycleHooks` `onWillCloseSession` - * slot before the scope is disposed. + * own; the newest request always wins the write-back. * Provider config comes * from `provider`, the bearer token from `auth`, host identity headers from * `model`, prompt history from `agentLifecycle`/`sessionTitle`, and logs @@ -35,17 +31,11 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/ import { ILogService } from '#/_base/log/log'; import { IOAuthService } from '#/app/auth/auth'; import { IEventService } from '#/app/event/event'; -import type { Hooks } from '#/hooks'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; 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 { - ISessionLifecycleHooks, - type SessionLifecycleHookSlots, -} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; -import { ISessionLifetime } from '#/session/sessionLifetime/sessionLifetime'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { IAgentTitlePromptSource } from './agentTitlePromptSource'; @@ -61,36 +51,24 @@ export class SessionTitleService implements ISessionTitleService { declare readonly _serviceBrand: undefined; private _shared: Promise | undefined; - private readonly _active = new Set>(); private _generationSeq = 0; constructor( @ISessionContext private readonly ctx: ISessionContext, @ISessionMetadata private readonly metadata: ISessionMetadata, - @ISessionLifetime private readonly lifetime: ISessionLifetime, - @ISessionLifecycleHooks - lifecycleHooks: Hooks, @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, @IEventService private readonly eventService: IEventService, @IProviderService private readonly providers: IProviderService, @IOAuthService private readonly oauth: IOAuthService, @IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders, @ILogService private readonly log: ILogService, - ) { - lifecycleHooks.onWillCloseSession.register('sessionTitle', async (_event, next) => { - await Promise.allSettled([...this._active]); - await next(); - }); - } + ) {} async generateTitle(options?: { readonly force?: boolean }): Promise { - if (this.lifetime.signal.aborted) return undefined; if (options?.force !== true && this._shared !== undefined) return this._shared; const tracked = this.generateTitleOnce(options).finally(() => { - this._active.delete(tracked); if (this._shared === tracked) this._shared = undefined; }); - this._active.add(tracked); if (options?.force !== true) this._shared = tracked; return tracked; } @@ -147,10 +125,9 @@ export class SessionTitleService implements ISessionTitleService { ...this.hostHeaders.headers, ...provider.customHeaders, }, - signal: this.lifetime.signal, }); let result = await requestTitle(token); - if (result.kind === 'error' && result.status === 401 && !this.lifetime.signal.aborted) { + if (result.kind === 'error' && result.status === 401) { try { token = await tokenProvider.getAccessToken({ force: true }); } catch (error) { @@ -164,11 +141,10 @@ export class SessionTitleService implements ISessionTitleService { this.log.debug(`chat_title request failed: ${result.message}`); return undefined; } - if (this.lifetime.signal.aborted) return undefined; const title = result.title.slice(0, MAX_GENERATED_TITLE_LENGTH); const applied = await this.metadata.setGeneratedTitleIfUncustomized( title, - () => seq === this._generationSeq && !this.lifetime.signal.aborted, + () => seq === this._generationSeq, ); if (!applied) return undefined; this.eventService.publish({ diff --git a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts index c21d04785b..2062917c34 100644 --- a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts @@ -4,11 +4,9 @@ * Owns the registry of THIS handler's open Session child scopes, creating * them through the DI scope tree (children of the handler's Workspace * scope) and seeding each with its identity, storage addressing derived - * from the handler's `workspaceContext.persistenceScope`, a per-session - * `sessionLifecycleHooks` slots instance it runs around create/close, and - * a per-session `sessionLifetime` abort signal it fires synchronously when - * a close/archive begins — before any async close hook — so in-flight - * session work can cancel itself and drop its write-back. A close/archive + * from the handler's `workspaceContext.persistenceScope`, and a + * per-session `sessionLifecycleHooks` slots instance it runs around + * create/close. A close/archive * is tracked in a closing registry from that first synchronous step until * the scope is disposed: `get`/`list` hide the session while it is * closing, `resume` waits the close out and re-materializes instead of @@ -122,7 +120,6 @@ import { sessionLifecycleHooksSeed, type SessionLifecycleHookSlots, } from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; -import { sessionLifetimeSeed } from '#/session/sessionLifetime/sessionLifetime'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionProcessRunner } from '#/session/process/processRunner'; import { sessionSkillCatalogDataSeed } from '#/session/sessionSkillCatalog/skillCatalogData'; @@ -188,7 +185,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan readonly onDidForkSession: Event = this._onDidForkSession.event; private readonly resuming = new Map>(); private readonly closing = new Map(); - private readonly sessionLifetimes = new Map(); private readonly reservedTargets = new Set(); private readonly pendingArchive = new Set(); @@ -275,7 +271,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan const sessionDir = handle.accessor.get(ISessionContext).sessionDir; if (this.sessions.get(sessionId) === handle) { this.sessions.delete(sessionId); - this.invalidateSessionLifetime(sessionId); } await this.drainAgents(handle).catch(() => {}); handle.dispose(); @@ -313,8 +308,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan 'onDidCreateSession', 'onWillCloseSession', ]); - const lifetime = new AbortController(); - this.sessionLifetimes.set(opts.sessionId, lifetime); await this.hostEnv.ready; const handle = createScopedChildHandle( this.instantiation, @@ -324,7 +317,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan extra: [ ...sessionContextSeed(ctx), ...sessionLifecycleHooksSeed(hooks), - ...sessionLifetimeSeed({ _serviceBrand: undefined, signal: lifetime.signal }), [ITelemetryService, this.telemetry.withContext({ sessionId: opts.sessionId })], // Workspace resource seeds (the §3.5 injection contracts): the // handler's shared skill / agent-profile catalogs, AGENTS.md @@ -359,7 +351,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan ]); await this.mcp.ready; } catch (error) { - this.invalidateSessionLifetime(opts.sessionId); handle.dispose(); // Re-arm the explicit agent-profile loader after a fatal rejection: // its `ready` tracks the latest load pass, so without a reload the @@ -473,7 +464,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan } private async doClose(sessionId: string, handle: ISessionScopeHandle): Promise { - this.invalidateSessionLifetime(sessionId); try { await this.announceWillClose({ sessionId, handle, reason: 'exit' }); } finally { @@ -525,7 +515,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan } private async doArchive(sessionId: string, handle: ISessionScopeHandle): Promise { - this.invalidateSessionLifetime(sessionId); const meta = handle.accessor.get(ISessionMetadata); await meta.setArchived(true); this.event.publish({ @@ -580,13 +569,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan .onWillCloseSession.run({ reason: event.reason }); } - private invalidateSessionLifetime(sessionId: string): void { - const lifetime = this.sessionLifetimes.get(sessionId); - if (lifetime === undefined) return; - this.sessionLifetimes.delete(sessionId); - lifetime.abort(); - } - private async drainAgents(handle: ISessionScopeHandle): Promise { const agentLifecycle = handle.accessor.get(IAgentLifecycleService); for (const agent of agentLifecycle.list()) { @@ -701,7 +683,6 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan this.sessions.get(targetId) === target ) { this.sessions.delete(targetId); - this.invalidateSessionLifetime(targetId); } if (target !== undefined) { try { diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index dc364a6b68..1faa14ee23 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -153,7 +153,6 @@ import { ISessionLifecycleHooks, type SessionLifecycleHookSlots, } from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; -import { ISessionLifetime } from '#/session/sessionLifetime/sessionLifetime'; import { IEventBus } from '#/app/event/eventBus'; import { IWireService } from '#/wire/wire'; import { WireService } from '#/wire/wireService'; @@ -1178,10 +1177,6 @@ export class AgentTestContext { 'onWillCloseSession', ]), ); - reg.defineInstance(ISessionLifetime, { - _serviceBrand: undefined, - signal: new AbortController().signal, - }); reg.defineInstance(ISessionInteractionService, this.createInteractionService()); reg.defineInstance(ISessionApprovalService, this.createApprovalService()); reg.defineInstance(ISessionQuestionService, this.createQuestionService()); 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 a5fe45d2db..1edee7b8c4 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -20,7 +20,6 @@ import { createServices, type TestInstantiationService } from '#/_base/di/test'; import { Emitter } from '#/_base/event'; import { IOAuthService } from '#/app/auth/auth'; import { type DomainEvent, IEventService } from '#/app/event/event'; -import { createHooks, type Hooks } from '#/hooks'; import { HostRequestHeaders, IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; import { IProviderService, @@ -32,11 +31,6 @@ import { IAgentLifecycleService, MAIN_AGENT_ID, } from '#/session/agentLifecycle/agentLifecycle'; -import { - ISessionLifecycleHooks, - type SessionLifecycleHookSlots, -} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; -import { ISessionLifetime } from '#/session/sessionLifetime/sessionLifetime'; import { IAgentTitlePromptSource, } from '#/session/sessionTitle/agentTitlePromptSource'; @@ -153,8 +147,6 @@ describe('SessionTitleService', () => { let resolvedOAuthRefs: Array; let titlePrompts: readonly string[]; let promptSourceImpl: (limit: number) => Promise; - let lifetimeController: AbortController; - let lifecycleHooks: Hooks; let tokenCalls: boolean[]; beforeEach(() => { @@ -166,11 +158,6 @@ describe('SessionTitleService', () => { tokenCalls = []; providers = { 'managed:kimi-code': MANAGED_PROVIDER }; metadata = new FakeSessionMetadata(); - lifetimeController = new AbortController(); - lifecycleHooks = createHooks([ - 'onDidCreateSession', - 'onWillCloseSession', - ]); events = new FakeEventService(); fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise>( async () => @@ -210,11 +197,6 @@ describe('SessionTitleService', () => { get: () => mainAgent, }); reg.defineInstance(IEventService, events); - reg.defineInstance(ISessionLifetime, { - _serviceBrand: undefined, - signal: lifetimeController.signal, - }); - reg.defineInstance(ISessionLifecycleHooks, lifecycleHooks); reg.defineInstance(IProviderService, stubProviderService(providers)); reg.definePartialInstance(IOAuthService, { resolveTokenProvider: (_provider, oauthRef) => { @@ -361,113 +343,6 @@ describe('SessionTitleService', () => { expect(metadata.meta.title).toBe('user 取的标题'); }); - it('drops the write-back when the lifetime signal aborts after the response arrived', async () => { - const pendingFetch = createPendingFetch(); - fetchMock.mockImplementationOnce(pendingFetch.fetch); - const applySpy = vi.spyOn(metadata, 'setGeneratedTitleIfUncustomized'); - titlePrompts = ['hello']; - - const generation = ix.get(ISessionTitleService).generateTitle(); - await pendingFetch.started; - lifetimeController.abort(); - pendingFetch.resolve( - new Response(JSON.stringify({ title: '生成的标题' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - - await expect(generation).resolves.toBeUndefined(); - expect(applySpy).not.toHaveBeenCalled(); - expect(metadata.meta.title).toBeUndefined(); - }); - - it('cancels the in-flight request when the lifetime signal aborts', async () => { - fetchMock.mockImplementationOnce( - async (_url, init) => - new Promise((_resolve, reject) => { - init?.signal?.addEventListener('abort', () => { - reject(new DOMException('The operation was aborted.', 'AbortError')); - }); - }), - ); - const applySpy = vi.spyOn(metadata, 'setGeneratedTitleIfUncustomized'); - titlePrompts = ['hello']; - - const generation = ix.get(ISessionTitleService).generateTitle(); - await vi.waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - lifetimeController.abort(); - - await expect(generation).resolves.toBeUndefined(); - expect(applySpy).not.toHaveBeenCalled(); - expect(metadata.meta.title).toBeUndefined(); - }); - - it('returns unavailable without fetching when the scope is already closing', async () => { - lifetimeController.abort(); - titlePrompts = ['hello']; - - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('drains an in-flight generation through onWillCloseSession', async () => { - const pendingFetch = createPendingFetch(); - fetchMock.mockImplementationOnce(pendingFetch.fetch); - titlePrompts = ['hello']; - - const generation = ix.get(ISessionTitleService).generateTitle(); - await pendingFetch.started; - - let reachedTerminal = false; - const closeRun = lifecycleHooks.onWillCloseSession - .run({ reason: 'exit' }, async () => { - reachedTerminal = true; - }) - .then(() => reachedTerminal); - // The hook chain must be parked on the pending generation, microtask- - // deterministically: the terminal callback cannot run while the fetch - // is unresolved. - await Promise.resolve(); - await Promise.resolve(); - expect(reachedTerminal).toBe(false); - - pendingFetch.resolve( - new Response(JSON.stringify({ title: '生成的标题' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - await expect(closeRun).resolves.toBe(true); - await expect(generation).resolves.toBe('生成的标题'); - expect(metadata.meta.title).toBe('生成的标题'); - }); - - it('drains a generation that is still collecting prompts through onWillCloseSession', async () => { - let resolvePrompts!: (prompts: readonly string[]) => void; - promptSourceImpl = async (limit) => - new Promise((resolve) => { - resolvePrompts = (prompts) => resolve(prompts.slice(0, limit)); - }); - - const generation = ix.get(ISessionTitleService).generateTitle(); - let reachedTerminal = false; - const closeRun = lifecycleHooks.onWillCloseSession - .run({ reason: 'exit' }, async () => { - reachedTerminal = true; - }) - .then(() => reachedTerminal); - await Promise.resolve(); - await Promise.resolve(); - expect(reachedTerminal).toBe(false); - - resolvePrompts(['hello']); - await expect(closeRun).resolves.toBe(true); - await expect(generation).resolves.toBe('生成的标题'); - }); - it('keeps the current title when the backend request fails', async () => { fetchMock.mockImplementationOnce(async () => new Response('', { status: 500 })); titlePrompts = ['hello']; From 7972c82227b6829dec4b2f571d6b373722f29f33 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Fri, 31 Jul 2026 15:28:59 +0800 Subject: [PATCH 38/47] Delete .changeset/sdk-session-title-kind.md Signed-off-by: 7Sageer --- .changeset/sdk-session-title-kind.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/sdk-session-title-kind.md diff --git a/.changeset/sdk-session-title-kind.md b/.changeset/sdk-session-title-kind.md deleted file mode 100644 index 8e44917e83..0000000000 --- a/.changeset/sdk-session-title-kind.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code-sdk": patch ---- - -Report the session's title state (replaceable, generated, or custom) in session summaries. From e11da34956f2c59fabfaa6ed624642f65f4ca6f7 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 15:36:41 +0800 Subject: [PATCH 39/47] refactor(session-title): drop the unused force regeneration path Nothing calls force: with it gone, plain calls always coalesce onto the shared in-flight generation, so the generation sequence and the caller-supplied allowWhen veto lose their only purpose and go with it. The title/generate REST route takes no body anymore. --- .../sessionMetadata/sessionMetadata.ts | 9 +- .../sessionMetadata/sessionMetadataService.ts | 13 ++- .../src/session/sessionTitle/sessionTitle.ts | 10 +- .../sessionTitle/sessionTitleService.ts | 30 +++--- .../sessionMetadata/sessionMetadata.test.ts | 40 -------- .../sessionTitle/sessionTitleService.test.ts | 92 +------------------ .../kap-server/src/protocol/rest-session.ts | 8 -- packages/kap-server/src/routes/sessions.ts | 6 +- packages/klient/src/contract/session/title.ts | 4 +- packages/klient/src/core/facade/session.ts | 7 +- packages/klient/test/contract-parity.ts | 9 +- packages/node-sdk/src/sdk-rpc-client-v2.ts | 4 +- packages/node-sdk/src/types.ts | 1 - 13 files changed, 34 insertions(+), 199 deletions(-) diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts index aaff7b6444..ed959d2263 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts @@ -57,12 +57,11 @@ export interface ISessionMetadata { update(patch: SessionMetaPatch): Promise; setTitle(title: string): Promise; /** - * Applies a generated title unless the user customized theirs. `allowWhen` - * is re-evaluated inside the serialized update, right before the write — - * a `false` there drops the write-back even if the update sat queued while - * the condition flipped (e.g. the session's close began). + * Applies a generated title unless the user customized theirs; the title + * kind is re-checked inside the serialized update, right before the write, + * so a custom title set while a generation was in flight still wins. */ - setGeneratedTitleIfUncustomized(title: string, allowWhen?: () => boolean): 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 cf0c62fd15..8bf2a783b2 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -19,9 +19,9 @@ * so the two can disagree) while a `false` marker never downgrades a * modern generated/custom state. The generated-title write path * (`setGeneratedTitleIfUncustomized`) serializes through the same update - * queue as everything else and re-evaluates its caller-supplied veto - * (`allowWhen`) inside the queued write, so a session close landing while - * the update waits still drops the write-back. + * queue as everything else and re-checks the title kind inside the queued + * write, so a custom title set while a generation was in flight is never + * overwritten. * Re-registering an agent whose metadata is unchanged is * a no-op (no write, no mirror, no event), so resuming a session — which * re-registers its agents as they materialize — never bumps `updatedAt` and @@ -110,9 +110,8 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { await this.enqueueUpdate(() => this.applyUpdate(patch)); } - private async applyUpdate(patch: SessionMetaPatch, allowWhen?: () => boolean): Promise { + private async applyUpdate(patch: SessionMetaPatch): Promise { await this.ready; - if (allowWhen?.() === false) return false; this.data = { ...this.data, ...patch, updatedAt: Date.now() }; await this.store.set(this.scope, META_KEY, encodeSessionMeta(this.data)); await this.mirrorToReadModel(); @@ -126,11 +125,11 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { await this.update({ title, titleKind: 'custom' }); } - async setGeneratedTitleIfUncustomized(title: string, allowWhen?: () => boolean): Promise { + async setGeneratedTitleIfUncustomized(title: string): Promise { return this.enqueueUpdate(async () => { await this.ready; if (this.data.titleKind === 'custom') return false; - return this.applyUpdate({ title, titleKind: 'generated' }, allowWhen); + return this.applyUpdate({ title, titleKind: 'generated' }); }); } diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts index b3f243dd0a..78ac524492 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts @@ -1,10 +1,10 @@ /** * `sessionTitle` domain (L6) — session title generation contract. * - * Defines the Session-scoped `ISessionTitleService` that (re)generates a - * session title from the main Agent's conversation history. A title that was - * already generated is not regenerated unless `force` is set; a custom title - * is never overwritten. + * Defines the Session-scoped `ISessionTitleService` that generates a + * session title from the main Agent's conversation history. An + * already-generated title is not regenerated; a custom title is never + * overwritten. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -12,7 +12,7 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio export interface ISessionTitleService { readonly _serviceBrand: undefined; - generateTitle(options?: { readonly force?: boolean }): Promise; + generateTitle(): Promise; } export const ISessionTitleService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index d443cb7f5e..7084824df8 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -9,9 +9,8 @@ * (the kap-server route), gated by a managed Kimi Code OAuth login; any * failure degrades to keeping the current title, and a custom title set by * the user is never overwritten. An already-generated title is not - * regenerated unless forced. Plain calls coalesce onto one shared - * in-flight generation while a forced regeneration always runs on its - * own; the newest request always wins the write-back. + * regenerated. Concurrent calls coalesce onto one shared in-flight + * generation. * Provider config comes * from `provider`, the bearer token from `auth`, host identity headers from * `model`, prompt history from `agentLifecycle`/`sessionTitle`, and logs @@ -51,7 +50,6 @@ export class SessionTitleService implements ISessionTitleService { declare readonly _serviceBrand: undefined; private _shared: Promise | undefined; - private _generationSeq = 0; constructor( @ISessionContext private readonly ctx: ISessionContext, @@ -64,21 +62,19 @@ export class SessionTitleService implements ISessionTitleService { @ILogService private readonly log: ILogService, ) {} - async generateTitle(options?: { readonly force?: boolean }): Promise { - if (options?.force !== true && this._shared !== undefined) return this._shared; - const tracked = this.generateTitleOnce(options).finally(() => { + async generateTitle(): Promise { + if (this._shared !== undefined) return this._shared; + const tracked = this.generateTitleOnce().finally(() => { if (this._shared === tracked) this._shared = undefined; }); - if (options?.force !== true) this._shared = tracked; + this._shared = tracked; return tracked; } - private async generateTitleOnce(options?: { - readonly force?: boolean; - }): Promise { + private async generateTitleOnce(): Promise { const current = await this.metadata.read(); if (current.titleKind === 'custom') return undefined; - if (current.titleKind === 'generated' && options?.force !== true) return undefined; + if (current.titleKind === 'generated') return undefined; const main = this.agentLifecycle.get(MAIN_AGENT_ID); const prompts = main === undefined @@ -86,11 +82,10 @@ export class SessionTitleService implements ISessionTitleService { : await main.accessor.get(IAgentTitlePromptSource).firstUserPrompts(MAX_TITLE_PROMPTS); const input = titleInputFromPrompts(prompts); if (input === undefined) return undefined; - const seq = ++this._generationSeq; - return this.generateAndApply(input, seq); + return this.generateAndApply(input); } - private async generateAndApply(chatContent: string, seq: number): Promise { + private async generateAndApply(chatContent: string): Promise { const current = await this.metadata.read(); if (current.titleKind === 'custom') return undefined; const provider = this.providers.get(KIMI_CODE_PROVIDER_NAME); @@ -142,10 +137,7 @@ export class SessionTitleService implements ISessionTitleService { return undefined; } const title = result.title.slice(0, MAX_GENERATED_TITLE_LENGTH); - const applied = await this.metadata.setGeneratedTitleIfUncustomized( - title, - () => seq === this._generationSeq, - ); + const applied = await this.metadata.setGeneratedTitleIfUncustomized(title); if (!applied) return undefined; this.eventService.publish({ type: 'session.meta.updated', 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 7d5c079c4d..c2eee6b8f2 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -426,46 +426,6 @@ describe('SessionMetadata', () => { }); }); - it('vetoes a queued generated title when the gate flips before the write runs', async () => { - // The lifetime-abort window behind `allowWhen`: the generated-title - // update sits in the metadata queue behind a blocked write, the - // session's close fires before the update executes, and the queued - // write must be dropped rather than land after the abort. - 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; - let allowed = true; - const generated = meta.setGeneratedTitleIfUncustomized('generated title', () => allowed); - allowed = false; - releaseWrite?.(); - - await priorWrite; - await expect(generated).resolves.toBe(false); - expect((await meta.read()).title).toBeUndefined(); - expect((await meta.read()).titleKind).toBeUndefined(); - }); - 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 1edee7b8c4..67fd7e4e5f 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -100,9 +100,8 @@ class FakeSessionMetadata implements ISessionMetadata { return this.update({ title, titleKind: 'custom' }); } - async setGeneratedTitleIfUncustomized(title: string, allowWhen?: () => boolean): Promise { + async setGeneratedTitleIfUncustomized(title: string): Promise { if (this.meta.titleKind === 'custom') return false; - if (allowWhen?.() === false) return false; await this.update({ title, titleKind: 'generated' }); return true; } @@ -321,28 +320,6 @@ describe('SessionTitleService', () => { expect(metadata.meta.title).toBe('已生成的标题'); }); - it('regenerates a generated title when forced', async () => { - await metadata.setGeneratedTitleIfUncustomized('已生成的标题'); - titlePrompts = ['hello']; - - await expect(ix.get(ISessionTitleService).generateTitle({ force: true })).resolves.toBe( - '生成的标题', - ); - expect(metadata.meta.title).toBe('生成的标题'); - expect(metadata.meta.titleKind).toBe('generated'); - }); - - it('never overwrites a custom title even when forced', async () => { - await metadata.setTitle('user 取的标题'); - titlePrompts = ['hello']; - - await expect( - ix.get(ISessionTitleService).generateTitle({ force: true }), - ).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); - expect(metadata.meta.title).toBe('user 取的标题'); - }); - it('keeps the current title when the backend request fails', async () => { fetchMock.mockImplementationOnce(async () => new Response('', { status: 500 })); titlePrompts = ['hello']; @@ -458,73 +435,6 @@ describe('SessionTitleService', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); - it('lets the later forced request win over a slower plain call', async () => { - const pendingFetch = createPendingFetch(); - fetchMock.mockImplementationOnce(pendingFetch.fetch); - titlePrompts = ['hello']; - - const plain = ix.get(ISessionTitleService).generateTitle(); - await pendingFetch.started; - // The forced call neither joins the plain call's slot nor waits for it. - const forced = ix.get(ISessionTitleService).generateTitle({ force: true }); - await expect(forced).resolves.toBe('生成的标题'); - - pendingFetch.resolve( - new Response(JSON.stringify({ title: '普通标题' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - // The plain call started earlier, so its slower response is superseded: - // its write-back is vetoed and the forced title stays. - await expect(plain).resolves.toBeUndefined(); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(metadata.meta.title).toBe('生成的标题'); - expect(metadata.meta.titleKind).toBe('generated'); - }); - - it('lets the later plain request win over a slower forced call', async () => { - const pendingFetch = createPendingFetch(); - fetchMock.mockImplementationOnce(pendingFetch.fetch); - titlePrompts = ['hello']; - - const forced = ix.get(ISessionTitleService).generateTitle({ force: true }); - await pendingFetch.started; - const plain = ix.get(ISessionTitleService).generateTitle(); - await expect(plain).resolves.toBe('生成的标题'); - - pendingFetch.resolve( - new Response(JSON.stringify({ title: '强制标题' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - await expect(forced).resolves.toBeUndefined(); - expect(metadata.meta.title).toBe('生成的标题'); - }); - - it('does not share a forced in-flight result with a plain call', async () => { - await metadata.setGeneratedTitleIfUncustomized('已生成的标题'); - const pendingFetch = createPendingFetch(); - fetchMock.mockImplementationOnce(pendingFetch.fetch); - titlePrompts = ['hello']; - - const forced = ix.get(ISessionTitleService).generateTitle({ force: true }); - await pendingFetch.started; - // An already-generated title makes the plain call exit early on its own - // instead of riding the forced regeneration's result. - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(fetchMock).toHaveBeenCalledTimes(1); - - pendingFetch.resolve( - new Response(JSON.stringify({ title: '强制标题' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - await expect(forced).resolves.toBe('强制标题'); - }); - it('returns unavailable without calling the backend when no prompt was seen', async () => { await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); expect(fetchMock).not.toHaveBeenCalled(); diff --git a/packages/kap-server/src/protocol/rest-session.ts b/packages/kap-server/src/protocol/rest-session.ts index b53d772db7..4cf33af6a7 100644 --- a/packages/kap-server/src/protocol/rest-session.ts +++ b/packages/kap-server/src/protocol/rest-session.ts @@ -164,14 +164,6 @@ export type CompactSessionRequest = z.infer; export const compactSessionResponseSchema = z.object({}); export type CompactSessionResponse = z.infer; -export const generateSessionTitleRequestSchema = z.preprocess( - (value) => (value === undefined ? {} : value), - z.object({ - force: z.boolean().optional(), - }), -); -export type GenerateSessionTitleRequest = z.infer; - export const undoSessionRequestSchema = z.preprocess( (value) => value === undefined ? {} : value, z.object({ diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index 1d5963a14f..516564755e 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -117,7 +117,6 @@ import { createSessionChildRequestSchema, createSessionRequestSchema, forkSessionRequestSchema, - generateSessionTitleRequestSchema, getSessionGoalResponseSchema, listSessionChildrenResponseSchema, sessionAbortResponseSchema, @@ -624,7 +623,6 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void method: 'POST', path: '/sessions/{session_id}/title/generate', params: sessionIdParamSchema, - body: generateSessionTitleRequestSchema, success: { data: z.object({ title: z.string() }) }, errors: { [ErrorCode.SESSION_NOT_FOUND]: {}, @@ -643,9 +641,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void ); return; } - const title = await handle.accessor - .get(ISessionTitleService) - .generateTitle({ force: req.body.force }); + const title = await handle.accessor.get(ISessionTitleService).generateTitle(); if (title === undefined) { reply.send( errEnvelope( diff --git a/packages/klient/src/contract/session/title.ts b/packages/klient/src/contract/session/title.ts index ae1691c474..c75d3293c4 100644 --- a/packages/klient/src/contract/session/title.ts +++ b/packages/klient/src/contract/session/title.ts @@ -8,11 +8,9 @@ import { z } from 'zod'; import { maybe } from '../helpers.js'; import type { ServiceContract } from '../types.js'; -export const generateTitleOptionsSchema = z.object({ force: z.boolean().optional() }); - export const sessionTitleContract = { generateTitle: { - input: z.tuple([generateTitleOptionsSchema.optional()]), + input: z.tuple([]), output: maybe(z.string()), }, } satisfies ServiceContract; diff --git a/packages/klient/src/core/facade/session.ts b/packages/klient/src/core/facade/session.ts index d65986ca71..8902d7256e 100644 --- a/packages/klient/src/core/facade/session.ts +++ b/packages/klient/src/core/facade/session.ts @@ -70,9 +70,8 @@ export interface SessionFacade { * Generate and apply a title from the main agent's first prompts via the * managed `chat_title` tool. `undefined` when generation is unavailable * (no managed OAuth login, no prompt yet, or a custom title is set). - * `force` regenerates over an already-generated title. */ - generateTitle(options?: { readonly force?: boolean }): Promise; + generateTitle(): Promise; update(patch: SessionMetaPatch): Promise; setArchived(archived: boolean): Promise; status(): Promise; @@ -121,8 +120,8 @@ export function createSessionFacade(call: ScopedCaller, sessionId: string): Sess return { get: read, setTitle: (title) => call(scope, 'sessionMetadata', 'setTitle', [title]) as Promise, - generateTitle: (options) => - call(scope, 'sessionTitleService', 'generateTitle', [options]) as Promise< + generateTitle: () => + call(scope, 'sessionTitleService', 'generateTitle', []) as Promise< string | undefined >, update: (patch) => call(scope, 'sessionMetadata', 'update', [patch]) as Promise, diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts index 254c2b6da1..5c50dcfb7d 100644 --- a/packages/klient/test/contract-parity.ts +++ b/packages/klient/test/contract-parity.ts @@ -202,10 +202,7 @@ import { questionResponseSchema, questionResultSchema, } from '../src/contract/session/question.js'; -import { - generateTitleOptionsSchema, - sessionTitleContract, -} from '../src/contract/session/title.js'; +import { sessionTitleContract } from '../src/contract/session/title.js'; import { authStatusSchema, @@ -458,10 +455,6 @@ const _questionResponse: AssertWire = true; // session/title.ts -const _generateTitleOptions: AssertWire< - typeof generateTitleOptionsSchema, - NonNullable[0]> -> = true; const _generateTitleOutput: AssertWire< (typeof sessionTitleContract)['generateTitle']['output'], Awaited> diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index cef31b116f..00fb8ff343 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -1099,9 +1099,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { input: GenerateSessionTitleInput, ): Promise { return this.runSessionAccess(input.id, () => - this.withTemporarySession(input.id, () => - this.klient.session(input.id).generateTitle({ force: input.force }), - ), + this.withTemporarySession(input.id, () => this.klient.session(input.id).generateTitle()), ); } diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 2e6c29ec80..447091dc41 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -135,7 +135,6 @@ export interface RenameSessionInput { export interface GenerateSessionTitleInput { readonly id: string; - readonly force?: boolean; } export interface ResumeSessionInput { From 21945e68d244903059f894ed8d749b87ac5343e1 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 16:27:42 +0800 Subject: [PATCH 40/47] refactor(agent-core-v2): drop the title state projection from the session index The listed-session titleKind had no consumer: the TUI's title-generation gate seeds from the resumed summary, which reads the live metadata document, and the kap-server REST wire never carried the field. Removing the projection also retires the read-model summary version stamp (the remaining shape is fully field-checkable) and the duplicate title-kind derivation that had to stay in lockstep with sessionMetadata. The klient list contract and the node-sdk list mapper drop the field with it; the resumed/live summary still reports the canonical title state. --- .../src/app/sessionIndex/sessionIndex.ts | 9 ---- .../app/sessionIndex/sessionIndexService.ts | 32 ++----------- .../sessionMetadata/sessionMetadataService.ts | 3 -- .../app/sessionIndex/sessionIndex.test.ts | 46 ------------------- .../klient/src/contract/global/sessions.ts | 1 - packages/node-sdk/src/types.ts | 4 +- packages/node-sdk/src/v2/session-mapper.ts | 1 - .../node-sdk/test/sdk-rpc-client-v2.test.ts | 9 ++-- 8 files changed, 10 insertions(+), 95 deletions(-) diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts index 9e7e0fd68e..743e85717c 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts @@ -5,12 +5,6 @@ * query facade over the set of persisted sessions (open or closed). It * enumerates sessions and derives session identity (`workspaceId`), returning * data (`SessionSummary`) or counts — never filesystem paths or live handles. - * The summary mirrors `sessionMetadata`'s title state (`title` / `titleKind`); - * the kind union is re-declared inline because this L2 contract cannot - * import the L6 metadata domain that canonically owns it. Entries cached in - * the derived read model carry `READ_MODEL_SUMMARY_VERSION`; older-stamped - * (or un-stamped) entries are treated as cold misses and backfilled from - * disk, so upgraded readers never serve a stale-shape summary. * Writes (create / archive) live in `workspaceHandler` / `session`; the index * is a read model. Backends are deployment-specific (local filesystem today; * database / query store on a server). @@ -19,8 +13,6 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { Page } from '#/persistence/interface/queryStore'; -export const READ_MODEL_SUMMARY_VERSION = 2; - export const PARENT_SESSION_ID_KEY = 'parent_session_id'; export const CHILD_SESSION_KIND_KEY = 'child_session_kind'; @@ -32,7 +24,6 @@ export interface SessionSummary { readonly workspaceId: string; readonly cwd?: string; readonly title?: string; - readonly titleKind?: 'replaceable' | 'generated' | 'custom'; readonly lastPrompt?: string; readonly createdAt: number; readonly updatedAt: number; diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index d63595a2d7..b58f9ed590 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -53,7 +53,6 @@ import { CHILD_SESSION_KIND_KEY, ISessionIndex, PARENT_SESSION_ID_KEY, - READ_MODEL_SUMMARY_VERSION, type SessionListQuery, type SessionSummary, } from './sessionIndex'; @@ -63,8 +62,6 @@ const META_KEY = 'state.json'; const SESSION_COLLECTION = 'session'; const READ_MODEL_FLAG = 'persistence_minidb_readmodel'; -type CachedSessionSummary = SessionSummary & { readonly v: number }; - function parseTime(value: unknown): number { if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string') { @@ -74,21 +71,6 @@ function parseTime(value: unknown): number { return 0; } -function toTitleKind(meta: Record): SessionSummary['titleKind'] { - const title = typeof meta['title'] === 'string' ? meta['title'] : undefined; - const titleKind = meta['titleKind']; - if (title !== undefined && meta['isCustomTitle'] === true) return 'custom'; - if ( - title !== undefined && - (titleKind === 'replaceable' || titleKind === 'generated' || titleKind === 'custom') - ) { - return titleKind; - } - if (title !== undefined && meta['isCustomTitle'] === false) return 'replaceable'; - if (typeof meta['customTitle'] === 'string') return 'custom'; - return title === undefined ? undefined : 'replaceable'; -} - function recoverCwd(meta: Record): string | undefined { if (typeof meta['cwd'] === 'string' && meta['cwd'].length > 0) return meta['cwd']; if (typeof meta['workDir'] === 'string' && meta['workDir'].length > 0) { @@ -119,11 +101,10 @@ function matchesChildOf(summary: SessionSummary, parentId: string | undefined): * fields the session-summary contract requires; anything else is treated as a * cold miss and rebuilt from disk. */ -function isSessionSummaryShape(value: unknown): value is CachedSessionSummary { +function isSessionSummaryShape(value: unknown): value is SessionSummary { if (value === null || typeof value !== 'object') return false; const summary = value as Record; return ( - summary['v'] === READ_MODEL_SUMMARY_VERSION && typeof summary['id'] === 'string' && typeof summary['workspaceId'] === 'string' && typeof summary['createdAt'] === 'number' && @@ -257,17 +238,11 @@ export class FileSessionIndex implements ISessionIndex { sessionId: string, ): Promise { const cached: unknown = await this.queryStore.get(SESSION_COLLECTION, sessionId); - if (isSessionSummaryShape(cached)) { - const { v: _version, ...summary } = cached; - return summary; - } + if (isSessionSummaryShape(cached)) return cached; const summary = await this.readSummary(workspaceId, sessionId); if (summary !== undefined) { // Also overwrites a cache entry that failed the shape check above. - await this.queryStore.put(SESSION_COLLECTION, sessionId, { - ...summary, - v: READ_MODEL_SUMMARY_VERSION, - }); + await this.queryStore.put(SESSION_COLLECTION, sessionId, summary); } return summary; } @@ -359,7 +334,6 @@ export class FileSessionIndex implements ISessionIndex { workspaceId, cwd: recoverCwd(meta), title: typeof meta['title'] === 'string' ? meta['title'] : undefined, - titleKind: toTitleKind(meta), lastPrompt: typeof meta['lastPrompt'] === 'string' ? meta['lastPrompt'] : undefined, createdAt: parseTime(meta['createdAt']), updatedAt: parseTime(meta['updatedAt']), diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 8bf2a783b2..638b02219d 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -42,7 +42,6 @@ import { Emitter, type Event } from '#/_base/event'; import { ILogService } from '#/_base/log/log'; import { defineState } from '#/_base/state/stateRegistry'; import { IFlagService } from '#/app/flag/flag'; -import { READ_MODEL_SUMMARY_VERSION } from '#/app/sessionIndex/sessionIndex'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IQueryStore } from '#/persistence/interface/queryStore'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -164,7 +163,6 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { workspaceId: this.ctx.workspaceId, cwd: this.ctx.cwd, title: this.data.title, - titleKind: this.data.titleKind, lastPrompt: this.data.lastPrompt, createdAt: this.data.createdAt, updatedAt: this.data.updatedAt, @@ -174,7 +172,6 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { // poison the cache entry and fail contract validation on reads. archived: this.data.archived === true, custom: this.data.custom, - v: READ_MODEL_SUMMARY_VERSION, }); } catch (error) { this.log.warn('failed to mirror session metadata to read model', { diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index 56fc7fd611..90231be064 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -17,7 +17,6 @@ import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IFlagService } from '#/app/flag/flag'; import { ISessionIndex, - READ_MODEL_SUMMARY_VERSION, type SessionSummary, } from '#/app/sessionIndex/sessionIndex'; import { FileSessionIndex } from '#/app/sessionIndex/sessionIndexService'; @@ -136,27 +135,6 @@ describe('FileSessionIndex (legacy)', () => { expect((await store.get('none'))?.cwd).toBeUndefined(); }); - it('projects the title state, honoring a legacy custom marker over a stale titleKind', async () => { - await seedSession('generated', { - title: 'gen', - titleKind: 'generated', - isCustomTitle: false, - }); - await seedSession('stale-mixed', { - title: '用户标题', - titleKind: 'replaceable', - isCustomTitle: true, - }); - await seedSession('plain', { title: 'plain' }); - await seedSession('legacy-custom', { customTitle: 'legacy title' }); - - const store = build(); - expect((await store.get('generated'))?.titleKind).toBe('generated'); - expect((await store.get('stale-mixed'))?.titleKind).toBe('custom'); - expect((await store.get('plain'))?.titleKind).toBe('replaceable'); - expect((await store.get('legacy-custom'))?.titleKind).toBe('custom'); - }); - it('list filters by sessionId without enumerating all sessions', async () => { await seedSession('active', { title: 'hello' }); await seedSession('archived', { archived: true }); @@ -327,7 +305,6 @@ describe('FileSessionIndex (read model)', () => { createdAt: 1, updatedAt: 2, archived: false, - v: READ_MODEL_SUMMARY_VERSION, ...overrides, }; } @@ -380,29 +357,6 @@ describe('FileSessionIndex (read model)', () => { expect(cached?.archived).toBe(false); }); - it('treats a cache entry stamped with an older summary version as a cold miss', async () => { - await seedSession('s1', { - title: '用户标题', - titleKind: 'replaceable', - isCustomTitle: true, - createdAt: 1, - updatedAt: 2, - }); - const store = build(); - await queryStore.put(SESSION_COLLECTION, 's1', { - ...summary('s1', { title: '用户标题', titleKind: 'replaceable' }), - v: 1, - }); - - const got = await store.get('s1'); - // The stale-stamped entry is ignored and backfilled from disk, which - // honors the legacy custom marker over the stale titleKind. - expect(got?.titleKind).toBe('custom'); - const cached = await queryStore.get>(SESSION_COLLECTION, 's1'); - expect(cached?.['v']).toBe(READ_MODEL_SUMMARY_VERSION); - expect(cached?.['titleKind']).toBe('custom'); - }); - it('get falls back to disk when the cached entry fails the shape check', async () => { await seedSession('s1', { title: 'on-disk', createdAt: 1, updatedAt: 2 }); const store = build(); diff --git a/packages/klient/src/contract/global/sessions.ts b/packages/klient/src/contract/global/sessions.ts index 7544d26d4a..27b6b6cca7 100644 --- a/packages/klient/src/contract/global/sessions.ts +++ b/packages/klient/src/contract/global/sessions.ts @@ -13,7 +13,6 @@ export const sessionSummarySchema = z.object({ workspaceId: z.string(), cwd: z.string().optional(), title: z.string().optional(), - titleKind: z.enum(['replaceable', 'generated', 'custom']).optional(), lastPrompt: z.string().optional(), createdAt: z.number(), updatedAt: z.number(), diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 447091dc41..f4e68d9589 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -267,7 +267,9 @@ export interface SessionStatus { * The engine's canonical title state: `replaceable` (a prompt-derived easy * title auto generation may overwrite), `generated` (an auto-generated title * already landed), `custom` (a user-set title that is never overwritten). - * Only populated by the v2 engine; v1 backends leave it undefined. + * Only populated by the v2 engine on live / resumed sessions (read off the + * metadata document); v1 backends leave it undefined, and the v2 list path + * does not project it. */ export type SessionTitleKind = 'replaceable' | 'generated' | 'custom'; diff --git a/packages/node-sdk/src/v2/session-mapper.ts b/packages/node-sdk/src/v2/session-mapper.ts index 08e9cb830e..8e70977b6f 100644 --- a/packages/node-sdk/src/v2/session-mapper.ts +++ b/packages/node-sdk/src/v2/session-mapper.ts @@ -48,7 +48,6 @@ export function v2SummaryToSessionSummary( return { id: summary.id, title: summary.title, - titleKind: summary.titleKind, lastPrompt: summary.lastPrompt, workDir: facts.workDir, sessionDir: facts.sessionDir, diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index d80a9d59bb..4ec8fca307 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -417,7 +417,7 @@ key = "${titleOAuthRef.key}" } }); - it('reports the title state in listSessions as well as in the resumed summary', async () => { + it('reports the title state in the resumed summary', async () => { const { harness } = await makeHarness(); const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); tempDirs.push(workDir); @@ -426,10 +426,9 @@ key = "${titleOAuthRef.key}" const session = await harness.createSession({ id: 'ses_title_kind', workDir }); await harness.renameSession({ id: session.id, title: '我的标题' }); - // The list path (index → klient contract → mapper) must project the - // same canonical title state the resume path serves. - const listed = await harness.listSessions({ workDir }); - expect(listed.find((item) => item.id === session.id)?.titleKind).toBe('custom'); + // The resumed summary is read off the live metadata document, so it + // carries the canonical title state; the list path (index projection) + // intentionally does not. await session.close(); const resumed = await harness.resumeSession({ id: session.id }); expect(resumed.summary?.titleKind).toBe('custom'); From 6523e8131e0caa29a97a5ce5b85b6f85c653f04e Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 16:30:02 +0800 Subject: [PATCH 41/47] refactor(agent-core-v2): inline the transcript live-tail merge into messageLegacy mergeContextTranscriptWithLive had a single caller; move the logic into messageLegacyService as the private mergeLiveTail and drop the export. --- .../agent/contextMemory/contextTranscript.ts | 17 ---------------- .../app/messageLegacy/messageLegacyService.ts | 20 +++++++++++++++++-- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index 3ddb85233c..6279b19bf2 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -32,23 +32,6 @@ export interface ContextTranscriptReducer { result(): ContextTranscript; } -export function mergeContextTranscriptWithLive( - transcript: ContextTranscript, - contextMessages: readonly ContextMessage[], -): { - readonly messages: readonly ContextMessage[]; - readonly times: readonly (number | undefined)[]; -} { - if (contextMessages.length <= transcript.foldedLength) { - return { messages: transcript.entries, times: transcript.times }; - } - const tail = contextMessages.slice(transcript.foldedLength); - return { - messages: [...transcript.entries, ...tail], - times: [...transcript.times, ...tail.map(() => undefined)], - }; -} - interface MutableMessage { id?: string; role: ContextMessage['role']; diff --git a/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts b/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts index 58a8526709..1ff08d60a3 100644 --- a/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts +++ b/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts @@ -32,7 +32,6 @@ import { IAgentBlobService } from '#/agent/blob/agentBlobService'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { createContextTranscriptReducer, - mergeContextTranscriptWithLive, type ContextTranscript, } from '#/agent/contextMemory/contextTranscript'; import { toProtocolMessage } from '#/agent/contextMemory/messageProjection'; @@ -130,7 +129,7 @@ export class MessageLegacyService implements IMessageLegacyService { const transcript = await this.readTranscript(agent); const contextMessages = agent.accessor.get(IAgentContextMemoryService).get(); - const merged = mergeContextTranscriptWithLive(transcript, contextMessages); + const merged = mergeLiveTail(transcript, contextMessages); const entries = await this.rehydrate(agent, merged.messages); let previousMs = Number.NEGATIVE_INFINITY; @@ -177,6 +176,23 @@ export class MessageLegacyService implements IMessageLegacyService { } } +function mergeLiveTail( + transcript: ContextTranscript, + contextMessages: readonly ContextMessage[], +): { + readonly messages: readonly ContextMessage[]; + readonly times: readonly (number | undefined)[]; +} { + if (contextMessages.length <= transcript.foldedLength) { + return { messages: transcript.entries, times: transcript.times }; + } + const tail = contextMessages.slice(transcript.foldedLength); + return { + messages: [...transcript.entries, ...tail], + times: [...transcript.times, ...tail.map(() => undefined)], + }; +} + registerScopedService( LifecycleScope.App, IMessageLegacyService, From 67a240c4f10659f1c99bb17a13cd7e13365943f2 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 17:00:45 +0800 Subject: [PATCH 42/47] refactor(agent-core-v2): drop the closing registry from the session lifecycle Auto title no longer consumes close-awareness, so the machinery goes back to the simple forms: close/archive run straight through, resume no longer waits out an in-flight close, create/fork drop their target reservation, and a cold archive is a no-op again. Reverts the behavior of e7c397a7c and cd1cea0fd on top of the sessionLifecycle rename. --- .../sessionLifecycleService.ts | 173 ++----------- .../sessionLifecycle/sessionLifecycle.test.ts | 231 ------------------ 2 files changed, 16 insertions(+), 388 deletions(-) diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index ecbc89cc12..4f61184e5e 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -5,20 +5,7 @@ * them through the DI scope tree (children of the handler's Workspace * scope) and seeding each with its identity, storage addressing derived * from the handler's persistence scope, and a per-session lifecycle-hooks - * slots instance it runs around create/close. A close/archive - * is tracked in a closing registry from that first synchronous step until - * the scope is disposed: `get`/`list` hide the session while it is - * closing, `resume` waits the close out and re-materializes instead of - * returning the doomed handle (retrying the same way when the close - * itself failed), and the teardown always completes even when a close - * hook or the agent drain fails. An archive requested during a plain - * close is folded into it: the live metadata applies the archived flag - * before the teardown when reached in time, otherwise the flag lands - * directly on the persisted document (a cold archive takes that direct - * path as well), and the archived event is published either way. Create - * and fork reserve their target id synchronously with the existence - * check, so a loser of that race is rejected before it materializes and - * its rollback can only ever tear down its own scope, + * slots instance it runs around create/close, * tearing sessions down on close/archive — archiving flags the session's * metadata, removes its agents, restoring clears * the archived flag, and broadcasts the transition; session start and @@ -156,11 +143,6 @@ type MaterializeSessionOptions = Omit & { readonly sessionId: string; }; -type ClosingEntry = { - readonly kind: 'close' | 'archive'; - readonly run: Promise; -}; - export class SessionLifecycleService extends Disposable implements ISessionLifecycleService { declare readonly _serviceBrand: undefined; private readonly sessions = new Map(); @@ -173,9 +155,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private readonly _onDidForkSession = this._register(new Emitter()); readonly onDidForkSession: Event = this._onDidForkSession.event; private readonly resuming = new Map>(); - private readonly closing = new Map(); - private readonly reservedTargets = new Set(); - private readonly pendingArchive = new Set(); constructor( @IInstantiationService private readonly instantiation: IInstantiationService, @@ -220,24 +199,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec async create(opts: CreateSessionOptions): Promise { const sessionId = opts.sessionId ?? createSessionId(); - if (this.sessions.has(sessionId) || this.reservedTargets.has(sessionId)) { - throw new Error2( - ErrorCodes.SESSION_ALREADY_EXISTS, - `Session "${sessionId}" already exists`, - ); - } - this.reservedTargets.add(sessionId); - try { - return await this.createReserved(opts, sessionId); - } finally { - this.reservedTargets.delete(sessionId); - } - } - - private async createReserved( - opts: CreateSessionOptions, - sessionId: string, - ): Promise { const handle = await this.materializeSession({ ...opts, sessionId }); try { const main = @@ -254,9 +215,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec await this.appendSessionIndexEntry(sessionId, opts.workDir); } catch (error) { const sessionDir = handle.accessor.get(ISessionContext).sessionDir; - if (this.sessions.get(sessionId) === handle) { - this.sessions.delete(sessionId); - } + this.sessions.delete(sessionId); await this.drainAgents(handle).catch(() => {}); handle.dispose(); await this.hostFs.remove(sessionDir).catch(() => {}); @@ -352,20 +311,13 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } get(sessionId: string): ISessionScopeHandle | undefined { - if (this.resuming.has(sessionId) || this.closing.has(sessionId)) return undefined; + if (this.resuming.has(sessionId)) return undefined; return this.sessions.get(sessionId); } resume(sessionId: string, opts?: ResumeSessionOptions): Promise { const inflight = this.resuming.get(sessionId); if (inflight !== undefined) return inflight; - const closing = this.closing.get(sessionId); - if (closing !== undefined) { - return closing.run.then( - () => this.resume(sessionId, opts), - () => this.resume(sessionId, opts), - ); - } const live = this.sessions.get(sessionId); if (live !== undefined) return Promise.resolve(live); const promise = this.doResume(sessionId, opts) @@ -409,112 +361,37 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec list(): readonly ISessionScopeHandle[] { const ready: ISessionScopeHandle[] = []; for (const [id, handle] of this.sessions) { - if (!this.resuming.has(id) && !this.closing.has(id)) ready.push(handle); + if (!this.resuming.has(id)) ready.push(handle); } return ready; } async close(sessionId: string): Promise { - const inflight = this.closing.get(sessionId); - if (inflight !== undefined) return inflight.run; const handle = this.sessions.get(sessionId); if (handle === undefined) return; - return this.trackClosing(sessionId, 'close', this.doClose(sessionId, handle)); - } - - private async doClose(sessionId: string, handle: ISessionScopeHandle): Promise { - try { - await this.announceWillClose({ sessionId, handle, reason: 'exit' }); - } finally { - if (this.pendingArchive.has(sessionId)) { - const archived = await handle.accessor - .get(ISessionMetadata) - .setArchived(true) - .then( - () => true, - () => false, - ); - if (archived) { - this.pendingArchive.delete(sessionId); - this.event.publish({ - type: 'event.session.archived', - payload: { sessionId }, - }); - } - } - this.sessions.delete(sessionId); - await this.drainAgents(handle).catch(() => {}); - handle.dispose(); - this._onDidCloseSession.fire({ sessionId }); - } + await this.announceWillClose({ sessionId, handle, reason: 'exit' }); + this.sessions.delete(sessionId); + await this.drainAgents(handle); + handle.dispose(); + this._onDidCloseSession.fire({ sessionId }); } async archive(sessionId: string): Promise { - const inflight = this.closing.get(sessionId); - if (inflight !== undefined) { - if (inflight.kind === 'archive') return inflight.run; - this.pendingArchive.add(sessionId); - return inflight.run.then( - () => this.consumePendingArchive(sessionId), - () => this.consumePendingArchive(sessionId), - ); - } const handle = this.sessions.get(sessionId); - if (handle === undefined) { - await this.markArchivedOnDisk(sessionId); - return; - } - return this.trackClosing(sessionId, 'archive', this.doArchive(sessionId, handle)); - } - - private async consumePendingArchive(sessionId: string): Promise { - if (this.pendingArchive.delete(sessionId)) { - await this.markArchivedOnDisk(sessionId); - } - } - - private async doArchive(sessionId: string, handle: ISessionScopeHandle): Promise { + if (handle === undefined) return; const meta = handle.accessor.get(ISessionMetadata); await meta.setArchived(true); + await this.drainAgents(handle); this.event.publish({ type: 'event.session.archived', payload: { sessionId }, }); - try { - await this.announceWillClose({ sessionId, handle, reason: 'exit' }); - } finally { - this.sessions.delete(sessionId); - await this.drainAgents(handle).catch(() => {}); - handle.dispose(); - this._onDidArchiveSession.fire({ sessionId }); - } - } - - private async markArchivedOnDisk(sessionId: string): Promise { - const scope = sessionScopeOf(this.handlerScope, sessionId); - const meta = await this.docs.get(scope, 'state.json'); - if (meta === undefined) return; - await this.docs.set(scope, 'state.json', { ...meta, archived: true, updatedAt: Date.now() }); - this.event.publish({ - type: 'event.session.archived', - payload: { sessionId }, - }); + await this.announceWillClose({ sessionId, handle, reason: 'exit' }); + this.sessions.delete(sessionId); + handle.dispose(); this._onDidArchiveSession.fire({ sessionId }); } - private async trackClosing( - sessionId: string, - kind: ClosingEntry['kind'], - run: Promise, - ): Promise { - this.closing.set(sessionId, { kind, run }); - try { - await run; - } finally { - if (this.closing.get(sessionId)?.run === run) this.closing.delete(sessionId); - } - } - async restore(sessionId: string): Promise { const handle = await this.resume(sessionId); if (handle === undefined) return undefined; @@ -538,7 +415,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec async fork(opts: ForkSessionOptions): Promise { const sourceId = opts.sourceSessionId; - await this.closing.get(sourceId)?.run; const sourceHandle = this.sessions.get(sourceId); const indexSummary = await this.index.get(sourceId); if ( @@ -551,7 +427,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec let targetId: string | undefined; let target: ISessionScopeHandle | undefined; let targetSessionDir: string | undefined; - let targetReserved = false; try { const sourceMeta = sourceHandle !== undefined @@ -559,15 +434,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec : await this.readMetaFromDisk(sourceId); targetId = opts.newSessionId ?? createSessionId(); - if (this.sessions.has(targetId) || this.reservedTargets.has(targetId)) { - throw new Error2( - ErrorCodes.SESSION_ALREADY_EXISTS, - `Session "${targetId}" already exists`, - ); - } - this.reservedTargets.add(targetId); - targetReserved = true; - if ((await this.index.get(targetId)) !== undefined) { + if (this.sessions.has(targetId) || (await this.index.get(targetId)) !== undefined) { throw new Error2( ErrorCodes.SESSION_ALREADY_EXISTS, `Session "${targetId}" already exists`, @@ -628,11 +495,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec await this.announceCreated({ sessionId: targetId, handle: target, source: 'fork' }); return target; } catch (error) { - if ( - targetId !== undefined && - target !== undefined && - this.sessions.get(targetId) === target - ) { + if (targetId !== undefined) { this.sessions.delete(targetId); } if (target !== undefined) { @@ -645,10 +508,6 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec await this.hostFs.remove(targetSessionDir).catch(() => {}); } throw error; - } finally { - if (targetReserved && targetId !== undefined) { - this.reservedTargets.delete(targetId); - } } } diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts index 85b162deb9..33b925adf1 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts @@ -620,237 +620,6 @@ describe('SessionLifecycleService', () => { expect(svc.get('s1')).toBeUndefined(); }); - it('resume waits out an in-flight close and returns a fresh scope', async () => { - const svc = await build([ - stubPair(IWorkspaceService, persistentWorkspaceStub()), - stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj')), - stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), - ]); - const original = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - - let closeStarted!: () => void; - let releaseClose!: () => void; - const closeBegan = new Promise((resolve) => { - closeStarted = resolve; - }); - const closeGate = new Promise((resolve) => { - releaseClose = resolve; - }); - original.accessor - .get(ISessionLifecycleHooks) - .onWillCloseSession.register('test-block', async (_event, next) => { - closeStarted(); - await closeGate; - await next(); - }); - - const closing = svc.close('s1'); - await closeBegan; - // The closing session is hidden from get/list immediately. - expect(svc.get('s1')).toBeUndefined(); - expect(svc.list()).toEqual([]); - - let settled = false; - const resumed = svc.resume('s1').then((handle) => { - settled = true; - return handle; - }); - await tick(); - expect(settled).toBe(false); - - releaseClose(); - await closing; - const fresh = await resumed; - expect(fresh).toBeDefined(); - expect(fresh).not.toBe(original); - expect(svc.get('s1')).toBe(fresh); - }); - - it('archive during a plain close is folded into the close through the live metadata', async () => { - const setArchived = vi.fn(() => Promise.resolve()); - const published: string[] = []; - const svc = await build([ - stubPair(ISessionMetadata, { ...metadataStub(), setArchived }), - stubPair(IEventService, { - ...eventStub(), - publish: (event: { readonly type: string }) => { - published.push(event.type); - }, - } as IEventService), - ]); - const original = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - - let releaseClose!: () => void; - const closeGate = new Promise((resolve) => { - releaseClose = resolve; - }); - original.accessor - .get(ISessionLifecycleHooks) - .onWillCloseSession.register('test-block', async (_event, next) => { - await closeGate; - await next(); - }); - - const closing = svc.close('s1'); - await tick(); - const archived = svc.archive('s1'); - releaseClose(); - await closing; - await archived; - - // The archive rides the close's own teardown through the live metadata - // — it never becomes a detached on-disk write. - expect(setArchived).toHaveBeenCalledWith(true); - expect(published).toContain('event.session.archived'); - }); - - it('archives a cold session by writing the persisted document and emitting the event', async () => { - const docs = new Map(); - const docStore = { - _serviceBrand: undefined, - get: (scope: string, key: string) => Promise.resolve(docs.get(`${scope}/${key}`)), - set: (scope: string, key: string, value: unknown) => { - docs.set(`${scope}/${key}`, value); - return Promise.resolve(); - }, - delete: () => Promise.resolve(), - list: () => Promise.resolve([]), - watch: () => (_listener: unknown) => ({ dispose: () => {} }), - acquire: () => ({ dispose: () => {} }), - } as unknown as IAtomicDocumentStore; - const published: string[] = []; - const svc = await build([ - stubPair(IAtomicDocumentStore, docStore), - stubPair(IEventService, { - ...eventStub(), - publish: (event: { readonly type: string }) => { - published.push(event.type); - }, - } as IEventService), - ]); - const original = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - const metaKey = `${original.accessor.get(ISessionContext).metaScope}/state.json`; - docs.set(metaKey, { id: 's1', version: 2, createdAt: 1, updatedAt: 1, archived: false }); - await svc.close('s1'); - - await svc.archive('s1'); - - expect(docs.get(metaKey)).toMatchObject({ archived: true }); - expect(published).toContain('event.session.archived'); - }); - - it('resume retries and succeeds after a close whose hook failed', async () => { - const svc = await build([ - stubPair(IWorkspaceService, persistentWorkspaceStub()), - stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj')), - stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), - ]); - const original = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - - let releaseClose!: () => void; - const closeGate = new Promise((resolve) => { - releaseClose = resolve; - }); - original.accessor - .get(ISessionLifecycleHooks) - .onWillCloseSession.register('test-boom', async (_event, next) => { - await closeGate; - await next(); - throw new Error('hook boom'); - }); - - const closing = svc.close('s1'); - const resumed = svc.resume('s1'); - releaseClose(); - - await expect(closing).rejects.toThrow('hook boom'); - // The resume waited on the failed close and retried into a cold restore. - await expect(resumed).resolves.toBeDefined(); - expect(svc.get('s1')).toBe(await resumed); - }); - - it('completes the teardown even when the agent drain fails', async () => { - const stuckAgent = { - id: 'a1', - kind: LifecycleScope.Agent, - accessor: { - get: () => { - throw new Error('unexpected service access'); - }, - }, - dispose: () => {}, - } as unknown as IAgentScopeHandle; - const svc = await build([ - stubPair(IAgentLifecycleService, { - ...agentLifecycleStub(), - list: () => [stuckAgent], - remove: () => Promise.reject(new Error('drain boom')), - }), - ]); - await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - - await svc.close('s1'); - expect(svc.get('s1')).toBeUndefined(); - }); - - it('a rejected same-target fork leaves the earlier fork handle live', async () => { - const svc = await build(); - await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); - const first = await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }); - - await expect(svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' })).rejects.toMatchObject({ - code: ErrorCodes.SESSION_ALREADY_EXISTS, - }); - - expect(svc.get('dst')).toBe(first); - }); - - it('still completes the teardown when a close hook fails', async () => { - const svc = await build(); - const original = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - original.accessor - .get(ISessionLifecycleHooks) - .onWillCloseSession.register('test-boom', async () => { - throw new Error('hook boom'); - }); - - await expect(svc.close('s1')).rejects.toThrow('hook boom'); - // The hook error reaches the caller, but the session is gone, not - // stranded as a half-closed zombie. - expect(svc.get('s1')).toBeUndefined(); - }); - - it('rejects a concurrent create with the same reserved session id', async () => { - const svc = await build(); - - const [first, second] = await Promise.allSettled([ - svc.create({ sessionId: 's1', workDir: '/tmp/proj' }), - svc.create({ sessionId: 's1', workDir: '/tmp/proj' }), - ]); - - expect([first.status, second.status].sort()).toEqual(['fulfilled', 'rejected']); - const rejection = [first, second].find((result) => result.status === 'rejected'); - expect((rejection as PromiseRejectedResult).reason).toMatchObject({ - code: ErrorCodes.SESSION_ALREADY_EXISTS, - }); - }); - - it('rejects a concurrent fork racing for the same reserved target id', async () => { - const svc = await build(); - await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); - - const [first, second] = await Promise.allSettled([ - svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }), - svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }), - ]); - - expect([first.status, second.status].sort()).toEqual(['fulfilled', 'rejected']); - const rejection = [first, second].find((result) => result.status === 'rejected'); - expect((rejection as PromiseRejectedResult).reason).toMatchObject({ - code: ErrorCodes.SESSION_ALREADY_EXISTS, - }); - }); - it('create seeds identity and materializes metadata', async () => { const svc = await build(); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); From 65b77674a97af38bd3ccad2b36a45d83849e78de Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 17:10:10 +0800 Subject: [PATCH 43/47] fix(agent-core-v2): complete the HostRequestHeaders migration in the title test The main merge reduced #/kosong/model/hostRequestHeaders to the pure port contract; define the test's headers as a plain value matching it and tidy the SDK test import grouping. --- .../test/session/sessionTitle/sessionTitleService.test.ts | 4 ++-- packages/node-sdk/test/sdk-rpc-client-v2.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 67fd7e4e5f..eaa94f64da 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -20,7 +20,7 @@ import { createServices, type TestInstantiationService } from '#/_base/di/test'; import { Emitter } from '#/_base/event'; import { IOAuthService } from '#/app/auth/auth'; import { type DomainEvent, IEventService } from '#/app/event/event'; -import { HostRequestHeaders, IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; +import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; import { IProviderService, type OAuthRef, @@ -212,7 +212,7 @@ describe('SessionTitleService', () => { }; }, }); - reg.defineInstance(IHostRequestHeaders, new HostRequestHeaders({ 'User-Agent': 'test' })); + reg.defineInstance(IHostRequestHeaders, { headers: { 'User-Agent': 'test' } }); reg.define(ISessionTitleService, SessionTitleService); }, }); diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index e27e4b6895..e35452d610 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -23,9 +23,9 @@ import { ErrorCodes, KimiError, KimiHarness, - type Event, removeProviderFromConfig, SDKRpcClientV2, + type Event, type KimiConfig, } from '#/index'; import { foldAgentWireReplay } from '#/v2/resume-replay'; From e56b878363a30ed8a37a8bccb9dd42f301bfe416 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 17:55:04 +0800 Subject: [PATCH 44/47] fix(node-sdk): mark resume telemetry field ignored --- packages/node-sdk/src/kimi-harness.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index a093663435..9dd701ddbd 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -141,7 +141,12 @@ export class KimiHarness { async resumeSession(input: ResumeSessionInput): Promise { const id = normalizeSessionId(input.id); const active = this.activeSessions.get(id); - const { kaos, persistenceKaos, sessionStartedProperties, ...resumeInput } = input; + const { + kaos, + persistenceKaos, + sessionStartedProperties: _sessionStartedProperties, + ...resumeInput + } = input; // A session whose close is in flight (`isClosed` but not yet unmapped) // is not a valid resume target — fall through and re-resume fresh, which // the engine serializes behind that close. From 771a55f463749bc94e30ab19e5dec68abdc98ae6 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 19:14:09 +0800 Subject: [PATCH 45/47] feat(tui): request the session title as soon as a prompt is accepted --- .changeset/auto-session-title.md | 2 +- .../tui/controllers/session-event-handler.ts | 7 +++--- apps/kimi-code/src/tui/kimi-tui.ts | 13 +++++++--- .../test/tui/kimi-tui-message-flow.test.ts | 25 +++++++++++++++++++ 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/.changeset/auto-session-title.md b/.changeset/auto-session-title.md index fbd25aa180..2ff827ab3c 100644 --- a/.changeset/auto-session-title.md +++ b/.changeset/auto-session-title.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -Generate a concise session title automatically after the first turn when signed in with a managed Kimi account. A title you set yourself is never overwritten. +Generate a concise session title automatically as soon as the first prompt is sent when signed in with a managed Kimi account. A title you set yourself is never overwritten. diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 34c371303e..cb06e01377 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -409,8 +409,9 @@ export class SessionEventHandler { } /** - * Best-effort auto title: after each completed turn, ask the engine to - * generate a title from the first prompts until one lands. The engine + * Best-effort auto title: right after a prompt is accepted by the engine + * (and again after each completed turn), ask the engine to generate a + * title from the first prompts until one lands. The engine * overwrites the prompt-derived easy title but never a custom title * (enforced server-side), and dedupes in-flight requests. A resolved string * means a title was applied — stop asking so later turns don't regenerate @@ -419,7 +420,7 @@ export class SessionEventHandler { * dead RPC) disables further attempts for this session. The generated * title lands through the regular `session.meta.updated` event. */ - private requestSessionTitleGeneration(): void { + requestSessionTitleGeneration(): void { if (this.titleGenerationDisabled) return; const { sessionId } = this.host.state.appState; if (sessionId.length === 0) return; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 2dad3776d4..a00cb17aae 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1357,10 +1357,15 @@ export class KimiTUI { }); return; } - void session.prompt(sdkInput).catch((error: unknown) => { - const message = formatErrorMessage(error); - this.failSessionRequest(`Failed to send: ${message}`); - }); + void session.prompt(sdkInput).then( + // The prompt is enqueued engine-side by the time this resolves, so the + // title endpoint can already read it — no need to wait for turn end. + () => this.sessionEventHandler.requestSessionTitleGeneration(), + (error: unknown) => { + const message = formatErrorMessage(error); + this.failSessionRequest(`Failed to send: ${message}`); + }, + ); } sendSkillActivation(session: Session, skillName: string, skillArgs: string): void { diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index d6a509e546..6a00aaa5b9 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -1203,6 +1203,31 @@ command = "vim" ]); }); + it('requests a session title once the prompt is accepted', async () => { + const { driver, harness } = await makeDriver(); + + driver.handleUserInput('hello'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 'ses-1' }); + }); + + it('does not request a session title when the prompt send fails', async () => { + const { driver, harness } = await makeDriver( + makeSession({ + prompt: vi.fn(async () => { + throw new Error('core rpc unavailable'); + }), + }), + ); + + driver.handleUserInput('hello'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(harness.generateSessionTitle).not.toHaveBeenCalled(); + expect(driver.state.appState.streamingPhase).toBe('idle'); + }); + it('keeps the transcript intact when undo RPC fails', async () => { const session = makeSession({ undoHistory: vi.fn(async () => { From bd10d5ef06e969a02c2672154f196b3bdf5148e2 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 19:14:09 +0800 Subject: [PATCH 46/47] fix(agent-core-v2): drop the numbered user prefixes from the title request input --- .../src/session/sessionTitle/sessionTitleService.ts | 2 +- .../test/session/sessionTitle/sessionTitleService.test.ts | 6 +++--- packages/kap-server/test/sessions.test.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index 7084824df8..94a75840e4 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -155,7 +155,7 @@ export class SessionTitleService implements ISessionTitleService { function titleInputFromPrompts(prompts: readonly string[]): string | undefined { if (prompts.length === 0) return undefined; return prompts - .map((prompt, index) => `user ${index + 1}: ${prompt}`) + .map((prompt) => `user: ${prompt}`) .join('\n') .slice(0, MAX_TITLE_INPUT_LENGTH); } 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 eaa94f64da..97ef040136 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -237,7 +237,7 @@ describe('SessionTitleService', () => { 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 报错' }, + params: { chat_content: 'user: 帮我看一下这个 Go 的 nil pointer 报错' }, }); expect(new Headers(init?.headers as Record).get('authorization')).toBe( 'Bearer test-token', @@ -260,7 +260,7 @@ describe('SessionTitleService', () => { expect(JSON.parse(init?.body as string)).toEqual({ method: 'chat_title', params: { - chat_content: 'user 1: 先帮我搭一个 Vite 项目\nuser 2: 加上路由\nuser 3: 现在配一下 ESLint', + chat_content: 'user: 先帮我搭一个 Vite 项目\nuser: 加上路由\nuser: 现在配一下 ESLint', }, }); }); @@ -272,7 +272,7 @@ describe('SessionTitleService', () => { 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.startsWith('user: 很长的输入')).toBe(true); expect(body.params.chat_content).toHaveLength(1000); }); diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index b7b0820565..ed34e12051 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -627,7 +627,7 @@ describe('server-v2 /api/v1/sessions', () => { method: 'chat_title', params: { chat_content: - 'user 1: first REST prompt\nuser 2: second REST prompt\nuser 3: third REST prompt', + 'user: first REST prompt\nuser: second REST prompt\nuser: third REST prompt', }, }); From bbcbc0929cc2f277e085f414dfbbde63c3db5b9a Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Fri, 31 Jul 2026 19:45:41 +0800 Subject: [PATCH 47/47] refactor(tui): ask for the session title only once per session attach --- .../tui/controllers/session-event-handler.ts | 40 ++----- .../session-event-handler-title.test.ts | 109 +++++------------- 2 files changed, 39 insertions(+), 110 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index cb06e01377..8cd1088cca 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -166,7 +166,6 @@ export class SessionEventHandler { private queuedGoalPromotionInFlight = false; private queuedGoalPromotionTimer: ReturnType | undefined; private titleGenerationDisabled = false; - private titleGenerationEpoch = 0; resetRuntimeState(): void { this.backgroundTasks.clear(); @@ -185,7 +184,6 @@ export class SessionEventHandler { this.queuedGoalPromotionPending = false; this.queuedGoalPromotionInFlight = false; this.clearQueuedGoalPromotionTimer(); - this.titleGenerationEpoch += 1; this.titleGenerationDisabled = false; this.stopAllMcpServerStatusSpinners(); } @@ -391,15 +389,14 @@ export class SessionEventHandler { } } this.pluginMcpToolsUsedInTurn.clear(); - this.requestSessionTitleGeneration(); this.scheduleQueuedGoalPromotion(); } /** * Seeds the title-generation gate from the persisted title state (read off * the resumed session's summary): a session whose title was already - * generated or customized has nothing left to ask for, so later turns skip - * the internally no-op generation call. Only ever closes the gate — + * generated or customized has nothing left to ask for, so the one-shot + * request is skipped. Only ever closes the gate — * reopening stays with `resetRuntimeState` on a session switch. */ syncTitleGenerationGate(titleKind: SessionTitleKind | undefined): void { @@ -409,34 +406,21 @@ export class SessionEventHandler { } /** - * Best-effort auto title: right after a prompt is accepted by the engine - * (and again after each completed turn), ask the engine to generate a - * title from the first prompts until one lands. The engine - * overwrites the prompt-derived easy title but never a custom title - * (enforced server-side), and dedupes in-flight requests. A resolved string - * means a title was applied — stop asking so later turns don't regenerate - * over it; a resolved `undefined` (no managed login / no prompt yet / a - * custom title) just retries on the next turn; a rejection (v1 engine, - * dead RPC) disables further attempts for this session. The generated - * title lands through the regular `session.meta.updated` event. + * Best-effort auto title: right after a prompt is accepted by the engine, + * ask it once to generate a title from the first prompts. One shot per + * session attach — the prompt-derived easy title is an acceptable + * fallback, so the outcome is not acted on and failures (no managed login, + * v1 engine, dead RPC) are not retried. The engine overwrites the + * prompt-derived easy title but never a custom title (enforced + * server-side), and a generated title lands through the regular + * `session.meta.updated` event. */ requestSessionTitleGeneration(): void { if (this.titleGenerationDisabled) return; const { sessionId } = this.host.state.appState; if (sessionId.length === 0) return; - const epoch = this.titleGenerationEpoch; - const isCurrentGeneration = () => - epoch === this.titleGenerationEpoch && sessionId === this.host.state.appState.sessionId; - void this.host.harness.generateSessionTitle({ id: sessionId }).then( - (title) => { - if (!isCurrentGeneration()) return; - if (title !== undefined) this.titleGenerationDisabled = true; - }, - () => { - if (!isCurrentGeneration()) return; - this.titleGenerationDisabled = true; - }, - ); + this.titleGenerationDisabled = true; + void this.host.harness.generateSessionTitle({ id: sessionId }).catch(() => undefined); } private handleStepBegin(event: TurnStepStartedEvent): void { diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts index 36d7f956a4..da33a95b2c 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; import { getBuiltInPalette } from '#/tui/theme'; -function makeHost(options: { sessionTitle?: string | null; generateTitle?: () => Promise } = {}) { +function makeHost(options: { generateTitle?: () => Promise } = {}) { const harness = { generateSessionTitle: vi.fn(options.generateTitle ?? (async () => undefined)), }; @@ -11,7 +11,7 @@ function makeHost(options: { sessionTitle?: string | null; generateTitle?: () => state: { appState: { sessionId: 's1', - sessionTitle: options.sessionTitle ?? null, + sessionTitle: null, workDir: '/tmp/work', streamingPhase: 'waiting', model: 'kimi-model', @@ -75,123 +75,68 @@ async function flushMicrotasks() { } describe('session auto title generation', () => { - it('requests a title after a turn ends while the session has none', () => { - const { host, harness } = makeHost(); - const handler = new SessionEventHandler(host); - - handler.handleEvent(turnEndedEvent(), vi.fn()); - - expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); - }); - - it('requests a title after a turn ends even when an easy title exists', () => { - const { host, harness } = makeHost({ sessionTitle: '首条 prompt 的截断标题' }); - const handler = new SessionEventHandler(host); - - handler.handleEvent(turnEndedEvent(), vi.fn()); - - expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); - }); - - it('stops requesting after a title was generated', async () => { - const { host, harness } = makeHost({ generateTitle: async () => '生成的标题' }); + it.each([ + ['unavailable', async (): Promise => undefined], + ['applied', async (): Promise => '生成的标题'], + [ + 'rejected', + async (): Promise => { + throw new Error('core rpc unavailable'); + }, + ], + ] as const)('requests only once per runtime when the attempt is %s', async (_outcome, generateTitle) => { + const { host, harness } = makeHost({ generateTitle }); const handler = new SessionEventHandler(host); - handler.handleEvent(turnEndedEvent(), vi.fn()); + handler.requestSessionTitleGeneration(); await flushMicrotasks(); - handler.handleEvent(turnEndedEvent(), vi.fn()); + handler.requestSessionTitleGeneration(); expect(harness.generateSessionTitle).toHaveBeenCalledTimes(1); + expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); }); - it('keeps requesting after an unavailable (undefined) result', async () => { + it('does not request a title when a turn ends', () => { const { host, harness } = makeHost(); const handler = new SessionEventHandler(host); handler.handleEvent(turnEndedEvent(), vi.fn()); - await flushMicrotasks(); - handler.handleEvent(turnEndedEvent(), vi.fn()); - - expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2); - }); - - it('stops requesting after the harness rejects, and resetRuntimeState re-enables', async () => { - const { host, harness } = makeHost({ - generateTitle: async () => { - throw new Error('not implemented'); - }, - }); - const handler = new SessionEventHandler(host); - - handler.handleEvent(turnEndedEvent(), vi.fn()); - await flushMicrotasks(); - handler.handleEvent(turnEndedEvent(), vi.fn()); - expect(harness.generateSessionTitle).toHaveBeenCalledTimes(1); - handler.resetRuntimeState(); - handler.handleEvent(turnEndedEvent(), vi.fn()); - expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2); - }); - - it('ignores a generated title from the previous session after runtime reset', async () => { - let resolveTitle: ((title: string | undefined) => void) | undefined; - const pendingTitle = new Promise((resolve) => { - resolveTitle = resolve; - }); - const { host, harness } = makeHost(); - harness.generateSessionTitle.mockReturnValueOnce(pendingTitle); - const handler = new SessionEventHandler(host); - - handler.handleEvent(turnEndedEvent('s1'), vi.fn()); - host.state.appState.sessionId = 's2'; - handler.resetRuntimeState(); - resolveTitle?.('s1 generated title'); - await flushMicrotasks(); - handler.handleEvent(turnEndedEvent('s2'), vi.fn()); - - expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2); - expect(harness.generateSessionTitle).toHaveBeenLastCalledWith({ id: 's2' }); + expect(harness.generateSessionTitle).not.toHaveBeenCalled(); }); - it('ignores a rejection from the previous runtime after same-session reset', async () => { - let rejectTitle: ((error: Error) => void) | undefined; - const pendingTitle = new Promise((_resolve, reject) => { - rejectTitle = reject; - }); + it('grants a fresh attempt on runtime reset', () => { const { host, harness } = makeHost(); - harness.generateSessionTitle.mockReturnValueOnce(pendingTitle); const handler = new SessionEventHandler(host); - handler.handleEvent(turnEndedEvent(), vi.fn()); + handler.requestSessionTitleGeneration(); handler.resetRuntimeState(); - rejectTitle?.(new Error('stale request failed')); - await flushMicrotasks(); - handler.handleEvent(turnEndedEvent(), vi.fn()); + handler.requestSessionTitleGeneration(); expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2); }); it.each(['generated', 'custom'] as const)( - 'stops requesting when the resumed session already has a %s title', + 'skips the request when the resumed session already has a %s title', (titleKind) => { const { host, harness } = makeHost(); const handler = new SessionEventHandler(host); handler.syncTitleGenerationGate(titleKind); - handler.handleEvent(turnEndedEvent(), vi.fn()); + handler.requestSessionTitleGeneration(); expect(harness.generateSessionTitle).not.toHaveBeenCalled(); }, ); it.each(['replaceable', undefined] as const)( - 'keeps requesting when the resumed title state is %s', + 'requests when the resumed title state is %s', (titleKind) => { const { host, harness } = makeHost(); const handler = new SessionEventHandler(host); handler.syncTitleGenerationGate(titleKind); - handler.handleEvent(turnEndedEvent(), vi.fn()); + handler.requestSessionTitleGeneration(); expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); }, @@ -203,7 +148,7 @@ describe('session auto title generation', () => { handler.syncTitleGenerationGate('generated'); handler.resetRuntimeState(); - handler.handleEvent(turnEndedEvent(), vi.fn()); + handler.requestSessionTitleGeneration(); expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); }); @@ -222,7 +167,7 @@ describe('session auto title generation', () => { } as const, vi.fn(), ); - handler.handleEvent(turnEndedEvent(), vi.fn()); + handler.requestSessionTitleGeneration(); expect(harness.generateSessionTitle).not.toHaveBeenCalled(); });