Browse Source

feat(session-title): add fallback and model providers

Tianyi Cui 1 month ago
parent
commit
58dc5f94de
41 changed files with 2900 additions and 1 deletions
  1. 4 0
      knip.json
  2. 3 1
      packages/examples/agent-spine-demo/package.json
  3. 3 0
      packages/examples/agent-spine-demo/tsconfig.json
  4. 2 0
      packages/session-query/session-query/package.json
  5. 3 0
      packages/session-query/session-query/tsconfig.json
  6. 12 0
      packages/session-title/README.md
  7. 26 0
      packages/session-title/session-title-all-messages-llm/README.md
  8. 34 0
      packages/session-title/session-title-all-messages-llm/package.json
  9. 36 0
      packages/session-title/session-title-all-messages-llm/src/index.ts
  10. 73 0
      packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts
  11. 13 0
      packages/session-title/session-title-all-messages-llm/tsconfig.json
  12. 26 0
      packages/session-title/session-title-first-message-llm/README.md
  13. 37 0
      packages/session-title/session-title-first-message-llm/package.json
  14. 40 0
      packages/session-title/session-title-first-message-llm/src/index.ts
  15. 120 0
      packages/session-title/session-title-first-message-llm/tests/loader-composition.spec.ts
  16. 59 0
      packages/session-title/session-title-first-message-llm/tests/provider.e2e.ts
  17. 86 0
      packages/session-title/session-title-first-message-llm/tests/provider.spec.ts
  18. 13 0
      packages/session-title/session-title-first-message-llm/tsconfig.json
  19. 43 0
      packages/session-title/session-title-llm/README.md
  20. 40 0
      packages/session-title/session-title-llm/package.json
  21. 245 0
      packages/session-title/session-title-llm/src/index.ts
  22. 266 0
      packages/session-title/session-title-llm/tests/llm.spec.ts
  23. 16 0
      packages/session-title/session-title-llm/tsconfig.json
  24. 52 0
      packages/session-title/session-title/README.md
  25. 41 0
      packages/session-title/session-title/package.json
  26. 556 0
      packages/session-title/session-title/src/index.ts
  27. 74 0
      packages/session-title/session-title/src/normalize.ts
  28. 91 0
      packages/session-title/session-title/tests/persistence.spec.ts
  29. 289 0
      packages/session-title/session-title/tests/provider.spec.ts
  30. 287 0
      packages/session-title/session-title/tests/service-contracts.spec.ts
  31. 145 0
      packages/session-title/session-title/tests/session-title.spec.ts
  32. 30 0
      packages/session-title/session-title/tsconfig.json
  33. 2 0
      packages/ui/acp/package.json
  34. 3 0
      packages/ui/acp/tsconfig.json
  35. 2 0
      packages/ui/tui/package.json
  36. 3 0
      packages/ui/tui/tsconfig.json
  37. 115 0
      pnpm-lock.yaml
  38. 1 0
      python/sdk-runtime/package.json
  39. 1 0
      tsconfig.base.json
  40. 4 0
      tsconfig.build.json
  41. 4 0
      tsconfig.json

+ 4 - 0
knip.json

@@ -89,6 +89,10 @@
       "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
       "project": ["src/**/*.ts", "tests/**/*.ts"]
     },
+    "packages/session-title/session-title-first-message-llm": {
+      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
+      "project": ["src/**/*.ts", "tests/**/*.ts"]
+    },
     "packages/context/workspace-context": {
       "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
       "project": ["src/**/*.ts", "tests/**/*.ts"]

+ 3 - 1
packages/examples/agent-spine-demo/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-agent-spine-demo",
-  "description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + bounded retry + tasks + invariants + tool-bash + workspace-context + tool-skill + tool-tasks + agent-loop)",
+  "description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + session titles + system-prompt + tools + skills + agents + bounded retry + tasks + invariants + tool-bash + workspace-context + tool-skill + tool-tasks + agent-loop)",
   "version": "0.0.1",
   "private": true,
   "type": "module",
@@ -31,6 +31,7 @@
     "@deepseek-ai/dsh-llm-retry": "^0.0.1",
     "@deepseek-ai/dsh-workspace-context": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
+    "@deepseek-ai/dsh-session-title": "^0.0.1",
     "@deepseek-ai/dsh-skill": "^0.0.1",
     "@deepseek-ai/dsh-skill-local": "^0.0.1",
     "@deepseek-ai/dsh-system-prompt": "^0.0.1",
@@ -52,6 +53,7 @@
     "@deepseek-ai/dsh-llm-retry": "workspace:^",
     "@deepseek-ai/dsh-workspace-context": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-title": "workspace:^",
     "@deepseek-ai/dsh-skill": "workspace:^",
     "@deepseek-ai/dsh-skill-local": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",

+ 3 - 0
packages/examples/agent-spine-demo/tsconfig.json

@@ -23,6 +23,9 @@
     {
       "path": "../../core/session"
     },
+    {
+      "path": "../../session-title/session-title"
+    },
     {
       "path": "../../core/system-prompt"
     },

+ 2 - 0
packages/session-query/session-query/package.json

@@ -24,6 +24,7 @@
   "peerDependencies": {
     "@deepseek-ai/dsh-llm": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
+    "@deepseek-ai/dsh-session-title": "^0.0.1",
     "@deepseek-ai/dsh-session-persistence": "^0.0.1",
     "cordis": "^4.0.0-rc.7"
   },
@@ -38,6 +39,7 @@
   "devDependencies": {
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-title": "workspace:^",
     "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "cordis": "^4.0.0-rc.7"
   }

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

@@ -23,6 +23,9 @@
     {
       "path": "../../core/session"
     },
+    {
+      "path": "../../session-title/session-title"
+    },
     {
       "path": "../../session-persistence/session-persistence"
     }

+ 12 - 0
packages/session-title/README.md

@@ -0,0 +1,12 @@
+# session-title/ — log-backed session-title capability family
+
+Durable session-title state, one optional asynchronous provider seam, and two opt-in model-backed implementations. The built-in first-message fallback is part of the service, so every composition can title a session without an auxiliary model call.
+
+| Package | Role | ctx key |
+|---|---|---|
+| [`session-title/`](session-title/README.md) | Log fold, deterministic fallback, provider registry, and refresh API | `ctx.sessionTitle` |
+| [`session-title-llm/`](session-title-llm/README.md) | Shared route, prompt, timeout, stream, and validation helper | — |
+| [`session-title-first-message-llm/`](session-title-first-message-llm/README.md) | Optional provider using the first eligible human message | registers on `ctx.sessionTitle` |
+| [`session-title-all-messages-llm/`](session-title-all-messages-llm/README.md) | Optional provider using every eligible human message | registers on `ctx.sessionTitle` |
+
+Only one provider may register at a time. The shared demo spine mounts the fallback service but leaves both model providers outside default composition, so deployments choose auxiliary cost and retitling cadence explicitly.

+ 26 - 0
packages/session-title/session-title-all-messages-llm/README.md

@@ -0,0 +1,26 @@
+# @deepseek-ai/dsh-session-title-all-messages-llm
+
+Optional `ctx.sessionTitle` provider that summarizes every eligible human message through `ctx.llm`. It registers the `all-user-messages` cadence and starts a new revision after each new human prompt, using seeded history as well as child-session prompts. A newer revision aborts and supersedes older work; even a provider that ignores cancellation cannot commit stale output.
+
+The plugin uses the complete required [shared LLM configuration](../session-title-llm/README.md#configuration). Omit both `provider` and `model` to inherit the exact route from each current logged main request, or set both to route title generation independently. If aggregate input exceeds `maxInputBytes`, the request fails instead of truncating history; automatic use warns and keeps the prior title.
+
+## Model Experience
+
+### All-messages title request
+
+#### What the model sees
+
+The title model receives the shared title instruction and a JSON array of all eligible human messages through the current revision, in log order with exact seqs. Seeded history is included.
+
+#### Token effect
+
+One auxiliary request may follow every new eligible prompt, bounded per request by `maxInputBytes` and `maxOutputTokens`; explicit refreshes may add calls. The main agent request gains zero tokens.
+
+#### KV Cache effect
+
+No main-request invalidation. Auxiliary input grows or changes after each prompt, so provider-specific cache reuse ends at the first changed JSON token.
+
+## Known Limitations and Deferred Work
+
+- Input overflow retains the prior title; this provider has no summarization-of-summaries or retention policy for very long sessions.
+- It treats all eligible human messages equally and offers no weighting, filtering, or manual-title precedence.

+ 34 - 0
packages/session-title/session-title-all-messages-llm/package.json

@@ -0,0 +1,34 @@
+{
+  "name": "@deepseek-ai/dsh-session-title-all-messages-llm",
+  "description": "All-user-messages LLM provider plugin for DeepSeek Harness session titles",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./package.json": "./package.json"
+  },
+  "files": ["lib/index.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src"],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-llm": "^0.0.1",
+    "@deepseek-ai/dsh-session-title": "^0.0.1",
+    "@deepseek-ai/dsh-session-title-llm": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "dependencies": {
+    "schemastery": "^3.18.0"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-title": "workspace:^",
+    "@deepseek-ai/dsh-session-title-llm": "workspace:^",
+    "cordis": "^4.0.0-rc.7"
+  }
+}

+ 36 - 0
packages/session-title/session-title-all-messages-llm/src/index.ts

@@ -0,0 +1,36 @@
+/** All-human-messages model provider for `ctx.sessionTitle`. */
+
+import type { Context } from 'cordis'
+import z from 'schemastery'
+import {
+  registerSessionTitleLlmProvider,
+  SessionTitleLlmConfigFields,
+} from '@deepseek-ai/dsh-session-title-llm'
+import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm'
+
+export const name = 'session-title-all-messages-llm'
+export const inject = ['sessionTitle', 'llm']
+
+/** Required LLM policy; this plugin adds no defaults. */
+export type Config = SessionTitleLlmConfig
+/** Loader schema shared with the first-message provider. */
+/* jscpd:ignore-start -- Loader requires each plugin to export its own statically walkable schema; the field validators remain shared. */
+export const Config: z<Config> = z.object({
+  targetWords: SessionTitleLlmConfigFields.targetWords,
+  targetCjkCharacters: SessionTitleLlmConfigFields.targetCjkCharacters,
+  maxInputBytes: SessionTitleLlmConfigFields.maxInputBytes,
+  maxOutputTokens: SessionTitleLlmConfigFields.maxOutputTokens,
+  timeoutMs: SessionTitleLlmConfigFields.timeoutMs,
+  provider: SessionTitleLlmConfigFields.provider,
+  model: SessionTitleLlmConfigFields.model,
+})
+/* jscpd:ignore-end */
+
+/**
+ * Register the all-user-messages model provider.
+ * @param ctx - context exposing session-title and LLM services.
+ * @param config - required route, target, byte, token, and timeout policy.
+ */
+export function apply(ctx: Context, config: Config): void {
+  registerSessionTitleLlmProvider(ctx, config, name, 'all-user-messages', messages => messages)
+}

+ 73 - 0
packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts

@@ -0,0 +1,73 @@
+import { Context } from 'cordis'
+import { describe, expect, it } from 'vitest'
+import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
+import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
+import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
+import SessionTitleService from '@deepseek-ai/dsh-session-title'
+import * as providerPlugin from '@deepseek-ai/dsh-session-title-all-messages-llm'
+
+class RecordingAdapter extends LlmAdapter {
+  readonly requests: GenerateOptions[] = []
+
+  override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
+    this.requests.push(options)
+    yield { type: 'text-delta', index: 0, text: 'All messages model title' }
+    yield { type: 'finish', reason: { kind: 'stop' } }
+  }
+}
+
+const TITLE_CONFIG = { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 } as const
+const LLM_CONFIG = {
+  targetWords: 5,
+  targetCjkCharacters: 10,
+  maxInputBytes: 1_000,
+  maxOutputTokens: 32,
+  timeoutMs: 1_000,
+} as const
+
+async function settle(): Promise<void> {
+  await new Promise(resolve => setTimeout(resolve, 0))
+}
+
+describe('all-messages LLM title provider', () => {
+  it('includes seeded history and the latest prompt while inheriting the logged request route', async () => {
+    const seeded = new Session(SessionId('seed-source'))
+    seeded.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    const inherited = seeded.append('user/message', {
+      content: [{ type: 'text', text: 'inherited prompt' }], source: { kind: 'user' },
+    }, { surfaceOp: 'append' })
+    seeded.append('session/title', {
+      title: 'Inherited fallback', messageSeqs: [inherited.seq], source: { kind: 'fallback' },
+    })
+    seeded.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+
+    const ctx = new Context()
+    await ctx.plugin(LlmService)
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionTitleService, TITLE_CONFIG)
+    const adapter = new RecordingAdapter()
+    ctx.llm.registerAdapter(['current-route'], adapter)
+    await ctx.plugin(providerPlugin, LLM_CONFIG)
+    const session = ctx.sessions.create(SessionId('all-plugin'), {
+      seed: seeded.events,
+      meta: { parentSession: seeded.id, seedLength: seeded.seq },
+    })
+    session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
+    const latest = session.append('user/message', {
+      content: [{ type: 'text', text: 'latest prompt' }], source: { kind: 'user' },
+    }, { surfaceOp: 'append' })
+    await settle()
+    session.append('request/header', {
+      header: { config: { provider: 'current-route', model: 'current-model' } }, reason: 'resume',
+    })
+    await settle()
+
+    expect(adapter.requests[0]).toMatchObject({ provider: 'current-route', model: 'current-model' })
+    const content = adapter.requests[0]?.messages[0]?.content[0]
+    expect(content?.type === 'text' && content.text).toContain('inherited prompt')
+    expect(content?.type === 'text' && content.text).toContain('latest prompt')
+    expect(ctx.sessionTitle.get(session)).toMatchObject({
+      messageSeqs: [inherited.seq, latest.seq],
+    })
+  })
+})

+ 13 - 0
packages/session-title/session-title-all-messages-llm/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": { "rootDir": "src", "outDir": "lib/types" },
+  "include": ["src"],
+  "references": [
+    { "path": "../../../vendor/cosmokit" },
+    { "path": "../../../vendor/cordis" },
+    { "path": "../../../vendor/schemastery" },
+    { "path": "../../llm/llm" },
+    { "path": "../session-title" },
+    { "path": "../session-title-llm" }
+  ]
+}

+ 26 - 0
packages/session-title/session-title-first-message-llm/README.md

