refactor(session): fold the session family into packages/session/
git mv the 12 packages from session-persistence/, session-projection/, session-title/, and telemetry/ into one session/ group per the regrouping RFC; merge the four group READMEs into one bilingual triplet; rewrite the group segment in tsconfig references (intra-group references shorten to ../<pkg>), tsconfig.base.json paths/globs, knip.json keys, vitest include, gate scripts, and authored doc/note citations; regenerate module graph, doc graphs, catalogs, and the lockfile importer keys. No npm names change. Full unit suite: 8779 passed; the 18 reported failures reproduce as env flakes (ambient-proxy IPv6 tunneling, watched-dir inotify timeouts under parallel load) — each passes in isolation with NO_PROXY set, matching their known pre-existing behavior on master.
This commit is contained in:
44
packages/session/session-title/tests/invariant.spec.ts
Normal file
44
packages/session/session-title/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Title-provenance invariant: messageSeqs is empty iff source.kind is 'user'
|
||||
// — the durable relationship every appended session/title event must keep.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import * as SessionTitleInvariantCompanion from '@deepseek-ai/dsh-session-title/invariant'
|
||||
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(SessionTitleInvariantCompanion)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('session-title provenance invariant', () => {
|
||||
it('accepts cited automatic titles and citation-free user renames', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('title-invariant-valid'))
|
||||
expect(() => {
|
||||
session.append('session/title', { title: 'auto', messageSeqs: [1], source: { kind: 'fallback' } })
|
||||
session.append('session/title', { title: 'named', messageSeqs: [], source: { kind: 'user' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a citation-free automatic title and a user rename that cites messages', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('title-invariant-invalid'))
|
||||
expect(() => {
|
||||
session.append('session/title', { title: 'auto', messageSeqs: [], source: { kind: 'fallback' } })
|
||||
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
|
||||
code: 'INVARIANT',
|
||||
packageName: '@deepseek-ai/dsh-session-title',
|
||||
}))
|
||||
expect(() => {
|
||||
session.append('session/title', { title: 'named', messageSeqs: [1], source: { kind: 'user' } })
|
||||
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
|
||||
code: 'INVARIANT',
|
||||
packageName: '@deepseek-ai/dsh-session-title',
|
||||
}))
|
||||
expect(session.seq).toBe(0)
|
||||
})
|
||||
})
|
||||
90
packages/session/session-title/tests/persistence.spec.ts
Normal file
90
packages/session/session-title/tests/persistence.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
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,
|
||||
})
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Persist this session title' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.sessionTitle.refresh(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: 3,
|
||||
})
|
||||
expect(loaded.events.map(event => event.type)).toEqual([
|
||||
'turn/start',
|
||||
'user/message',
|
||||
'turn/end',
|
||||
'session/title',
|
||||
])
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
76
packages/session/session-title/tests/projection.spec.ts
Normal file
76
packages/session/session-title/tests/projection.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The `title` projection unit: mounting the title service beside the
|
||||
* projection registry serves the current normalized title (last-wins over
|
||||
* session/title events, the same events foldSessionTitle consumes) — null
|
||||
* before the first title — through the registry snapshot and the change
|
||||
* feed; compositions without the registry are unaffected; unmounting the
|
||||
* service removes the key (HMR safety). The bespoke session/title mux frame
|
||||
* is untouched by this unit (its retirement is the client value-store
|
||||
* migration's concern).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SessionTitleService from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
const CONFIG = { fallbackMaxWords: 8, fallbackMaxBytes: 64, maxTitleBytes: 256 }
|
||||
|
||||
async function harness(withTitleService: boolean): Promise<{ ctx: Context; session: Session }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
if (withTitleService) await ctx.plugin(SessionTitleService, CONFIG)
|
||||
return { ctx, session: ctx.sessions.create(SessionId('titled')) }
|
||||
}
|
||||
|
||||
/** Append one session/title event directly (the replay-plane shape the unit folds). */
|
||||
function appendTitle(session: Session, title: string): number {
|
||||
return session.append('session/title', { title, messageSeqs: [1], source: { kind: 'fallback' } }).seq
|
||||
}
|
||||
|
||||
describe('title projection unit', () => {
|
||||
it('serves null before the first title event', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
const snapshot = ctx.sessionProjections.snapshot(session)
|
||||
expect(snapshot.values.title).toBeNull()
|
||||
})
|
||||
|
||||
it('serves the latest title last-wins and notifies the change feed with the causing seq', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
const changes: { key: string; value: unknown; seq: number }[] = []
|
||||
ctx.sessionProjections.onChanged((_session, key, value, seq) => {
|
||||
changes.push({ key, value, seq })
|
||||
})
|
||||
const firstSeq = appendTitle(session, 'First title')
|
||||
const secondSeq = appendTitle(session, 'Second title')
|
||||
// Unrelated event: same-reference apply, no notification.
|
||||
session.append('turn/start', { turn: 1 })
|
||||
expect(changes).toEqual([
|
||||
{ key: 'title', value: 'First title', seq: firstSeq },
|
||||
{ key: 'title', value: 'Second title', seq: secondSeq },
|
||||
])
|
||||
const snapshot = ctx.sessionProjections.snapshot(session)
|
||||
expect(snapshot.values.title).toBe('Second title')
|
||||
expect(snapshot.asOfSeq).toBe(session.seq - 1)
|
||||
})
|
||||
|
||||
it('folds titles already in the log when the service mounts late (lazy cell build)', async () => {
|
||||
const { ctx, session } = await harness(false)
|
||||
appendTitle(session, 'Pre-mount title')
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
expect(ctx.sessionProjections.snapshot(session).values.title).toBe('Pre-mount title')
|
||||
})
|
||||
|
||||
it('has no title key without the title service, and drops it when the service unloads (HMR safety)', async () => {
|
||||
const { ctx, session } = await harness(false)
|
||||
expect('title' in ctx.sessionProjections.snapshot(session).values).toBe(false)
|
||||
const fiber = await ctx.plugin(SessionTitleService, CONFIG)
|
||||
appendTitle(session, 'Ephemeral')
|
||||
expect(ctx.sessionProjections.snapshot(session).values.title).toBe('Ephemeral')
|
||||
await fiber.dispose()
|
||||
expect('title' in ctx.sessionProjections.snapshot(session).values).toBe(false)
|
||||
})
|
||||
})
|
||||
374
packages/session/session-title/tests/provider.spec.ts
Normal file
374
packages/session/session-title/tests/provider.spec.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import LlmService, { createUserMessage, deepFreeze, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
|
||||
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', createUserMessage({
|
||||
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,
|
||||
})
|
||||
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,
|
||||
})
|
||||
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()
|
||||
await 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,
|
||||
})
|
||||
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,
|
||||
})
|
||||
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 drains 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,
|
||||
})
|
||||
const message = appendHumanPrompt(session, 'Generate this title')
|
||||
await settle()
|
||||
appendRoute(session)
|
||||
await settle()
|
||||
expect(observedSignal?.aborted).toBe(false)
|
||||
|
||||
const disposal = dispose()
|
||||
expect(observedSignal?.aborted).toBe(true)
|
||||
let disposed = false
|
||||
void disposal.then(() => { disposed = true })
|
||||
await settle()
|
||||
expect(disposed).toBe(false)
|
||||
pending.resolve({ title: 'stale provider result', messageSeqs: [message.seq] })
|
||||
await disposal
|
||||
expect(disposed).toBe(true)
|
||||
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)
|
||||
await 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,
|
||||
})
|
||||
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('runs an all-messages revision when the next main request reuses its logged header', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const requests: SessionTitleProviderRequest[] = []
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('unchanged-route'),
|
||||
automatic: 'all-user-messages',
|
||||
async generate(request) {
|
||||
requests.push(request)
|
||||
return {
|
||||
title: `Revision ${requests.length}`,
|
||||
messageSeqs: request.messages.map(message => message.seq),
|
||||
}
|
||||
},
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('unchanged-route'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
const first = appendHumanPrompt(session, 'First routed prompt')
|
||||
await settle()
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
appendRoute(session)
|
||||
await settle()
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
})
|
||||
const second = appendHumanPrompt(session, 'Second prompt on the same route')
|
||||
await settle()
|
||||
session.append('step/start', { turn: 2, step: 1 })
|
||||
void ctx.llm.stream(markAgentLoopRequest(deepFreeze({
|
||||
provider: 'main-route',
|
||||
model: 'chat-model',
|
||||
messages: session.deriveMessages(),
|
||||
sessionId: session.id,
|
||||
})))
|
||||
await settle()
|
||||
|
||||
expect(session.events.filter(event => event.type === 'request/header')).toHaveLength(1)
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1]).toMatchObject({
|
||||
messages: [
|
||||
{ seq: first.seq, text: 'First routed prompt' },
|
||||
{ seq: second.seq, text: 'Second prompt on the same route' },
|
||||
],
|
||||
route: { provider: 'main-route', model: 'chat-model' },
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores model streams that are not a matching loop request', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult> => ({
|
||||
title: 'Unexpected title',
|
||||
messageSeqs: request.messages.map(message => message.seq),
|
||||
}))
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('request-filter'),
|
||||
automatic: 'all-user-messages',
|
||||
generate,
|
||||
})
|
||||
const options = { provider: 'main-route', model: 'chat-model', messages: [] }
|
||||
|
||||
void ctx.llm.stream(deepFreeze(options))
|
||||
void ctx.llm.stream(markAgentLoopRequest(deepFreeze({ ...options, sessionId: SessionId('missing') })))
|
||||
const quiet = ctx.sessions.create(SessionId('quiet'))
|
||||
void ctx.llm.stream(markAgentLoopRequest(deepFreeze({ ...options, sessionId: quiet.id })))
|
||||
const pending = ctx.sessions.create(SessionId('unmatched-boundary'))
|
||||
pending.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
appendHumanPrompt(pending, 'Wait for a matching request boundary')
|
||||
await settle()
|
||||
void ctx.llm.stream(markAgentLoopRequest(deepFreeze({ ...options, sessionId: pending.id })))
|
||||
await settle()
|
||||
|
||||
expect(generate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
181
packages/session/session-title/tests/rename.spec.ts
Normal file
181
packages/session/session-title/tests/rename.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
// 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 '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<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function appendHumanPrompt(session: ReturnType<Context['sessions']['create']>, 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, ' [31m ')).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-user-messages',
|
||||
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<void>((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-user-messages',
|
||||
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')
|
||||
})
|
||||
})
|
||||
452
packages/session/session-title/tests/service-contracts.spec.ts
Normal file
452
packages/session/session-title/tests/service-contracts.spec.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { Context, type Fiber } 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,
|
||||
})
|
||||
return session
|
||||
}
|
||||
|
||||
function appendPrompt(session: ReturnType<Context['sessions']['create']>, text: string) {
|
||||
return session.append('user/message', createUserMessage({
|
||||
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(Session.create(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,
|
||||
})
|
||||
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('shares one fallback across concurrent refreshes', async () => {
|
||||
const ctx = await setup()
|
||||
const seed = Session.create(SessionId('fallback-concurrency-seed'))
|
||||
seed.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
const source = appendPrompt(seed, 'Create exactly one fallback title')
|
||||
seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const session = ctx.sessions.create(SessionId('fallback-concurrency'), { seed: seed.events })
|
||||
|
||||
const results = await Promise.all([
|
||||
ctx.sessionTitle.refresh(session),
|
||||
ctx.sessionTitle.refresh(session),
|
||||
])
|
||||
|
||||
expect(results[0]).toEqual(results[1])
|
||||
expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1)
|
||||
expect(session.events.map(event => event.type)).toEqual([
|
||||
'turn/start',
|
||||
'user/message',
|
||||
'turn/end',
|
||||
// The seeded constructor's end-seed marker.
|
||||
'session/end-seed',
|
||||
'session/title',
|
||||
])
|
||||
expect(ctx.sessionTitle.get(session)?.messageSeqs).toEqual([source.seq])
|
||||
})
|
||||
|
||||
it('reuses a title accepted before the queued fallback commits', async () => {
|
||||
const ctx = await setup()
|
||||
const session = startSession(ctx, 'fallback-already-accepted')
|
||||
const source = appendPrompt(session, 'Reuse the title that wins the fallback race')
|
||||
|
||||
const refresh = ctx.sessionTitle.refresh(session)
|
||||
session.append('session/title', {
|
||||
title: 'Already accepted',
|
||||
messageSeqs: [source.seq],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
|
||||
await expect(refresh).resolves.toMatchObject({ title: 'Already accepted' })
|
||||
expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('lets the newest overlapping explicit refresh win', async () => {
|
||||
const ctx = await setup()
|
||||
const session = startSession(ctx, 'refresh-order')
|
||||
const source = appendPrompt(session, 'Keep the newest explicit refresh')
|
||||
await settle()
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const requests: SessionTitleProviderRequest[] = []
|
||||
const results: Array<ReturnType<typeof deferred<SessionTitleProviderResult>>> = []
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('refresh-order'),
|
||||
automatic: 'first-message',
|
||||
generate(request) {
|
||||
requests.push(request)
|
||||
const result = deferred<SessionTitleProviderResult>()
|
||||
results.push(result)
|
||||
return result.promise
|
||||
},
|
||||
})
|
||||
|
||||
const older = ctx.sessionTitle.refresh(session)
|
||||
await settle()
|
||||
const newer = ctx.sessionTitle.refresh(session)
|
||||
await settle()
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[0]?.signal.aborted).toBe(true)
|
||||
expect(requests[1]?.signal.aborted).toBe(false)
|
||||
results[0]?.resolve({ title: 'Obsolete title', messageSeqs: [source.seq] })
|
||||
await expect(older).rejects.toThrow(/superseded/)
|
||||
results[1]?.resolve({ title: 'Newest explicit title', messageSeqs: [source.seq] })
|
||||
await expect(newer).resolves.toMatchObject({ title: 'Newest explicit title' })
|
||||
})
|
||||
|
||||
it('cancels a queued fallback when the session-title service unloads', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const lifecycle: { fiber?: Fiber; session?: Session; inactiveRefresh?: Promise<unknown> } = {}
|
||||
ctx.on('internal/plugin', (subject) => {
|
||||
if (subject !== lifecycle.fiber || subject.uid !== null || lifecycle.session === undefined) return
|
||||
appendPrompt(lifecycle.session, 'Ignore reentrant disposal prompt')
|
||||
lifecycle.session.append('request/header', {
|
||||
header: { config: { provider: 'main', model: 'main' } },
|
||||
reason: 'initial',
|
||||
})
|
||||
lifecycle.inactiveRefresh = ctx.sessionTitle.refresh(lifecycle.session).then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
)
|
||||
})
|
||||
const fiber = await ctx.plugin(SessionTitleService, CONFIG)
|
||||
lifecycle.fiber = fiber
|
||||
const session = startSession(ctx, 'service-dispose-fallback')
|
||||
lifecycle.session = session
|
||||
appendPrompt(session, 'Do not publish after service disposal')
|
||||
|
||||
await fiber.dispose()
|
||||
await settle()
|
||||
|
||||
expect(session.events.some(event => event.type === 'session/title')).toBe(false)
|
||||
const inactiveError = await lifecycle.inactiveRefresh
|
||||
expect(inactiveError).toBeInstanceOf(Error)
|
||||
if (!(inactiveError instanceof Error)) throw new Error('expected inactive refresh to reject')
|
||||
expect(inactiveError.message).toBe('session-title service disposed')
|
||||
})
|
||||
|
||||
it('suppresses a queued fallback failure after service unload begins', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const session = startSession(ctx, 'service-unload-started-fallback')
|
||||
appendPrompt(session, 'Start fallback before unloading the service')
|
||||
|
||||
await Promise.resolve()
|
||||
await fiber.dispose()
|
||||
|
||||
expect(session.events.some(event => event.type === 'session/title')).toBe(false)
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aborts pending and active provider work and drains ignored cancellation during service unload', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const result = deferred<SessionTitleProviderResult>()
|
||||
const requests: SessionTitleProviderRequest[] = []
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('service-unload'),
|
||||
automatic: 'all-user-messages',
|
||||
generate(request) {
|
||||
requests.push(request)
|
||||
return result.promise
|
||||
},
|
||||
})
|
||||
const active = startSession(ctx, 'service-unload-active')
|
||||
const activeMessage = appendPrompt(active, 'Active provider work')
|
||||
await settle()
|
||||
const refresh = ctx.sessionTitle.refresh(active)
|
||||
const refreshOutcome = refresh.then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
)
|
||||
await settle()
|
||||
expect(requests).toHaveLength(1)
|
||||
const pending = startSession(ctx, 'service-unload-pending')
|
||||
appendPrompt(pending, 'Pending provider work')
|
||||
|
||||
const disposal = fiber.dispose()
|
||||
let disposed = false
|
||||
void disposal.then(() => { disposed = true })
|
||||
await settle()
|
||||
expect(requests[0]?.signal.aborted).toBe(true)
|
||||
expect(disposed).toBe(false)
|
||||
result.resolve({ title: 'Ignored service abort', messageSeqs: [activeMessage.seq] })
|
||||
await disposal
|
||||
|
||||
expect(disposed).toBe(true)
|
||||
await expect(refreshOutcome).resolves.toEqual(expect.objectContaining({ message: 'session-title service disposed' }))
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
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',
|
||||
})
|
||||
const pending = startSession(ctx, 'pending-provider-dispose')
|
||||
appendPrompt(pending, 'Drop pending provider work')
|
||||
await dispose()
|
||||
await settle()
|
||||
expect(generate).not.toHaveBeenCalled()
|
||||
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
|
||||
expect(ctx.sessionTitle.get(pending)?.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')
|
||||
}
|
||||
})
|
||||
})
|
||||
162
packages/session/session-title/tests/session-title.spec.ts
Normal file
162
packages/session/session-title/tests/session-title.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
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,
|
||||
})
|
||||
const message = session.append('user/message', createUserMessage({
|
||||
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('derives a fallback title from the direct prompt instead of baked prefix context', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const session = ctx.sessions.create(SessionId('prefixed-title'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Explain this referenced session' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
await settleTitles()
|
||||
|
||||
expect(ctx.sessionTitle.get(session)?.title).toBe('Explain this referenced session')
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'plugin text' }],
|
||||
source: { kind: 'plugin', plugin: 'seed' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'reasoning', text: 'not visible text' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
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', createUserMessage({
|
||||
content: [{ type: 'text', text: 'first real prompt' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
await settleTitles()
|
||||
const first = ctx.sessionTitle.get(session)
|
||||
session.append('user/message', createUserMessage({
|
||||
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 = Session.create(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,
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user