Преглед изворни кода

Merge branch 'master' into fix/page-scroll

imccyu пре 2 месеци
родитељ
комит
67acf86b2e
63 измењених фајлова са 1018 додато и 76 уклоњено
  1. 3 3
      .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml
  5. 6 2
      .agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md
  6. 6 2
      .agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md
  7. 130 0
      apps/web/tests/session-actions.snapshot.ts
  8. 14 0
      apps/web/tests/snapshots/session-actions/rename-rows.json
  9. 1 1
      docs/config-catalog.md
  10. 14 1
      docs/cordis-catalog/services.md
  11. 2 2
      docs/core-data-structures/session-title.i18n.yaml
  12. 6 2
      docs/core-data-structures/session-title.md
  13. 6 2
      docs/core-data-structures/session-title.zh.md
  14. 1 1
      docs/event-producer-consumer.md
  15. 1 1
      docs/persistence-catalog.md
  16. 22 0
      packages/client/connection/src/client/fixture.ts
  17. 2 0
      packages/client/connection/tests/fake-api.ts
  18. 41 0
      packages/client/connection/tests/fixture.spec.ts
  19. 2 2
      packages/client/runtime/README.i18n.yaml
  20. 1 1
      packages/client/runtime/README.md
  21. 1 1
      packages/client/runtime/README.zh.md
  22. 7 0
      packages/client/runtime/src/client/contract/session.ts
  23. 19 0
      packages/client/runtime/src/client/sessions/session.ts
  24. 2 0
      packages/client/runtime/tests/fake-api.ts
  25. 26 0
      packages/client/runtime/tests/session.spec.ts
  26. 7 0
      packages/client/test-runtime/src/sessions.ts
  27. 1 0
      packages/client/test-runtime/tests/runtime.spec.tsx
  28. 2 2
      packages/client/ui-workspace/README.i18n.yaml
  29. 0 1
      packages/client/ui-workspace/README.md
  30. 2 2
      packages/client/ui-workspace/README.zh.md
  31. 73 3
      packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
  32. 2 0
      packages/client/ui-workspace/src/client/contract/slots.ts
  33. 8 0
      packages/client/ui-workspace/src/client/index.ts
  34. 10 4
      packages/client/ui-workspace/src/client/rows/Rows.tsx
  35. 29 11
      packages/client/ui-workspace/tests/rows.spec.tsx
  36. 1 0
      packages/client/ui-workspace/tests/workspace-browser.spec.tsx
  37. 5 1
      packages/cordis/tool-cordis/src/api-catalog.ts
  38. 2 2
      packages/host/apiproxy/README.i18n.yaml
  39. 1 1
      packages/host/apiproxy/README.md
  40. 1 1
      packages/host/apiproxy/README.zh.md
  41. 1 0
      packages/host/apiproxy/package.json
  42. 32 0
      packages/host/apiproxy/src/api-proxy.ts
  43. 1 0
      packages/host/apiproxy/src/api/rpc-map.ts
  44. 1 0
      packages/host/apiproxy/src/api/rpc.schema.ts
  45. 1 0
      packages/host/apiproxy/src/api/rpc.ts
  46. 12 0
      packages/host/apiproxy/src/api/sessions.schema.ts
  47. 10 0
      packages/host/apiproxy/src/api/sessions.ts
  48. 4 0
      packages/host/apiproxy/src/fetch/client.ts
  49. 2 0
      packages/host/apiproxy/src/fetch/handler.ts
  50. 129 0
      packages/host/apiproxy/tests/api-proxy-rename.spec.ts
  51. 1 0
      packages/host/apiproxy/tests/client-handler.spec.ts
  52. 5 0
      packages/host/apiproxy/tests/fetch-carrier.spec.ts
  53. 2 0
      packages/host/apiproxy/tests/rpc-schemas.spec.ts
  54. 3 0
      packages/host/apiproxy/tsconfig.json
  55. 2 2
      packages/session-title/session-title/README.i18n.yaml
  56. 2 1
      packages/session-title/session-title/README.md
  57. 2 1
      packages/session-title/session-title/README.zh.md
  58. 95 12
      packages/session-title/session-title/src/index.ts
  59. 21 5
      packages/session-title/session-title/src/invariant.ts
  60. 44 0
      packages/session-title/session-title/tests/invariant.spec.ts
  61. 181 0
      packages/session-title/session-title/tests/rename.spec.ts
  62. 3 0
      pnpm-lock.yaml
  63. 1 0
      tsconfig.base.json

+ 3 - 3
.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write
-2026-07-19-gui-layering-and-rpc-protocol.md: 63db4786adcc007d09b7a58824a59f4d1e1e8be1
-2026-07-19-gui-layering-and-rpc-protocol.zh.md: b3037ceb8c172925581d2862ea675e53a7f8c54e
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md
+2026-07-19-gui-layering-and-rpc-protocol.md: b9718da4725316c64686adef24827e2984d8723d
+2026-07-19-gui-layering-and-rpc-protocol.zh.md: 2add148054e8f97c65600cd719fb4f8e0283f52d

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md

@@ -165,7 +165,7 @@ One example row (the table structure is the reading key):
 |---|---|---|---|
 | `session.list` | `{ cursor?: string }` (cursor is a reserved seat, unimplemented) | `{ items: SessionSummary[] }` | persisted sessions, updatedAt descending; v1 builds no index |
 
-The remaining methods (`session.create`/`session.history`/`session.prompt`/`session.cancel`/`host.describe`) are not re-copied here — signatures are the source of truth; see `api/sessions.ts`, `api/host.ts`, and `RpcMethodMap`.
+The remaining methods (`session.create`/`session.history`/`session.rename`/`session.prompt`/`session.cancel`/`host.describe`) are not re-copied here — signatures are the source of truth; see `api/sessions.ts`, `api/host.ts`, and `RpcMethodMap`.
 
 ### Frames (server→client, named unions)
 
@@ -187,7 +187,7 @@ The remaining frame types are not re-copied here; the full unions are `MuxFrame`
 - **Cold sessions resume implicitly**: when `history`/`prompt` hits an unattached session the impl auto-resumes, deduplicating concurrent triggers with an in-flight table; attachment status is not exposed to clients (`running` already covers it).
 - **Approvals/questions**: the requested frame mints a stable rpcId on acceptance; first answer wins, and the host's in-memory pending table (keyed by rpcId) is the only referee; after a mux reopen, still-pending requested frames replay after the subscribed frame (rpcId reused verbatim — refresh recovery). The audit events `approval/asked`/`decided` continue through the durable log — frames = the live control plane, events = the durable audit. **Status**: the contract and frame types are shipped; the host-side pending table/wire answerer is unimplemented (`respond` in `api-proxy.ts` is a stub, always `not-pending`); PendingCard v1 is display-only.
 - **No protocol version**: client and host release bound together; `host.describe` has no protocolVersion field; introduce one when an independently released client appears.
-- **Reserved-seam discipline**: the map holds only implemented methods; an unknown method fails loud at envelope parse (`bad-request`) — no not-implemented fallback code. The reservation list (implementing = copy the signature into the domain interface + add the map row + add the schema pair): `session.fork`, `prompt.mode` gaining `'inject'`, `task.list`, `host.listModels`, describe gaining `hostInstanceId`.
+- **Reserved-seam discipline**: the map holds only implemented methods; an unknown method fails loud at envelope parse (`bad-request`) — no not-implemented fallback code. The reservation list (implementing = copy the signature into the domain interface + add the map row + add the schema pair): `session.fork`, `prompt.mode` gaining `'inject'`, `task.list`, `host.listModels`, describe gaining `hostInstanceId`. (`session.rename` graduated from this list: it appends a user-source `session/title` event.)
 
 ## The client carrier: the AbstractApiClient class family (`fetch/client.ts`)
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md

@@ -163,7 +163,7 @@ export type ResponseValue<K> =
 |---|---|---|---|
 | `session.list` | `{ cursor?: string }`(cursor 留座不实现) | `{ items: SessionSummary[] }` | 已持久化 session,updatedAt 倒序;v1 不建索引 |
 
-其余方法(`session.create`/`session.history`/`session.prompt`/`session.cancel`/`host.describe`)的参数与返回不在此复写——签名即事实源,见 `api/sessions.ts`、`api/host.ts` 与 `RpcMethodMap`。
+其余方法(`session.create`/`session.history`/`session.rename`/`session.prompt`/`session.cancel`/`host.describe`)的参数与返回不在此复写——签名即事实源,见 `api/sessions.ts`、`api/host.ts` 与 `RpcMethodMap`。
 
 ### 帧(server→client,具名 union)
 
@@ -185,7 +185,7 @@ export type ResponseValue<K> =
 - **冷 session 隐式 resume**:`history`/`prompt` 命中未 attach 的 session 时 impl 自动 resume,并发触发用在途表去重;attach 与否不对客暴露(`running` 已覆盖)。
 - **审批/问答**:requested 帧受理时 mint 稳定 rpcId;先到先赢,host 内存 pending 表(keyed by rpcId)是唯一裁判;mux 重开后在 subscribed 帧后重放仍 pending 的 requested 帧(rpcId 原样复用,刷新恢复)。审计事件 `approval/asked`/`decided` 照旧走 durable 日志——帧=live 控制面,事件=durable 审计。**现状**:契约与帧类型已 shipped,host 侧 pending 表/wire answerer 未实现(`api-proxy.ts` 的 `respond` 是 stub,恒回 `not-pending`);PendingCard v1 只展示。
 - **不设协议版本**:client 与 host 绑定发布,`host.describe` 无 protocolVersion 字段;出现独立发布的 client 时再引入。
-- **预留接缝纪律**:map 只含已实现方法,未知 method 在信封 parse 即 fail loud(`bad-request`),不设 not-implemented 兜底码。预留清单(实现时把签名抄进域接口+map 加行+schema 加对即升格):`session.fork`、`prompt.mode` 加 `'inject'`、`task.list`、`host.listModels`、describe 加 `hostInstanceId`。
+- **预留接缝纪律**:map 只含已实现方法,未知 method 在信封 parse 即 fail loud(`bad-request`),不设 not-implemented 兜底码。预留清单(实现时把签名抄进域接口+map 加行+schema 加对即升格):`session.fork`、`prompt.mode` 加 `'inject'`、`task.list`、`host.listModels`、describe 加 `hostInstanceId`。(`session.rename` 已从本清单毕业:追加 user 来源的 `session/title` 事件。)
 
 ## 客户端载体:AbstractApiClient 类体系(`fetch/client.ts`)
 

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md
-2026-07-21-log-backed-session-titles.md: 1bd58e35ec625fb0b04c0c119ce425ff30a64881
-2026-07-21-log-backed-session-titles.zh.md: 37ec95efbca334f71d19d2bc3e18c22d50d9b5fb
+2026-07-21-log-backed-session-titles.md: 81ac687c6f55dd0ca1eaeb9d84c811edcfe17b5c
+2026-07-21-log-backed-session-titles.zh.md: b0c7e9d76a1b9365fa16dcb223b390b5aec3e174

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

@@ -36,9 +36,13 @@ Model providers require explicit word, CJK-character, input-byte, output-token,
 
 Automatic provider failures are nonfatal warnings and retain the latest title. Explicit refresh failures reject to the caller. Output must be non-empty text with unique ordered seqs drawn from the fixed request; the service normalizes and byte-limits it before log acceptance.
 
