fix(session-title): typed rename rejection, provenance invariant, contract docs

SessionTitleInvalidError narrows the one rename failure that blames the
input; the fallback-unpin append extracts to appendFallback beside
ensureFallback's guarded twin; a deferred-provider test proves rename
supersedes ACTIVE generation; the invariant companion enforces
messageSeqs-empty iff user-source on every appended session/title event
(tsconfig gains the session-title invariant path); SessionTitleEventData
field docs state the third source kind and the empty-seqs rule, mirrored
into the bilingual core-data-structures page; the note qualifies the
refresh unpin as conditional on a derivable replacement.
This commit is contained in:
imccyu
2026-07-29 20:10:42 +08:00
parent a6eba044b2
commit c8374e916f
11 changed files with 151 additions and 30 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: 8d429ad93dbe348700696737dd14a71fd3a97c05
2026-07-21-log-backed-session-titles.zh.md: b8f59d77cc2e9a09f2638849f8015bf95b92fb4f
2026-07-21-log-backed-session-titles.md: 81ac687c6f55dd0ca1eaeb9d84c811edcfe17b5c
2026-07-21-log-backed-session-titles.zh.md: b0c7e9d76a1b9365fa16dcb223b390b5aec3e174

View File

@@ -38,7 +38,7 @@ Automatic provider failures are nonfatal warnings and retain the latest title. E
### 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.
`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 appends a provider or fallback event over the pinned one whenever a replacement title is derivable (an underivable fallback, e.g. under a tiny byte cap, leaves the pin standing). 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

View File

@@ -38,7 +38,7 @@ Status: implemented
### 显式重命名
`rename(session, title)` 同步接受用户标题:按已接受标题的字节上限规范化文本、取代在途自动工作,并追加一条第三种来源 `user``session/title` 事件。最新标题来源为 user 即钉住该会话:只要它还在,`onUserMessage` 在任一节奏下都不再安排自动修订。显式 `refresh()` 仍是有意的解钉手段——它预留一个修订号,并在被钉住的标题之上追加提供方或回退事件。Web host 将其暴露为 `session.rename` unary 方法(冷会话先恢复),并返回规范化后的标题及其事件 seq使 client 在推送帧到达前就结算自己的 `title` 投影格。
`rename(session, title)` 同步接受用户标题:按已接受标题的字节上限规范化文本、取代在途自动工作,并追加一条第三种来源 `user``session/title` 事件。最新标题来源为 user 即钉住该会话:只要它还在,`onUserMessage` 在任一节奏下都不再安排自动修订。显式 `refresh()` 仍是有意的解钉手段——只要能推导出替代标题,它就在被钉住的标题之上追加提供方或回退事件(推导不出回退标题时,例如字节上限过小,钉住状态保持不变)。Web host 将其暴露为 `session.rename` unary 方法(冷会话先恢复),并返回规范化后的标题及其事件 seq使 client 在推送帧到达前就结算自己的 `title` 投影格。
### Fork 与消费方

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: 0857f5255be616d00ea1f49fdfd97cffda1fd4b2
session-title.zh.md: 75fa42bfbdec18afc20ca59e2c02631e01cfd994
session-title.md: fff1aa1f6be45d0cfc4d7f6a9527ccb93561618f
session-title.zh.md: 73821b07c6be40d10d0961dd79b7c06bcadb7d0b

View File

@@ -45,9 +45,9 @@ type SessionTitleSource =
interface SessionTitleEventData {
/** Normalized non-empty title text. */
readonly title: string
/** Exact human `user/message` seqs used to derive this title. */
/** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */
readonly messageSeqs: number[]
/** Built-in fallback or registered-provider provenance. */
/** Built-in fallback, registered-provider, or explicit-user provenance. */
readonly source: SessionTitleSource
}
```

View File

