feat(session-title): user rename pins the title against automatic generation

sessionTitle.rename appends a user-source session/title event; onUserMessage
skips scheduling while a user title stands, and an explicit refresh is the
deliberate unpin (provider regeneration, or a re-derived fallback when no
provider is registered).
This commit is contained in:
imccyu
2026-07-29 18:59:09 +08:00
parent 87c9ab06b3
commit abbcacf42a
15 changed files with 263 additions and 26 deletions

View File

@@ -2,5 +2,5 @@
# 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 .agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md
2026-07-21-log-backed-session-titles.md: 1bd58e35ec625fb0b04c0c119ce425ff30a64881
2026-07-21-log-backed-session-titles.zh.md: 37ec95efbca334f71d19d2bc3e18c22d50d9b5fb
2026-07-21-log-backed-session-titles.md: 8d429ad93dbe348700696737dd14a71fd3a97c05
2026-07-21-log-backed-session-titles.zh.md: b8f59d77cc2e9a09f2638849f8015bf95b92fb4f

View File

@@ -36,9 +36,13 @@ Model providers require explicit word, CJK-character, input-byte, output-token,
Automatic provider failures are nonfatal warnings and retain the latest title. Explicit refresh failures reject to the caller. Output must be non-empty text with unique ordered seqs drawn from the fixed request; the service normalizes and byte-limits it before log acceptance.
### Explicit rename
`rename(session, title)` accepts a user title synchronously: it normalizes the text under the accepted-title byte limit, supersedes in-flight automatic work, and appends a `session/title` event with the third source kind, `user`. A user-sourced latest title pins the session: `onUserMessage` schedules no automatic revision while it stands, under either cadence. An explicit `refresh()` remains the deliberate unpin — it reserves a revision and appends a provider or fallback event over the pinned one. The Web host exposes this as the `session.rename` unary method (resuming cold sessions first) and returns the normalized title plus its event seq so the client settles its `title` projection cell before the push frame arrives.
### Forks and consumers
A fork inherits seed title events unchanged, like the rest of its source log. The first-message provider does not automatically retitle a fork. The all-messages provider may append a child-owned revision after a later child prompt, using inherited and new eligible messages.
A fork inherits seed title events unchanged, like the rest of its source log — a pinned (user-sourced) title stays pinned in the child until an explicit refresh. The first-message provider does not automatically retitle a fork. The all-messages provider may append a child-owned revision after a later child prompt, using inherited and new eligible messages.
`ctx.sessionQuery.readTitle()` folds one live-preferred or persisted log without loading titles during `listSessions()`. The TUI uses the latest title as its header subtitle and sets the terminal window title to `<session title> — <configured product title>` after terminal-safe rendering. The Web host folds the same log state into a validated mux control frame after each attached-session subscription baseline and immediately after forwarding a live raw title event. The browser retains only newer title event seqs even when the control frame precedes list or session-instance creation; sidebar labels, search, breadcrumbs, and the browser title then react to the projected revision. `session.list` remains metadata-only, so a cold persisted session uses the cwd basename or id until opening or resuming it attaches the log. The browser title uses `<session title> — <existing HTML title>` only for a selected titled session and otherwise preserves the product title. Consumers reporting agent completion use the core `findLastMessageTurnEnd()` fold, so a later between-turn title record cannot replace the preceding message-triggered outcome.
@@ -59,4 +63,4 @@ A fork inherits seed title events unchanged, like the rest of its source log. Th
- A fallback appears immediately. Each fresh Web session adds one first-message auxiliary call; other compositions choose whether better titles justify model cost and whether later prompts should retitle a session.
- Auxiliary request records and late accepted titles consume event seqs without consuming turn numbers, so persistence exposes both attempted dispatches and accepted updates even though model history and KV-cache identity do not change.
- One provider and monotonic per-session revisions make disposal, supersession, and stale-result rejection explicit, at the cost of leaving multi-strategy precedence to a composite provider.
- Manual rename, deletion, generated-versus-user precedence, search, and list indexing remain outside the capability.
- Deletion (unpinning without an explicit refresh), search, and list indexing remain outside the capability.

View File