@@ -0,0 +1,26 @@
+# @deepseek-ai/dsh-session-title-first-message-llm
+
+Optional `ctx.sessionTitle` provider that summarizes the first eligible human message through `ctx.llm`. It registers the `first-message` cadence, runs automatically only when a fresh non-fork session first creates its fallback, and attributes the result to that message's exact seq. An automatic failure retains the fallback and is retried only through `ctx.sessionTitle.refresh()`.
+
+The plugin uses the complete required [shared LLM configuration](../session-title-llm/README.md#configuration). Omit both `provider` and `model` to inherit the exact route from the current logged main request, or set both to route title generation independently.
+
+## Model Experience
+
+### First-message title request
+
+#### What the model sees
+
+The title model receives the shared title instruction and a JSON array containing only the first eligible human message. Later prompts and inherited fork history do not trigger another automatic call.
+
+#### Token effect
+
+At most one automatic auxiliary request is made for a fresh session, bounded by `maxInputBytes` and `maxOutputTokens`; explicit refreshes may make additional calls. The main agent request gains zero tokens.
+
+#### KV Cache effect
+
+No main-request invalidation. The auxiliary request uses the configured or logged route and has provider-specific cache behavior.
+
+## Known Limitations and Deferred Work
+
+- The first message alone may cease to represent a long-running session; use the all-messages provider when later prompts should retitle it.
+- A fork keeps its inherited title and never runs this provider automatically, even when its seeded first message came from the parent.

+ 37 - 0
packages/session-title/session-title-first-message-llm/package.json

@@ -0,0 +1,37 @@
+{
+  "name": "@deepseek-ai/dsh-session-title-first-message-llm",
+  "description": "First-message LLM provider plugin for DeepSeek Harness session titles",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./package.json": "./package.json"
+  },
+  "files": ["lib/index.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src"],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-llm": "^0.0.1",
+    "@deepseek-ai/dsh-session-title": "^0.0.1",
+    "@deepseek-ai/dsh-session-title-llm": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "dependencies": {
+    "schemastery": "^3.18.0"
+  },
+  "devDependencies": {
+    "@cordisjs/plugin-include": "workspace:^",
+    "@cordisjs/plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-llm-deepseek": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-title": "workspace:^",
+    "@deepseek-ai/dsh-session-title-llm": "workspace:^",
+    "cordis": "^4.0.0-rc.7"
+  }
+}

+ 40 - 0
packages/session-title/session-title-first-message-llm/src/index.ts

@@ -0,0 +1,40 @@
+/** First-human-message model provider for `ctx.sessionTitle`. */
+
+import type { Context } from 'cordis'
+import z from 'schemastery'
+import {
+  registerSessionTitleLlmProvider,
+  SessionTitleLlmConfigFields,
+} from '@deepseek-ai/dsh-session-title-llm'
+import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm'
+
+export const name = 'session-title-first-message-llm'
+export const inject = ['sessionTitle', 'llm']
+
+/** Required LLM policy; this plugin adds no defaults. */
+export type Config = SessionTitleLlmConfig
+/** Loader schema shared with the all-messages provider. */
+/* jscpd:ignore-start -- Loader requires each plugin to export its own statically walkable schema; the field validators remain shared. */
+export const Config: z<Config> = z.object({
+  targetWords: SessionTitleLlmConfigFields.targetWords,
+  targetCjkCharacters: SessionTitleLlmConfigFields.targetCjkCharacters,
+  maxInputBytes: SessionTitleLlmConfigFields.maxInputBytes,
+  maxOutputTokens: SessionTitleLlmConfigFields.maxOutputTokens,
+  timeoutMs: SessionTitleLlmConfigFields.timeoutMs,
+  provider: SessionTitleLlmConfigFields.provider,
+  model: SessionTitleLlmConfigFields.model,
+})
+/* jscpd:ignore-end */
+
+/**
+ * Register the first-message model provider.
+ * @param ctx - context exposing session-title and LLM services.
+ * @param config - required route, target, byte, token, and timeout policy.
+ */
+export function apply(ctx: Context, config: Config): void {
+  registerSessionTitleLlmProvider(ctx, config, name, 'first-message', (messages) => {
+    const first = messages[0]
+    if (first === undefined) throw new Error('first-message title provider requires one human message')
+    return [first]
+  })
+}

+ 120 - 0
packages/session-title/session-title-first-message-llm/tests/loader-composition.spec.ts

@@ -0,0 +1,120 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import Loader from '@cordisjs/plugin-loader'
+import Include from '@cordisjs/plugin-include'
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
+import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SessionTitleService from '@deepseek-ai/dsh-session-title'
+import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-message-llm'
+
+let root: string | undefined
+let context: Context | undefined
+
+class LoaderAdapter extends LlmAdapter {
+  readonly requests: GenerateOptions[] = []
+
+  override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
+    this.requests.push(options)
+    yield { type: 'text-delta', index: 0, text: 'Loader composed title' }
+    yield { type: 'finish', reason: { kind: 'stop' } }
+  }
+}
+
+afterEach(async () => {
+  await context?.fiber.dispose()
+  context = undefined
+  if (root !== undefined) await rm(root, { recursive: true, force: true })
+  root = undefined
+})
+
+async function loadComposition(): Promise<Context> {
+  root = await mkdtemp(join(tmpdir(), 'dsh-title-loader-'))
+  const configPath = join(root, 'cordis.yml')
+  await writeFile(configPath, [
+    "- name: '@deepseek-ai/dsh-llm'",
+    "- name: '@deepseek-ai/dsh-session'",
+    "- name: '@deepseek-ai/dsh-session-title'",
+    '  config:',
+    '    fallbackMaxWords: 5',
+    '    fallbackMaxBytes: 40',
+    '    maxTitleBytes: 80',
+    "- name: '@deepseek-ai/dsh-session-title-first-message-llm'",
+    '  config:',
+    '    targetWords: 5',
+    '    targetCjkCharacters: 10',
+    '    maxInputBytes: 1000',
+    '    maxOutputTokens: 32',
+    '    timeoutMs: 1000',
+    "    provider: 'title-route'",
+    "    model: 'title-model'",
+    '',
+  ].join('\n'))
+
+  context = new Context()
+  context.baseUrl = pathToFileURL(root).href + '/'
+  await context.plugin(Loader)
+  context.loader.builtins.include = Include
+  const modules = new Map<string, unknown>([
+    ['@deepseek-ai/dsh-llm', LlmService],
+    ['@deepseek-ai/dsh-session', SessionStore],
+    ['@deepseek-ai/dsh-session-title', SessionTitleService],
+    ['@deepseek-ai/dsh-session-title-first-message-llm', providerPlugin],
+  ])
+  context.loader.internal = {
+    version: 'v2',
+    async import(specifier: string) {
+      if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
+      return modules.get(specifier)
+    },
+  } as unknown as NonNullable<typeof context.loader.internal>
+  await context.loader.create({
+    name: 'cordis:include',
+    config: { path: pathToFileURL(configPath).href },
+  })
+  await context.loader.await()
+  return context
+}
+
+describe('session-title Loader composition', () => {
+  it('loads the service and one model provider with required deployment policy', async () => {
+    const ctx = await loadComposition()
+    const unloaded = [...ctx.loader.entries()]
+      .filter(entry => entry.fiber === undefined && !entry.disabled)
+      .map(entry => entry.options.name)
+    expect(unloaded).toEqual([])
+
+    const adapter = new LoaderAdapter()
+    ctx.llm.registerAdapter(['title-route'], adapter)
+    const session = ctx.sessions.create(SessionId('loader-title'))
+    session.append('turn/start', {
+      turn: 1,
+      trigger: { kind: 'message', source: { kind: 'user' } },
+    })
+    const message = session.append('user/message', {
+      content: [{ type: 'text', text: 'Compose a title through Loader' }],
+      source: { kind: 'user' },
+    }, { surfaceOp: 'append' })
+    await new Promise(resolve => setTimeout(resolve, 0))
+    session.append('request/header', {
+      header: { config: { provider: 'main-route', model: 'main-model' } },
+      reason: 'initial',
+    })
+    await new Promise(resolve => setTimeout(resolve, 0))
+
+    expect(adapter.requests[0]).toMatchObject({ provider: 'title-route', model: 'title-model' })
+    expect(ctx.sessionTitle.get(session)).toMatchObject({
+      title: 'Loader composed title',
+      messageSeqs: [message.seq],
+      source: {
+        kind: 'provider',
+        provider: 'session-title-first-message-llm',
+        model: { provider: 'title-route', model: 'title-model' },
+      },
+    })
+  })
+})

+ 59 - 0
packages/session-title/session-title-first-message-llm/tests/provider.e2e.ts

@@ -0,0 +1,59 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import LlmService from '@deepseek-ai/dsh-llm'
+import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SessionTitleService from '@deepseek-ai/dsh-session-title'
+import * as FirstMessageTitleProvider from '@deepseek-ai/dsh-session-title-first-message-llm'
+
+const contexts: Context[] = []
+
+afterEach(async () => {
+  await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+})
+
+describe.skipIf(!process.env.DEEPSEEK_API_KEY)('first-message title provider with real DeepSeek API', () => {
+  it('replaces the fallback with a short model title', async () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(LlmService)
+    await ctx.plugin(LlmDeepSeek, { thinking: 'disabled' })
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionTitleService, {
+      fallbackMaxWords: 5,
+      fallbackMaxBytes: 40,
+      maxTitleBytes: 80,
+    })
+    await ctx.plugin(FirstMessageTitleProvider, {
+      targetWords: 5,
+      targetCjkCharacters: 10,
+      maxInputBytes: 4_096,
+      maxOutputTokens: 64,
+      timeoutMs: 60_000,
+      provider: 'deepseek',
+      model: 'deepseek-v4-flash',
+    })
+    const session = ctx.sessions.create(SessionId('real-title-provider'))
+    session.append('turn/start', {
+      turn: 1,
+      trigger: { kind: 'message', source: { kind: 'user' } },
+    })
+    const message = session.append('user/message', {
+      content: [{ type: 'text', text: 'Explain why append-only logs make session titles durable.' }],
+      source: { kind: 'user' },
+    }, { surfaceOp: 'append' })
+
+    const title = await ctx.sessionTitle.refresh(session)
+
+    expect(title).toMatchObject({
+      messageSeqs: [message.seq],
+      source: {
+        kind: 'provider',
+        provider: 'session-title-first-message-llm',
+        model: { provider: 'deepseek', model: 'deepseek-v4-flash' },
+      },
+    })
+    expect(title?.title.length).toBeGreaterThan(0)
+    expect(Buffer.byteLength(title?.title ?? '', 'utf8')).toBeLessThanOrEqual(80)
+  })
+})

+ 86 - 0
packages/session-title/session-title-first-message-llm/tests/provider.spec.ts

@@ -0,0 +1,86 @@
+import { Context } from 'cordis'
+import { describe, expect, it, vi } from 'vitest'
+import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
+import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
+import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
+import SessionTitleService, { type SessionTitleProvider } from '@deepseek-ai/dsh-session-title'
+import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-message-llm'
+
+class RecordingAdapter extends LlmAdapter {
+  readonly requests: GenerateOptions[] = []
+
+  override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
+    this.requests.push(options)
+    yield { type: 'text-delta', index: 0, text: 'First-message model title' }
+    yield { type: 'finish', reason: { kind: 'stop' } }
+  }
+}
+
+const TITLE_CONFIG = { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 } as const
+const LLM_CONFIG = {
+  targetWords: 5,
+  targetCjkCharacters: 10,
+  maxInputBytes: 1_000,
+  maxOutputTokens: 32,
+  timeoutMs: 1_000,
+  provider: 'title-route',
+  model: 'title-model',
+} as const
+
+async function settle(): Promise<void> {
+  await new Promise(resolve => setTimeout(resolve, 0))
+}
+
+describe('first-message LLM title provider', () => {
+  it('rejects an impossible empty provider request at its own boundary', async () => {
+    const ctx = new Context()
+    await ctx.plugin(LlmService)
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionTitleService, TITLE_CONFIG)
+    let registered: SessionTitleProvider | undefined
+    vi.spyOn(ctx.sessionTitle, 'register').mockImplementation((provider) => {
+      registered = provider
+      return () => undefined
+    })
+    providerPlugin.apply(ctx, LLM_CONFIG)
+
+    await expect(registered!.generate({
+      session: new Session(SessionId('empty-first-provider')),
+      messages: [],
+      signal: new AbortController().signal,
+    })).rejects.toThrow(/requires one human message/)
+  })
+
+  it('always selects only the first eligible human message, including explicit refresh', async () => {
+    const ctx = new Context()
+    await ctx.plugin(LlmService)
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionTitleService, TITLE_CONFIG)
+    const adapter = new RecordingAdapter()
+    ctx.llm.registerAdapter(['title-route'], adapter)
+    await ctx.plugin(providerPlugin, LLM_CONFIG)
+    const session = ctx.sessions.create(SessionId('first-plugin'))
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    const first = session.append('user/message', {
+      content: [{ type: 'text', text: 'first input' }], source: { kind: 'user' },
+    }, { surfaceOp: 'append' })
+    await settle()
+    session.append('request/header', {
+      header: { config: { provider: 'main', model: 'main-model' } }, reason: 'initial',
+    })
+    await settle()
+    session.append('user/message', {
+      content: [{ type: 'text', text: 'second input must be ignored' }], source: { kind: 'user' },
+    }, { surfaceOp: 'append' })
+
+    await ctx.sessionTitle.refresh(session)
+
+    expect(adapter.requests).toHaveLength(2)
+    for (const options of adapter.requests) {
+      const content = options.messages[0]?.content[0]
+      expect(content?.type === 'text' && content.text).toContain('first input')
+      expect(content?.type === 'text' && content.text).not.toContain('second input must be ignored')
+    }
+    expect(ctx.sessionTitle.get(session)).toMatchObject({ messageSeqs: [first.seq] })
+  })
+})

+ 13 - 0
packages/session-title/session-title-first-message-llm/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": { "rootDir": "src", "outDir": "lib/types" },
+  "include": ["src"],
+  "references": [
+    { "path": "../../../vendor/cosmokit" },
+    { "path": "../../../vendor/cordis" },
+    { "path": "../../../vendor/schemastery" },
+    { "path": "../../llm/llm" },
+    { "path": "../session-title" },
+    { "path": "../session-title-llm" }
+  ]
+}

+ 43 - 0
packages/session-title/session-title-llm/README.md

