fix: preserve session title state invariants

This commit is contained in:
7Sageer 2026-07-31 11:38:12 +08:00
parent 30cd334c49
commit a108338ce5
13 changed files with 187 additions and 108 deletions

View file

@ -335,8 +335,7 @@ export interface SessionStateSnapshot {
readonly id: string;
readonly version?: number;
readonly title?: string;
readonly titleSource?: 'prompt' | 'generated' | 'custom';
readonly isCustomTitle?: boolean;
readonly titleKind?: 'replaceable' | 'generated' | 'custom';
readonly lastPrompt?: string;
readonly createdAt: number;
readonly updatedAt: number;
@ -1009,7 +1008,7 @@ export interface AgentStateSnapshot {
'llmRequester.lastConfigLogSignature': string | undefined;
'llmRequester.mediaDegradedTurns': Set<number>;
'llmRequester.mediaStrippedTurns': Map<number, /* MediaStripSnapshot — packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts */ {
readonly "__@mediaStripSnapshotBrand@2657": undefined;
readonly "__@mediaStripSnapshotBrand@2658": undefined;
}>;
'llmRequester.turnConfigs': Map<number, /* TurnRequestConfig — packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts */ {
readonly resolved: /* ProfileModelContext — packages/agent-core-v2/src/agent/profile/profile.ts */ {

View file

@ -8,7 +8,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,
@ -61,12 +64,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({
@ -77,7 +80,7 @@ export async function applyPromptMetadataUpdate(
title: patch.title,
patch: {
title: patch.title,
isCustomTitle: patch.isCustomTitle,
isCustomTitle: patch.titleKind === undefined ? undefined : false,
lastPrompt: text,
},
},

View file

@ -428,7 +428,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
const title = opts.title ?? `Fork: ${sourceMeta?.title || sourceId}`;
await targetMeta.update({
title,
isCustomTitle: opts.title !== undefined ? true : sourceMeta?.isCustomTitle === true,
titleKind: opts.title === undefined ? 'replaceable' : 'custom',
forkedFrom: sourceId,
archived: false,
lastPrompt: sourceMeta?.lastPrompt,

View file

@ -25,12 +25,13 @@ export interface AgentMeta {
export const SESSION_META_VERSION = 2;
export type SessionTitleKind = 'replaceable' | 'generated' | 'custom';
export interface SessionMeta {
readonly id: string;
readonly version?: number;
readonly title?: string;
readonly titleSource?: 'prompt' | 'generated' | 'custom';
readonly isCustomTitle?: boolean;
readonly titleKind?: SessionTitleKind;
readonly lastPrompt?: string;
readonly createdAt: number;
readonly updatedAt: number;

View file

@ -44,6 +44,7 @@ import {
type SessionMeta,
type SessionMetadataChangedEvent,
type SessionMetaPatch,
type SessionTitleKind,
} from './sessionMetadata';
const META_KEY = 'state.json';
@ -109,14 +110,14 @@ export class SessionMetadata extends Disposable implements ISessionMetadata {
}
async setTitle(title: string): Promise<void> {
await this.update({ title, titleSource: 'custom', isCustomTitle: true });
await this.update({ title, titleKind: 'custom' });
}
async setGeneratedTitleIfUncustomized(title: string): Promise<boolean> {
return this.enqueueUpdate(async () => {
await this.ready;
if (this.data.isCustomTitle === true) return false;
await this.applyUpdate({ title, titleSource: 'generated', isCustomTitle: false });
if (this.data.titleKind === 'custom') return false;
await this.applyUpdate({ title, titleKind: 'generated' });
return true;
});
}
@ -174,7 +175,11 @@ export class SessionMetadata extends Disposable implements ISessionMetadata {
const existing = await this.store.get<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 ?? {},
@ -220,45 +225,74 @@ function recordEquals(a: AgentMeta['labels'], b: AgentMeta['labels']): boolean {
}
export function normalizeSessionMeta(raw: SessionMeta, sessionId: string): SessionMeta {
const clean = { ...raw };
const legacy = raw as unknown as {
createdAt?: unknown;
updatedAt?: unknown;
workDir?: unknown;
customTitle?: unknown;
};
const legacy = raw as unknown as LegacySessionMeta;
const normalizedTitle = normalizeSessionTitle(legacy);
const {
createdAt: legacyCreatedAt,
updatedAt: legacyUpdatedAt,
workDir: legacyWorkDir,
titleSource: _legacyTitleSource,
isCustomTitle: _legacyIsCustomTitle,
customTitle: _legacyCustomTitle,
...clean
} = legacy;
const cwd =
clean.cwd ?? (typeof legacy.workDir === 'string' && legacy.workDir.length > 0
? legacy.workDir
clean.cwd ?? (typeof legacyWorkDir === 'string' && legacyWorkDir.length > 0
? legacyWorkDir
: undefined);
const legacyCustomTitle =
typeof legacy.customTitle === 'string' ? legacy.customTitle : undefined;
const hasModernTitleState =
typeof clean.title === 'string' && typeof clean.isCustomTitle === 'boolean';
const title = hasModernTitleState ? clean.title : (legacyCustomTitle ?? clean.title);
const isCustomTitle = hasModernTitleState
? clean.isCustomTitle
: legacyCustomTitle === undefined
? clean.isCustomTitle
: true;
if (clean.version === SESSION_META_VERSION) {
if (cwd === clean.cwd && title === clean.title && isCustomTitle === clean.isCustomTitle) {
return clean;
}
return { ...clean, cwd, title, isCustomTitle };
}
const { title, titleKind } = normalizedTitle;
return {
...clean,
id: sessionId,
id: clean.version === SESSION_META_VERSION ? clean.id : sessionId,
version: SESSION_META_VERSION,
cwd,
title,
isCustomTitle,
createdAt: toEpochMs(legacy.createdAt),
updatedAt: toEpochMs(legacy.updatedAt),
titleKind,
createdAt: toEpochMs(legacyCreatedAt),
updatedAt: toEpochMs(legacyUpdatedAt),
};
}
type LegacySessionMeta = Omit<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 && isSessionTitleKind(raw.titleKind)) {
return { title, titleKind: raw.titleKind };
}
if (title !== undefined && typeof raw.isCustomTitle === 'boolean') {
return { title, titleKind: raw.isCustomTitle ? 'custom' : 'replaceable' };
}
if (typeof raw.customTitle === 'string') {
return { title: raw.customTitle, titleKind: 'custom' };
}
return title === undefined ? {} : { title, titleKind: 'replaceable' };
}
function isSessionTitleKind(value: unknown): value is SessionTitleKind {
return value === 'replaceable' || value === 'generated' || value === 'custom';
}
function sessionMetaTitleNeedsMigration(raw: SessionMeta, normalized: SessionMeta): boolean {
const record = raw as unknown as Record<string, unknown>;
return (
raw.title !== normalized.title ||
raw.titleKind !== normalized.titleKind ||
Object.hasOwn(record, 'titleSource') ||
Object.hasOwn(record, 'isCustomTitle') ||
Object.hasOwn(record, 'customTitle')
);
}
export function toEpochMs(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {

View file

@ -65,8 +65,8 @@ export class SessionTitleService implements ISessionTitleService {
async generateTitle(options?: { readonly force?: boolean }): Promise<string | undefined> {
const current = await this.metadata.read();
if (hasCustomTitle(current)) return undefined;
if (current.titleSource === 'generated' && options?.force !== true) return undefined;
if (current.titleKind === 'custom') return undefined;
if (current.titleKind === 'generated' && options?.force !== true) return undefined;
const main = this.agentLifecycle.get(MAIN_AGENT_ID);
const prompts =
main === undefined
@ -89,7 +89,7 @@ export class SessionTitleService implements ISessionTitleService {
private async generateAndApplyOnce(chatContent: string): Promise<string | undefined> {
const current = await this.metadata.read();
if (hasCustomTitle(current)) return undefined;
if (current.titleKind === 'custom') return undefined;
const provider = this.providers.get(KIMI_CODE_PROVIDER_NAME);
if (
provider === undefined ||
@ -158,17 +158,6 @@ export class SessionTitleService implements ISessionTitleService {
}
}
function hasCustomTitle(metadata: {
readonly title?: unknown;
readonly isCustomTitle?: unknown;
readonly customTitle?: unknown;
}): boolean {
if (typeof metadata.title === 'string' && typeof metadata.isCustomTitle === 'boolean') {
return metadata.isCustomTitle;
}
return metadata.isCustomTitle === true || typeof metadata.customTitle === 'string';
}
function titleInputFromPrompts(prompts: readonly string[]): string | undefined {
if (prompts.length === 0) return undefined;
return prompts

View file

@ -97,6 +97,7 @@ describe('applyPromptMetadataUpdate', () => {
expect(readMeta().lastPrompt).toBe('第二条');
expect(readMeta().title).toBe('第一条');
expect(readMeta().titleKind).toBe('replaceable');
});
it('updates metadata for slash activations', async () => {

View file

@ -34,7 +34,10 @@ import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycl
import { SessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycleService';
import { IAgentActivityView } from '#/agent/activityView/activityView';
import { ISessionExternalHooksService } from '#/session/externalHooks/externalHooks';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import {
ISessionMetadata,
type SessionMetaPatch,
} from '#/session/sessionMetadata/sessionMetadata';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy';
import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog';
@ -1181,6 +1184,55 @@ describe('SessionLifecycleService', () => {
});
}
it('marks the default fork title as replaceable', async () => {
const updates: SessionMetaPatch[] = [];
const svc = build([
workspaceGetStub(),
stubPair(ISessionMetadata, {
...metadataStub(),
read: () =>
Promise.resolve({
title: 'generated source',
titleKind: 'generated',
agents: {},
} as never),
update: (patch) => {
updates.push(patch);
return Promise.resolve();
},
}),
]);
await svc.create({ sessionId: 'src', workDir: '/tmp/proj' });
await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' });
expect(updates).toContainEqual(
expect.objectContaining({ title: 'Fork: generated source', titleKind: 'replaceable' }),
);
});
it('marks an explicit fork title as custom', async () => {
const updates: SessionMetaPatch[] = [];
const svc = build([
workspaceGetStub(),
stubPair(ISessionMetadata, {
...metadataStub(),
read: () => Promise.resolve({ title: 'source', agents: {} } as never),
update: (patch) => {
updates.push(patch);
return Promise.resolve();
},
}),
]);
await svc.create({ sessionId: 'src', workDir: '/tmp/proj' });
await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst', title: 'user title' });
expect(updates).toContainEqual(
expect.objectContaining({ title: 'user title', titleKind: 'custom' }),
);
});
it('copies blobs, plans, background tasks, and media originals into the fork', async () => {
const root = await makeTmpRoot();
const svc = build([

View file

@ -93,7 +93,7 @@ describe('SessionMetadata', () => {
const meta = ix.get(ISessionMetadata);
await meta.setTitle('t');
await meta.setArchived(true);
expect(await meta.read()).toMatchObject({ title: 't', titleSource: 'custom', archived: true });
expect(await meta.read()).toMatchObject({ title: 't', titleKind: 'custom', archived: true });
});
it('sets a generated title while the metadata remains uncustomized', async () => {
@ -102,8 +102,7 @@ describe('SessionMetadata', () => {
await expect(meta.setGeneratedTitleIfUncustomized('generated title')).resolves.toBe(true);
await expect(meta.read()).resolves.toMatchObject({
title: 'generated title',
titleSource: 'generated',
isCustomTitle: false,
titleKind: 'generated',
});
});
@ -183,13 +182,13 @@ describe('SessionMetadata', () => {
const meta = ix.get(ISessionMetadata);
await expect(meta.read()).resolves.toMatchObject({
title: 'legacy title',
isCustomTitle: true,
titleKind: 'custom',
});
const fresh = createFreshMetadata(ix);
await expect(fresh.read()).resolves.toMatchObject({
title: 'legacy title',
isCustomTitle: true,
titleKind: 'custom',
});
});
@ -209,16 +208,44 @@ describe('SessionMetadata', () => {
const meta = ix.get(ISessionMetadata);
await expect(meta.read()).resolves.toMatchObject({
title: 'renamed title',
isCustomTitle: true,
titleKind: 'custom',
});
await meta.update({ archived: true });
const fresh = createFreshMetadata(ix);
await expect(fresh.read()).resolves.toMatchObject({
title: 'renamed title',
isCustomTitle: true,
titleKind: 'custom',
archived: true,
});
const persisted = await store.get<Record<string, unknown>>(META_SCOPE, 'state.json');
expect(persisted).not.toHaveProperty('isCustomTitle');
expect(persisted).not.toHaveProperty('customTitle');
});
it('migrates a legacy non-custom title to replaceable title state', async () => {
const store = ix.get(IAtomicDocumentStore);
await store.set(META_SCOPE, 'state.json', {
id: 's1',
version: 2,
createdAt: 1700000000000,
updatedAt: 1700000000000,
archived: false,
title: 'prompt title',
isCustomTitle: false,
agents: {},
custom: {},
});
const meta = ix.get(ISessionMetadata);
await expect(meta.read()).resolves.toMatchObject({
title: 'prompt title',
titleKind: 'replaceable',
});
const persisted = await store.get<Record<string, unknown>>(META_SCOPE, 'state.json');
expect(persisted).toMatchObject({ title: 'prompt title', titleKind: 'replaceable' });
expect(persisted).not.toHaveProperty('isCustomTitle');
});
it('keeps a queued custom title when a generated title is enqueued afterward', async () => {
@ -255,7 +282,7 @@ describe('SessionMetadata', () => {
await expect(generated).resolves.toBe(false);
await expect(meta.read()).resolves.toMatchObject({
title: 'user title',
isCustomTitle: true,
titleKind: 'custom',
});
});

View file

@ -1,6 +1,6 @@
/**
* Scenario: on-demand managed chat_title generation through the session-scoped
* service, including OAuth failures, legacy custom titles, request headers,
* service, including OAuth failures, title-state transitions, request headers,
* and races.
* Wiring: the real title service with contract fakes; only fetch crosses the
* external boundary. Run with `pnpm --filter @moonshot-ai/agent-core-v2 exec
@ -100,12 +100,12 @@ class FakeSessionMetadata implements ISessionMetadata {
}
setTitle(title: string): Promise<void> {
return this.update({ title, titleSource: 'custom', isCustomTitle: true });
return this.update({ title, titleKind: 'custom' });
}
async setGeneratedTitleIfUncustomized(title: string): Promise<boolean> {
if (this.meta.isCustomTitle === true) return false;
await this.update({ title, titleSource: 'generated', isCustomTitle: false });
if (this.meta.titleKind === 'custom') return false;
await this.update({ title, titleKind: 'generated' });
return true;
}
@ -252,8 +252,7 @@ describe('SessionTitleService', () => {
expect(title).toBe('生成的标题');
expect(metadata.meta.title).toBe('生成的标题');
expect(metadata.meta.isCustomTitle).toBe(false);
expect(metadata.meta.titleSource).toBe('generated');
expect(metadata.meta.titleKind).toBe('generated');
const [, init] = fetchMock.mock.calls[0]!;
expect(JSON.parse(init?.body as string)).toEqual({
@ -329,7 +328,7 @@ describe('SessionTitleService', () => {
await expect(generation).resolves.toBeUndefined();
expect(metadata.meta.title).toBe('user 取的标题');
expect(metadata.meta.isCustomTitle).toBe(true);
expect(metadata.meta.titleKind).toBe('custom');
});
it('skips generation when the current title was already generated', async () => {
@ -349,7 +348,7 @@ describe('SessionTitleService', () => {
'生成的标题',
);
expect(metadata.meta.title).toBe('生成的标题');
expect(metadata.meta.titleSource).toBe('generated');
expect(metadata.meta.titleKind).toBe('generated');
});
it('never overwrites a custom title even when forced', async () => {
@ -387,7 +386,7 @@ describe('SessionTitleService', () => {
it('keeps the current title when the backend request fails', async () => {
fetchMock.mockImplementationOnce(async () => new Response('', { status: 500 }));
titlePrompts = ['hello'];
await metadata.update({ title: 'hello', isCustomTitle: false });
await metadata.update({ title: 'hello', titleKind: 'replaceable' });
await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined();
expect(metadata.meta.title).toBe('hello');
@ -479,29 +478,6 @@ describe('SessionTitleService', () => {
expect(resolvedOAuthRefs[0]?.key).not.toBe(MANAGED_PROVIDER.oauth?.key);
});
it('does not generate over a legacy customTitle', async () => {
metadata.meta = {
...metadata.meta,
customTitle: 'legacy title',
} as SessionMeta;
await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
});
it('trusts a modern non-custom title over a stale legacy customTitle', async () => {
metadata.meta = {
...metadata.meta,
title: 'easy title',
isCustomTitle: false,
customTitle: 'stale legacy title',
} as SessionMeta;
titlePrompts = ['hello'];
await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBe('生成的标题');
expect(metadata.meta.title).toBe('生成的标题');
});
it('shares an in-flight generation between concurrent requests', async () => {
const pendingFetch = createPendingFetch();
fetchMock.mockImplementationOnce(pendingFetch.fetch);

View file

@ -30,7 +30,7 @@ export interface SessionMetaUpdatedPayload {
readonly patch: {
readonly title?: string;
readonly isCustomTitle?: boolean;
readonly lastPrompt: string;
readonly lastPrompt?: string;
};
}
@ -75,7 +75,7 @@ const sessionMetaUpdatedSchema = z.object({
isCustomTitle: z.boolean().optional(),
lastPrompt: z.string().optional(),
}),
});
}) satisfies z.ZodType<SessionMetaUpdatedPayload>;
export const catalogChangedSchema = z.object({
changed: z.array(

View file

@ -22,8 +22,7 @@ export const sessionMetaSchema = z.object({
id: z.string(),
version: z.number().optional(),
title: z.string().optional(),
titleSource: z.enum(['prompt', 'generated', 'custom']).optional(),
isCustomTitle: z.boolean().optional(),
titleKind: z.enum(['replaceable', 'generated', 'custom']).optional(),
lastPrompt: z.string().optional(),
createdAt: z.number(),
updatedAt: z.number(),
@ -38,8 +37,7 @@ export const sessionMetaSchema = z.object({
export const sessionMetaPatchSchema = z.object({
version: z.number().optional(),
title: z.string().optional(),
titleSource: z.enum(['prompt', 'generated', 'custom']).optional(),
isCustomTitle: z.boolean().optional(),
titleKind: z.enum(['replaceable', 'generated', 'custom']).optional(),
lastPrompt: z.string().optional(),
updatedAt: z.number().optional(),
archived: z.boolean().optional(),
@ -54,8 +52,7 @@ export const sessionMetaKeySchema = z.enum([
'id',
'version',
'title',
'titleSource',
'isCustomTitle',
'titleKind',
'lastPrompt',
'createdAt',
'updatedAt',

View file

@ -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,