Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
63f195c
feat: auto-generate session titles via the managed chat_title tool
7Sageer Jul 29, 2026
b8c1244
fix: harden auto-generated session titles
7Sageer Jul 29, 2026
db79855
fix: preserve managed title request headers
7Sageer Jul 29, 2026
7e09814
Pair auto-title endpoint overrides with matching OAuth credentials
7Sageer Jul 29, 2026
6ef7c26
fix: preserve legacy custom session titles
7Sageer Jul 29, 2026
2162ec9
fix: preserve automatic session title invariants
7Sageer Jul 29, 2026
f66a0c5
refactor: keep only the on-demand session title generation interface
7Sageer Jul 29, 2026
42fe810
feat: generate session title from the first recorded prompts
7Sageer Jul 30, 2026
159d444
test: make session title race tests deterministic
7Sageer Jul 30, 2026
81c6c64
Generate session titles from agent conversation history
7Sageer Jul 30, 2026
1b66557
fix: reject title generation without user prompts
7Sageer Jul 30, 2026
c4f5f3f
fix: bound session title prompt history
7Sageer Jul 30, 2026
d22a434
feat: enable session title generation without an experimental flag
7Sageer Jul 30, 2026
cee7f54
Merge remote-tracking branch 'origin/main' into auto-title
7Sageer Jul 30, 2026
86b1163
test: cover session title generation through the public REST path
7Sageer Jul 30, 2026
68ed623
feat: request session title generation from the TUI after each turn
7Sageer Jul 30, 2026
cca5387
Retry auto title generation for prompt-derived session titles
7Sageer Jul 30, 2026
87408d0
feat: record session title source and harden the generation lifecycle
7Sageer Jul 30, 2026
17271c9
chore: follow agent-core comment convention
7Sageer Jul 30, 2026
30cd334
fix: ignore stale session title callbacks
7Sageer Jul 30, 2026
a108338
fix: preserve session title state invariants
7Sageer Jul 31, 2026
531dbd3
Merge remote-tracking branch 'origin/main' into auto-title
7Sageer Jul 31, 2026
5e16893
refactor: seed session lifetime instead of querying the workspace han…
7Sageer Jul 31, 2026
b68c993
fix: honor the legacy custom title marker over a stale titleKind
7Sageer Jul 31, 2026
4196abf
fix: serialize session access and expose the session title state
7Sageer Jul 31, 2026
c63c511
chore: add session title changesets
7Sageer Jul 31, 2026
4ffb1b7
fix: close the session lifecycle races around close and title generation
7Sageer Jul 31, 2026
46a7797
feat: project the session title state through the session index
7Sageer Jul 31, 2026
add1f26
fix: serialize the remaining session access paths in the SDK
7Sageer Jul 31, 2026
9a95e1c
fix: detach the external abort listener once the chat title request s…
7Sageer Jul 31, 2026
e7c397a
fix: harden the session close/archive and create/fork lifecycle
7Sageer Jul 31, 2026
939627c
fix: keep forced title regeneration independent and veto queued title…
7Sageer Jul 31, 2026
a9727a0
fix: carry the title state through the session index and klient contract
7Sageer Jul 31, 2026
f4445aa
fix: coalesce harness resumes, lock fork targets, and cover the title…
7Sageer Jul 31, 2026
0e89f70
fix: serve the canonical title state from the session index and versi…
7Sageer Jul 31, 2026
c2d7217
fix: let the newest title generation request win the write-back
7Sageer Jul 31, 2026
cd1cea0
fix: fold archive into close and own the create/fork rollback
7Sageer Jul 31, 2026
7292ccc
fix: key harness resume coalescing by the full input
7Sageer Jul 31, 2026
1a23797
refactor(agent-core-v2): drop session close-awareness from title gene…
7Sageer Jul 31, 2026
7972c82
Delete .changeset/sdk-session-title-kind.md
7Sageer Jul 31, 2026
e11da34
refactor(session-title): drop the unused force regeneration path
7Sageer Jul 31, 2026
21945e6
refactor(agent-core-v2): drop the title state projection from the ses…
7Sageer Jul 31, 2026
6523e81
refactor(agent-core-v2): inline the transcript live-tail merge into m…
7Sageer Jul 31, 2026
9f65e2a
Merge remote-tracking branch 'origin/main' into auto-title
7Sageer Jul 31, 2026
67a240c
refactor(agent-core-v2): drop the closing registry from the session l…
7Sageer Jul 31, 2026
64099bd
Merge branch 'main' into auto-title
7Sageer Jul 31, 2026
65b7767
fix(agent-core-v2): complete the HostRequestHeaders migration in the …
7Sageer Jul 31, 2026
e56b878
fix(node-sdk): mark resume telemetry field ignored
7Sageer Jul 31, 2026
771a55f
feat(tui): request the session title as soon as a prompt is accepted
7Sageer Jul 31, 2026
bd10d5e
fix(agent-core-v2): drop the numbered user prefixes from the title re…
7Sageer Jul 31, 2026
bbcbc09
refactor(tui): ask for the session title only once per session attach
7Sageer Jul 31, 2026
b3cb0e8
refactor(tui): drop the automatic session title trigger
7Sageer Aug 3, 2026
f99befd
Delete .changeset/session-title-generation.md
7Sageer Aug 3, 2026
122e4e5
feat(agent-core-v2): add forced session title regeneration
liruifengv Aug 12, 2026
6a27279
feat(agent-core-v2): selectable conversation excerpts for title gener…
liruifengv Aug 13, 2026
7b1ecb0
Merge remote-tracking branch 'origin/main' into auto-title
liruifengv Aug 13, 2026
6da6934
feat(agent-core-v2): gate session title generation behind an experime…
liruifengv Aug 13, 2026
93a8202
refactor(agent-core-v2): rename the title flag to auto_session_title
liruifengv Aug 13, 2026
a40c9e2
fix(agent-core-v2): land the merge resolution leftovers
liruifengv Aug 13, 2026
8e3f9f7
test(node-sdk): enable the auto_session_title flag in the title suites
liruifengv Aug 13, 2026
4f1aa38
chore: changeset for the experimental web session titles
liruifengv Aug 13, 2026
949b913
chore: scope the title changeset to the SDK package
liruifengv Aug 13, 2026
e2a7e5c
chore: cover the internal packages in the title changesets
liruifengv Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/agent-core-v2-session-title.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/agent-core-v2": minor
---

