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:
Tianyi Cui
2026-07-30 01:52:06 +08:00
parent 645fcf5713
commit 7e445c3a67
220 changed files with 258 additions and 286 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-title/session-title/README.md
README.md: 9a5ec27c36f3411add37ebe231262eb5d205bc9e
README.zh.md: 3960be40e74507279ed2f21927ee8ad4224fe138

View File

@@ -0,0 +1,55 @@
# @deepseek-ai/dsh-session-title
English | [中文](README.zh.md)
Log-backed session titles with an immediate deterministic fallback and one optional asynchronous provider. Every accepted revision is a log-only `session/title` event; `foldSessionTitle()` and `ctx.sessionTitle.get()` select the latest event and return its event seq and timestamp.
Only text blocks from human `user/message` events are eligible. The first eligible prompt schedules a fallback from its first words within the configured UTF-8 byte limit. Whitespace is normalized, terminal control sequences are removed, and truncation never splits a code point. Empty and non-text prompts wait for later eligible input.
## Service: `SessionTitleService` (ctx key: `sessionTitle`)
- `get(session)` folds the latest accepted title from a live or replayed log.
- `refresh(session, signal?)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages. Provider errors and caller cancellation reject; cancellation does not roll back an already accepted fallback event.
- `rename(session, title)` accepts an explicit user title synchronously: it normalizes the text, supersedes in-flight automatic work, and appends a `session/title` event with the `user` source. A user-sourced latest title pins the session — later user messages schedule no automatic revision; an explicit `refresh` remains the deliberate unpin.
- `register(provider)` installs the sole optional provider and returns its awaitable Cordis effect disposer. A second registration throws immediately; disposal aborts pending and active calls, waits for their settlement, and only then permits another provider to register.
Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion appends a standalone log-only event directly through `Session` without opening a turn. Persistence observes that event eagerly and drains on ordinary lifecycle checkpoints; title publication itself does not force a flush. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their revision before provider work, while overlapping automatic and explicit fallback requests share one session-local in-flight append. The service and bundled model provider each append their own literal event type, so no generic title-write marker, cast, or settlement queue is needed. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes.
Forks inherit title events in their seed unchanged. The first-message cadence does not automatically retitle a child; the all-messages cadence may append a new revision after the child receives a later human prompt.
## Configuration
All limits are required; the library supplies no defaults.
| Key | Contract |
|---|---|
| `fallbackMaxWords` | Positive maximum whitespace-delimited words in the deterministic fallback. |
| `fallbackMaxBytes` | Positive maximum UTF-8 bytes in the fallback; must not exceed `maxTitleBytes`. |
| `maxTitleBytes` | Positive maximum UTF-8 bytes accepted from any source. |
## Provider contract
A provider supplies a branded stable id, automatic mode (`first-message` or `all-user-messages`), and `generate(request)`. The request carries the live session, all eligible messages through one fixed revision, the current logged main-request route when available, and cancellation. The result identifies a non-empty title, unique ordered source-message seqs from that request, and optional model provenance. The service normalizes and validates the result before it becomes durable.
See the [session-title data structures](../../../docs/core-data-structures/session-title.md) and [implemented decision](../../../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md).
## Model Experience
### Session title state
#### What the model sees
Nothing. `session/title` is log-only and never enters the session surface, `deriveMessages()`, system prompt, tool schemas, or request prefix.
#### Token effect
The fallback and accepted provider revisions add zero tokens to the main agent request. An optional provider's separate auxiliary request is documented by that provider package.
#### KV Cache effect
None for the main request; title events do not change its reconstructed content or cache key.
## Known Limitations and Deferred Work
- Title deletion (unpinning back to automatic titles without an explicit `refresh`), search, and list indexing are outside this service.
- The provider registry deliberately accepts at most one implementation, so a deployment cannot compose competing title strategies without writing one provider that owns their precedence.

View File

@@ -0,0 +1,55 @@
# @deepseek-ai/dsh-session-title
[English](README.md) | 中文
由日志支持的会话标题,提供即时确定性回退与一个可选异步提供方。每次已接受的修订都是仅写入日志的 `session/title` 事件;`foldSessionTitle()``ctx.sessionTitle.get()` 会选择最新事件,并返回其事件 seq 和时间戳。
只有用户 `user/message` 事件中的文本块符合条件。第一条符合条件的提示词会安排回退,从其开头若干词生成标题,并受所配置 UTF-8 字节上限约束。系统会规范化空白、移除终端控制序列,且截断绝不会切断码点。空提示词和非文本提示词会等待后续符合条件的输入。
## 服务:`SessionTitleService`ctx 键:`sessionTitle`
- `get(session)` 从活跃或回放日志折叠最新已接受标题。
- `refresh(session, signal?)` 在需要时物化回退,然后显式运行已注册提供方,处理当前符合条件的消息。提供方错误或调用方取消都会导致返回的 Promise 被拒绝;取消不会回滚已接受的回退事件。
- `rename(session, title)` 同步接受用户显式标题:规范化文本、取代在途自动工作,并追加一条 `user` 来源的 `session/title` 事件。最新标题来源为 user 即钉住该会话——后续用户消息不再安排自动 revision显式 `refresh` 仍是有意的解钉手段。
- `register(provider)` 安装唯一可选提供方,并返回可等待的 Cordis effect disposer。第二次注册会立即抛出对提供方执行 dispose资源释放会中止待处理和活跃调用等待其结算之后才允许注册另一个提供方。
自动工作绝不会延迟主 agent智能体响应。只有当带标记、由循环构建的请求其确切路由与当前已记录的 `request/header` 匹配时,提供方才会启动;即使请求头未变而无需新快照,也适用此规则。延迟完成会直接通过 `Session` 追加一个独立的纯日志事件,而不打开轮次。持久化会立即观察到该事件,并在常规生命周期检查点完成刷写;标题发布本身不会强制刷写。自动失败会发出警告并保留最新标题。新的全消息修订、提供方 dispose、会话 dispose 和显式刷新都会中止旧工作,陈旧的完成结果无法追加。并发显式刷新会在提供方工作之前预留修订号;重叠的自动/显式回退请求共享一个会话本地正在进行的追加操作。服务与内置模型提供方各自追加自己的字面事件类型,因此不需要通用标题写入标记、类型断言或结算队列。服务拆卸会取消排队工作,并在卸载完成前等待不响应取消的调用结算完成。
Fork 出的会话会原样继承种子中的标题事件。首消息节奏不会自动为子会话重新生成标题;全消息节奏可以在子会话收到后续用户提示词后追加新修订。
## 配置
所有上限都是必填项;该库不提供默认值。
| 键 | 契约 |
|---|---|
| `fallbackMaxWords` | 确定性回退中以空白分隔的最大正整数词数。 |
| `fallbackMaxBytes` | 回退允许的最大正整数 UTF-8 字节数;不得超过 `maxTitleBytes`。 |
| `maxTitleBytes` | 接受任何来源标题的最大正整数 UTF-8 字节数。 |
## 提供方契约
提供方会提供带品牌类型的稳定 id、自动模式`first-message``all-user-messages`)和 `generate(request)`。请求携带活跃会话、截至一次固定修订的所有符合条件消息、可用时当前已记录的主请求路由,以及取消信号。结果包含非空标题、该请求中唯一且有序的来源消息 seq以及可选的模型来源信息。服务会在结果持久保存前进行规范化和验证。
参见[会话标题数据结构](../../../docs/core-data-structures/session-title.md)与[已实现决策](../../../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)。
## 模型体验
### 会话标题状态
#### 模型看到的内容
无。`session/title` 只写入日志,绝不会进入会话接口、`deriveMessages()`、系统提示词、工具 schema 或请求前缀。
#### Token 影响
回退与已接受的提供方修订不会向主 agent 请求增加 token。可选提供方的独立辅助请求由对应提供方包的文档说明。
#### KV Cache 影响
不影响主请求;标题事件不会改变重建内容或缓存键。
## 已知限制与暂缓事项
- 删除标题(不经显式 `refresh` 就解钉回自动标题)、搜索和列表索引不属于此服务。
- 提供方注册表有意最多接受一个实现,因此部署若要组合相互竞争的标题策略,必须编写一个自行负责优先级的提供方。

View File

@@ -0,0 +1,58 @@
{
"name": "@deepseek-ai/dsh-session-title",
"description": "Log-backed session title service and provider registry for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./client": {
"types": "./lib/types/client.d.ts",
"default": "./lib/types/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-projection": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,10 @@
/**
* Client-namespace projection of the title domain: a pure re-export of the package's
* types outlet. Client code imports ONLY the client namespace (repo
* discipline), so `./client` projects the same single-source content
* `./types` serves to host consumers — zero duplication.
*
* @module @deepseek-ai/dsh-session-title/client
*/
export type * from './types.ts'

View File

@@ -0,0 +1,792 @@
/**
* Log-backed session title service, deterministic fallback, and provider seam.
* @module @deepseek-ai/dsh-session-title
*/
import { Context, FiberState, Service, type Fiber } from 'cordis'
import z from 'schemastery'
import { z as zod } from 'zod'
import type { Branded } from '@deepseek-ai/dsh-brand'
import { assertNever, deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import type {
Session,
SessionEvent,
} from '@deepseek-ai/dsh-session'
// Type-only: resolves ctx.sessionProjections for the optional unit child.
import type {} from '@deepseek-ai/dsh-session-projection'
// The `title` projection-key declaration lives in src/types.ts (its one home);
// this re-export projects the type face onto the package root AND keeps the
// module edge in the emitted index.d.ts, so aggregate programs consuming the
// declarations still receive the SessionProjectionMap merge.
export type * from './types.ts'
import { fallbackSessionTitle, normalizeSessionTitle } from './normalize.ts'
export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from './normalize.ts'
/** Identifies one session-title provider registration. */
export type SessionTitleProviderId = Branded<'SessionTitleProviderId'>
/**
* Brand a raw provider id.
* @param id - stable non-empty provider identifier supplied by a plugin.
* @returns the same string with the session-title provider brand.
*/
export function SessionTitleProviderId(id: string): SessionTitleProviderId {
return id as SessionTitleProviderId
}
/** Exact auxiliary model route that produced a title. */
export interface SessionTitleModelProvenance {
/** Registered LLM provider route. */
readonly provider: string
/** Provider model id. */
readonly model: string
}
/** Durable ownership record for an accepted session title. */
export type SessionTitleSource =
| { readonly kind: 'fallback' }
| {
readonly kind: 'provider'
readonly provider: SessionTitleProviderId
readonly model?: SessionTitleModelProvenance
}
| {
/** Explicit user rename: pins the title — automatic generation stops scheduling. */
readonly kind: 'user'
}
/** Payload of the log-only `session/title` event. */
export interface SessionTitleEventData {
/** Normalized non-empty title text. */
readonly title: string
/** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */
readonly messageSeqs: number[]
/** Built-in fallback, registered-provider, or explicit-user provenance. */
readonly source: SessionTitleSource
}
/** Latest folded title plus the title event's durable envelope facts. */
export interface SessionTitleSnapshot extends SessionTitleEventData {
/** Seq of the latest `session/title` event. */
readonly eventSeq: number
/** Timestamp of the latest `session/title` event. */
readonly updatedAt: number
}
/** Required deterministic fallback and accepted-title limits. */
export interface Config {
/** Maximum whitespace-delimited words in the built-in fallback. */
readonly fallbackMaxWords: number
/** Maximum UTF-8 bytes in the built-in fallback. */
readonly fallbackMaxBytes: number
/** Maximum UTF-8 bytes in any accepted title. */
readonly maxTitleBytes: number
}
declare module 'cordis' {
interface Context {
sessionTitle: SessionTitleService
}
}
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* Latest-wins session title snapshot. Log-only: it never enters the model
* surface or derived history.
*/
'session/title': SessionTitleEventData
}
}
/**
* Rejection of an explicit user title whose text normalizes to empty — the
* one {@link SessionTitleService.rename} failure that blames the input.
* Callers translating rename failures onto a wire (`title-invalid`) narrow on
* this class; liveness and disposal failures stay plain `Error`s.
*/
export class SessionTitleInvalidError extends Error {
override readonly name = 'SessionTitleInvalidError'
}
/** One eligible human text message exposed to title providers. */
export interface SessionTitleUserMessage {
/** Source `user/message` event seq. */
readonly seq: number
/** Exact concatenated text-block content. */
readonly text: string
}
/** Automatic generation cadence owned by a registered provider. */
export type SessionTitleAutomaticMode = 'first-message' | 'all-user-messages'
/** Immutable input supplied to one title-provider call. */
export interface SessionTitleProviderRequest {
/** Live session being titled. */
readonly session: Session
/** All eligible human messages through this generation revision. */
readonly messages: readonly SessionTitleUserMessage[]
/** Exact current logged main-request route, when one has been recorded. */
readonly route?: SessionTitleModelProvenance
/** Cancellation for supersession, disposal, timeout composition, or the explicit caller. */
readonly signal: AbortSignal
}
/** Provider output before service-owned normalization and log acceptance. */
export interface SessionTitleProviderResult {
/** Proposed title text. */
readonly title: string
/** Exact seqs from `request.messages` used by this result. */
readonly messageSeqs: readonly number[]
/** Auxiliary LLM route, when generation used a model. */
readonly model?: SessionTitleModelProvenance
}
/** One optional asynchronous title implementation registered with the service. */
export interface SessionTitleProvider {
/** Stable provider identity recorded in title provenance. */
readonly id: SessionTitleProviderId
/** When new human prompts start automatic generation. */
readonly automatic: SessionTitleAutomaticMode
/**
* Produce one title revision.
* @param request - message snapshot, current route, session, and cancellation.
* @returns proposed title plus exact input seqs and optional model provenance.
*/
generate(request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult>
}
/**
* Collect human text-bearing user messages in log order.
* @param events - session log or persisted replay.
* @param throughSeq - optional inclusive event boundary.
* @returns eligible messages with exact source seqs.
*/
export function collectSessionTitleMessages(
events: readonly SessionEvent[],
throughSeq?: number,
): SessionTitleUserMessage[] {
const messages: SessionTitleUserMessage[] = []
for (const event of events) {
if (throughSeq !== undefined && event.seq > throughSeq) break
if (event.type !== 'user/message' || event.data.source.kind !== 'user') continue
const content = event.data.content
const text = content
.filter((block): block is Extract<(typeof content)[number], { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join('\n')
if (normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER).length === 0) continue
messages.push({ seq: event.seq, text })
}
return messages
}
/**
* Fold the latest logged title without consulting mutable metadata.
* @param events - live or persisted session log.
* @returns the latest immutable title snapshot, or `undefined`.
*/
export function foldSessionTitle(events: readonly SessionEvent[]): SessionTitleSnapshot | undefined {
const event = events.findLast(item => item.type === 'session/title')
if (event === undefined) return undefined
return deepFreeze({
title: event.data.title,
messageSeqs: [...event.data.messageSeqs],
source: copySessionTitleSource(event.data.source),
eventSeq: event.seq,
updatedAt: event.time,
})
}
/** Defensive copy of a logged title source (the snapshot must not alias log-owned objects). */
function copySessionTitleSource(source: SessionTitleSource): SessionTitleSource {
switch (source.kind) {
case 'fallback': return { kind: 'fallback' }
case 'provider': return {
kind: 'provider',
provider: source.provider,
...(source.model === undefined ? {} : { model: { ...source.model } }),
}
case 'user': return { kind: 'user' }
/* v8 ignore next -- closed-union exhaustiveness guard */
default: return assertNever(source, 'SessionTitleSource')
}
}
/** Service-owned resolved limits. */
interface ResolvedConfig {
readonly fallbackMaxWords: number
readonly fallbackMaxBytes: number
readonly maxTitleBytes: number
}
/** One exact provider registration generation. */
interface ProviderRegistration {
readonly provider: SessionTitleProvider
readonly active: Set<Promise<unknown>>
closing: boolean
}
/** Automatic work waiting for the matching main-request header. */
interface PendingAutomaticWork {
readonly registration: ProviderRegistration
readonly revision: number
readonly throughSeq: number
}
/** Provider call currently allowed to commit for one session. */
interface ActiveProviderWork extends PendingAutomaticWork {
readonly controller: AbortController
readonly signal: AbortSignal
}
/** Mutable concurrency state scoped to one live session. */
interface SessionTitleWorkState {
revision: number
fallback?: Promise<SessionTitleSnapshot | undefined>
pending?: PendingAutomaticWork
active?: ActiveProviderWork
}
/** Validate one positive integer configuration field. */
function assertPositiveInteger(name: keyof Config, value: number): void {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`session-title: ${name} must be a positive integer`)
}
}
/** Log-backed title fold plus asynchronous fallback generation. */
export class SessionTitleService extends Service {
static inject = ['sessions']
static Config: z<Config> = z.object({
fallbackMaxWords: z.number().step(1).min(1).required(),
fallbackMaxBytes: z.number().step(1).min(1).required(),
maxTitleBytes: z.number().step(1).min(1).required(),
})
private readonly config: ResolvedConfig
private readonly ownerFiber: Fiber
private registration: ProviderRegistration | undefined
private readonly work = new Map<Session, SessionTitleWorkState>()
private readonly lifetime = new AbortController()
private readonly inFlight = new Set<Promise<unknown>>()
constructor(ctx: Context, config: Config) {
super(ctx, 'sessionTitle')
this.ownerFiber = ctx.fiber
const candidate: unknown = config
if (candidate === null || typeof candidate !== 'object') {
throw new Error('session-title: configuration is required')
}
const value = candidate as Config
assertPositiveInteger('fallbackMaxWords', value.fallbackMaxWords)
assertPositiveInteger('fallbackMaxBytes', value.fallbackMaxBytes)
assertPositiveInteger('maxTitleBytes', value.maxTitleBytes)
if (value.fallbackMaxBytes > value.maxTitleBytes) {
throw new Error('session-title: fallbackMaxBytes must not exceed maxTitleBytes')
}
this.config = deepFreeze({ ...value })
ctx.effect(() => async () => {
this.lifetime.abort(new Error('session-title service disposed'))
if (this.registration !== undefined) this.registration.closing = true
this.registration = undefined
for (const state of this.work.values()) {
delete state.pending
state.active?.controller.abort(new Error('session-title service disposed'))
}
await this.drain(this.inFlight)
this.work.clear()
}, 'sessionTitle lifecycle')
// The title projection unit: pure last-wins fold of session/title events
// (the same events foldSessionTitle consumes), serving the plain title
// string clients list rows read. The unit child activates only when a
// projection registry is composed (headless assemblies stay unaffected).
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register<'title', string | null>({
key: 'title',
schema: zod.union([zod.string().min(1), zod.null()]),
init: () => null,
apply: (state, event) => (event.type === 'session/title' ? event.data.title : state),
view: state => state,
stateVersion: 1,
})
})
ctx.on('session/event', (session, event) => {
switch (event.type) {
case 'user/message':
this.onUserMessage(session, event)
break
case 'request/header':
this.onRequestHeader(session, event)
break
default:
break
}
})
ctx.on('llm/stream', (options, next) => {
this.onMainRequest(options)
return next()
}, { global: true, prepend: true })
ctx.on('session/disposed', (session) => {
const state = this.work.get(session)
if (state === undefined) return
state.active?.controller.abort(new Error('session disposed during title generation'))
this.work.delete(session)
})
}
/**
* Read the latest folded title from one live or replayed session.
* @param session - session whose log is the title source of truth.
* @returns latest title snapshot, or `undefined` before eligible input.
*/
get(session: Session): SessionTitleSnapshot | undefined {
return foldSessionTitle(session.events)
}
/**
* Accept an explicit user title. Appends a `session/title` event with the
* `user` source, which pins the title: in-flight automatic generation is
* superseded and later user messages schedule none (an explicit
* {@link SessionTitleService.refresh} remains the deliberate unpin).
* @param session - exact live session to rename.
* @param title - raw user input; normalized before acceptance.
* @returns the accepted title snapshot.
* @throws {SessionTitleInvalidError} when the title normalizes to empty.
* @throws {Error} when the session is not live or the service is disposed.
*/
rename(session: Session, title: string): SessionTitleSnapshot {
this.assertServiceActive()
if (this.ctx.sessions.get(session.id) !== session) {
throw new Error(`session "${session.id}" is not live in this store`)
}
const normalized = normalizeSessionTitle(title, this.config.maxTitleBytes)
if (normalized.length === 0) {
throw new SessionTitleInvalidError('session title must contain visible characters')
}
const state = this.stateFor(session)
this.supersede(state, 'user rename superseded automatic title generation')
session.append('session/title', {
title: normalized,
messageSeqs: [],
source: { kind: 'user' },
})
const snapshot = this.get(session)
/* v8 ignore next -- unreachable: the append above just committed a session/title event. */
if (snapshot === undefined) throw new Error('renamed title failed to fold')
return snapshot
}
/**
* Explicitly retry the registered provider, or materialize the built-in
* fallback when no provider is registered.
* @param session - exact live session to refresh.
* @param signal - optional caller cancellation.
* @returns latest accepted title, or `undefined` when no eligible text exists.
*/
async refresh(session: Session, signal?: AbortSignal): Promise<SessionTitleSnapshot | undefined> {
signal?.throwIfAborted()
this.assertServiceActive()
if (this.ctx.sessions.get(session.id) !== session) {
throw new Error(`session "${session.id}" is not live in this store`)
}
const registration = this.registration
const messages = collectSessionTitleMessages(session.events)
const latest = messages.at(-1)
if (registration === undefined || registration.closing || latest === undefined) {
// Explicit refresh is the unpin even without a provider: a standing
// user title must not short-circuit ensureFallback into a no-op, so
// re-derive and append the fallback over it when one is derivable.
const current = this.get(session)
const [first] = messages
if (current?.source.kind === 'user' && first !== undefined) {
this.appendFallback(session, first)
signal?.throwIfAborted()
return this.get(session)
}
const fallback = await this.ensureFallback(session)
signal?.throwIfAborted()
return fallback
}
const state = this.stateFor(session)
const revision = this.supersede(state, 'explicit title refresh superseded older generation')
const work = this.activate({
registration,
revision,
throughSeq: latest.seq,
}, state, signal)
const config = session.requestHeader()?.config
const route = config === undefined ? undefined : { provider: config.provider, model: config.model }
return this.startProvider(session, work, route)
}
/**
* Register the sole optional title provider. Disposal aborts its pending and
* active work before another provider may register.
* @param provider - provider identity, cadence, and generation function.
* @returns exact Cordis effect disposer, which settles after active calls quiesce.
*/
register(provider: SessionTitleProvider): () => Promise<void> {
this.validateProvider(provider)
if (this.registration !== undefined) {
throw new Error(`session-title provider "${this.registration.provider.id}" is already registered`)
}
const registration: ProviderRegistration = {
provider,
active: new Set(),
closing: false,
}
const dispose = this.ctx.effect(function* (this: SessionTitleService) {
this.registration = registration
yield async () => {
registration.closing = true
for (const state of this.work.values()) {
if (state.pending?.registration === registration) delete state.pending
if (state.active?.registration === registration) {
state.active.controller.abort(new Error(`session-title provider "${provider.id}" was disposed`))
}
}
await this.drain(registration.active)
if (this.registration === registration) this.registration = undefined
}
}.bind(this), 'sessionTitle.register()')
return dispose
}
/** Schedule fallback creation and any provider cadence for one eligible event. */
private onUserMessage(session: Session, event: Extract<SessionEvent, { type: 'user/message' }>): void {
if (!this.serviceActive()) return
if (event.data.source.kind !== 'user' || collectSessionTitleMessages([event]).length === 0) return
// A user rename pins the title: no automatic revision may override it.
if (this.get(session)?.source.kind === 'user') return
const registration = this.registration
if (registration !== undefined && !registration.closing) {
const messages = collectSessionTitleMessages(session.events, event.seq)
const shouldSchedule = registration.provider.automatic === 'all-user-messages'
|| (session.header.parentSession === undefined && messages.length === 1 && this.get(session) === undefined)
if (shouldSchedule) {
const state = this.stateFor(session)
const revision = this.supersede(state, 'newer user message superseded title generation')
state.pending = { registration, revision, throughSeq: event.seq }
}
}
this.defer(async () => {
try {
await this.ensureFallback(session)
} catch (error: unknown) {
if (!this.serviceActive()) return
this.ctx.logger.warn(`session "${session.id}": fallback title update failed: ${String(error)}`)
}
})
}
/** Start pending automatic work only after its exact main-request route is logged. */
private onRequestHeader(session: Session, event: Extract<SessionEvent, { type: 'request/header' }>): void {
if (!this.serviceActive()) return
const state = this.work.get(session)
const pending = state?.pending
if (state === undefined || pending === undefined || pending.throughSeq >= event.seq) return
const route = {
provider: event.data.header.config.provider,
model: event.data.header.config.model,
}
this.startPending(session, state, pending, route)
}
/** Start unchanged-route work from the marked loop request after its header fold is current. */
private onMainRequest(options: GenerateOptions): void {
if (!this.serviceActive() || options.sessionId === undefined || !isAgentLoopRequest(options)) return
const session = this.ctx.sessions.get(options.sessionId)
const state = session === undefined ? undefined : this.work.get(session)
const pending = state?.pending
if (session === undefined || state === undefined || pending === undefined) return
const boundary = session.events.findLast(event => event.type === 'step/start' || event.type === 'step/end')
const route = session.requestHeader()?.config
if (boundary?.type !== 'step/start'
|| boundary.seq <= pending.throughSeq
|| route?.provider !== options.provider
|| route.model !== options.model) return
this.startPending(session, state, pending, { provider: options.provider, model: options.model })
}
/** Consume one pending revision and schedule its non-blocking provider call. */
private startPending(
session: Session,
state: SessionTitleWorkState,
pending: PendingAutomaticWork,
route: SessionTitleModelProvenance,
): void {
delete state.pending
this.defer(async () => {
if (this.registration !== pending.registration
|| pending.registration.closing
|| this.work.get(session) !== state
|| state.revision !== pending.revision) return
const work = this.activate(pending, state)
try {
await this.startProvider(session, work, route)
} catch (error: unknown) {
if (work.signal.aborted || !this.serviceActive()) return
this.ctx.logger.warn(`session "${session.id}": automatic title generation failed: ${String(error)}`)
}
})
}
/** Start one tracked provider call after publishing its active revision. */
private startProvider(
session: Session,
work: ActiveProviderWork,
route?: SessionTitleModelProvenance,
): Promise<SessionTitleSnapshot | undefined> {
const run = Promise.resolve().then(() => this.runProvider(session, work, route))
return this.track(run, work.registration)
}
/** Execute and accept one current provider revision. */
private async runProvider(
session: Session,
work: ActiveProviderWork,
route?: SessionTitleModelProvenance,
): Promise<SessionTitleSnapshot | undefined> {
try {
this.assertCurrent(session, work)
await this.ensureFallback(session)
this.assertCurrent(session, work)
const messages = collectSessionTitleMessages(session.events, work.throughSeq)
const result = await work.registration.provider.generate({
session,
messages,
...route === undefined ? {} : { route },
signal: work.signal,
})
this.assertCurrent(session, work)
const accepted = this.validateResult(result, messages)
session.append('session/title', {
title: accepted.title,
messageSeqs: [...accepted.messageSeqs],
source: {
kind: 'provider',
provider: work.registration.provider.id,
...accepted.model === undefined ? {} : { model: accepted.model },
},
})
return this.get(session)
} finally {
const state = this.work.get(session)
if (state?.active === work) delete state.active
}
}
/** Validate and normalize provider output against the supplied message snapshot. */
private validateResult(
result: unknown,
messages: readonly SessionTitleUserMessage[],
): SessionTitleProviderResult {
if (result === null || typeof result !== 'object') {
throw new Error('session-title provider returned an invalid result')
}
const candidate = result as Record<string, unknown>
if (typeof candidate.title !== 'string') throw new Error('session-title provider title must be a string')
const title = normalizeSessionTitle(candidate.title, this.config.maxTitleBytes)
if (title.length === 0) throw new Error('session-title provider returned an empty title')
if (!Array.isArray(candidate.messageSeqs) || candidate.messageSeqs.length === 0) {
throw new Error('session-title provider must identify at least one source message seq')
}
const messageSeqs: number[] = []
const order = new Map(messages.map((message, index) => [message.seq, index]))
let previous = -1
for (const seq of candidate.messageSeqs as unknown[]) {
if (typeof seq !== 'number') {
throw new Error('session-title provider messageSeqs must be unique, ordered seqs from the request')
}
const index = order.get(seq)
if (!Number.isSafeInteger(seq) || seq < 0 || index === undefined || index <= previous) {
throw new Error('session-title provider messageSeqs must be unique, ordered seqs from the request')
}
messageSeqs.push(seq)
previous = index
}
const modelCandidate = candidate.model
let model: SessionTitleModelProvenance | undefined
if (modelCandidate !== undefined) {
if (modelCandidate === null || typeof modelCandidate !== 'object') {
throw new Error('session-title provider model provenance requires non-empty provider and model')
}
const record = modelCandidate as Record<string, unknown>
if (typeof record.provider !== 'string' || record.provider.length === 0
|| typeof record.model !== 'string' || record.model.length === 0) {
throw new Error('session-title provider model provenance requires non-empty provider and model')
}
model = { provider: record.provider, model: record.model }
}
return {
title,
messageSeqs,
...(model === undefined ? {} : { model }),
}
}
/** Fail a completion whose provider, revision, session, or signal is stale. */
private assertCurrent(session: Session, work: ActiveProviderWork): void {
this.assertServiceActive()
work.signal.throwIfAborted()
const state = this.work.get(session)
/* v8 ignore next -- every supported supersession, provider disposal, and session disposal aborts
* the work signal before changing this state. */
if (this.registration !== work.registration
|| state?.active !== work
|| state.revision !== work.revision
|| this.ctx.sessions.get(session.id) !== session) {
throw new Error('session title generation state changed without cancellation')
}
}
/** Create and publish an active provider call from one fixed revision. */
private activate(
pending: PendingAutomaticWork,
state: SessionTitleWorkState,
upstream?: AbortSignal,
): ActiveProviderWork {
const controller = new AbortController()
const signal = upstream === undefined
? AbortSignal.any([controller.signal, this.lifetime.signal])
: AbortSignal.any([controller.signal, this.lifetime.signal, upstream])
const work: ActiveProviderWork = { ...pending, controller, signal }
state.active = work
return work
}
/** Abort older active work and reserve the next session-local revision. */
private supersede(state: SessionTitleWorkState, reason: string): number {
state.active?.controller.abort(new Error(reason))
delete state.pending
state.revision += 1
return state.revision
}
/** Return mutable work state for one session. */
private stateFor(session: Session): SessionTitleWorkState {
let state = this.work.get(session)
if (state === undefined) {
state = { revision: 0 }
this.work.set(session, state)
}
return state
}
/** Queue detached service work and retain it through service disposal. */
private defer(task: () => Promise<void>): void {
const run = Promise.resolve().then(async () => {
if (!this.serviceActive()) return
await task()
})
void this.track(run)
}
/** Retain one promise until settlement for service and optional provider teardown. */
private track<T>(run: Promise<T>, registration?: ProviderRegistration): Promise<T> {
this.inFlight.add(run)
registration?.active.add(run)
const settled = (): void => {
this.inFlight.delete(run)
registration?.active.delete(run)
}
void run.then(settled, settled)
return run
}
/** Await every current and settling promise in one lifecycle registry. */
private async drain(active: Set<Promise<unknown>>): Promise<void> {
while (active.size > 0) await Promise.allSettled([...active])
}
/** Whether the owning plugin fiber can still start or commit title work. */
private serviceActive(): boolean {
return !this.lifetime.signal.aborted
&& this.ownerFiber.uid !== null
&& this.ownerFiber.state === FiberState.ACTIVE
}
/** Reject work once the owning plugin fiber has begun unloading. */
private assertServiceActive(): void {
if (!this.serviceActive()) throw new Error('session-title service disposed')
}
/** Reject malformed provider registrations before publishing an effect. */
private validateProvider(provider: unknown): asserts provider is SessionTitleProvider {
if (provider === null || typeof provider !== 'object') {
throw new Error('session-title provider must be an object')
}
const candidate = provider as Record<string, unknown>
if (typeof candidate.id !== 'string' || candidate.id.length === 0) {
throw new Error('session-title provider id must be a non-empty string')
}
if (candidate.automatic !== 'first-message' && candidate.automatic !== 'all-user-messages') {
throw new Error('session-title provider automatic mode is invalid')
}
if (typeof candidate.generate !== 'function') {
throw new Error(`session-title provider "${candidate.id}" requires generate()`)
}
}
/**
* Derive and append the deterministic fallback title over whatever stands
* (the refresh unpin path: overwriting a pinned user title is the point).
* Synchronous on purpose — no await may separate derivation from append, so
* it needs neither ensureFallback's in-flight dedup nor its liveness
* re-check. An underivable fallback (empty after the caps) appends nothing.
*/
private appendFallback(session: Session, first: SessionTitleUserMessage): void {
const title = fallbackSessionTitle(first.text, this.config.fallbackMaxWords, this.config.fallbackMaxBytes)
if (title.length === 0) return
session.append('session/title', {
title,
messageSeqs: [first.seq],
source: { kind: 'fallback' },
})
}
/** Create the first deterministic fallback if the session still lacks a title. */
private async ensureFallback(session: Session): Promise<SessionTitleSnapshot | undefined> {
this.assertServiceActive()
const current = this.get(session)
if (current !== undefined) return current
const [first] = collectSessionTitleMessages(session.events)
if (first === undefined) return undefined
const title = fallbackSessionTitle(
first.text,
this.config.fallbackMaxWords,
this.config.fallbackMaxBytes,
)
if (title.length === 0) return undefined
const state = this.stateFor(session)
if (state.fallback !== undefined) return state.fallback
const fallback = Promise.resolve().then(() => {
this.assertServiceActive()
if (this.ctx.sessions.get(session.id) !== session) {
throw new Error(`session "${session.id}" is not live in this store`)
}
const accepted = this.get(session)
if (accepted !== undefined) return accepted
session.append('session/title', {
title,
messageSeqs: [first.seq],
source: { kind: 'fallback' },
})
return this.get(session)
})
state.fallback = fallback
try {
return await fallback
} finally {
delete state.fallback
}
}
}
export default SessionTitleService

View File

@@ -0,0 +1,47 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-session-title`.
* @module @deepseek-ai/dsh-session-title/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-title'
/** Cordis companion plugin name. */
export const name = 'session-title-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* Durable title-provenance invariant: an automatic title always cites at
* least one human `user/message` seq, and an explicit user rename cites none
* — `messageSeqs` is empty iff `source.kind` is `user`. Provider revisions
* are validated by the service before their append; this checks the durable
* relationship every appended `session/title` event must keep, whichever
* writer produced it.
*/
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
// internal/dispatch interception rejects the append before publication
// (the session/event listener would only observe the already-committed log).
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [, event] = args as [unknown, SessionEvent]
if (event.type !== 'session/title') return
const { source, messageSeqs } = event.data
if ((messageSeqs.length === 0) !== (source.kind === 'user')) {
fail(`session/title event ${String(event.seq)} breaks provenance: source "${source.kind}" with ${String(messageSeqs.length)} cited message seq(s)`)
}
}, { global: true })
}, { inject: ['sessions'] })
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,74 @@
/** Title text normalization and UTF-8-safe truncation. */
/** Operating-system-command escape sequences, including unterminated tails. */
const OSC_SEQUENCE = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu
/** Control-sequence-introducer escapes such as SGR color codes. */
const CSI_SEQUENCE = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu
/** Remaining two-byte ESC control sequences. */
const ESC_SEQUENCE = /\u001B[@-_]/gu
/** Non-whitespace C0/C1 control characters. */
const CONTROL_CHARACTER = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/gu
/** Directional and invisible controls that can make a displayed title deceptive. */
const DIRECTIONAL_CONTROL = /[\u200B\u200E\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\uFEFF]/gu
/** Reject an invalid public text limit. */
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer`)
}
}
/** Remove controls and produce one trimmed, whitespace-normalized line. */
function cleanTitleText(input: string): string {
return input
.replace(OSC_SEQUENCE, '')
.replace(CSI_SEQUENCE, '')
.replace(ESC_SEQUENCE, '')
.replace(CONTROL_CHARACTER, '')
.replace(DIRECTIONAL_CONTROL, '')
.replace(/\s+/gu, ' ')
.trim()
}
/**
* Truncate a string to a UTF-8 byte budget without splitting a Unicode code point.
* @param input - normalized title text.
* @param maxBytes - positive UTF-8 byte budget.
* @returns the longest leading code-point prefix within the budget.
*/
export function truncateTitleUtf8(input: string, maxBytes: number): string {
assertPositiveInteger('maxBytes', maxBytes)
if (Buffer.byteLength(input, 'utf8') <= maxBytes) return input
let used = 0
let output = ''
for (const character of input) {
const bytes = Buffer.byteLength(character, 'utf8')
if (used + bytes > maxBytes) break
output += character
used += bytes
}
return output
}
/**
* Normalize one accepted session title and enforce its UTF-8 byte budget.
* @param input - untrusted title text.
* @param maxBytes - positive maximum encoded size.
* @returns a terminal-safe one-line title, possibly empty after sanitization.
*/
export function normalizeSessionTitle(input: string, maxBytes: number): string {
return truncateTitleUtf8(cleanTitleText(input), maxBytes).trimEnd()
}
/**
* Derive the deterministic first-message fallback.
* @param input - text from the first eligible human message.
* @param maxWords - positive whitespace-delimited word cap.
* @param maxBytes - positive UTF-8 byte cap.
* @returns the normalized leading words within both limits.
*/
export function fallbackSessionTitle(input: string, maxWords: number, maxBytes: number): string {
assertPositiveInteger('maxWords', maxWords)
const words = cleanTitleText(input).split(' ').filter(Boolean).slice(0, maxWords)
return truncateTitleUtf8(words.join(' '), maxBytes).trimEnd()
}

View File

@@ -0,0 +1,24 @@
/**
* Pure types of the title domain: the ONE home of the `title` projection-key
* declaration, free of this package's host-side value imports (cordis
* service, schemastery, the llm seam). Two namespace projections serve it —
* `./types` for host consumers, `./client/types` (the browser half-entry's
* re-export) for client aggregates — with zero content duplication.
*
* @module @deepseek-ai/dsh-session-title/types
*/
// Marks this file a module so the declaration below AUGMENTS the projection
// table instead of declaring an ambient module.
export {}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
/**
* The session's current normalized title — the latest `session/title`
* event's text (last-wins), or `null` before the first title lands. A
* plain string: the shape the client list rows consume.
*/
title: string | null
}
}

View 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)
})
})

View 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()
})
})

View 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)
})
})

View 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()
})
})

View 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, '  ')).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')
})
})

View 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')
}
})
})

View 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,
})
})
})

View File

@@ -0,0 +1,36 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/brand"
},
{
"path": "../../support/invariants"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../session-projection"
}
]
}