@@ -36,9 +36,13 @@ Status: implemented
自动提供方故障只会发出非致命警告,并保留最新标题。显式刷新失败则会向调用方返回拒绝。输出必须是非空文本,并包含来自固定请求、唯一且有序的 seq服务会在日志接受前对其进行规范化并施加字节限制。
### 显式重命名
`rename(session, title)` 同步接受用户标题:按已接受标题的字节上限规范化文本、取代在途自动工作,并追加一条第三种来源 `user``session/title` 事件。最新标题来源为 user 即钉住该会话:只要它还在,`onUserMessage` 在任一节奏下都不再安排自动修订。显式 `refresh()` 仍是有意的解钉手段——它预留一个修订号并在被钉住的标题之上追加提供方或回退事件。Web host 将其暴露为 `session.rename` unary 方法(冷会话先恢复),并返回规范化后的标题及其事件 seq使 client 在推送帧到达前就结算自己的 `title` 投影格。
### Fork 与消费方
与源日志的其他部分相同fork 会原样继承作为种子的标题事件。首消息提供方不会自动为 fork 重新生成标题。全部消息提供方可以在子会话出现后续提示词后追加一项归子会话所有的修订,并使用继承的合格消息和新增的合格消息。
与源日志的其他部分相同fork 会原样继承作为种子的标题事件——被钉住user 来源)的标题在子会话中保持钉住,直到显式 refresh。首消息提供方不会自动为 fork 重新生成标题。全部消息提供方可以在子会话出现后续提示词后追加一项归子会话所有的修订,并使用继承的合格消息和新增的合格消息。
`ctx.sessionQuery.readTitle()` 会折叠一份实时优先或已持久化的日志,而不会在 `listSessions()` 期间加载标题。TUI 使用最新标题作为其标题栏副标题,并在完成终端安全渲染后,将终端窗口标题设置为 `<session title> — <configured product title>`。Web host 会在每个已附加会话的订阅基线之后,以及转发实时原始标题事件后立即,将同一份日志状态折叠为经过校验的 mux 控制帧。即使控制帧先于列表或会话实例创建抵达,浏览器也只保留标题事件 seq 较新的版本;侧边栏标签、搜索、面包屑和浏览器标题会随投影后的修订更新。`session.list` 仍只包含元数据,因此尚未打开的持久化会话会继续以 cwd 基名或 id 作为回退,直至打开或恢复会话时附加其日志。浏览器仅在选中已有标题的会话时将标题设置为 `<session title> — <existing HTML title>`,否则保留产品标题。报告 agent 完成情况的消费方使用核心的 `findLastMessageTurnEnd()` 折叠逻辑,因此后续的轮次间标题记录无法取代此前由消息触发的结果。
@@ -59,4 +63,4 @@ Status: implemented
- 回退标题会立即出现。每个新建的 Web 会话都会增加一次针对首消息的辅助调用;其他组合可以自行决定更优标题是否值得模型成本,以及后续提示词是否需要重新生成会话标题。
- 辅助请求记录和延迟接受的标题会占用事件 seq但不会占用轮次编号因此持久化会同时呈现尝试发起的调用与已接受的更新尽管模型历史和 KV 缓存标识保持不变。
- 单个提供方和每会话单调递增的修订号让释放、取代和陈旧结果拒绝行为明确可见,但多策略优先级必须由复合提供方负责。
- 手动重命名、删除、生成标题与用户标题的优先级、搜索和列表索引不在此功能范围内。
- 删除(不经显式 refresh 的解钉)、搜索和列表索引不在此功能范围内。

View File

