From c8374e916f9ce710979ecdecb317aeee4abd7a94 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:10:42 +0800 Subject: [PATCH] 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. --- ...-07-21-log-backed-session-titles.i18n.yaml | 4 +- .../2026-07-21-log-backed-session-titles.md | 2 +- ...2026-07-21-log-backed-session-titles.zh.md | 2 +- .../session-title.i18n.yaml | 4 +- docs/core-data-structures/session-title.md | 4 +- docs/core-data-structures/session-title.zh.md | 4 +- .../session-title/session-title/src/index.ts | 45 ++++++++++++++----- .../session-title/src/invariant.ts | 26 ++++++++--- .../session-title/tests/invariant.spec.ts | 44 ++++++++++++++++++ .../session-title/tests/rename.spec.ts | 45 +++++++++++++++++-- tsconfig.base.json | 1 + 11 files changed, 151 insertions(+), 30 deletions(-) create mode 100644 packages/session-title/session-title/tests/invariant.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml index fd80dd4e09..029cc4e19c 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md index 8d429ad93d..81ac687c6f 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md index b8f59d77cc..b0c7e9d76a 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md +++ b/.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 与消费方 diff --git a/docs/core-data-structures/session-title.i18n.yaml b/docs/core-data-structures/session-title.i18n.yaml index e463ddd6bd..ab368c4c37 100644 --- a/docs/core-data-structures/session-title.i18n.yaml +++ b/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 diff --git a/docs/core-data-structures/session-title.md b/docs/core-data-structures/session-title.md index 0857f5255b..fff1aa1f6b 100644 --- a/docs/core-data-structures/session-title.md +++ b/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 } ``` diff --git a/docs/core-data-structures/session-title.zh.md b/docs/core-data-structures/session-title.zh.md index 75fa42bfbd..73821b07c6 100644 --- a/docs/core-data-structures/session-title.zh.md +++ b/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 } ``` diff --git a/packages/session-title/session-title/src/index.ts b/packages/session-title/session-title/src/index.ts index ac01591533..12da431c68 100644 --- a/packages/session-title/session-title/src/index.ts +++ b/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 { this.assertServiceActive() diff --git a/packages/session-title/session-title/src/invariant.ts b/packages/session-title/session-title/src/invariant.ts index 9bae01a72f..25b00a10aa 100644 --- a/packages/session-title/session-title/src/invariant.ts +++ b/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. diff --git a/packages/session-title/session-title/tests/invariant.spec.ts b/packages/session-title/session-title/tests/invariant.spec.ts new file mode 100644 index 0000000000..5b316448da --- /dev/null +++ b/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 { + 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>({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-session-title', + })) + expect(() => { + session.append('session/title', { title: 'named', messageSeqs: [1], source: { kind: 'user' } }) + }).toThrow(expect.objectContaining>({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-session-title', + })) + expect(session.seq).toBe(0) + }) +}) diff --git a/packages/session-title/session-title/tests/rename.spec.ts b/packages/session-title/session-title/tests/rename.spec.ts index bfb75394d0..01d613ee3e 100644 --- a/packages/session-title/session-title/tests/rename.spec.ts +++ b/packages/session-title/session-title/tests/rename.spec.ts @@ -29,7 +29,7 @@ function appendHumanPrompt(session: ReturnType, 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((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) diff --git a/tsconfig.base.json b/tsconfig.base.json index 00c19c4b8c..934d37e26e 100644 --- a/tsconfig.base.json +++ b/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",