* What a conversation is called, and what renaming it does.
*
* The title has two possible sources — the name the user gave it, and the first
* message it derives from otherwise — and two surfaces that must never disagree
* about which is showing (the pane header and the rail row). Both read the
* derivation here; both write through `commitSessionRename`.
*/
import { describe, expect, it, vi } from 'vitest';
import {
commitSessionRename,
isDerivedSessionTitle,
normalizeSessionTitleInput,
normalizeSessionTitleOverride,
SESSION_TITLE_MAX_LENGTH,
workbenchSessionTitle,
} from '@/lib/workbench/session-title';
import { foldEvents, type WorkbenchEvent, type WorkbenchFold } from '@/lib/workbench/session-store';
describe('what a conversation is called', () => {
it('prefers the name the user gave it', () => {
expect(workbenchSessionTitle({ title: '期末复习课', prompt: '帮我做一节课' })).toBe(
'期末复习课',
);
});
it('derives it from the first message when there is no name', () => {
expect(workbenchSessionTitle({ title: null, prompt: '帮我做一节课' })).toBe('帮我做一节课');
expect(workbenchSessionTitle({ title: ' ', prompt: '帮我做一节课' })).toBe('帮我做一节课');
});
it('has nothing to show for an empty conversation', () => {
expect(workbenchSessionTitle({ title: null, prompt: '' })).toBeNull();
expect(workbenchSessionTitle({})).toBeNull();
});
});
describe('what a rename sends', () => {
const session = { title: '期末复习课', prompt: '帮我做一节课' };
it('trims and caps', () => {
expect(normalizeSessionTitleInput(session, ' 新名字 ')).toBe('新名字');
expect(normalizeSessionTitleInput(session, 'x'.repeat(400))).toHaveLength(
SESSION_TITLE_MAX_LENGTH,
);
});
it('uses the input maxLength budget without splitting a surrogate pair', () => {
expect(normalizeSessionTitleOverride(`${'x'.repeat(118)}😀`)).toBe(`${'x'.repeat(118)}😀`);
expect(normalizeSessionTitleOverride(`${'x'.repeat(119)}😀tail`)).toBe('x'.repeat(119));
});
it('makes PostgreSQL-invalid API text safe for storage and projection', () => {
expect(normalizeSessionTitleOverride(`Safe\ud83d title`)).toBe('Safe� title');
expect(normalizeSessionTitleOverride(`Safe\u0000 title`)).toBe('Safe� title');
});
it('reads an empty box as "clear the name", not as a blank title', () => {
expect(normalizeSessionTitleInput(session, '')).toBeNull();
expect(normalizeSessionTitleInput(session, ' ')).toBeNull();
});
it('reads the derived title typed back in as a clear too', () => {
expect(isDerivedSessionTitle(session, '帮我做一节课')).toBe(true);
expect(normalizeSessionTitleInput(session, ' 帮我做一节课 ')).toBeNull();
});
});
describe('committing a rename', () => {
const session = { title: null, prompt: '帮我做一节课' };
it('writes it locally first, then settles on what the server stored', async () => {
const applied: { title: string | null; settled: boolean }[] = [];
const save = vi.fn(async () => '期末复习');
const outcome = await commitSessionRename({
current: session,
raw: '期末复习课',
apply: (title, settled) => applied.push({ title, settled }),
save,
});
expect(outcome).toBe('renamed');
expect(save).toHaveBeenCalledWith('期末复习课');
expect(applied).toEqual([
{ title: '期末复习课', settled: false },
{ title: '期末复习', settled: true },
]);
});
it('puts the old name back when the write is refused', async () => {
const applied: { title: string | null; settled: boolean }[] = [];
const outcome = await commitSessionRename({
current: { title: '旧名字', prompt: '帮我做一节课' },
raw: '新名字',
apply: (title, settled) => applied.push({ title, settled }),
save: async () => {
throw new Error('500');
},
});
expect(outcome).toBe('failed');
expect(applied).toEqual([
{ title: '新名字', settled: false },
{ title: '旧名字', settled: true },
]);
});
it.each([
{ settlement: 'success', save: async () => '服务端名字', outcome: 'renamed' },
{
settlement: 'failure',
save: async () => {
throw new Error('500');
},
outcome: 'failed',
},
])('does not apply a stale $settlement settlement', async ({ save, outcome: expected }) => {
const applied: (string | null)[] = [];
let current = true;
const outcome = await commitSessionRename({
current: { title: '旧名字', prompt: '帮我做一节课' },
raw: '本地名字',
apply: (title) => {
applied.push(title);
current = false;
},
save,
isCurrent: () => current,
});
expect(outcome).toBe(expected);
expect(applied).toEqual(['本地名字']);
});
it('clears the override on an empty box, so the derived title comes back', async () => {
const applied: (string | null)[] = [];
const save = vi.fn(async () => null);
const outcome = await commitSessionRename({
current: { title: '旧名字', prompt: '帮我做一节课' },
raw: ' ',
apply: (title) => applied.push(title),
save,
});
expect(outcome).toBe('renamed');
expect(save).toHaveBeenCalledWith(null);
expect(applied).toEqual([null, null]);
});
it.each([
['an empty input', ' '],
['the unchanged prompt fallback', ' 帮我做一节课 '],
])('does not turn %s into a manual clear when no override exists', async (_case, raw) => {
const save = vi.fn(async () => null);
const outcome = await commitSessionRename({
current: session,
raw,
apply: () => expect.unreachable('nothing should be written'),
save,
});
expect(outcome).toBe('unchanged');
expect(save).not.toHaveBeenCalled();
});
it('spends no round trip when nothing changed', async () => {
const save = vi.fn(async () => null);
const outcome = await commitSessionRename({
current: { title: '旧名字', prompt: '帮我做一节课' },
raw: ' 旧名字 ',
apply: () => expect.unreachable('nothing should be written'),
save,
});
expect(outcome).toBe('unchanged');
expect(save).not.toHaveBeenCalled();
});
it('preserves an explicit same-value decision queued behind an ambiguous write', async () => {
const applied: (string | null)[] = [];
const save = vi.fn(async () => '旧名字');
const outcome = await commitSessionRename({
current: { title: '旧名字', prompt: '帮我做一节课' },
raw: '旧名字',
apply: (title) => applied.push(title),
save,
forceSave: true,
});
expect(outcome).toBe('renamed');
expect(save).toHaveBeenCalledWith('旧名字');
expect(applied).toEqual(['旧名字', '旧名字']);
});
});
describe('the fold leaves the name alone', () => {
* A rename is not something the run did, so it is NOT in the event log: it
* arrives with the session meta, like the prompt. Replaying the whole log —
* which is what a reconnect and a fresh attach both do — must therefore leave
* it standing, or a renamed chat would revert to its first message every time
* the stream caught up.
*/
it('survives a replay of the whole event log', () => {
const named = { ...BLANK, sessionPrompt: '帮我做一节课', sessionTitle: '期末复习课' };
const replayed = foldEvents(named, [
event('session_start', { prompt: '帮我做一节课' }),
event('message_update', { text: '好的' }),
event('session_end', { status: 'succeeded' }),
]);
expect(replayed.sessionTitle).toBe('期末复习课');
expect(
workbenchSessionTitle({ title: replayed.sessionTitle, prompt: replayed.sessionPrompt }),
).toBe('期末复习课');
});
});
let seq = 0;
function event(type: string, data: unknown): WorkbenchEvent {
seq += 1;
return { id: seq, ts: 1000 + seq, attempt: 1, type, data };
}
const BLANK: WorkbenchFold = {
status: 'connecting',
lastEventId: 0,
error: null,
courseTitle: null,
sessionPrompt: null,
sessionTitle: null,
skillId: null,
skillViolations: [],
plan: [],
pages: {},
chat: [],
libraryRevision: 0,
stageLinkStageIds: [],
touchedStageIds: [],
runCourseStageIds: [],
generatingOrder: null,
panelOpen: false,
panelPinned: false,
thinkingKey: null,
assistantKey: null,
generationOpen: false,
epoch: 0,
waitingKey: null,
waitingArmed: false,
stageId: null,
};