+### Explicit rename
+
+`rename(session, title)` accepts a user title synchronously: it normalizes the text under the accepted-title byte limit, supersedes in-flight automatic work, and appends a `session/title` event with the third source kind, `user`. A user-sourced latest title pins the session: `onUserMessage` schedules no automatic revision while it stands, under either cadence. An explicit `refresh()` remains the deliberate unpin — it appends a provider or fallback event over the pinned one whenever a replacement title is derivable (an underivable fallback, e.g. under a tiny byte cap, leaves the pin standing). The Web host exposes this as the `session.rename` unary method (resuming cold sessions first) and returns the normalized title plus its event seq so the client settles its `title` projection cell before the push frame arrives.
+
 ### Forks and consumers
 
-A fork inherits seed title events unchanged, like the rest of its source log. The first-message provider does not automatically retitle a fork. The all-messages provider may append a child-owned revision after a later child prompt, using inherited and new eligible messages.
+A fork inherits seed title events unchanged, like the rest of its source log — a pinned (user-sourced) title stays pinned in the child until an explicit refresh. The first-message provider does not automatically retitle a fork. The all-messages provider may append a child-owned revision after a later child prompt, using inherited and new eligible messages.
 
 `ctx.sessionQuery.readTitle()` folds one live-preferred or persisted log without loading titles during `listSessions()`. The TUI uses the latest title as its header subtitle and sets the terminal window title to `<session title> — <configured product title>` after terminal-safe rendering. The Web host folds the same log state into a validated mux control frame after each attached-session subscription baseline and immediately after forwarding a live raw title event. The browser retains only newer title event seqs even when the control frame precedes list or session-instance creation; sidebar labels, search, breadcrumbs, and the browser title then react to the projected revision. `session.list` remains metadata-only, so a cold persisted session uses the cwd basename or id until opening or resuming it attaches the log. The browser title uses `<session title> — <existing HTML title>` only for a selected titled session and otherwise preserves the product title. Consumers reporting agent completion use the core `findLastMessageTurnEnd()` fold, so a later between-turn title record cannot replace the preceding message-triggered outcome.
 
@@ -59,4 +63,4 @@ A fork inherits seed title events unchanged, like the rest of its source log. Th
 - A fallback appears immediately. Each fresh Web session adds one first-message auxiliary call; other compositions choose whether better titles justify model cost and whether later prompts should retitle a session.
 - Auxiliary request records and late accepted titles consume event seqs without consuming turn numbers, so persistence exposes both attempted dispatches and accepted updates even though model history and KV-cache identity do not change.
 - One provider and monotonic per-session revisions make disposal, supersession, and stale-result rejection explicit, at the cost of leaving multi-strategy precedence to a composite provider.
-- Manual rename, deletion, generated-versus-user precedence, search, and list indexing remain outside the capability.
+- Deletion (unpinning without an explicit refresh), search, and list indexing remain outside the capability.

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

@@ -36,9 +36,13 @@ Status: implemented
 
 自动提供方故障只会发出非致命警告,并保留最新标题。显式刷新失败则会向调用方返回拒绝。输出必须是非空文本,并包含来自固定请求、唯一且有序的 seq;服务会在日志接受前对其进行规范化并施加字节限制。
 
+### 显式重命名
+
+`rename(session, title)` 同步接受用户标题:按已接受标题的字节上限规范化文本、取代在途自动工作,并追加一条第三种来源 `user` 的 `session/title` 事件。最新标题来源为 user 即钉住该会话:只要它还在,`onUserMessage` 在任一节奏下都不再安排自动修订。显式 `refresh()` 仍是有意的解钉手段——只要能推导出替代标题,它就在被钉住的标题之上追加提供方或回退事件(推导不出回退标题时,例如字节上限过小,钉住状态保持不变)。Web host 将其暴露为 `session.rename` unary 方法(冷会话先恢复),并返回规范化后的标题及其事件 seq,使 client 在推送帧到达前就结算自己的 `title` 投影格。
+
 ### Fork 与消费方
 
-与源日志的其他部分相同,fork 会原样继承作为种子的标题事件。首消息提供方不会自动为 fork 重新生成标题。全部消息提供方可以在子会话出现后续提示词后追加一项归子会话所有的修订,并使用继承的合格消息和新增的合格消息。
+与源日志的其他部分相同,fork 会原样继承作为种子的标题事件——被钉住(user 来源)的标题在子会话中保持钉住,直到显式 refresh。首消息提供方不会自动为 fork 重新生成标题。全部消息提供方可以在子会话出现后续提示词后追加一项归子会话所有的修订,并使用继承的合格消息和新增的合格消息。
 
 `ctx.sessionQuery.readTitle()` 会折叠一份实时优先或已持久化的日志,而不会在 `listSessions()` 期间加载标题。TUI 使用最新标题作为其标题栏副标题,并在完成终端安全渲染后,将终端窗口标题设置为 `<session title> — <configured product title>`。Web host 会在每个已附加会话的订阅基线之后,以及转发实时原始标题事件后立即,将同一份日志状态折叠为经过校验的 mux 控制帧。即使控制帧先于列表或会话实例创建抵达,浏览器也只保留标题事件 seq 较新的版本;侧边栏标签、搜索、面包屑和浏览器标题会随投影后的修订更新。`session.list` 仍只包含元数据,因此尚未打开的持久化会话会继续以 cwd 基名或 id 作为回退,直至打开或恢复会话时附加其日志。浏览器仅在选中已有标题的会话时将标题设置为 `<session title> — <existing HTML title>`,否则保留产品标题。报告 agent 完成情况的消费方使用核心的 `findLastMessageTurnEnd()` 折叠逻辑,因此后续的轮次间标题记录无法取代此前由消息触发的结果。
 
@@ -59,4 +63,4 @@ Status: implemented
 - 回退标题会立即出现。每个新建的 Web 会话都会增加一次针对首消息的辅助调用;其他组合可以自行决定更优标题是否值得模型成本,以及后续提示词是否需要重新生成会话标题。
 - 辅助请求记录和延迟接受的标题会占用事件 seq,但不会占用轮次编号,因此持久化会同时呈现尝试发起的调用与已接受的更新,尽管模型历史和 KV 缓存标识保持不变。
 - 单个提供方和每会话单调递增的修订号让释放、取代和陈旧结果拒绝行为明确可见,但多策略优先级必须由复合提供方负责。
-- 手动重命名、删除、生成标题与用户标题的优先级、搜索和列表索引不在此功能范围内。
+- 删除(不经显式 refresh 的解钉)、搜索和列表索引不在此功能范围内。

+ 130 - 0
apps/web/tests/session-actions.snapshot.ts

@@ -0,0 +1,130 @@
+// @vitest-environment jsdom
+// Session row actions in the assembled fixture app: Rename opens the
+// browser-owned dialog and settles the title from the unary response.
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
+import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
+
+const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
+  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
+  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
+  { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+  { id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] },
+  { id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
+  { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] },
+]
+
+const bundles = new Map(PLUGINS.map(plugin => [
+  plugin.url,
+  readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
+]))
+
+interface FixtureWindow extends Window {
+  __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
+  __ModuleLoader__?: unknown
+}
+
+class ResizeObserverStub {
+  observe(): void {}
+  disconnect(): void {}
+  unobserve(): void {}
+}
+
+const win = window as FixtureWindow
+let unmount: (() => void) | undefined
+
+beforeEach(() => {
+  localStorage.clear()
+  history.replaceState(null, '', '/?fixture')
+  document.title = 'DeepSeek Harness'
+  const root = document.createElement('div')
+  root.id = 'root'
+  document.body.appendChild(root)
+  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
+    setTimeout(() => { callback(0) }, 0) as unknown as number)
+  vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
+  win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
+})
+
+afterEach(() => {
+  act(() => { unmount?.() })
+  unmount = undefined
+  cleanup()
+  delete win.__DSH_BOOT__
+  delete win.__ModuleLoader__
+  delete (globalThis as Record<string, unknown>).__fxTiming
+  document.body.innerHTML = ''
+  document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
+  document.title = ''
+  history.replaceState(null, '', '/')
+  vi.unstubAllGlobals()
+})
+
+async function bootApp(): Promise<void> {
+  const root = document.querySelector<HTMLElement>('#root')
+  if (root === null) throw new Error('snapshot root missing')
+  act(() => {
+    const entry = new AppWebEntry(root, {
+      fetchBundle: (url) => {
+        const code = bundles.get(url)
+        return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
+      },
+      executeBundle: (code) => { (0, eval)(code) },
+    })
+    void entry.run()
+    unmount = () => { entry.dispose() }
+  })
+  await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
+}
+
+/** The session row element carrying the given visible label. */
+function rowOf(label: string): HTMLElement {
+  const tree = screen.getByRole('tree', { name: 'Sessions' })
+  const row = within(tree).getByText(label).closest<HTMLElement>('[role="treeitem"]')
+  if (row === null) throw new Error(`session row "${label}" missing`)
+  return row
+}
+
+/** Open the row's ... menu and click one action. The anchor button is
+ *  CSS-hover-revealed (real stylesheets are injected in this assembled run,
+ *  so role queries filter it as hidden); target it directly. */
+function pickRowAction(label: string, action: string): void {
+  const anchor = rowOf(label).querySelector<HTMLElement>(`button[aria-label="Session actions for ${label}"]`)
+  if (anchor === null) throw new Error(`row menu anchor for "${label}" missing`)
+  fireEvent.click(anchor)
+  fireEvent.click(screen.getByRole('menuitem', { name: action, hidden: true }))
+}
+
+it('renames a session through the row-menu dialog; the row settles from the unary response', async () => {
+  await bootApp()
+  const sourceLabel = 'Fixture 历史会话'
+  await screen.findByText(sourceLabel)
+
+  pickRowAction(sourceLabel, 'Rename')
+  const input = await screen.findByLabelText('Session name')
+  expect((input as HTMLInputElement).value).toBe(sourceLabel)
+  fireEvent.change(input, { target: { value: '  分叉  实验记录  ' } })
+  fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
+
+  // Host-side normalization collapses whitespace; the dialog closes on
+  // acceptance and the row re-labels without any push-frame wait.
+  const renamed = '分叉 实验记录'
+  await waitFor(() => { expect(screen.queryByLabelText('Session name')).toBeNull() })
+  await screen.findByText(renamed)
+  const tree = screen.getByRole('tree', { name: 'Sessions' })
+  expect(within(tree).queryByText(sourceLabel)).toBeNull()
+
+  const rows = [...tree.querySelectorAll('[role="treeitem"]')].map(row => ({
+    label: row.textContent?.replace(/\s+/g, ' ').trim() ?? '',
+  }))
+  await expect(`${JSON.stringify(rows, null, 2)}\n`)
+    .toMatchFileSnapshot('./snapshots/session-actions/rename-rows.json')
+})

+ 14 - 0
apps/web/tests/snapshots/session-actions/rename-rows.json

@@ -0,0 +1,14 @@
+[
+  {
+    "label": "fixture4 sessions"
+  },
+  {
+    "label": "New Sessionnow"
+  },
+  {
+    "label": "分叉 实验记录now"
+  },
+  {
+    "label": "fixture2min"
+  }
+]

+ 1 - 1
docs/config-catalog.md

