Prechádzať zdrojové kódy

feat: title projection unit in dsh-session-title (key 'title', last-wins over session/title)

imccyu 2 mesiacov pred
rodič
commit
1097330df2

+ 4 - 1
packages/session-title/session-title/package.json

@@ -31,10 +31,12 @@
     "@deepseek-ai/dsh-invariants": "^0.0.1",
     "@deepseek-ai/dsh-llm": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
+    "@deepseek-ai/dsh-session-projection": "^0.0.1",
     "cordis": "^4.0.0-rc.7"
   },
   "dependencies": {
-    "schemastery": "^3.18.0"
+    "schemastery": "^3.18.0",
+    "zod": "^4.4.3"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-brand": "workspace:^",
@@ -43,6 +45,7 @@
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
+    "@deepseek-ai/dsh-session-projection": "workspace:^",
     "cordis": "^4.0.0-rc.7"
   }
 }

+ 29 - 0
packages/session-title/session-title/src/index.ts

@@ -5,6 +5,7 @@
 
 import { Context, FiberState, Service, type Fiber } from 'cordis'
 import z from 'schemastery'
+import { z as zod } from 'zod'
 import type { Branded } from '@deepseek-ai/dsh-brand'
 import { deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
 import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
@@ -14,6 +15,8 @@ import type {
   SessionEvent,
   SessionEventMap,
 } from '@deepseek-ai/dsh-session'
+// Type-only: resolves ctx.sessionProjections for the optional unit child.
+import type {} from '@deepseek-ai/dsh-session-projection'
 import { fallbackSessionTitle, normalizeSessionTitle } from './normalize.ts'
 
 export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from './normalize.ts'
@@ -100,6 +103,17 @@ declare module '@deepseek-ai/dsh-session' {
   }
 }
 
+declare module '@deepseek-ai/dsh-session-projection/types' {
+  interface SessionProjectionMap {
+    /**
+     * The session's current normalized title — the latest `session/title`
+     * event's text (last-wins), or `null` before the first title lands. A
+     * plain string: the shape the client list rows consume.
+     */
+    title: string | null
+  }
+}
+
 /** Per-session settlement tails for title-capability out-of-band writes. */
 const SESSION_TITLE_WRITE_TAILS = new WeakMap<Session, Promise<void>>()
 
@@ -323,6 +337,21 @@ export class SessionTitleService extends Service {
       this.work.clear()
     }, 'sessionTitle lifecycle')
 
+    // The title projection unit: pure last-wins fold of session/title events
+    // (the same events foldSessionTitle consumes), serving the plain title
+    // string clients list rows read. The unit child activates only when a
+    // projection registry is composed (headless assemblies stay unaffected).
+    ctx.inject(['sessionProjections'], (projectionCtx) => {
+      projectionCtx.sessionProjections.register<'title', string | null>({
+        key: 'title',
+        schema: zod.union([zod.string().min(1), zod.null()]),
+        init: () => null,
+        apply: (state, event) => (event.type === 'session/title' ? event.data.title : state),
+        view: state => state,
+        stateVersion: 1,
+      })
+    })
+
     ctx.on('session/event', (session, event) => {
       switch (event.type) {
         case 'user/message':

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

@@ -0,0 +1,76 @@
+/**
+ * The `title` projection unit: mounting the title service beside the
+ * projection registry serves the current normalized title (last-wins over
+ * session/title events, the same events foldSessionTitle consumes) — null
+ * before the first title — through the registry snapshot and the change
+ * feed; compositions without the registry are unaffected; unmounting the
+ * service removes the key (HMR safety). The bespoke session/title mux frame
+ * is untouched by this unit (its retirement is the client value-store
+ * migration's concern).
+ */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import type { Session } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import SessionTitleService from '@deepseek-ai/dsh-session-title'
+
+const CONFIG = { fallbackMaxWords: 8, fallbackMaxBytes: 64, maxTitleBytes: 256 }
+
+async function harness(withTitleService: boolean): Promise<{ ctx: Context; session: Session }> {
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SessionProjectionRegistry)
+  if (withTitleService) await ctx.plugin(SessionTitleService, CONFIG)
+  return { ctx, session: ctx.sessions.create(SessionId('titled')) }
+}
+
+/** Append one session/title event directly (the replay-plane shape the unit folds). */
+function appendTitle(session: Session, title: string): number {
+  return session.append('session/title', { title, messageSeqs: [1], source: { kind: 'fallback' } }).seq
+}
+
+describe('title projection unit', () => {
+  it('serves null before the first title event', async () => {
+    const { ctx, session } = await harness(true)
+    const snapshot = ctx.sessionProjections.snapshot(session)
+    expect(snapshot.values.title).toBeNull()
+  })
+
+  it('serves the latest title last-wins and notifies the change feed with the causing seq', async () => {
+    const { ctx, session } = await harness(true)
+    const changes: { key: string; value: unknown; seq: number }[] = []
+    ctx.sessionProjections.onChanged((_session, key, value, seq) => {
+      changes.push({ key, value, seq })
+    })
+    const firstSeq = appendTitle(session, 'First title')
+    const secondSeq = appendTitle(session, 'Second title')
+    // Unrelated event: same-reference apply, no notification.
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    expect(changes).toEqual([
+      { key: 'title', value: 'First title', seq: firstSeq },
+      { key: 'title', value: 'Second title', seq: secondSeq },
+    ])
+    const snapshot = ctx.sessionProjections.snapshot(session)
+    expect(snapshot.values.title).toBe('Second title')
+    expect(snapshot.asOfSeq).toBe(session.seq - 1)
+  })
+
+  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')
+    await ctx.plugin(SessionTitleService, CONFIG)
+    expect(ctx.sessionProjections.snapshot(session).values.title).toBe('Pre-mount title')
+  })
+
+  it('has no title key without the title service, and drops it when the service unloads (HMR safety)', async () => {
+    const { ctx, session } = await harness(false)
+    expect('title' in ctx.sessionProjections.snapshot(session).values).toBe(false)
+    const fiber = await ctx.plugin(SessionTitleService, CONFIG)
+    appendTitle(session, 'Ephemeral')
+    expect(ctx.sessionProjections.snapshot(session).values.title).toBe('Ephemeral')
+    await fiber.dispose()
+    expect('title' in ctx.sessionProjections.snapshot(session).values).toBe(false)
+  })
+})

+ 3 - 0
packages/session-title/session-title/tsconfig.json

@@ -28,6 +28,9 @@
     },
     {
       "path": "../../core/session"
+    },
+    {
+      "path": "../../session-projection/session-projection"
     }
   ]
 }

+ 6 - 0
pnpm-lock.yaml

@@ -3454,6 +3454,9 @@ importers:
       schemastery:
         specifier: ^3.18.0
         version: 3.18.0
+      zod:
+        specifier: ^4.4.3
+        version: 4.4.3
     devDependencies:
       '@deepseek-ai/dsh-brand':
         specifier: workspace:^
@@ -3473,6 +3476,9 @@ importers:
       '@deepseek-ai/dsh-session-persistence-sqlite':
         specifier: workspace:^
         version: link:../../session-persistence/session-persistence-sqlite
+      '@deepseek-ai/dsh-session-projection':
+        specifier: workspace:^
+        version: link:../../session-projection/session-projection
       cordis:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)