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

@@ -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)