@@ -1203,7 +1203,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/session-title/session-title/src/index.ts:75`](../packages/session-title/session-title/src/index.ts)
+Source: [`packages/session-title/session-title/src/index.ts:79`](../packages/session-title/session-title/src/index.ts)
 
 ## `@deepseek-ai/dsh-session-title-all-messages-llm`
 

+ 14 - 1
docs/cordis-catalog/services.md

@@ -1604,6 +1604,19 @@ Log-backed title fold plus asynchronous fallback generation.
  */
 get(session: Session): SessionTitleSnapshot | undefined
 
+/**
+ * Accept an explicit user title. Appends a `session/title` event with the
+ * `user` source, which pins the title: in-flight automatic generation is
+ * superseded and later user messages schedule none (an explicit
+ * {@link SessionTitleService.refresh} remains the deliberate unpin).
+ * @param session - exact live session to rename.
+ * @param title - raw user input; normalized before acceptance.
+ * @returns the accepted title snapshot.
+ * @throws {SessionTitleInvalidError} when the title normalizes to empty.
+ * @throws {Error} when the session is not live or the service is disposed.
+ */
+rename(session: Session, title: string): SessionTitleSnapshot
+
 /**
  * Explicitly retry the registered provider, or materialize the built-in
  * fallback when no provider is registered.
@@ -1624,7 +1637,7 @@ register(provider: SessionTitleProvider): () => Promise<void>
 
 Types: [Session](../core-data-structures/session.md) · [SessionTitleProvider](../core-data-structures/session-title.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md)
 
-Source: [`packages/session-title/session-title/src/index.ts:240`](../../packages/session-title/session-title/src/index.ts)
+Source: [`packages/session-title/session-title/src/index.ts:261`](../../packages/session-title/session-title/src/index.ts)
 
 ## `ctx.skills` — `SkillService`
 

+ 2 - 2
docs/core-data-structures/session-title.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/core-data-structures/session-title.md
-session-title.md: 33efc911c0ca1ae94dc4ded74676e5c32a73bdd5
-session-title.zh.md: a4b95a726d2bc89a13d14f1daa2f825cd5aa91b1
+session-title.md: fff1aa1f6be45d0cfc4d7f6a9527ccb93561618f
+session-title.zh.md: 73821b07c6be40d10d0961dd79b7c06bcadb7d0b

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

@@ -34,6 +34,10 @@ type SessionTitleSource =
     readonly provider: SessionTitleProviderId
     readonly model?: SessionTitleModelProvenance
   }
+  | {
+    /** Explicit user rename: pins the title — automatic generation stops scheduling. */
+    readonly kind: 'user'
+  }
 ```
 
 ```ts type-equiv
@@ -41,9 +45,9 @@ type SessionTitleSource =
 interface SessionTitleEventData {
   /** Normalized non-empty title text. */
   readonly title: string
-  /** Exact human `user/message` seqs used to derive this title. */
+  /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */
   readonly messageSeqs: number[]
-  /** Built-in fallback or registered-provider provenance. */
+  /** Built-in fallback, registered-provider, or explicit-user provenance. */
   readonly source: SessionTitleSource
 }
 ```

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

@@ -34,6 +34,10 @@ type SessionTitleSource =
     readonly provider: SessionTitleProviderId
     readonly model?: SessionTitleModelProvenance
   }
+  | {
+    /** Explicit user rename: pins the title — automatic generation stops scheduling. */
+    readonly kind: 'user'
+  }
 ```
 
 ```ts type-equiv
@@ -41,9 +45,9 @@ type SessionTitleSource =
 interface SessionTitleEventData {
   /** Normalized non-empty title text. */
   readonly title: string
-  /** Exact human `user/message` seqs used to derive this title. */
+  /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */
   readonly messageSeqs: number[]
-  /** Built-in fallback or registered-provider provenance. */
+  /** Built-in fallback, registered-provider, or explicit-user provenance. */
   readonly source: SessionTitleSource
 }
 ```

+ 1 - 1
docs/event-producer-consumer.md

@@ -65,7 +65,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | --- | --- | --- |
 | `commands/changed` | `runtime` (`emit`) | `ui-command` |
 | `connection/reset` | `runtime` (`emit`) | `ui-command` |
-| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
+| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
 | `internal/plugin` | - | `hmr`, `modules`, `webserver` |
 | `internal/status` | - | [`agent`](../packages/core/agent) |
 | `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` |

+ 1 - 1
docs/persistence-catalog.md

@@ -416,7 +416,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
 
 Types: [SessionTitleEventData](core-data-structures/session-title.md)
 
-Source: [`packages/session-title/session-title/src/index.ts:96`](../packages/session-title/session-title/src/index.ts)
+Source: [`packages/session-title/session-title/src/index.ts:100`](../packages/session-title/session-title/src/index.ts)
 
 #### `session/title-llm-request` — log-only
 

+ 22 - 0
packages/client/connection/src/client/fixture.ts

@@ -1021,6 +1021,27 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
         if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication')
         return ok(request, { sessionId: created.sessionId })
       },
+      rename: (request) => {
+        const missing = requireSession(request)
+        if (missing !== undefined) return missing
+        const { sessionId, title } = request.payload
+        const normalized = title.trim().replace(/\s+/g, ' ')
+        if (normalized.length === 0) {
+          return err(request, {
+            code: 'title-invalid',
+            message: 'session title must contain visible characters',
+            details: { sessionId },
+          })
+        }
+        // The append emits the session/event and its session/projection frame
+        // (host parallel); the unary response settles the caller first.
+        append(sessionId, {
+          type: 'session/title',
+          data: { title: normalized, messageSeqs: [], source: { kind: 'user' } },
+        })
+        const appended = logOf(sessionId).at(-1) as SessionEvent
+        return ok(request, { title: normalized, seq: appended.seq })
+      },
       history: async (request) => {
         const log = logs.get(request.payload.sessionId) ?? []
         // Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
@@ -1564,6 +1585,7 @@ export class FixtureApiClient extends AbstractApiClient {
       case 'session.history': return this.api.sessions.history(request)
       case 'session.models': return this.api.sessions.models(request)
       case 'session.selectModel': return this.api.sessions.selectModel(request)
+      case 'session.rename': return this.api.sessions.rename(request)
       case 'session.prompt': return this.api.sessions.prompt(request)
       case 'session.cancel': return this.api.sessions.cancel(request)
       case 'host.describe': return this.api.host.describe(request)

+ 2 - 0
packages/client/connection/tests/fake-api.ts

@@ -45,6 +45,7 @@ export class FakeApiClient implements IApiClient {
   // Programmable slots (defaults answer OK-empty); reassign per case.
   onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
   onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
+  onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
   onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
   => Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
     () => Promise.resolve(ok({
@@ -96,6 +97,7 @@ export class FakeApiClient implements IApiClient {
     models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
     selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
       this.record('session.selectModel', payload, this.onSelectModel(payload)),
+    rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
     prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
     cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
   }

+ 41 - 0
packages/client/connection/tests/fixture.spec.ts

@@ -464,6 +464,47 @@ describe('createFixtureApi', () => {
     expect(seen.map(f => f.type)).toEqual(['host/workspace-changed', 'host/workspace-changed'])
   })
 
+  it('session.rename covers not-found, blank title, and the accepted append + title frame', async () => {
+    const api = createFixtureApi()
+    const abort = new AbortController()
+    const framesPromise = (async () => {
+      const frames: MuxFrame[] = []
+      for await (const envelope of api.events.mux(req({}), abort.signal)) {
+        frames.push(envelope.payload)
+        if (frames.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === '重命名')) abort.abort()
+      }
+      return frames
+    })()
+    await new Promise(resolve => setTimeout(resolve, 10))
+
+    const missing = await api.sessions.rename(req({ sessionId: sid('fx-void'), title: 'x' }))
+    expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'fx-void' } } })
+
+    const blank = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: '   ' }))
+    expect(blank.result).toMatchObject({ ok: false, error: { code: 'title-invalid', details: { sessionId: 'fx-alpha' } } })
+
+    const renamed = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: '  重命名  ' }))
+    if (!renamed.result.ok) throw new Error('rename failed')
+    expect(renamed.result.value.title).toBe('重命名')
+    const acceptedSeq = renamed.result.value.seq
+    // The response seq addresses the appended title event (the client plane
+    // has no session/title in its event union — titles ride the projection —
+    // so the event is located by seq and its payload checked structurally).
+    const history = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 100 }))
+    if (!history.result.ok) throw new Error('history failed')
+    const appended = history.result.value.events.find(entry => entry.event.seq === acceptedSeq)
+    expect(appended?.event).toMatchObject({
+      type: 'session/title',
+      data: { title: '重命名', messageSeqs: [], source: { kind: 'user' } },
+    })
+    // Beyond the subscribe-time baseline replay, the append emitted exactly
+    // one title projection frame carrying the new value at the response seq.
+    const frames = await framesPromise
+    const titleFrames = frames.filter(f => f.type === 'session/projection' && f.key === 'title' && f.sessionId === sid('fx-alpha') && f.value === '重命名')
+    expect(titleFrames).toHaveLength(1)
+    expect(titleFrames[0]).toMatchObject({ seq: acceptedSeq })
+  })
+
   it('workspace.insertSessionBefore moves, appends, no-ops, and rejects invalid ids', async () => {
     const api = createFixtureApi()
     const wsid = 'fx-ws-fixture' as WorkspaceId

+ 2 - 2
packages/client/runtime/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/runtime/README.md
-README.md: d283cf19572f4888d17884472ea0d2272109de7f
-README.zh.md: b2d479e1ba277738390de2122c295ce44c77b1e0
+README.md: b51cc0276d8635ea9faa506e30246a107c1c1418
+README.zh.md: 4b2248d875ae37f1b848c51a0009d2497c6b3e61

+ 1 - 1
packages/client/runtime/README.md

@@ -22,7 +22,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
 
 ## Session title projection
 
-`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title.
+`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. `ISession.rename` settles the `title` projection cell directly from the unary response's `{title, seq}` under the same higher-seq-wins rule — the list row and every `useProjection('title')` reader update ahead of the push frame, whose later replay of the same seq is a no-op.
 
 ## Session model selection
 

+ 1 - 1
packages/client/runtime/README.zh.md

@@ -22,7 +22,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
 
 ## Session 标题投影
 
-`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影由日志支撑的标题。
+`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影由日志支撑的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。
 
 ## 会话模型选择
 

+ 7 - 0
packages/client/runtime/src/client/contract/session.ts

@@ -41,6 +41,13 @@ export interface ISession {
    * @returns acceptance, or the business error.
    */
   cancel(): Promise<RpcResult<{ accepted: true }>>
+  /**
+   * Rename this session (explicit user title; pins it against automatic
+   * regeneration).
+   * @param title - raw title text (the host normalizes acceptance).
+   * @returns the normalized accepted title and its event seq, or the business error.
+   */
+  rename(title: string): Promise<RpcResult<{ title: string; seq: number }>>
   /**
    * Extend the history window backwards (older messages pagination).
    * @returns completion; failures land in snapshot.openState/loadingOlder.

+ 19 - 0
packages/client/runtime/src/client/sessions/session.ts

@@ -252,6 +252,25 @@ export class Session implements SessionFace {
     return result
   }
 
+  /**
+   * Rename: contract session.rename 1:1. On success settle the 'title'
+   * projection cell from the response's `{title, seq}` under the store's
+   * higher-seq-wins rule (the push frame arriving later is a no-op replay),
+   * so the list row and any useProjection('title') reader update without
+   * waiting for the mux frame.
+   * @param title - raw title text (the host normalizes acceptance).
+   * @returns the rename result (normalized accepted title + title event seq).
+   */
+  async rename(title: string): Promise<RpcResult<{ title: string; seq: number }>> {
+    try {
+      const { result } = await this.api.sessions.rename({ sessionId: this.sessionId, title })
+      if (result.ok) this.projections.apply('title', result.value.title, result.value.seq)
+      return result
+    } catch (error) {
+      return transportError(error)
+    }
+  }
+
   /**
    * Execute one slash-command line against this session's agent — pure
    * admission semantics (the host executor durably logs the lifecycle;

+ 2 - 0
packages/client/runtime/tests/fake-api.ts

@@ -63,6 +63,7 @@ export class FakeApiClient implements IApiClient {
   onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
   onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
   readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
+  onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
   onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
   => Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
     () => Promise.resolve(ok({ events: [], hasMore: false }))
@@ -115,6 +116,7 @@ export class FakeApiClient implements IApiClient {
     models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
     selectModel: (payload: { provider: string; model: string }) =>
       this.record('session.selectModel', payload, this.onSelectModel(payload)),
+    rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
     prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
     cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
   }

+ 26 - 0
packages/client/runtime/tests/session.spec.ts

@@ -301,6 +301,32 @@ describe('prompt and cancel errors', () => {
   })
 })
 
+describe('rename', () => {
+  it('settles the title projection cell from the unary response (higher-seq-wins vs the push frame)', async () => {
+    const { api, session } = makeSession()
+    api.onRename = () => Promise.resolve(ok({ title: '正名', seq: 7 }))
+    const result = await session.rename('  正名  ')
+    expect(result).toMatchObject({ ok: true, value: { title: '正名', seq: 7 } })
+    expect(api.callsOf('session.rename')).toMatchObject([{ sessionId: SID, title: '  正名  ' }])
+    expect(session.projections.faceOf('title').getSnapshot()).toBe('正名')
+    // A stale lower-seq apply (the push-frame path routes into this same
+    // store) must not roll the settled value back.
+    session.projections.apply('title', '旧名', 3)
+    expect(session.projections.faceOf('title').getSnapshot()).toBe('正名')
+  })
+
+  it('returns the business error untouched and folds a transport throw to internal', async () => {
+    const { api, session } = makeSession()
+    api.onRename = () => Promise.resolve(err({ code: 'title-invalid', message: 'empty', details: { sessionId: SID } }))
+    const rejected = await session.rename('   ')
+    expect(rejected).toMatchObject({ ok: false, error: { code: 'title-invalid' } })
+    expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined()
+    api.onRename = () => Promise.reject(new Error('rename transport down'))
+    const folded = await session.rename('x')
+    expect(folded).toMatchObject({ ok: false, error: { code: 'internal' } })
+  })
+})
+
 describe('pending interactions', () => {
   it('adds approval/question on requested and removes them on resolved', async () => {
     const { session } = makeSession()

+ 7 - 0
packages/client/test-runtime/src/sessions.ts

@@ -108,6 +108,13 @@ export class FixtureSession implements SessionFace {
     throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`)
   }
 