@@ -1203,7 +1203,7 @@ export interface Config {
}
```
Source: [`packages/session-title/session-title/src/index.ts:75`](../packages/session-title/session-title/src/index.ts)
Source: [`packages/session-title/session-title/src/index.ts:79`](../packages/session-title/session-title/src/index.ts)
## `@deepseek-ai/dsh-session-title-all-messages-llm`

View File

@@ -1604,6 +1604,18 @@ Log-backed title fold plus asynchronous fallback generation.
*/
get(session: Session): SessionTitleSnapshot | undefined
/**
* 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 {Error} when the session is not live or the title normalizes to empty.
*/
rename(session: Session, title: string): SessionTitleSnapshot
/**
* Explicitly retry the registered provider, or materialize the built-in
* fallback when no provider is registered.
@@ -1624,7 +1636,7 @@ register(provider: SessionTitleProvider): () => Promise<void>
Types: [Session](../core-data-structures/session.md) · [SessionTitleProvider](../core-data-structures/session-title.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md)
Source: [`packages/session-title/session-title/src/index.ts:240`](../../packages/session-title/session-title/src/index.ts)
Source: [`packages/session-title/session-title/src/index.ts:244`](../../packages/session-title/session-title/src/index.ts)
## `ctx.skills` — `SkillService`

View File

@@ -2,5 +2,5 @@
# 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 docs/core-data-structures/session-title.md
session-title.md: 33efc911c0ca1ae94dc4ded74676e5c32a73bdd5
session-title.zh.md: a4b95a726d2bc89a13d14f1daa2f825cd5aa91b1
session-title.md: 0857f5255be616d00ea1f49fdfd97cffda1fd4b2
session-title.zh.md: 75fa42bfbdec18afc20ca59e2c02631e01cfd994

View File

@@ -34,6 +34,10 @@ type SessionTitleSource =
readonly provider: SessionTitleProviderId
readonly model?: SessionTitleModelProvenance
}
| {
/** Explicit user rename: pins the title — automatic generation stops scheduling. */
readonly kind: 'user'
}
```
```ts type-equiv

View File

@@ -34,6 +34,10 @@ type SessionTitleSource =
readonly provider: SessionTitleProviderId
readonly model?: SessionTitleModelProvenance
}
| {
/** Explicit user rename: pins the title — automatic generation stops scheduling. */
readonly kind: 'user'
}
```
```ts type-equiv

View File

@@ -412,7 +412,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
Types: [SessionTitleEventData](core-data-structures/session-title.md)
Source: [`packages/session-title/session-title/src/index.ts:96`](../packages/session-title/session-title/src/index.ts)
Source: [`packages/session-title/session-title/src/index.ts:100`](../packages/session-title/session-title/src/index.ts)
#### `session/title-llm-request` — log-only

View File

@@ -734,6 +734,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'get(session: Session): SessionTitleSnapshot | undefined',
jsDoc: '/**\n * Read the latest folded title from one live or replayed session.\n * @param session - session whose log is the title source of truth.\n * @returns latest title snapshot, or `undefined` before eligible input.\n */',
},
{
signature: 'rename(session: Session, title: string): SessionTitleSnapshot',
jsDoc: '/**\n * Accept an explicit user title. Appends a `session/title` event with the\n * `user` source, which pins the title: in-flight automatic generation is\n * superseded and later user messages schedule none (an explicit\n * {@link SessionTitleService.refresh} remains the deliberate unpin).\n * @param session - exact live session to rename.\n * @param title - raw user input; normalized before acceptance.\n * @returns the accepted title snapshot.\n * @throws {Error} when the session is not live or the title normalizes to empty.\n */',
},
{
signature: 'async refresh(session: Session, signal?: AbortSignal): Promise<SessionTitleSnapshot | undefined>',
jsDoc: '/**\n * Explicitly retry the registered provider, or materialize the built-in\n * fallback when no provider is registered.\n * @param session - exact live session to refresh.\n * @param signal - optional caller cancellation.\n * @returns latest accepted title, or `undefined` when no eligible text exists.\n */',
@@ -2325,7 +2329,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionTitleSource',
declaration: 'export type SessionTitleSource = {\n readonly kind: \'fallback\';\n} | {\n readonly kind: \'provider\';\n readonly provider: SessionTitleProviderId;\n readonly model?: SessionTitleModelProvenance;\n};',
declaration: 'export type SessionTitleSource = {\n readonly kind: \'fallback\';\n} | {\n readonly kind: \'provider\';\n readonly provider: SessionTitleProviderId;\n readonly model?: SessionTitleModelProvenance;\n} | {\n readonly kind: \'user\';\n};',
},
{
name: 'SessionTitleUserMessage',

View File

@@ -2,5 +2,5 @@
# 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: 1939d00f7e78834ec19e2d6b4590cf12af297a30
README.zh.md: d373c212a193a82567686a634bad79726185e832
README.md: 9a5ec27c36f3411add37ebe231262eb5d205bc9e
README.zh.md: f8bdf28ca3eaa7aca329d0f4a71f637daa316502

View File

@@ -10,6 +10,7 @@ Only text blocks from human `user/message` events are eligible. The first eligib
- `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.
@@ -50,5 +51,5 @@ None for the main request; title events do not change its reconstructed content
## Known Limitations and Deferred Work
- Manual rename, title deletion, generated-versus-user precedence, search, and list indexing are outside this service.
- Title deletion (unpinning back to automatic titles without an explicit `refresh`), search, and list indexing are outside this service.
- The provider registry deliberately accepts at most one implementation, so a deployment cannot compose competing title strategies without writing one provider that owns their precedence.

View File

