Jelajahi Sumber

fix(session-title): preserve v1 title cache schema

_Kerman 4 minggu lalu
induk
melakukan
5c9ca1f25b

+ 2 - 2
docs/subsystems/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/subsystems/session-title.md
-session-title.md: 28c6580fcbde47cd3fb1b77d1412fc217af778ed
-session-title.zh.md: faf6590bfd5fcb2f12f0d97ab2dff15806698ce9
+session-title.md: 05d20c2d7b278dd0663e50d86f1cd22b64852e81
+session-title.zh.md: 2eaa296871bd90e1708f2cf27298b7bbc1efa2c0

+ 1 - 1
docs/subsystems/session-title.md

@@ -8,7 +8,7 @@ Sources: [`packages/session/session-title/src/index.ts`](../../packages/session/
 
 ## Durable title state
 
-`SessionTitleProviderId` is recorded for provider-produced revisions. `SessionTitleEventData` lists the exact human-message seqs used for the title, while `SessionTitleSnapshot` adds the durable event envelope facts retained in the `title` projection state. `ctx.sessionTitle.get()` reads that state through `stateOf()`; the client view remains only the title string or `null`. `foldSessionTitle()` provides the same selection for detached logs.
+`SessionTitleProviderId` is recorded for provider-produced revisions. `SessionTitleEventData` lists the exact human-message seqs used for the title, while `SessionTitleSnapshot` adds the durable event envelope facts returned by `ctx.sessionTitle.get()` and `foldSessionTitle()`. The `title` projection keeps its version-1 state and client view as only the title string or `null`, so existing persisted cache rows remain readable.
 
 ```ts type-equiv
 /** Identifies one session-title provider registration. */

+ 1 - 1
docs/subsystems/session-title.zh.md

@@ -8,7 +8,7 @@
 
 ## 持久标题状态
 
-提供方生成修订时会记录 `SessionTitleProviderId`。`SessionTitleEventData` 列出生成标题时使用的精确人类消息 seq,`SessionTitleSnapshot` 则加入 `title` 投影状态保留的持久事件封装信息。`ctx.sessionTitle.get()` 通过 `stateOf()` 读取该状态;客户端视图仍只包含标题字符串或 `null`。`foldSessionTitle()` 为脱离服务的日志提供相同选择。
+提供方生成修订时会记录 `SessionTitleProviderId`。`SessionTitleEventData` 列出生成标题时使用的精确人类消息 seq,`SessionTitleSnapshot` 则加入 `ctx.sessionTitle.get()` 与 `foldSessionTitle()` 返回的持久事件封装信息。`title` 投影的版本 1 状态与客户端视图都只保留标题字符串或 `null`,因此既有持久化缓存行仍可读取。
 
 ```ts type-equiv
 /** Identifies one session-title provider registration. */

+ 18 - 36
packages/session/session-title/src/index.ts

@@ -255,55 +255,38 @@ function collectSessionTitleMessages(
   return messages
 }
 
-// Zod cannot express the branded provider id without a runtime transform.
-const titleProjectionSchema = zod.object({
-  title: zod.string().min(1),
-  messageSeqs: zod.array(zod.number().int().nonnegative()),
-  source: zod.discriminatedUnion('kind', [
-    zod.object({ kind: zod.literal('fallback') }),
-    zod.object({
-      kind: zod.literal('provider'),
-      provider: zod.string(),
-      model: zod.object({ provider: zod.string(), model: zod.string() }).optional(),
-    }),
-    zod.object({ kind: zod.literal('user') }),
-  ]),
-  eventSeq: zod.number().int().nonnegative(),
-  updatedAt: zod.number(),
-}).nullable() as unknown as ZodType<TitleProjection | null>
-
 const titleViewSchema: ZodType<string | null> = zod.string().min(1).nullable()
 
-/** Latest logged title and its client view. */
+/** Latest logged title text and its client view. */
 export const titleProjectionDefinition = {
   key: 'title',
-  stateVersion: 2,
-  stateSchema: titleProjectionSchema,
+  stateVersion: 1,
+  stateSchema: titleViewSchema,
   init: () => null,
   apply: (state, event) => (event.type === 'session/title'
-    ? {
-      title: event.data.title,
-      messageSeqs: event.data.messageSeqs,
-      source: event.data.source,
-      eventSeq: event.seq,
-      updatedAt: event.time,
-    }
+    ? event.data.title
     : state),
   wire: {
     viewSchema: titleViewSchema,
-    view: state => state?.title ?? null,
+    view: state => state,
   },
-} satisfies ProjectionDefinition<'title', TitleProjection | null>
+} satisfies ProjectionDefinition<'title', string | null>
 
 /**
- * Fold the latest title from a session log.
+ * Fold the latest logged title without consulting mutable metadata.
  * @param events - live or persisted session log.
- * @returns the immutable latest title snapshot, or `undefined`.
+ * @returns the latest immutable title snapshot, or `undefined`.
  */
 export function foldSessionTitle(events: readonly SessionEvent[]): SessionTitleSnapshot | undefined {
-  let state: TitleProjection | null = titleProjectionDefinition.init()
-  for (const event of events) state = titleProjectionDefinition.apply(state, event)
-  return state === null ? undefined : titleSnapshotFromState(state)
+  const event = events.findLast(item => item.type === 'session/title')
+  if (event === undefined) return undefined
+  return titleSnapshotFromState({
+    title: event.data.title,
+    messageSeqs: event.data.messageSeqs,
+    source: event.data.source,
+    eventSeq: event.seq,
+    updatedAt: event.time,
+  })
 }
 
 /** Log-backed title fold plus asynchronous fallback generation. */