+  /**
+   * Fail-loud stub; supply `rename` on the fixture's session face to exercise it.
+   * @returns never — always throws.
+   */
+  rename(): never {
+    throw new Error(`test session "${this.sessionId}": rename is not stubbed — supply it on the fixture's session face`)
+  }
 }
 
 /** One live test session: fixture-derived stores plus its minted scope state. */

+ 1 - 0
packages/client/test-runtime/tests/runtime.spec.tsx

@@ -470,6 +470,7 @@ describe('fixture session face', () => {
     expect(() => bare.cancel()).toThrow(/cancel is not stubbed/)
     expect(() => bare.command()).toThrow(/command is not stubbed/)
     expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
+    expect(() => bare.rename()).toThrow(/rename is not stubbed/)
     await runtime.dispose()
   })
 

+ 2 - 2
packages/client/ui-workspace/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
-README.md: 8acf819121b46512d38b39ff858bb2bf797cfe96
-README.zh.md: e97d93f7e38d00af91b43f0df9fb0e3b17ae8ed5
+README.md: a1b58f4abe0925be3b426d10344777e46caa9ba0
+README.zh.md: a472507bc45549c8feb55a75d294cbd7b3138cc5

Разлика између датотеке није приказан због своје велике величине
+ 0 - 1
packages/client/ui-workspace/README.md


+ 2 - 2
packages/client/ui-workspace/README.zh.md

@@ -4,7 +4,7 @@
 
 共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot,因此两个表层使用同一菜单和创建流程。
 
-该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
+该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。
 
 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
 
@@ -18,5 +18,5 @@
 
 ## 已知限制与暂缓事项
 
-- **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
+- **没有 Session 删除与 fork 控件**:Session 菜单的 Fork 与 Delete 行仍仅提供视觉效果(Rename 已接线);删除 Workspace 注册记录不会删除 Session。
 - **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。

+ 73 - 3
packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx

