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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 与消费方
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user