@@ -0,0 +1,43 @@
+# @deepseek-ai/dsh-session-title-llm
+
+Shared implementation policy for model-backed session-title providers. It resolves the auxiliary route, frames exact selected human messages as JSON, applies a language-aware title instruction, enforces input and output budgets, composes timeout and caller cancellation, assembles the stream, and returns normalized text with exact source seqs and model provenance.
+
+This package is a library, not a Cordis plugin. The provider plugins call `registerSessionTitleLlmProvider()` with their cadence and message selector; it validates shared config and delegates each revision to `generateSessionTitleWithLlm()`, so registration, route, prompt, cancellation, and validation behavior cannot drift between them.
+
+## Route and failure contract
+
+`provider` and `model` overrides are optional but must be supplied together as non-empty strings. Without that pair, the helper uses the exact provider/model route captured from the current session's logged `request/header`; an explicit refresh before any route exists therefore needs overrides. Input exceeding `maxInputBytes` rejects instead of being truncated. Timeout, cancellation, malformed or empty output, tool calls, and non-stop finish reasons also reject; the session-title service decides whether that rejection is an automatic warning or an explicit caller failure.
+
+## Configuration
+
+Every field is required except the paired route override; there are no library defaults.
+
+| Key | Contract |
+|---|---|
+| `targetWords` | Positive target word count for non-CJK titles. |
+| `targetCjkCharacters` | Positive target character count for Chinese, Japanese, or Korean titles. |
+| `maxInputBytes` | Positive aggregate UTF-8 byte ceiling across selected messages. |
+| `maxOutputTokens` | Positive auxiliary generation token cap. |
+| `timeoutMs` | Positive end-to-end deadline within the runtime timer limit. |
+| `provider`, `model` | Optional explicit route; both or neither. |
+
+## Model Experience
+
+### Auxiliary title request
+
+#### What the model sees
+
+The title model receives a fixed system instruction to return one concise unadorned title in the input language, including the configured word and CJK-character targets. Its one user message contains a JSON array of the exact selected human messages and their seqs.
+
+#### Token effect
+
+The auxiliary request consumes tokens according to selected input size and `maxOutputTokens`. It is separate from the main agent request and does not add title text or framing to agent history.
+
+#### KV Cache effect
+
+No main-request invalidation. Auxiliary cache reuse is provider-specific; the fixed instruction is reusable while the JSON message array changes with each revision.
+
+## Known Limitations and Deferred Work
+
+- The helper accepts text output only and rejects tool calls; structured-output adapters and provider-specific prompt variants are not exposed.
+- It enforces a byte ceiling for the whole selected input rather than clipping individual messages or applying a retention policy.

+ 40 - 0
packages/session-title/session-title-llm/package.json

@@ -0,0 +1,40 @@
+{
+  "name": "@deepseek-ai/dsh-session-title-llm",
+  "description": "Shared LLM generation policy for DeepSeek Harness session-title providers",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-llm": "^0.0.1",
+    "@deepseek-ai/dsh-session-title": "^0.0.1",
+    "@deepseek-ai/dsh-timeout": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "dependencies": {
+    "schemastery": "^3.18.0"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-title": "workspace:^",
+    "@deepseek-ai/dsh-timeout": "workspace:^",
+    "cordis": "^4.0.0-rc.7"
+  }
+}

+ 245 - 0
packages/session-title/session-title-llm/src/index.ts

@@ -0,0 +1,245 @@
+/**
+ * Shared route, framing, timeout, assembly, and validation policy for
+ * model-backed session-title providers.
+ * @module @deepseek-ai/dsh-session-title-llm
+ */
+
+import type { Context } from 'cordis'
+import z from 'schemastery'
+import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm'
+import type { FinishReason, GenerateOptions } from '@deepseek-ai/dsh-llm'
+import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
+import { normalizeSessionTitle, SessionTitleProviderId } from '@deepseek-ai/dsh-session-title'
+import type {
+  SessionTitleAutomaticMode,
+  SessionTitleModelProvenance,
+  SessionTitleProviderRequest,
+  SessionTitleProviderResult,
+  SessionTitleUserMessage,
+} from '@deepseek-ai/dsh-session-title'
+
+/** Capability-owned timeout reason code for auxiliary title requests. */
+export const SESSION_TITLE_TIMEOUT_CODE = 'SESSION_TITLE_TIMEOUT'
+
+/** Required deployment policy for one model-backed title plugin. */
+export interface SessionTitleLlmConfig {
+  /** Target word count for non-CJK titles. */
+  readonly targetWords: number
+  /** Target character count for Chinese, Japanese, or Korean titles. */
+  readonly targetCjkCharacters: number
+  /** Maximum total UTF-8 bytes across selected source-message text. */
+  readonly maxInputBytes: number
+  /** Auxiliary generation output-token cap. */
+  readonly maxOutputTokens: number
+  /** End-to-end auxiliary request deadline in milliseconds. */
+  readonly timeoutMs: number
+  /** Optional explicit provider route; must be paired with `model`. */
+  readonly provider?: string
+  /** Optional explicit model id; must be paired with `provider`. */
+  readonly model?: string
+}
+
+/** Validated immutable model-provider policy. */
+export interface ResolvedSessionTitleLlmConfig extends SessionTitleLlmConfig {}
+
+/** Shared Loader field schemas with no library defaults. */
+export const SessionTitleLlmConfigFields = {
+  targetWords: z.number().step(1).min(1).required(),
+  targetCjkCharacters: z.number().step(1).min(1).required(),
+  maxInputBytes: z.number().step(1).min(1).required(),
+  maxOutputTokens: z.number().step(1).min(1).required(),
+  timeoutMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS).required(),
+  provider: z.string(),
+  model: z.string(),
+}
+
+/** Shared Loader schema with no library defaults. */
+export const SessionTitleLlmConfigSchema: z<SessionTitleLlmConfig> = z.object(SessionTitleLlmConfigFields)
+
+/** Complete configuration key set for direct construction validation. */
+const CONFIG_KEYS: ReadonlySet<string> = new Set([
+  'targetWords',
+  'targetCjkCharacters',
+  'maxInputBytes',
+  'maxOutputTokens',
+  'timeoutMs',
+  'provider',
+  'model',
+])
+
+/** Validate one positive integer limit. */
+function assertPositiveInteger(name: string, value: number): void {
+  if (!Number.isInteger(value) || value <= 0) {
+    throw new Error(`session-title-llm: ${name} must be a positive integer`)
+  }
+}
+
+/**
+ * Validate and detach required model-provider configuration.
+ * @param config - untrusted plugin configuration.
+ * @returns immutable policy with optional route absence preserved.
+ */
+export function resolveSessionTitleLlmConfig(
+  config: SessionTitleLlmConfig,
+): ResolvedSessionTitleLlmConfig {
+  const candidate: unknown = config
+  if (candidate === null || typeof candidate !== 'object') {
+    throw new Error('session-title-llm: configuration is required')
+  }
+  const value = candidate as SessionTitleLlmConfig
+  for (const key of Object.keys(value)) {
+    if (!CONFIG_KEYS.has(key)) throw new Error(`session-title-llm: unknown config key "${key}"`)
+  }
+  assertPositiveInteger('targetWords', value.targetWords)
+  assertPositiveInteger('targetCjkCharacters', value.targetCjkCharacters)
+  assertPositiveInteger('maxInputBytes', value.maxInputBytes)
+  assertPositiveInteger('maxOutputTokens', value.maxOutputTokens)
+  assertPositiveInteger('timeoutMs', value.timeoutMs)
+  if (value.timeoutMs > MAX_TIMER_DELAY_MS) {
+    throw new Error(`session-title-llm: timeoutMs must not exceed ${MAX_TIMER_DELAY_MS}`)
+  }
+  const hasProvider = value.provider !== undefined
+  const hasModel = value.model !== undefined
+  if (hasProvider !== hasModel) {
+    throw new Error('session-title-llm: provider and model must be supplied together')
+  }
+  if (hasProvider
+    && (typeof value.provider !== 'string' || value.provider.length === 0
+      || typeof value.model !== 'string' || value.model.length === 0)) {
+    throw new Error('session-title-llm: provider and model overrides must be non-empty strings')
+  }
+  return deepFreeze({ ...value })
+}
+
+/** Select the provider-owned message subset from one fixed service revision. */
+export type SessionTitleLlmMessageSelector = (
+  messages: readonly SessionTitleUserMessage[],
+) => readonly SessionTitleUserMessage[]
+
+/**
+ * Register one model-backed provider through the shared configuration and call policy.
+ * @param ctx - context exposing the title and LLM services.
+ * @param config - untrusted required deployment policy.
+ * @param id - stable plugin identity recorded in title provenance.
+ * @param automatic - provider-owned automatic generation cadence.
+ * @param selectMessages - exact source-message selection for one revision.
+ */
+export function registerSessionTitleLlmProvider(
+  ctx: Context,
+  config: SessionTitleLlmConfig,
+  id: string,
+  automatic: SessionTitleAutomaticMode,
+  selectMessages: SessionTitleLlmMessageSelector,
+): void {
+  const resolved = resolveSessionTitleLlmConfig(config)
+  ctx.sessionTitle.register({
+    id: SessionTitleProviderId(id),
+    automatic,
+    async generate(request) {
+      return generateSessionTitleWithLlm(ctx, resolved, request, selectMessages(request.messages))
+    },
+  })
+}
+
+/** Resolve the explicit pair or the exact route captured from `request/header`. */
+function resolveRoute(
+  config: ResolvedSessionTitleLlmConfig,
+  request: SessionTitleProviderRequest,
+): SessionTitleModelProvenance {
+  if (config.provider !== undefined && config.model !== undefined) {
+    return { provider: config.provider, model: config.model }
+  }
+  if (request.route === undefined) {
+    throw new Error('session-title-llm: no logged request route is available; configure provider and model together')
+  }
+  return request.route
+}
+
+/** Stable language-aware system instruction shared by both provider plugins. */
+function systemPrompt(config: ResolvedSessionTitleLlmConfig): string {
+  return [
+    'Create a concise title for an AI coding-assistant session from the supplied human messages.',
+    'Return only the title on one line, with no quotes, prefix, explanation, Markdown, or terminal control codes.',
+    'Use the language of the messages.',
+    `Aim for about ${config.targetWords} words in non-CJK languages or ${config.targetCjkCharacters} CJK characters.`,
+  ].join('\n')
+}
+
+/** Frame exact messages as JSON so user text cannot break structural delimiters. */
+function frameMessages(messages: readonly SessionTitleUserMessage[]): string {
+  return `Generate the session title from this JSON array of human messages:\n${JSON.stringify(messages)}`
+}
+
+/** Translate terminal finish reasons into an auxiliary-call failure. */
+function finishError(finish: FinishReason): Error | undefined {
+  switch (finish.kind) {
+    case 'stop':
+      return undefined
+    case 'error':
+    case 'aborted': {
+      const error = new Error(finish.failure.message) as Error & { code?: string }
+      error.code = finish.failure.code
+      return error
+    }
+    case 'max-tokens':
+      return new Error('session-title-llm: title output reached maxOutputTokens')
+    case 'tool-calls':
+      return new Error('session-title-llm: title model unexpectedly requested a tool')
+    default:
+      return new Error(`session-title-llm: unsupported finish reason "${String((finish as { kind?: unknown }).kind)}"`)
+  }
+}
+
+/**
+ * Generate one title through the shared auxiliary LLM call.
+ * @param ctx - context exposing the registered LLM service.
+ * @param config - validated model-provider policy.
+ * @param request - service-owned session, route, message snapshot, and cancellation.
+ * @param selectedMessages - exact provider-selected subset to frame and attribute.
+ * @returns normalized non-empty title, exact source seqs, and used model route.
+ */
+export async function generateSessionTitleWithLlm(
+  ctx: Context,
+  config: ResolvedSessionTitleLlmConfig,
+  request: SessionTitleProviderRequest,
+  selectedMessages: readonly SessionTitleUserMessage[],
+): Promise<SessionTitleProviderResult> {
+  request.signal.throwIfAborted()
+  if (selectedMessages.length === 0) {
+    throw new Error('session-title-llm: at least one source message is required')
+  }
+  const inputBytes = selectedMessages.reduce((total, message) => total + Buffer.byteLength(message.text, 'utf8'), 0)
+  if (inputBytes > config.maxInputBytes) {
+    throw new Error(`session-title-llm: input is ${inputBytes} bytes, exceeding maxInputBytes ${config.maxInputBytes}`)
+  }
+  const route = resolveRoute(config, request)
+  using callDeadline = deadline(request.signal, config.timeoutMs, SESSION_TITLE_TIMEOUT_CODE)
+  const options: GenerateOptions = {
+    provider: route.provider,
+    model: route.model,
+    messages: [{ role: 'user', content: [{ type: 'text', text: frameMessages(selectedMessages) }] }],
+    system: systemPrompt(config),
+    maxTokens: config.maxOutputTokens,
+    sessionId: request.session.id,
+    signal: callDeadline.signal,
+  }
+  const assembler = new BlockAssembler()
+  for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk)
+  const terminalError = finishError(assembler.finish)
+  if (terminalError !== undefined) throw terminalError
+  const blocks = assembler.message().content
+  if (blocks.some(block => block.type === 'tool-call')) {
+    throw new Error('session-title-llm: title output must contain text only')
+  }
+  const text = blocks
+    .filter((block): block is Extract<(typeof blocks)[number], { type: 'text' }> => block.type === 'text')
+    .map(block => block.text)
+    .join(' ')
+  const title = normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER)
+  if (title.length === 0) throw new Error('session-title-llm: title model produced no text')
+  return {
+    title,
+    messageSeqs: selectedMessages.map(message => message.seq),
+    model: route,
+  }
+}

+ 266 - 0
packages/session-title/session-title-llm/tests/llm.spec.ts