@@ -92,12 +92,14 @@ type SessionTreeProps = Pick<
   onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
   /** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
   onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
+  /** Open the browser-owned session rename dialog. */
+  onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void
 }
 
 /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
 function SessionTree({
   useSessions, startSession, open, workspaces, query,
-  onRenameRequest, onDeleteRequest, insertSessionBefore,
+  onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore,
 }: SessionTreeProps) {
   const list = useSessions(s => s)
   const current = list.current
@@ -192,6 +194,7 @@ function SessionTree({
                   currentId={current}
                   now={now}
                   onOpen={open}
+                  onRename={onSessionRename}
                   onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
                   drag={dragProps}
                 />
@@ -206,7 +209,7 @@ function SessionTree({
 }
 
 /** The flat "In one list" body: every session a top-level row, newest-first. */
-function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'query'>) {
+function FlatList({ useSessions, open, onSessionRename, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'onSessionRename' | 'query'>) {
   const list = useSessions(s => s)
   const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
   const now = Date.now()
@@ -224,6 +227,7 @@ function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessi
             currentId={list.current}
             now={now}
             onOpen={open}
+            onRename={onSessionRename}
             /* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
             onToggle={() => {}}
             flat
@@ -249,6 +253,7 @@ export function WorkspaceBrowser({
   actions,
   startSession,
   open,
+  renameSession,
   renameWorkspace,
   deleteWorkspace,
   insertSessionBefore,
@@ -309,6 +314,39 @@ export function WorkspaceBrowser({
     })
   }
 
+  // Session rename dialog (same browser-owned pattern as workspace rename;
+  // sessions have no client-side name-conflict rule — the host normalizes).
+  // Unlike workspace rename, an unchanged title is NOT blocked: confirming
+  // the current automatic title is the gesture that pins it.
+  const [sessionRenameTarget, setSessionRenameTarget] = useState<{ sessionId: SessionNode['id']; currentTitle: string } | null>(null)
+  const [sessionRenameDraft, setSessionRenameDraft] = useState('')
+  const [sessionRenaming, setSessionRenaming] = useState(false)
+  const [sessionRenameError, setSessionRenameError] = useState<string | null>(null)
+  const sessionRenameTrimmed = sessionRenameDraft.trim()
+  const sessionRenameBlocked = sessionRenaming || sessionRenameTrimmed === '' || sessionRenameTarget === null
+  const closeSessionRename = () => {
+    if (sessionRenaming) return
+    setSessionRenameTarget(null)
+    setSessionRenameError(null)
+  }
+  const confirmSessionRename = () => {
+    if (sessionRenameBlocked) return
+    setSessionRenaming(true)
+    setSessionRenameError(null)
+    renameSession(sessionRenameTarget.sessionId, sessionRenameTrimmed).then(() => {
+      setSessionRenaming(false)
+      setSessionRenameTarget(null)
+    }).catch((reason: unknown) => {
+      setSessionRenaming(false)
+      setSessionRenameError(reason instanceof Error ? reason.message : String(reason))
+    })
+  }
+  const onSessionRename = (sessionId: SessionNode['id'], currentTitle: string) => {
+    setSessionRenameTarget({ sessionId, currentTitle })
+    setSessionRenameDraft(currentTitle)
+    setSessionRenameError(null)
+  }
+
   // Delete dialog is separate from the row so a successful removal can
   // unmount that row without tearing down the in-flight confirmation state.
   const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null)
@@ -424,10 +462,11 @@ export function WorkspaceBrowser({
           itself is wide-only. */}
       <div className={css.listArea}>
         {wide && (groupBy === 'flat'
-          ? <FlatList useSessions={useSessions} open={open} query={query} />
+          ? <FlatList useSessions={useSessions} open={open} onSessionRename={onSessionRename} query={query} />
           : (
             <SessionTree
               useSessions={useSessions}
+              onSessionRename={onSessionRename}
               workspaces={workspaces}
               startSession={startSession}
               open={open}
@@ -479,6 +518,37 @@ export function WorkspaceBrowser({
         )}
         {renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>}
       </Modal>
+
+      <Modal
+        open={sessionRenameTarget !== null}
+        onClose={closeSessionRename}
+        title="Rename session"
+        footer={(
+          <>
+            <Button variant="outline" disabled={sessionRenaming} onClick={closeSessionRename}>Cancel</Button>
+            <Button variant="primary" disabled={sessionRenameBlocked} onClick={confirmSessionRename}>Rename</Button>
+          </>
+        )}
+      >
+        <input
+          className={css.renameInput}
+          value={sessionRenameDraft}
+          aria-label="Session name"
+          autoFocus
+          disabled={sessionRenaming}
+          onFocus={(e) => { e.target.select() }}
+          onChange={(e) => { setSessionRenameDraft(e.target.value); setSessionRenameError(null) }}
+          onCompositionStart={() => { composingRef.current = true }}
+          onCompositionEnd={() => { composingRef.current = false }}
+          onKeyDown={(e) => {
+            if (e.key === 'Enter' && !composingRef.current) {
+              e.preventDefault()
+              confirmSessionRename()
+            }
+          }}
+        />
+        {sessionRenameError !== null && <div className={css.renameError} role="alert">{sessionRenameError}</div>}
+      </Modal>
       <Modal
         open={deleteTarget !== null}
         onClose={closeDelete}

+ 2 - 0
packages/client/ui-workspace/src/client/contract/slots.ts

@@ -93,6 +93,8 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
   startSession: (workspaceId?: WorkspaceId) => void
   /** Open a real Session. */
   open: (sessionId: SessionId) => void
+  /** Rename a Session (explicit user title; resolves on host acceptance). */
+  renameSession: (sessionId: SessionId, title: string) => Promise<void>
   /** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
   renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
   /** Delete only a Host Workspace registration; directory and Session logs remain. */

+ 8 - 0
packages/client/ui-workspace/src/client/index.ts

@@ -51,6 +51,14 @@ export function apply(ctx: ClientContext): void {
     // the runtime's shared action (recent-Workspace projection inside).
     startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
     open: (sessionId) => { ctx.sessions.open(sessionId) },
+    renameSession: async (sessionId, title) => {
+      // Row → session-face hop: rename is a per-session verb (ISession), not
+      // a list-service verb; the binding resolves any listed session.
+      const session = ctx.sessions.binding(sessionId)?.session
+      if (session === undefined) throw new Error(`unknown session "${sessionId}"`)
+      const result = await session.rename(title)
+      if (!result.ok) throw new Error(result.error.message)
+    },
     renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
     deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
     insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {

+ 10 - 4
packages/client/ui-workspace/src/client/rows/Rows.tsx

@@ -2,8 +2,8 @@
  * Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
  * all data and callbacks arrive via props. Hover swaps (folder->chevron,
  * time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
- * except workspace Rename; the session hover card is suppressed while a menu
- * is open. Workspace Rename/Delete are wired; session actions remain visual-only.
+ * except workspace Rename/Delete and session Rename; the session hover card is
+ * suppressed while a menu is open.
  */
 import { useState } from 'react'
 import clsx from 'clsx'
@@ -159,12 +159,14 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
   return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
 }
 
-export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle, drag, flat = false }: {
+export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onToggle, drag, flat = false }: {
   node: SessionNode
   depth: number
   currentId: string | undefined
   now: number
   onOpen: (id: SessionNode['id']) => void
+  /** Open the browser-owned session rename dialog (row menu action). */
+  onRename: (id: SessionNode['id'], currentTitle: string) => void
   onToggle: (id: SessionNode['id']) => void
   /** Present only on draggable rows (workspace-group roots outside search). */
   drag?: RowDragProps | undefined
@@ -232,7 +234,10 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle,
           open={menuOpen}
           onClose={() => { setMenuOpen(false) }}
           items={SESSION_MENU_ITEMS}
-          onSelect={() => { setMenuOpen(false) }} // Visual-only for now.
+          onSelect={(id) => {
+            setMenuOpen(false)
+            if (id === 'rename') onRename(node.id, row.title) // fork/delete stay visual-only.
+          }}
           portal
           closeOnPointerLeave
           anchor={(
@@ -264,6 +269,7 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle,
           currentId={currentId}
           now={now}
           onOpen={onOpen}
+          onRename={onRename}
           onToggle={onToggle}
         />
       ))}

+ 29 - 11
packages/client/ui-workspace/tests/rows.spec.tsx

@@ -68,7 +68,8 @@ describe('workspace browser rows', () => {
     const onOpen = vi.fn()
     const onToggle = vi.fn()
     const view = render(
-      <SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen} onToggle={onToggle} />,
+      <SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen}
+        onRename={vi.fn()} onToggle={onToggle} />,
     )
 
     const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
@@ -88,7 +89,8 @@ describe('workspace browser rows', () => {
     view.rerender(
       <SessionNodeItem
         node={{ ...parent, children: [], expanded: false, running: false }}
-        depth={1} currentId={undefined} now={0} onOpen={onOpen} onToggle={onToggle}
+        depth={1} currentId={undefined} now={0} onOpen={onOpen}
+        onRename={vi.fn()} onToggle={onToggle}
       />,
     )
     expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
@@ -135,19 +137,29 @@ describe('workspace browser rows', () => {
     expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull()
   })
 
-  it('session row menu opens without opening the session and closes on selection', () => {
+  it('session row menu opens without opening the session and dispatches rename', () => {
     const onOpen = vi.fn()
+    const onRename = vi.fn()
     const node: SessionNode = {
       id: sid('s1'), title: 'One', children: [], hasChildren: false,
       expanded: false, running: false, updatedAt: 0,
     }
-    render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen} onToggle={vi.fn()} />)
+    render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
+      onRename={onRename} onToggle={vi.fn()} />)
     fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
     expect(onOpen).not.toHaveBeenCalled()
     expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
-    fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
+    // Rename dispatches with the current display title (dialog prefill).
+    fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
     expect(screen.queryByRole('menu')).toBeNull()
+    expect(onRename).toHaveBeenCalledWith(node.id, 'One')
     expect(onOpen).not.toHaveBeenCalled()
+    // Fork and Delete stay visual-only.
+    fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
+    fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
+    fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
+    fireEvent.click(screen.getByRole('menuitem', { name: 'Delete session' }))
+    expect(onRename).toHaveBeenCalledOnce()
     // Escape closes without selecting (Menu onClose path).
     fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
     fireEvent.keyDown(document, { key: 'Escape' })
@@ -159,7 +171,8 @@ describe('workspace browser rows', () => {
       id: sid('p'), title: 'Parent', children: [], hasChildren: true,
       expanded: false, running: false, updatedAt: 0,
     }
-    render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} flat />)
+    render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
+      onRename={vi.fn()} onToggle={vi.fn()} flat />)
     expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
   })
 
@@ -170,7 +183,8 @@ describe('workspace browser rows', () => {
         id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
         expanded: false, running: true, updatedAt: 0,
       }
-      render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()} onToggle={vi.fn()} />)
+      render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
+        onRename={vi.fn()} onToggle={vi.fn()} />)
       const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
       fireEvent.pointerEnter(wrapper)
       act(() => { vi.advanceTimersByTime(500) })
@@ -196,7 +210,8 @@ describe('workspace browser rows', () => {
         id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
         expanded: false, running: false, updatedAt: 0,
       }
-      render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} />)
+      render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
+        onRename={vi.fn()} onToggle={vi.fn()} />)
       fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
       act(() => { vi.advanceTimersByTime(500) })
       expect(screen.getByText('Idle')).toBeTruthy()
@@ -213,7 +228,8 @@ describe('workspace browser rows', () => {
     }
     const inactive = dragProps()
     const { rerender } = render(
-      <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
+      <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
+        onRename={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
     )
     const row = screen.getByRole('treeitem')
     stubRect(row)
@@ -230,7 +246,8 @@ describe('workspace browser rows', () => {
 
     const active = dragProps({ active: true, marker: 'before' })
     rerender(
-      <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={active} />,
+      <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
+        onRename={vi.fn()} onToggle={vi.fn()} drag={active} />,
     )
     stubRect(screen.getByRole('treeitem'))
     // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
@@ -243,7 +260,8 @@ describe('workspace browser rows', () => {
 
     const after = dragProps({ active: true, marker: 'after' })
     rerender(
-      <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={after} />,
+      <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
+        onRename={vi.fn()} onToggle={vi.fn()} drag={after} />,
     )
     expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
   })

+ 1 - 0
packages/client/ui-workspace/tests/workspace-browser.spec.tsx

@@ -55,6 +55,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
     actions: store.actions,
     startSession: vi.fn(),
     open: vi.fn(),
+    renameSession: vi.fn(async () => {}),
     renameWorkspace: vi.fn(async () => {}),
     deleteWorkspace: vi.fn(async () => {}),
     insertSessionBefore: vi.fn(async () => {}),

+ 5 - 1
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -734,6 +734,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         signature: 'get(session: Session): SessionTitleSnapshot | undefined',
         jsDoc: '/**\n * Read the latest folded title from one live or replayed session.\n * @param session - session whose log is the title source of truth.\n * @returns latest title snapshot, or `undefined` before eligible input.\n */',
       },
+      {
+        signature: 'rename(session: Session, title: string): SessionTitleSnapshot',
+        jsDoc: '/**\n * Accept an explicit user title. Appends a `session/title` event with the\n * `user` source, which pins the title: in-flight automatic generation is\n * superseded and later user messages schedule none (an explicit\n * {@link SessionTitleService.refresh} remains the deliberate unpin).\n * @param session - exact live session to rename.\n * @param title - raw user input; normalized before acceptance.\n * @returns the accepted title snapshot.\n * @throws {SessionTitleInvalidError} when the title normalizes to empty.\n * @throws {Error} when the session is not live or the service is disposed.\n */',
+      },
       {
         signature: 'async refresh(session: Session, signal?: AbortSignal): Promise<SessionTitleSnapshot | undefined>',
         jsDoc: '/**\n * Explicitly retry the registered provider, or materialize the built-in\n * fallback when no provider is registered.\n * @param session - exact live session to refresh.\n * @param signal - optional caller cancellation.\n * @returns latest accepted title, or `undefined` when no eligible text exists.\n */',
@@ -2325,7 +2329,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SessionTitleSource',
-    declaration: 'export type SessionTitleSource = {\n    readonly kind: \'fallback\';\n} | {\n    readonly kind: \'provider\';\n    readonly provider: SessionTitleProviderId;\n    readonly model?: SessionTitleModelProvenance;\n};',
+    declaration: 'export type SessionTitleSource = {\n    readonly kind: \'fallback\';\n} | {\n    readonly kind: \'provider\';\n    readonly provider: SessionTitleProviderId;\n    readonly model?: SessionTitleModelProvenance;\n} | {\n    readonly kind: \'user\';\n};',
   },
   {
     name: 'SessionTitleUserMessage',

+ 2 - 2
packages/host/apiproxy/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
-README.md: ca4471454f5be5d3fcba38ce665d4fb3fbd85e74
-README.zh.md: 953539e1198a52b2bf7cdd9ca1b0d263cc2ae6f9
+README.md: b5f80dcb3a077a411db3b721737a9c16b56fcecf
+README.zh.md: 1c3c7486f7d500f8c2d36028d47f29b112d9b5ae

+ 1 - 1
packages/host/apiproxy/README.md

@@ -12,7 +12,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
 
 `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
 
-Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs.
+Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.
 
 Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`.
 

+ 1 - 1
packages/host/apiproxy/README.zh.md

@@ -12,7 +12,7 @@
 
 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
 
-会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。
+会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。
 
 会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理(reasoning)目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。
 

+ 1 - 0
packages/host/apiproxy/package.json

@@ -51,6 +51,7 @@
     "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-session-projection-cache": "workspace:^",
+    "@deepseek-ai/dsh-session-title": "workspace:^",
     "@deepseek-ai/dsh-skill": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-user-approval": "workspace:^",

+ 32 - 0
packages/host/apiproxy/src/api-proxy.ts

@@ -38,6 +38,8 @@ import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal'
 // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
 import type {} from '@deepseek-ai/dsh-commands'
 import type {} from '@deepseek-ai/dsh-skill'
+// Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`.
+import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
 import type { CallId } from '@deepseek-ai/dsh-llm/brand'
 import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
 // Side-effect type import: resolves the `approval/request` waterfall and
@@ -1049,6 +1051,36 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
         }
       },
 
+      async rename(request) {
+        const { sessionId, title } = request.payload
+        const found = await agentFor(sessionId)
+        if ('error' in found) return err(request, found.error)
+        const titles = ctx.get('sessionTitle')
+        if (titles === undefined) {
+          return err(request, { code: 'internal', message: 'renaming is unavailable: this deployment mounts no session-title service', details: {} })
+        }
+        try {
+          const accepted = titles.rename(found.agent.session, title)
+          return ok(request, { title: accepted.title, seq: accepted.eventSeq })
+        } catch (error: unknown) {
+          // Only the input's fault maps to title-invalid (the message is
+          // product-user-visible in the rename dialog); liveness and disposal
+          // races are deployment trouble, not a bad title.
+          if (error instanceof SessionTitleInvalidError) {
+            return err(request, {
+              code: 'title-invalid',
+              message: error.message,
+              details: { sessionId },
+            })
+          }
+          return err(request, {
+            code: 'internal',
+            message: `failed to rename session "${sessionId}": ${String(error)}`,
+            details: {},
+          })
+        }
+      },
+
       async prompt(request) {
         const { sessionId, mode, content } = request.payload
         const found = await agentFor(sessionId)

+ 1 - 0
packages/host/apiproxy/src/api/rpc-map.ts

@@ -23,6 +23,7 @@ export interface RpcMethodMap {
   'session.history': SessionsApi['history']
   'session.models': SessionsApi['models']
   'session.selectModel': SessionsApi['selectModel']
+  'session.rename': SessionsApi['rename']
   'session.prompt': SessionsApi['prompt']
   'session.cancel': SessionsApi['cancel']
   'host.describe': HostApi['describe']

+ 1 - 0
packages/host/apiproxy/src/api/rpc.schema.ts

@@ -49,6 +49,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
   z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
   z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
   z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
+  z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
   z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
 ]) as unknown as z.ZodType<RpcError>
 

+ 1 - 0
packages/host/apiproxy/src/api/rpc.ts

@@ -48,6 +48,7 @@ export interface RpcErrorDetailsMap {
   'command-error': {}
   /** A leading-/ prompt named no registered command; the message names the token. */
   'unknown-command': {}
+  'title-invalid': { sessionId: SessionId }
   'internal': {}
 }
 

+ 12 - 0
packages/host/apiproxy/src/api/sessions.schema.ts

@@ -73,6 +73,18 @@ export const sessionCreateValueSchema = z.object({
   sessionId: sessionIdSchema,
 }) satisfies z.ZodType<Wire<ResponseValue<'session.create'>>>
 
+/** session.rename request payload (raw title; host-side normalization decides acceptance). */
+export const sessionRenameRequestSchema = z.object({
+  sessionId: sessionIdSchema,
+  title: z.string(),
+}) satisfies z.ZodType<Wire<RequestPayload<'session.rename'>>>
+
+/** session.rename response value (the normalized accepted title and its event seq). */
+export const sessionRenameValueSchema = z.object({
+  title: z.string().min(1),
+  seq: z.number().int().nonnegative(),
+}) satisfies z.ZodType<Wire<ResponseValue<'session.rename'>>>
+
 /** session.history request payload (beforeSeq/maxMessages page backwards from the window tail). */
 export const sessionHistoryRequestSchema = z.object({
   sessionId: sessionIdSchema,

+ 10 - 0
packages/host/apiproxy/src/api/sessions.ts

@@ -208,6 +208,16 @@ export interface SessionsApi {
   }>):
   Promise<RpcResponse<{ selected: ModelTarget }>>
 
+  /**
+   * Renames a session: appends a `session/title` event with the `user`
+   * source, which pins the title against automatic regeneration. The
+   * normalized accepted title and the title event's seq return so the caller
+   * can settle its projection cell without waiting for the push frame. A
+   * title that normalizes to empty fails with `title-invalid`.
+   */
+  rename(request: RpcRequest<{ sessionId: SessionId; title: string }>):
+  Promise<RpcResponse<{ title: string; seq: number }>>
+
   /**
    * Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer.
    * A prompt whose content is exactly one text block starting with '/' is a slash command: the host

+ 4 - 0
packages/host/apiproxy/src/fetch/client.ts

@@ -24,6 +24,7 @@ import {
   sessionListValueSchema,
   sessionModelsValueSchema,
   sessionPromptValueSchema,
+  sessionRenameValueSchema,
   sessionSelectModelValueSchema,
 } from '../api/sessions.schema.ts'
 import {
@@ -66,6 +67,7 @@ export interface IApiClient {
     history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.history'>>>
     models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
     selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
+    rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.rename'>>>
     prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
     cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
   }
@@ -116,6 +118,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
   'session.history': sessionHistoryValueSchema,
   'session.models': sessionModelsValueSchema,
   'session.selectModel': sessionSelectModelValueSchema,
+  'session.rename': sessionRenameValueSchema,
   'session.prompt': sessionPromptValueSchema,
   'session.cancel': sessionCancelValueSchema,
   'host.describe': hostDescribeValueSchema,
@@ -327,6 +330,7 @@ export abstract class AbstractApiClient implements IApiClient {
     history: (payload, signal) => this.callUnary('session.history', payload, signal),
     models: (payload, signal) => this.callUnary('session.models', payload, signal),
     selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal),
+    rename: (payload, signal) => this.callUnary('session.rename', payload, signal),
     prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
     cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
   }

+ 2 - 0
packages/host/apiproxy/src/fetch/handler.ts

@@ -21,6 +21,7 @@ import {
   sessionListRequestSchema,
   sessionModelsRequestSchema,
   sessionPromptRequestSchema,
+  sessionRenameRequestSchema,
   sessionSelectModelRequestSchema,
 } from '../api/sessions.schema.ts'
 import {
@@ -68,6 +69,7 @@ const UNARY_ROUTES: UnaryRoutes = {
   'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) },
   'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },
   'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) },
+  'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) },
   'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
   'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
   'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },

+ 129 - 0
packages/host/apiproxy/tests/api-proxy-rename.spec.ts

@@ -0,0 +1,129 @@
+/**
+ * sessions.rename delegation through the composed SessionTitleService. The
+ * agent factory is a structural stub whose createAgent forwards seed/meta into
+ * the real SessionStore, and whose resume never runs (every source here is
+ * already attached). Cold-session resolution is the shared `agentFor` path —
+ * api-proxy-cold.spec.ts owns the resume evidence for every unary that rides
+ * it, rename included.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import SessionStore from '@deepseek-ai/dsh-session'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import SessionTitleService from '@deepseek-ai/dsh-session-title'
+import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
+import type { Session, SessionId } from '@deepseek-ai/dsh-session'
+import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
+import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
+import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
+
+const sid = (id: string): SessionId => id as SessionId
+
+let nextRpc = 1
+function request<P>(payload: P): RpcRequest<P> {
+  return { rpcId: RpcId(`fr-${String(nextRpc++)}`), payload }
+}
+
+async function composed(withTitles = true): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(UserInteractionService)
+  if (withTitles) {
+    await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 40 })
+  }
+  // Store-backed structural factory: create builds the session with the
+  // forwarded seed/meta (the store validates the balanced prefix) and
+  // registers an idle agent stub over it.
+  ctx.agents.setFactory({
+    createAgent: (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
+      const session = ctx.sessions.create(options.sessionId, {
+        ...options.seed === undefined ? {} : { seed: [...options.seed] },
+        ...options.meta === undefined ? {} : { meta: options.meta },
+      })
+      const agent = { id: session.id, session, status: 'idle', ctx: ownerCtx } as Agent
+      ctx.agents.register(agent)
+      return Promise.resolve({ agent, dispose: () => Promise.resolve() })
+    },
+    resume: () => Promise.reject(new Error('resume must not run: every source is attached')),
+  })
+  return ctx
+}
+
+/** Register one live agent whose log holds `turns` completed turns. */
+function liveAgent(ctx: Context, id: string, turns: number): Session {
+  const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } })
+  for (let turn = 1; turn <= turns; turn++) {
+    session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
+    session.append('user/message', createUserMessage({
+      content: [{ type: 'text', text: `prompt ${String(turn)}` }],
+      source: { kind: 'user' },
+    }), { surfaceOp: 'append' })
+    session.append('turn/end', { turn, reason: { kind: 'completed' } })
+  }
+  ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
+  return session
+}
+
+const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+
+describe('sessions.rename', () => {
+  it('accepts through the composed title service: normalized user-source event, echoed seq', async () => {
+    const ctx = await composed()
+    const source = liveAgent(ctx, 'session-rename', 1)
+
+    const renamed = await api(ctx).sessions.rename(request({ sessionId: source.id, title: '  new   name  ' }))
+    expect(renamed.result.ok).toBe(true)
+    if (!renamed.result.ok) return
+    expect(renamed.result.value.title).toBe('new name')
+    const event = source.events.findLast(item => item.type === 'session/title')
+    expect(event?.seq).toBe(renamed.result.value.seq)
+    expect(event?.data).toMatchObject({ title: 'new name', source: { kind: 'user' } })
+  })
+
+  it('maps only an empty-normalizing title to title-invalid, with a presentable message', async () => {
+    const ctx = await composed()
+    const source = liveAgent(ctx, 'session-rename-bad', 1)
+
+    // U+200B passes a client-side trim gate but normalizes to empty host-side.
+    const response = await api(ctx).sessions.rename(request({ sessionId: source.id, title: ' ​ ' }))
+    expect(response.result.ok).toBe(false)
+    if (!response.result.ok) {
+      expect(response.result.error).toMatchObject({
+        code: 'title-invalid',
+        details: { sessionId: source.id },
+      })
+      // The message renders verbatim in the rename dialog's alert.
+      expect(response.result.error.message).toBe('session title must contain visible characters')
+    }
+  })
+
+  it('maps a non-validation rename failure (stale session object) to internal, not title-invalid', async () => {
+    const ctx = await composed()
+    // The registered agent holds a session object from another store: the
+    // title service's liveness check throws a plain Error, which must not
+    // read as the user's fault.
+    const foreign = await composed(false)
+    const stale = liveAgent(foreign, 'session-rename-stale', 1)
+    ctx.agents.register({ id: stale.id, session: stale, status: 'idle', ctx } as Agent)
+
+    const response = await api(ctx).sessions.rename(request({ sessionId: stale.id, title: 'name' }))
+    expect(response.result.ok).toBe(false)
+    if (!response.result.ok) expect(response.result.error.code).toBe('internal')
+  })
+
+  it('answers internal when the composition mounts no session-title service', async () => {
+    const ctx = await composed(false)
+    const source = liveAgent(ctx, 'session-no-titles', 1)
+
+    const response = await api(ctx).sessions.rename(request({ sessionId: source.id, title: 'name' }))
+    expect(response.result.ok).toBe(false)
+    if (!response.result.ok) {
+      expect(response.result.error.code).toBe('internal')
+      expect(response.result.error.message).toMatch(/mounts no session-title service/)
+    }
+  })
+})

+ 1 - 0
packages/host/apiproxy/tests/client-handler.spec.ts

@@ -46,6 +46,7 @@ function scriptedApi(overrides: {
       selectModel: r => ok(r, {
         selected: { provider: r.payload.provider, model: r.payload.model },
       }),
+      rename: r => ok(r, { title: 'renamed', seq: 0 }),
       prompt: r => ok(r, { accepted: true as const }),
       cancel: r => ok(r, { accepted: true as const }),
       ...overrides.sessions,

+ 5 - 0
packages/host/apiproxy/tests/fetch-carrier.spec.ts

@@ -67,6 +67,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
           },
         }
       },
+      async rename(request) {
+        return { rpcId: request.rpcId, result: { ok: true, value: { title: request.payload.title, seq: 0 } } }
+      },
       async prompt(request) {
         return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
       },
@@ -224,6 +227,8 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
         },
       },
     })
