diff --git a/.changeset/auto-session-title.md b/.changeset/auto-session-title.md new file mode 100644 index 0000000000..2ff827ab3c --- /dev/null +++ b/.changeset/auto-session-title.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Generate a concise session title automatically as soon as the first prompt is sent when signed in with a managed Kimi account. A title you set yourself is never overwritten. diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index b82f919610..8cd1088cca 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -14,8 +14,10 @@ import type { GoalChange, GoalUpdatedEvent, HookResultEvent, + KimiHarness, Session, SessionMetaUpdatedEvent, + SessionTitleKind, SkillActivatedEvent, PluginCommandActivatedEvent, ThinkingDeltaEvent, @@ -94,6 +96,7 @@ export interface SessionEventHost { aborted: boolean; sessionEventUnsubscribe: (() => void) | undefined; readonly streamingUI: StreamingUIController; + readonly harness: KimiHarness; requireSession(): Session; setAppState(patch: Partial): void; @@ -162,6 +165,7 @@ export class SessionEventHandler { private queuedGoalPromotionPending = false; private queuedGoalPromotionInFlight = false; private queuedGoalPromotionTimer: ReturnType | undefined; + private titleGenerationDisabled = false; resetRuntimeState(): void { this.backgroundTasks.clear(); @@ -180,6 +184,7 @@ export class SessionEventHandler { this.queuedGoalPromotionPending = false; this.queuedGoalPromotionInFlight = false; this.clearQueuedGoalPromotionTimer(); + this.titleGenerationDisabled = false; this.stopAllMcpServerStatusSpinners(); } @@ -387,6 +392,37 @@ export class SessionEventHandler { this.scheduleQueuedGoalPromotion(); } + /** + * Seeds the title-generation gate from the persisted title state (read off + * the resumed session's summary): a session whose title was already + * generated or customized has nothing left to ask for, so the one-shot + * request is skipped. Only ever closes the gate — + * reopening stays with `resetRuntimeState` on a session switch. + */ + syncTitleGenerationGate(titleKind: SessionTitleKind | undefined): void { + if (titleKind === 'generated' || titleKind === 'custom') { + this.titleGenerationDisabled = true; + } + } + + /** + * Best-effort auto title: right after a prompt is accepted by the engine, + * ask it once to generate a title from the first prompts. One shot per + * session attach — the prompt-derived easy title is an acceptable + * fallback, so the outcome is not acted on and failures (no managed login, + * v1 engine, dead RPC) are not retried. The engine overwrites the + * prompt-derived easy title but never a custom title (enforced + * server-side), and a generated title lands through the regular + * `session.meta.updated` event. + */ + requestSessionTitleGeneration(): void { + if (this.titleGenerationDisabled) return; + const { sessionId } = this.host.state.appState; + if (sessionId.length === 0) return; + this.titleGenerationDisabled = true; + void this.host.harness.generateSessionTitle({ id: sessionId }).catch(() => undefined); + } + private handleStepBegin(event: TurnStepStartedEvent): void { this.host.streamingUI.flushNow(); this.host.streamingUI.setStep(event.step); @@ -891,6 +927,11 @@ export class SessionEventHandler { this.host.setAppState({ sessionTitle: title }); this.host.updateTerminalTitle(); } + // A custom rename (here or by another client) settles title generation: + // the engine would only keep returning undefined for it. + if (event.patch?.['isCustomTitle'] === true) { + this.titleGenerationDisabled = true; + } } private handleSessionError(event: ErrorEvent): void { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 834bd77771..a00cb17aae 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1357,10 +1357,15 @@ export class KimiTUI { }); return; } - void session.prompt(sdkInput).catch((error: unknown) => { - const message = formatErrorMessage(error); - this.failSessionRequest(`Failed to send: ${message}`); - }); + void session.prompt(sdkInput).then( + // The prompt is enqueued engine-side by the time this resolves, so the + // title endpoint can already read it — no need to wait for turn end. + () => this.sessionEventHandler.requestSessionTitleGeneration(), + (error: unknown) => { + const message = formatErrorMessage(error); + this.failSessionRequest(`Failed to send: ${message}`); + }, + ); } sendSkillActivation(session: Session, skillName: string, skillArgs: string): void { @@ -1613,6 +1618,7 @@ export class KimiTUI { sessionTitle: session.summary?.title ?? null, goal: goalResult.goal, }); + this.sessionEventHandler.syncTitleGenerationGate(session.summary?.titleKind); this.syncAdditionalDirs(session); } diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index 8b9d5fbdfc..fea52b8353 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -95,6 +95,7 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, tasksBrowserController: {}, + harness: { generateSessionTitle: vi.fn(async () => undefined) }, }; host.setAppState.mockImplementation((patch: Record) => { Object.assign(host.state.appState, patch); diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts index 3220d1b916..9a6d6c8fdd 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts @@ -56,6 +56,7 @@ function makeHost() { shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, tasksBrowserController: {}, + harness: { generateSessionTitle: vi.fn(async () => undefined) }, }; return { host: host as never, streamingUI }; } diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts new file mode 100644 index 0000000000..da33a95b2c --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-title.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; +import { getBuiltInPalette } from '#/tui/theme'; + +function makeHost(options: { generateTitle?: () => Promise } = {}) { + const harness = { + generateSessionTitle: vi.fn(options.generateTitle ?? (async () => undefined)), + }; + const host = { + state: { + appState: { + sessionId: 's1', + sessionTitle: null, + workDir: '/tmp/work', + streamingPhase: 'waiting', + model: 'kimi-model', + permissionMode: 'auto', + }, + queuedMessages: [], + queuedMessageDispatchPending: false, + theme: { palette: getBuiltInPalette('dark') }, + toolOutputExpanded: false, + todoPanel: { getTodos: vi.fn(() => []) }, + transcriptContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: undefined, + aborted: false, + sessionEventUnsubscribe: undefined, + streamingUI: { + setTurnId: vi.fn(), + flushNow: vi.fn(), + resetToolUi: vi.fn(), + finalizeTurn: vi.fn(), + }, + harness, + requireSession: vi.fn(), + setAppState: vi.fn(), + patchLivePane: vi.fn(), + resetLivePane: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + track: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + appendTranscriptEntry: vi.fn(), + sendNormalUserInput: vi.fn(), + sendQueuedMessage: vi.fn(), + shiftQueuedMessage: vi.fn(), + updateActivityPane: vi.fn(), + updateTerminalTitle: vi.fn(), + handleShellOutput: vi.fn(), + handleShellStarted: vi.fn(), + btwPanelController: { routeEvent: vi.fn(() => false) }, + tasksBrowserController: {}, + }; + return { host: host as any, harness }; +} + +function turnEndedEvent(sessionId = 's1') { + return { + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 1, + reason: 'completed', + } as const; +} + +async function flushMicrotasks() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('session auto title generation', () => { + it.each([ + ['unavailable', async (): Promise => undefined], + ['applied', async (): Promise => '生成的标题'], + [ + 'rejected', + async (): Promise => { + throw new Error('core rpc unavailable'); + }, + ], + ] as const)('requests only once per runtime when the attempt is %s', async (_outcome, generateTitle) => { + const { host, harness } = makeHost({ generateTitle }); + const handler = new SessionEventHandler(host); + + handler.requestSessionTitleGeneration(); + await flushMicrotasks(); + handler.requestSessionTitleGeneration(); + + expect(harness.generateSessionTitle).toHaveBeenCalledTimes(1); + expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); + }); + + it('does not request a title when a turn ends', () => { + const { host, harness } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(harness.generateSessionTitle).not.toHaveBeenCalled(); + }); + + it('grants a fresh attempt on runtime reset', () => { + const { host, harness } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.requestSessionTitleGeneration(); + handler.resetRuntimeState(); + handler.requestSessionTitleGeneration(); + + expect(harness.generateSessionTitle).toHaveBeenCalledTimes(2); + }); + + it.each(['generated', 'custom'] as const)( + 'skips the request when the resumed session already has a %s title', + (titleKind) => { + const { host, harness } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.syncTitleGenerationGate(titleKind); + handler.requestSessionTitleGeneration(); + + expect(harness.generateSessionTitle).not.toHaveBeenCalled(); + }, + ); + + it.each(['replaceable', undefined] as const)( + 'requests when the resumed title state is %s', + (titleKind) => { + const { host, harness } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.syncTitleGenerationGate(titleKind); + handler.requestSessionTitleGeneration(); + + expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); + }, + ); + + it('re-opens the gate on runtime reset for a session seeded as settled', () => { + const { host, harness } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.syncTitleGenerationGate('generated'); + handler.resetRuntimeState(); + handler.requestSessionTitleGeneration(); + + expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 's1' }); + }); + + it('stops requesting after a custom rename event arrives', () => { + const { host, harness } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent( + { + type: 'session.meta.updated', + agentId: 'main', + sessionId: 's1', + title: '用户手工标题', + patch: { title: '用户手工标题', isCustomTitle: true }, + } as const, + vi.fn(), + ); + handler.requestSessionTitleGeneration(); + + expect(harness.generateSessionTitle).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index b46eab17bf..6a00aaa5b9 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -282,6 +282,7 @@ function makeHarness(session = makeSession(), overrides: Record return interactiveAgentScope.run(agentId, fn); }), getExperimentalFeatures: vi.fn(async () => []), + generateSessionTitle: vi.fn(async () => undefined), auth: { status: vi.fn(), login: vi.fn(), @@ -1202,6 +1203,31 @@ command = "vim" ]); }); + it('requests a session title once the prompt is accepted', async () => { + const { driver, harness } = await makeDriver(); + + driver.handleUserInput('hello'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(harness.generateSessionTitle).toHaveBeenCalledWith({ id: 'ses-1' }); + }); + + it('does not request a session title when the prompt send fails', async () => { + const { driver, harness } = await makeDriver( + makeSession({ + prompt: vi.fn(async () => { + throw new Error('core rpc unavailable'); + }), + }), + ); + + driver.handleUserInput('hello'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(harness.generateSessionTitle).not.toHaveBeenCalled(); + expect(driver.state.appState.streamingPhase).toBe('idle'); + }); + it('keeps the transcript intact when undo RPC fails', async () => { const session = makeSession({ undoHistory: vi.fn(async () => { diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index d6ed6bd8bc..e8893d5164 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -437,7 +437,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/agent/rpc/prompt-metadata.ts b/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts index 519e2a440f..702c1e33ac 100644 --- a/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts +++ b/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts @@ -7,7 +7,10 @@ */ import type { IEventService } from '#/app/event/event'; -import type { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import type { + ISessionMetadata, + SessionTitleKind, +} from '#/session/sessionMetadata/sessionMetadata'; import { promptMetadataTextFromContentParts, @@ -60,12 +63,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({ @@ -76,7 +79,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/index.ts b/packages/agent-core-v2/src/index.ts index 169cf99901..1b36adeab7 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -95,6 +95,10 @@ export * from '#/session/sessionMetadata/sessionMetadata'; export * from '#/session/sessionMetadata/sessionMetadataService'; export * from '#/session/sessionActivity/sessionActivity'; export * from '#/session/sessionActivity/sessionActivityService'; +export * from '#/session/sessionTitle/agentTitlePromptSource'; +import '#/session/sessionTitle/agentTitlePromptSourceService'; +export * from '#/session/sessionTitle/sessionTitle'; +export * from '#/session/sessionTitle/sessionTitleService'; 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/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts index 014dd1c9f4..e1040e20a9 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts @@ -23,11 +23,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; @@ -52,6 +54,12 @@ export interface ISessionMetadata { read(): Promise; update(patch: SessionMetaPatch): 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. + */ + setGeneratedTitleIfUncustomized(title: string): Promise; setArchived(archived: boolean): Promise; registerAgent(agentId: string, meta: AgentMeta): Promise; } diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 3c1be42969..94fe4e1cde 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -7,10 +7,22 @@ * construction (creating it on first run), and logs through `log`. The * plain-data state (`data`) is registered into `sessionState` * (`ISessionStateService`) and read/written through it. The - * document always carries the `agents` / `custom` maps — seeded at creation, - * backfilled and persisted on load for documents written before the seeding - * existed (without touching `updatedAt`, so a format heal never reorders - * session listings). Re-registering an agent whose metadata is unchanged is + * document always carries the `agents` / `custom` maps that v1's + * `Session.resume()` reads unconditionally — seeded at creation, backfilled + * and persisted on load for documents written before the seeding existed + * (without touching `updatedAt`, so a format heal never reorders session + * listings) — keeping sessions on a shared `KIMI_CODE_HOME` resumable by + * released v1 builds. 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. + * Re-registering an agent whose metadata is unchanged is * a no-op (no write, no mirror, no event), so resuming a session — which * re-registers its agents as they materialize — never bumps `updatedAt` and * never reorders session listings. Bound at Session scope. @@ -42,6 +54,7 @@ import { type SessionMeta, type SessionMetadataChangedEvent, type SessionMetaPatch, + type SessionTitleKind, } from './sessionMetadata'; const META_KEY = 'state.json'; @@ -93,21 +106,30 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { } async update(patch: SessionMetaPatch): Promise { - return this.enqueueUpdate(() => this.applyUpdate(patch)); + await this.enqueueUpdate(() => this.applyUpdate(patch)); } - private async applyUpdate(patch: SessionMetaPatch): Promise { + private async applyUpdate(patch: SessionMetaPatch): Promise { await this.ready; this.data = { ...this.data, ...patch, updatedAt: Date.now() }; - await this.store.set(this.scope, META_KEY, this.data); + await this.store.set(this.scope, META_KEY, encodeSessionMeta(this.data)); await this.mirrorToReadModel(); this._onDidChangeMetadata.fire({ changed: Object.keys(patch) as (keyof SessionMeta)[], }); + return true; } async setTitle(title: string): Promise { - await this.update({ title, isCustomTitle: true }); + await this.update({ title, titleKind: 'custom' }); + } + + async setGeneratedTitleIfUncustomized(title: string): Promise { + return this.enqueueUpdate(async () => { + await this.ready; + if (this.data.titleKind === 'custom') return false; + return this.applyUpdate({ title, titleKind: 'generated' }); + }); } async setArchived(archived: boolean): Promise { @@ -124,9 +146,12 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { }); } - private enqueueUpdate(work: () => Promise): Promise { + private enqueueUpdate(work: () => Promise): Promise { const run = this.updateQueue.then(work, work); - this.updateQueue = run.catch(() => {}); + this.updateQueue = run.then( + () => undefined, + () => undefined, + ); return run; } @@ -156,13 +181,17 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { const existing = await this.store.get(this.scope, META_KEY); if (existing !== undefined) { this.data = normalizeSessionMeta(existing, this.ctx.sessionId); - if (this.data.agents === undefined || this.data.custom === undefined) { + if ( + this.data.agents === undefined || + this.data.custom === undefined || + sessionMetaTitleNeedsMigration(existing, this.data) + ) { this.data = { ...this.data, agents: this.data.agents ?? {}, 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; } @@ -177,7 +206,7 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { agents: {}, custom: {}, }; - await this.store.set(this.scope, META_KEY, this.data); + await this.store.set(this.scope, META_KEY, encodeSessionMeta(this.data)); this.log.debug('session metadata created', { sessionId: this.ctx.sessionId }); } } @@ -202,28 +231,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 0000000000..87a5f62698 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSource.ts @@ -0,0 +1,17 @@ +/** + * `sessionTitle` domain (L6) — title prompt projection contract. + * + * Defines the Agent-scoped `IAgentTitlePromptSource` used to read the first + * active natural-language prompts from the live conversation context. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentTitlePromptSource { + readonly _serviceBrand: undefined; + + firstUserPrompts(limit: number): Promise; +} + +export const IAgentTitlePromptSource: ServiceIdentifier = + createDecorator('agentTitlePromptSource'); diff --git a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts new file mode 100644 index 0000000000..03239b51e1 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts @@ -0,0 +1,63 @@ +/** + * `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. The window may be post-compaction — acceptable for title + * generation: compaction keeps the head user messages, and a title derived + * from the surviving tail is a fine degradation. Bound at Agent scope. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { IAgentPromptService } from '#/agent/prompt/prompt'; +import { promptMetadataTextFromContentParts } from '#/agent/prompt/promptMetadataText'; + +import { IAgentTitlePromptSource } 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 queue = this.prompt.list(); + const result: string[] = []; + const seenMessageIds = new Set(); + + const add = (message: ContextMessage): void => { + if (result.length >= limit || !isNaturalLanguagePrompt(message)) return; + if (message.id !== undefined) { + if (seenMessageIds.has(message.id)) return; + seenMessageIds.add(message.id); + } + const text = promptMetadataTextFromContentParts(message.content); + if (text !== undefined) result.push(text); + }; + + for (const message of this.context.get()) add(message); + if (queue.active !== undefined) add(queue.active.message); + for (const item of queue.pending) add(item.message); + return result; + } +} + +function isNaturalLanguagePrompt(message: ContextMessage): boolean { + if (message.role !== 'user') return false; + const origin = message.origin; + return origin === undefined || origin.kind === 'user'; +} + +registerScopedService( + LifecycleScope.Agent, + IAgentTitlePromptSource, + AgentTitlePromptSourceService, + ScopeActivation.OnDemand, + 'sessionTitle', +); diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts new file mode 100644 index 0000000000..78ac524492 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts @@ -0,0 +1,19 @@ +/** + * `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. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ISessionTitleService { + readonly _serviceBrand: undefined; + + generateTitle(): Promise; +} + +export const ISessionTitleService: ServiceIdentifier = + createDecorator('sessionTitleService'); diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts new file mode 100644 index 0000000000..94a75840e4 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -0,0 +1,169 @@ +/** + * `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 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. + * 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 { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +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 { ISessionTitleService } from './sessionTitle'; + +const MAX_GENERATED_TITLE_LENGTH = 200; + +const MAX_TITLE_INPUT_LENGTH = 1000; + +const MAX_TITLE_PROMPTS = 3; + +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, + @ILogService private readonly log: ILogService, + ) {} + + async generateTitle(): Promise { + if (this._shared !== undefined) return this._shared; + const tracked = this.generateTitleOnce().finally(() => { + if (this._shared === tracked) this._shared = undefined; + }); + this._shared = tracked; + return tracked; + } + + private async generateTitleOnce(): Promise { + const current = await this.metadata.read(); + if (current.titleKind === 'custom') return undefined; + if (current.titleKind === 'generated') return undefined; + const main = this.agentLifecycle.get(MAIN_AGENT_ID); + const prompts = + main === undefined + ? [] + : await main.accessor.get(IAgentTitlePromptSource).firstUserPrompts(MAX_TITLE_PROMPTS); + const input = titleInputFromPrompts(prompts); + if (input === undefined) return undefined; + return this.generateAndApply(input); + } + + private async generateAndApply(chatContent: string): Promise { + const current = await this.metadata.read(); + if (current.titleKind === 'custom') return undefined; + const provider = this.providers.get(KIMI_CODE_PROVIDER_NAME); + 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); + 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); +} + +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 3b8effec93..4f61184e5e 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -468,7 +468,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const title = opts.title ?? `Fork: ${sourceMeta?.title || sourceId}`; await targetMeta.update({ title, - isCustomTitle: opts.title !== undefined ? true : sourceMeta?.isCustomTitle === true, + titleKind: opts.title === undefined ? 'replaceable' : 'custom', forkedFrom: sourceId, archived: false, lastPrompt: sourceMeta?.lastPrompt, diff --git a/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts b/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts index 7e1527c443..2fd05e8d4d 100644 --- a/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts +++ b/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts @@ -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 { promptMetadataTextFromPayload } from '#/agent/rpc/prompt-metadata'; +import { + applyPromptMetadataUpdate, + promptMetadataTextFromPayload, + type PromptMetadataUpdateTarget, +} from '#/agent/rpc/prompt-metadata'; import { buildImageCompressionCaption } from '#/agent/media/image-compress'; +import type { IEventService } from '#/app/event/event'; +import { + type ISessionMetadata, + type SessionMeta, + type SessionMetaPatch, +} from '#/session/sessionMetadata/sessionMetadata'; const CAPTION = buildImageCompressionCaption({ original: { width: 3264, height: 666, byteLength: 344 * 1024, mimeType: 'image/png' }, @@ -53,3 +65,47 @@ describe('promptMetadataTextFromPayload', () => { expect(text).not.toContain('Image compressed'); }); }); + +describe('applyPromptMetadataUpdate', () => { + function createTarget(initial: Partial = {}) { + let meta: SessionMeta = { + id: 'sess-1', + createdAt: 0, + updatedAt: 0, + archived: false, + ...initial, + }; + const target: PromptMetadataUpdateTarget = { + metadata: { + read: () => Promise.resolve(meta), + update: (patch: SessionMetaPatch) => { + meta = { ...meta, ...patch }; + return Promise.resolve(); + }, + } as unknown as ISessionMetadata, + eventService: { publish: () => undefined } as unknown as IEventService, + sessionId: 'sess-1', + }; + return { target, readMeta: () => meta }; + } + + it('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 f3fa2039f8..5e96d5b144 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -1016,6 +1016,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 a0b35cceef..90231be064 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -15,7 +15,10 @@ import { ILogService } from '#/_base/log/log'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IFlagService } from '#/app/flag/flag'; -import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; +import { + ISessionIndex, + type SessionSummary, +} from '#/app/sessionIndex/sessionIndex'; import { FileSessionIndex } from '#/app/sessionIndex/sessionIndexService'; import { MiniDbQueryStore } from '#/persistence/backends/minidb/miniDbQueryStore'; import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; @@ -295,7 +298,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 25c041495a..4681b56d06 100644 --- a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts @@ -138,6 +138,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 ca8a2cd787..c2eee6b8f2 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; @@ -60,7 +60,10 @@ describe('SessionMetadata', () => { ix.set(ISessionMetadata, new SyncDescriptor(SessionMetadata)); }); - afterEach(() => { disposables.dispose(); }); + afterEach(() => { + disposables.dispose(); + vi.restoreAllMocks(); + }); it('creates an initial document on first read', async () => { const meta = ix.get(ISessionMetadata); @@ -90,7 +93,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('mirrors a boolean archived to the read model even when the loaded document lacks the field', async () => { @@ -155,6 +168,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 0000000000..174f864969 --- /dev/null +++ b/packages/agent-core-v2/test/session/sessionTitle/agentTitlePromptSourceService.test.ts @@ -0,0 +1,134 @@ +/** + * 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 { 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, + }; +} + +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(['同一条']); + }); +}); diff --git a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts new file mode 100644 index 0000000000..97ef040136 --- /dev/null +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -0,0 +1,442 @@ +/** + * 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 { + LifecycleScope, + type IAgentScopeHandle, +} from '#/_base/di/scope'; +import { createServices, type TestInstantiationService } from '#/_base/di/test'; +import { Emitter } from '#/_base/event'; +import { IOAuthService } from '#/app/auth/auth'; +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, +} 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): Promise { + if (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 tokenCalls: boolean[]; + + beforeEach(() => { + tokenError = undefined; + forceTokenError = undefined; + resolvedOAuthRefs = []; + titlePrompts = []; + promptSourceImpl = async (limit) => titlePrompts.slice(0, limit); + tokenCalls = []; + 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), + }; + 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' } }); + reg.define(ISessionTitleService, SessionTitleService); + }, + }); + ix.get(ISessionTitleService); + }); + + afterEach(() => { + disposables.dispose(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + 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('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/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 2f5c185acb..0cab5689a9 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -426,6 +426,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 1611d33a1e..33b925adf1 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts @@ -56,7 +56,10 @@ import { ISessionLifecycleHooks, type SessionLifecycleHookSlots, } from '#/session/sessionLifecycleHooks/sessionLifecycleHooks'; -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'; @@ -121,6 +124,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(), }; @@ -1248,6 +1252,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('copies blobs, plans, background tasks, and media originals into the fork', async () => { const root = await makeTmpRoot(); const svc = await build([ diff --git a/packages/kap-server/src/protocol/error-codes.ts b/packages/kap-server/src/protocol/error-codes.ts index e8f033aa78..1f6af4c8b0 100644 --- a/packages/kap-server/src/protocol/error-codes.ts +++ b/packages/kap-server/src/protocol/error-codes.ts @@ -116,6 +116,8 @@ export const ErrorCode = { GOAL_UNSUPPORTED_AGENT: 40920, /** 创建时 provider_id 已存在 */ PROVIDER_ALREADY_EXISTS: 40921, + /** 会话标题生成不可用(flag 未开 / 无 managed OAuth 登录 / 还没有 prompt / 后端失败) */ + SESSION_TITLE_UNAVAILABLE: 40922, /** approval 60s 超时 */ APPROVAL_EXPIRED: 41001, diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index ce666c946c..367f0f0035 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 @@ -89,6 +91,7 @@ import { ISessionIndex, ISessionMetadata, ISessionLegacyService, + ISessionTitleService, ISessionSecondaryModelWarningService, IEventService, IWorkspaceAliases, @@ -615,6 +618,52 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void updateProfileRoute.handler as Parameters[2], ); + const generateTitleRoute = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/title/generate', + params: sessionIdParamSchema, + success: { data: z.object({ title: z.string() }) }, + errors: { + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.SESSION_TITLE_UNAVAILABLE]: {}, + }, + description: 'Generate the session title via the managed chat_title tool', + tags: ['sessions'], + }, + async (req, reply) => { + try { + const { session_id } = req.params; + const handle = await 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(); + 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 0db6f9b9bf..7699e5a972 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -388,6 +388,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 859c66148c..10ceb752a3 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 c8049bcd41..ed34e12051 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,131 @@ describe('server-v2 /api/v1/sessions', () => { expect(got.body.data.title).toBe('renamed'); }); + it('returns title-unavailable when generation cannot run', async () => { + const created = await postJson('/api/v1/sessions', { + metadata: { cwd: home as string }, + }); + + const generated = await postJson( + `/api/v1/sessions/${created.body.data.id}/title/generate`, + ); + + expect(generated.body.code).toBe(40922); + }); + + it('generates and persists a title through the public REST path', async () => { + await server?.close(); + server = undefined; + await writeFile( + join(home as string, 'config.toml'), + [ + 'default_model = "stub"', + '', + '[providers.stub]', + 'type = "openai"', + 'base_url = "http://127.0.0.1:9999"', + 'api_key = "stub"', + '', + '[models.stub]', + 'provider = "stub"', + 'model = "stub"', + 'max_context_size = 1000', + '', + '[providers."managed:kimi-code"]', + 'type = "kimi"', + 'base_url = "https://api.example.test/coding/v1"', + '', + '[providers."managed:kimi-code".oauth]', + 'storage = "file"', + 'key = "kimi-code"', + '', + ].join('\n'), + 'utf-8', + ); + + const oauth: IOAuthServiceType = { + _serviceBrand: undefined, + startLogin: async () => { + throw new Error('unused'); + }, + getFlow: () => undefined, + cancelLogin: async () => { + throw new Error('unused'); + }, + logout: async () => { + throw new Error('unused'); + }, + status: async () => ({ loggedIn: true, provider: 'managed:kimi-code' }), + refreshOAuthProviderModels: async () => ({ changed: [], unchanged: [], failed: [] }), + getManagedUsage: async () => ({ kind: 'error', message: 'unused' }), + getManagedUserInfo: async () => ({ kind: 'error', message: 'unused' }), + resolveTokenProvider: () => ({ getAccessToken: async () => 'test-token' }), + getCachedAccessToken: async () => 'test-token', + }; + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + seeds: [[IOAuthService, oauth]] as ScopeSeed, + }); + base = `http://127.0.0.1:${server.port}`; + + let toolsRequest: { method: string; params: { chat_content: string } } | undefined; + const actualFetch = globalThis.fetch.bind(globalThis); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url === 'https://api.example.test/coding/v1/tools') { + const body = init?.body; + if (typeof body !== 'string') { + throw new TypeError('expected a string request body'); + } + toolsRequest = JSON.parse(body) as typeof toolsRequest; + return new Response(JSON.stringify({ title: 'generated from REST' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + return actualFetch(input, init); + }); + + const created = await postJson('/api/v1/sessions', { + metadata: { cwd: home as string }, + }); + const id = created.body.data.id; + for (const text of ['first REST prompt', 'second REST prompt', 'third REST prompt']) { + const submitted = await postJson<{ prompt_id: string }>( + `/api/v1/sessions/${id}/prompts`, + { content: [{ type: 'text', text }] }, + ); + expect(submitted.body.code).toBe(0); + } + + const generated = await postJson<{ title: string }>( + `/api/v1/sessions/${id}/title/generate`, + ); + expect(generated.body).toMatchObject({ code: 0, data: { title: 'generated from REST' } }); + expect(toolsRequest).toEqual({ + method: 'chat_title', + params: { + chat_content: + 'user: 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' } }); + }); + + 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 599a9e96fd..20ba36f07a 100644 --- a/packages/klient/src/contract/global/events.ts +++ b/packages/klient/src/contract/global/events.ts @@ -30,7 +30,7 @@ export interface SessionMetaUpdatedPayload { readonly patch: { readonly title?: string; readonly isCustomTitle?: boolean; - readonly lastPrompt: string; + readonly lastPrompt?: string; }; } @@ -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 94d8f5a4a4..a7d6a1609f 100644 --- a/packages/klient/src/contract/index.ts +++ b/packages/klient/src/contract/index.ts @@ -36,6 +36,7 @@ import { } from './session/lifecycle.js'; import { sessionMetadataContract } from './session/metadata.js'; import { sessionQuestionContract } from './session/question.js'; +import { sessionTitleContract } from './session/title.js'; export const globalContract: KlientContract = { // core (app scope) @@ -60,6 +61,7 @@ export const globalContract: KlientContract = { sessionInteractionService: sessionInteractionContract, sessionApprovalService: sessionApprovalContract, sessionQuestionService: sessionQuestionContract, + sessionTitleService: sessionTitleContract, // agent scope agentRPCService: agentRpcContract, agentActivityView: agentActivityViewContract, diff --git a/packages/klient/src/contract/session/metadata.ts b/packages/klient/src/contract/session/metadata.ts index 13eaabc023..ae0112c2e6 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(), @@ -37,7 +37,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(), @@ -52,7 +52,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 0000000000..c75d3293c4 --- /dev/null +++ b/packages/klient/src/contract/session/title.ts @@ -0,0 +1,16 @@ +/** + * `sessionTitleService` — on-demand session title generation. Mirrors + * `agent-core-v2/session/sessionTitle/sessionTitle.ts`. + */ + +import { z } from 'zod'; + +import { maybe } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const sessionTitleContract = { + generateTitle: { + input: z.tuple([]), + output: maybe(z.string()), + }, +} satisfies ServiceContract; diff --git a/packages/klient/src/core/facade/session.ts b/packages/klient/src/core/facade/session.ts index b4daa19496..2d3532537d 100644 --- a/packages/klient/src/core/facade/session.ts +++ b/packages/klient/src/core/facade/session.ts @@ -66,6 +66,12 @@ export type SessionStatus = 'running' | 'idle' | 'awaiting_approval' | 'awaiting export interface SessionFacade { get(): Promise; setTitle(title: string): Promise; + /** + * Generate and apply a title from the main agent's first prompts via the + * managed `chat_title` tool. `undefined` when generation is unavailable + * (no managed OAuth login, no prompt yet, or a custom title is set). + */ + generateTitle(): Promise; update(patch: SessionMetaPatch): Promise; setArchived(archived: boolean): Promise; status(): Promise; @@ -114,6 +120,10 @@ export function createSessionFacade(call: ScopedCaller, sessionId: string): Sess return { get: read, setTitle: (title) => call(scope, 'sessionMetadata', 'setTitle', [title]) as Promise, + generateTitle: () => + call(scope, 'sessionTitleService', 'generateTitle', []) as Promise< + 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 e7277567f1..1173f13580 100644 --- a/packages/klient/src/transports/memory/serviceRegistry.ts +++ b/packages/klient/src/transports/memory/serviceRegistry.ts @@ -28,6 +28,7 @@ import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMeta import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/interaction/interaction'; import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval'; import { ISessionQuestionService } from '@moonshot-ai/agent-core-v2/session/question/question'; +import { ISessionTitleService } from '@moonshot-ai/agent-core-v2/session/sessionTitle/sessionTitle'; import { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc'; import { IAgentActivityView } from '@moonshot-ai/agent-core-v2/agent/activityView/activityView'; import { IAgentPlanService } from '@moonshot-ai/agent-core-v2/agent/plan/plan'; @@ -57,6 +58,7 @@ export const serviceTokens: Readonly>> sessionInteractionService: ISessionInteractionService, sessionApprovalService: ISessionApprovalService, sessionQuestionService: ISessionQuestionService, + sessionTitleService: ISessionTitleService, agentRPCService: IAgentRPCService, agentActivityView: IAgentActivityView, agentShellCommandService: IAgentShellCommandService, diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts index 7d7a17942a..7d75fe51f6 100644 --- a/packages/klient/test/contract-parity.ts +++ b/packages/klient/test/contract-parity.ts @@ -67,6 +67,7 @@ import type { SessionMetadataChangedEvent, SessionMetaPatch, } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; +import type { ISessionTitleService } from '@moonshot-ai/agent-core-v2/session/sessionTitle/sessionTitle'; import type { AuthStatus, IOAuthService, @@ -201,6 +202,7 @@ import { questionResponseSchema, questionResultSchema, } from '../src/contract/session/question.js'; +import { sessionTitleContract } from '../src/contract/session/title.js'; import { authStatusSchema, @@ -452,6 +454,12 @@ const _questionAnswers: AssertWire = true; const _questionResult: 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 c7cb911a8a..c8dbb5cd09 100644 --- a/packages/klient/test/facade.test.ts +++ b/packages/klient/test/facade.test.ts @@ -210,6 +210,37 @@ describe('event hub', () => { expect(channel.subscriptions[0]?.dispose).toHaveBeenCalledTimes(1); }); + it('delivers session.metaUpdated when the patch carries no lastPrompt', async () => { + const channel = new FakeChannel(); + const klient = createKlientFromChannel(channel); + const seen: unknown[] = []; + const errors: Error[] = []; + klient.events.onError((error) => { + errors.push(error); + }); + + klient.events.on('session.metaUpdated', (event) => seen.push(event)); + channel.emit(0, { + type: 'session.meta.updated', + payload: { + agentId: 'main', + sessionId: 's1', + title: 'generated title', + patch: { title: 'generated title', isCustomTitle: false }, + }, + }); + await tick(); + expect(seen).toEqual([ + { + agentId: 'main', + sessionId: 's1', + title: 'generated title', + patch: { title: 'generated title', isCustomTitle: false }, + }, + ]); + expect(errors).toHaveLength(0); + }); + it('disposes the emitter subscription when the last listener detaches', async () => { const channel = new FakeChannel(); const klient = createKlientFromChannel(channel); diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index a57a8d81e5..9dd701ddbd 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -17,6 +17,7 @@ import type { ExportSessionInput, ExportSessionResult, ForkSessionInput, + GenerateSessionTitleInput, GetConfigOptions, KimiConfig, KimiConfigPatch, @@ -64,6 +65,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; @@ -122,7 +124,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); @@ -137,8 +141,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) { @@ -147,6 +159,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 }) @@ -157,7 +189,9 @@ export class KimiHarness { summary, rpc: this.rpc, onClose: () => { - this.activeSessions.delete(summary.id); + if (this.activeSessions.get(summary.id) === session) { + this.activeSessions.delete(summary.id); + } }, }); this.activeSessions.set(session.id, session); @@ -187,7 +221,9 @@ export class KimiHarness { summary, rpc: this.rpc, onClose: () => { - this.activeSessions.delete(summary.id); + if (this.activeSessions.get(summary.id) === session) { + this.activeSessions.delete(summary.id); + } }, }); this.activeSessions.set(session.id, session); @@ -210,7 +246,9 @@ export class KimiHarness { summary, rpc: this.rpc, onClose: () => { - this.activeSessions.delete(summary.id); + if (this.activeSessions.get(summary.id) === session) { + this.activeSessions.delete(summary.id); + } }, }); this.activeSessions.set(session.id, session); @@ -235,7 +273,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 { @@ -396,6 +445,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 fbcc4618cb..412a529551 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -33,6 +33,7 @@ import type { ExportSessionResult, CreateGoalInput, ForkSessionInput, + GenerateSessionTitleInput, GetConfigOptions, McpServerConfig, GoalSnapshot, @@ -244,6 +245,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 2190f85257..9f5859a545 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -263,6 +263,7 @@ import type { ExportSessionInput, ExportSessionResult, ForkSessionInput, + GenerateSessionTitleInput, GetConfigOptions, GetCronTasksResult, GoalSnapshot, @@ -399,6 +400,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[] = []; @@ -770,6 +782,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`, { @@ -815,6 +885,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, @@ -1000,6 +1071,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 = @@ -1057,17 +1138,24 @@ 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()), + ); } /** @@ -1086,22 +1174,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(), + ); } /** @@ -1120,14 +1219,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, + }); }); } @@ -1141,33 +1242,35 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { */ override async reloadSession(input: ReloadSessionRpcInput): Promise { const sessionId = input.sessionId; - const live = this.liveSession(sessionId); - if (live !== undefined) { - for (const agent of live.accessor.get(IAgentLifecycleService).list()) { - if (agent.accessor.get(IAgentActivityView).state().turn !== undefined) { - throw new KimiError( - ErrorCodes.TURN_AGENT_BUSY, - `Session "${sessionId}" cannot be reloaded while a turn is running`, - { details: { sessionId } }, - ); + return this.runSessionAccess(sessionId, async () => { + const live = this.liveSession(sessionId); + if (live !== undefined) { + for (const agent of live.accessor.get(IAgentLifecycleService).list()) { + if (agent.accessor.get(IAgentActivityView).state().turn !== undefined) { + throw new KimiError( + ErrorCodes.TURN_AGENT_BUSY, + `Session "${sessionId}" cannot be reloaded while a turn is running`, + { details: { sessionId } }, + ); + } } + } else if ((await this.engineAccessor.get(ISessionIndex).get(sessionId)) === undefined) { + throw SDKRpcClientV2.sessionNotFound(sessionId); } - } else if ((await this.engineAccessor.get(ISessionIndex).get(sessionId)) === undefined) { - throw SDKRpcClientV2.sessionNotFound(sessionId); - } - await this.configReady; - await this.klient.global.config.reload(); - await this.klient.global.plugins.reload(); - if (live !== undefined) { - await closeSessionById(this.engineAccessor, sessionId); - } - // Same print-steer reset as closeSession: v1's reload rebuilds the - // Session, and with it the counters. - this.printSteerStates.delete(sessionId); - const handle = await resumeSessionById(this.engineAccessor, sessionId); - if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(sessionId); - this.wireSession(handle); - return this.resumedSessionSummary(handle); + await this.configReady; + await this.klient.global.config.reload(); + await this.klient.global.plugins.reload(); + if (live !== undefined) { + await closeSessionById(this.engineAccessor, sessionId); + } + // Same print-steer reset as closeSession: v1's reload rebuilds the + // Session, and with it the counters. + this.printSteerStates.delete(sessionId); + const handle = await resumeSessionById(this.engineAccessor, sessionId); + if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(sessionId); + this.wireSession(handle); + return this.resumedSessionSummary(handle); + }); } /** diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 9222112832..eacc225167 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -68,6 +68,11 @@ export class Session { this.onClose = options.onClose; } + /** True once {@link close} began — the session may still be closing in the engine. */ + get isClosed(): boolean { + return this.closed; + } + getResumeState(): ResumedSessionState | undefined { this.ensureOpen(); return this.resumeState; @@ -598,7 +603,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 e2b58129cb..cf61dbdbd8 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -144,6 +144,10 @@ export interface RenameSessionInput { readonly title: string; } +export interface GenerateSessionTitleInput { + readonly id: string; +} + export interface ResumeSessionInput { readonly id: string; readonly kaos?: Kaos | undefined; @@ -270,9 +274,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 293f67a557..8e70977b6f 100644 --- a/packages/node-sdk/src/v2/session-mapper.ts +++ b/packages/node-sdk/src/v2/session-mapper.ts @@ -69,7 +69,7 @@ export function v2MetaToSessionMeta(meta: V2SessionMeta): SessionMeta { createdAt: new Date(meta.createdAt).toISOString(), updatedAt: new Date(meta.updatedAt).toISOString(), title: meta.title ?? '', - isCustomTitle: meta.isCustomTitle ?? false, + isCustomTitle: meta.titleKind === 'custom', lastPrompt: meta.lastPrompt, forkedFrom: meta.forkedFrom, workDir: meta.cwd, 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 aa2a3b2e5b..e35452d610 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -4,14 +4,19 @@ * 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, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { + FileTokenStorage, + resolveKimiCodeOAuthRef, + resolveKimiTokenStorageName, +} from '@moonshot-ai/kimi-code-oauth'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createKimiHarnessV2, @@ -20,10 +25,16 @@ import { KimiHarness, removeProviderFromConfig, SDKRpcClientV2, + type Event, type KimiConfig, } from '#/index'; import { foldAgentWireReplay } from '#/v2/resume-replay'; -import { IHostRequestHeaders } from '@moonshot-ai/agent-core-v2'; +import { + IHostRequestHeaders, + ISessionLifecycleHooks, + ISessionLifecycleService, + IWorkspaceLifecycleService, +} from '@moonshot-ai/agent-core-v2'; import { TEST_IDENTITY } from './test-identity'; import { recordingTelemetry, type TelemetryRecord } from './telemetry'; @@ -78,6 +89,356 @@ describe('SDKRpcClientV2 (agent-core-v2 wiring MVP)', () => { } }); + it('emits one complete metadata event when a generated title is applied', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + const titleBaseUrl = 'https://api.example.test/coding/v1'; + const titleOAuthRef = resolveKimiCodeOAuthRef({ baseUrl: titleBaseUrl }); + // Storage names strip the `oauth/` prefix (FileTokenStorage rejects + // namespaced keys); the engine resolves the same name when reading. + await new FileTokenStorage(join(homeDir, 'credentials')).save( + resolveKimiTokenStorageName({ oauthKey: titleOAuthRef.key }), + { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + expiresAt: Math.floor(Date.now() / 1000) + 3600, + scope: '', + tokenType: 'Bearer', + expiresIn: 3600, + }, + ); + await writeFile( + join(homeDir, 'config.toml'), + ` +default_model = "stub" + +[providers.stub] +type = "openai" +base_url = "https://model.example.test/v1" +api_key = "stub" + +[models.stub] +provider = "stub" +model = "stub" +max_context_size = 1000 + +[providers."managed:kimi-code"] +type = "kimi" +base_url = "${titleBaseUrl}" + +[providers."managed:kimi-code".oauth] +storage = "file" +key = "${titleOAuthRef.key}" +`, + 'utf-8', + ); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url === 'https://api.example.test/coding/v1/tools') { + return new Response(JSON.stringify({ title: 'Generated title' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + const harness = createKimiHarnessV2({ homeDir, identity: TEST_IDENTITY }); + + try { + const session = await harness.createSession({ id: 'ses_generated_title_event', workDir }); + await session.importContext( + 'Generate a concise title for this session', + "session 'source-session'", + ); + await expect( + harness.auth.getCachedAccessToken('managed:kimi-code', { + storage: titleOAuthRef.storage, + key: titleOAuthRef.key, + }), + ).resolves.toBe('test-access-token'); + await expect(session.getContext()).resolves.toMatchObject({ + history: [ + expect.objectContaining({ + role: 'user', + origin: { kind: 'user' }, + }), + ], + }); + const events: Event[] = []; + const unsubscribe = session.onEvent((event) => { + if (event.type === 'session.meta.updated' && event.title === 'Generated title') { + events.push(event); + } + }); + + await expect(harness.generateSessionTitle({ id: session.id })).resolves.toBe( + 'Generated title', + ); + unsubscribe(); + + expect(events).toEqual([ + expect.objectContaining({ + type: 'session.meta.updated', + sessionId: session.id, + agentId: 'main', + title: 'Generated title', + patch: { title: 'Generated title', isCustomTitle: false }, + }), + ]); + } finally { + await harness.close(); + fetchSpy.mockRestore(); + } + }); + + it('serializes a temporary title-generation close against a public resume', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + const titleBaseUrl = 'https://api.example.test/coding/v1'; + const titleOAuthRef = resolveKimiCodeOAuthRef({ baseUrl: titleBaseUrl }); + await new FileTokenStorage(join(homeDir, 'credentials')).save( + resolveKimiTokenStorageName({ oauthKey: titleOAuthRef.key }), + { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + expiresAt: Math.floor(Date.now() / 1000) + 3600, + scope: '', + tokenType: 'Bearer', + expiresIn: 3600, + }, + ); + await writeFile( + join(homeDir, 'config.toml'), + ` +default_model = "stub" + +[providers.stub] +type = "openai" +base_url = "https://model.example.test/v1" +api_key = "stub" + +[models.stub] +provider = "stub" +model = "stub" +max_context_size = 1000 + +[providers."managed:kimi-code"] +type = "kimi" +base_url = "${titleBaseUrl}" + +[providers."managed:kimi-code".oauth] +storage = "file" +key = "${titleOAuthRef.key}" +`, + 'utf-8', + ); + let markFetchStarted!: () => void; + let resolveFetch!: (response: Response) => void; + const fetchStarted = new Promise((resolve) => { + markFetchStarted = resolve; + }); + const fetchResponse = new Promise((resolve) => { + resolveFetch = resolve; + }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url === 'https://api.example.test/coding/v1/tools') { + markFetchStarted(); + return fetchResponse; + } + throw new Error(`Unexpected fetch: ${url}`); + }); + const client = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + + try { + await client.createSession({ id: 'ses_title_race', workDir }); + await client.importContext({ + sessionId: 'ses_title_race', + content: 'Generate a concise title for this session', + source: "session 'source-session'", + }); + await client.closeSession({ sessionId: 'ses_title_race' }); + + // The cold session is temporarily resumed for generation; block its + // cleanup close inside the will-close hooks so the public resume below + // lands while the close is still in flight. + const titlePromise = client.generateSessionTitle({ id: 'ses_title_race' }); + await fetchStarted; + const handler = await client.engineAccessor + .get(IWorkspaceLifecycleService) + .handlerFor({ root: workDir }); + const tempHandle = handler.accessor.get(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 2cc2499a75..18ce08d82b 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -218,7 +218,9 @@ const KNOWN_DIFFS = { // default title 'New Session' into state.json and reports it for // never-titled sessions where v2 leaves the title unset; only that // materialized default is projected away (explicit titles compare in - // full). Per-home paths (sessionDir, agent homedirs) compare after the + // full). `titleKind` is v2-only (the v1 wire has no canonical title-state + // field, only the `isCustomTitle` boolean inside `sessionMetadata`) — + // deleted. Per-home paths (sessionDir, agent homedirs) compare after the // home-prefix scrub — both engines lay sessions out as // `/sessions//` with the same key derivation. listSessions: (summaries: readonly SessionSummary[], home: HomePair): unknown => @@ -343,6 +345,7 @@ function projectSessionSummary(summary: SessionSummary, home: HomePair): unknown const projected = scrubHomePrefixes(summary, home) as Record; delete projected['createdAt']; delete projected['updatedAt']; + delete projected['titleKind']; if (projected['title'] === 'New Session') delete projected['title']; return projected; } diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 9418deb926..5a631ce726 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -110,6 +110,13 @@ export type { UsageWindow, } from './managed-usage'; +export { fetchChatTitle, kimiCodeToolsUrl } from './managed-tools'; +export type { + FetchChatTitleError, + FetchChatTitleOk, + FetchChatTitleResult, +} from './managed-tools'; + export { fetchSubmitFeedback, kimiCodeFeedbackUrl } from './managed-feedback'; export type { FetchSubmitFeedbackError, diff --git a/packages/oauth/src/managed-tools.ts b/packages/oauth/src/managed-tools.ts new file mode 100644 index 0000000000..46bf05f65a --- /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 0000000000..53de6cc6f4 --- /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]); + }); +});