@@ -0,0 +1,266 @@
+import { Context } from 'cordis'
+import { describe, expect, it, vi } from 'vitest'
+import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
+import type { FinishReason, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionTitleProviderRequest } from '@deepseek-ai/dsh-session-title'
+import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
+import {
+  generateSessionTitleWithLlm,
+  resolveSessionTitleLlmConfig,
+  SESSION_TITLE_TIMEOUT_CODE,
+} from '@deepseek-ai/dsh-session-title-llm'
+import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm'
+
+class RecordingAdapter extends LlmAdapter {
+  readonly requests: GenerateOptions[] = []
+
+  constructor(private readonly script: readonly StreamChunk[]) {
+    super()
+  }
+
+  override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
+    this.requests.push(options)
+    yield * this.script
+  }
+}
+
+class CooperativeAdapter extends LlmAdapter {
+  override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
+    const signal = options.signal
+    if (signal === undefined) throw new Error('expected title request signal')
+    await new Promise<never>((_resolve, reject) => {
+      const rejectAbort = (): void => {
+        // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise exact AbortSignal.reason propagation
+        reject(signal.reason)
+      }
+      if (signal.aborted) {
+        rejectAbort()
+        return
+      }
+      signal.addEventListener('abort', rejectAbort, { once: true })
+    })
+  }
+}
+
+const SCRIPT: StreamChunk[] = [
+  { type: 'block-start', index: 0, blockType: 'text' },
+  { type: 'text-delta', index: 0, text: '  五个字标题  ' },
+  { type: 'finish', reason: { kind: 'stop' } },
+]
+
+const CONFIG = {
+  targetWords: 5,
+  targetCjkCharacters: 10,
+  maxInputBytes: 1_000,
+  maxOutputTokens: 32,
+  timeoutMs: 1_000,
+} as const
+
+function request(signal = new AbortController().signal): SessionTitleProviderRequest {
+  return {
+    session: new Session(SessionId('title-call')),
+    messages: [
+      { seq: 2, text: 'first prompt' },
+      { seq: 9, text: '第二个问题' },
+    ],
+    route: { provider: 'current-route', model: 'current-model' },
+    signal,
+  }
+}
+
+function requestWithoutRoute(signal = new AbortController().signal): SessionTitleProviderRequest {
+  return {
+    session: new Session(SessionId('title-call-no-route')),
+    messages: [{ seq: 2, text: 'first prompt' }],
+    signal,
+  }
+}
+
+async function withScript(script: readonly StreamChunk[]): Promise<{
+  ctx: Context
+  adapter: RecordingAdapter
+}> {
+  const ctx = new Context()
+  await ctx.plugin(LlmService)
+  const adapter = new RecordingAdapter(script)
+  ctx.llm.registerAdapter(['current-route'], adapter)
+  return { ctx, adapter }
+}
+
+describe('generateSessionTitleWithLlm', () => {
+  it('uses the exact logged route, language targets, full framed input, and output token cap', async () => {
+    const ctx = new Context()
+    await ctx.plugin(LlmService)
+    const adapter = new RecordingAdapter(SCRIPT)
+    ctx.llm.registerAdapter(['current-route'], adapter)
+
+    const result = await generateSessionTitleWithLlm(
+      ctx,
+      resolveSessionTitleLlmConfig(CONFIG),
+      request(),
+      request().messages,
+    )
+
+    expect(result).toEqual({
+      title: '五个字标题',
+      messageSeqs: [2, 9],
+      model: { provider: 'current-route', model: 'current-model' },
+    })
+    expect(adapter.requests).toHaveLength(1)
+    const options = adapter.requests[0]!
+    expect(options).toMatchObject({
+      provider: 'current-route',
+      model: 'current-model',
+      maxTokens: 32,
+      sessionId: SessionId('title-call'),
+    })
+    expect(options.system).toContain('5 words')
+    expect(options.system).toContain('10 CJK characters')
+    const prompt = options.messages[0]?.content[0]
+    expect(prompt?.type === 'text' && prompt.text).toContain('first prompt')
+    expect(prompt?.type === 'text' && prompt.text).toContain('第二个问题')
+  })
+
+  it('uses paired explicit overrides and rejects an oversized input without calling the model', async () => {
+    const ctx = new Context()
+    await ctx.plugin(LlmService)
+    const adapter = new RecordingAdapter(SCRIPT)
+    ctx.llm.registerAdapter(['explicit-route'], adapter)
+    const config = resolveSessionTitleLlmConfig({
+      ...CONFIG,
+      provider: 'explicit-route',
+      model: 'explicit-model',
+      maxInputBytes: 4,
+    })
+
+    await expect(generateSessionTitleWithLlm(ctx, config, request(), request().messages))
+      .rejects.toThrow(/input.*bytes.*maxInputBytes/i)
+    expect(adapter.requests).toEqual([])
+
+    const withinLimit = resolveSessionTitleLlmConfig({ ...config, maxInputBytes: 1_000 })
+    await generateSessionTitleWithLlm(ctx, withinLimit, request(), [request().messages[0]!])
+    expect(adapter.requests[0]).toMatchObject({
+      provider: 'explicit-route',
+      model: 'explicit-model',
+    })
+  })
+
+  it('requires every deployment limit and a complete optional route pair', () => {
+    expect(() => resolveSessionTitleLlmConfig(undefined as never)).toThrow(/configuration is required/)
+    expect(() => resolveSessionTitleLlmConfig(null as never)).toThrow(/configuration is required/)
+    expect(() => resolveSessionTitleLlmConfig('invalid' as never)).toThrow(/configuration is required/)
+    expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, extra: true } as SessionTitleLlmConfig))
+      .toThrow(/unknown config key "extra"/)
+    expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, targetWords: 0 }))
+      .toThrow(/targetWords.*positive integer/)
+    expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, targetWords: 1.5 }))
+      .toThrow(/targetWords.*positive integer/)
+    expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 'only-provider' }))
+      .toThrow(/provider and model must be supplied together/)
+    expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, model: 'only-model' }))
+      .toThrow(/provider and model must be supplied together/)
+    expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: '', model: 'model' }))
+      .toThrow(/overrides must be non-empty strings/)
+    expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 'provider', model: '' }))
+      .toThrow(/overrides must be non-empty strings/)
+    expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 1, model: 'model' } as never))
+      .toThrow(/overrides must be non-empty strings/)
+    expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 'provider', model: 1 } as never))
+      .toThrow(/overrides must be non-empty strings/)
+    expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: MAX_TIMER_DELAY_MS + 1 }))
+      .toThrow(/timeoutMs must not exceed/)
+    expect(() => resolveSessionTitleLlmConfig(CONFIG)).not.toThrow()
+  })
+
+  it('rejects an absent route, empty selection, and pre-aborted caller before model dispatch', async () => {
+    const { ctx, adapter } = await withScript(SCRIPT)
+    const config = resolveSessionTitleLlmConfig(CONFIG)
+    await expect(generateSessionTitleWithLlm(ctx, config, requestWithoutRoute(), requestWithoutRoute().messages))
+      .rejects.toThrow(/no logged request route/)
+    await expect(generateSessionTitleWithLlm(ctx, config, request(), []))
+      .rejects.toThrow(/at least one source message/)
+    const controller = new AbortController()
+    controller.abort(new Error('caller stopped'))
+    await expect(generateSessionTitleWithLlm(ctx, config, request(controller.signal), request().messages))
+      .rejects.toThrow('caller stopped')
+    expect(adapter.requests).toEqual([])
+  })
+
+  it.each([
+    [{ kind: 'error', failure: { message: 'provider failed', code: 'SERVER' } }, 'provider failed', 'SERVER'],
+    [{ kind: 'aborted', failure: { message: 'provider aborted', code: 'ABORTED' } }, 'provider aborted', 'ABORTED'],
+  ] satisfies Array<[FinishReason, string, string]>)('preserves %s terminal failure details', async (reason, message, code) => {
+    const { ctx } = await withScript([{ type: 'finish', reason }])
+    await expect(generateSessionTitleWithLlm(
+      ctx,
+      resolveSessionTitleLlmConfig(CONFIG),
+      request(),
+      request().messages,
+    )).rejects.toMatchObject({ message, code })
+  })
+
+  it.each([
+    [{ kind: 'max-tokens' }, /reached maxOutputTokens/],
+    [{ kind: 'tool-calls' }, /unexpectedly requested a tool/],
+    [{ kind: 'future-finish' } as never, /unsupported finish reason "future-finish"/],
+  ] satisfies Array<[FinishReason, RegExp]>)('rejects the terminal finish reason %s', async (reason, error) => {
+    const { ctx } = await withScript([{ type: 'finish', reason }])
+    await expect(generateSessionTitleWithLlm(
+      ctx,
+      resolveSessionTitleLlmConfig(CONFIG),
+      request(),
+      request().messages,
+    )).rejects.toThrow(error)
+  })
+
+  it('rejects tool-call blocks and a successful response with no text', async () => {
+    const toolScript: StreamChunk[] = [
+      { type: 'block-start', index: 0, blockType: 'tool-call' },
+      { type: 'tool-call-delta', index: 0, id: CallId('title-tool'), name: 'unexpected', argumentsDelta: '{}' },
+      { type: 'finish', reason: { kind: 'stop' } },
+    ]
+    const tool = await withScript(toolScript)
+    await expect(generateSessionTitleWithLlm(
+      tool.ctx,
+      resolveSessionTitleLlmConfig(CONFIG),
+      request(),
+      request().messages,
+    )).rejects.toThrow(/output must contain text only/)
+
+    const reasoning = await withScript([
+      { type: 'block-start', index: 0, blockType: 'reasoning' },
+      { type: 'reasoning-delta', index: 0, text: 'no final title' },
+      { type: 'finish', reason: { kind: 'stop' } },
+    ])
+    await expect(generateSessionTitleWithLlm(
+      reasoning.ctx,
+      resolveSessionTitleLlmConfig(CONFIG),
+      request(),
+      request().messages,
+    )).rejects.toThrow(/produced no text/)
+  })
+
+  it('aborts a cooperative model stream at the configured deadline', async () => {
+    vi.useFakeTimers()
+    try {
+      const ctx = new Context()
+      await ctx.plugin(LlmService)
+      ctx.llm.registerAdapter(['current-route'], new CooperativeAdapter())
+      const pending = generateSessionTitleWithLlm(
+        ctx,
+        resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: 10 }),
+        request(),
+        request().messages,
+      )
+      const rejected = expect(pending).rejects.toMatchObject({
+        code: SESSION_TITLE_TIMEOUT_CODE,
+        timeoutMs: 10,
+      })
+      await vi.advanceTimersByTimeAsync(10)
+      await rejected
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+})

+ 16 - 0
packages/session-title/session-title-llm/tsconfig.json

@@ -0,0 +1,16 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": ["src"],
+  "references": [
+    { "path": "../../../vendor/cosmokit" },
+    { "path": "../../../vendor/cordis" },
+    { "path": "../../../vendor/schemastery" },
+    { "path": "../../llm/llm" },
+    { "path": "../../util/timeout" },
+    { "path": "../session-title" }
+  ]
+}

+ 52 - 0
packages/session-title/session-title/README.md