+    const renamed = await c.sessions.rename({ sessionId: 's' as never, title: 'named' })
+    expect(renamed.result).toMatchObject({ ok: true, value: { title: 'named', seq: 0 } })
     expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
     expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true)
     expect((await c.host.describe({})).result.ok).toBe(true)

+ 2 - 0
packages/host/apiproxy/tests/rpc-schemas.spec.ts

@@ -70,11 +70,13 @@ describe('rpcErrorSchema', () => {
     expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
     expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error')
     expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command')
+    expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid')
     expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
   })
 
   it('rejects a known code with missing details', () => {
     expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow()
+    expect(() => rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: {} })).toThrow()
     expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow()
     expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow()
   })

+ 3 - 0
packages/host/apiproxy/tsconfig.json

@@ -41,6 +41,9 @@
     {
       "path": "../../session-projection/session-projection-cache"
     },
+    {
+      "path": "../../session-title/session-title"
+    },
     {
       "path": "../../skill/skill"
     },

+ 2 - 2
packages/session-title/session-title/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/session-title/session-title/README.md
-README.md: 1939d00f7e78834ec19e2d6b4590cf12af297a30
-README.zh.md: f842db9ca5d0215ecf6589978983952678a19897
+README.md: 9a5ec27c36f3411add37ebe231262eb5d205bc9e
+README.zh.md: 38fc9f82e3fcd94233cad31e291b8258c97d0975

