From abbcacf42a8a3fa3d2da58799688772b232aacbd Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:59:09 +0800 Subject: [PATCH 01/10] feat(session-title): user rename pins the title against automatic generation sessionTitle.rename appends a user-source session/title event; onUserMessage skips scheduling while a user title stands, and an explicit refresh is the deliberate unpin (provider regeneration, or a re-derived fallback when no provider is registered). --- ...-07-21-log-backed-session-titles.i18n.yaml | 4 +- .../2026-07-21-log-backed-session-titles.md | 8 +- ...2026-07-21-log-backed-session-titles.zh.md | 8 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 14 +- .../session-title.i18n.yaml | 4 +- docs/core-data-structures/session-title.md | 4 + docs/core-data-structures/session-title.zh.md | 4 + docs/persistence-catalog.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 6 +- .../session-title/README.i18n.yaml | 4 +- .../session-title/session-title/README.md | 3 +- .../session-title/session-title/README.zh.md | 3 +- .../session-title/session-title/src/index.ts | 81 ++++++++-- .../session-title/tests/rename.spec.ts | 142 ++++++++++++++++++ 15 files changed, 263 insertions(+), 26 deletions(-) create mode 100644 packages/session-title/session-title/tests/rename.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml index b09dcbf17d..fd80dd4e09 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md -2026-07-21-log-backed-session-titles.md: 1bd58e35ec625fb0b04c0c119ce425ff30a64881 -2026-07-21-log-backed-session-titles.zh.md: 37ec95efbca334f71d19d2bc3e18c22d50d9b5fb +2026-07-21-log-backed-session-titles.md: 8d429ad93dbe348700696737dd14a71fd3a97c05 +2026-07-21-log-backed-session-titles.zh.md: b8f59d77cc2e9a09f2638849f8015bf95b92fb4f diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md index 1bd58e35ec..8d429ad93d 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md @@ -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 reserves a revision and appends a provider or fallback event over the pinned one. The Web host exposes this as the `session.rename` unary method (resuming cold sessions first) and returns the normalized title plus its event seq so the client settles its `title` projection cell before the push frame arrives. + ### 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 `` 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 `` 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. diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md index 37ec95efbc..b8f59d77cc 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md @@ -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 使用最新标题作为其标题栏副标题,并在完成终端安全渲染后,将终端窗口标题设置为 ``。Web host 会在每个已附加会话的订阅基线之后,以及转发实时原始标题事件后立即,将同一份日志状态折叠为经过校验的 mux 控制帧。即使控制帧先于列表或会话实例创建抵达,浏览器也只保留标题事件 seq 较新的版本;侧边栏标签、搜索、面包屑和浏览器标题会随投影后的修订更新。`session.list` 仍只包含元数据,因此尚未打开的持久化会话会继续以 cwd 基名或 id 作为回退,直至打开或恢复会话时附加其日志。浏览器仅在选中已有标题的会话时将标题设置为 ``,否则保留产品标题。报告 agent 完成情况的消费方使用核心的 `findLastMessageTurnEnd()` 折叠逻辑,因此后续的轮次间标题记录无法取代此前由消息触发的结果。 @@ -59,4 +63,4 @@ Status: implemented - 回退标题会立即出现。每个新建的 Web 会话都会增加一次针对首消息的辅助调用;其他组合可以自行决定更优标题是否值得模型成本,以及后续提示词是否需要重新生成会话标题。 - 辅助请求记录和延迟接受的标题会占用事件 seq,但不会占用轮次编号,因此持久化会同时呈现尝试发起的调用与已接受的更新,尽管模型历史和 KV 缓存标识保持不变。 - 单个提供方和每会话单调递增的修订号让释放、取代和陈旧结果拒绝行为明确可见,但多策略优先级必须由复合提供方负责。 -- 手动重命名、删除、生成标题与用户标题的优先级、搜索和列表索引不在此功能范围内。 +- 删除(不经显式 refresh 的解钉)、搜索和列表索引不在此功能范围内。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3c0543e4ca..83900c7d85 100644 --- a/docs/config-catalog.md +++ b/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` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index db31daaf06..3ef83f0018 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1604,6 +1604,18 @@ 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 {Error} when the session is not live or the title normalizes to empty. + */ +rename(session: Session, title: string): SessionTitleSnapshot + /** * Explicitly retry the registered provider, or materialize the built-in * fallback when no provider is registered. @@ -1624,7 +1636,7 @@ register(provider: SessionTitleProvider): () => Promise 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:244`](../../packages/session-title/session-title/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/session-title.i18n.yaml b/docs/core-data-structures/session-title.i18n.yaml index 64b3d8f342..e463ddd6bd 100644 --- a/docs/core-data-structures/session-title.i18n.yaml +++ b/docs/core-data-structures/session-title.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session-title.md -session-title.md: 33efc911c0ca1ae94dc4ded74676e5c32a73bdd5 -session-title.zh.md: a4b95a726d2bc89a13d14f1daa2f825cd5aa91b1 +session-title.md: 0857f5255be616d00ea1f49fdfd97cffda1fd4b2 +session-title.zh.md: 75fa42bfbdec18afc20ca59e2c02631e01cfd994 diff --git a/docs/core-data-structures/session-title.md b/docs/core-data-structures/session-title.md index 33efc911c0..0857f5255b 100644 --- a/docs/core-data-structures/session-title.md +++ b/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 diff --git a/docs/core-data-structures/session-title.zh.md b/docs/core-data-structures/session-title.zh.md index a4b95a726d..75fa42bfbd 100644 --- a/docs/core-data-structures/session-title.zh.md +++ b/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 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index a1535f36dc..a9bb298c7c 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -412,7 +412,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 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7d9b087b8f..7d78adecca 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/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 {Error} when the session is not live or the title normalizes to empty.\n */', + }, { signature: 'async refresh(session: Session, signal?: AbortSignal): Promise', 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', diff --git a/packages/session-title/session-title/README.i18n.yaml b/packages/session-title/session-title/README.i18n.yaml index 1e430b8974..5bb27cf6a0 100644 --- a/packages/session-title/session-title/README.i18n.yaml +++ b/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: d373c212a193a82567686a634bad79726185e832 +README.md: 9a5ec27c36f3411add37ebe231262eb5d205bc9e +README.zh.md: f8bdf28ca3eaa7aca329d0f4a71f637daa316502 diff --git a/packages/session-title/session-title/README.md b/packages/session-title/session-title/README.md index 1939d00f7e..9a5ec27c36 100644 --- a/packages/session-title/session-title/README.md +++ b/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. diff --git a/packages/session-title/session-title/README.zh.md b/packages/session-title/session-title/README.zh.md index d373c212a1..f8bdf28ca3 100644 --- a/packages/session-title/session-title/README.zh.md +++ b/packages/session-title/session-title/README.zh.md @@ -10,6 +10,7 @@ - `get(session)` 从活跃或回放日志折叠最新已接受标题。 - `refresh(session, signal?)` 在需要时物化回退,然后显式运行已注册提供方,处理当前符合条件的消息。提供方错误与调用方取消会 reject;取消不会回滚已接受的回退事件。 +- `rename(session, title)` 同步接受用户显式标题:规范化文本、取代在途自动工作,并追加一条 `user` 来源的 `session/title` 事件。最新标题来源为 user 即钉住该会话——后续用户消息不再安排自动 revision;显式 `refresh` 仍是有意的解钉手段。 - `register(provider)` 安装唯一可选提供方,并返回可等待的 Cordis effect disposer。第二次注册会立即抛出;资源释放会中止待处理和活跃调用,等待其结算,之后才允许注册另一个提供方。 自动工作绝不会延迟主 agent(智能体)响应。只有当带标记、由循环构建的请求,其确切路由与当前已记录的 `request/header` 匹配时,提供方才会启动;即使 header 未变而无需新快照,也适用此规则。延迟完成会直接通过 `Session` 追加一个独立的纯日志事件,而不打开轮次。持久化会尽快观察该事件,并在常规生命周期检查点排空;标题发布本身不会强制 flush。自动失败会发出警告并保留最新标题。新的全消息 revision、提供方资源释放、会话资源释放和显式刷新都会中止旧工作,陈旧完成值无法追加。并发显式刷新会在提供方工作之前预留修订号;重叠的自动/显式回退请求共享一个会话本地进行中追加。服务与随附模型提供方各自追加自己的字面量事件类型,因此不需要通用标题写入标记、类型断言或结算队列。服务 teardown 会取消排队工作,并在卸载完成前排空忽略取消的调用。 @@ -50,5 +51,5 @@ Fork 会原样继承 seed 中的标题事件。首消息节奏不会自动为子 ## 已知限制与暂缓工作 -- 手动重命名、删除标题、生成标题与用户标题的优先级、搜索和列表索引都不属于此服务。 +- 删除标题(不经显式 `refresh` 就解钉回自动标题)、搜索和列表索引不属于此服务。 - 提供方注册表有意最多接受一个实现,因此部署若要组合相互竞争的标题策略,必须编写一个自行负责优先级的提供方。 diff --git a/packages/session-title/session-title/src/index.ts b/packages/session-title/session-title/src/index.ts index 995b216086..9c4e46a974 100644 --- a/packages/session-title/session-title/src/index.ts +++ b/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,6 +52,10 @@ 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 { @@ -180,20 +184,26 @@ 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' } + default: return assertNever(source, 'SessionTitleSource') + } +} + /** Service-owned resolved limits. */ interface ResolvedConfig { readonly fallbackMaxWords: number @@ -328,6 +338,38 @@ 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 {Error} when the session is not live or the title normalizes to empty. + */ + 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 Error('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 +387,23 @@ 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) { + const title = fallbackSessionTitle(first.text, this.config.fallbackMaxWords, this.config.fallbackMaxBytes) + if (title.length > 0) { + session.append('session/title', { + title, + messageSeqs: [first.seq], + source: { kind: 'fallback' }, + }) + } + signal?.throwIfAborted() + return this.get(session) + } const fallback = await this.ensureFallback(session) signal?.throwIfAborted() return fallback @@ -398,6 +457,8 @@ export class SessionTitleService extends Service { private onUserMessage(session: Session, event: Extract): 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) diff --git a/packages/session-title/session-title/tests/rename.spec.ts b/packages/session-title/session-title/tests/rename.spec.ts new file mode 100644 index 0000000000..bfb75394d0 --- /dev/null +++ b/packages/session-title/session-title/tests/rename.spec.ts @@ -0,0 +1,142 @@ +// 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 { + await new Promise(resolve => setTimeout(resolve, 0)) +} + +function appendHumanPrompt(session: ReturnType, 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 and supersedes automatic work', 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 next user message schedules automatic work again + // (observable as a fresh fallback-source title remaining latest). + expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback') + }) + + 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') + }) +}) From 19336686a6919c8a9a1be5d836e11d4fab9b15b0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:59:23 +0800 Subject: [PATCH 02/10] feat(apiproxy): session.rename RPC delegating to the session-title service New unary method in RpcMethodMap with the title-invalid error code; the impl resolves the agent (cold sessions resume first) and delegates to ctx.sessionTitle.rename, returning the normalized title plus its event seq so clients settle the title projection cell ahead of the push frame. session.fork stays on the reserved-seam list. --- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 6 +- ...026-07-19-gui-layering-and-rpc-protocol.md | 4 +- ...-07-19-gui-layering-and-rpc-protocol.zh.md | 4 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/package.json | 1 + packages/host/apiproxy/src/api-proxy.ts | 22 ++++ packages/host/apiproxy/src/api/rpc-map.ts | 1 + packages/host/apiproxy/src/api/rpc.schema.ts | 1 + packages/host/apiproxy/src/api/rpc.ts | 1 + .../host/apiproxy/src/api/sessions.schema.ts | 12 ++ packages/host/apiproxy/src/api/sessions.ts | 10 ++ packages/host/apiproxy/src/fetch/client.ts | 4 + packages/host/apiproxy/src/fetch/handler.ts | 2 + .../apiproxy/tests/api-proxy-rename.spec.ts | 110 ++++++++++++++++++ .../apiproxy/tests/client-handler.spec.ts | 1 + .../host/apiproxy/tests/fetch-carrier.spec.ts | 5 + packages/host/apiproxy/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 20 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 packages/host/apiproxy/tests/api-proxy-rename.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index 055804b569..70618612a2 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 63db4786ad..b9718da472 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.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`) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index b3037ceb8c..2add148054 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -163,7 +163,7 @@ export type ResponseValue = |---|---|---|---| | `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 = - **冷 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`) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 73b0845370..6f22ce2466 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/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 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index ca4471454f..b5f80dcb3a 100644 --- a/packages/host/apiproxy/README.md +++ b/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`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 953539e119..1c3c7486f7 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/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`。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 8cbdb91cf4..8b4a895774 100644 --- a/packages/host/apiproxy/package.json +++ b/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:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f178bfefd0..8ed681e239 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -36,6 +36,8 @@ import type {} from '@deepseek-ai/dsh-session-projection-cache' import { GoalError } from '@deepseek-ai/dsh-goal' 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')`. +// Type-only edge: resolves `ctx.get('sessionTitle')` for the rename impl. +import type {} from '@deepseek-ai/dsh-session-title' import type {} from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-skill' import type { CallId } from '@deepseek-ai/dsh-llm/brand' @@ -1049,6 +1051,26 @@ 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: 'session-title service is absent: this deployment does not mount @deepseek-ai/dsh-session-title in its composition (cordis.yml or explicit assembly)', details: {} }) + } + try { + const accepted = titles.rename(found.agent.session, title) + return ok(request, { title: accepted.title, seq: accepted.eventSeq }) + } catch (error: unknown) { + return err(request, { + code: 'title-invalid', + message: `rename rejected for session "${sessionId}": ${String(error)}`, + details: { sessionId }, + }) + } + }, + async prompt(request) { const { sessionId, mode, content } = request.payload const found = await agentFor(sessionId) diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 5394691248..8b26b1fc2a 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/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'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 58d3a17126..db5435a757 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -49,6 +49,7 @@ export const rpcErrorSchema: z.ZodType = 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 diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index fd99d015ee..8daf898693 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/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': {} } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 81c42b8a56..29dd88c89b 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -73,6 +73,18 @@ export const sessionCreateValueSchema = z.object({ sessionId: sessionIdSchema, }) satisfies z.ZodType>> +/** session.rename request payload (raw title; host-side normalization decides acceptance). */ +export const sessionRenameRequestSchema = z.object({ + sessionId: sessionIdSchema, + title: z.string(), +}) satisfies z.ZodType>> + +/** 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>> + /** session.history request payload (beforeSeq/maxMessages page backwards from the window tail). */ export const sessionHistoryRequestSchema = z.object({ sessionId: sessionIdSchema, diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index f952c7c542..3a824c8261 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -208,6 +208,16 @@ export interface SessionsApi { }>): Promise> + /** + * 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> + /** * 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 diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 8bcc6f7c3e..5940775499 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -20,6 +20,7 @@ import { import { sessionCancelValueSchema, sessionCreateValueSchema, + sessionRenameValueSchema, sessionHistoryValueSchema, sessionListValueSchema, sessionModelsValueSchema, @@ -66,6 +67,7 @@ export interface IApiClient { history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise>> models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise>> selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise>> + rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise>> prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise>> cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise>> } @@ -116,6 +118,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType 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), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index e1340aad5b..c14e680119 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -17,6 +17,7 @@ import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts' import { sessionCancelRequestSchema, sessionCreateRequestSchema, + sessionRenameRequestSchema, sessionHistoryRequestSchema, sessionListRequestSchema, sessionModelsRequestSchema, @@ -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) }, diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts new file mode 100644 index 0000000000..5bdac4fb11 --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts @@ -0,0 +1,110 @@ +/** + * 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). + */ + +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