@@ -398,8 +381,7 @@ export class SessionTitleService extends Service {
    * @returns latest title snapshot, or `undefined` before eligible input.
    */
   get(session: Session): SessionTitleSnapshot | undefined {
-    const state = this.ctx.sessionProjections.stateOf(session, 'title') as TitleProjection | null
-    return state === null ? undefined : titleSnapshotFromState(state)
+    return foldSessionTitle(session.events)
   }
 
   /**

+ 2 - 2
packages/session/session-title/src/types.ts

@@ -77,8 +77,8 @@ export interface TitleInputState {
 
 declare module '@deepseek-ai/dsh-session-projection/types' {
   interface SessionProjectionStateMap {
-    /** Latest logged title, or null. */
-    title: TitleProjection | null
+    /** Latest logged title text, or null. */
+    title: string | null
     /** Eligible human title input. */
     titleInput: TitleInputState
   }

+ 9 - 0
packages/session/session-title/tests/projection.spec.ts

@@ -25,6 +25,7 @@ describe('title projection unit', () => {
     const { ctx, session } = await harness(true)
     const snapshot = ctx.sessionProjections.snapshot(session)
     expect(snapshot.values.title).toBeNull()
+    expect(ctx.sessionProjections.checkpoint(session).title).toEqual({ ver: 1, seq: -1, val: null })
   })
 
   it('serves the latest title last-wins and notifies the change feed with the causing seq', async () => {
@@ -45,6 +46,14 @@ describe('title projection unit', () => {
     expect(snapshot.asOfSeq).toBe(session.seq - 1)
   })
 
+  it('reads the version-1 string checkpoint format used by existing title caches', async () => {
+    const { ctx } = await harness(true)
+
+    expect(ctx.sessionProjections.viewCheckpoint({
+      title: { ver: 1, seq: 8, val: 'Cached title' },
+    })).toEqual({ title: 'Cached title' })
+  })
+
   it('folds titles already in the log when the service mounts late (lazy cell build)', async () => {
     const { ctx, session } = await harness(false)
     appendTitle(session, 'Pre-mount title')