Add the Session-scoped `ISessionTitleService` for managed AI session titles: composes the excerpt sent to the platform chat_title tool from the main agent's conversation (the first user prompts, the strict `first_turn` pair, or the head+tail `digest` for multi-turn sessions; assistant segments keep only final text), persists the result with a `titleKind` (`replaceable` / `generated` / `custom`) that never overwrites a user-renamed title unless explicitly forced, and rebroadcasts `session.meta.updated`. Gated by the new experimental `auto_session_title` flag and a managed OAuth login.
5 changes: 5 additions & 0 deletions .changeset/auto-session-title.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code-sdk": minor
---

Add `generateSessionTitle` (v2 engine) for managed AI session titles: optional `force` regeneration over generated/custom titles and selectable conversation excerpts (`user_prompts` / `first_turn` / `digest`). Gated by the experimental `auto_session_title` flag and a managed OAuth login.
5 changes: 5 additions & 0 deletions .changeset/kap-server-session-title-route.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kap-server": patch
---

Add `POST /api/v1/sessions/{session_id}/title/generate` with an optional `{ "force": true, "source": "user_prompts" | "first_turn" | "digest" }` body; unknown sessions return 40401 and unavailable generation (flag off, no managed login, no prompt yet, backend failure) returns the new 40923 SESSION_TITLE_UNAVAILABLE.
5 changes: 5 additions & 0 deletions .changeset/klient-session-title.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/klient": patch
---

Expose `session(id).generateTitle({ force, source })` on the session facade and the matching `sessionTitleService` wire contract.
5 changes: 5 additions & 0 deletions .changeset/oauth-chat-title.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code-oauth": patch
---

