mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-03 21:45:27 +00:00
fix: preserve session title state invariants
This commit is contained in:
parent
30cd334c49
commit
a108338ce5
13 changed files with 187 additions and 108 deletions
|
|
@ -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 */ {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue