Просмотр исходного кода

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.
imccyu 1 месяц назад
Родитель
Сommit
c8374e916f

+ 2 - 2
.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml

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

+ 1 - 1
.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md

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

+ 1 - 1
.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md

@@ -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 - 2
docs/core-data-structures/session-title.i18n.yaml

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

+ 2 - 2
docs/core-data-structures/session-title.md

@@ -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
 }
 ```

+ 2 - 2
docs/core-data-structures/session-title.zh.md

@@ -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
 }
 ```

+ 33 - 12
packages/session-title/session-title/src/index.ts

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

+ 21 - 5
packages/session-title/session-title/src/invariant.ts

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

+ 44 - 0
packages/session-title/session-title/tests/invariant.spec.ts

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

+ 42 - 3
packages/session-title/session-title/tests/rename.spec.ts

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

+ 1 - 0
tsconfig.base.json

@@ -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",