Add `fetchChatTitle` for the managed platform `/tools` `chat_title` method: protocol headers, an 8s timeout, response validation, and structured failures.
2 changes: 1 addition & 1 deletion packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ export interface SessionStateSnapshot {
readonly id: string;
readonly version?: number;
readonly title?: string;
readonly isCustomTitle?: boolean;
readonly titleKind?: 'replaceable' | 'generated' | 'custom';
readonly lastPrompt?: string;
readonly createdAt: number;
readonly updatedAt: number;
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-core-v2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ export * from '#/session/sessionActivity/sessionActivity';
export * from '#/session/sessionActivity/sessionActivityService';
export * from '#/session/sessionActivity/sessionOutcomeMirror';
export * from '#/session/sessionActivity/sessionOutcomeMirrorService';
export * from '#/session/sessionTitle/agentTitlePromptSource';
import '#/session/sessionTitle/agentTitlePromptSourceService';
export * from '#/session/sessionTitle/sessionTitle';
export * from '#/session/sessionTitle/sessionTitleService';
import '#/session/sessionTitle/flag';
export * from '#/session/sessionToolPolicy/sessionToolPolicy';
export * from '#/session/sessionToolPolicy/sessionToolPolicyService';
export * from '#/app/config/config';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { IEventService } from '#/app/event/event';

import { titleFromPromptMetadataText } from '#/agent/prompt/promptMetadataText';

import type { ISessionMetadata } from './sessionMetadata';
import type { ISessionMetadata, SessionTitleKind } from './sessionMetadata';

export function isUntitled(title: string | undefined): boolean {
return title === undefined || title.trim().length === 0 || title === 'New Session';
Expand All @@ -32,12 +32,12 @@ export async function applyPromptMetadataUpdate(
): Promise<void> {
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({
Expand All @@ -48,7 +48,7 @@ export async function applyPromptMetadataUpdate(
title: patch.title,
patch: {
title: patch.title,
isCustomTitle: patch.isCustomTitle,
isCustomTitle: patch.titleKind === undefined ? undefined : false,
lastPrompt: text,
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ export interface AgentMeta {

export const SESSION_META_VERSION = 2;

export type SessionTitleKind = 'replaceable' | 'generated' | 'custom';

export interface SessionMeta {
readonly id: string;
readonly version?: number;
readonly title?: string;
readonly isCustomTitle?: boolean;
readonly titleKind?: SessionTitleKind;
readonly lastPrompt?: string;
readonly createdAt: number;
readonly updatedAt: number;
Expand All @@ -56,6 +58,17 @@ export interface ISessionMetadata {
read(): Promise<SessionMeta>;
update(patch: SessionMetaPatch, opts?: { readonly touchUpdatedAt?: boolean }): Promise<void>;
setTitle(title: string): Promise<void>;
/**
* Applies a generated title unless the user customized theirs; the title
* kind is re-checked inside the serialized update, right before the write,
* so a custom title set while a generation was in flight still wins.
* `force` skips the kind check entirely (explicit user-requested
* regeneration — last writer wins).
*/
setGeneratedTitleIfUncustomized(
title: string,
opts?: { force?: boolean },
): Promise<boolean>;
setArchived(archived: boolean): Promise<void>;
registerAgent(agentId: string, meta: AgentMeta): Promise<void>;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,23 @@
* backfilled and persisted on load for documents written before the seeding
* existed (without touching `updatedAt`, so a format heal never reorders
* session listings). `updatedAt` tracks content activity only: management
* writes (rename via `setTitle`, archive/restore via `setArchived`) keep the
* persisted value through `touchUpdatedAt: false`, an explicit
* `patch.updatedAt` always wins (fork restores the source's recency), and
* agent registration is a structural write that never touches it — neither
* when resume materializes a cold session's agents, nor when a runtime
* subagent registers mid-turn (the turn's own submit/end moments carry
* recency). Bound at Session scope.
* writes (rename via `setTitle`, archive/restore via `setArchived`, the
* generated-title write-back) keep the persisted value through
* `touchUpdatedAt: false`, an explicit `patch.updatedAt` always wins (fork
* restores the source's recency), and agent registration is a structural
* write that never touches it — neither when resume materializes a cold
* session's agents, nor when a runtime subagent registers mid-turn (the
* turn's own submit/end moments carry recency). The canonical title state
* is `titleKind`; every persist additionally double-writes the v1-readable
* `isCustomTitle` marker derived from it, and on load an explicit
* `isCustomTitle: true` outranks a stale `titleKind` (a v1 rename spreads
* the original document, so the two can disagree) while a `false` marker
* never downgrades a modern generated/custom state. The generated-title
* write path (`setGeneratedTitleIfUncustomized`) serializes through the same
* update queue as everything else and re-checks the title kind inside the
* queued write, so a custom title set while a generation was in flight is
* never overwritten — unless the caller passes `force` (explicit
* regeneration, last writer wins). Bound at Session scope.
*
* Read-model mirroring (flag `persistence_minidb_readmodel`): after a metadata
* update is persisted, the fresh summary is recorded into the App-scoped
Expand Down Expand Up @@ -55,6 +65,7 @@ import {
type SessionMeta,
type SessionMetadataChangedEvent,
type SessionMetaPatch,
type SessionTitleKind,
} from './sessionMetadata';

const META_KEY = 'state.json';
Expand Down Expand Up @@ -119,28 +130,42 @@ export class SessionMetadata extends Service implements ISessionMetadata {
patch: SessionMetaPatch,
opts?: { readonly touchUpdatedAt?: boolean },
): Promise<void> {
return this.enqueueUpdate(() => this.applyUpdate(patch, opts));
return this.enqueueUpdate(async () => {
await this.applyUpdate(patch, opts);
});
}

private async applyUpdate(
patch: SessionMetaPatch,
opts?: { readonly touchUpdatedAt?: boolean },
): Promise<void> {
): Promise<boolean> {
await this.ready;
if (this.disposed) return;
if (this.disposed) return false;
const updatedAt =
patch.updatedAt ?? (opts?.touchUpdatedAt === false ? this.data.updatedAt : Date.now());
this.data = { ...this.data, ...patch, updatedAt };
await this.store.set(this.scope, META_KEY, this.data);
if (this.disposed) return;
await this.store.set(this.scope, META_KEY, encodeSessionMeta(this.data));
if (this.disposed) return false;
this.mirrorToReadModel();
this._onDidChangeMetadata.fire({
changed: Object.keys(patch) as (keyof SessionMeta)[],
});
return true;
}

async setTitle(title: string): Promise<void> {
await this.update({ title, isCustomTitle: true }, { touchUpdatedAt: false });
await this.update({ title, titleKind: 'custom' }, { touchUpdatedAt: false });
}

async setGeneratedTitleIfUncustomized(
title: string,
opts?: { force?: boolean },
): Promise<boolean> {
return this.enqueueUpdate(async () => {
await this.ready;
if (opts?.force !== true && this.data.titleKind === 'custom') return false;
return this.applyUpdate({ title, titleKind: 'generated' }, { touchUpdatedAt: false });
});
}

async setArchived(archived: boolean): Promise<void> {
Expand All @@ -160,9 +185,12 @@ export class SessionMetadata extends Service implements ISessionMetadata {
});
}

private enqueueUpdate(work: () => Promise<void>): Promise<void> {
private enqueueUpdate<T>(work: () => Promise<T>): Promise<T> {
const run = this.updateQueue.then(work, work);
const tracked = run.catch(() => {});
const tracked: Promise<void> = run.then(
() => undefined,
() => undefined,
);
this.updateQueue = tracked;
pendingWrites.add(tracked);
void tracked.finally(() => pendingWrites.delete(tracked));
Expand Down Expand Up @@ -201,13 +229,17 @@ export class SessionMetadata extends Service implements ISessionMetadata {
const existing = await this.store.get<SessionMeta>(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;
}
Expand All @@ -222,7 +254,7 @@ export class SessionMetadata extends Service implements ISessionMetadata {
agents: {},
custom: {},
};
await this.store.set(this.scope, META_KEY, this.data);
await this.store.set(this.scope, META_KEY, encodeSessionMeta(this.data));
this.mirrorToReadModel();
this.log.debug('session metadata created', { sessionId: this.ctx.sessionId });
}
Expand All @@ -248,28 +280,83 @@ function recordEquals(a: AgentMeta['labels'], b: AgentMeta['labels']): boolean {
}

export function normalizeSessionMeta(raw: SessionMeta, sessionId: string): SessionMeta {
const legacy = raw as unknown as {
createdAt?: unknown;
updatedAt?: unknown;
workDir?: unknown;
};
const legacy = raw as unknown as LegacySessionMeta;
const normalizedTitle = normalizeSessionTitle(legacy);
const {
createdAt: legacyCreatedAt,
updatedAt: legacyUpdatedAt,
workDir: legacyWorkDir,
titleSource: _legacyTitleSource,
isCustomTitle: _legacyIsCustomTitle,
customTitle: _legacyCustomTitle,
...clean
} = legacy;
const cwd =
raw.cwd ?? (typeof legacy.workDir === 'string' && legacy.workDir.length > 0
? legacy.workDir
clean.cwd ?? (typeof legacyWorkDir === 'string' && legacyWorkDir.length > 0
? legacyWorkDir
: undefined);
if (raw.version === SESSION_META_VERSION) {
return cwd === raw.cwd ? raw : { ...raw, cwd };
}
const { title, titleKind } = normalizedTitle;
return {
...raw,
id: sessionId,
...clean,
id: clean.version === SESSION_META_VERSION ? clean.id : sessionId,
version: SESSION_META_VERSION,
cwd,
createdAt: toEpochMs(legacy.createdAt),
updatedAt: toEpochMs(legacy.updatedAt),
title,
titleKind,
createdAt: toEpochMs(legacyCreatedAt),
updatedAt: toEpochMs(legacyUpdatedAt),
};
}

type LegacySessionMeta = Omit<SessionMeta, 'createdAt' | 'updatedAt'> & {
readonly createdAt?: unknown;
readonly updatedAt?: unknown;
readonly workDir?: unknown;
readonly titleSource?: unknown;
readonly isCustomTitle?: unknown;
readonly customTitle?: unknown;
};

function normalizeSessionTitle(
raw: LegacySessionMeta,
): Pick<SessionMeta, 'title' | 'titleKind'> {
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<string, unknown>;
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') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* `sessionTitle` domain (L6) — title prompt projection contract.
*
* Defines the Agent-scoped `IAgentTitlePromptSource` used to read the first
* active natural-language prompts from the live conversation context, plus
* the turn excerpts behind the `first_turn` / `digest` title sources.
*/

import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';

/**
* The first turn's excerpt: the opening natural-language user prompt and the
* final assistant text of that turn. Either side is `undefined` when the
* live window does not (yet) hold it — `first_turn` generation stays strict
* and reports unavailability instead of degrading.
*/
export interface TitleTurnExcerpt {
readonly user?: string | undefined;
readonly assistant?: string | undefined;
}

/**
* The whole-conversation digest excerpt: the first and last natural-language
* user prompts (collapsed into one when the conversation has a single
* prompt) and the final assistant text of the latest turn.
*/
export interface TitleDigestExcerpt {
readonly firstUser?: string | undefined;
readonly lastUser?: string | undefined;
readonly assistant?: string | undefined;
}

export interface IAgentTitlePromptSource {
readonly _serviceBrand: undefined;

firstUserPrompts(limit: number): Promise<readonly string[]>;

firstTurnExcerpt(): Promise<TitleTurnExcerpt>;

digestExcerpt(): Promise<TitleDigestExcerpt>;
}

export const IAgentTitlePromptSource: ServiceIdentifier<IAgentTitlePromptSource> =
createDecorator<IAgentTitlePromptSource>('agentTitlePromptSource');
Loading
Loading