(payload: P): RpcRequest

{ + return { rpcId: RpcId(`fr-${String(nextRpc++)}`), payload } +} + +async function composed(withTitles = true): Promise { + 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 => { + 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 an empty-normalizing title to title-invalid', async () => { + const ctx = await composed() + const source = liveAgent(ctx, 'session-rename-bad', 1) + + 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 }, + }) + } + }) + + 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(/session-title service is absent/) + } + }) +}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index d807557d00..4f56f17dfd 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/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, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index d7528072f7..1e975d6008 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/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) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 7e1e83e39b..908e2cb546 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -38,6 +38,9 @@ { "path": "../../session-projection/session-projection" }, + { + "path": "../../session-title/session-title" + }, { "path": "../../session-projection/session-projection-cache" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66ea5bea26..8c728eac97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2890,6 +2890,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 From ff049f1e82f04ff99e15f43132dee523f9dfe1a0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:59:34 +0800 Subject: [PATCH 03/10] feat(client): ISession.rename settles the title projection cell from the response rename is a per-session verb on the outward session face (prompt/cancel precedent), not a list-service verb: the Session calls session.rename and applies the response {title, seq} to its projection store under higher-seq-wins, so the list row updates before the push frame. The fixture api and the test-runtime double follow the same face. --- .../client/connection/src/client/fixture.ts | 28 +++++++++++++++++++ packages/client/connection/tests/fake-api.ts | 2 ++ .../runtime/src/client/contract/session.ts | 7 +++++ .../runtime/src/client/sessions/session.ts | 19 +++++++++++++ packages/client/runtime/tests/fake-api.ts | 2 ++ packages/client/runtime/tests/session.spec.ts | 26 +++++++++++++++++ packages/client/test-runtime/src/sessions.ts | 8 ++++++ .../test-runtime/tests/runtime.spec.tsx | 1 + 8 files changed, 93 insertions(+) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index cab616aabd..a586e78477 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -943,6 +943,33 @@ 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 { sessionId, title } = request.payload + const source = summaryOf(sessionId) + if (source === undefined) { + return err(request, { + code: 'session-not-found', + message: `no session ${sessionId}`, + details: { sessionId }, + }) + } + const normalized = title.trim().replace(/\s+/g, ' ') + if (normalized.length === 0) { + return err(request, { + code: 'title-invalid', + message: `rename rejected for session ${sessionId}: empty title`, + 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 log = logOf(sessionId) + return ok(request, { title: normalized, seq: log.length - 1 }) + }, 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). @@ -1486,6 +1513,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) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 6a876c8ed4..650f982b11 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/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> = () => Promise.resolve(ok({ items: [] })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) + onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => Promise> = () => 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)), } diff --git a/packages/client/runtime/src/client/contract/session.ts b/packages/client/runtime/src/client/contract/session.ts index 31335c388c..2ba8725b02 100644 --- a/packages/client/runtime/src/client/contract/session.ts +++ b/packages/client/runtime/src/client/contract/session.ts @@ -41,6 +41,13 @@ export interface ISession { * @returns acceptance, or the business error. */ cancel(): Promise> + /** + * 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> /** * Extend the history window backwards (older messages pagination). * @returns completion; failures land in snapshot.openState/loadingOlder. diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 0f5d39ac8e..f18ced4dfe 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/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> { + 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; diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 0a1de3f7e1..04ca13ee11 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -63,6 +63,7 @@ export class FakeApiClient implements IApiClient { onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' } + onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => Promise> = () => 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)), } diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index c7be330d55..c1c6b572f7 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/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() diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 5ec4652aef..59dd7d9186 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -107,6 +107,14 @@ export class FixtureSession implements SessionFace { loadOlder(): never { 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. */ diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 22d3bb5cfc..00faae9905 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/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() }) From d257c3878eeb690bd92554724efb59d05f1b0f0a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:59:46 +0800 Subject: [PATCH 04/10] feat(web): session-row Rename menu action with a browser-owned dialog The row menu's Rename item opens the same dialog pattern as workspace rename (no client-side conflict rule; the host normalizes) and resolves through the session face via ctx.sessions.binding. Assembled-app snapshot covers the row-menu -> dialog -> unary-settle flow. --- apps/web/tests/session-actions.snapshot.ts | 130 ++++++++++++++++++ .../session-actions/rename-rows.json | 14 ++ .../src/client/WorkspaceBrowser.tsx | 75 +++++++++- .../ui-workspace/src/client/contract/slots.ts | 2 + .../client/ui-workspace/src/client/index.ts | 8 ++ .../ui-workspace/src/client/rows/Rows.tsx | 14 +- .../client/ui-workspace/tests/rows.spec.tsx | 40 ++++-- .../tests/workspace-browser.spec.tsx | 1 + 8 files changed, 266 insertions(+), 18 deletions(-) create mode 100644 apps/web/tests/session-actions.snapshot.ts create mode 100644 apps/web/tests/snapshots/session-actions/rename-rows.json diff --git a/apps/web/tests/session-actions.snapshot.ts b/apps/web/tests/session-actions.snapshot.ts new file mode 100644 index 0000000000..684afe5532 --- /dev/null +++ b/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).__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 { + const root = document.querySelector('#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('[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(`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') +}) diff --git a/apps/web/tests/snapshots/session-actions/rename-rows.json b/apps/web/tests/snapshots/session-actions/rename-rows.json new file mode 100644 index 0000000000..db30b0d121 --- /dev/null +++ b/apps/web/tests/snapshots/session-actions/rename-rows.json @@ -0,0 +1,14 @@ +[ + { + "label": "fixture4 sessions" + }, + { + "label": "New Sessionnow" + }, + { + "label": "分叉 实验记录now" + }, + { + "label": "fixture2min" + } +] diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 7effff36b5..0c607f1b70 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/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) { +function FlatList({ useSessions, open, onSessionRename, query }: Pick) { 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 {}} flat @@ -249,6 +253,7 @@ export function WorkspaceBrowser({ actions, startSession, open, + renameSession, renameWorkspace, deleteWorkspace, insertSessionBefore, @@ -309,6 +314,38 @@ export function WorkspaceBrowser({ }) } + // Session rename dialog (same browser-owned pattern as workspace rename; + // sessions have no client-side name-conflict rule — the host normalizes). + const [sessionRenameTarget, setSessionRenameTarget] = useState<{ sessionId: SessionNode['id']; currentTitle: string } | null>(null) + const [sessionRenameDraft, setSessionRenameDraft] = useState('') + const [sessionRenaming, setSessionRenaming] = useState(false) + const [sessionRenameError, setSessionRenameError] = useState(null) + const sessionRenameTrimmed = sessionRenameDraft.trim() + const sessionRenameBlocked = sessionRenaming || sessionRenameTrimmed === '' + || sessionRenameTarget === null || sessionRenameTrimmed === sessionRenameTarget.currentTitle + 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 +461,11 @@ export function WorkspaceBrowser({ itself is wide-only. */}

