feat(session-title): add fallback and model providers

This commit is contained in:
Tianyi Cui
2026-07-21 01:53:24 +08:00
parent 9a6914d845
commit 58dc5f94de
41 changed files with 2900 additions and 1 deletions

View File

@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite'
import SessionTitleService, { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
const CONFIG = {
fallbackMaxWords: 5,
fallbackMaxBytes: 40,
maxTitleBytes: 80,
} as const
const roots: string[] = []
afterEach(async () => {
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
async function appendPersistedTitle(ctx: Context, id: ReturnType<typeof SessionId>): Promise<void> {
const session = ctx.sessions.create(id)
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('user/message', {
content: [{ type: 'text', text: 'Persist this session title' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
await new Promise(resolve => setTimeout(resolve, 0))
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
}
async function expectPersistedTitle(ctx: Context, id: ReturnType<typeof SessionId>): Promise<void> {
const loaded = await ctx.sessionPersistence.load(id)
expect(foldSessionTitle(loaded.events)).toMatchObject({
title: 'Persist this session title',
messageSeqs: [1],
source: { kind: 'fallback' },
eventSeq: 2,
})
expect(loaded.events.map(event => event.type)).toEqual([
'turn/start',
'user/message',
'session/title',
'turn/end',
])
}
describe('session title persistence round trips', () => {
it('round-trips through a remounted JSONL backend', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-title-jsonl-'))
roots.push(root)
const id = SessionId('title-jsonl')
const writer = new Context()
await writer.plugin(SessionStore)
await writer.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await writer.plugin(SessionTitleService, CONFIG)
await appendPersistedTitle(writer, id)
await writer.fiber.dispose()
const reader = new Context()
await reader.plugin(SessionStore)
await reader.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await expectPersistedTitle(reader, id)
await reader.fiber.dispose()
})
it('round-trips through a remounted SQLite backend', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-title-sqlite-'))
roots.push(root)
const path = join(root, 'sessions.db')
const id = SessionId('title-sqlite')
const writer = new Context()
await writer.plugin(SessionStore)
await writer.plugin(SessionPersistenceSqlite, { path })
await writer.plugin(SessionTitleService, CONFIG)
await appendPersistedTitle(writer, id)
await writer.fiber.dispose()
const reader = new Context()
await reader.plugin(SessionStore)
await reader.plugin(SessionPersistenceSqlite, { path })
await expectPersistedTitle(reader, id)
await reader.fiber.dispose()
})
})

View File

@@ -0,0 +1,289 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionTitleService, {
SessionTitleProviderId,
type SessionTitleProvider,
type SessionTitleProviderRequest,
type SessionTitleProviderResult,
} from '@deepseek-ai/dsh-session-title'
const CONFIG = {
fallbackMaxWords: 5,
fallbackMaxBytes: 24,
maxTitleBytes: 24,
} as const
function deferred<T>(): {
promise: Promise<T>
resolve(value: T): void
reject(error: unknown): void
} {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((accept, decline) => {
resolve = accept
reject = decline
})
return { promise, resolve, reject }
}
async function settle(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 0))
}
function appendHumanPrompt(session: ReturnType<Context['sessions']['create']>, text: string) {
return session.append('user/message', {
content: [{ type: 'text', text }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}
function appendRoute(session: ReturnType<Context['sessions']['create']>, reason: 'initial' | 'change' = 'initial'): void {
session.append('request/header', {
header: { config: { provider: 'main-route', model: 'chat-model' } },
reason,
})
}
describe('SessionTitleService provider lifecycle', () => {
it('inherits title events across forks, skips first-message retitling, and lets all-messages update later', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const parent = ctx.sessions.create(SessionId('title-parent'))
parent.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const inheritedMessage = appendHumanPrompt(parent, 'Inherited title prompt')
await settle()
parent.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const child = ctx.sessions.fork(parent, undefined, SessionId('title-child'))
expect(ctx.sessionTitle.get(child)).toEqual(ctx.sessionTitle.get(parent))
expect(child.events.find(event => event.type === 'session/title'))
.toEqual(parent.events.find(event => event.type === 'session/title'))
const firstGenerate = vi.fn(async (request: SessionTitleProviderRequest) => ({
title: 'Should not run',
messageSeqs: [request.messages[0]!.seq],
}))
const disposeFirst = ctx.sessionTitle.register({
id: SessionTitleProviderId('fork-first'),
automatic: 'first-message',
generate: firstGenerate,
})
child.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const childMessage = appendHumanPrompt(child, 'Child follow-up prompt')
await settle()
appendRoute(child)
await settle()
child.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
expect(firstGenerate).not.toHaveBeenCalled()
disposeFirst()
const allGenerate = vi.fn(async (request: SessionTitleProviderRequest) => ({
title: 'Fork all prompts',
messageSeqs: request.messages.map(message => message.seq),
}))
ctx.sessionTitle.register({
id: SessionTitleProviderId('fork-all'),
automatic: 'all-user-messages',
generate: allGenerate,
})
child.append('turn/start', {
turn: 3,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const latestMessage = appendHumanPrompt(child, 'Retitle the fork now')
await settle()
appendRoute(child, 'change')
await settle()
child.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
expect(allGenerate).toHaveBeenCalledOnce()
expect(ctx.sessionTitle.get(child)).toMatchObject({
title: 'Fork all prompts',
messageSeqs: [inheritedMessage.seq, childMessage.seq, latestMessage.seq],
source: { kind: 'provider', provider: SessionTitleProviderId('fork-all') },
})
expect(ctx.sessionTitle.get(parent)?.title).toBe('Inherited title prompt')
})
it('runs a first-message provider once after the routed request and retries only through refresh', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const requests: SessionTitleProviderRequest[] = []
const provider: SessionTitleProvider = {
id: SessionTitleProviderId('first-model'),
automatic: 'first-message',
async generate(request) {
requests.push(request)
return {
title: '\u001B[31m A model-generated title that is too long ',
messageSeqs: [request.messages[0]!.seq],
model: { provider: 'aux-route', model: 'title-model' },
}
},
}
ctx.sessionTitle.register(provider)
const session = ctx.sessions.create(SessionId('first-provider'))
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const first = appendHumanPrompt(session, 'Explain asynchronous title generation')
await settle()
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
appendRoute(session)
await settle()
expect(requests).toHaveLength(1)
expect(requests[0]).toMatchObject({
session,
messages: [{ seq: first.seq, text: 'Explain asynchronous title generation' }],
route: { provider: 'main-route', model: 'chat-model' },
})
expect(ctx.sessionTitle.get(session)).toMatchObject({
title: 'A model-generated title',
messageSeqs: [first.seq],
source: {
kind: 'provider',
provider: SessionTitleProviderId('first-model'),
model: { provider: 'aux-route', model: 'title-model' },
},
})
const second = appendHumanPrompt(session, 'A later prompt')
appendRoute(session, 'change')
await settle()
expect(requests).toHaveLength(1)
await ctx.sessionTitle.refresh(session)
expect(requests).toHaveLength(2)
expect(requests[1]?.messages.map(message => message.seq)).toEqual([first.seq, second.seq])
})
it('rejects a second provider and aborts stale work when the winner is disposed', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const pending = deferred<SessionTitleProviderResult>()
let observedSignal: AbortSignal | undefined
const first: SessionTitleProvider = {
id: SessionTitleProviderId('winner'),
automatic: 'all-user-messages',
generate(request) {
observedSignal = request.signal
return pending.promise
},
}
const dispose = ctx.sessionTitle.register(first)
expect(() => ctx.sessionTitle.register({
id: SessionTitleProviderId('duplicate'),
automatic: 'first-message',
generate: async () => ({ title: 'duplicate', messageSeqs: [0] }),
})).toThrow(/already registered/)
const session = ctx.sessions.create(SessionId('dispose-provider'))
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const message = appendHumanPrompt(session, 'Generate this title')
await settle()
appendRoute(session)
await settle()
expect(observedSignal?.aborted).toBe(false)
dispose()
expect(observedSignal?.aborted).toBe(true)
pending.resolve({ title: 'stale provider result', messageSeqs: [message.seq] })
await settle()
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
const replacement: SessionTitleProvider = {
id: SessionTitleProviderId('replacement'),
automatic: 'first-message',
generate: async () => ({ title: 'replacement', messageSeqs: [message.seq] }),
}
const disposeReplacement = ctx.sessionTitle.register(replacement)
disposeReplacement()
})
it('supersedes an older all-messages revision and cannot commit an ignored abort', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const firstResult = deferred<SessionTitleProviderResult>()
const requests: SessionTitleProviderRequest[] = []
const provider: SessionTitleProvider = {
id: SessionTitleProviderId('all-model'),
automatic: 'all-user-messages',
generate(request) {
requests.push(request)
if (requests.length === 1) return firstResult.promise
return Promise.resolve({
title: 'Newest complete title',
messageSeqs: request.messages.map(message => message.seq),
})
},
}
ctx.sessionTitle.register(provider)
const session = ctx.sessions.create(SessionId('supersede'))
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const first = appendHumanPrompt(session, 'First prompt')
await settle()
appendRoute(session)
await settle()
const second = appendHumanPrompt(session, 'Second prompt')
expect(requests[0]?.signal.aborted).toBe(true)
appendRoute(session, 'change')
await settle()
expect(ctx.sessionTitle.get(session)).toMatchObject({
title: 'Newest complete title',
messageSeqs: [first.seq, second.seq],
})
firstResult.resolve({ title: 'Old ignored result', messageSeqs: [first.seq] })
await settle()
expect(ctx.sessionTitle.get(session)?.title).toBe('Newest complete title')
})
it('contains automatic failures but lets explicit refresh reject', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const provider: SessionTitleProvider = {
id: SessionTitleProviderId('failing'),
automatic: 'all-user-messages',
generate: async () => { throw new Error('title backend failed') },
}
ctx.sessionTitle.register(provider)
const session = ctx.sessions.create(SessionId('failure'))
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
appendHumanPrompt(session, 'Keep a fallback')
await settle()
appendRoute(session)
await settle()
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
expect(warn).toHaveBeenCalledWith(expect.stringContaining('automatic title generation failed'))
await expect(ctx.sessionTitle.refresh(session)).rejects.toThrow('title backend failed')
warn.mockRestore()
})
})

View File

@@ -0,0 +1,287 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionTitleService, {
SessionTitleProviderId,
type Config,
type SessionTitleProvider,
type SessionTitleProviderRequest,
type SessionTitleProviderResult,
} from '@deepseek-ai/dsh-session-title'
const CONFIG = {
fallbackMaxWords: 5,
fallbackMaxBytes: 40,
maxTitleBytes: 80,
} as const
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((accept) => { resolve = accept })
return { promise, resolve }
}
async function settle(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 0))
}
async function setup(config: Config = CONFIG): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, config)
return ctx
}
function startSession(ctx: Context, id: string): ReturnType<Context['sessions']['create']> {
const session = ctx.sessions.create(SessionId(id))
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
return session
}
function appendPrompt(session: ReturnType<Context['sessions']['create']>, text: string) {
return session.append('user/message', {
content: [{ type: 'text', text }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}
describe('SessionTitleService configuration and refresh boundaries', () => {
it('requires explicit positive limits with a fallback cap no larger than the accepted-title cap', () => {
expect(() => new SessionTitleService(new Context(), undefined as never))
.toThrow('configuration is required')
expect(() => new SessionTitleService(new Context(), null as never))
.toThrow('configuration is required')
expect(() => new SessionTitleService(new Context(), { ...CONFIG, fallbackMaxWords: 0 }))
.toThrow(/fallbackMaxWords must be a positive integer/)
expect(() => new SessionTitleService(new Context(), { ...CONFIG, fallbackMaxWords: 1.5 }))
.toThrow(/fallbackMaxWords must be a positive integer/)
expect(() => new SessionTitleService(new Context(), { ...CONFIG, fallbackMaxBytes: 81 }))
.toThrow(/fallbackMaxBytes must not exceed maxTitleBytes/)
})
it('returns no title for empty input with or without a provider, and rejects detached or pre-aborted refreshes', async () => {
const fallbackOnly = await setup()
const empty = fallbackOnly.sessions.create(SessionId('empty-fallback'))
await expect(fallbackOnly.sessionTitle.refresh(empty)).resolves.toBeUndefined()
const withProvider = await setup()
const generate = vi.fn(async (): Promise<SessionTitleProviderResult> => ({
title: 'unused',
messageSeqs: [0],
}))
withProvider.sessionTitle.register({
id: SessionTitleProviderId('empty-provider'),
automatic: 'first-message',
generate,
})
const providerEmpty = withProvider.sessions.create(SessionId('empty-provider'))
await expect(withProvider.sessionTitle.refresh(providerEmpty)).resolves.toBeUndefined()
expect(generate).not.toHaveBeenCalled()
await expect(withProvider.sessionTitle.refresh(new Session(SessionId('detached'))))
.rejects.toThrow(/not live in this store/)
const controller = new AbortController()
controller.abort(new Error('already cancelled'))
await expect(withProvider.sessionTitle.refresh(providerEmpty, controller.signal))
.rejects.toThrow('already cancelled')
})
it('passes an absent route and caller cancellation into explicit generation', async () => {
const ctx = await setup()
let observed: SessionTitleProviderRequest | undefined
ctx.sessionTitle.register({
id: SessionTitleProviderId('explicit-no-route'),
automatic: 'first-message',
async generate(request) {
observed = request
return { title: 'Explicit title', messageSeqs: [request.messages[0]!.seq] }
},
})
const session = startSession(ctx, 'explicit-no-route')
appendPrompt(session, 'Refresh before any request header')
await settle()
const controller = new AbortController()
await expect(ctx.sessionTitle.refresh(session, controller.signal))
.resolves.toMatchObject({ title: 'Explicit title' })
expect(observed?.route).toBeUndefined()
expect(observed?.signal.aborted).toBe(false)
})
it('propagates explicit cancellation and session disposal to active work', async () => {
const callerCtx = await setup()
const callerPending = deferred<SessionTitleProviderResult>()
let callerSignal: AbortSignal | undefined
callerCtx.sessionTitle.register({
id: SessionTitleProviderId('caller-cancel'),
automatic: 'first-message',
generate(request) {
callerSignal = request.signal
return callerPending.promise
},
})
const callerSession = startSession(callerCtx, 'caller-cancel')
const callerMessage = appendPrompt(callerSession, 'Cancel this refresh')
await settle()
const controller = new AbortController()
const refresh = callerCtx.sessionTitle.refresh(callerSession, controller.signal)
await settle()
controller.abort(new Error('caller cancelled'))
callerPending.resolve({ title: 'ignored', messageSeqs: [callerMessage.seq] })
await expect(refresh).rejects.toThrow('caller cancelled')
expect(callerSignal?.aborted).toBe(true)
const disposeCtx = await setup()
const disposePending = deferred<SessionTitleProviderResult>()
let disposeSignal: AbortSignal | undefined
disposeCtx.sessionTitle.register({
id: SessionTitleProviderId('session-dispose'),
automatic: 'first-message',
generate(request) {
disposeSignal = request.signal
return disposePending.promise
},
})
const disposed = disposeCtx.sessions.prepare(SessionId('session-dispose'))
const detach = disposeCtx.sessions.enter(disposed)
disposeCtx.sessions.announce(disposed)
disposed.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const disposedMessage = appendPrompt(disposed, 'Dispose this session')
await settle()
const disposedRefresh = disposeCtx.sessionTitle.refresh(disposed)
await settle()
detach()
disposePending.resolve({ title: 'ignored', messageSeqs: [disposedMessage.seq] })
await expect(disposedRefresh).rejects.toThrow(/session disposed/)
expect(disposeSignal?.aborted).toBe(true)
})
it('warns when a detached session prevents queued fallback publication', async () => {
const ctx = await setup()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const session = ctx.sessions.prepare(SessionId('fallback-detach'))
const detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
ctx.on('session/event', (subject, event) => {
if (subject === session && event.type === 'user/message') detach()
})
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
appendPrompt(session, 'Detach before the fallback microtask')
await settle()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('fallback title update failed'))
expect(ctx.sessionTitle.get(session)).toBeUndefined()
})
it('leaves a title absent when the byte cap cannot hold the first code point', async () => {
const ctx = await setup({ fallbackMaxWords: 5, fallbackMaxBytes: 1, maxTitleBytes: 2 })
const session = startSession(ctx, 'no-code-point')
appendPrompt(session, '😀')
await settle()
expect(ctx.sessionTitle.get(session)).toBeUndefined()
await expect(ctx.sessionTitle.refresh(session)).resolves.toBeUndefined()
})
})
describe('SessionTitleService provider validation and stale scheduling', () => {
it('rejects malformed provider registrations before publishing them', async () => {
const ctx = await setup()
const generate = async (): Promise<SessionTitleProviderResult> => ({ title: 'title', messageSeqs: [0] })
expect(() => ctx.sessionTitle.register(null as never)).toThrow(/must be an object/)
expect(() => ctx.sessionTitle.register('provider' as never)).toThrow(/must be an object/)
expect(() => ctx.sessionTitle.register({
id: 1,
automatic: 'first-message',
generate,
} as unknown as SessionTitleProvider)).toThrow(/id must be a non-empty string/)
expect(() => ctx.sessionTitle.register({
id: SessionTitleProviderId(''),
automatic: 'first-message',
generate,
})).toThrow(/id must be a non-empty string/)
expect(() => ctx.sessionTitle.register({
id: SessionTitleProviderId('bad-mode'),
automatic: 'sometimes' as never,
generate,
})).toThrow(/automatic mode is invalid/)
expect(() => ctx.sessionTitle.register({
id: SessionTitleProviderId('missing-generate'),
automatic: 'first-message',
generate: undefined,
} as unknown as SessionTitleProvider)).toThrow(/requires generate/)
})
it('drops automatic work when its provider is disposed before the queued start', async () => {
const ctx = await setup()
const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult> => ({
title: 'too late',
messageSeqs: [request.messages[0]!.seq],
}))
const dispose = ctx.sessionTitle.register({
id: SessionTitleProviderId('queued-dispose'),
automatic: 'all-user-messages',
generate,
})
const session = startSession(ctx, 'queued-dispose')
appendPrompt(session, 'Queue provider work')
await settle()
session.append('request/header', {
header: { config: { provider: 'main', model: 'main' } },
reason: 'initial',
})
dispose()
await settle()
expect(generate).not.toHaveBeenCalled()
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
})
it('rejects malformed provider results without replacing the fallback', async () => {
const ctx = await setup()
let result: unknown
ctx.sessionTitle.register({
id: SessionTitleProviderId('invalid-results'),
automatic: 'first-message',
generate: async () => result as SessionTitleProviderResult,
})
const session = startSession(ctx, 'invalid-results')
const first = appendPrompt(session, 'First source')
await settle()
const second = appendPrompt(session, 'Second source')
await settle()
const cases: Array<{ value: unknown; error: RegExp }> = [
{ value: null, error: /invalid result/ },
{ value: 1, error: /invalid result/ },
{ value: { title: 1, messageSeqs: [first.seq] }, error: /title must be a string/ },
{ value: { title: '\u001B[31m', messageSeqs: [first.seq] }, error: /empty title/ },
{ value: { title: 'valid', messageSeqs: undefined }, error: /at least one source message/ },
{ value: { title: 'valid', messageSeqs: [] }, error: /at least one source message/ },
{ value: { title: 'valid', messageSeqs: ['not-a-seq'] }, error: /unique, ordered seqs/ },
{ value: { title: 'valid', messageSeqs: [1.5] }, error: /unique, ordered seqs/ },
{ value: { title: 'valid', messageSeqs: [-1] }, error: /unique, ordered seqs/ },
{ value: { title: 'valid', messageSeqs: [999] }, error: /unique, ordered seqs/ },
{ value: { title: 'valid', messageSeqs: [first.seq, first.seq] }, error: /unique, ordered seqs/ },
{ value: { title: 'valid', messageSeqs: [second.seq, first.seq] }, error: /unique, ordered seqs/ },
{ value: { title: 'valid', messageSeqs: [first.seq], model: null }, error: /model provenance/ },
{ value: { title: 'valid', messageSeqs: [first.seq], model: 'route' }, error: /model provenance/ },
{ value: { title: 'valid', messageSeqs: [first.seq], model: { provider: 1, model: 'm' } }, error: /model provenance/ },
{ value: { title: 'valid', messageSeqs: [first.seq], model: { provider: '', model: 'm' } }, error: /model provenance/ },
{ value: { title: 'valid', messageSeqs: [first.seq], model: { provider: 'p', model: 1 } }, error: /model provenance/ },
{ value: { title: 'valid', messageSeqs: [first.seq], model: { provider: 'p', model: '' } }, error: /model provenance/ },
]
for (const item of cases) {
result = item.value
await expect(ctx.sessionTitle.refresh(session)).rejects.toThrow(item.error)
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
}
})
})

View File

@@ -0,0 +1,145 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionTitleService, {
SessionTitleProviderId,
fallbackSessionTitle,
foldSessionTitle,
normalizeSessionTitle,
truncateTitleUtf8,
} from '@deepseek-ai/dsh-session-title'
const CONFIG = {
fallbackMaxWords: 5,
fallbackMaxBytes: 40,
maxTitleBytes: 80,
} as const
async function settleTitles(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 0))
}
describe('session title normalization', () => {
it('removes terminal controls, collapses whitespace, and applies word and UTF-8 byte caps', () => {
expect(normalizeSessionTitle('\u001B]0;stolen\u0007 Hello\t brave\nnew world ', 80))
.toBe('Hello brave new world')
expect(fallbackSessionTitle('one two three four', 3, 80)).toBe('one two three')
expect(fallbackSessionTitle('你好世界', 5, 7)).toBe('你好')
expect(Buffer.byteLength(fallbackSessionTitle('😀😀', 5, 5), 'utf8')).toBe(4)
})
it('rejects non-positive and fractional public limits', () => {
expect(() => truncateTitleUtf8('title', 0)).toThrow(/maxBytes must be a positive integer/)
expect(() => fallbackSessionTitle('title', 1.5, 10)).toThrow(/maxWords must be a positive integer/)
})
})
describe('SessionTitleService', () => {
it('logs and folds an immediate fallback after the first eligible human text message', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const session = ctx.sessions.create(SessionId('fresh'))
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const message = session.append('user/message', {
content: [{ type: 'text', text: ' Build\nlog-backed session titles please ' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
await settleTitles()
const titleEvent = session.events.findLast(event => event.type === 'session/title')
expect(titleEvent).toMatchObject({
type: 'session/title',
seq: 2,
data: {
title: 'Build log-backed session titles please',
messageSeqs: [message.seq],
source: { kind: 'fallback' },
},
})
expect(ctx.sessionTitle.get(session)).toEqual({
title: 'Build log-backed session titles please',
messageSeqs: [message.seq],
source: { kind: 'fallback' },
eventSeq: 2,
updatedAt: titleEvent?.time,
})
expect(session.deriveMessages()).toHaveLength(1)
expect(session.surface.nodes).toEqual([message.seq])
})
it('waits through synthetic, empty, and non-text messages, then keeps the first fallback', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const session = ctx.sessions.create(SessionId('eligibility'))
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('user/message', {
content: [{ type: 'text', text: 'plugin text' }],
source: { kind: 'plugin', plugin: 'seed' },
}, { surfaceOp: 'append' })
session.append('user/message', {
content: [{ type: 'reasoning', text: 'not visible text' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
session.append('user/message', {
content: [{ type: 'text', text: ' \n\t ' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
await settleTitles()
expect(ctx.sessionTitle.get(session)).toBeUndefined()
const eligible = session.append('user/message', {
content: [{ type: 'text', text: 'first real prompt' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
await settleTitles()
const first = ctx.sessionTitle.get(session)
session.append('user/message', {
content: [{ type: 'text', text: 'later prompt' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
await settleTitles()
expect(first?.messageSeqs).toEqual([eligible.seq])
expect(ctx.sessionTitle.get(session)).toEqual(first)
expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1)
})
it('folds the latest title event during replay', () => {
const seed = new Session(SessionId('source'))
seed.append('session/title', {
title: 'Earlier',
messageSeqs: [1],
source: { kind: 'fallback' },
})
seed.append('session/title', {
title: 'Later',
messageSeqs: [1, 4],
source: {
kind: 'provider',
provider: SessionTitleProviderId('test-provider'),
model: { provider: 'mock', model: 'title-model' },
},
})
expect(foldSessionTitle(seed.events)).toEqual({
title: 'Later',
messageSeqs: [1, 4],
source: {
kind: 'provider',
provider: SessionTitleProviderId('test-provider'),
model: { provider: 'mock', model: 'title-model' },
},
eventSeq: 1,
updatedAt: seed.events[1]?.time,
})
})
})