mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 就解钉回自动标题)、搜索和列表索引不属于此服务。
|
||||
- 提供方注册表有意最多接受一个实现,因此部署若要组合相互竞争的标题策略,必须编写一个自行负责优先级的提供方。
|
||||
|
||||
@@ -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)
|
||||
|
||||
142
packages/session-title/session-title/tests/rename.spec.ts
Normal file
142
packages/session-title/session-title/tests/rename.spec.ts
Normal 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, ' [31m ')).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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user