// SessionTitleService.rename: user-source acceptance, normalization/rejection // boundaries, and the pin (a user-sourced latest title schedules no automatic // revision; explicit refresh stays the unpin). import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import SessionTitleService, { SessionTitleProviderId, foldSessionTitle, type SessionTitleProviderRequest, } from '@deepseek-ai/dsh-session-title' const CONFIG = { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 40, } as const async function settle(): Promise { await new Promise(resolve => setTimeout(resolve, 0)) } function appendHumanPrompt(session: ReturnType, text: string) { return session.append('user/message', createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) } describe('SessionTitleService.rename', () => { it('appends a normalized user-source title', async () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('rename-accept')) session.append('turn/start', { turn: 1 }) appendHumanPrompt(session, 'Original prompt text') await settle() const accepted = ctx.sessionTitle.rename(session, ' Hand\tpicked name ') expect(accepted).toMatchObject({ title: 'Hand picked name', messageSeqs: [], source: { kind: 'user' }, }) const event = session.events.findLast(item => item.type === 'session/title') expect(event?.data).toEqual({ title: 'Hand picked name', messageSeqs: [], source: { kind: 'user' }, }) // foldSessionTitle round-trips the third source kind. expect(foldSessionTitle(session.events)?.source).toEqual({ kind: 'user' }) }) it('rejects titles that normalize to empty and dead sessions', async () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('rename-reject')) expect(() => ctx.sessionTitle.rename(session, '  ')).toThrow(/visible characters/) expect(() => ctx.sessionTitle.rename(Session.create(SessionId('detached')), 'name')) .toThrow(/not live in this store/) }) it('pins the title: later user messages schedule no automatic revision; refresh unpins', async () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionTitleService, CONFIG) const generate = vi.fn(async (request: SessionTitleProviderRequest) => ({ title: 'Provider title', messageSeqs: request.messages.map(message => message.seq), })) ctx.sessionTitle.register({ id: SessionTitleProviderId('pin-provider'), automatic: 'all-prompts', generate, }) const session = ctx.sessions.create(SessionId('rename-pin')) session.append('turn/start', { turn: 1 }) appendHumanPrompt(session, 'First prompt') await settle() ctx.sessionTitle.rename(session, 'Pinned by hand') // A later eligible prompt must schedule nothing while the pin stands. appendHumanPrompt(session, 'Second prompt after the pin') await settle() session.append('request/header', { header: { config: { provider: 'main-route', model: 'chat-model' } }, reason: 'change', }) await settle() expect(generate).not.toHaveBeenCalled() expect(ctx.sessionTitle.get(session)?.title).toBe('Pinned by hand') // Explicit refresh remains the deliberate unpin. const refreshed = await ctx.sessionTitle.refresh(session) expect(generate).toHaveBeenCalledOnce() expect(refreshed?.title).toBe('Provider title') expect(ctx.sessionTitle.get(session)?.source.kind).toBe('provider') }) it('fallback-only refresh also unpins: the user title yields to a re-derived fallback', async () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('rename-unpin-fallback')) session.append('turn/start', { turn: 1 }) appendHumanPrompt(session, 'Derivable prompt words') await settle() ctx.sessionTitle.rename(session, 'Pinned without provider') expect(ctx.sessionTitle.get(session)?.source.kind).toBe('user') const refreshed = await ctx.sessionTitle.refresh(session) expect(refreshed).toMatchObject({ title: 'Derivable prompt words', source: { kind: 'fallback' }, }) // The pin is gone: the latest title is fallback-sourced, so the // onUserMessage pin check no longer skips scheduling. expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback') }) it('supersedes in-flight automatic generation: a late provider result cannot override the user title', async () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionTitleService, CONFIG) // The provider parks on a test-held deferred so rename lands while its // generation is ACTIVE (not merely scheduled). let releaseProvider: (() => void) | undefined const gate = new Promise((resolve) => { releaseProvider = resolve }) let aborted = false const generate = vi.fn(async (request: SessionTitleProviderRequest) => { request.signal.addEventListener('abort', () => { aborted = true }) await gate return { title: 'Late provider title', messageSeqs: request.messages.map(message => message.seq) } }) ctx.sessionTitle.register({ id: SessionTitleProviderId('deferred-provider'), automatic: 'all-prompts', generate, }) const session = ctx.sessions.create(SessionId('rename-supersede')) session.append('turn/start', { turn: 1 }) appendHumanPrompt(session, 'Prompt that triggers generation') session.append('request/header', { header: { config: { provider: 'main-route', model: 'chat-model' } }, reason: 'change', }) await settle() expect(generate).toHaveBeenCalledOnce() ctx.sessionTitle.rename(session, 'User wins') expect(aborted).toBe(true) releaseProvider?.() await settle() // The released provider result must not append over the user title, and // the swallowed abort must not surface as an unhandled rejection. const latest = session.events.findLast(item => item.type === 'session/title') expect(latest?.data).toMatchObject({ title: 'User wins', source: { kind: 'user' } }) }) it('fallback-only refresh keeps the user title when no fallback is derivable', async () => { const ctx = new Context() await ctx.plugin(SessionStore) // A 3-byte fallback cap cannot hold the 4-byte emoji prompt: the // re-derived fallback is empty, so the pinned title survives the refresh. await ctx.plugin(SessionTitleService, { ...CONFIG, fallbackMaxBytes: 3 }) const session = ctx.sessions.create(SessionId('rename-unpin-empty')) session.append('turn/start', { turn: 1 }) appendHumanPrompt(session, '😀😀') await settle() ctx.sessionTitle.rename(session, 'Sticky emoji pin') const refreshed = await ctx.sessionTitle.refresh(session) expect(refreshed?.title).toBe('Sticky emoji pin') expect(ctx.sessionTitle.get(session)?.source.kind).toBe('user') }) })