{wide && (groupBy === 'flat' - ? + ? : ( {renameError}
} + + + + + + )} + > + { 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 &&
{sessionRenameError}
} +
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 /** Rename a Host Workspace (rejects on name conflict; resolves on durability). */ renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise /** Delete only a Host Workspace registration; directory and Session logs remain. */ diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 1cf5a7ae5a..c1f5e61ba4 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/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) => { diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 1ee2a84adc..d75fabdd8b 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/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} /> ))} diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 15121e929a..bfaa8a36dd 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/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( - , + , ) const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')! @@ -88,7 +89,8 @@ describe('workspace browser rows', () => { view.rerender( , ) 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() + render() 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() + render() 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() + render() 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() + render() 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( - , + , ) const row = screen.getByRole('treeitem') stubRect(row) @@ -230,7 +246,8 @@ describe('workspace browser rows', () => { const active = dragProps({ active: true, marker: 'before' }) rerender( - , + , ) 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( - , + , ) expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/) }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 12a5efc264..abe896dffe 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -55,6 +55,7 @@ function mount(overrides: Partial = {}) { 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 () => {}), From 833d999abfe1e1e7c47672353c7a7c4981ed3884 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:09:22 +0800 Subject: [PATCH 05/10] test(session-title): mark the assertNever guard arm v8-ignored The default arm of copySessionTitleSource is a closed-union exhaustiveness guard (compile-time protection for the next kind); it is unreachable at runtime, so the per-file coverage gate ignores it like every other assertNever arm. --- packages/session-title/session-title/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/session-title/session-title/src/index.ts b/packages/session-title/session-title/src/index.ts index 9c4e46a974..ac01591533 100644 --- a/packages/session-title/session-title/src/index.ts +++ b/packages/session-title/session-title/src/index.ts @@ -200,6 +200,7 @@ function copySessionTitleSource(source: SessionTitleSource): SessionTitleSource ...(source.model === undefined ? {} : { model: { ...source.model } }), } case 'user': return { kind: 'user' } + /* v8 ignore next -- closed-union exhaustiveness guard */ default: return assertNever(source, 'SessionTitleSource') } } From a6eba044b2dc965b5fc521c7e6c892e80992b153 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:09:31 +0800 Subject: [PATCH 06/10] docs: regenerate cordis catalog for the shifted session-title source line gen-cordis-catalog embeds source line anchors; the switch extraction and the ignore annotation moved SessionTitleService, so the services page was stale against the committed code. --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 3ef83f0018..f19a94054b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1636,7 +1636,7 @@ register(provider: SessionTitleProvider): () => Promise 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:244`](../../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:251`](../../packages/session-title/session-title/src/index.ts) ## `ctx.skills` — `SkillService` From c8374e916f9ce710979ecdecb317aeee4abd7a94 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:10:42 +0800 Subject: [PATCH 07/10] fix(session-title): typed rename rejection, provenance invariant, contract docs SessionTitleInvalidError narrows the one rename failure that blames the input; the fallback-unpin append extracts to appendFallback beside ensureFallback's guarded twin; a deferred-provider test proves rename supersedes ACTIVE generation; the invariant companion enforces messageSeqs-empty iff user-source on every appended session/title event (tsconfig gains the session-title invariant path); SessionTitleEventData field docs state the third source kind and the empty-seqs rule, mirrored into the bilingual core-data-structures page; the note qualifies the refresh unpin as conditional on a derivable replacement. --- ...-07-21-log-backed-session-titles.i18n.yaml | 4 +- .../2026-07-21-log-backed-session-titles.md | 2 +- ...2026-07-21-log-backed-session-titles.zh.md | 2 +- .../session-title.i18n.yaml | 4 +- docs/core-data-structures/session-title.md | 4 +- docs/core-data-structures/session-title.zh.md | 4 +- .../session-title/session-title/src/index.ts | 45 ++++++++++++++----- .../session-title/src/invariant.ts | 26 ++++++++--- .../session-title/tests/invariant.spec.ts | 44 ++++++++++++++++++ .../session-title/tests/rename.spec.ts | 45 +++++++++++++++++-- tsconfig.base.json | 1 + 11 files changed, 151 insertions(+), 30 deletions(-) create mode 100644 packages/session-title/session-title/tests/invariant.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml index fd80dd4e09..029cc4e19c 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md -2026-07-21-log-backed-session-titles.md: 8d429ad93dbe348700696737dd14a71fd3a97c05 -2026-07-21-log-backed-session-titles.zh.md: b8f59d77cc2e9a09f2638849f8015bf95b92fb4f +2026-07-21-log-backed-session-titles.md: 81ac687c6f55dd0ca1eaeb9d84c811edcfe17b5c +2026-07-21-log-backed-session-titles.zh.md: b0c7e9d76a1b9365fa16dcb223b390b5aec3e174 diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md index 8d429ad93d..81ac687c6f 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md @@ -38,7 +38,7 @@ Automatic provider failures are nonfatal warnings and retain the latest title. E ### Explicit rename -`rename(session, title)` accepts a user title synchronously: it normalizes the text under the accepted-title byte limit, supersedes in-flight automatic work, and appends a `session/title` event with the third source kind, `user`. A user-sourced latest title pins the session: `onUserMessage` schedules no automatic revision while it stands, under either cadence. An explicit `refresh()` remains the deliberate unpin — it reserves a revision and appends a provider or fallback event over the pinned one. The Web host exposes this as the `session.rename` unary method (resuming cold sessions first) and returns the normalized title plus its event seq so the client settles its `title` projection cell before the push frame arrives. +`rename(session, title)` accepts a user title synchronously: it normalizes the text under the accepted-title byte limit, supersedes in-flight automatic work, and appends a `session/title` event with the third source kind, `user`. A user-sourced latest title pins the session: `onUserMessage` schedules no automatic revision while it stands, under either cadence. An explicit `refresh()` remains the deliberate unpin — it appends a provider or fallback event over the pinned one whenever a replacement title is derivable (an underivable fallback, e.g. under a tiny byte cap, leaves the pin standing). The Web host exposes this as the `session.rename` unary method (resuming cold sessions first) and returns the normalized title plus its event seq so the client settles its `title` projection cell before the push frame arrives. ### Forks and consumers diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md index b8f59d77cc..b0c7e9d76a 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md @@ -38,7 +38,7 @@ Status: implemented ### 显式重命名 -`rename(session, title)` 同步接受用户标题:按已接受标题的字节上限规范化文本、取代在途自动工作,并追加一条第三种来源 `user` 的 `session/title` 事件。最新标题来源为 user 即钉住该会话:只要它还在,`onUserMessage` 在任一节奏下都不再安排自动修订。显式 `refresh()` 仍是有意的解钉手段——它预留一个修订号,并在被钉住的标题之上追加提供方或回退事件。Web host 将其暴露为 `session.rename` unary 方法(冷会话先恢复),并返回规范化后的标题及其事件 seq,使 client 在推送帧到达前就结算自己的 `title` 投影格。 +`rename(session, title)` 同步接受用户标题:按已接受标题的字节上限规范化文本、取代在途自动工作,并追加一条第三种来源 `user` 的 `session/title` 事件。最新标题来源为 user 即钉住该会话:只要它还在,`onUserMessage` 在任一节奏下都不再安排自动修订。显式 `refresh()` 仍是有意的解钉手段——只要能推导出替代标题,它就在被钉住的标题之上追加提供方或回退事件(推导不出回退标题时,例如字节上限过小,钉住状态保持不变)。Web host 将其暴露为 `session.rename` unary 方法(冷会话先恢复),并返回规范化后的标题及其事件 seq,使 client 在推送帧到达前就结算自己的 `title` 投影格。 ### Fork 与消费方 diff --git a/docs/core-data-structures/session-title.i18n.yaml b/docs/core-data-structures/session-title.i18n.yaml index e463ddd6bd..ab368c4c37 100644 --- a/docs/core-data-structures/session-title.i18n.yaml +++ b/docs/core-data-structures/session-title.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session-title.md -session-title.md: 0857f5255be616d00ea1f49fdfd97cffda1fd4b2 -session-title.zh.md: 75fa42bfbdec18afc20ca59e2c02631e01cfd994 +session-title.md: fff1aa1f6be45d0cfc4d7f6a9527ccb93561618f +session-title.zh.md: 73821b07c6be40d10d0961dd79b7c06bcadb7d0b diff --git a/docs/core-data-structures/session-title.md b/docs/core-data-structures/session-title.md index 0857f5255b..fff1aa1f6b 100644 --- a/docs/core-data-structures/session-title.md +++ b/docs/core-data-structures/session-title.md @@ -45,9 +45,9 @@ type SessionTitleSource = interface SessionTitleEventData { /** Normalized non-empty title text. */ readonly title: string - /** Exact human `user/message` seqs used to derive this title. */ + /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */ readonly messageSeqs: number[] - /** Built-in fallback or registered-provider provenance. */ + /** Built-in fallback, registered-provider, or explicit-user provenance. */ readonly source: SessionTitleSource } ``` diff --git a/docs/core-data-structures/session-title.zh.md b/docs/core-data-structures/session-title.zh.md index 75fa42bfbd..73821b07c6 100644 --- a/docs/core-data-structures/session-title.zh.md +++ b/docs/core-data-structures/session-title.zh.md @@ -45,9 +45,9 @@ type SessionTitleSource = interface SessionTitleEventData { /** Normalized non-empty title text. */ readonly title: string - /** Exact human `user/message` seqs used to derive this title. */ + /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */ readonly messageSeqs: number[] - /** Built-in fallback or registered-provider provenance. */ + /** Built-in fallback, registered-provider, or explicit-user provenance. */ readonly source: SessionTitleSource } ``` diff --git a/packages/session-title/session-title/src/index.ts b/packages/session-title/session-title/src/index.ts index ac01591533..12da431c68 100644 --- a/packages/session-title/session-title/src/index.ts +++ b/packages/session-title/session-title/src/index.ts @@ -61,9 +61,9 @@ export type SessionTitleSource = export interface SessionTitleEventData { /** Normalized non-empty title text. */ readonly title: string - /** Exact human `user/message` seqs used to derive this title. */ + /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */ readonly messageSeqs: number[] - /** Built-in fallback or registered-provider provenance. */ + /** Built-in fallback, registered-provider, or explicit-user provenance. */ readonly source: SessionTitleSource } @@ -101,6 +101,16 @@ declare module '@deepseek-ai/dsh-session' { } } +/** + * Rejection of an explicit user title whose text normalizes to empty — the + * one {@link SessionTitleService.rename} failure that blames the input. + * Callers translating rename failures onto a wire (`title-invalid`) narrow on + * this class; liveness and disposal failures stay plain `Error`s. + */ +export class SessionTitleInvalidError extends Error { + override readonly name = 'SessionTitleInvalidError' +} + /** One eligible human text message exposed to title providers. */ export interface SessionTitleUserMessage { /** Source `user/message` event seq. */ @@ -347,7 +357,8 @@ export class SessionTitleService extends Service { * @param session - exact live session to rename. * @param title - raw user input; normalized before acceptance. * @returns the accepted title snapshot. - * @throws {Error} when the session is not live or the title normalizes to empty. + * @throws {SessionTitleInvalidError} when the title normalizes to empty. + * @throws {Error} when the session is not live or the service is disposed. */ rename(session: Session, title: string): SessionTitleSnapshot { this.assertServiceActive() @@ -356,7 +367,7 @@ export class SessionTitleService extends Service { } const normalized = normalizeSessionTitle(title, this.config.maxTitleBytes) if (normalized.length === 0) { - throw new Error('session title must contain visible characters') + throw new SessionTitleInvalidError('session title must contain visible characters') } const state = this.stateFor(session) this.supersede(state, 'user rename superseded automatic title generation') @@ -394,14 +405,7 @@ export class SessionTitleService extends Service { const current = this.get(session) const [first] = messages if (current?.source.kind === 'user' && first !== undefined) { - const title = fallbackSessionTitle(first.text, this.config.fallbackMaxWords, this.config.fallbackMaxBytes) - if (title.length > 0) { - session.append('session/title', { - title, - messageSeqs: [first.seq], - source: { kind: 'fallback' }, - }) - } + this.appendFallback(session, first) signal?.throwIfAborted() return this.get(session) } @@ -730,6 +734,23 @@ export class SessionTitleService extends Service { } } + /** + * Derive and append the deterministic fallback title over whatever stands + * (the refresh unpin path: overwriting a pinned user title is the point). + * Synchronous on purpose — no await may separate derivation from append, so + * it needs neither ensureFallback's in-flight dedup nor its liveness + * re-check. An underivable fallback (empty after the caps) appends nothing. + */ + private appendFallback(session: Session, first: SessionTitleUserMessage): void { + const title = fallbackSessionTitle(first.text, this.config.fallbackMaxWords, this.config.fallbackMaxBytes) + if (title.length === 0) return + session.append('session/title', { + title, + messageSeqs: [first.seq], + source: { kind: 'fallback' }, + }) + } + /** Create the first deterministic fallback if the session still lacks a title. */ private async ensureFallback(session: Session): Promise { this.assertServiceActive() diff --git a/packages/session-title/session-title/src/invariant.ts b/packages/session-title/session-title/src/invariant.ts index 9bae01a72f..25b00a10aa 100644 --- a/packages/session-title/session-title/src/invariant.ts +++ b/packages/session-title/session-title/src/invariant.ts @@ -5,7 +5,8 @@ /* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { SessionEvent } from '@deepseek-ai/dsh-session' const PACKAGE_NAME = '@deepseek-ai/dsh-session-title' @@ -15,11 +16,26 @@ export const name = 'session-title-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the service validates provider revisions before their - * title append, and its remaining lifecycle state is process-local and covered - * by package tests. + * Durable title-provenance invariant: an automatic title always cites at + * least one human `user/message` seq, and an explicit user rename cites none + * — `messageSeqs` is empty iff `source.kind` is `user`. Provider revisions + * are validated by the service before their append; this checks the durable + * relationship every appended `session/title` event must keep, whichever + * writer produced it. */ -const install: InvariantInstaller = () => {} +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + // internal/dispatch interception rejects the append before publication + // (the session/event listener would only observe the already-committed log). + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [, event] = args as [unknown, SessionEvent] + if (event.type !== 'session/title') return + const { source, messageSeqs } = event.data + if ((messageSeqs.length === 0) !== (source.kind === 'user')) { + fail(`session/title event ${String(event.seq)} breaks provenance: source "${source.kind}" with ${String(messageSeqs.length)} cited message seq(s)`) + } + }, { global: true }) +}, { inject: ['sessions'] }) /** * Register this package's invariant companion. diff --git a/packages/session-title/session-title/tests/invariant.spec.ts b/packages/session-title/session-title/tests/invariant.spec.ts new file mode 100644 index 0000000000..5b316448da --- /dev/null +++ b/packages/session-title/session-title/tests/invariant.spec.ts @@ -0,0 +1,44 @@ +// Title-provenance invariant: messageSeqs is empty iff source.kind is 'user' +// — the durable relationship every appended session/title event must keep. +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as SessionTitleInvariantCompanion from '@deepseek-ai/dsh-session-title/invariant' +import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(SessionTitleInvariantCompanion) + return ctx +} + +describe('session-title provenance invariant', () => { + it('accepts cited automatic titles and citation-free user renames', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('title-invariant-valid')) + expect(() => { + session.append('session/title', { title: 'auto', messageSeqs: [1], source: { kind: 'fallback' } }) + session.append('session/title', { title: 'named', messageSeqs: [], source: { kind: 'user' } }) + }).not.toThrow() + }) + + it('rejects a citation-free automatic title and a user rename that cites messages', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('title-invariant-invalid')) + expect(() => { + session.append('session/title', { title: 'auto', messageSeqs: [], source: { kind: 'fallback' } }) + }).toThrow(expect.objectContaining>({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-session-title', + })) + expect(() => { + session.append('session/title', { title: 'named', messageSeqs: [1], source: { kind: 'user' } }) + }).toThrow(expect.objectContaining>({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-session-title', + })) + expect(session.seq).toBe(0) + }) +}) diff --git a/packages/session-title/session-title/tests/rename.spec.ts b/packages/session-title/session-title/tests/rename.spec.ts index bfb75394d0..01d613ee3e 100644 --- a/packages/session-title/session-title/tests/rename.spec.ts +++ b/packages/session-title/session-title/tests/rename.spec.ts @@ -29,7 +29,7 @@ function appendHumanPrompt(session: ReturnType, t } describe('SessionTitleService.rename', () => { - it('appends a normalized user-source title and supersedes automatic work', async () => { + it('appends a normalized user-source title', async () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionTitleService, CONFIG) @@ -118,11 +118,50 @@ describe('SessionTitleService.rename', () => { title: 'Derivable prompt words', source: { kind: 'fallback' }, }) - // The pin is gone: the next user message schedules automatic work again - // (observable as a fresh fallback-source title remaining latest). + // The pin is gone: the latest title is fallback-sourced, so the + // onUserMessage pin check no longer skips scheduling. expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback') }) + it('supersedes in-flight automatic generation: a late provider result cannot override the user title', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionTitleService, CONFIG) + // The provider parks on a test-held deferred so rename lands while its + // generation is ACTIVE (not merely scheduled). + let releaseProvider: (() => void) | undefined + const gate = new Promise((resolve) => { releaseProvider = resolve }) + let aborted = false + const generate = vi.fn(async (request: SessionTitleProviderRequest) => { + request.signal.addEventListener('abort', () => { aborted = true }) + await gate + return { title: 'Late provider title', messageSeqs: request.messages.map(message => message.seq) } + }) + ctx.sessionTitle.register({ + id: SessionTitleProviderId('deferred-provider'), + automatic: 'all-user-messages', + generate, + }) + const session = ctx.sessions.create(SessionId('rename-supersede')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + appendHumanPrompt(session, 'Prompt that triggers generation') + session.append('request/header', { + header: { config: { provider: 'main-route', model: 'chat-model' } }, + reason: 'change', + }) + await settle() + expect(generate).toHaveBeenCalledOnce() + + ctx.sessionTitle.rename(session, 'User wins') + expect(aborted).toBe(true) + releaseProvider?.() + await settle() + // The released provider result must not append over the user title, and + // the swallowed abort must not surface as an unhandled rejection. + const latest = session.events.findLast(item => item.type === 'session/title') + expect(latest?.data).toMatchObject({ title: 'User wins', source: { kind: 'user' } }) + }) + it('fallback-only refresh keeps the user title when no fallback is derivable', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/tsconfig.base.json b/tsconfig.base.json index 00c19c4b8c..934d37e26e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -86,6 +86,7 @@ "./packages/hooks/*/src/invariant.ts", "./packages/session-persistence/*/src/invariant.ts", "./packages/session-projection/*/src/invariant.ts", + "./packages/session-title/*/src/invariant.ts", "./packages/session-query/*/src/invariant.ts", "./packages/telemetry/*/src/invariant.ts", "./packages/acp/*/src/invariant.ts", From 1c3fa6edcddfcd3f239d1d1167b928aca75a0177 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:11:19 +0800 Subject: [PATCH 08/10] fix(apiproxy): title-invalid only for the input's fault, presentable messages The rename impl narrows on SessionTitleInvalidError: only an empty-normalizing title maps to title-invalid (its message renders verbatim in the rename dialog alert), while liveness/disposal races fall to internal; the absent-service message trims to one presentable sentence. rpc-schemas gains the title-invalid accept/missing-details lines; cosmetic ordering (type-only import comment, tsconfig reference, schema import order) restored. --- packages/host/apiproxy/src/api-proxy.ts | 22 ++++++++++----- packages/host/apiproxy/src/fetch/client.ts | 2 +- packages/host/apiproxy/src/fetch/handler.ts | 2 +- .../apiproxy/tests/api-proxy-rename.spec.ts | 27 ++++++++++++++++--- .../host/apiproxy/tests/rpc-schemas.spec.ts | 2 ++ packages/host/apiproxy/tsconfig.json | 4 +-- 6 files changed, 45 insertions(+), 14 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 8ed681e239..4f5336323c 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -36,10 +36,10 @@ import type {} from '@deepseek-ai/dsh-session-projection-cache' import { GoalError } from '@deepseek-ai/dsh-goal' 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')`. -// Type-only edge: resolves `ctx.get('sessionTitle')` for the rename impl. -import type {} from '@deepseek-ai/dsh-session-title' 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 @@ -1057,16 +1057,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if ('error' in found) return err(request, found.error) const titles = ctx.get('sessionTitle') if (titles === undefined) { - return err(request, { code: 'internal', message: 'session-title service is absent: this deployment does not mount @deepseek-ai/dsh-session-title in its composition (cordis.yml or explicit assembly)', details: {} }) + 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: 'title-invalid', - message: `rename rejected for session "${sessionId}": ${String(error)}`, - details: { sessionId }, + code: 'internal', + message: `failed to rename session "${sessionId}": ${String(error)}`, + details: {}, }) } }, diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 5940775499..5b81d1c4ea 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -20,11 +20,11 @@ import { import { sessionCancelValueSchema, sessionCreateValueSchema, - sessionRenameValueSchema, sessionHistoryValueSchema, sessionListValueSchema, sessionModelsValueSchema, sessionPromptValueSchema, + sessionRenameValueSchema, sessionSelectModelValueSchema, } from '../api/sessions.schema.ts' import { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index c14e680119..46e3703f3a 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -17,11 +17,11 @@ import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts' import { sessionCancelRequestSchema, sessionCreateRequestSchema, - sessionRenameRequestSchema, sessionHistoryRequestSchema, sessionListRequestSchema, sessionModelsRequestSchema, sessionPromptRequestSchema, + sessionRenameRequestSchema, sessionSelectModelRequestSchema, } from '../api/sessions.schema.ts' import { diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts index 5bdac4fb11..0e3f0ffe18 100644 --- a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts @@ -2,7 +2,9 @@ * 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). + * 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' @@ -82,20 +84,37 @@ describe('sessions.rename', () => { expect(event?.data).toMatchObject({ title: 'new name', source: { kind: 'user' } }) }) - it('maps an empty-normalizing title to title-invalid', async () => { + 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) - const response = await api(ctx).sessions.rename(request({ sessionId: source.id, title: ' ' })) + // 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) @@ -104,7 +123,7 @@ describe('sessions.rename', () => { expect(response.result.ok).toBe(false) if (!response.result.ok) { expect(response.result.error.code).toBe('internal') - expect(response.result.error.message).toMatch(/session-title service is absent/) + expect(response.result.error.message).toMatch(/mounts no session-title service/) } }) }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index fd1f107a80..fe2780eadc 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/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() }) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 908e2cb546..5185081283 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -39,10 +39,10 @@ "path": "../../session-projection/session-projection" }, { - "path": "../../session-title/session-title" + "path": "../../session-projection/session-projection-cache" }, { - "path": "../../session-projection/session-projection-cache" + "path": "../../session-title/session-title" }, { "path": "../../skill/skill" From acd9716634521731801b2bfb71c8a441166b24e3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:11:43 +0800 Subject: [PATCH 09/10] fix(client): fixture rename via requireSession, unchanged-title pin gesture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture route shares the requireSession guard, reads the accepted seq off the appended event, and gains the error-arm spec the workspace.rename case set as precedent; WorkspaceBrowser drops the unchanged-title block — confirming the current automatic title IS the pin gesture — and both touched client READMEs document the new dialog and the unary-settle rule bilingually. --- .../client/connection/src/client/fixture.ts | 16 +++----- .../client/connection/tests/fixture.spec.ts | 41 +++++++++++++++++++ packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 4 +- packages/client/ui-workspace/README.zh.md | 4 +- .../src/client/WorkspaceBrowser.tsx | 5 ++- 9 files changed, 59 insertions(+), 23 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index a586e78477..0a36dd3b8f 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -944,20 +944,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { return ok(request, { sessionId: created.sessionId }) }, rename: (request) => { + const missing = requireSession(request) + if (missing !== undefined) return missing const { sessionId, title } = request.payload - const source = summaryOf(sessionId) - if (source === undefined) { - return err(request, { - code: 'session-not-found', - message: `no session ${sessionId}`, - details: { sessionId }, - }) - } const normalized = title.trim().replace(/\s+/g, ' ') if (normalized.length === 0) { return err(request, { code: 'title-invalid', - message: `rename rejected for session ${sessionId}: empty title`, + message: 'session title must contain visible characters', details: { sessionId }, }) } @@ -967,8 +961,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { type: 'session/title', data: { title: normalized, messageSeqs: [], source: { kind: 'user' } }, }) - const log = logOf(sessionId) - return ok(request, { title: normalized, seq: log.length - 1 }) + 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) ?? [] diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 09a3efecd3..ac27323eb9 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/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 diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 429ae8f0a9..239549bbdf 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/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: 25eb60e2c95059ae918669c9f5169b6b8e9c6816 -README.zh.md: e3085f91750503aeaffda41d86c40c62943b4ba9 +README.md: eeeb813d6d0ddddf9c5c718a9ede65b3e222c220 +README.zh.md: 0aba4674d2deb2cfc93712becf1626a0db4241b2 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 25eb60e2c9..eeeb813d6d 100644 --- a/packages/client/runtime/README.md +++ b/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 diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index e3085f9175..0aba4674d2 100644 --- a/packages/client/runtime/README.zh.md +++ b/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 时为无操作。 ## 会话模型选择 diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 00b639f625..536911a16a 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/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 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 8acf819121..a1b58f4abe 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. @@ -18,5 +18,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions. +- **No Session deletion or fork control** — the Session menu's Fork and Delete rows remain visual-only (Rename is wired); Workspace registration deletion does not delete Sessions. - **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index e97d93f7e3..a472507bc4 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/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` 组合的应用内流程。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 0c607f1b70..a1fa0683ac 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -316,13 +316,14 @@ 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(null) const sessionRenameTrimmed = sessionRenameDraft.trim() - const sessionRenameBlocked = sessionRenaming || sessionRenameTrimmed === '' - || sessionRenameTarget === null || sessionRenameTrimmed === sessionRenameTarget.currentTitle + const sessionRenameBlocked = sessionRenaming || sessionRenameTrimmed === '' || sessionRenameTarget === null const closeSessionRename = () => { if (sessionRenaming) return setSessionRenameTarget(null) From 37a75aa85e8cebf3660c238c70d3839c37de13e8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:20:27 +0800 Subject: [PATCH 10/10] docs: regenerate catalogs for the rename JSDoc and invariant companion gen-cordis-catalog/gen-cordis-api pick up the SessionTitleService rename JSDoc and line shifts; gen-doc-graphs picks up the session-title invariant companion's session/event edge. --- docs/cordis-catalog/services.md | 5 +++-- docs/event-producer-consumer.md | 2 +- packages/cordis/tool-cordis/src/api-catalog.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f19a94054b..20d3fbcd3b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1612,7 +1612,8 @@ get(session: Session): SessionTitleSnapshot | undefined * @param session - exact live session to rename. * @param title - raw user input; normalized before acceptance. * @returns the accepted title snapshot. - * @throws {Error} when the session is not live or the title normalizes to empty. + * @throws {SessionTitleInvalidError} when the title normalizes to empty. + * @throws {Error} when the session is not live or the service is disposed. */ rename(session: Session, title: string): SessionTitleSnapshot @@ -1636,7 +1637,7 @@ register(provider: SessionTitleProvider): () => Promise 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:251`](../../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` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 71238305e5..271e948348 100644 --- a/docs/event-producer-consumer.md +++ b/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` | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7d78adecca..a19d49f860 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -736,7 +736,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { 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 {Error} when the session is not live or the title normalizes to empty.\n */', + 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',