@@ -10,6 +10,7 @@
- `get(session)` 从活跃或回放日志折叠最新已接受标题。
- `refresh(session, signal?)` 在需要时物化回退,然后显式运行已注册提供方,处理当前符合条件的消息。提供方错误与调用方取消会 reject取消不会回滚已接受的回退事件。
- `rename(session, title)` 同步接受用户显式标题:规范化文本、取代在途自动工作,并追加一条 `user` 来源的 `session/title` 事件。最新标题来源为 user 即钉住该会话——后续用户消息不再安排自动 revision显式 `refresh` 仍是有意的解钉手段。
- `register(provider)` 安装唯一可选提供方,并返回可等待的 Cordis effect disposer。第二次注册会立即抛出资源释放会中止待处理和活跃调用等待其结算之后才允许注册另一个提供方。
自动工作绝不会延迟主 agent智能体响应。只有当带标记、由循环构建的请求其确切路由与当前已记录的 `request/header` 匹配时,提供方才会启动;即使 header 未变而无需新快照,也适用此规则。延迟完成会直接通过 `Session` 追加一个独立的纯日志事件,而不打开轮次。持久化会尽快观察该事件,并在常规生命周期检查点排空;标题发布本身不会强制 flush。自动失败会发出警告并保留最新标题。新的全消息 revision、提供方资源释放、会话资源释放和显式刷新都会中止旧工作陈旧完成值无法追加。并发显式刷新会在提供方工作之前预留修订号重叠的自动显式回退请求共享一个会话本地进行中追加。服务与随附模型提供方各自追加自己的字面量事件类型因此不需要通用标题写入标记、类型断言或结算队列。服务 teardown 会取消排队工作,并在卸载完成前排空忽略取消的调用。
@@ -50,5 +51,5 @@ Fork 会原样继承 seed 中的标题事件。首消息节奏不会自动为子
## 已知限制与暂缓工作
- 手动重命名、删除标题、生成标题与用户标题的优先级、搜索和列表索引不属于此服务。
- 删除标题(不经显式 `refresh` 就解钉回自动标题)、搜索和列表索引不属于此服务。
- 提供方注册表有意最多接受一个实现,因此部署若要组合相互竞争的标题策略,必须编写一个自行负责优先级的提供方。

View File

@@ -7,7 +7,7 @@ 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 { deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import { assertNever, deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import type {
Session,
@@ -52,6 +52,10 @@ export type SessionTitleSource =
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 {
@@ -180,20 +184,26 @@ export function foldSessionTitle(events: readonly SessionEvent[]): SessionTitleS
return deepFreeze({
title: event.data.title,
messageSeqs: [...event.data.messageSeqs],
source: event.data.source.kind === 'fallback'
? { kind: 'fallback' }
: {
kind: 'provider',
provider: event.data.source.provider,
...(event.data.source.model === undefined
? {}
: { model: { ...event.data.source.model } }),
},
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' }
default: return assertNever(source, 'SessionTitleSource')
}
}
/** Service-owned resolved limits. */
interface ResolvedConfig {
readonly fallbackMaxWords: number
@@ -328,6 +338,38 @@ export class SessionTitleService extends Service {
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 {Error} when the session is not live or the title normalizes to empty.
*/
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 Error('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.
@@ -345,6 +387,23 @@ export class SessionTitleService extends Service {
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) {
const title = fallbackSessionTitle(first.text, this.config.fallbackMaxWords, this.config.fallbackMaxBytes)
if (title.length > 0) {
session.append('session/title', {
title,
messageSeqs: [first.seq],
source: { kind: 'fallback' },
})
}
signal?.throwIfAborted()
return this.get(session)
}
const fallback = await this.ensureFallback(session)
signal?.throwIfAborted()
return fallback
@@ -398,6 +457,8 @@ export class SessionTitleService extends Service {
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)

View File

@@ -0,0 +1,142 @@
// 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 and supersedes automatic work', 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, trigger: { kind: 'message', source: { kind: 'user' } } })
appendHumanPrompt(session, 'Original prompt text')
await settle()
const accepted = ctx.sessionTitle.rename(session, ' Hand\tpicked name ')
expect(accepted).toMatchObject({
title: 'Hand picked name',
messageSeqs: [],
source: { kind: 'user' },
})
const event = session.events.findLast(item => item.type === 'session/title')
expect(event?.data).toEqual({
title: 'Hand picked name',
messageSeqs: [],
source: { kind: 'user' },
})
// foldSessionTitle round-trips the third source kind.
expect(foldSessionTitle(session.events)?.source).toEqual({ kind: 'user' })
})
it('rejects titles that normalize to empty and dead sessions', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const session = ctx.sessions.create(SessionId('rename-reject'))
expect(() => ctx.sessionTitle.rename(session, '  ')).toThrow(/visible characters/)
expect(() => ctx.sessionTitle.rename(new Session(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, trigger: { kind: 'message', source: { kind: 'user' } } })
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, trigger: { kind: 'message', source: { kind: 'user' } } })
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 next user message schedules automatic work again
// (observable as a fresh fallback-source title remaining latest).
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
})
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, trigger: { kind: 'message', source: { kind: 'user' } } })
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')
})
})