@@ -0,0 +1,52 @@
+# @deepseek-ai/dsh-session-title
+
+Log-backed session titles with an immediate deterministic fallback and one optional asynchronous provider. Every accepted revision is a log-only `session/title` event; `foldSessionTitle()` and `ctx.sessionTitle.get()` select the latest event and return its event seq and timestamp.
+
+Only text blocks from human `user/message` events are eligible. The first eligible prompt schedules a fallback from its first words within the configured UTF-8 byte limit. Whitespace is normalized, terminal control sequences are removed, and truncation never splits a code point. Empty and non-text prompts wait for later eligible input.
+
+## Service: `SessionTitleService` (ctx key: `sessionTitle`)
+
+- `get(session)` folds the latest accepted title from a live or replayed log.
+- `refresh(session, signal?)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages. Provider errors and caller cancellation reject.
+- `register(provider)` installs the sole optional provider and returns its Cordis effect disposer. A second registration throws immediately; disposal aborts pending and active calls before another provider can register.
+
+Automatic work never delays the main agent response. A provider starts after the matching `request/header` records the main request's exact route; its late completion joins an open turn or uses a flushed zero-step `session-title` turn through `ctx.sessions.appendOutOfBand()`. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append.
+
+Forks inherit title events in their seed unchanged. The first-message cadence does not automatically retitle a child; the all-messages cadence may append a new revision after the child receives a later human prompt.
+
+## Configuration
+
+All limits are required; the library supplies no defaults.
+
+| Key | Contract |
+|---|---|
+| `fallbackMaxWords` | Positive maximum whitespace-delimited words in the deterministic fallback. |
+| `fallbackMaxBytes` | Positive maximum UTF-8 bytes in the fallback; must not exceed `maxTitleBytes`. |
+| `maxTitleBytes` | Positive maximum UTF-8 bytes accepted from any source. |
+
+## Provider contract
+
+A provider supplies a branded stable id, automatic mode (`first-message` or `all-user-messages`), and `generate(request)`. The request carries the live session, all eligible messages through one fixed revision, the current logged main-request route when available, and cancellation. The result identifies a non-empty title, unique ordered source-message seqs from that request, and optional model provenance. The service normalizes and validates the result before it becomes durable.
+
+See the [session-title data structures](../../../docs/core-data-structures/session-title.md) and [implemented decision](../../../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md).
+
+## Model Experience
+
+### Session title state
+
+#### What the model sees
+
+Nothing. `session/title` is log-only and never enters the session surface, `deriveMessages()`, system prompt, tool schemas, or request prefix.
+
+#### Token effect
+
+The fallback and accepted provider revisions add zero tokens to the main agent request. An optional provider's separate auxiliary request is documented by that provider package.
+
+#### KV Cache effect
+
+None for the main request; title events do not change its reconstructed content or cache key.
+
+## Known Limitations and Deferred Work
+
+- Manual rename, title deletion, generated-versus-user precedence, search, and list indexing are outside this service.
+- The provider registry deliberately accepts at most one implementation, so a deployment cannot compose competing title strategies without writing one provider that owns their precedence.

+ 41 - 0
packages/session-title/session-title/package.json

@@ -0,0 +1,41 @@
+{
+  "name": "@deepseek-ai/dsh-session-title",
+  "description": "Log-backed session title service and provider registry for the DeepSeek Harness",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-brand": "^0.0.1",
+    "@deepseek-ai/dsh-llm": "^0.0.1",
+    "@deepseek-ai/dsh-session": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "dependencies": {
+    "schemastery": "^3.18.0"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-brand": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
+    "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
+    "cordis": "^4.0.0-rc.7"
+  }
+}

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

@@ -0,0 +1,556 @@
+/**
+ * Log-backed session title service, deterministic fallback, and provider seam.
+ * @module @deepseek-ai/dsh-session-title
+ */
+
+import { Context, Service } from 'cordis'
+import z from 'schemastery'
+import type { Branded } from '@deepseek-ai/dsh-brand'
+import { deepFreeze } from '@deepseek-ai/dsh-llm'
+import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+import { fallbackSessionTitle, normalizeSessionTitle } from './normalize.ts'
+
+export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from './normalize.ts'
+
+/** Identifies one session-title provider registration. */
+export type SessionTitleProviderId = Branded<'SessionTitleProviderId'>
+
+/**
+ * Brand a raw provider id.
+ * @param id - stable non-empty provider identifier supplied by a plugin.
+ * @returns the same string with the session-title provider brand.
+ */
+export function SessionTitleProviderId(id: string): SessionTitleProviderId {
+  return id as SessionTitleProviderId
+}
+
+/** Exact auxiliary model route that produced a title. */
+export interface SessionTitleModelProvenance {
+  /** Registered LLM provider route. */
+  readonly provider: string
+  /** Provider model id. */
+  readonly model: string
+}
+
+/** Durable ownership record for an accepted session title. */
+export type SessionTitleSource =
+  | { readonly kind: 'fallback' }
+  | {
+    readonly kind: 'provider'
+    readonly provider: SessionTitleProviderId
+    readonly model?: SessionTitleModelProvenance
+  }
+
+/** Payload of the log-only `session/title` event. */
+export interface SessionTitleEventData {
+  /** Normalized non-empty title text. */
+  readonly title: string
+  /** Exact human `user/message` seqs used to derive this title. */
+  readonly messageSeqs: number[]
+  /** Built-in fallback or registered-provider provenance. */
+  readonly source: SessionTitleSource
+}
+
+/** Latest folded title plus the title event's durable envelope facts. */
+export interface SessionTitleSnapshot extends SessionTitleEventData {
+  /** Seq of the latest `session/title` event. */
+  readonly eventSeq: number
+  /** Timestamp of the latest `session/title` event. */
+  readonly updatedAt: number
+}
+
+/** Required deterministic fallback and accepted-title limits. */
+export interface Config {
+  /** Maximum whitespace-delimited words in the built-in fallback. */
+  readonly fallbackMaxWords: number
+  /** Maximum UTF-8 bytes in the built-in fallback. */
+  readonly fallbackMaxBytes: number
+  /** Maximum UTF-8 bytes in any accepted title. */
+  readonly maxTitleBytes: number
+}
+
+declare module 'cordis' {
+  interface Context {
+    sessionTitle: SessionTitleService
+  }
+}
+
+declare module '@deepseek-ai/dsh-session' {
+  interface TurnTriggerMap {
+    /** Zero-step turn opened only to durably append a late title update. */
+    'session-title': { kind: 'session-title' }
+  }
+
+  interface SessionEventMap {
+    /**
+     * Latest-wins session title snapshot. Log-only: it never enters the model
+     * surface or derived history.
+     */
+    'session/title': SessionTitleEventData
+  }
+
+  interface OutOfBandSessionEventMap {
+    'session/title': true
+  }
+}
+
+/** One eligible human text message exposed to title providers. */
+export interface SessionTitleUserMessage {
+  /** Source `user/message` event seq. */
+  readonly seq: number
+  /** Exact concatenated text-block content. */
+  readonly text: string
+}
+
+/** Automatic generation cadence owned by a registered provider. */
+export type SessionTitleAutomaticMode = 'first-message' | 'all-user-messages'
+
+/** Immutable input supplied to one title-provider call. */
+export interface SessionTitleProviderRequest {
+  /** Live session being titled. */
+  readonly session: Session
+  /** All eligible human messages through this generation revision. */
+  readonly messages: readonly SessionTitleUserMessage[]
+  /** Exact current logged main-request route, when one has been recorded. */
+  readonly route?: SessionTitleModelProvenance
+  /** Cancellation for supersession, disposal, timeout composition, or the explicit caller. */
+  readonly signal: AbortSignal
+}
+
+/** Provider output before service-owned normalization and durable acceptance. */
+export interface SessionTitleProviderResult {
+  /** Proposed title text. */
+  readonly title: string
+  /** Exact seqs from `request.messages` used by this result. */
+  readonly messageSeqs: readonly number[]
+  /** Auxiliary LLM route, when generation used a model. */
+  readonly model?: SessionTitleModelProvenance
+}
+
+/** One optional asynchronous title implementation registered with the service. */
+export interface SessionTitleProvider {
+  /** Stable provider identity recorded in title provenance. */
+  readonly id: SessionTitleProviderId
+  /** When new human prompts start automatic generation. */
+  readonly automatic: SessionTitleAutomaticMode
+  /**
+   * Produce one title revision.
+   * @param request - message snapshot, current route, session, and cancellation.
+   * @returns proposed title plus exact input seqs and optional model provenance.
+   */
+  generate(request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult>
+}
+
+/**
+ * Collect human text-bearing user messages in log order.
+ * @param events - session log or persisted replay.
+ * @param throughSeq - optional inclusive event boundary.
+ * @returns eligible messages with exact source seqs.
+ */
+export function collectSessionTitleMessages(
+  events: readonly SessionEvent[],
+  throughSeq?: number,
+): SessionTitleUserMessage[] {
+  const messages: SessionTitleUserMessage[] = []
+  for (const event of events) {
+    if (throughSeq !== undefined && event.seq > throughSeq) break
+    if (event.type !== 'user/message' || event.data.source.kind !== 'user') continue
+    const text = event.data.content
+      .filter((block): block is Extract<(typeof event.data.content)[number], { type: 'text' }> => block.type === 'text')
+      .map(block => block.text)
+      .join('\n')
+    if (normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER).length === 0) continue
+    messages.push({ seq: event.seq, text })
+  }
+  return messages
+}
+
+/**
+ * Fold the latest logged title without consulting mutable metadata.
+ * @param events - live or persisted session log.
+ * @returns the latest immutable title snapshot, or `undefined`.
+ */
+export function foldSessionTitle(events: readonly SessionEvent[]): SessionTitleSnapshot | undefined {
+  const event = events.findLast(item => item.type === 'session/title')
+  if (event === undefined) return undefined
+  return deepFreeze({
+    title: event.data.title,
+    messageSeqs: [...event.data.messageSeqs],
+    source: event.data.source.kind === 'fallback'
+      ? { kind: 'fallback' }
+      : {
+        kind: 'provider',
+        provider: event.data.source.provider,
+        ...(event.data.source.model === undefined
+          ? {}
+          : { model: { ...event.data.source.model } }),
+      },
+    eventSeq: event.seq,
+    updatedAt: event.time,
+  })
+}
+
+/** Service-owned resolved limits. */
+interface ResolvedConfig {
+  readonly fallbackMaxWords: number
+  readonly fallbackMaxBytes: number
+  readonly maxTitleBytes: number
+}
+
+/** One exact provider registration generation. */
+interface ProviderRegistration {
+  readonly provider: SessionTitleProvider
+}
+
+/** Automatic work waiting for the matching main-request header. */
+interface PendingAutomaticWork {
+  readonly registration: ProviderRegistration
+  readonly revision: number
+  readonly throughSeq: number
+}
+
+/** Provider call currently allowed to commit for one session. */
+interface ActiveProviderWork extends PendingAutomaticWork {
+  readonly controller: AbortController
+  readonly signal: AbortSignal
+}
+
+/** Mutable concurrency state scoped to one live session. */
+interface SessionTitleWorkState {
+  revision: number
+  pending?: PendingAutomaticWork
+  active?: ActiveProviderWork
+}
+
+/** Validate one positive integer configuration field. */
+function assertPositiveInteger(name: keyof Config, value: number): void {
+  if (!Number.isInteger(value) || value <= 0) {
+    throw new Error(`session-title: ${name} must be a positive integer`)
+  }
+}
+
+/** Log-backed title fold plus asynchronous fallback generation. */
+export class SessionTitleService extends Service {
+  static inject = ['sessions']
+  static Config: z<Config> = z.object({
+    fallbackMaxWords: z.number().step(1).min(1).required(),
+    fallbackMaxBytes: z.number().step(1).min(1).required(),
+    maxTitleBytes: z.number().step(1).min(1).required(),
+  })
+
+  private readonly config: ResolvedConfig
+  private registration: ProviderRegistration | undefined
+  private readonly work = new Map<Session, SessionTitleWorkState>()
+
+  constructor(ctx: Context, config: Config) {
+    super(ctx, 'sessionTitle')
+    const candidate: unknown = config
+    if (candidate === null || typeof candidate !== 'object') {
+      throw new Error('session-title: configuration is required')
+    }
+    const value = candidate as Config
+    assertPositiveInteger('fallbackMaxWords', value.fallbackMaxWords)
+    assertPositiveInteger('fallbackMaxBytes', value.fallbackMaxBytes)
+    assertPositiveInteger('maxTitleBytes', value.maxTitleBytes)
+    if (value.fallbackMaxBytes > value.maxTitleBytes) {
+      throw new Error('session-title: fallbackMaxBytes must not exceed maxTitleBytes')
+    }
+    this.config = deepFreeze({ ...value })
+
+    ctx.on('session/event', (session, event) => {
+      switch (event.type) {
+        case 'user/message':
+          this.onUserMessage(session, event)
+          break
+        case 'request/header':
+          this.onRequestHeader(session, event)
+          break
+        default:
+          break
+      }
+    })
+    ctx.on('session/disposed', (session) => {
+      const state = this.work.get(session)
+      if (state === undefined) return
+      state.active?.controller.abort(new Error('session disposed during title generation'))
+      this.work.delete(session)
+    })
+  }
+
+  /**
+   * Read the latest folded title from one live or replayed session.
+   * @param session - session whose log is the title source of truth.
+   * @returns latest title snapshot, or `undefined` before eligible input.
+   */
+  get(session: Session): SessionTitleSnapshot | undefined {
+    return foldSessionTitle(session.events)
+  }
+
+  /**
+   * Explicitly retry the registered provider, or materialize the built-in
+   * fallback when no provider is registered.
+   * @param session - exact live session to refresh.
+   * @param signal - optional caller cancellation.
+   * @returns latest accepted title, or `undefined` when no eligible text exists.
+   */
+  async refresh(session: Session, signal?: AbortSignal): Promise<SessionTitleSnapshot | undefined> {
+    signal?.throwIfAborted()
+    if (this.ctx.sessions.get(session.id) !== session) {
+      throw new Error(`session "${session.id}" is not live in this store`)
+    }
+    const fallback = await this.ensureFallback(session)
+    const registration = this.registration
+    if (registration === undefined) return fallback
+    const messages = collectSessionTitleMessages(session.events)
+    const latest = messages.at(-1)
+    if (latest === undefined) return fallback
+    const state = this.stateFor(session)
+    const revision = this.supersede(state, 'explicit title refresh superseded older generation')
+    const work = this.activate({
+      registration,
+      revision,
+      throughSeq: latest.seq,
+    }, state, signal)
+    const config = session.requestHeader()?.config
+    const route = config === undefined ? undefined : { provider: config.provider, model: config.model }
+    return this.runProvider(session, work, route)
+  }
+
+  /**
+   * Register the sole optional title provider. Disposal aborts its pending and
+   * active work before another provider may register.
+   * @param provider - provider identity, cadence, and generation function.
+   * @returns exact Cordis effect disposer for HMR-safe unregistration.
+   */
+  register(provider: SessionTitleProvider): () => void {
+    this.validateProvider(provider)
+    if (this.registration !== undefined) {
+      throw new Error(`session-title provider "${this.registration.provider.id}" is already registered`)
+    }
+    const registration: ProviderRegistration = {
+      provider,
+    }
+    const dispose = this.ctx.effect(function* (this: SessionTitleService) {
+      this.registration = registration
+      yield () => {
+        this.registration = undefined
+        for (const state of this.work.values()) {
+          delete state.pending
+          state.active?.controller.abort(new Error(`session-title provider "${provider.id}" was disposed`))
+        }
+      }
+    }.bind(this), 'sessionTitle.register()')
+    // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact effect disposer preserves owner teardown ordering
+    return dispose
+  }
+
+  /** Schedule fallback creation and any provider cadence for one eligible event. */
+  private onUserMessage(session: Session, event: Extract<SessionEvent, { type: 'user/message' }>): void {
+    if (event.data.source.kind !== 'user' || collectSessionTitleMessages([event]).length === 0) return
+    const registration = this.registration
+    if (registration !== undefined) {
+      const messages = collectSessionTitleMessages(session.events, event.seq)
+      const shouldSchedule = registration.provider.automatic === 'all-user-messages'
+        || (session.header.parentSession === undefined && messages.length === 1 && this.get(session) === undefined)
+      if (shouldSchedule) {
+        const state = this.stateFor(session)
+        const revision = this.supersede(state, 'newer user message superseded title generation')
+        state.pending = { registration, revision, throughSeq: event.seq }
+      }
+    }
+    queueMicrotask(() => {
+      void this.ensureFallback(session).catch((error: unknown) => {
+        this.ctx.logger.warn(`session "${session.id}": fallback title update failed: ${String(error)}`)
+      })
+    })
+  }
+
+  /** Start pending automatic work only after its exact main-request route is logged. */
+  private onRequestHeader(session: Session, event: Extract<SessionEvent, { type: 'request/header' }>): void {
+    const state = this.work.get(session)
+    const pending = state?.pending
+    if (state === undefined || pending === undefined || pending.throughSeq >= event.seq) return
+    delete state.pending
+    const route = {
+      provider: event.data.header.config.provider,
+      model: event.data.header.config.model,
+    }
+    queueMicrotask(() => {
+      if (this.registration !== pending.registration || state.revision !== pending.revision) return
+      const work = this.activate(pending, state)
+      void this.runProvider(session, work, route).catch((error: unknown) => {
+        if (work.signal.aborted) return
+        this.ctx.logger.warn(`session "${session.id}": automatic title generation failed: ${String(error)}`)
+      })
+    })
+  }
+
+  /** Execute and durably accept one current provider revision. */
+  private async runProvider(
+    session: Session,
+    work: ActiveProviderWork,
+    route?: SessionTitleModelProvenance,
+  ): Promise<SessionTitleSnapshot | undefined> {
+    try {
+      await this.ensureFallback(session)
+      this.assertCurrent(session, work)
+      const messages = collectSessionTitleMessages(session.events, work.throughSeq)
+      const result = await work.registration.provider.generate({
+        session,
+        messages,
+        ...route === undefined ? {} : { route },
+        signal: work.signal,
+      })
+      this.assertCurrent(session, work)
+      const accepted = this.validateResult(result, messages)
+      await this.ctx.sessions.appendOutOfBand(session, 'session/title', {
+        title: accepted.title,
+        messageSeqs: [...accepted.messageSeqs],
+        source: {
+          kind: 'provider',
+          provider: work.registration.provider.id,
+          ...accepted.model === undefined ? {} : { model: accepted.model },
+        },
+      }, { kind: 'session-title' })
+      return this.get(session)
+    } finally {
+      const state = this.work.get(session)
+      if (state?.active === work) delete state.active
+    }
+  }
+
+  /** Validate and normalize provider output against the supplied message snapshot. */
+  private validateResult(
+    result: unknown,
+    messages: readonly SessionTitleUserMessage[],
+  ): SessionTitleProviderResult {
+    if (result === null || typeof result !== 'object') {
+      throw new Error('session-title provider returned an invalid result')
+    }
+    const candidate = result as Record<string, unknown>
+    if (typeof candidate.title !== 'string') throw new Error('session-title provider title must be a string')
+    const title = normalizeSessionTitle(candidate.title, this.config.maxTitleBytes)
+    if (title.length === 0) throw new Error('session-title provider returned an empty title')
+    if (!Array.isArray(candidate.messageSeqs) || candidate.messageSeqs.length === 0) {
+      throw new Error('session-title provider must identify at least one source message seq')
+    }
+    const messageSeqs: number[] = []
+    const order = new Map(messages.map((message, index) => [message.seq, index]))
+    let previous = -1
+    for (const seq of candidate.messageSeqs as unknown[]) {
+      if (typeof seq !== 'number') {
+        throw new Error('session-title provider messageSeqs must be unique, ordered seqs from the request')
+      }
+      const index = order.get(seq)
+      if (!Number.isSafeInteger(seq) || seq < 0 || index === undefined || index <= previous) {
+        throw new Error('session-title provider messageSeqs must be unique, ordered seqs from the request')
+      }
+      messageSeqs.push(seq)
+      previous = index
+    }
+    const modelCandidate = candidate.model
+    let model: SessionTitleModelProvenance | undefined
+    if (modelCandidate !== undefined) {
+      if (modelCandidate === null || typeof modelCandidate !== 'object') {
+        throw new Error('session-title provider model provenance requires non-empty provider and model')
+      }
+      const record = modelCandidate as Record<string, unknown>
+      if (typeof record.provider !== 'string' || record.provider.length === 0
+        || typeof record.model !== 'string' || record.model.length === 0) {
+        throw new Error('session-title provider model provenance requires non-empty provider and model')
+      }
+      model = { provider: record.provider, model: record.model }
+    }
+    return {
+      title,
+      messageSeqs,
+      ...(model === undefined ? {} : { model }),
+    }
+  }
+
+  /** Fail a completion whose provider, revision, session, or signal is stale. */
+  private assertCurrent(session: Session, work: ActiveProviderWork): void {
+    work.signal.throwIfAborted()
+    const state = this.work.get(session)
+    /* v8 ignore next -- every supported supersession, provider disposal, and session disposal aborts
+     * the work signal before changing this state. */
+    if (this.registration !== work.registration
+      || state?.active !== work
+      || state.revision !== work.revision
+      || this.ctx.sessions.get(session.id) !== session) {
+      throw new Error('session title generation state changed without cancellation')
+    }
+  }
+
+  /** Create and publish an active provider call from one fixed revision. */
+  private activate(
+    pending: PendingAutomaticWork,
+    state: SessionTitleWorkState,
+    upstream?: AbortSignal,
+  ): ActiveProviderWork {
+    const controller = new AbortController()
+    const signal = upstream === undefined
+      ? controller.signal
+      : AbortSignal.any([controller.signal, upstream])
+    const work: ActiveProviderWork = { ...pending, controller, signal }
+    state.active = work
+    return work
+  }
+
+  /** Abort older active work and reserve the next session-local revision. */
+  private supersede(state: SessionTitleWorkState, reason: string): number {
+    state.active?.controller.abort(new Error(reason))
+    delete state.pending
+    state.revision += 1
+    return state.revision
+  }
+
+  /** Return mutable work state for one session. */
+  private stateFor(session: Session): SessionTitleWorkState {
+    let state = this.work.get(session)
+    if (state === undefined) {
+      state = { revision: 0 }
+      this.work.set(session, state)
+    }
+    return state
+  }
+
+  /** Reject malformed provider registrations before publishing an effect. */
+  private validateProvider(provider: unknown): asserts provider is SessionTitleProvider {
+    if (provider === null || typeof provider !== 'object') {
+      throw new Error('session-title provider must be an object')
+    }
+    const candidate = provider as Record<string, unknown>
+    if (typeof candidate.id !== 'string' || candidate.id.length === 0) {
+      throw new Error('session-title provider id must be a non-empty string')
+    }
+    if (candidate.automatic !== 'first-message' && candidate.automatic !== 'all-user-messages') {
+      throw new Error('session-title provider automatic mode is invalid')
+    }
+    if (typeof candidate.generate !== 'function') {
+      throw new Error(`session-title provider "${candidate.id}" requires generate()`)
+    }
+  }
+
+  /** Create the first deterministic fallback if the session still lacks a title. */
+  private async ensureFallback(session: Session): Promise<SessionTitleSnapshot | undefined> {
+    const current = this.get(session)
+    if (current !== undefined) return current
+    const [first] = collectSessionTitleMessages(session.events)
+    if (first === undefined) return undefined
+    const title = fallbackSessionTitle(
+      first.text,
+      this.config.fallbackMaxWords,
+      this.config.fallbackMaxBytes,
+    )
+    if (title.length === 0) return undefined
+    await this.ctx.sessions.appendOutOfBand(session, 'session/title', {
+      title,
+      messageSeqs: [first.seq],
+      source: { kind: 'fallback' },
+    }, { kind: 'session-title' })
+    return this.get(session)
+  }
+}
+
+export default SessionTitleService

+ 74 - 0
packages/session-title/session-title/src/normalize.ts

@@ -0,0 +1,74 @@
+/** Title text normalization and UTF-8-safe truncation. */
+
+/** Operating-system-command escape sequences, including unterminated tails. */
+const OSC_SEQUENCE = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu
+/** Control-sequence-introducer escapes such as SGR color codes. */
+const CSI_SEQUENCE = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu
+/** Remaining two-byte ESC control sequences. */
+const ESC_SEQUENCE = /\u001B[@-_]/gu
+/** Non-whitespace C0/C1 control characters. */
+const CONTROL_CHARACTER = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/gu
+/** Directional and invisible controls that can make a displayed title deceptive. */
+const DIRECTIONAL_CONTROL = /[\u200B\u200E\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\uFEFF]/gu
+
+/** Reject an invalid public text limit. */
+function assertPositiveInteger(name: string, value: number): void {
+  if (!Number.isInteger(value) || value <= 0) {
+    throw new Error(`${name} must be a positive integer`)
+  }
+}
+
+/** Remove controls and produce one trimmed, whitespace-normalized line. */
+function cleanTitleText(input: string): string {
+  return input
+    .replace(OSC_SEQUENCE, '')
+    .replace(CSI_SEQUENCE, '')
+    .replace(ESC_SEQUENCE, '')
+    .replace(CONTROL_CHARACTER, '')
+    .replace(DIRECTIONAL_CONTROL, '')
+    .replace(/\s+/gu, ' ')
+    .trim()
+}
+
+/**
+ * Truncate a string to a UTF-8 byte budget without splitting a Unicode code point.
+ * @param input - normalized title text.
+ * @param maxBytes - positive UTF-8 byte budget.
+ * @returns the longest leading code-point prefix within the budget.
+ */
+export function truncateTitleUtf8(input: string, maxBytes: number): string {
+  assertPositiveInteger('maxBytes', maxBytes)
+  if (Buffer.byteLength(input, 'utf8') <= maxBytes) return input
+  let used = 0
+  let output = ''
+  for (const character of input) {
+    const bytes = Buffer.byteLength(character, 'utf8')
+    if (used + bytes > maxBytes) break
+    output += character
+    used += bytes
+  }
+  return output
+}
+
+/**
+ * Normalize one accepted session title and enforce its UTF-8 byte budget.
+ * @param input - untrusted title text.
+ * @param maxBytes - positive maximum encoded size.
+ * @returns a terminal-safe one-line title, possibly empty after sanitization.
+ */
+export function normalizeSessionTitle(input: string, maxBytes: number): string {
+  return truncateTitleUtf8(cleanTitleText(input), maxBytes).trimEnd()
+}
+
+/**
+ * Derive the deterministic first-message fallback.
+ * @param input - text from the first eligible human message.
+ * @param maxWords - positive whitespace-delimited word cap.
+ * @param maxBytes - positive UTF-8 byte cap.
+ * @returns the normalized leading words within both limits.
+ */
+export function fallbackSessionTitle(input: string, maxWords: number, maxBytes: number): string {
+  assertPositiveInteger('maxWords', maxWords)
+  const words = cleanTitleText(input).split(' ').filter(Boolean).slice(0, maxWords)
+  return truncateTitleUtf8(words.join(' '), maxBytes).trimEnd()
+}

+ 91 - 0
packages/session-title/session-title/tests/persistence.spec.ts

@@ -0,0 +1,91 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
+import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite'
+import SessionTitleService, { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
+
+const CONFIG = {
+  fallbackMaxWords: 5,
+  fallbackMaxBytes: 40,
+  maxTitleBytes: 80,
+} as const
+
+const roots: string[] = []
+
+afterEach(async () => {
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
+})
+
+async function appendPersistedTitle(ctx: Context, id: ReturnType<typeof SessionId>): Promise<void> {
+  const session = ctx.sessions.create(id)
+  session.append('turn/start', {
+    turn: 1,
+    trigger: { kind: 'message', source: { kind: 'user' } },
+  })
+  session.append('user/message', {
+    content: [{ type: 'text', text: 'Persist this session title' }],
+    source: { kind: 'user' },
+  }, { surfaceOp: 'append' })
+  await new Promise(resolve => setTimeout(resolve, 0))
+  session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+  await ctx.parallel('session/flush', session)
+}
+
+async function expectPersistedTitle(ctx: Context, id: ReturnType<typeof SessionId>): Promise<void> {
+  const loaded = await ctx.sessionPersistence.load(id)
+  expect(foldSessionTitle(loaded.events)).toMatchObject({
+    title: 'Persist this session title',
+    messageSeqs: [1],
+    source: { kind: 'fallback' },
+    eventSeq: 2,
+  })
+  expect(loaded.events.map(event => event.type)).toEqual([
+    'turn/start',
+    'user/message',
+    'session/title',
+    'turn/end',
+  ])
+}
+
+describe('session title persistence round trips', () => {
+  it('round-trips through a remounted JSONL backend', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-title-jsonl-'))
+    roots.push(root)
+    const id = SessionId('title-jsonl')
+    const writer = new Context()
+    await writer.plugin(SessionStore)
+    await writer.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
+    await writer.plugin(SessionTitleService, CONFIG)
+    await appendPersistedTitle(writer, id)
+    await writer.fiber.dispose()
+
+    const reader = new Context()
+    await reader.plugin(SessionStore)
+    await reader.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
+    await expectPersistedTitle(reader, id)
+    await reader.fiber.dispose()
+  })
+
+  it('round-trips through a remounted SQLite backend', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-title-sqlite-'))
+    roots.push(root)
+    const path = join(root, 'sessions.db')
+    const id = SessionId('title-sqlite')
+    const writer = new Context()
+    await writer.plugin(SessionStore)
+    await writer.plugin(SessionPersistenceSqlite, { path })
+    await writer.plugin(SessionTitleService, CONFIG)
+    await appendPersistedTitle(writer, id)
+    await writer.fiber.dispose()
+
+    const reader = new Context()
+    await reader.plugin(SessionStore)
+    await reader.plugin(SessionPersistenceSqlite, { path })
+    await expectPersistedTitle(reader, id)
+    await reader.fiber.dispose()
+  })
+})

+ 289 - 0
packages/session-title/session-title/tests/provider.spec.ts

@@ -0,0 +1,289 @@
+import { Context } from 'cordis'
+import { describe, expect, it, vi } from 'vitest'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SessionTitleService, {
+  SessionTitleProviderId,
+  type SessionTitleProvider,
+  type SessionTitleProviderRequest,
+  type SessionTitleProviderResult,
+} from '@deepseek-ai/dsh-session-title'
+
+const CONFIG = {
+  fallbackMaxWords: 5,
+  fallbackMaxBytes: 24,
+  maxTitleBytes: 24,
+} as const
+
+function deferred<T>(): {
+  promise: Promise<T>
+  resolve(value: T): void
+  reject(error: unknown): void
+} {
+  let resolve!: (value: T) => void
+  let reject!: (error: unknown) => void
+  const promise = new Promise<T>((accept, decline) => {
+    resolve = accept
+    reject = decline
+  })
+  return { promise, resolve, reject }
+}
+
+async function settle(): Promise<void> {
+  await new Promise(resolve => setTimeout(resolve, 0))
+}
+
+function appendHumanPrompt(session: ReturnType<Context['sessions']['create']>, text: string) {
+  return session.append('user/message', {
+    content: [{ type: 'text', text }],
+    source: { kind: 'user' },
+  }, { surfaceOp: 'append' })
+}
+
+function appendRoute(session: ReturnType<Context['sessions']['create']>, reason: 'initial' | 'change' = 'initial'): void {
+  session.append('request/header', {
+    header: { config: { provider: 'main-route', model: 'chat-model' } },
+    reason,
+  })
+}
+
+describe('SessionTitleService provider lifecycle', () => {
+  it('inherits title events across forks, skips first-message retitling, and lets all-messages update later', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionTitleService, CONFIG)
+    const parent = ctx.sessions.create(SessionId('title-parent'))
+    parent.append('turn/start', {
+      turn: 1,
+      trigger: { kind: 'message', source: { kind: 'user' } },
+    })
+    const inheritedMessage = appendHumanPrompt(parent, 'Inherited title prompt')
+    await settle()
+    parent.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+
+    const child = ctx.sessions.fork(parent, undefined, SessionId('title-child'))
+    expect(ctx.sessionTitle.get(child)).toEqual(ctx.sessionTitle.get(parent))
+    expect(child.events.find(event => event.type === 'session/title'))
+      .toEqual(parent.events.find(event => event.type === 'session/title'))
+
+    const firstGenerate = vi.fn(async (request: SessionTitleProviderRequest) => ({
+      title: 'Should not run',
+      messageSeqs: [request.messages[0]!.seq],
+    }))
+    const disposeFirst = ctx.sessionTitle.register({
+      id: SessionTitleProviderId('fork-first'),
+      automatic: 'first-message',
+      generate: firstGenerate,
+    })
+    child.append('turn/start', {
+      turn: 2,
+      trigger: { kind: 'message', source: { kind: 'user' } },
+    })
+    const childMessage = appendHumanPrompt(child, 'Child follow-up prompt')
+    await settle()
+    appendRoute(child)
+    await settle()
+    child.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
+    expect(firstGenerate).not.toHaveBeenCalled()
+    disposeFirst()
+
+    const allGenerate = vi.fn(async (request: SessionTitleProviderRequest) => ({
+      title: 'Fork all prompts',
+      messageSeqs: request.messages.map(message => message.seq),
+    }))
+    ctx.sessionTitle.register({
+      id: SessionTitleProviderId('fork-all'),
+      automatic: 'all-user-messages',
+      generate: allGenerate,
+    })
+    child.append('turn/start', {
+      turn: 3,
+      trigger: { kind: 'message', source: { kind: 'user' } },
+    })
+    const latestMessage = appendHumanPrompt(child, 'Retitle the fork now')
+    await settle()
+    appendRoute(child, 'change')
+    await settle()
+    child.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
+
+    expect(allGenerate).toHaveBeenCalledOnce()
+    expect(ctx.sessionTitle.get(child)).toMatchObject({
+      title: 'Fork all prompts',
+      messageSeqs: [inheritedMessage.seq, childMessage.seq, latestMessage.seq],
+      source: { kind: 'provider', provider: SessionTitleProviderId('fork-all') },
+    })
+    expect(ctx.sessionTitle.get(parent)?.title).toBe('Inherited title prompt')
+  })
+
+  it('runs a first-message provider once after the routed request and retries only through refresh', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionTitleService, CONFIG)
+    const requests: SessionTitleProviderRequest[] = []
+    const provider: SessionTitleProvider = {
+      id: SessionTitleProviderId('first-model'),
+      automatic: 'first-message',
+      async generate(request) {
+        requests.push(request)
+        return {
+          title: '\u001B[31m  A   model-generated title that is too long  ',
+          messageSeqs: [request.messages[0]!.seq],
+          model: { provider: 'aux-route', model: 'title-model' },
+        }
+      },
+    }
+    ctx.sessionTitle.register(provider)
+    const session = ctx.sessions.create(SessionId('first-provider'))
+    session.append('turn/start', {
+      turn: 1,
+      trigger: { kind: 'message', source: { kind: 'user' } },
+    })
+    const first = appendHumanPrompt(session, 'Explain asynchronous title generation')
+    await settle()
+    expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
+
+    appendRoute(session)
+    await settle()
+
+    expect(requests).toHaveLength(1)
+    expect(requests[0]).toMatchObject({
+      session,
+      messages: [{ seq: first.seq, text: 'Explain asynchronous title generation' }],
+      route: { provider: 'main-route', model: 'chat-model' },
+    })
+    expect(ctx.sessionTitle.get(session)).toMatchObject({
+      title: 'A model-generated title',
+      messageSeqs: [first.seq],
+      source: {
+        kind: 'provider',
+        provider: SessionTitleProviderId('first-model'),
+        model: { provider: 'aux-route', model: 'title-model' },
+      },
+    })
+
+    const second = appendHumanPrompt(session, 'A later prompt')
+    appendRoute(session, 'change')
+    await settle()
+    expect(requests).toHaveLength(1)
+
+    await ctx.sessionTitle.refresh(session)
+    expect(requests).toHaveLength(2)
+    expect(requests[1]?.messages.map(message => message.seq)).toEqual([first.seq, second.seq])
+  })
+
+  it('rejects a second provider and aborts stale work when the winner is disposed', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionTitleService, CONFIG)
+    const pending = deferred<SessionTitleProviderResult>()
+    let observedSignal: AbortSignal | undefined
+    const first: SessionTitleProvider = {
+      id: SessionTitleProviderId('winner'),
+      automatic: 'all-user-messages',
+      generate(request) {
+        observedSignal = request.signal
+        return pending.promise
+      },
+    }
+    const dispose = ctx.sessionTitle.register(first)
+    expect(() => ctx.sessionTitle.register({
+      id: SessionTitleProviderId('duplicate'),
+      automatic: 'first-message',
+      generate: async () => ({ title: 'duplicate', messageSeqs: [0] }),
+    })).toThrow(/already registered/)
+
+    const session = ctx.sessions.create(SessionId('dispose-provider'))
+    session.append('turn/start', {
+      turn: 1,
+      trigger: { kind: 'message', source: { kind: 'user' } },
+    })
+    const message = appendHumanPrompt(session, 'Generate this title')
+    await settle()
+    appendRoute(session)
+    await settle()
+    expect(observedSignal?.aborted).toBe(false)
+
+    dispose()
+    expect(observedSignal?.aborted).toBe(true)
+    pending.resolve({ title: 'stale provider result', messageSeqs: [message.seq] })
+    await settle()
+    expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
+
+    const replacement: SessionTitleProvider = {
+      id: SessionTitleProviderId('replacement'),
+      automatic: 'first-message',
+      generate: async () => ({ title: 'replacement', messageSeqs: [message.seq] }),
+    }
+    const disposeReplacement = ctx.sessionTitle.register(replacement)
+    disposeReplacement()
+  })
+
+  it('supersedes an older all-messages revision and cannot commit an ignored abort', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionTitleService, CONFIG)
+    const firstResult = deferred<SessionTitleProviderResult>()
+    const requests: SessionTitleProviderRequest[] = []
+    const provider: SessionTitleProvider = {
+      id: SessionTitleProviderId('all-model'),
+      automatic: 'all-user-messages',
+      generate(request) {
+        requests.push(request)
+        if (requests.length === 1) return firstResult.promise
+        return Promise.resolve({
+          title: 'Newest complete title',
+          messageSeqs: request.messages.map(message => message.seq),
+        })
+      },
+    }
+    ctx.sessionTitle.register(provider)
+    const session = ctx.sessions.create(SessionId('supersede'))
+    session.append('turn/start', {
+      turn: 1,
+      trigger: { kind: 'message', source: { kind: 'user' } },
+    })
+    const first = appendHumanPrompt(session, 'First prompt')
+    await settle()
+    appendRoute(session)
+    await settle()
+
+    const second = appendHumanPrompt(session, 'Second prompt')
+    expect(requests[0]?.signal.aborted).toBe(true)
+    appendRoute(session, 'change')
+    await settle()
+    expect(ctx.sessionTitle.get(session)).toMatchObject({
+      title: 'Newest complete title',
+      messageSeqs: [first.seq, second.seq],
+    })
+
+    firstResult.resolve({ title: 'Old ignored result', messageSeqs: [first.seq] })
+    await settle()
+    expect(ctx.sessionTitle.get(session)?.title).toBe('Newest complete title')
+  })
+
+  it('contains automatic failures but lets explicit refresh reject', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionTitleService, CONFIG)
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
+    const provider: SessionTitleProvider = {
+      id: SessionTitleProviderId('failing'),
+      automatic: 'all-user-messages',
+      generate: async () => { throw new Error('title backend failed') },
+    }
+    ctx.sessionTitle.register(provider)
+    const session = ctx.sessions.create(SessionId('failure'))
+    session.append('turn/start', {
+      turn: 1,
+      trigger: { kind: 'message', source: { kind: 'user' } },
+    })
+    appendHumanPrompt(session, 'Keep a fallback')
+    await settle()
+    appendRoute(session)
+    await settle()
+
+    expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
+    expect(warn).toHaveBeenCalledWith(expect.stringContaining('automatic title generation failed'))
+    await expect(ctx.sessionTitle.refresh(session)).rejects.toThrow('title backend failed')
+    warn.mockRestore()
+  })
+})

+ 287 - 0
packages/session-title/session-title/tests/service-contracts.spec.ts

@@ -0,0 +1,287 @@
+import { Context } from 'cordis'
+import { describe, expect, it, vi } from 'vitest'
+import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
+import SessionTitleService, {
+  SessionTitleProviderId,
+  type Config,
+  type SessionTitleProvider,
+  type SessionTitleProviderRequest,
+  type SessionTitleProviderResult,
+} from '@deepseek-ai/dsh-session-title'
+
+const CONFIG = {
+  fallbackMaxWords: 5,
+  fallbackMaxBytes: 40,
+  maxTitleBytes: 80,
+} as const
+
+function deferred<T>(): { promise: Promise<T>; resolve(value: T): void } {
+  let resolve!: (value: T) => void
+  const promise = new Promise<T>((accept) => { resolve = accept })
+  return { promise, resolve }
+}
+
+async function settle(): Promise<void> {
+  await new Promise(resolve => setTimeout(resolve, 0))
+}
+
+async function setup(config: Config = CONFIG): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SessionTitleService, config)
+  return ctx
+}
+
+function startSession(ctx: Context, id: string): ReturnType<Context['sessions']['create']> {
+  const session = ctx.sessions.create(SessionId(id))
+  session.append('turn/start', {
+    turn: 1,
+    trigger: { kind: 'message', source: { kind: 'user' } },
+  })
+  return session
+}
+
+function appendPrompt(session: ReturnType<Context['sessions']['create']>, text: string) {
+  return session.append('user/message', {
+    content: [{ type: 'text', text }],
+    source: { kind: 'user' },
+  }, { surfaceOp: 'append' })
+}
+
+describe('SessionTitleService configuration and refresh boundaries', () => {
+  it('requires explicit positive limits with a fallback cap no larger than the accepted-title cap', () => {
+    expect(() => new SessionTitleService(new Context(), undefined as never))
+      .toThrow('configuration is required')
+    expect(() => new SessionTitleService(new Context(), null as never))
+      .toThrow('configuration is required')
+    expect(() => new SessionTitleService(new Context(), { ...CONFIG, fallbackMaxWords: 0 }))
+      .toThrow(/fallbackMaxWords must be a positive integer/)
+    expect(() => new SessionTitleService(new Context(), { ...CONFIG, fallbackMaxWords: 1.5 }))
+      .toThrow(/fallbackMaxWords must be a positive integer/)
+    expect(() => new SessionTitleService(new Context(), { ...CONFIG, fallbackMaxBytes: 81 }))
+      .toThrow(/fallbackMaxBytes must not exceed maxTitleBytes/)
+  })
+
+  it('returns no title for empty input with or without a provider, and rejects detached or pre-aborted refreshes', async () => {
+    const fallbackOnly = await setup()
+    const empty = fallbackOnly.sessions.create(SessionId('empty-fallback'))
+    await expect(fallbackOnly.sessionTitle.refresh(empty)).resolves.toBeUndefined()
+
+    const withProvider = await setup()
+    const generate = vi.fn(async (): Promise<SessionTitleProviderResult> => ({
+      title: 'unused',
+      messageSeqs: [0],
+    }))
+    withProvider.sessionTitle.register({
+      id: SessionTitleProviderId('empty-provider'),
+      automatic: 'first-message',
+      generate,
+    })
+    const providerEmpty = withProvider.sessions.create(SessionId('empty-provider'))
+    await expect(withProvider.sessionTitle.refresh(providerEmpty)).resolves.toBeUndefined()
+    expect(generate).not.toHaveBeenCalled()
+
+    await expect(withProvider.sessionTitle.refresh(new Session(SessionId('detached'))))
+      .rejects.toThrow(/not live in this store/)
+    const controller = new AbortController()
+    controller.abort(new Error('already cancelled'))
+    await expect(withProvider.sessionTitle.refresh(providerEmpty, controller.signal))
+      .rejects.toThrow('already cancelled')
+  })
+
+  it('passes an absent route and caller cancellation into explicit generation', async () => {
+    const ctx = await setup()
+    let observed: SessionTitleProviderRequest | undefined
+    ctx.sessionTitle.register({
+      id: SessionTitleProviderId('explicit-no-route'),
+      automatic: 'first-message',
+      async generate(request) {
+        observed = request
+        return { title: 'Explicit title', messageSeqs: [request.messages[0]!.seq] }
+      },
+    })
+    const session = startSession(ctx, 'explicit-no-route')
+    appendPrompt(session, 'Refresh before any request header')
+    await settle()
+    const controller = new AbortController()
+
+    await expect(ctx.sessionTitle.refresh(session, controller.signal))
+      .resolves.toMatchObject({ title: 'Explicit title' })
+    expect(observed?.route).toBeUndefined()
+    expect(observed?.signal.aborted).toBe(false)
+  })
+
+  it('propagates explicit cancellation and session disposal to active work', async () => {
+    const callerCtx = await setup()
+    const callerPending = deferred<SessionTitleProviderResult>()
+    let callerSignal: AbortSignal | undefined
+    callerCtx.sessionTitle.register({
+      id: SessionTitleProviderId('caller-cancel'),
+      automatic: 'first-message',
+      generate(request) {
+        callerSignal = request.signal
+        return callerPending.promise
+      },
+    })
+    const callerSession = startSession(callerCtx, 'caller-cancel')
+    const callerMessage = appendPrompt(callerSession, 'Cancel this refresh')
+    await settle()
+    const controller = new AbortController()
+    const refresh = callerCtx.sessionTitle.refresh(callerSession, controller.signal)
+    await settle()
+    controller.abort(new Error('caller cancelled'))
+    callerPending.resolve({ title: 'ignored', messageSeqs: [callerMessage.seq] })
+    await expect(refresh).rejects.toThrow('caller cancelled')
+    expect(callerSignal?.aborted).toBe(true)
+
+    const disposeCtx = await setup()
+    const disposePending = deferred<SessionTitleProviderResult>()
+    let disposeSignal: AbortSignal | undefined
+    disposeCtx.sessionTitle.register({
+      id: SessionTitleProviderId('session-dispose'),
+      automatic: 'first-message',
+      generate(request) {
+        disposeSignal = request.signal
+        return disposePending.promise
+      },
+    })
+    const disposed = disposeCtx.sessions.prepare(SessionId('session-dispose'))
+    const detach = disposeCtx.sessions.enter(disposed)
+    disposeCtx.sessions.announce(disposed)
+    disposed.append('turn/start', {
+      turn: 1,
+      trigger: { kind: 'message', source: { kind: 'user' } },
+    })
+    const disposedMessage = appendPrompt(disposed, 'Dispose this session')
+    await settle()
+    const disposedRefresh = disposeCtx.sessionTitle.refresh(disposed)
+    await settle()
+    detach()
+    disposePending.resolve({ title: 'ignored', messageSeqs: [disposedMessage.seq] })
+    await expect(disposedRefresh).rejects.toThrow(/session disposed/)
+    expect(disposeSignal?.aborted).toBe(true)
+  })
+
+  it('warns when a detached session prevents queued fallback publication', async () => {
+    const ctx = await setup()
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
+    const session = ctx.sessions.prepare(SessionId('fallback-detach'))
+    const detach = ctx.sessions.enter(session)
+    ctx.sessions.announce(session)
+    ctx.on('session/event', (subject, event) => {
+      if (subject === session && event.type === 'user/message') detach()
+    })
+    session.append('turn/start', {
+      turn: 1,
+      trigger: { kind: 'message', source: { kind: 'user' } },
+    })
+    appendPrompt(session, 'Detach before the fallback microtask')
+    await settle()
+
+    expect(warn).toHaveBeenCalledWith(expect.stringContaining('fallback title update failed'))
+    expect(ctx.sessionTitle.get(session)).toBeUndefined()
+  })
+
+  it('leaves a title absent when the byte cap cannot hold the first code point', async () => {
+    const ctx = await setup({ fallbackMaxWords: 5, fallbackMaxBytes: 1, maxTitleBytes: 2 })
+    const session = startSession(ctx, 'no-code-point')
+    appendPrompt(session, '😀')
+    await settle()
+    expect(ctx.sessionTitle.get(session)).toBeUndefined()
+    await expect(ctx.sessionTitle.refresh(session)).resolves.toBeUndefined()
+  })
+})
+
+describe('SessionTitleService provider validation and stale scheduling', () => {
+  it('rejects malformed provider registrations before publishing them', async () => {
+    const ctx = await setup()
+    const generate = async (): Promise<SessionTitleProviderResult> => ({ title: 'title', messageSeqs: [0] })
+    expect(() => ctx.sessionTitle.register(null as never)).toThrow(/must be an object/)
+    expect(() => ctx.sessionTitle.register('provider' as never)).toThrow(/must be an object/)
+    expect(() => ctx.sessionTitle.register({
+      id: 1,
+      automatic: 'first-message',
+      generate,
+    } as unknown as SessionTitleProvider)).toThrow(/id must be a non-empty string/)
+    expect(() => ctx.sessionTitle.register({
+      id: SessionTitleProviderId(''),
+      automatic: 'first-message',
+      generate,
+    })).toThrow(/id must be a non-empty string/)
+    expect(() => ctx.sessionTitle.register({
+      id: SessionTitleProviderId('bad-mode'),
+      automatic: 'sometimes' as never,
+      generate,
+    })).toThrow(/automatic mode is invalid/)
+    expect(() => ctx.sessionTitle.register({
+      id: SessionTitleProviderId('missing-generate'),
+      automatic: 'first-message',
+      generate: undefined,
+    } as unknown as SessionTitleProvider)).toThrow(/requires generate/)
+  })
+
+  it('drops automatic work when its provider is disposed before the queued start', async () => {
+    const ctx = await setup()
+    const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult> => ({
+      title: 'too late',
+      messageSeqs: [request.messages[0]!.seq],
+    }))
+    const dispose = ctx.sessionTitle.register({
+      id: SessionTitleProviderId('queued-dispose'),
+      automatic: 'all-user-messages',
+      generate,
+    })
+    const session = startSession(ctx, 'queued-dispose')
+    appendPrompt(session, 'Queue provider work')
+    await settle()
+    session.append('request/header', {
+      header: { config: { provider: 'main', model: 'main' } },
+      reason: 'initial',
+    })
+    dispose()
+    await settle()
+    expect(generate).not.toHaveBeenCalled()
+    expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
+  })
+
+  it('rejects malformed provider results without replacing the fallback', async () => {
+    const ctx = await setup()
+    let result: unknown
+    ctx.sessionTitle.register({
+      id: SessionTitleProviderId('invalid-results'),
+      automatic: 'first-message',
+      generate: async () => result as SessionTitleProviderResult,
+    })
+    const session = startSession(ctx, 'invalid-results')
+    const first = appendPrompt(session, 'First source')
+    await settle()
+    const second = appendPrompt(session, 'Second source')
+    await settle()
+
+    const cases: Array<{ value: unknown; error: RegExp }> = [
+      { value: null, error: /invalid result/ },
+      { value: 1, error: /invalid result/ },
+      { value: { title: 1, messageSeqs: [first.seq] }, error: /title must be a string/ },
+      { value: { title: '\u001B[31m', messageSeqs: [first.seq] }, error: /empty title/ },
+      { value: { title: 'valid', messageSeqs: undefined }, error: /at least one source message/ },
+      { value: { title: 'valid', messageSeqs: [] }, error: /at least one source message/ },
+      { value: { title: 'valid', messageSeqs: ['not-a-seq'] }, error: /unique, ordered seqs/ },
+      { value: { title: 'valid', messageSeqs: [1.5] }, error: /unique, ordered seqs/ },
+      { value: { title: 'valid', messageSeqs: [-1] }, error: /unique, ordered seqs/ },
+      { value: { title: 'valid', messageSeqs: [999] }, error: /unique, ordered seqs/ },
+      { value: { title: 'valid', messageSeqs: [first.seq, first.seq] }, error: /unique, ordered seqs/ },
+      { value: { title: 'valid', messageSeqs: [second.seq, first.seq] }, error: /unique, ordered seqs/ },
+      { value: { title: 'valid', messageSeqs: [first.seq], model: null }, error: /model provenance/ },
+      { value: { title: 'valid', messageSeqs: [first.seq], model: 'route' }, error: /model provenance/ },
+      { value: { title: 'valid', messageSeqs: [first.seq], model: { provider: 1, model: 'm' } }, error: /model provenance/ },
+      { value: { title: 'valid', messageSeqs: [first.seq], model: { provider: '', model: 'm' } }, error: /model provenance/ },
+      { value: { title: 'valid', messageSeqs: [first.seq], model: { provider: 'p', model: 1 } }, error: /model provenance/ },
+      { value: { title: 'valid', messageSeqs: [first.seq], model: { provider: 'p', model: '' } }, error: /model provenance/ },
+    ]
+    for (const item of cases) {
+      result = item.value
+      await expect(ctx.sessionTitle.refresh(session)).rejects.toThrow(item.error)
+      expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
+    }
+  })
+})

+ 145 - 0
packages/session-title/session-title/tests/session-title.spec.ts

@@ -0,0 +1,145 @@
+import { Context } from 'cordis'
+import { describe, expect, it } from 'vitest'
+import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
+import SessionTitleService, {
+  SessionTitleProviderId,
+  fallbackSessionTitle,
+  foldSessionTitle,
+  normalizeSessionTitle,
+  truncateTitleUtf8,
+} from '@deepseek-ai/dsh-session-title'
+
+const CONFIG = {
+  fallbackMaxWords: 5,
+  fallbackMaxBytes: 40,
+  maxTitleBytes: 80,
+} as const
+
+async function settleTitles(): Promise<void> {
+  await new Promise(resolve => setTimeout(resolve, 0))
+}
+
+describe('session title normalization', () => {
+  it('removes terminal controls, collapses whitespace, and applies word and UTF-8 byte caps', () => {
+    expect(normalizeSessionTitle('\u001B]0;stolen\u0007  Hello\t brave\nnew world  ', 80))
+      .toBe('Hello brave new world')
+    expect(fallbackSessionTitle('one two three four', 3, 80)).toBe('one two three')
+    expect(fallbackSessionTitle('你好世界', 5, 7)).toBe('你好')
+    expect(Buffer.byteLength(fallbackSessionTitle('😀😀', 5, 5), 'utf8')).toBe(4)
+  })
+
+  it('rejects non-positive and fractional public limits', () => {
+    expect(() => truncateTitleUtf8('title', 0)).toThrow(/maxBytes must be a positive integer/)
+    expect(() => fallbackSessionTitle('title', 1.5, 10)).toThrow(/maxWords must be a positive integer/)
+  })
+})
+
+describe('SessionTitleService', () => {
+  it('logs and folds an immediate fallback after the first eligible human text message', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionTitleService, CONFIG)
+    const session = ctx.sessions.create(SessionId('fresh'))
+    session.append('turn/start', {
+      turn: 1,
+      trigger: { kind: 'message', source: { kind: 'user' } },
+    })
+    const message = session.append('user/message', {
+      content: [{ type: 'text', text: '  Build\nlog-backed session titles please  ' }],
+      source: { kind: 'user' },
+    }, { surfaceOp: 'append' })
+
+    await settleTitles()
+
+    const titleEvent = session.events.findLast(event => event.type === 'session/title')
+    expect(titleEvent).toMatchObject({
+      type: 'session/title',
+      seq: 2,
+      data: {
+        title: 'Build log-backed session titles please',
+        messageSeqs: [message.seq],
+        source: { kind: 'fallback' },
+      },
+    })
+    expect(ctx.sessionTitle.get(session)).toEqual({
+      title: 'Build log-backed session titles please',
+      messageSeqs: [message.seq],
+      source: { kind: 'fallback' },
+      eventSeq: 2,
+      updatedAt: titleEvent?.time,
+    })
+    expect(session.deriveMessages()).toHaveLength(1)
+    expect(session.surface.nodes).toEqual([message.seq])
+  })
+
+  it('waits through synthetic, empty, and non-text messages, then keeps the first fallback', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionTitleService, CONFIG)
+    const session = ctx.sessions.create(SessionId('eligibility'))
+    session.append('turn/start', {
+      turn: 1,
+      trigger: { kind: 'message', source: { kind: 'user' } },
+    })
+    session.append('user/message', {
+      content: [{ type: 'text', text: 'plugin text' }],
+      source: { kind: 'plugin', plugin: 'seed' },
+    }, { surfaceOp: 'append' })
+    session.append('user/message', {
+      content: [{ type: 'reasoning', text: 'not visible text' }],
+      source: { kind: 'user' },
+    }, { surfaceOp: 'append' })
+    session.append('user/message', {
+      content: [{ type: 'text', text: ' \n\t ' }],
+      source: { kind: 'user' },
+    }, { surfaceOp: 'append' })
+    await settleTitles()
+    expect(ctx.sessionTitle.get(session)).toBeUndefined()
+
+    const eligible = session.append('user/message', {
+      content: [{ type: 'text', text: 'first real prompt' }],
+      source: { kind: 'user' },
+    }, { surfaceOp: 'append' })
+    await settleTitles()
+    const first = ctx.sessionTitle.get(session)
+    session.append('user/message', {
+      content: [{ type: 'text', text: 'later prompt' }],
+      source: { kind: 'user' },
+    }, { surfaceOp: 'append' })
+    await settleTitles()
+
+    expect(first?.messageSeqs).toEqual([eligible.seq])
+    expect(ctx.sessionTitle.get(session)).toEqual(first)
+    expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1)
+  })
+
+  it('folds the latest title event during replay', () => {
+    const seed = new Session(SessionId('source'))
+    seed.append('session/title', {
+      title: 'Earlier',
+      messageSeqs: [1],
+      source: { kind: 'fallback' },
+    })
+    seed.append('session/title', {
+      title: 'Later',
+      messageSeqs: [1, 4],
+      source: {
+        kind: 'provider',
+        provider: SessionTitleProviderId('test-provider'),
+        model: { provider: 'mock', model: 'title-model' },
+      },
+    })
+
+    expect(foldSessionTitle(seed.events)).toEqual({
+      title: 'Later',
+      messageSeqs: [1, 4],
+      source: {
+        kind: 'provider',
+        provider: SessionTitleProviderId('test-provider'),
+        model: { provider: 'mock', model: 'title-model' },
+      },
+      eventSeq: 1,
+      updatedAt: seed.events[1]?.time,
+    })
+  })
+})

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

@@ -0,0 +1,30 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../util/brand"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../../core/session"
+    }
+  ]
+}

+ 2 - 0
packages/ui/acp/package.json

@@ -34,6 +34,7 @@
     "@deepseek-ai/dsh-permission": "^0.0.1",
     "@deepseek-ai/dsh-sandbox": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
+    "@deepseek-ai/dsh-session-title": "^0.0.1",
     "@deepseek-ai/dsh-session-persistence": "^0.0.1",
     "@deepseek-ai/dsh-system-prompt": "^0.0.1",
     "@deepseek-ai/dsh-tools": "^0.0.1",
@@ -55,6 +56,7 @@
     "@deepseek-ai/dsh-permission": "workspace:^",
     "@deepseek-ai/dsh-sandbox": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-title": "workspace:^",
     "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",

+ 3 - 0
packages/ui/acp/tsconfig.json

@@ -26,6 +26,9 @@
     {
       "path": "../../core/session"
     },
+    {
+      "path": "../../session-title/session-title"
+    },
     {
       "path": "../../core/agent"
     },

+ 2 - 0
packages/ui/tui/package.json

@@ -27,6 +27,7 @@
     "@deepseek-ai/dsh-llm": "^0.0.1",
     "@deepseek-ai/dsh-llm-retry": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
+    "@deepseek-ai/dsh-session-title": "^0.0.1",
     "@deepseek-ai/dsh-tools": "^0.0.1",
     "@deepseek-ai/dsh-user-interaction": "^0.0.1",
     "cordis": "^4.0.0-rc.7"
@@ -42,6 +43,7 @@
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-llm-retry": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-title": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-tool-cordis": "workspace:^",
     "@deepseek-ai/dsh-tool-workflow": "workspace:^",

+ 3 - 0
packages/ui/tui/tsconfig.json

@@ -23,6 +23,9 @@
     {
       "path": "../../core/session"
     },
+    {
+      "path": "../../session-title/session-title"
+    },
     {
       "path": "../../llm/llm"
     },

+ 115 - 0
pnpm-lock.yaml

@@ -768,6 +768,9 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-session-title':
+        specifier: workspace:^
+        version: link:../../session-title/session-title
       '@deepseek-ai/dsh-skill':
         specifier: workspace:^
         version: link:../../skill/skill
@@ -1518,6 +1521,109 @@ importers:
       '@deepseek-ai/dsh-session-persistence':
         specifier: workspace:^
         version: link:../../session-persistence/session-persistence
+      '@deepseek-ai/dsh-session-title':
+        specifier: workspace:^
+        version: link:../../session-title/session-title
+      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)
+
+  packages/session-title/session-title:
+    dependencies:
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
+    devDependencies:
+      '@deepseek-ai/dsh-brand':
+        specifier: workspace:^
+        version: link:../../util/brand
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-session-persistence-jsonl':
+        specifier: workspace:^
+        version: link:../../session-persistence/session-persistence-jsonl
+      '@deepseek-ai/dsh-session-persistence-sqlite':
+        specifier: workspace:^
+        version: link:../../session-persistence/session-persistence-sqlite
+      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)
+
+  packages/session-title/session-title-all-messages-llm:
+    dependencies:
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
+    devDependencies:
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-session-title':
+        specifier: workspace:^
+        version: link:../session-title
+      '@deepseek-ai/dsh-session-title-llm':
+        specifier: workspace:^
+        version: link:../session-title-llm
+      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)
+
+  packages/session-title/session-title-first-message-llm:
+    dependencies:
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
+    devDependencies:
+      '@cordisjs/plugin-include':
+        specifier: workspace:^
+        version: link:../../../vendor/include
+      '@cordisjs/plugin-loader':
+        specifier: workspace:^
+        version: link:../../../vendor/loader
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-llm-deepseek':
+        specifier: workspace:^
+        version: link:../../llm/llm-deepseek
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-session-title':
+        specifier: workspace:^
+        version: link:../session-title
+      '@deepseek-ai/dsh-session-title-llm':
+        specifier: workspace:^
+        version: link:../session-title-llm
+      cordis:
+        specifier: ^4.0.0-rc.7
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader)
+
+  packages/session-title/session-title-llm:
+    dependencies:
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
+    devDependencies:
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-session-title':
+        specifier: workspace:^
+        version: link:../session-title
+      '@deepseek-ai/dsh-timeout':
+        specifier: workspace:^
+        version: link:../../util/timeout
       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)
@@ -2104,6 +2210,9 @@ importers:
       '@deepseek-ai/dsh-session-persistence-jsonl':
         specifier: workspace:^
         version: link:../../session-persistence/session-persistence-jsonl
+      '@deepseek-ai/dsh-session-title':
+        specifier: workspace:^
+        version: link:../../session-title/session-title
       '@deepseek-ai/dsh-system-prompt':
         specifier: workspace:^
         version: link:../../core/system-prompt
@@ -2254,6 +2363,9 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-session-title':
+        specifier: workspace:^
+        version: link:../../session-title/session-title
       '@deepseek-ai/dsh-system-prompt':
         specifier: workspace:^
         version: link:../../core/system-prompt
@@ -2683,6 +2795,9 @@ importers:
       '@deepseek-ai/dsh-session-persistence-sqlite':
         specifier: workspace:^
         version: link:../../packages/session-persistence/session-persistence-sqlite
+      '@deepseek-ai/dsh-session-title':
+        specifier: workspace:^
+        version: link:../../packages/session-title/session-title
       '@deepseek-ai/dsh-skill':
         specifier: workspace:^
         version: link:../../packages/skill/skill

+ 1 - 0
python/sdk-runtime/package.json

@@ -46,6 +46,7 @@
     "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
+    "@deepseek-ai/dsh-session-title": "workspace:^",
     "@deepseek-ai/dsh-skill": "workspace:^",
     "@deepseek-ai/dsh-skill-local": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",

+ 1 - 0
tsconfig.base.json

@@ -62,6 +62,7 @@
         "./packages/hooks/*/src",
         "./packages/session-persistence/*/src",
         "./packages/session-query/*/src",
+        "./packages/session-title/*/src",
         "./packages/sdk/*/src",
         "./packages/ui/*/src",
         "./packages/examples/*/src",

+ 4 - 0
tsconfig.build.json

@@ -23,6 +23,10 @@
     { "path": "./packages/session-persistence/session-persistence-jsonl" },
     { "path": "./packages/session-persistence/session-persistence-sqlite" },
     { "path": "./packages/session-query/session-query" },
+    { "path": "./packages/session-title/session-title" },
+    { "path": "./packages/session-title/session-title-llm" },
+    { "path": "./packages/session-title/session-title-first-message-llm" },
+    { "path": "./packages/session-title/session-title-all-messages-llm" },
     { "path": "./packages/core/system-prompt" },
     { "path": "./packages/core/agent" },
     { "path": "./packages/context/time-context" },

+ 4 - 0
tsconfig.json

@@ -36,6 +36,10 @@
     { "path": "./packages/session-persistence/session-persistence-jsonl" },
     { "path": "./packages/session-persistence/session-persistence-sqlite" },
     { "path": "./packages/session-query/session-query" },
+    { "path": "./packages/session-title/session-title" },
+    { "path": "./packages/session-title/session-title-llm" },
+    { "path": "./packages/session-title/session-title-first-message-llm" },
+    { "path": "./packages/session-title/session-title-all-messages-llm" },
     { "path": "./packages/core/system-prompt" },
     { "path": "./packages/core/agent" },
     { "path": "./packages/context/time-context" },