@@ -45,9 +45,9 @@ type SessionTitleSource =
interface SessionTitleEventData {
/** Normalized non-empty title text. */
readonly title: string
/** Exact human `user/message` seqs used to derive this title. */
/** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */
readonly messageSeqs: number[]
/** Built-in fallback or registered-provider provenance. */
/** Built-in fallback, registered-provider, or explicit-user provenance. */
readonly source: SessionTitleSource
}
```

View File

@@ -61,9 +61,9 @@ export type SessionTitleSource =
export interface SessionTitleEventData {
/** Normalized non-empty title text. */
readonly title: string
/** Exact human `user/message` seqs used to derive this title. */
/** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */
readonly messageSeqs: number[]
/** Built-in fallback or registered-provider provenance. */
/** Built-in fallback, registered-provider, or explicit-user provenance. */
readonly source: SessionTitleSource
}
@@ -101,6 +101,16 @@ declare module '@deepseek-ai/dsh-session' {
}
}
/**
* 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. */
@@ -347,7 +357,8 @@ export class SessionTitleService extends Service {
* @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.
* @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()
@@ -356,7 +367,7 @@ export class SessionTitleService extends Service {
}
const normalized = normalizeSessionTitle(title, this.config.maxTitleBytes)
if (normalized.length === 0) {
throw new Error('session title must contain visible characters')
throw new SessionTitleInvalidError('session title must contain visible characters')
}
const state = this.stateFor(session)
this.supersede(state, 'user rename superseded automatic title generation')
@@ -394,14 +405,7 @@ export class SessionTitleService extends Service {
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' },
})
}
this.appendFallback(session, first)
signal?.throwIfAborted()
return this.get(session)
}
@@ -730,6 +734,23 @@ export class SessionTitleService extends Service {
}
}
/**
* 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()

View File

@@ -5,7 +5,8 @@
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
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'
@@ -15,11 +16,26 @@ export const name = 'session-title-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: the service validates provider revisions before their
* title append, and its remaining lifecycle state is process-local and covered
* by package tests.
* 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 = () => {}
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.

View File

@@ -0,0 +1,44 @@
// Title-provenance invariant: messageSeqs is empty iff source.kind is 'user'
// — the durable relationship every appended session/title event must keep.
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import * as SessionTitleInvariantCompanion from '@deepseek-ai/dsh-session-title/invariant'
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(SessionTitleInvariantCompanion)
return ctx
}
describe('session-title provenance invariant', () => {
it('accepts cited automatic titles and citation-free user renames', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('title-invariant-valid'))
expect(() => {
session.append('session/title', { title: 'auto', messageSeqs: [1], source: { kind: 'fallback' } })
session.append('session/title', { title: 'named', messageSeqs: [], source: { kind: 'user' } })
}).not.toThrow()
})
it('rejects a citation-free automatic title and a user rename that cites messages', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('title-invariant-invalid'))
expect(() => {
session.append('session/title', { title: 'auto', messageSeqs: [], source: { kind: 'fallback' } })
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
code: 'INVARIANT',
packageName: '@deepseek-ai/dsh-session-title',
}))
expect(() => {
session.append('session/title', { title: 'named', messageSeqs: [1], source: { kind: 'user' } })
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
code: 'INVARIANT',
packageName: '@deepseek-ai/dsh-session-title',
}))
expect(session.seq).toBe(0)
})
})

View File

@@ -29,7 +29,7 @@ function appendHumanPrompt(session: ReturnType<Context['sessions']['create']>, t
}
describe('SessionTitleService.rename', () => {
it('appends a normalized user-source title and supersedes automatic work', async () => {
it('appends a normalized user-source title', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
@@ -118,11 +118,50 @@ describe('SessionTitleService.rename', () => {
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).
// 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, trigger: { kind: 'message', source: { kind: 'user' } } })
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)

View File

@@ -86,6 +86,7 @@
"./packages/hooks/*/src/invariant.ts",
"./packages/session-persistence/*/src/invariant.ts",
"./packages/session-projection/*/src/invariant.ts",
"./packages/session-title/*/src/invariant.ts",
"./packages/session-query/*/src/invariant.ts",
"./packages/telemetry/*/src/invariant.ts",
"./packages/acp/*/src/invariant.ts",