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:
6
packages/session/session-title/README.i18n.yaml
Normal file
6
packages/session/session-title/README.i18n.yaml
Normal 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
|
||||
55
packages/session/session-title/README.md
Normal file
55
packages/session/session-title/README.md
Normal 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.
|
||||
55
packages/session/session-title/README.zh.md
Normal file
55
packages/session/session-title/README.zh.md
Normal 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` 就解钉回自动标题)、搜索和列表索引不属于此服务。
|
||||
- 提供方注册表有意最多接受一个实现,因此部署若要组合相互竞争的标题策略,必须编写一个自行负责优先级的提供方。
|
||||
58
packages/session/session-title/package.json
Normal file
58
packages/session/session-title/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
10
packages/session/session-title/src/client.ts
Normal file
10
packages/session/session-title/src/client.ts
Normal 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'
|
||||
792
packages/session/session-title/src/index.ts
Normal file
792
packages/session/session-title/src/index.ts
Normal 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
|
||||
47
packages/session/session-title/src/invariant.ts
Normal file
47
packages/session/session-title/src/invariant.ts
Normal 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 */
|
||||
74
packages/session/session-title/src/normalize.ts
Normal file
74
packages/session/session-title/src/normalize.ts
Normal 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()
|
||||
}
|
||||
24
packages/session/session-title/src/types.ts
Normal file
24
packages/session/session-title/src/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
44
packages/session/session-title/tests/invariant.spec.ts
Normal file
44
packages/session/session-title/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Title-provenance invariant: messageSeqs is empty iff source.kind is 'user'
|
||||
// — the durable relationship every appended session/title event must keep.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import * as SessionTitleInvariantCompanion from '@deepseek-ai/dsh-session-title/invariant'
|
||||
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(SessionTitleInvariantCompanion)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('session-title provenance invariant', () => {
|
||||
it('accepts cited automatic titles and citation-free user renames', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('title-invariant-valid'))
|
||||
expect(() => {
|
||||
session.append('session/title', { title: 'auto', messageSeqs: [1], source: { kind: 'fallback' } })
|
||||
session.append('session/title', { title: 'named', messageSeqs: [], source: { kind: 'user' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a citation-free automatic title and a user rename that cites messages', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('title-invariant-invalid'))
|
||||
expect(() => {
|
||||
session.append('session/title', { title: 'auto', messageSeqs: [], source: { kind: 'fallback' } })
|
||||
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
|
||||
code: 'INVARIANT',
|
||||
packageName: '@deepseek-ai/dsh-session-title',
|
||||
}))
|
||||
expect(() => {
|
||||
session.append('session/title', { title: 'named', messageSeqs: [1], source: { kind: 'user' } })
|
||||
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
|
||||
code: 'INVARIANT',
|
||||
packageName: '@deepseek-ai/dsh-session-title',
|
||||
}))
|
||||
expect(session.seq).toBe(0)
|
||||
})
|
||||
})
|
||||
90
packages/session/session-title/tests/persistence.spec.ts
Normal file
90
packages/session/session-title/tests/persistence.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite'
|
||||
import SessionTitleService, { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
const CONFIG = {
|
||||
fallbackMaxWords: 5,
|
||||
fallbackMaxBytes: 40,
|
||||
maxTitleBytes: 80,
|
||||
} as const
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function appendPersistedTitle(ctx: Context, id: ReturnType<typeof SessionId>): Promise<void> {
|
||||
const session = ctx.sessions.create(id)
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Persist this session title' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.sessionTitle.refresh(session)
|
||||
}
|
||||
|
||||
async function expectPersistedTitle(ctx: Context, id: ReturnType<typeof SessionId>): Promise<void> {
|
||||
const loaded = await ctx.sessionPersistence.load(id)
|
||||
expect(foldSessionTitle(loaded.events)).toMatchObject({
|
||||
title: 'Persist this session title',
|
||||
messageSeqs: [1],
|
||||
source: { kind: 'fallback' },
|
||||
eventSeq: 3,
|
||||
})
|
||||
expect(loaded.events.map(event => event.type)).toEqual([
|
||||
'turn/start',
|
||||
'user/message',
|
||||
'turn/end',
|
||||
'session/title',
|
||||
])
|
||||
}
|
||||
|
||||
describe('session title persistence round trips', () => {
|
||||
it('round-trips through a remounted JSONL backend', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-title-jsonl-'))
|
||||
roots.push(root)
|
||||
const id = SessionId('title-jsonl')
|
||||
const writer = new Context()
|
||||
await writer.plugin(SessionStore)
|
||||
await writer.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
||||
await writer.plugin(SessionTitleService, CONFIG)
|
||||
await appendPersistedTitle(writer, id)
|
||||
await writer.fiber.dispose()
|
||||
|
||||
const reader = new Context()
|
||||
await reader.plugin(SessionStore)
|
||||
await reader.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
||||
await expectPersistedTitle(reader, id)
|
||||
await reader.fiber.dispose()
|
||||
})
|
||||
|
||||
it('round-trips through a remounted SQLite backend', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-title-sqlite-'))
|
||||
roots.push(root)
|
||||
const path = join(root, 'sessions.db')
|
||||
const id = SessionId('title-sqlite')
|
||||
const writer = new Context()
|
||||
await writer.plugin(SessionStore)
|
||||
await writer.plugin(SessionPersistenceSqlite, { path })
|
||||
await writer.plugin(SessionTitleService, CONFIG)
|
||||
await appendPersistedTitle(writer, id)
|
||||
await writer.fiber.dispose()
|
||||
|
||||
const reader = new Context()
|
||||
await reader.plugin(SessionStore)
|
||||
await reader.plugin(SessionPersistenceSqlite, { path })
|
||||
await expectPersistedTitle(reader, id)
|
||||
await reader.fiber.dispose()
|
||||
})
|
||||
})
|
||||
76
packages/session/session-title/tests/projection.spec.ts
Normal file
76
packages/session/session-title/tests/projection.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The `title` projection unit: mounting the title service beside the
|
||||
* projection registry serves the current normalized title (last-wins over
|
||||
* session/title events, the same events foldSessionTitle consumes) — null
|
||||
* before the first title — through the registry snapshot and the change
|
||||
* feed; compositions without the registry are unaffected; unmounting the
|
||||
* service removes the key (HMR safety). The bespoke session/title mux frame
|
||||
* is untouched by this unit (its retirement is the client value-store
|
||||
* migration's concern).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SessionTitleService from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
const CONFIG = { fallbackMaxWords: 8, fallbackMaxBytes: 64, maxTitleBytes: 256 }
|
||||
|
||||
async function harness(withTitleService: boolean): Promise<{ ctx: Context; session: Session }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
if (withTitleService) await ctx.plugin(SessionTitleService, CONFIG)
|
||||
return { ctx, session: ctx.sessions.create(SessionId('titled')) }
|
||||
}
|
||||
|
||||
/** Append one session/title event directly (the replay-plane shape the unit folds). */
|
||||
function appendTitle(session: Session, title: string): number {
|
||||
return session.append('session/title', { title, messageSeqs: [1], source: { kind: 'fallback' } }).seq
|
||||
}
|
||||
|
||||
describe('title projection unit', () => {
|
||||
it('serves null before the first title event', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
const snapshot = ctx.sessionProjections.snapshot(session)
|
||||
expect(snapshot.values.title).toBeNull()
|
||||
})
|
||||
|
||||
it('serves the latest title last-wins and notifies the change feed with the causing seq', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
const changes: { key: string; value: unknown; seq: number }[] = []
|
||||
ctx.sessionProjections.onChanged((_session, key, value, seq) => {
|
||||
changes.push({ key, value, seq })
|
||||
})
|
||||
const firstSeq = appendTitle(session, 'First title')
|
||||
const secondSeq = appendTitle(session, 'Second title')
|
||||
// Unrelated event: same-reference apply, no notification.
|
||||
session.append('turn/start', { turn: 1 })
|
||||
expect(changes).toEqual([
|
||||
{ key: 'title', value: 'First title', seq: firstSeq },
|
||||
{ key: 'title', value: 'Second title', seq: secondSeq },
|
||||
])
|
||||
const snapshot = ctx.sessionProjections.snapshot(session)
|
||||
expect(snapshot.values.title).toBe('Second title')
|
||||
expect(snapshot.asOfSeq).toBe(session.seq - 1)
|
||||
})
|
||||
|
||||
it('folds titles already in the log when the service mounts late (lazy cell build)', async () => {
|
||||
const { ctx, session } = await harness(false)
|
||||
appendTitle(session, 'Pre-mount title')
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
expect(ctx.sessionProjections.snapshot(session).values.title).toBe('Pre-mount title')
|
||||
})
|
||||
|
||||
it('has no title key without the title service, and drops it when the service unloads (HMR safety)', async () => {
|
||||
const { ctx, session } = await harness(false)
|
||||
expect('title' in ctx.sessionProjections.snapshot(session).values).toBe(false)
|
||||
const fiber = await ctx.plugin(SessionTitleService, CONFIG)
|
||||
appendTitle(session, 'Ephemeral')
|
||||
expect(ctx.sessionProjections.snapshot(session).values.title).toBe('Ephemeral')
|
||||
await fiber.dispose()
|
||||
expect('title' in ctx.sessionProjections.snapshot(session).values).toBe(false)
|
||||
})
|
||||
})
|
||||
374
packages/session/session-title/tests/provider.spec.ts
Normal file
374
packages/session/session-title/tests/provider.spec.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import LlmService, { createUserMessage, deepFreeze, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionTitleService, {
|
||||
SessionTitleProviderId,
|
||||
type SessionTitleProvider,
|
||||
type SessionTitleProviderRequest,
|
||||
type SessionTitleProviderResult,
|
||||
} from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
const CONFIG = {
|
||||
fallbackMaxWords: 5,
|
||||
fallbackMaxBytes: 24,
|
||||
maxTitleBytes: 24,
|
||||
} as const
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve(value: T): void
|
||||
reject(error: unknown): void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((accept, decline) => {
|
||||
resolve = accept
|
||||
reject = decline
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function appendHumanPrompt(session: ReturnType<Context['sessions']['create']>, text: string) {
|
||||
return session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
function appendRoute(session: ReturnType<Context['sessions']['create']>, reason: 'initial' | 'change' = 'initial'): void {
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'main-route', model: 'chat-model' } },
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
describe('SessionTitleService provider lifecycle', () => {
|
||||
it('inherits title events across forks, skips first-message retitling, and lets all-messages update later', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const parent = ctx.sessions.create(SessionId('title-parent'))
|
||||
parent.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
const inheritedMessage = appendHumanPrompt(parent, 'Inherited title prompt')
|
||||
await settle()
|
||||
parent.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const child = ctx.sessions.fork(parent, undefined, SessionId('title-child'))
|
||||
expect(ctx.sessionTitle.get(child)).toEqual(ctx.sessionTitle.get(parent))
|
||||
expect(child.events.find(event => event.type === 'session/title'))
|
||||
.toEqual(parent.events.find(event => event.type === 'session/title'))
|
||||
|
||||
const firstGenerate = vi.fn(async (request: SessionTitleProviderRequest) => ({
|
||||
title: 'Should not run',
|
||||
messageSeqs: [request.messages[0]!.seq],
|
||||
}))
|
||||
const disposeFirst = ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('fork-first'),
|
||||
automatic: 'first-message',
|
||||
generate: firstGenerate,
|
||||
})
|
||||
child.append('turn/start', {
|
||||
turn: 2,
|
||||
})
|
||||
const childMessage = appendHumanPrompt(child, 'Child follow-up prompt')
|
||||
await settle()
|
||||
appendRoute(child)
|
||||
await settle()
|
||||
child.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
expect(firstGenerate).not.toHaveBeenCalled()
|
||||
await disposeFirst()
|
||||
|
||||
const allGenerate = vi.fn(async (request: SessionTitleProviderRequest) => ({
|
||||
title: 'Fork all prompts',
|
||||
messageSeqs: request.messages.map(message => message.seq),
|
||||
}))
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('fork-all'),
|
||||
automatic: 'all-user-messages',
|
||||
generate: allGenerate,
|
||||
})
|
||||
child.append('turn/start', {
|
||||
turn: 3,
|
||||
})
|
||||
const latestMessage = appendHumanPrompt(child, 'Retitle the fork now')
|
||||
await settle()
|
||||
appendRoute(child, 'change')
|
||||
await settle()
|
||||
child.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
|
||||
|
||||
expect(allGenerate).toHaveBeenCalledOnce()
|
||||
expect(ctx.sessionTitle.get(child)).toMatchObject({
|
||||
title: 'Fork all prompts',
|
||||
messageSeqs: [inheritedMessage.seq, childMessage.seq, latestMessage.seq],
|
||||
source: { kind: 'provider', provider: SessionTitleProviderId('fork-all') },
|
||||
})
|
||||
expect(ctx.sessionTitle.get(parent)?.title).toBe('Inherited title prompt')
|
||||
})
|
||||
|
||||
it('runs a first-message provider once after the routed request and retries only through refresh', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const requests: SessionTitleProviderRequest[] = []
|
||||
const provider: SessionTitleProvider = {
|
||||
id: SessionTitleProviderId('first-model'),
|
||||
automatic: 'first-message',
|
||||
async generate(request) {
|
||||
requests.push(request)
|
||||
return {
|
||||
title: '\u001B[31m A model-generated title that is too long ',
|
||||
messageSeqs: [request.messages[0]!.seq],
|
||||
model: { provider: 'aux-route', model: 'title-model' },
|
||||
}
|
||||
},
|
||||
}
|
||||
ctx.sessionTitle.register(provider)
|
||||
const session = ctx.sessions.create(SessionId('first-provider'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
const first = appendHumanPrompt(session, 'Explain asynchronous title generation')
|
||||
await settle()
|
||||
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
|
||||
|
||||
appendRoute(session)
|
||||
await settle()
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]).toMatchObject({
|
||||
session,
|
||||
messages: [{ seq: first.seq, text: 'Explain asynchronous title generation' }],
|
||||
route: { provider: 'main-route', model: 'chat-model' },
|
||||
})
|
||||
expect(ctx.sessionTitle.get(session)).toMatchObject({
|
||||
title: 'A model-generated title',
|
||||
messageSeqs: [first.seq],
|
||||
source: {
|
||||
kind: 'provider',
|
||||
provider: SessionTitleProviderId('first-model'),
|
||||
model: { provider: 'aux-route', model: 'title-model' },
|
||||
},
|
||||
})
|
||||
|
||||
const second = appendHumanPrompt(session, 'A later prompt')
|
||||
appendRoute(session, 'change')
|
||||
await settle()
|
||||
expect(requests).toHaveLength(1)
|
||||
|
||||
await ctx.sessionTitle.refresh(session)
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1]?.messages.map(message => message.seq)).toEqual([first.seq, second.seq])
|
||||
})
|
||||
|
||||
it('rejects a second provider and drains stale work when the winner is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const pending = deferred<SessionTitleProviderResult>()
|
||||
let observedSignal: AbortSignal | undefined
|
||||
const first: SessionTitleProvider = {
|
||||
id: SessionTitleProviderId('winner'),
|
||||
automatic: 'all-user-messages',
|
||||
generate(request) {
|
||||
observedSignal = request.signal
|
||||
return pending.promise
|
||||
},
|
||||
}
|
||||
const dispose = ctx.sessionTitle.register(first)
|
||||
expect(() => ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('duplicate'),
|
||||
automatic: 'first-message',
|
||||
generate: async () => ({ title: 'duplicate', messageSeqs: [0] }),
|
||||
})).toThrow(/already registered/)
|
||||
|
||||
const session = ctx.sessions.create(SessionId('dispose-provider'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
const message = appendHumanPrompt(session, 'Generate this title')
|
||||
await settle()
|
||||
appendRoute(session)
|
||||
await settle()
|
||||
expect(observedSignal?.aborted).toBe(false)
|
||||
|
||||
const disposal = dispose()
|
||||
expect(observedSignal?.aborted).toBe(true)
|
||||
let disposed = false
|
||||
void disposal.then(() => { disposed = true })
|
||||
await settle()
|
||||
expect(disposed).toBe(false)
|
||||
pending.resolve({ title: 'stale provider result', messageSeqs: [message.seq] })
|
||||
await disposal
|
||||
expect(disposed).toBe(true)
|
||||
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
|
||||
|
||||
const replacement: SessionTitleProvider = {
|
||||
id: SessionTitleProviderId('replacement'),
|
||||
automatic: 'first-message',
|
||||
generate: async () => ({ title: 'replacement', messageSeqs: [message.seq] }),
|
||||
}
|
||||
const disposeReplacement = ctx.sessionTitle.register(replacement)
|
||||
await disposeReplacement()
|
||||
})
|
||||
|
||||
it('supersedes an older all-messages revision and cannot commit an ignored abort', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const firstResult = deferred<SessionTitleProviderResult>()
|
||||
const requests: SessionTitleProviderRequest[] = []
|
||||
const provider: SessionTitleProvider = {
|
||||
id: SessionTitleProviderId('all-model'),
|
||||
automatic: 'all-user-messages',
|
||||
generate(request) {
|
||||
requests.push(request)
|
||||
if (requests.length === 1) return firstResult.promise
|
||||
return Promise.resolve({
|
||||
title: 'Newest complete title',
|
||||
messageSeqs: request.messages.map(message => message.seq),
|
||||
})
|
||||
},
|
||||
}
|
||||
ctx.sessionTitle.register(provider)
|
||||
const session = ctx.sessions.create(SessionId('supersede'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
const first = appendHumanPrompt(session, 'First prompt')
|
||||
await settle()
|
||||
appendRoute(session)
|
||||
await settle()
|
||||
|
||||
const second = appendHumanPrompt(session, 'Second prompt')
|
||||
expect(requests[0]?.signal.aborted).toBe(true)
|
||||
appendRoute(session, 'change')
|
||||
await settle()
|
||||
expect(ctx.sessionTitle.get(session)).toMatchObject({
|
||||
title: 'Newest complete title',
|
||||
messageSeqs: [first.seq, second.seq],
|
||||
})
|
||||
|
||||
firstResult.resolve({ title: 'Old ignored result', messageSeqs: [first.seq] })
|
||||
await settle()
|
||||
expect(ctx.sessionTitle.get(session)?.title).toBe('Newest complete title')
|
||||
})
|
||||
|
||||
it('runs an all-messages revision when the next main request reuses its logged header', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const requests: SessionTitleProviderRequest[] = []
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('unchanged-route'),
|
||||
automatic: 'all-user-messages',
|
||||
async generate(request) {
|
||||
requests.push(request)
|
||||
return {
|
||||
title: `Revision ${requests.length}`,
|
||||
messageSeqs: request.messages.map(message => message.seq),
|
||||
}
|
||||
},
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('unchanged-route'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
const first = appendHumanPrompt(session, 'First routed prompt')
|
||||
await settle()
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
appendRoute(session)
|
||||
await settle()
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
})
|
||||
const second = appendHumanPrompt(session, 'Second prompt on the same route')
|
||||
await settle()
|
||||
session.append('step/start', { turn: 2, step: 1 })
|
||||
void ctx.llm.stream(markAgentLoopRequest(deepFreeze({
|
||||
provider: 'main-route',
|
||||
model: 'chat-model',
|
||||
messages: session.deriveMessages(),
|
||||
sessionId: session.id,
|
||||
})))
|
||||
await settle()
|
||||
|
||||
expect(session.events.filter(event => event.type === 'request/header')).toHaveLength(1)
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1]).toMatchObject({
|
||||
messages: [
|
||||
{ seq: first.seq, text: 'First routed prompt' },
|
||||
{ seq: second.seq, text: 'Second prompt on the same route' },
|
||||
],
|
||||
route: { provider: 'main-route', model: 'chat-model' },
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores model streams that are not a matching loop request', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult> => ({
|
||||
title: 'Unexpected title',
|
||||
messageSeqs: request.messages.map(message => message.seq),
|
||||
}))
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('request-filter'),
|
||||
automatic: 'all-user-messages',
|
||||
generate,
|
||||
})
|
||||
const options = { provider: 'main-route', model: 'chat-model', messages: [] }
|
||||
|
||||
void ctx.llm.stream(deepFreeze(options))
|
||||
void ctx.llm.stream(markAgentLoopRequest(deepFreeze({ ...options, sessionId: SessionId('missing') })))
|
||||
const quiet = ctx.sessions.create(SessionId('quiet'))
|
||||
void ctx.llm.stream(markAgentLoopRequest(deepFreeze({ ...options, sessionId: quiet.id })))
|
||||
const pending = ctx.sessions.create(SessionId('unmatched-boundary'))
|
||||
pending.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
appendHumanPrompt(pending, 'Wait for a matching request boundary')
|
||||
await settle()
|
||||
void ctx.llm.stream(markAgentLoopRequest(deepFreeze({ ...options, sessionId: pending.id })))
|
||||
await settle()
|
||||
|
||||
expect(generate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('contains automatic failures but lets explicit refresh reject', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const provider: SessionTitleProvider = {
|
||||
id: SessionTitleProviderId('failing'),
|
||||
automatic: 'all-user-messages',
|
||||
generate: async () => { throw new Error('title backend failed') },
|
||||
}
|
||||
ctx.sessionTitle.register(provider)
|
||||
const session = ctx.sessions.create(SessionId('failure'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
appendHumanPrompt(session, 'Keep a fallback')
|
||||
await settle()
|
||||
appendRoute(session)
|
||||
await settle()
|
||||
|
||||
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('automatic title generation failed'))
|
||||
await expect(ctx.sessionTitle.refresh(session)).rejects.toThrow('title backend failed')
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
181
packages/session/session-title/tests/rename.spec.ts
Normal file
181
packages/session/session-title/tests/rename.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
// SessionTitleService.rename: user-source acceptance, normalization/rejection
|
||||
// boundaries, and the pin (a user-sourced latest title schedules no automatic
|
||||
// revision; explicit refresh stays the unpin).
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionTitleService, {
|
||||
SessionTitleProviderId,
|
||||
foldSessionTitle,
|
||||
type SessionTitleProviderRequest,
|
||||
} from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
const CONFIG = {
|
||||
fallbackMaxWords: 5,
|
||||
fallbackMaxBytes: 40,
|
||||
maxTitleBytes: 40,
|
||||
} as const
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function appendHumanPrompt(session: ReturnType<Context['sessions']['create']>, text: string) {
|
||||
return session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
describe('SessionTitleService.rename', () => {
|
||||
it('appends a normalized user-source title', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const session = ctx.sessions.create(SessionId('rename-accept'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
appendHumanPrompt(session, 'Original prompt text')
|
||||
await settle()
|
||||
|
||||
const accepted = ctx.sessionTitle.rename(session, ' Hand\tpicked name ')
|
||||
expect(accepted).toMatchObject({
|
||||
title: 'Hand picked name',
|
||||
messageSeqs: [],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const event = session.events.findLast(item => item.type === 'session/title')
|
||||
expect(event?.data).toEqual({
|
||||
title: 'Hand picked name',
|
||||
messageSeqs: [],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
// foldSessionTitle round-trips the third source kind.
|
||||
expect(foldSessionTitle(session.events)?.source).toEqual({ kind: 'user' })
|
||||
})
|
||||
|
||||
it('rejects titles that normalize to empty and dead sessions', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const session = ctx.sessions.create(SessionId('rename-reject'))
|
||||
expect(() => ctx.sessionTitle.rename(session, ' [31m ')).toThrow(/visible characters/)
|
||||
|
||||
expect(() => ctx.sessionTitle.rename(Session.create(SessionId('detached')), 'name'))
|
||||
.toThrow(/not live in this store/)
|
||||
})
|
||||
|
||||
it('pins the title: later user messages schedule no automatic revision; refresh unpins', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const generate = vi.fn(async (request: SessionTitleProviderRequest) => ({
|
||||
title: 'Provider title',
|
||||
messageSeqs: request.messages.map(message => message.seq),
|
||||
}))
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('pin-provider'),
|
||||
automatic: 'all-user-messages',
|
||||
generate,
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('rename-pin'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
appendHumanPrompt(session, 'First prompt')
|
||||
await settle()
|
||||
ctx.sessionTitle.rename(session, 'Pinned by hand')
|
||||
|
||||
// A later eligible prompt must schedule nothing while the pin stands.
|
||||
appendHumanPrompt(session, 'Second prompt after the pin')
|
||||
await settle()
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'main-route', model: 'chat-model' } },
|
||||
reason: 'change',
|
||||
})
|
||||
await settle()
|
||||
expect(generate).not.toHaveBeenCalled()
|
||||
expect(ctx.sessionTitle.get(session)?.title).toBe('Pinned by hand')
|
||||
|
||||
// Explicit refresh remains the deliberate unpin.
|
||||
const refreshed = await ctx.sessionTitle.refresh(session)
|
||||
expect(generate).toHaveBeenCalledOnce()
|
||||
expect(refreshed?.title).toBe('Provider title')
|
||||
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('provider')
|
||||
})
|
||||
|
||||
it('fallback-only refresh also unpins: the user title yields to a re-derived fallback', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const session = ctx.sessions.create(SessionId('rename-unpin-fallback'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
appendHumanPrompt(session, 'Derivable prompt words')
|
||||
await settle()
|
||||
ctx.sessionTitle.rename(session, 'Pinned without provider')
|
||||
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('user')
|
||||
|
||||
const refreshed = await ctx.sessionTitle.refresh(session)
|
||||
expect(refreshed).toMatchObject({
|
||||
title: 'Derivable prompt words',
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
// The pin is gone: the latest title is fallback-sourced, so the
|
||||
// onUserMessage pin check no longer skips scheduling.
|
||||
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
|
||||
})
|
||||
|
||||
it('supersedes in-flight automatic generation: a late provider result cannot override the user title', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
// The provider parks on a test-held deferred so rename lands while its
|
||||
// generation is ACTIVE (not merely scheduled).
|
||||
let releaseProvider: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => { releaseProvider = resolve })
|
||||
let aborted = false
|
||||
const generate = vi.fn(async (request: SessionTitleProviderRequest) => {
|
||||
request.signal.addEventListener('abort', () => { aborted = true })
|
||||
await gate
|
||||
return { title: 'Late provider title', messageSeqs: request.messages.map(message => message.seq) }
|
||||
})
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('deferred-provider'),
|
||||
automatic: 'all-user-messages',
|
||||
generate,
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('rename-supersede'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
appendHumanPrompt(session, 'Prompt that triggers generation')
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'main-route', model: 'chat-model' } },
|
||||
reason: 'change',
|
||||
})
|
||||
await settle()
|
||||
expect(generate).toHaveBeenCalledOnce()
|
||||
|
||||
ctx.sessionTitle.rename(session, 'User wins')
|
||||
expect(aborted).toBe(true)
|
||||
releaseProvider?.()
|
||||
await settle()
|
||||
// The released provider result must not append over the user title, and
|
||||
// the swallowed abort must not surface as an unhandled rejection.
|
||||
const latest = session.events.findLast(item => item.type === 'session/title')
|
||||
expect(latest?.data).toMatchObject({ title: 'User wins', source: { kind: 'user' } })
|
||||
})
|
||||
|
||||
it('fallback-only refresh keeps the user title when no fallback is derivable', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
// A 3-byte fallback cap cannot hold the 4-byte emoji prompt: the
|
||||
// re-derived fallback is empty, so the pinned title survives the refresh.
|
||||
await ctx.plugin(SessionTitleService, { ...CONFIG, fallbackMaxBytes: 3 })
|
||||
const session = ctx.sessions.create(SessionId('rename-unpin-empty'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
appendHumanPrompt(session, '😀😀')
|
||||
await settle()
|
||||
ctx.sessionTitle.rename(session, 'Sticky emoji pin')
|
||||
|
||||
const refreshed = await ctx.sessionTitle.refresh(session)
|
||||
expect(refreshed?.title).toBe('Sticky emoji pin')
|
||||
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('user')
|
||||
})
|
||||
})
|
||||
452
packages/session/session-title/tests/service-contracts.spec.ts
Normal file
452
packages/session/session-title/tests/service-contracts.spec.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionTitleService, {
|
||||
SessionTitleProviderId,
|
||||
type Config,
|
||||
type SessionTitleProvider,
|
||||
type SessionTitleProviderRequest,
|
||||
type SessionTitleProviderResult,
|
||||
} from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
const CONFIG = {
|
||||
fallbackMaxWords: 5,
|
||||
fallbackMaxBytes: 40,
|
||||
maxTitleBytes: 80,
|
||||
} as const
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void } {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((accept) => { resolve = accept })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
async function setup(config: Config = CONFIG): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, config)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function startSession(ctx: Context, id: string): ReturnType<Context['sessions']['create']> {
|
||||
const session = ctx.sessions.create(SessionId(id))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
return session
|
||||
}
|
||||
|
||||
function appendPrompt(session: ReturnType<Context['sessions']['create']>, text: string) {
|
||||
return session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
describe('SessionTitleService configuration and refresh boundaries', () => {
|
||||
it('requires explicit positive limits with a fallback cap no larger than the accepted-title cap', () => {
|
||||
expect(() => new SessionTitleService(new Context(), undefined as never))
|
||||
.toThrow('configuration is required')
|
||||
expect(() => new SessionTitleService(new Context(), null as never))
|
||||
.toThrow('configuration is required')
|
||||
expect(() => new SessionTitleService(new Context(), { ...CONFIG, fallbackMaxWords: 0 }))
|
||||
.toThrow(/fallbackMaxWords must be a positive integer/)
|
||||
expect(() => new SessionTitleService(new Context(), { ...CONFIG, fallbackMaxWords: 1.5 }))
|
||||
.toThrow(/fallbackMaxWords must be a positive integer/)
|
||||
expect(() => new SessionTitleService(new Context(), { ...CONFIG, fallbackMaxBytes: 81 }))
|
||||
.toThrow(/fallbackMaxBytes must not exceed maxTitleBytes/)
|
||||
})
|
||||
|
||||
it('returns no title for empty input with or without a provider, and rejects detached or pre-aborted refreshes', async () => {
|
||||
const fallbackOnly = await setup()
|
||||
const empty = fallbackOnly.sessions.create(SessionId('empty-fallback'))
|
||||
await expect(fallbackOnly.sessionTitle.refresh(empty)).resolves.toBeUndefined()
|
||||
|
||||
const withProvider = await setup()
|
||||
const generate = vi.fn(async (): Promise<SessionTitleProviderResult> => ({
|
||||
title: 'unused',
|
||||
messageSeqs: [0],
|
||||
}))
|
||||
withProvider.sessionTitle.register({
|
||||
id: SessionTitleProviderId('empty-provider'),
|
||||
automatic: 'first-message',
|
||||
generate,
|
||||
})
|
||||
const providerEmpty = withProvider.sessions.create(SessionId('empty-provider'))
|
||||
await expect(withProvider.sessionTitle.refresh(providerEmpty)).resolves.toBeUndefined()
|
||||
expect(generate).not.toHaveBeenCalled()
|
||||
|
||||
await expect(withProvider.sessionTitle.refresh(Session.create(SessionId('detached'))))
|
||||
.rejects.toThrow(/not live in this store/)
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('already cancelled'))
|
||||
await expect(withProvider.sessionTitle.refresh(providerEmpty, controller.signal))
|
||||
.rejects.toThrow('already cancelled')
|
||||
})
|
||||
|
||||
it('passes an absent route and caller cancellation into explicit generation', async () => {
|
||||
const ctx = await setup()
|
||||
let observed: SessionTitleProviderRequest | undefined
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('explicit-no-route'),
|
||||
automatic: 'first-message',
|
||||
async generate(request) {
|
||||
observed = request
|
||||
return { title: 'Explicit title', messageSeqs: [request.messages[0]!.seq] }
|
||||
},
|
||||
})
|
||||
const session = startSession(ctx, 'explicit-no-route')
|
||||
appendPrompt(session, 'Refresh before any request header')
|
||||
await settle()
|
||||
const controller = new AbortController()
|
||||
|
||||
await expect(ctx.sessionTitle.refresh(session, controller.signal))
|
||||
.resolves.toMatchObject({ title: 'Explicit title' })
|
||||
expect(observed?.route).toBeUndefined()
|
||||
expect(observed?.signal.aborted).toBe(false)
|
||||
})
|
||||
|
||||
it('propagates explicit cancellation and session disposal to active work', async () => {
|
||||
const callerCtx = await setup()
|
||||
const callerPending = deferred<SessionTitleProviderResult>()
|
||||
let callerSignal: AbortSignal | undefined
|
||||
callerCtx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('caller-cancel'),
|
||||
automatic: 'first-message',
|
||||
generate(request) {
|
||||
callerSignal = request.signal
|
||||
return callerPending.promise
|
||||
},
|
||||
})
|
||||
const callerSession = startSession(callerCtx, 'caller-cancel')
|
||||
const callerMessage = appendPrompt(callerSession, 'Cancel this refresh')
|
||||
await settle()
|
||||
const controller = new AbortController()
|
||||
const refresh = callerCtx.sessionTitle.refresh(callerSession, controller.signal)
|
||||
await settle()
|
||||
controller.abort(new Error('caller cancelled'))
|
||||
callerPending.resolve({ title: 'ignored', messageSeqs: [callerMessage.seq] })
|
||||
await expect(refresh).rejects.toThrow('caller cancelled')
|
||||
expect(callerSignal?.aborted).toBe(true)
|
||||
|
||||
const disposeCtx = await setup()
|
||||
const disposePending = deferred<SessionTitleProviderResult>()
|
||||
let disposeSignal: AbortSignal | undefined
|
||||
disposeCtx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('session-dispose'),
|
||||
automatic: 'first-message',
|
||||
generate(request) {
|
||||
disposeSignal = request.signal
|
||||
return disposePending.promise
|
||||
},
|
||||
})
|
||||
const disposed = disposeCtx.sessions.prepare(SessionId('session-dispose'))
|
||||
const detach = disposeCtx.sessions.enter(disposed)
|
||||
disposeCtx.sessions.announce(disposed)
|
||||
disposed.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
const disposedMessage = appendPrompt(disposed, 'Dispose this session')
|
||||
await settle()
|
||||
const disposedRefresh = disposeCtx.sessionTitle.refresh(disposed)
|
||||
await settle()
|
||||
detach()
|
||||
disposePending.resolve({ title: 'ignored', messageSeqs: [disposedMessage.seq] })
|
||||
await expect(disposedRefresh).rejects.toThrow(/session disposed/)
|
||||
expect(disposeSignal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('shares one fallback across concurrent refreshes', async () => {
|
||||
const ctx = await setup()
|
||||
const seed = Session.create(SessionId('fallback-concurrency-seed'))
|
||||
seed.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
const source = appendPrompt(seed, 'Create exactly one fallback title')
|
||||
seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const session = ctx.sessions.create(SessionId('fallback-concurrency'), { seed: seed.events })
|
||||
|
||||
const results = await Promise.all([
|
||||
ctx.sessionTitle.refresh(session),
|
||||
ctx.sessionTitle.refresh(session),
|
||||
])
|
||||
|
||||
expect(results[0]).toEqual(results[1])
|
||||
expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1)
|
||||
expect(session.events.map(event => event.type)).toEqual([
|
||||
'turn/start',
|
||||
'user/message',
|
||||
'turn/end',
|
||||
// The seeded constructor's end-seed marker.
|
||||
'session/end-seed',
|
||||
'session/title',
|
||||
])
|
||||
expect(ctx.sessionTitle.get(session)?.messageSeqs).toEqual([source.seq])
|
||||
})
|
||||
|
||||
it('reuses a title accepted before the queued fallback commits', async () => {
|
||||
const ctx = await setup()
|
||||
const session = startSession(ctx, 'fallback-already-accepted')
|
||||
const source = appendPrompt(session, 'Reuse the title that wins the fallback race')
|
||||
|
||||
const refresh = ctx.sessionTitle.refresh(session)
|
||||
session.append('session/title', {
|
||||
title: 'Already accepted',
|
||||
messageSeqs: [source.seq],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
|
||||
await expect(refresh).resolves.toMatchObject({ title: 'Already accepted' })
|
||||
expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('lets the newest overlapping explicit refresh win', async () => {
|
||||
const ctx = await setup()
|
||||
const session = startSession(ctx, 'refresh-order')
|
||||
const source = appendPrompt(session, 'Keep the newest explicit refresh')
|
||||
await settle()
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const requests: SessionTitleProviderRequest[] = []
|
||||
const results: Array<ReturnType<typeof deferred<SessionTitleProviderResult>>> = []
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('refresh-order'),
|
||||
automatic: 'first-message',
|
||||
generate(request) {
|
||||
requests.push(request)
|
||||
const result = deferred<SessionTitleProviderResult>()
|
||||
results.push(result)
|
||||
return result.promise
|
||||
},
|
||||
})
|
||||
|
||||
const older = ctx.sessionTitle.refresh(session)
|
||||
await settle()
|
||||
const newer = ctx.sessionTitle.refresh(session)
|
||||
await settle()
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[0]?.signal.aborted).toBe(true)
|
||||
expect(requests[1]?.signal.aborted).toBe(false)
|
||||
results[0]?.resolve({ title: 'Obsolete title', messageSeqs: [source.seq] })
|
||||
await expect(older).rejects.toThrow(/superseded/)
|
||||
results[1]?.resolve({ title: 'Newest explicit title', messageSeqs: [source.seq] })
|
||||
await expect(newer).resolves.toMatchObject({ title: 'Newest explicit title' })
|
||||
})
|
||||
|
||||
it('cancels a queued fallback when the session-title service unloads', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const lifecycle: { fiber?: Fiber; session?: Session; inactiveRefresh?: Promise<unknown> } = {}
|
||||
ctx.on('internal/plugin', (subject) => {
|
||||
if (subject !== lifecycle.fiber || subject.uid !== null || lifecycle.session === undefined) return
|
||||
appendPrompt(lifecycle.session, 'Ignore reentrant disposal prompt')
|
||||
lifecycle.session.append('request/header', {
|
||||
header: { config: { provider: 'main', model: 'main' } },
|
||||
reason: 'initial',
|
||||
})
|
||||
lifecycle.inactiveRefresh = ctx.sessionTitle.refresh(lifecycle.session).then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
)
|
||||
})
|
||||
const fiber = await ctx.plugin(SessionTitleService, CONFIG)
|
||||
lifecycle.fiber = fiber
|
||||
const session = startSession(ctx, 'service-dispose-fallback')
|
||||
lifecycle.session = session
|
||||
appendPrompt(session, 'Do not publish after service disposal')
|
||||
|
||||
await fiber.dispose()
|
||||
await settle()
|
||||
|
||||
expect(session.events.some(event => event.type === 'session/title')).toBe(false)
|
||||
const inactiveError = await lifecycle.inactiveRefresh
|
||||
expect(inactiveError).toBeInstanceOf(Error)
|
||||
if (!(inactiveError instanceof Error)) throw new Error('expected inactive refresh to reject')
|
||||
expect(inactiveError.message).toBe('session-title service disposed')
|
||||
})
|
||||
|
||||
it('suppresses a queued fallback failure after service unload begins', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const session = startSession(ctx, 'service-unload-started-fallback')
|
||||
appendPrompt(session, 'Start fallback before unloading the service')
|
||||
|
||||
await Promise.resolve()
|
||||
await fiber.dispose()
|
||||
|
||||
expect(session.events.some(event => event.type === 'session/title')).toBe(false)
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aborts pending and active provider work and drains ignored cancellation during service unload', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const result = deferred<SessionTitleProviderResult>()
|
||||
const requests: SessionTitleProviderRequest[] = []
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('service-unload'),
|
||||
automatic: 'all-user-messages',
|
||||
generate(request) {
|
||||
requests.push(request)
|
||||
return result.promise
|
||||
},
|
||||
})
|
||||
const active = startSession(ctx, 'service-unload-active')
|
||||
const activeMessage = appendPrompt(active, 'Active provider work')
|
||||
await settle()
|
||||
const refresh = ctx.sessionTitle.refresh(active)
|
||||
const refreshOutcome = refresh.then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
)
|
||||
await settle()
|
||||
expect(requests).toHaveLength(1)
|
||||
const pending = startSession(ctx, 'service-unload-pending')
|
||||
appendPrompt(pending, 'Pending provider work')
|
||||
|
||||
const disposal = fiber.dispose()
|
||||
let disposed = false
|
||||
void disposal.then(() => { disposed = true })
|
||||
await settle()
|
||||
expect(requests[0]?.signal.aborted).toBe(true)
|
||||
expect(disposed).toBe(false)
|
||||
result.resolve({ title: 'Ignored service abort', messageSeqs: [activeMessage.seq] })
|
||||
await disposal
|
||||
|
||||
expect(disposed).toBe(true)
|
||||
await expect(refreshOutcome).resolves.toEqual(expect.objectContaining({ message: 'session-title service disposed' }))
|
||||
})
|
||||
|
||||
it('warns when a detached session prevents queued fallback publication', async () => {
|
||||
const ctx = await setup()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const session = ctx.sessions.prepare(SessionId('fallback-detach'))
|
||||
const detach = ctx.sessions.enter(session)
|
||||
ctx.sessions.announce(session)
|
||||
ctx.on('session/event', (subject, event) => {
|
||||
if (subject === session && event.type === 'user/message') detach()
|
||||
})
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
appendPrompt(session, 'Detach before the fallback microtask')
|
||||
await settle()
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('fallback title update failed'))
|
||||
expect(ctx.sessionTitle.get(session)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves a title absent when the byte cap cannot hold the first code point', async () => {
|
||||
const ctx = await setup({ fallbackMaxWords: 5, fallbackMaxBytes: 1, maxTitleBytes: 2 })
|
||||
const session = startSession(ctx, 'no-code-point')
|
||||
appendPrompt(session, '😀')
|
||||
await settle()
|
||||
expect(ctx.sessionTitle.get(session)).toBeUndefined()
|
||||
await expect(ctx.sessionTitle.refresh(session)).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionTitleService provider validation and stale scheduling', () => {
|
||||
it('rejects malformed provider registrations before publishing them', async () => {
|
||||
const ctx = await setup()
|
||||
const generate = async (): Promise<SessionTitleProviderResult> => ({ title: 'title', messageSeqs: [0] })
|
||||
expect(() => ctx.sessionTitle.register(null as never)).toThrow(/must be an object/)
|
||||
expect(() => ctx.sessionTitle.register('provider' as never)).toThrow(/must be an object/)
|
||||
expect(() => ctx.sessionTitle.register({
|
||||
id: 1,
|
||||
automatic: 'first-message',
|
||||
generate,
|
||||
} as unknown as SessionTitleProvider)).toThrow(/id must be a non-empty string/)
|
||||
expect(() => ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId(''),
|
||||
automatic: 'first-message',
|
||||
generate,
|
||||
})).toThrow(/id must be a non-empty string/)
|
||||
expect(() => ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('bad-mode'),
|
||||
automatic: 'sometimes' as never,
|
||||
generate,
|
||||
})).toThrow(/automatic mode is invalid/)
|
||||
expect(() => ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('missing-generate'),
|
||||
automatic: 'first-message',
|
||||
generate: undefined,
|
||||
} as unknown as SessionTitleProvider)).toThrow(/requires generate/)
|
||||
})
|
||||
|
||||
it('drops automatic work when its provider is disposed before the queued start', async () => {
|
||||
const ctx = await setup()
|
||||
const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult> => ({
|
||||
title: 'too late',
|
||||
messageSeqs: [request.messages[0]!.seq],
|
||||
}))
|
||||
const dispose = ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('queued-dispose'),
|
||||
automatic: 'all-user-messages',
|
||||
generate,
|
||||
})
|
||||
const session = startSession(ctx, 'queued-dispose')
|
||||
appendPrompt(session, 'Queue provider work')
|
||||
await settle()
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'main', model: 'main' } },
|
||||
reason: 'initial',
|
||||
})
|
||||
const pending = startSession(ctx, 'pending-provider-dispose')
|
||||
appendPrompt(pending, 'Drop pending provider work')
|
||||
await dispose()
|
||||
await settle()
|
||||
expect(generate).not.toHaveBeenCalled()
|
||||
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
|
||||
expect(ctx.sessionTitle.get(pending)?.source.kind).toBe('fallback')
|
||||
})
|
||||
|
||||
it('rejects malformed provider results without replacing the fallback', async () => {
|
||||
const ctx = await setup()
|
||||
let result: unknown
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('invalid-results'),
|
||||
automatic: 'first-message',
|
||||
generate: async () => result as SessionTitleProviderResult,
|
||||
})
|
||||
const session = startSession(ctx, 'invalid-results')
|
||||
const first = appendPrompt(session, 'First source')
|
||||
await settle()
|
||||
const second = appendPrompt(session, 'Second source')
|
||||
await settle()
|
||||
|
||||
const cases: Array<{ value: unknown; error: RegExp }> = [
|
||||
{ value: null, error: /invalid result/ },
|
||||
{ value: 1, error: /invalid result/ },
|
||||
{ value: { title: 1, messageSeqs: [first.seq] }, error: /title must be a string/ },
|
||||
{ value: { title: '\u001B[31m', messageSeqs: [first.seq] }, error: /empty title/ },
|
||||
{ value: { title: 'valid', messageSeqs: undefined }, error: /at least one source message/ },
|
||||
{ value: { title: 'valid', messageSeqs: [] }, error: /at least one source message/ },
|
||||
{ value: { title: 'valid', messageSeqs: ['not-a-seq'] }, error: /unique, ordered seqs/ },
|
||||
{ value: { title: 'valid', messageSeqs: [1.5] }, error: /unique, ordered seqs/ },
|
||||
{ value: { title: 'valid', messageSeqs: [-1] }, error: /unique, ordered seqs/ },
|
||||
{ value: { title: 'valid', messageSeqs: [999] }, error: /unique, ordered seqs/ },
|
||||
{ value: { title: 'valid', messageSeqs: [first.seq, first.seq] }, error: /unique, ordered seqs/ },
|
||||
{ value: { title: 'valid', messageSeqs: [second.seq, first.seq] }, error: /unique, ordered seqs/ },
|
||||
{ value: { title: 'valid', messageSeqs: [first.seq], model: null }, error: /model provenance/ },
|
||||
{ value: { title: 'valid', messageSeqs: [first.seq], model: 'route' }, error: /model provenance/ },
|
||||
{ value: { title: 'valid', messageSeqs: [first.seq], model: { provider: 1, model: 'm' } }, error: /model provenance/ },
|
||||
{ value: { title: 'valid', messageSeqs: [first.seq], model: { provider: '', model: 'm' } }, error: /model provenance/ },
|
||||
{ value: { title: 'valid', messageSeqs: [first.seq], model: { provider: 'p', model: 1 } }, error: /model provenance/ },
|
||||
{ value: { title: 'valid', messageSeqs: [first.seq], model: { provider: 'p', model: '' } }, error: /model provenance/ },
|
||||
]
|
||||
for (const item of cases) {
|
||||
result = item.value
|
||||
await expect(ctx.sessionTitle.refresh(session)).rejects.toThrow(item.error)
|
||||
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
|
||||
}
|
||||
})
|
||||
})
|
||||
162
packages/session/session-title/tests/session-title.spec.ts
Normal file
162
packages/session/session-title/tests/session-title.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionTitleService, {
|
||||
SessionTitleProviderId,
|
||||
fallbackSessionTitle,
|
||||
foldSessionTitle,
|
||||
normalizeSessionTitle,
|
||||
truncateTitleUtf8,
|
||||
} from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
const CONFIG = {
|
||||
fallbackMaxWords: 5,
|
||||
fallbackMaxBytes: 40,
|
||||
maxTitleBytes: 80,
|
||||
} as const
|
||||
|
||||
async function settleTitles(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe('session title normalization', () => {
|
||||
it('removes terminal controls, collapses whitespace, and applies word and UTF-8 byte caps', () => {
|
||||
expect(normalizeSessionTitle('\u001B]0;stolen\u0007 Hello\t brave\nnew world ', 80))
|
||||
.toBe('Hello brave new world')
|
||||
expect(fallbackSessionTitle('one two three four', 3, 80)).toBe('one two three')
|
||||
expect(fallbackSessionTitle('你好世界', 5, 7)).toBe('你好')
|
||||
expect(Buffer.byteLength(fallbackSessionTitle('😀😀', 5, 5), 'utf8')).toBe(4)
|
||||
})
|
||||
|
||||
it('rejects non-positive and fractional public limits', () => {
|
||||
expect(() => truncateTitleUtf8('title', 0)).toThrow(/maxBytes must be a positive integer/)
|
||||
expect(() => fallbackSessionTitle('title', 1.5, 10)).toThrow(/maxWords must be a positive integer/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionTitleService', () => {
|
||||
it('logs and folds an immediate fallback after the first eligible human text message', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const session = ctx.sessions.create(SessionId('fresh'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
const message = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: ' Build\nlog-backed session titles please ' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
await settleTitles()
|
||||
|
||||
const titleEvent = session.events.findLast(event => event.type === 'session/title')
|
||||
expect(titleEvent).toMatchObject({
|
||||
type: 'session/title',
|
||||
seq: 2,
|
||||
data: {
|
||||
title: 'Build log-backed session titles please',
|
||||
messageSeqs: [message.seq],
|
||||
source: { kind: 'fallback' },
|
||||
},
|
||||
})
|
||||
expect(ctx.sessionTitle.get(session)).toEqual({
|
||||
title: 'Build log-backed session titles please',
|
||||
messageSeqs: [message.seq],
|
||||
source: { kind: 'fallback' },
|
||||
eventSeq: 2,
|
||||
updatedAt: titleEvent?.time,
|
||||
})
|
||||
expect(session.deriveMessages()).toHaveLength(1)
|
||||
expect(session.surface.nodes).toEqual([message.seq])
|
||||
})
|
||||
|
||||
it('derives a fallback title from the direct prompt instead of baked prefix context', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const session = ctx.sessions.create(SessionId('prefixed-title'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Explain this referenced session' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
await settleTitles()
|
||||
|
||||
expect(ctx.sessionTitle.get(session)?.title).toBe('Explain this referenced session')
|
||||
})
|
||||
|
||||
it('waits through synthetic, empty, and non-text messages, then keeps the first fallback', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const session = ctx.sessions.create(SessionId('eligibility'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'plugin text' }],
|
||||
source: { kind: 'plugin', plugin: 'seed' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'reasoning', text: 'not visible text' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: ' \n\t ' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
await settleTitles()
|
||||
expect(ctx.sessionTitle.get(session)).toBeUndefined()
|
||||
|
||||
const eligible = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'first real prompt' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
await settleTitles()
|
||||
const first = ctx.sessionTitle.get(session)
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'later prompt' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
await settleTitles()
|
||||
|
||||
expect(first?.messageSeqs).toEqual([eligible.seq])
|
||||
expect(ctx.sessionTitle.get(session)).toEqual(first)
|
||||
expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('folds the latest title event during replay', () => {
|
||||
const seed = Session.create(SessionId('source'))
|
||||
seed.append('session/title', {
|
||||
title: 'Earlier',
|
||||
messageSeqs: [1],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
seed.append('session/title', {
|
||||
title: 'Later',
|
||||
messageSeqs: [1, 4],
|
||||
source: {
|
||||
kind: 'provider',
|
||||
provider: SessionTitleProviderId('test-provider'),
|
||||
model: { provider: 'mock', model: 'title-model' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(foldSessionTitle(seed.events)).toEqual({
|
||||
title: 'Later',
|
||||
messageSeqs: [1, 4],
|
||||
source: {
|
||||
kind: 'provider',
|
||||
provider: SessionTitleProviderId('test-provider'),
|
||||
model: { provider: 'mock', model: 'title-model' },
|
||||
},
|
||||
eventSeq: 1,
|
||||
updatedAt: seed.events[1]?.time,
|
||||
})
|
||||
})
|
||||
})
|
||||
36
packages/session/session-title/tsconfig.json
Normal file
36
packages/session/session-title/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user