diff --git a/.changeset/agent-core-v2-session-title.md b/.changeset/agent-core-v2-session-title.md new file mode 100644 index 000000000..17df2875d --- /dev/null +++ b/.changeset/agent-core-v2-session-title.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/agent-core-v2": minor +--- + +Add the Session-scoped `ISessionTitleService` for managed AI session titles: composes the excerpt sent to the platform chat_title tool from the main agent's conversation (the first user prompts, the strict `first_turn` pair, or the head+tail `digest` for multi-turn sessions; assistant segments keep only final text), persists the result with a `titleKind` (`replaceable` / `generated` / `custom`) that never overwrites a user-renamed title unless explicitly forced, and rebroadcasts `session.meta.updated`. Gated by the new experimental `auto_session_title` flag and a managed OAuth login. diff --git a/.changeset/auto-session-title.md b/.changeset/auto-session-title.md new file mode 100644 index 000000000..253b97ff0 --- /dev/null +++ b/.changeset/auto-session-title.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-sdk": minor +--- + +Add `generateSessionTitle` (v2 engine) for managed AI session titles: optional `force` regeneration over generated/custom titles and selectable conversation excerpts (`user_prompts` / `first_turn` / `digest`). Gated by the experimental `auto_session_title` flag and a managed OAuth login. diff --git a/.changeset/kap-server-session-title-route.md b/.changeset/kap-server-session-title-route.md new file mode 100644 index 000000000..2c4ab9108 --- /dev/null +++ b/.changeset/kap-server-session-title-route.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kap-server": patch +--- + +Add `POST /api/v1/sessions/{session_id}/title/generate` with an optional `{ "force": true, "source": "user_prompts" | "first_turn" | "digest" }` body; unknown sessions return 40401 and unavailable generation (flag off, no managed login, no prompt yet, backend failure) returns the new 40923 SESSION_TITLE_UNAVAILABLE. diff --git a/.changeset/klient-session-title.md b/.changeset/klient-session-title.md new file mode 100644 index 000000000..833b46b1c --- /dev/null +++ b/.changeset/klient-session-title.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/klient": patch +--- + +Expose `session(id).generateTitle({ force, source })` on the session facade and the matching `sessionTitleService` wire contract. diff --git a/.changeset/oauth-chat-title.md b/.changeset/oauth-chat-title.md new file mode 100644 index 000000000..329c6fe18 --- /dev/null +++ b/.changeset/oauth-chat-title.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code-oauth": patch +--- + +Add `fetchChatTitle` for the managed platform `/tools` `chat_title` method: protocol headers, an 8s timeout, response validation, and structured failures. diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 6698b6bb7..b8def9ff8 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -448,7 +448,7 @@ export interface SessionStateSnapshot { readonly id: string; readonly version?: number; readonly title?: string; - readonly isCustomTitle?: boolean; + readonly titleKind?: 'replaceable' | 'generated' | 'custom'; readonly lastPrompt?: string; readonly createdAt: number; readonly updatedAt: number; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 33e343ba4..92bf96165 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -129,6 +129,11 @@ export * from '#/session/sessionActivity/sessionActivity'; export * from '#/session/sessionActivity/sessionActivityService'; export * from '#/session/sessionActivity/sessionOutcomeMirror'; export * from '#/session/sessionActivity/sessionOutcomeMirrorService'; +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/sessionMetadata/promptMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/promptMetadata.ts index 611a495bb..a2d12da6d 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/promptMetadata.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/promptMetadata.ts @@ -14,7 +14,7 @@ import type { IEventService } from '#/app/event/event'; import { titleFromPromptMetadataText } from '#/agent/prompt/promptMetadataText'; -import type { ISessionMetadata } from './sessionMetadata'; +import type { ISessionMetadata, SessionTitleKind } from './sessionMetadata'; export function isUntitled(title: string | undefined): boolean { return title === undefined || title.trim().length === 0 || title === 'New Session'; @@ -32,12 +32,12 @@ export async function applyPromptMetadataUpdate( ): Promise { 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({ @@ -48,7 +48,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/session/sessionMetadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts index e7af38ce3..fbf7c13e9 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts @@ -25,11 +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 isCustomTitle?: boolean; + readonly titleKind?: SessionTitleKind; readonly lastPrompt?: string; readonly createdAt: number; readonly updatedAt: number; @@ -56,6 +58,17 @@ export interface ISessionMetadata { read(): Promise; update(patch: SessionMetaPatch, opts?: { readonly touchUpdatedAt?: boolean }): Promise; setTitle(title: string): Promise; + /** + * 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. + * `force` skips the kind check entirely (explicit user-requested + * regeneration — last writer wins). + */ + setGeneratedTitleIfUncustomized( + title: string, + opts?: { force?: 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 8cd2b79a9..6de1a390f 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -11,13 +11,23 @@ * backfilled and persisted on load for documents written before the seeding * existed (without touching `updatedAt`, so a format heal never reorders * session listings). `updatedAt` tracks content activity only: management - * writes (rename via `setTitle`, archive/restore via `setArchived`) keep the - * persisted value through `touchUpdatedAt: false`, an explicit - * `patch.updatedAt` always wins (fork restores the source's recency), and - * agent registration is a structural write that never touches it — neither - * when resume materializes a cold session's agents, nor when a runtime - * subagent registers mid-turn (the turn's own submit/end moments carry - * recency). Bound at Session scope. + * writes (rename via `setTitle`, archive/restore via `setArchived`, the + * generated-title write-back) keep the persisted value through + * `touchUpdatedAt: false`, an explicit `patch.updatedAt` always wins (fork + * restores the source's recency), and agent registration is a structural + * write that never touches it — neither when resume materializes a cold + * session's agents, nor when a runtime subagent registers mid-turn (the + * turn's own submit/end moments carry recency). 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) 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-checks the title kind inside the + * queued write, so a custom title set while a generation was in flight is + * never overwritten — unless the caller passes `force` (explicit + * regeneration, last writer wins). Bound at Session scope. * * Read-model mirroring (flag `persistence_minidb_readmodel`): after a metadata * update is persisted, the fresh summary is recorded into the App-scoped @@ -55,6 +65,7 @@ import { type SessionMeta, type SessionMetadataChangedEvent, type SessionMetaPatch, + type SessionTitleKind, } from './sessionMetadata'; const META_KEY = 'state.json'; @@ -119,28 +130,42 @@ export class SessionMetadata extends Service implements ISessionMetadata { patch: SessionMetaPatch, opts?: { readonly touchUpdatedAt?: boolean }, ): Promise { - return this.enqueueUpdate(() => this.applyUpdate(patch, opts)); + return this.enqueueUpdate(async () => { + await this.applyUpdate(patch, opts); + }); } private async applyUpdate( patch: SessionMetaPatch, opts?: { readonly touchUpdatedAt?: boolean }, - ): Promise { + ): Promise { await this.ready; - if (this.disposed) return; + if (this.disposed) return false; const updatedAt = patch.updatedAt ?? (opts?.touchUpdatedAt === false ? this.data.updatedAt : Date.now()); this.data = { ...this.data, ...patch, updatedAt }; - await this.store.set(this.scope, META_KEY, this.data); - if (this.disposed) return; + await this.store.set(this.scope, META_KEY, encodeSessionMeta(this.data)); + if (this.disposed) return false; this.mirrorToReadModel(); this._onDidChangeMetadata.fire({ changed: Object.keys(patch) as (keyof SessionMeta)[], }); + return true; } async setTitle(title: string): Promise { - await this.update({ title, isCustomTitle: true }, { touchUpdatedAt: false }); + await this.update({ title, titleKind: 'custom' }, { touchUpdatedAt: false }); + } + + async setGeneratedTitleIfUncustomized( + title: string, + opts?: { force?: boolean }, + ): Promise { + return this.enqueueUpdate(async () => { + await this.ready; + if (opts?.force !== true && this.data.titleKind === 'custom') return false; + return this.applyUpdate({ title, titleKind: 'generated' }, { touchUpdatedAt: false }); + }); } async setArchived(archived: boolean): Promise { @@ -160,9 +185,12 @@ export class SessionMetadata extends Service implements ISessionMetadata { }); } - private enqueueUpdate(work: () => Promise): Promise { + private enqueueUpdate(work: () => Promise): Promise { const run = this.updateQueue.then(work, work); - const tracked = run.catch(() => {}); + const tracked: Promise = run.then( + () => undefined, + () => undefined, + ); this.updateQueue = tracked; pendingWrites.add(tracked); void tracked.finally(() => pendingWrites.delete(tracked)); @@ -201,13 +229,17 @@ export class SessionMetadata extends Service 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 ?? {}, 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; } @@ -222,7 +254,7 @@ export class SessionMetadata extends Service 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.mirrorToReadModel(); this.log.debug('session metadata created', { sessionId: this.ctx.sessionId }); } @@ -248,28 +280,83 @@ function recordEquals(a: AgentMeta['labels'], b: AgentMeta['labels']): boolean { } export function normalizeSessionMeta(raw: SessionMeta, sessionId: string): SessionMeta { - const legacy = raw as unknown as { - createdAt?: unknown; - updatedAt?: unknown; - workDir?: 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 = - raw.cwd ?? (typeof legacy.workDir === 'string' && legacy.workDir.length > 0 - ? legacy.workDir + clean.cwd ?? (typeof legacyWorkDir === 'string' && legacyWorkDir.length > 0 + ? legacyWorkDir : undefined); - if (raw.version === SESSION_META_VERSION) { - return cwd === raw.cwd ? raw : { ...raw, cwd }; - } + const { title, titleKind } = normalizedTitle; return { - ...raw, - id: sessionId, + ...clean, + id: clean.version === SESSION_META_VERSION ? clean.id : sessionId, version: SESSION_META_VERSION, cwd, - createdAt: toEpochMs(legacy.createdAt), - updatedAt: toEpochMs(legacy.updatedAt), + title, + 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 && raw.isCustomTitle === true) { + return { title, titleKind: 'custom' }; + } + if (title !== undefined && isSessionTitleKind(raw.titleKind)) { + return { title, titleKind: raw.titleKind }; + } + if (title !== undefined && raw.isCustomTitle === false) { + return { title, titleKind: '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'; +} + +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, '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/agentTitlePromptSource.ts b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts new file mode 100644 index 000000000..98bb4f96f --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts @@ -0,0 +1,44 @@ +/** + * `sessionTitle` domain (L6) — title prompt projection contract. + * + * Defines the Agent-scoped `IAgentTitlePromptSource` used to read the first + * active natural-language prompts from the live conversation context, plus + * the turn excerpts behind the `first_turn` / `digest` title sources. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +/** + * The first turn's excerpt: the opening natural-language user prompt and the + * final assistant text of that turn. Either side is `undefined` when the + * live window does not (yet) hold it — `first_turn` generation stays strict + * and reports unavailability instead of degrading. + */ +export interface TitleTurnExcerpt { + readonly user?: string | undefined; + readonly assistant?: string | undefined; +} + +/** + * The whole-conversation digest excerpt: the first and last natural-language + * user prompts (collapsed into one when the conversation has a single + * prompt) and the final assistant text of the latest turn. + */ +export interface TitleDigestExcerpt { + readonly firstUser?: string | undefined; + readonly lastUser?: string | undefined; + readonly assistant?: string | undefined; +} + +export interface IAgentTitlePromptSource { + readonly _serviceBrand: undefined; + + firstUserPrompts(limit: number): Promise; + + firstTurnExcerpt(): Promise; + + digestExcerpt(): 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 000000000..45edbab57 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts @@ -0,0 +1,136 @@ +/** + * `sessionTitle` domain (L6) — `IAgentTitlePromptSource` implementation. + * + * 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, and projects the turn excerpts behind the `first_turn` / + * `digest` title sources: assistant segments keep only the final natural + * language text of the turn (tool calls, thinking, and media parts never + * contribute; the shared metadata sanitizer redacts secrets and long + * base64-looking runs). 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 { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { IAgentPromptService } from '#/agent/prompt/prompt'; +import { + promptMetadataTextFromContentParts, + promptMetadataTextFromText, +} from '#/agent/prompt/promptMetadataText'; +import type { ContentPart } from '#/kosong/contract/message'; + +import { + IAgentTitlePromptSource, + type TitleDigestExcerpt, + type TitleTurnExcerpt, +} from './agentTitlePromptSource'; + +export class AgentTitlePromptSourceService implements IAgentTitlePromptSource { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IAgentPromptService private readonly prompt: IAgentPromptService, + ) {} + + async firstUserPrompts(limit: number): Promise { + if (!Number.isSafeInteger(limit) || limit <= 0) return []; + + 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 this.combinedMessages()) add(message); + return result; + } + + async firstTurnExcerpt(): Promise { + const all = this.combinedMessages(); + const firstUserIndex = all.findIndex(isNaturalLanguagePrompt); + if (firstUserIndex < 0) return {}; + const user = promptMetadataTextFromContentParts(all[firstUserIndex]!.content); + const span: ContextMessage[] = []; + for (const message of all.slice(firstUserIndex + 1)) { + if (isNaturalLanguagePrompt(message)) break; + span.push(message); + } + return { user, assistant: finalAssistantText(span) }; + } + + async digestExcerpt(): Promise { + const all = this.combinedMessages(); + const firstUserIndex = all.findIndex(isNaturalLanguagePrompt); + if (firstUserIndex < 0) return {}; + let lastUserIndex = -1; + for (let index = all.length - 1; index >= 0; index--) { + if (isNaturalLanguagePrompt(all[index]!)) { + lastUserIndex = index; + break; + } + } + const firstUser = promptMetadataTextFromContentParts(all[firstUserIndex]!.content); + const lastUser = + lastUserIndex > firstUserIndex + ? promptMetadataTextFromContentParts(all[lastUserIndex]!.content) + : undefined; + const assistant = + finalAssistantText(all.slice(lastUserIndex + 1)) ?? + finalAssistantText(all.slice(firstUserIndex + 1)); + return { firstUser, lastUser, assistant }; + } + + private combinedMessages(): ContextMessage[] { + const queue = this.prompt.list(); + const all = [...this.context.get()]; + if (queue.active !== undefined) all.push(queue.active.message); + for (const item of queue.pending) all.push(item.message); + return all; + } +} + +function isNaturalLanguagePrompt(message: ContextMessage): boolean { + if (message.role !== 'user') return false; + const origin = message.origin; + return origin === undefined || origin.kind === 'user'; +} + +function finalAssistantText(messages: readonly ContextMessage[]): string | undefined { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]!; + if (message.role !== 'assistant') continue; + const text = assistantTextFromContentParts(message.content); + if (text !== undefined) return text; + } + return undefined; +} + +function assistantTextFromContentParts(parts: readonly ContentPart[]): string | undefined { + const texts: string[] = []; + for (const part of parts) { + if (part.type === 'text' && part.text.trim().length > 0) texts.push(part.text); + } + if (texts.length === 0) return undefined; + return promptMetadataTextFromText(texts.join('\n')); +} + +registerScopedService( + LifecycleScope.Agent, + IAgentTitlePromptSource, + AgentTitlePromptSourceService, + ScopeActivation.OnDemand, + 'sessionTitle', +); 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 000000000..0303878b2 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionTitle/flag.ts @@ -0,0 +1,25 @@ +/** + * `sessionTitle` domain — experimental flag for AI session title generation. + * + * Gates every `generateTitle` entry point (the kap-server route, klient, and + * through them the desktop/web auto trigger and rename-field action). Off by + * default; enable via `KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE`, the master + * `KIMI_CODE_EXPERIMENTAL_FLAG`, or the `[experimental]` config section. + */ + +import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; + +export const AUTO_SESSION_TITLE_FLAG_ID = 'auto_session_title'; +export const AUTO_SESSION_TITLE_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE'; + +export const sessionTitleFlag: FlagDefinitionInput = { + id: AUTO_SESSION_TITLE_FLAG_ID, + title: 'AI session titles', + description: + 'Generate concise session titles from the conversation through the managed chat_title tool: clients auto-generate once the first turn completes and offer on-demand regeneration in the rename field.', + env: AUTO_SESSION_TITLE_FLAG_ENV, + default: false, + surface: 'core', +}; + +registerFlagDefinition(sessionTitleFlag); 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 000000000..b5e367093 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts @@ -0,0 +1,35 @@ +/** + * `sessionTitle` domain (L6) — session title generation contract. + * + * 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 — unless the caller passes `force` (an explicit + * user-requested regeneration). + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +/** + * Which conversation excerpt a title generation draws from: + * - `user_prompts` (default): the first natural-language user prompts. + * - `first_turn`: the opening user prompt plus the first turn's final + * assistant text; strict — unavailable until the first turn has produced + * an assistant reply. + * - `digest`: first user prompt + latest user prompt + the latest turn's + * final assistant text, using whatever the (possibly compacted) window + * still holds; meant for explicit regeneration on multi-turn sessions. + */ +export type SessionTitleSource = 'user_prompts' | 'first_turn' | 'digest'; + +export interface ISessionTitleService { + readonly _serviceBrand: undefined; + + generateTitle(opts?: { + force?: boolean; + source?: SessionTitleSource; + }): 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 000000000..1d45e9ea5 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -0,0 +1,229 @@ +/** + * `sessionTitle` domain (L6) — `ISessionTitleService` implementation. + * + * 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 experimental `auto_session_title` 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. An already-generated title is not + * regenerated. Concurrent calls coalesce onto one shared in-flight + * generation. `force` requests an explicit user-driven regeneration: it + * bypasses the in-flight coalescing and both title-kind guards, and the + * applied title is marked `generated` (a previous custom marking is + * dropped). The `source` option picks the conversation excerpt sent to the + * backend (see `SessionTitleSource`): the default first-prompts window, the + * strict `first_turn` user+assistant pair, or the head+tail `digest` for + * multi-turn regeneration. + * 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 { + KIMI_CODE_PROVIDER_NAME, + OAuthError, + fetchChatTitle, + kimiCodeToolsUrl, + parseKimiCodeCustomHeaders, + resolveKimiCodeRuntimeAuth, +} from '@moonshot-ai/kimi-code-oauth'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { IFlagService } from '#/app/flag/flag'; +import { ILogService } from '#/_base/log/log'; +import { IOAuthService } from '#/app/auth/auth'; +import { IEventService } from '#/app/event/event'; +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 } from '#/session/sessionMetadata/sessionMetadata'; + +import { IAgentTitlePromptSource } from './agentTitlePromptSource'; +import { AUTO_SESSION_TITLE_FLAG_ID } from './flag'; +import { ISessionTitleService, type SessionTitleSource } from './sessionTitle'; + +const MAX_GENERATED_TITLE_LENGTH = 200; + +const MAX_TITLE_INPUT_LENGTH = 1000; + +const MAX_TITLE_PROMPTS = 3; + +/** Per-segment excerpt budgets inside the composed chat_content. */ +const MAX_TITLE_USER_SEGMENT = 300; + +const MAX_TITLE_FIRST_TURN_ASSISTANT = 600; + +const MAX_TITLE_DIGEST_ASSISTANT = 400; + +export class SessionTitleService implements ISessionTitleService { + declare readonly _serviceBrand: undefined; + + private _shared: Promise | undefined; + + constructor( + @ISessionContext private readonly ctx: ISessionContext, + @ISessionMetadata private readonly metadata: ISessionMetadata, + @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, + @IFlagService private readonly flags: IFlagService, + @ILogService private readonly log: ILogService, + ) {} + + async generateTitle(opts?: { + force?: boolean; + source?: SessionTitleSource; + }): Promise { + const force = opts?.force === true; + const source = opts?.source ?? 'user_prompts'; + if (force) return this.generateTitleOnce(true, source); + if (this._shared !== undefined) return this._shared; + const tracked = this.generateTitleOnce(false, source).finally(() => { + if (this._shared === tracked) this._shared = undefined; + }); + this._shared = tracked; + return tracked; + } + + private async generateTitleOnce( + force: boolean, + source: SessionTitleSource, + ): Promise { + if (!this.flags.enabled(AUTO_SESSION_TITLE_FLAG_ID)) return undefined; + const current = await this.metadata.read(); + if (!force) { + if (current.titleKind === 'custom') return undefined; + if (current.titleKind === 'generated') return undefined; + } + const main = this.agentLifecycle.get(MAIN_AGENT_ID); + if (main === undefined) return undefined; + const promptSource = main.accessor.get(IAgentTitlePromptSource); + const input = await composeTitleInput(promptSource, source); + if (input === undefined) return undefined; + return this.generateAndApply(input, force); + } + + private async generateAndApply( + chatContent: string, + force: boolean, + ): Promise { + const current = await this.metadata.read(); + if (!force && current.titleKind === 'custom') return undefined; + const provider = this.providers.get(KIMI_CODE_PROVIDER_NAME); + if ( + provider === undefined || + !isOAuthCatalogVendor(provider.type) || + provider.oauth === undefined + ) { + return undefined; + } + 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 { + token = await tokenProvider.getAccessToken(); + } catch (error) { + if (!(error instanceof OAuthError)) throw error; + this.log.debug(`chat_title request unavailable: ${error.message}`); + return undefined; + } + 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) { + 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; + } + const title = result.title.slice(0, MAX_GENERATED_TITLE_LENGTH); + const applied = await this.metadata.setGeneratedTitleIfUncustomized(title, { force }); + if (!applied) return undefined; + this.eventService.publish({ + type: 'session.meta.updated', + payload: { + agentId: 'main', + sessionId: this.ctx.sessionId, + title, + patch: { title, isCustomTitle: false }, + }, + }); + return title; + } +} + +function titleInputFromPrompts(prompts: readonly string[]): string | undefined { + if (prompts.length === 0) return undefined; + return prompts + .map((prompt) => `user: ${prompt}`) + .join('\n') + .slice(0, MAX_TITLE_INPUT_LENGTH); +} + +async function composeTitleInput( + promptSource: IAgentTitlePromptSource, + source: SessionTitleSource, +): Promise { + if (source === 'first_turn') { + const excerpt = await promptSource.firstTurnExcerpt(); + if (excerpt.user === undefined || excerpt.assistant === undefined) return undefined; + return [ + `user: ${excerpt.user.slice(0, MAX_TITLE_USER_SEGMENT)}`, + `assistant: ${excerpt.assistant.slice(0, MAX_TITLE_FIRST_TURN_ASSISTANT)}`, + ].join('\n'); + } + if (source === 'digest') { + const excerpt = await promptSource.digestExcerpt(); + const lines: string[] = []; + if (excerpt.firstUser !== undefined) { + lines.push(`user: ${excerpt.firstUser.slice(0, MAX_TITLE_USER_SEGMENT)}`); + } + if (excerpt.lastUser !== undefined) { + lines.push(`user: ${excerpt.lastUser.slice(0, MAX_TITLE_USER_SEGMENT)}`); + } + if (excerpt.assistant !== undefined) { + lines.push(`assistant: ${excerpt.assistant.slice(0, MAX_TITLE_DIGEST_ASSISTANT)}`); + } + return lines.length === 0 ? undefined : lines.join('\n'); + } + return titleInputFromPrompts(await promptSource.firstUserPrompts(MAX_TITLE_PROMPTS)); +} + +registerScopedService( + LifecycleScope.Session, + ISessionTitleService, + SessionTitleService, + ScopeActivation.OnScopeCreated, + 'sessionTitle', +); diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 7942c9a2f..22493e986 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -574,7 +574,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec await targetMeta.update({ title, - isCustomTitle: opts.title !== undefined ? true : sourceMeta?.isCustomTitle === true, + titleKind: opts.title !== undefined ? 'custom' : 'replaceable', forkedFrom: sourceId, archived: false, updatedAt: toEpochMs(sourceMeta?.updatedAt) || Date.now(), diff --git a/packages/agent-core-v2/test/agent/prompt/promptMetadataText.test.ts b/packages/agent-core-v2/test/agent/prompt/promptMetadataText.test.ts index 629d1fa58..e93d59ff9 100644 --- a/packages/agent-core-v2/test/agent/prompt/promptMetadataText.test.ts +++ b/packages/agent-core-v2/test/agent/prompt/promptMetadataText.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 + * - prompt metadata updates retain the latest sanitized prompt and derive + * the easy title */ import { describe, expect, it } from 'vitest'; import { promptMetadataTextFromContentParts } from '#/agent/prompt/promptMetadataText'; +import { + applyPromptMetadataUpdate, + type PromptMetadataUpdateTarget, +} from '#/session/sessionMetadata/promptMetadata'; 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' }, @@ -47,3 +59,47 @@ describe('promptMetadataTextFromContentParts', () => { 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('updates the latest prompt and derives the easy title', async () => { + const { target, readMeta } = createTarget(); + + await applyPromptMetadataUpdate(target, '第一条'); + await applyPromptMetadataUpdate(target, '第二条'); + + expect(readMeta().lastPrompt).toBe('第二条'); + expect(readMeta().title).toBe('第一条'); + expect(readMeta().titleKind).toBe('replaceable'); + }); + + it('updates metadata for slash activations', async () => { + const { target, readMeta } = createTarget(); + + await applyPromptMetadataUpdate(target, '/compact'); + + expect(readMeta().lastPrompt).toBe('/compact'); + expect(readMeta().title).toBe('/compact'); + }); +}); 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 2e2c9fd03..648776590 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -1021,6 +1021,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/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index 85410d3da..aba489639 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -371,7 +371,7 @@ 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, diff --git a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts index d1b46fc82..77e5a5b6e 100644 --- a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts @@ -155,6 +155,7 @@ function sessionStubs(): ReturnType[] { read: () => Promise.resolve({} as never), update: () => Promise.resolve(), setTitle: () => Promise.resolve(), + setGeneratedTitleIfUncustomized: () => Promise.resolve(false), setArchived: () => Promise.resolve(), registerAgent: () => Promise.resolve(), } satisfies ISessionMetadata), 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 c4c199f11..fe6b7b597 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'; @@ -56,7 +56,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); @@ -94,7 +97,17 @@ 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', titleKind: 'custom', 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', + titleKind: 'generated', + }); }); it('setTitle keeps updatedAt (rename must not reorder listings)', async () => { @@ -236,6 +249,264 @@ 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', + titleKind: 'custom', + }); + + const fresh = createFreshMetadata(ix); + await expect(fresh.read()).resolves.toMatchObject({ + title: 'legacy title', + titleKind: 'custom', + }); + }); + + 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', + version: 2, + createdAt: 1700000000000, + updatedAt: 1700000000000, + archived: false, + title: 'renamed title', + isCustomTitle: true, + customTitle: 'legacy custom title', + }); + + const meta = ix.get(ISessionMetadata); + await expect(meta.read()).resolves.toMatchObject({ + title: 'renamed title', + titleKind: 'custom', + }); + + await meta.update({ archived: true }); + const fresh = createFreshMetadata(ix); + await expect(fresh.read()).resolves.toMatchObject({ + title: 'renamed title', + titleKind: 'custom', + archived: true, + }); + const persisted = await store.get>(META_SCOPE, 'state.json'); + // 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'); + }); + + 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', + 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.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; + 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', + titleKind: 'custom', + }); + }); + 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/agentTitlePromptSourceService.test.ts b/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts new file mode 100644 index 000000000..41c8f63b2 --- /dev/null +++ b/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts @@ -0,0 +1,211 @@ +/** + * 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'; + +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 type { ContentPart } from '#/kosong/contract/message'; +import { IAgentTitlePromptSource } from '#/session/sessionTitle/agentTitlePromptSource'; +import { AgentTitlePromptSourceService } from '#/session/sessionTitle/agentTitlePromptSourceService'; + +const USER_ORIGIN: ContextMessage['origin'] = { kind: 'user' }; + +function userMessage( + id: string, + text: string, + origin: ContextMessage['origin'] = USER_ORIGIN, +): ContextMessage { + return { + id, + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], + origin, + }; +} + +function assistantMessage(id: string, parts: ContentPart[]): ContextMessage { + return { id, role: 'assistant', content: parts, toolCalls: [] }; +} + +function toolMessage(id: string, text: string): ContextMessage { + return { id, role: 'tool', content: [{ type: 'text', text }], toolCalls: [] }; +} + +describe('AgentTitlePromptSource', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let liveMessages: readonly ContextMessage[]; + let queue: ReturnType; + + beforeEach(() => { + liveMessages = []; + queue = { active: undefined, pending: [] }; + disposables = new DisposableStore(); + ix = createServices(disposables, { + additionalServices: (reg) => { + reg.definePartialInstance(IAgentContextMemoryService, { get: () => liveMessages }); + reg.definePartialInstance(IAgentPromptService, { list: () => queue }); + reg.define(IAgentTitlePromptSource, AgentTitlePromptSourceService); + }, + }); + }); + + afterEach(() => { + disposables.dispose(); + }); + + it('returns the first three prompts from the live context and queue in order', async () => { + liveMessages = [userMessage('one', '第一条')]; + queue = { + active: undefined, + pending: [ + { + id: 'two', + userMessageId: 'two', + createdAt: '2026-01-01T00:00:00.000Z', + state: 'pending', + message: userMessage('two', '第二条'), + }, + { + id: 'three', + userMessageId: 'three', + createdAt: '2026-01-01T00:00:01.000Z', + state: 'pending', + message: userMessage('three', '第三条'), + }, + ], + }; + + await expect(ix.get(IAgentTitlePromptSource).firstUserPrompts(3)).resolves.toEqual([ + '第一条', + '第二条', + '第三条', + ]); + }); + + 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 () => { + liveMessages = [ + userMessage('skill', 'expanded skill instructions', { + kind: 'skill_activation', + activationId: 'skill-1', + skillName: 'compact', + trigger: 'user-slash', + }), + userMessage('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([]); + }); + + 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(['同一条']); + }); + + it('firstTurnExcerpt pairs the opening prompt with the turn’s final assistant text', async () => { + liveMessages = [ + userMessage('u1', '帮我写一个快排'), + assistantMessage('a1-think', [{ type: 'think', think: '让我想想' }]), + assistantMessage('a1-text', [{ type: 'text', text: '好的,先写一版' }]), + toolMessage('t1', 'tool output'), + assistantMessage('a2', [ + { type: 'text', text: '这是最终版实现' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } }, + ]), + userMessage('u2', '再加个单测'), + assistantMessage('a3', [{ type: 'text', text: '第二轮的回复' }]), + ]; + + await expect(ix.get(IAgentTitlePromptSource).firstTurnExcerpt()).resolves.toEqual({ + user: '帮我写一个快排', + assistant: '这是最终版实现', + }); + }); + + it('firstTurnExcerpt reports a missing assistant reply until the turn ends', async () => { + liveMessages = [userMessage('u1', '刚发的问题')]; + + await expect(ix.get(IAgentTitlePromptSource).firstTurnExcerpt()).resolves.toEqual({ + user: '刚发的问题', + assistant: undefined, + }); + }); + + it('digestExcerpt anchors the first prompt and lands on the latest turn', async () => { + liveMessages = [ + userMessage('u1', '最初的目标'), + assistantMessage('a1', [{ type: 'text', text: '第一轮回答' }]), + userMessage('u2', '中途追问'), + assistantMessage('a2', [{ type: 'text', text: '中间回答' }]), + userMessage('u3', '最近的要求'), + assistantMessage('a3', [{ type: 'think', think: '思考中' }]), + assistantMessage('a4', [{ type: 'text', text: '最新正文' }]), + ]; + + await expect(ix.get(IAgentTitlePromptSource).digestExcerpt()).resolves.toEqual({ + firstUser: '最初的目标', + lastUser: '最近的要求', + assistant: '最新正文', + }); + }); + + it('digestExcerpt collapses a single-prompt conversation and skips dangling questions', async () => { + liveMessages = [ + userMessage('u1', '唯一的问题'), + assistantMessage('a1', [{ type: 'text', text: '唯一的回答' }]), + userMessage('u2', '还没得到回复的新问题'), + ]; + + await expect(ix.get(IAgentTitlePromptSource).digestExcerpt()).resolves.toEqual({ + firstUser: '唯一的问题', + lastUser: '还没得到回复的新问题', + assistant: '唯一的回答', + }); + + liveMessages = [userMessage('u1', '唯一的问题')]; + await expect(ix.get(IAgentTitlePromptSource).digestExcerpt()).resolves.toEqual({ + firstUser: '唯一的问题', + lastUser: undefined, + assistant: undefined, + }); + }); +}); 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 000000000..7cd64dd33 --- /dev/null +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -0,0 +1,574 @@ +/** + * Scenario: on-demand managed chat_title generation through the session-scoped + * 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 + * vitest run test/session/sessionTitle/sessionTitleService.test.ts`. + */ + +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import { OAuthConnectionError, OAuthUnauthorizedError } from '@moonshot-ai/kimi-code-oauth'; + +import { DisposableStore, type IDisposable } from '#/_base/di/lifecycle'; +import { type IAgentScopeHandle } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { createServices, type TestInstantiationService } from '#/_base/di/test'; +import { Emitter } from '#/_base/event'; +import { IOAuthService } from '#/app/auth/auth'; +import { IFlagService } from '#/app/flag/flag'; +import { type DomainEvent, IEventService } from '#/app/event/event'; +import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; +import { + IProviderService, + type OAuthRef, + type ProviderConfig, +} from '#/kosong/provider/provider'; +import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; +import { + IAgentLifecycleService, + MAIN_AGENT_ID, +} from '#/session/agentLifecycle/agentLifecycle'; +import { + IAgentTitlePromptSource, + type TitleDigestExcerpt, + type TitleTurnExcerpt, +} 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 '#/kosong/provider/providers/kimi/kimi.contrib'; + +import { registerLogServices } from '../../_base/log/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, titleKind: 'custom' }); + } + + async setGeneratedTitleIfUncustomized( + title: string, + opts?: { force?: boolean }, + ): Promise { + if (opts?.force !== true && this.meta.titleKind === 'custom') return false; + await this.update({ title, titleKind: 'generated' }); + return true; + } + + setArchived(archived: boolean): Promise { + return this.update({ archived }); + } + + registerAgent(): Promise { + return Promise.resolve(); + } +} + +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', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let events: FakeEventService; + let metadata: FakeSessionMetadata; + 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 promptSourceImpl: (limit: number) => Promise; + let turnExcerpt: TitleTurnExcerpt; + let digestExcerpt: TitleDigestExcerpt; + let tokenCalls: boolean[]; + let flagEnabled: boolean; + + beforeEach(() => { + tokenError = undefined; + forceTokenError = undefined; + resolvedOAuthRefs = []; + titlePrompts = []; + promptSourceImpl = async (limit) => titlePrompts.slice(0, limit); + turnExcerpt = {}; + digestExcerpt = {}; + tokenCalls = []; + 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); + const promptSource: IAgentTitlePromptSource = { + _serviceBrand: undefined, + firstUserPrompts: (limit) => promptSourceImpl(limit), + firstTurnExcerpt: async () => turnExcerpt, + digestExcerpt: async () => digestExcerpt, + }; + 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(IProviderService, stubProviderService(providers)); + reg.definePartialInstance(IOAuthService, { + resolveTokenProvider: (_provider, oauthRef) => { + resolvedOAuthRefs.push(oauthRef); + return { + getAccessToken: async (options) => { + tokenCalls.push(options?.force === true); + if (tokenError !== undefined) throw tokenError; + if (options?.force === true && forceTokenError !== undefined) { + throw forceTokenError; + } + return 'test-token'; + }, + }; + }, + }); + reg.defineInstance(IHostRequestHeaders, { + headers: { 'User-Agent': 'test' }, + thirdPartyHeaders: {}, + }); + reg.definePartialInstance(IFlagService, { enabled: () => flagEnabled }); + reg.define(ISessionTitleService, SessionTitleService); + }, + }); + ix.get(ISessionTitleService); + }); + + afterEach(() => { + disposables.dispose(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it('is unavailable while the experimental auto_session_title flag is off', async () => { + flagEnabled = false; + titlePrompts = ['hello']; + + await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); + await expect( + ix.get(ISessionTitleService).generateTitle({ force: true, source: 'digest' }), + ).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('replaces the easy title with the generated one', async () => { + titlePrompts = ['帮我看一下这个 Go 的 nil pointer 报错']; + + const title = await ix.get(ISessionTitleService).generateTitle(); + + expect(title).toBe('生成的标题'); + expect(metadata.meta.title).toBe('生成的标题'); + expect(metadata.meta.titleKind).toBe('generated'); + + 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('composes the title input from the recorded prompts in order', async () => { + titlePrompts = ['先帮我搭一个 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: 先帮我搭一个 Vite 项目\nuser: 加上路由\nuser: 现在配一下 ESLint', + }, + }); + }); + + it('truncates the composed title input to the total budget, keeping the head', async () => { + titlePrompts = ['很长的输入'.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: 很长的输入')).toBe(true); + expect(body.params.chat_content).toHaveLength(1000); + }); + + it('returns unavailable when only a slash activation updated lastPrompt', async () => { + await metadata.update({ lastPrompt: '/compact' }); + + 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']; + titlePrompts = ['hello']; + + await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('never overwrites a custom title set while generation is in flight', async () => { + const pendingFetch = createPendingFetch(); + fetchMock.mockImplementationOnce(pendingFetch.fetch); + + titlePrompts = ['hello']; + const generation = ix.get(ISessionTitleService).generateTitle(); + await pendingFetch.started; + await metadata.setTitle('user 取的标题'); + pendingFetch.resolve( + new Response(JSON.stringify({ title: '生成的标题' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + await expect(generation).resolves.toBeUndefined(); + expect(metadata.meta.title).toBe('user 取的标题'); + expect(metadata.meta.titleKind).toBe('custom'); + }); + + 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('force regenerates an already-generated title', async () => { + await metadata.setGeneratedTitleIfUncustomized('已生成的标题'); + titlePrompts = ['hello']; + + await expect( + ix.get(ISessionTitleService).generateTitle({ force: true }), + ).resolves.toBe('生成的标题'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(metadata.meta.title).toBe('生成的标题'); + expect(metadata.meta.titleKind).toBe('generated'); + }); + + it('force overwrites a custom title and drops its custom marking', async () => { + await metadata.setTitle('user 取的标题'); + titlePrompts = ['hello']; + + await expect( + ix.get(ISessionTitleService).generateTitle({ force: true }), + ).resolves.toBe('生成的标题'); + expect(metadata.meta.title).toBe('生成的标题'); + expect(metadata.meta.titleKind).toBe('generated'); + }); + + it('force still degrades when the backend request fails', async () => { + fetchMock.mockImplementationOnce(async () => new Response('', { status: 500 })); + await metadata.setTitle('user 取的标题'); + titlePrompts = ['hello']; + + await expect( + ix.get(ISessionTitleService).generateTitle({ force: true }), + ).resolves.toBeUndefined(); + expect(metadata.meta.title).toBe('user 取的标题'); + expect(metadata.meta.titleKind).toBe('custom'); + }); + + it('first_turn composes the opening prompt with the first reply, within budget', async () => { + turnExcerpt = { user: '最初的问题', assistant: '第一轮的回答' }; + + await expect( + ix.get(ISessionTitleService).generateTitle({ source: 'first_turn' }), + ).resolves.toBe('生成的标题'); + + const [, init] = fetchMock.mock.calls[0]!; + expect(JSON.parse(init?.body as string)).toEqual({ + method: 'chat_title', + params: { chat_content: 'user: 最初的问题\nassistant: 第一轮的回答' }, + }); + }); + + it('first_turn is strict: no assistant reply yet means unavailable', async () => { + turnExcerpt = { user: '只有问题' }; + + await expect( + ix.get(ISessionTitleService).generateTitle({ source: 'first_turn' }), + ).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('first_turn truncates each segment to its budget', async () => { + turnExcerpt = { user: '问'.repeat(500), assistant: '答'.repeat(1000) }; + + await expect( + ix.get(ISessionTitleService).generateTitle({ source: 'first_turn' }), + ).resolves.toBe('生成的标题'); + + const [, init] = fetchMock.mock.calls[0]!; + const content = (JSON.parse(init?.body as string) as { params: { chat_content: string } }) + .params.chat_content; + expect(content).toBe(`user: ${'问'.repeat(300)}\nassistant: ${'答'.repeat(600)}`); + }); + + it('digest composes head and tail segments, tolerating a missing reply', async () => { + digestExcerpt = { firstUser: '开场', lastUser: '最新追问', assistant: '当前进展' }; + + await expect( + ix.get(ISessionTitleService).generateTitle({ source: 'digest' }), + ).resolves.toBe('生成的标题'); + + let [, init] = fetchMock.mock.calls[0]!; + expect(JSON.parse(init?.body as string)).toEqual({ + method: 'chat_title', + params: { chat_content: 'user: 开场\nuser: 最新追问\nassistant: 当前进展' }, + }); + + fetchMock.mockClear(); + digestExcerpt = { firstUser: '开场' }; + await expect( + ix.get(ISessionTitleService).generateTitle({ force: true, source: 'digest' }), + ).resolves.toBe('生成的标题'); + [, init] = fetchMock.mock.calls[0]!; + expect(JSON.parse(init?.body as string)).toEqual({ + method: 'chat_title', + params: { chat_content: 'user: 开场' }, + }); + }); + + it('digest is unavailable when the window yields no segments at all', async () => { + digestExcerpt = {}; + + await expect( + ix.get(ISessionTitleService).generateTitle({ source: 'digest' }), + ).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + 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', titleKind: 'replaceable' }); + + 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 () => { + tokenError = new OAuthUnauthorizedError('re-login required'); + titlePrompts = ['hello']; + + const svc = ix.get(ISessionTitleService); + await expect(svc.generateTitle()).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns unavailable when OAuth token retrieval has an operational failure', async () => { + tokenError = new OAuthConnectionError('connection failed'); + titlePrompts = ['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'); + titlePrompts = ['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'); + titlePrompts = ['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('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'); + titlePrompts = ['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('shares an in-flight generation between concurrent requests', async () => { + const pendingFetch = createPendingFetch(); + fetchMock.mockImplementationOnce(pendingFetch.fetch); + + titlePrompts = ['hello']; + const first = ix.get(ISessionTitleService).generateTitle(); + const second = ix.get(ISessionTitleService).generateTitle(); + await pendingFetch.started; + + pendingFetch.resolve( + new Response(JSON.stringify({ title: '生成的标题' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + await expect(first).resolves.toBe('生成的标题'); + await expect(second).resolves.toBe('生成的标题'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + 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/agent-core-v2/test/session/sessionTitle/titleExcerpt.integration.test.ts b/packages/agent-core-v2/test/session/sessionTitle/titleExcerpt.integration.test.ts new file mode 100644 index 000000000..bd64ba399 --- /dev/null +++ b/packages/agent-core-v2/test/session/sessionTitle/titleExcerpt.integration.test.ts @@ -0,0 +1,94 @@ +/** + * Scenario: the title excerpts read through the REAL context memory — loop + * events fold into assistant messages, tool calls and thinking stay out of + * the excerpt, and the turn's final text wins. Wiring: harness agent (real + * contextMemory + prompt queue) with the real AgentTitlePromptSourceService. + * Run: pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/session/sessionTitle/titleExcerpt.integration.test.ts + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IAgentTitlePromptSource } from '#/session/sessionTitle/agentTitlePromptSource'; + +import { createTestAgent, type TestAgentContext } from '../../harness'; + +describe('title excerpts over the real context memory', () => { + let ctx: TestAgentContext; + + beforeEach(() => { + ctx = createTestAgent(); + }); + + afterEach(async () => { + await ctx.dispose(); + }); + + it('first_turn pairs the opening prompt with the folded assistant final text', async () => { + const context = ctx.get(IAgentContextMemoryService); + context.append({ + role: 'user', + content: [{ type: 'text', text: '帮我部署这个服务' }], + toolCalls: [], + origin: { kind: 'user' }, + }); + context.appendLoopEvent({ type: 'step.begin', uuid: 's1' }); + context.appendLoopEvent({ + type: 'content.part', + stepUuid: 's1', + part: { type: 'text', text: '先看一下配置' }, + }); + context.appendLoopEvent({ + type: 'tool.call', + stepUuid: 's1', + toolCallId: 'c1', + name: 'Read', + args: {}, + }); + context.appendLoopEvent({ + type: 'tool.result', + toolCallId: 'c1', + result: { output: 'file contents', isError: false }, + }); + context.appendLoopEvent({ type: 'step.end', uuid: 's1' }); + context.appendLoopEvent({ type: 'step.begin', uuid: 's2' }); + context.appendLoopEvent({ + type: 'content.part', + stepUuid: 's2', + part: { type: 'think', think: '收尾' }, + }); + context.appendLoopEvent({ + type: 'content.part', + stepUuid: 's2', + part: { type: 'text', text: '部署完成,服务在 8080 端口' }, + }); + context.appendLoopEvent({ type: 'step.end', uuid: 's2' }); + + const source = ctx.get(IAgentTitlePromptSource); + await expect(source.firstTurnExcerpt()).resolves.toEqual({ + user: '帮我部署这个服务', + assistant: '部署完成,服务在 8080 端口', + }); + await expect(source.digestExcerpt()).resolves.toEqual({ + firstUser: '帮我部署这个服务', + lastUser: undefined, + assistant: '部署完成,服务在 8080 端口', + }); + }); + + it('first_turn reports no assistant text while the turn has not produced any', async () => { + const context = ctx.get(IAgentContextMemoryService); + context.append({ + role: 'user', + content: [{ type: 'text', text: '刚发的问题' }], + toolCalls: [], + origin: { kind: 'user' }, + }); + + await expect(ctx.get(IAgentTitlePromptSource).firstTurnExcerpt()).resolves.toEqual({ + user: '刚发的问题', + assistant: undefined, + }); + }); +}); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 842419609..f4818fa62 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -413,6 +413,7 @@ function sessionMetadataStub(agents: Readonly>): ISess }), update: async () => {}, setTitle: async () => {}, + setGeneratedTitleIfUncustomized: async () => false, setArchived: async () => {}, registerAgent: async () => {}, }; 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 b0da2dce2..86bb82bdc 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts @@ -64,7 +64,10 @@ import { type SessionLifecycleHookSlots, } from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; import { ISessionIndexMirror } from '#/app/sessionIndex/sessionIndex'; -import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { + ISessionMetadata, + type SessionMetaPatch, +} from '#/session/sessionMetadata/sessionMetadata'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; import { ISessionProcessRunner } from '#/session/process/processRunner'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; @@ -132,6 +135,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(), }; @@ -1730,6 +1734,53 @@ describe('SessionLifecycleService', () => { }); describe('fork session state', () => { + it('marks the default fork title as replaceable', async () => { + const updates: SessionMetaPatch[] = []; + const svc = await build([ + 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 = await build([ + 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('fork inherits the source session\'s last turn outcome', async () => { const updates: { readonly lastTurnReason?: unknown }[] = []; const metaStub: ISessionMetadata = { diff --git a/packages/kap-server/src/protocol/error-codes.ts b/packages/kap-server/src/protocol/error-codes.ts index 2f05e6857..2fadbc3dd 100644 --- a/packages/kap-server/src/protocol/error-codes.ts +++ b/packages/kap-server/src/protocol/error-codes.ts @@ -118,6 +118,8 @@ export const ErrorCode = { PROVIDER_ALREADY_EXISTS: 40921, /** page_token 损坏 / 版本不符 / 与当前查询条件不匹配,需从首页重新拉取 */ PAGE_TOKEN_MISMATCH: 40922, + /** 会话标题生成不可用(flag 未开 / 无 managed OAuth 登录 / 还没有 prompt / 后端失败) */ + SESSION_TITLE_UNAVAILABLE: 40923, /** approval 60s 超时 */ APPROVAL_EXPIRED: 41001, diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index 6611ee1d3..f735ad6de 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 { ISessionIndex, ISessionMetadata, ISessionLegacyService, + ISessionTitleService, IEventService, IWorkspaceAliases, ISessionLifecycleService, @@ -651,6 +654,65 @@ 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, + // Optional body: `{ "force": true }` requests an explicit regeneration + // that overwrites an already-generated or user-customized title; + // `source` picks the conversation excerpt (`user_prompts` default, + // `first_turn`, `digest`). + body: z.preprocess( + (value) => (value === undefined ? {} : value), + z.object({ + force: z.boolean().optional(), + source: z.enum(['user_prompts', 'first_turn', 'digest']).optional(), + }), + ), + 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 resumeSessionById(core.accessor, 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({ force: req.body.force === true, source: req.body.source }); + if (title === undefined) { + reply.send( + errEnvelope( + ErrorCode.SESSION_TITLE_UNAVAILABLE, + 'session title generation is unavailable (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 3bc3a036e..b03321c43 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -392,6 +392,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/kap-server/test/prompts.test.ts b/packages/kap-server/test/prompts.test.ts index 859c66148..10ceb752a 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, @@ -215,6 +216,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 = getLiveSessionById(server!.core.accessor, 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 = getLiveSessionById(server!.core.accessor, id); diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 3a3d6ede9..56d39545a 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, @@ -27,6 +29,7 @@ import { getLiveSessionById, sessionDirOf, 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'; @@ -104,6 +107,8 @@ describe('server-v2 /api/v1/sessions', () => { }); afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); if (server !== undefined) { await server.close(); server = undefined; @@ -513,6 +518,165 @@ 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(40923); + }); + + 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"', + '', + '[experimental]', + 'auto_session_title = true', + '', + ].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: first REST prompt\nuser: second REST prompt\nuser: third REST prompt', + }, + }); + + const got = await getJson(`/api/v1/sessions/${id}`); + expect(got.body).toMatchObject({ code: 0, data: { title: 'generated from REST' } }); + + // A second non-force call refuses: the title is already generated. + const again = await postJson(`/api/v1/sessions/${id}/title/generate`); + expect(again.body.code).toBe(40923); + + // `force: true` regenerates an already-generated title … + const forced = await postJson<{ title: string }>(`/api/v1/sessions/${id}/title/generate`, { + force: true, + }); + expect(forced.body).toMatchObject({ code: 0, data: { title: 'generated from REST' } }); + + // … and overwrites a user-customized title. + await postJson(`/api/v1/sessions/${id}/profile`, { title: 'custom title' }); + const forcedCustom = await postJson<{ title: string }>( + `/api/v1/sessions/${id}/title/generate`, + { force: true }, + ); + expect(forcedCustom.body).toMatchObject({ code: 0, data: { title: 'generated from REST' } }); + const afterCustom = await getJson(`/api/v1/sessions/${id}`); + expect(afterCustom.body.data.title).toBe('generated from REST'); + + // `source: 'digest'` composes the head+tail user segments server-side + // (this session's turns produced no assistant reply to draw from). + const digested = await postJson<{ title: string }>(`/api/v1/sessions/${id}/title/generate`, { + force: true, + source: 'digest', + }); + expect(digested.body).toMatchObject({ code: 0, data: { title: 'generated from REST' } }); + expect(toolsRequest?.params.chat_content).toBe( + 'user: first REST prompt\nuser: third REST prompt', + ); + }); + + 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/klient/src/contract/global/events.ts b/packages/klient/src/contract/global/events.ts index 126d281ef..905f96521 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; }; } @@ -73,9 +73,9 @@ const sessionMetaUpdatedSchema = z.object({ patch: z.object({ title: z.string().optional(), isCustomTitle: z.boolean().optional(), - lastPrompt: z.string(), + lastPrompt: z.string().optional(), }), -}); +}) satisfies z.ZodType; export const catalogChangedSchema = z.object({ changed: z.array( diff --git a/packages/klient/src/contract/index.ts b/packages/klient/src/contract/index.ts index 510f761c4..ef665c326 100644 --- a/packages/klient/src/contract/index.ts +++ b/packages/klient/src/contract/index.ts @@ -46,6 +46,7 @@ import { import { sessionMetadataContract } from './session/metadata.js'; import { sessionQuestionContract } from './session/question.js'; import { sessionSkillCatalogContract } from './session/skills.js'; +import { sessionTitleContract } from './session/title.js'; export const globalContract: KlientContract = { // core (app scope) @@ -72,6 +73,7 @@ export const globalContract: KlientContract = { sessionApprovalService: sessionApprovalContract, sessionQuestionService: sessionQuestionContract, sessionSkillCatalog: sessionSkillCatalogContract, + sessionTitleService: sessionTitleContract, // agent scope agentPromptService: agentPromptContract, agentSkillService: agentSkillContract, diff --git a/packages/klient/src/contract/session/metadata.ts b/packages/klient/src/contract/session/metadata.ts index e72d6642c..39f50e410 100644 --- a/packages/klient/src/contract/session/metadata.ts +++ b/packages/klient/src/contract/session/metadata.ts @@ -22,7 +22,7 @@ export const sessionMetaSchema = z.object({ id: z.string(), version: z.number().optional(), title: z.string().optional(), - isCustomTitle: z.boolean().optional(), + titleKind: z.enum(['replaceable', 'generated', 'custom']).optional(), lastPrompt: z.string().optional(), createdAt: z.number(), updatedAt: z.number(), @@ -39,7 +39,7 @@ export const sessionMetaSchema = z.object({ export const sessionMetaPatchSchema = z.object({ version: z.number().optional(), title: z.string().optional(), - isCustomTitle: z.boolean().optional(), + titleKind: z.enum(['replaceable', 'generated', 'custom']).optional(), lastPrompt: z.string().optional(), updatedAt: z.number().optional(), archived: z.boolean().optional(), @@ -56,7 +56,7 @@ export const sessionMetaKeySchema = z.enum([ 'id', 'version', 'title', - 'isCustomTitle', + 'titleKind', 'lastPrompt', 'createdAt', 'updatedAt', diff --git a/packages/klient/src/contract/session/title.ts b/packages/klient/src/contract/session/title.ts new file mode 100644 index 000000000..3777e8103 --- /dev/null +++ b/packages/klient/src/contract/session/title.ts @@ -0,0 +1,23 @@ +/** + * `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([ + z + .object({ + force: z.boolean().optional(), + source: z.enum(['user_prompts', 'first_turn', 'digest']).optional(), + }) + .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 e0fe3134f..77480cb72 100644 --- a/packages/klient/src/core/facade/session.ts +++ b/packages/klient/src/core/facade/session.ts @@ -88,6 +88,19 @@ 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). + * `force` regenerates anyway, overwriting a generated or custom title. + * `source` picks the conversation excerpt: `user_prompts` (default), + * `first_turn` (opening prompt + first reply; strict), or `digest` + * (head+tail of a multi-turn conversation). + */ + generateTitle(opts?: { + force?: boolean; + source?: 'user_prompts' | 'first_turn' | 'digest'; + }): Promise; update(patch: SessionMetaPatch): Promise; setArchived(archived: boolean): Promise; status(): Promise; @@ -136,6 +149,10 @@ export function createSessionFacade(call: ScopedCaller, sessionId: string): Sess return { get: read, setTitle: (title) => call(scope, 'sessionMetadata', 'setTitle', [title]) as Promise, + generateTitle: (opts) => + call(scope, 'sessionTitleService', 'generateTitle', [opts]) 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/src/transports/memory/serviceRegistry.ts b/packages/klient/src/transports/memory/serviceRegistry.ts index adf87dad7..b65e5adac 100644 --- a/packages/klient/src/transports/memory/serviceRegistry.ts +++ b/packages/klient/src/transports/memory/serviceRegistry.ts @@ -30,6 +30,7 @@ import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/i import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval'; import { ISessionQuestionService } from '@moonshot-ai/agent-core-v2/session/question/question'; import { ISessionSkillCatalog } from '@moonshot-ai/agent-core-v2/session/sessionSkillCatalog/skillCatalog'; +import { ISessionTitleService } from '@moonshot-ai/agent-core-v2/session/sessionTitle/sessionTitle'; import { IAgentPromptService } from '@moonshot-ai/agent-core-v2/agent/prompt/prompt'; import { IAgentSkillService } from '@moonshot-ai/agent-core-v2/agent/skill/skill'; import { IAgentLoopService } from '@moonshot-ai/agent-core-v2/agent/loop/loop'; @@ -69,6 +70,7 @@ export const serviceTokens: Readonly>> sessionApprovalService: ISessionApprovalService, sessionQuestionService: ISessionQuestionService, sessionSkillCatalog: ISessionSkillCatalog, + sessionTitleService: ISessionTitleService, agentPromptService: IAgentPromptService, agentSkillService: IAgentSkillService, agentLoopService: IAgentLoopService, diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts index 83173652b..b285cc55b 100644 --- a/packages/klient/test/contract-parity.ts +++ b/packages/klient/test/contract-parity.ts @@ -64,6 +64,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, @@ -224,6 +225,7 @@ import { questionResultSchema, } from '../src/contract/session/question.js'; import { skillSummarySchema } from '../src/contract/session/skills.js'; +import { sessionTitleContract } from '../src/contract/session/title.js'; import { authStatusSchema, @@ -494,6 +496,12 @@ const _questionResult: AssertWire = // session/skills.ts const _skillSummary: AssertWire = true; +// session/title.ts +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 6c6093a33..6999f9ff3 100644 --- a/packages/klient/test/facade.test.ts +++ b/packages/klient/test/facade.test.ts @@ -515,6 +515,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 4ab32498a..15f755ab9 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -18,6 +18,7 @@ import type { ExportSessionInput, ExportSessionResult, ForkSessionInput, + GenerateSessionTitleInput, GetConfigOptions, GlobalMcpServerAuthStatus, KimiConfig, @@ -72,6 +73,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; @@ -130,7 +132,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); @@ -145,8 +149,16 @@ 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; - if (active !== undefined) { + 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. + 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) { @@ -155,6 +167,26 @@ export class KimiHarness { return active; } + // 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(key, run); + try { + return await run; + } finally { + if (this.resumeInflight.get(key) === run) this.resumeInflight.delete(key); + } + } + + 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 }) @@ -165,7 +197,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); @@ -195,7 +229,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); @@ -218,7 +254,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); @@ -243,7 +281,18 @@ 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 }); + } + + /** + * 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 { + return this.rpc.generateSessionTitle(input); } async exportSession(input: ExportSessionInput): Promise { @@ -486,6 +535,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/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 04f2e623e..c535d2e92 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -34,6 +34,7 @@ import type { ExportSessionResult, CreateGoalInput, ForkSessionInput, + GenerateSessionTitleInput, GetConfigOptions, GlobalMcpServerAuthStatus, McpServerConfig, @@ -257,6 +258,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 93fffe656..d32b6e0a7 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -264,6 +264,7 @@ import type { ExportSessionInput, ExportSessionResult, ForkSessionInput, + GenerateSessionTitleInput, GetConfigOptions, GetCronTasksResult, GlobalMcpServerAuthState, @@ -403,6 +404,17 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * registered handlers. */ private readonly sessionWirings = new Map(); + /** + * 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[] = []; @@ -817,6 +829,64 @@ 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; + } + + /** + * 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 + * 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`, { @@ -867,6 +937,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, @@ -1113,6 +1184,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 = @@ -1170,17 +1251,28 @@ 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 { - await closeSessionById(this.engineAccessor, input.id); - } + await this.runSessionAccess(input.id, () => + this.withTemporarySession(input.id, () => this.klient.session(input.id).setTitle(title)), + ); + } + + /** + * 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 { + return this.runSessionAccess(input.id, () => + this.withTemporarySession(input.id, () => + this.klient + .session(input.id) + .generateTitle({ force: input.force === true, source: input.source }), + ), + ); } /** @@ -1199,22 +1291,33 @@ 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(ISessionLifecycleService).fork({ - sourceSessionId: input.id, - newSessionId: input.forkId, - title: input.title, - metadata: input.metadata, - }); - this.wireSession(handle); - return this.resumedSessionSummary(handle); + // The source session's reads (metadata, wire flush) stay atomic against + // 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(ISessionLifecycleService).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 { // v1's print-steer counters die with the Session object; drop ours too. this.printSteerStates.delete(input.sessionId); - await this.klient.session(input.sessionId).close(); + await this.runSessionAccess(input.sessionId, () => + this.klient.session(input.sessionId).close(), + ); } /** @@ -1233,14 +1336,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); - 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, + }); }); } @@ -1254,36 +1359,38 @@ 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(); - await this.refreshPluginSessionStarts(sessionId); - 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); - const main = handle.accessor.get(IAgentLifecycleService).get(MAIN_AGENT_ID); - await main?.accessor.get(IAgentPluginService).refreshSessionStart(); - this.wireSession(handle); - return this.resumedSessionSummary(handle); + await this.configReady; + await this.klient.global.config.reload(); + await this.klient.global.plugins.reload(); + await this.refreshPluginSessionStarts(sessionId); + 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); + const main = handle.accessor.get(IAgentLifecycleService).get(MAIN_AGENT_ID); + await main?.accessor.get(IAgentPluginService).refreshSessionStart(); + this.wireSession(handle); + return this.resumedSessionSummary(handle); + }); } private async refreshPluginSessionStarts(excludedSessionId?: string): Promise { diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index ae3ab30de..7a18fdbb0 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -94,6 +94,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; @@ -660,7 +665,7 @@ export class Session { } /** @internal */ - emitMetaUpdated(patch: { readonly title?: string | 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/src/types.ts b/packages/node-sdk/src/types.ts index d87143c82..6e444ce21 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -160,6 +160,14 @@ export interface RenameSessionInput { readonly title: string; } +export interface GenerateSessionTitleInput { + readonly id: string; + /** Regenerate even when the session already has a generated/custom title. */ + readonly force?: boolean; + /** Conversation excerpt to generate from (default `user_prompts`). */ + readonly source?: 'user_prompts' | 'first_turn' | 'digest'; +} + export interface ResumeSessionInput { readonly id: string; readonly kaos?: Kaos | undefined; @@ -300,9 +308,20 @@ 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 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'; + export interface SessionSummary { readonly id: string; readonly title?: string | undefined; + readonly titleKind?: SessionTitleKind; readonly lastPrompt?: string; readonly workDir: string; readonly sessionDir: string; diff --git a/packages/node-sdk/src/v2/session-mapper.ts b/packages/node-sdk/src/v2/session-mapper.ts index ba8918ba7..7ec6f113c 100644 --- a/packages/node-sdk/src/v2/session-mapper.ts +++ b/packages/node-sdk/src/v2/session-mapper.ts @@ -70,7 +70,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, 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 13f74c5a8..94bd1776e 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -4,13 +4,18 @@ * 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, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { + FileTokenStorage, + resolveKimiCodeOAuthRef, + resolveKimiTokenStorageName, +} from '@moonshot-ai/kimi-code-oauth'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { @@ -20,6 +25,7 @@ import { KimiHarness, removeProviderFromConfig, SDKRpcClientV2, + type Event, type KimiConfig, } from '#/index'; import { foldAgentWireReplay } from '#/v2/resume-replay'; @@ -28,6 +34,9 @@ import { drainSessionIndexMirror, HostProcessError, IHostRequestHeaders, + ISessionLifecycleHooks, + ISessionLifecycleService, + IWorkspaceLifecycleService, OsProcessErrors, } from '@moonshot-ai/agent-core-v2'; @@ -235,6 +244,362 @@ 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" + +[experimental] +auto_session_title = true + +[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('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" + +[experimental] +auto_session_title = true + +[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(ISessionLifecycleService).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('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('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('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 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 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'); + } 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-')); diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts index 0c915fa7e..d176b6e3a 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -233,7 +233,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 => @@ -362,6 +364,7 @@ function projectSessionSummary(summary: SessionSummary, home: HomePair): unknown const projected = scrubHomePrefixes(summary, home) as Record; delete projected['createdAt']; delete projected['updatedAt']; + delete projected['titleKind']; // `lastTurnReason` is v2-only: the v1 engine never records a turn outcome, // so the field cannot compare across engines. delete projected['lastTurnReason']; diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index bcf502ecc..417876b66 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -111,6 +111,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 000000000..46bf05f65 --- /dev/null +++ b/packages/oauth/src/managed-tools.ts @@ -0,0 +1,99 @@ +/** + * 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; signal?: AbortSignal } = {}, +): Promise { + const controller = new AbortController(); + const onExternalAbort = () => { + controller.abort(); + }; + if (opts.signal !== undefined) { + if (opts.signal.aborted) controller.abort(); + else opts.signal.addEventListener('abort', onExternalAbort, { once: true }); + } + const timer = setTimeout(() => { + 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, + 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') { + 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}` }; + } finally { + clearTimeout(timer); + opts.signal?.removeEventListener('abort', onExternalAbort); + } +} + +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 000000000..53de6cc6f --- /dev/null +++ b/packages/oauth/test/managed-tools.test.ts @@ -0,0 +1,272 @@ +/** + * 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'; + +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('keeps protocol headers authoritative when custom header casing differs', 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', + 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/); + }); + + 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]); + }); +});