+ 2 - 1
packages/session-title/session-title/README.md

@@ -10,6 +10,7 @@ Only text blocks from human `user/message` events are eligible. The first eligib
 
 - `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; cancellation does not roll back an already accepted fallback event.
+- `rename(session, title)` accepts an explicit user title synchronously: it normalizes the text, supersedes in-flight automatic work, and appends a `session/title` event with the `user` source. A user-sourced latest title pins the session — later user messages schedule no automatic revision; an explicit `refresh` remains the deliberate unpin.
 - `register(provider)` installs the sole optional provider and returns its awaitable Cordis effect disposer. A second registration throws immediately; disposal aborts pending and active calls, waits for their settlement, and only then permits another provider to register.
 
 Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion appends a standalone log-only event directly through `Session` without opening a turn. Persistence observes that event eagerly and drains on ordinary lifecycle checkpoints; title publication itself does not force a flush. 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. Concurrent explicit refreshes reserve their revision before provider work, while overlapping automatic and explicit fallback requests share one session-local in-flight append. The service and bundled model provider each append their own literal event type, so no generic title-write marker, cast, or settlement queue is needed. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes.
@@ -50,5 +51,5 @@ None for the main request; title events do not change its reconstructed content
 
 ## Known Limitations and Deferred Work
 
-- Manual rename, title deletion, generated-versus-user precedence, search, and list indexing are outside this service.
+- Title deletion (unpinning back to automatic titles without an explicit `refresh`), 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.

+ 2 - 1
packages/session-title/session-title/README.zh.md

@@ -10,6 +10,7 @@
 
 - `get(session)` 从活跃或回放日志折叠最新已接受标题。
 - `refresh(session, signal?)` 在需要时物化回退,然后显式运行已注册提供方,处理当前符合条件的消息。提供方错误或调用方取消都会导致返回的 Promise 被拒绝;取消不会回滚已接受的回退事件。
+- `rename(session, title)` 同步接受用户显式标题:规范化文本、取代在途自动工作,并追加一条 `user` 来源的 `session/title` 事件。最新标题来源为 user 即钉住该会话——后续用户消息不再安排自动 revision;显式 `refresh` 仍是有意的解钉手段。
 - `register(provider)` 安装唯一可选提供方,并返回可等待的 Cordis effect disposer。第二次注册会立即抛出;对提供方执行 dispose(资源释放)会中止待处理和活跃调用,等待其结算,之后才允许注册另一个提供方。
 
 自动工作绝不会延迟主 agent(智能体)响应。只有当带标记、由循环构建的请求,其确切路由与当前已记录的 `request/header` 匹配时,提供方才会启动;即使请求头未变而无需新快照,也适用此规则。延迟完成会直接通过 `Session` 追加一个独立的纯日志事件,而不打开轮次。持久化会立即观察到该事件,并在常规生命周期检查点完成刷写;标题发布本身不会强制刷写。自动失败会发出警告并保留最新标题。新的全消息修订、提供方 dispose、会话 dispose 和显式刷新都会中止旧工作,陈旧的完成结果无法追加。并发显式刷新会在提供方工作之前预留修订号;重叠的自动/显式回退请求共享一个会话本地正在进行的追加操作。服务与内置模型提供方各自追加自己的字面事件类型,因此不需要通用标题写入标记、类型断言或结算队列。服务拆卸会取消排队工作,并在卸载完成前等待不响应取消的调用结算完成。
@@ -50,5 +51,5 @@ Fork 出的会话会原样继承种子中的标题事件。首消息节奏不会
 
 ## 已知限制与暂缓事项
 
-- 手动重命名、删除标题、生成标题与用户标题的优先级、搜索和列表索引都不属于此服务。
+- 删除标题(不经显式 `refresh` 就解钉回自动标题)、搜索和列表索引不属于此服务。
 - 提供方注册表有意最多接受一个实现,因此部署若要组合相互竞争的标题策略,必须编写一个自行负责优先级的提供方。

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

@@ -7,7 +7,7 @@ import { Context, FiberState, Service, type Fiber } from 'cordis'
 import z from 'schemastery'
 import { z as zod } from 'zod'
 import type { Branded } from '@deepseek-ai/dsh-brand'
-import { deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
+import { assertNever, deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
 import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
 import type {
   Session,
@@ -52,14 +52,18 @@ export type SessionTitleSource =
     readonly provider: SessionTitleProviderId
     readonly model?: SessionTitleModelProvenance
   }
+  | {
+    /** Explicit user rename: pins the title — automatic generation stops scheduling. */
+    readonly kind: 'user'
+  }
 
 /** 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. */
+  /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */
   readonly messageSeqs: number[]
-  /** Built-in fallback or registered-provider provenance. */
+  /** Built-in fallback, registered-provider, or explicit-user provenance. */
   readonly source: SessionTitleSource
 }
 
@@ -97,6 +101,16 @@ declare module '@deepseek-ai/dsh-session' {
   }
 }
 
+/**
+ * Rejection of an explicit user title whose text normalizes to empty — the
+ * one {@link SessionTitleService.rename} failure that blames the input.
+ * Callers translating rename failures onto a wire (`title-invalid`) narrow on
+ * this class; liveness and disposal failures stay plain `Error`s.
+ */
+export class SessionTitleInvalidError extends Error {
+  override readonly name = 'SessionTitleInvalidError'
+}
+
 /** One eligible human text message exposed to title providers. */
 export interface SessionTitleUserMessage {
   /** Source `user/message` event seq. */
@@ -180,20 +194,27 @@ export function foldSessionTitle(events: readonly SessionEvent[]): SessionTitleS
   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 } }),
-      },
+    source: copySessionTitleSource(event.data.source),
     eventSeq: event.seq,
     updatedAt: event.time,
   })
 }
 
+/** Defensive copy of a logged title source (the snapshot must not alias log-owned objects). */
+function copySessionTitleSource(source: SessionTitleSource): SessionTitleSource {
+  switch (source.kind) {
+    case 'fallback': return { kind: 'fallback' }
+    case 'provider': return {
+      kind: 'provider',
+      provider: source.provider,
+      ...(source.model === undefined ? {} : { model: { ...source.model } }),
+    }
+    case 'user': return { kind: 'user' }
+    /* v8 ignore next -- closed-union exhaustiveness guard */
+    default: return assertNever(source, 'SessionTitleSource')
+  }
+}
+
 /** Service-owned resolved limits. */
 interface ResolvedConfig {
   readonly fallbackMaxWords: number
@@ -328,6 +349,39 @@ export class SessionTitleService extends Service {
     return foldSessionTitle(session.events)
   }
 
+  /**
+   * Accept an explicit user title. Appends a `session/title` event with the
+   * `user` source, which pins the title: in-flight automatic generation is
+   * superseded and later user messages schedule none (an explicit
+   * {@link SessionTitleService.refresh} remains the deliberate unpin).
+   * @param session - exact live session to rename.
+   * @param title - raw user input; normalized before acceptance.
+   * @returns the accepted title snapshot.
+   * @throws {SessionTitleInvalidError} when the title normalizes to empty.
+   * @throws {Error} when the session is not live or the service is disposed.
+   */
+  rename(session: Session, title: string): SessionTitleSnapshot {
+    this.assertServiceActive()
+    if (this.ctx.sessions.get(session.id) !== session) {
+      throw new Error(`session "${session.id}" is not live in this store`)
+    }
+    const normalized = normalizeSessionTitle(title, this.config.maxTitleBytes)
+    if (normalized.length === 0) {
+      throw new SessionTitleInvalidError('session title must contain visible characters')
+    }
+    const state = this.stateFor(session)
+    this.supersede(state, 'user rename superseded automatic title generation')
+    session.append('session/title', {
+      title: normalized,
+      messageSeqs: [],
+      source: { kind: 'user' },
+    })
+    const snapshot = this.get(session)
+    /* v8 ignore next -- unreachable: the append above just committed a session/title event. */
+    if (snapshot === undefined) throw new Error('renamed title failed to fold')
+    return snapshot
+  }
+
   /**
    * Explicitly retry the registered provider, or materialize the built-in
    * fallback when no provider is registered.
@@ -345,6 +399,16 @@ export class SessionTitleService extends Service {
     const messages = collectSessionTitleMessages(session.events)
     const latest = messages.at(-1)
     if (registration === undefined || registration.closing || latest === undefined) {
+      // Explicit refresh is the unpin even without a provider: a standing
+      // user title must not short-circuit ensureFallback into a no-op, so
+      // re-derive and append the fallback over it when one is derivable.
+      const current = this.get(session)
+      const [first] = messages
+      if (current?.source.kind === 'user' && first !== undefined) {
+        this.appendFallback(session, first)
+        signal?.throwIfAborted()
+        return this.get(session)
+      }
       const fallback = await this.ensureFallback(session)
       signal?.throwIfAborted()
       return fallback
@@ -398,6 +462,8 @@ export class SessionTitleService extends Service {
   private onUserMessage(session: Session, event: Extract<SessionEvent, { type: 'user/message' }>): void {
     if (!this.serviceActive()) return
     if (event.data.source.kind !== 'user' || collectSessionTitleMessages([event]).length === 0) return
+    // A user rename pins the title: no automatic revision may override it.
+    if (this.get(session)?.source.kind === 'user') return
     const registration = this.registration
     if (registration !== undefined && !registration.closing) {
       const messages = collectSessionTitleMessages(session.events, event.seq)
@@ -668,6 +734,23 @@ export class SessionTitleService extends Service {
     }
   }
 
+  /**
+   * Derive and append the deterministic fallback title over whatever stands
+   * (the refresh unpin path: overwriting a pinned user title is the point).
+   * Synchronous on purpose — no await may separate derivation from append, so
+   * it needs neither ensureFallback's in-flight dedup nor its liveness
+   * re-check. An underivable fallback (empty after the caps) appends nothing.
+   */
+  private appendFallback(session: Session, first: SessionTitleUserMessage): void {
+    const title = fallbackSessionTitle(first.text, this.config.fallbackMaxWords, this.config.fallbackMaxBytes)
+    if (title.length === 0) return
+    session.append('session/title', {
+      title,
+      messageSeqs: [first.seq],
+      source: { kind: 'fallback' },
+    })
+  }
+
   /** Create the first deterministic fallback if the session still lacks a title. */
   private async ensureFallback(session: Session): Promise<SessionTitleSnapshot | undefined> {
     this.assertServiceActive()

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

@@ -5,7 +5,8 @@
 
 /* jscpd:ignore-start */
 import type { Context } from 'cordis'
-import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
 
 const PACKAGE_NAME = '@deepseek-ai/dsh-session-title'
 
@@ -15,11 +16,26 @@ export const name = 'session-title-invariant'
 export const inject = ['invariants']
 
 /**
- * No runtime invariant: the service validates provider revisions before their
- * title append, and its remaining lifecycle state is process-local and covered
- * by package tests.
+ * Durable title-provenance invariant: an automatic title always cites at
+ * least one human `user/message` seq, and an explicit user rename cites none
+ * — `messageSeqs` is empty iff `source.kind` is `user`. Provider revisions
+ * are validated by the service before their append; this checks the durable
+ * relationship every appended `session/title` event must keep, whichever
+ * writer produced it.
  */
-const install: InvariantInstaller = () => {}
+const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
+  // internal/dispatch interception rejects the append before publication
+  // (the session/event listener would only observe the already-committed log).
+  ctx.on('internal/dispatch', (_mode, eventName, args) => {
+    if (eventName !== 'session/event') return
+    const [, event] = args as [unknown, SessionEvent]
+    if (event.type !== 'session/title') return
+    const { source, messageSeqs } = event.data
+    if ((messageSeqs.length === 0) !== (source.kind === 'user')) {
+      fail(`session/title event ${String(event.seq)} breaks provenance: source "${source.kind}" with ${String(messageSeqs.length)} cited message seq(s)`)
+    }
+  }, { global: true })
+}, { inject: ['sessions'] })
 
 /**
  * Register this package's invariant companion.

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

@@ -0,0 +1,44 @@
+// Title-provenance invariant: messageSeqs is empty iff source.kind is 'user'
+// — the durable relationship every appended session/title event must keep.
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import * as SessionTitleInvariantCompanion from '@deepseek-ai/dsh-session-title/invariant'
+import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+
+async function setup(): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(InvariantService, { enabled: true })
+  await ctx.plugin(SessionTitleInvariantCompanion)
+  return ctx
+}
+
+describe('session-title provenance invariant', () => {
+  it('accepts cited automatic titles and citation-free user renames', async () => {
+    const ctx = await setup()
+    const session = ctx.sessions.create(SessionId('title-invariant-valid'))
+    expect(() => {
+      session.append('session/title', { title: 'auto', messageSeqs: [1], source: { kind: 'fallback' } })
+      session.append('session/title', { title: 'named', messageSeqs: [], source: { kind: 'user' } })
+    }).not.toThrow()
+  })
+
+  it('rejects a citation-free automatic title and a user rename that cites messages', async () => {
+    const ctx = await setup()
+    const session = ctx.sessions.create(SessionId('title-invariant-invalid'))
+    expect(() => {
+      session.append('session/title', { title: 'auto', messageSeqs: [], source: { kind: 'fallback' } })
+    }).toThrow(expect.objectContaining<Partial<InvariantError>>({
+      code: 'INVARIANT',
+      packageName: '@deepseek-ai/dsh-session-title',
+    }))
+    expect(() => {
+      session.append('session/title', { title: 'named', messageSeqs: [1], source: { kind: 'user' } })
+    }).toThrow(expect.objectContaining<Partial<InvariantError>>({
+      code: 'INVARIANT',
+      packageName: '@deepseek-ai/dsh-session-title',
+    }))
+    expect(session.seq).toBe(0)
+  })
+})

+ 181 - 0
packages/session-title/session-title/tests/rename.spec.ts

@@ -0,0 +1,181 @@
+// SessionTitleService.rename: user-source acceptance, normalization/rejection
+// boundaries, and the pin (a user-sourced latest title schedules no automatic
+// revision; explicit refresh stays the unpin).
+import { Context } from 'cordis'
+import { describe, expect, it, vi } from 'vitest'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
+import SessionTitleService, {
+  SessionTitleProviderId,
+  foldSessionTitle,
+  type SessionTitleProviderRequest,
+} from '@deepseek-ai/dsh-session-title'
+
+const CONFIG = {
+  fallbackMaxWords: 5,
+  fallbackMaxBytes: 40,
+  maxTitleBytes: 40,
+} as const
+
+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', createUserMessage({
+    content: [{ type: 'text', text }],
+    source: { kind: 'user' },
+  }), { surfaceOp: 'append' })
+}
+
+describe('SessionTitleService.rename', () => {
+  it('appends a normalized user-source title', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionTitleService, CONFIG)
+    const session = ctx.sessions.create(SessionId('rename-accept'))
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    appendHumanPrompt(session, 'Original prompt text')
+    await settle()
+
+    const accepted = ctx.sessionTitle.rename(session, '  Hand\tpicked   name  ')
+    expect(accepted).toMatchObject({
+      title: 'Hand picked name',
+      messageSeqs: [],
+      source: { kind: 'user' },
+    })
+    const event = session.events.findLast(item => item.type === 'session/title')
+    expect(event?.data).toEqual({
+      title: 'Hand picked name',
+      messageSeqs: [],
+      source: { kind: 'user' },
+    })
+    // foldSessionTitle round-trips the third source kind.
+    expect(foldSessionTitle(session.events)?.source).toEqual({ kind: 'user' })
+  })
+
+  it('rejects titles that normalize to empty and dead sessions', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionTitleService, CONFIG)
+    const session = ctx.sessions.create(SessionId('rename-reject'))
+    expect(() => ctx.sessionTitle.rename(session, '    ')).toThrow(/visible characters/)
+
+    expect(() => ctx.sessionTitle.rename(new Session(SessionId('detached')), 'name'))
+      .toThrow(/not live in this store/)
+  })
+
+  it('pins the title: later user messages schedule no automatic revision; refresh unpins', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionTitleService, CONFIG)
+    const generate = vi.fn(async (request: SessionTitleProviderRequest) => ({
+      title: 'Provider title',
+      messageSeqs: request.messages.map(message => message.seq),
+    }))
+    ctx.sessionTitle.register({
+      id: SessionTitleProviderId('pin-provider'),
+      automatic: 'all-user-messages',
+      generate,
+    })
+    const session = ctx.sessions.create(SessionId('rename-pin'))
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    appendHumanPrompt(session, 'First prompt')
+    await settle()
+    ctx.sessionTitle.rename(session, 'Pinned by hand')
+
+    // A later eligible prompt must schedule nothing while the pin stands.
+    appendHumanPrompt(session, 'Second prompt after the pin')
+    await settle()
+    session.append('request/header', {
+      header: { config: { provider: 'main-route', model: 'chat-model' } },
+      reason: 'change',
+    })
+    await settle()
+    expect(generate).not.toHaveBeenCalled()
+    expect(ctx.sessionTitle.get(session)?.title).toBe('Pinned by hand')
+
+    // Explicit refresh remains the deliberate unpin.
+    const refreshed = await ctx.sessionTitle.refresh(session)
+    expect(generate).toHaveBeenCalledOnce()
+    expect(refreshed?.title).toBe('Provider title')
+    expect(ctx.sessionTitle.get(session)?.source.kind).toBe('provider')
+  })
+
+  it('fallback-only refresh also unpins: the user title yields to a re-derived fallback', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionTitleService, CONFIG)
+    const session = ctx.sessions.create(SessionId('rename-unpin-fallback'))
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    appendHumanPrompt(session, 'Derivable prompt words')
+    await settle()
+    ctx.sessionTitle.rename(session, 'Pinned without provider')
+    expect(ctx.sessionTitle.get(session)?.source.kind).toBe('user')
+
+    const refreshed = await ctx.sessionTitle.refresh(session)
+    expect(refreshed).toMatchObject({
+      title: 'Derivable prompt words',
+      source: { kind: 'fallback' },
+    })
+    // The pin is gone: the latest title is fallback-sourced, so the
+    // onUserMessage pin check no longer skips scheduling.
+    expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
+  })
+
+  it('supersedes in-flight automatic generation: a late provider result cannot override the user title', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionTitleService, CONFIG)
+    // The provider parks on a test-held deferred so rename lands while its
+    // generation is ACTIVE (not merely scheduled).
+    let releaseProvider: (() => void) | undefined
+    const gate = new Promise<void>((resolve) => { releaseProvider = resolve })
+    let aborted = false
+    const generate = vi.fn(async (request: SessionTitleProviderRequest) => {
+      request.signal.addEventListener('abort', () => { aborted = true })
+      await gate
+      return { title: 'Late provider title', messageSeqs: request.messages.map(message => message.seq) }
+    })
+    ctx.sessionTitle.register({
+      id: SessionTitleProviderId('deferred-provider'),
+      automatic: 'all-user-messages',
+      generate,
+    })
+    const session = ctx.sessions.create(SessionId('rename-supersede'))
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    appendHumanPrompt(session, 'Prompt that triggers generation')
+    session.append('request/header', {
+      header: { config: { provider: 'main-route', model: 'chat-model' } },
+      reason: 'change',
+    })
+    await settle()
+    expect(generate).toHaveBeenCalledOnce()
+
+    ctx.sessionTitle.rename(session, 'User wins')
+    expect(aborted).toBe(true)
+    releaseProvider?.()
+    await settle()
+    // The released provider result must not append over the user title, and
+    // the swallowed abort must not surface as an unhandled rejection.
+    const latest = session.events.findLast(item => item.type === 'session/title')
+    expect(latest?.data).toMatchObject({ title: 'User wins', source: { kind: 'user' } })
+  })
+
+  it('fallback-only refresh keeps the user title when no fallback is derivable', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    // A 3-byte fallback cap cannot hold the 4-byte emoji prompt: the
+    // re-derived fallback is empty, so the pinned title survives the refresh.
+    await ctx.plugin(SessionTitleService, { ...CONFIG, fallbackMaxBytes: 3 })
+    const session = ctx.sessions.create(SessionId('rename-unpin-empty'))
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    appendHumanPrompt(session, '😀😀')
+    await settle()
+    ctx.sessionTitle.rename(session, 'Sticky emoji pin')
+
+    const refreshed = await ctx.sessionTitle.refresh(session)
+    expect(refreshed?.title).toBe('Sticky emoji pin')
+    expect(ctx.sessionTitle.get(session)?.source.kind).toBe('user')
+  })
+})

+ 3 - 0
pnpm-lock.yaml

@@ -2949,6 +2949,9 @@ importers:
       '@deepseek-ai/dsh-session-projection-cache':
         specifier: workspace:^
         version: link:../../session-projection/session-projection-cache
+      '@deepseek-ai/dsh-session-title':
+        specifier: workspace:^
+        version: link:../../session-title/session-title
       '@deepseek-ai/dsh-skill':
         specifier: workspace:^
         version: link:../../skill/skill

+ 1 - 0
tsconfig.base.json

@@ -86,6 +86,7 @@
         "./packages/hooks/*/src/invariant.ts",
         "./packages/session-persistence/*/src/invariant.ts",
         "./packages/session-projection/*/src/invariant.ts",
+        "./packages/session-title/*/src/invariant.ts",
         "./packages/session-query/*/src/invariant.ts",
         "./packages/telemetry/*/src/invariant.ts",
         "./packages/acp/*/src/invariant.ts",

Неке датотеке нису приказане због велике количине промена