From b76659aa576b450555d41371eabde8d8e7aa2711 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 27 Jul 2026 16:50:56 +0800 Subject: [PATCH 01/32] feat(skill): hot-refresh skill catalogs --- ...-07-27-skill-catalog-hot-refresh.i18n.yaml | 6 + .../2026-07-27-skill-catalog-hot-refresh.md | 46 ++ ...2026-07-27-skill-catalog-hot-refresh.zh.md | 46 ++ docs/config-catalog.md | 20 +- docs/cordis-catalog/events.md | 19 + docs/cordis-catalog/services.md | 21 +- docs/core-data-structures/skills.i18n.yaml | 6 +- docs/core-data-structures/skills.md | 28 +- docs/core-data-structures/skills.zh.md | 28 +- docs/event-producer-consumer.md | 5 +- docs/tool-catalog.md | 2 +- examples/tui-agent/tests/pty-harness.ts | 38 +- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 30 ++ .../cordis/tool-cordis/src/api-catalog.ts | 19 + .../examples/agent-spine-demo/package.json | 1 + .../agent-spine-demo/tests/agent-core.spec.ts | 145 ++++- packages/skill/README.i18n.yaml | 6 +- packages/skill/README.md | 6 +- packages/skill/README.zh.md | 6 +- packages/skill/skill-local/README.i18n.yaml | 6 +- packages/skill/skill-local/README.md | 27 +- packages/skill/skill-local/README.zh.md | 27 +- packages/skill/skill-local/package.json | 1 + packages/skill/skill-local/src/index.ts | 498 +++++++++++++++++- .../tests/skill-local-watcher.spec.ts | 220 ++++++++ .../skill-local/tests/skill-local.spec.ts | 340 +++++++++++- packages/skill/skill/README.i18n.yaml | 6 +- packages/skill/skill/README.md | 18 +- packages/skill/skill/README.zh.md | 18 +- packages/skill/skill/src/index.ts | 82 ++- packages/skill/skill/tests/skill.spec.ts | 154 +++++- packages/skill/tool-skill/README.i18n.yaml | 6 +- packages/skill/tool-skill/README.md | 22 +- packages/skill/tool-skill/README.zh.md | 22 +- packages/skill/tool-skill/package.json | 1 + packages/skill/tool-skill/src/index.ts | 109 +++- .../skill/tool-skill/tests/tool-skill.spec.ts | 272 +++++++++- packages/ui/tui/README.i18n.yaml | 6 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/index.ts | 34 +- packages/ui/tui/tests/tui.spec.ts | 140 ++++- pnpm-lock.yaml | 23 + scripts/gen-cordis-catalog.ts | 1 + scripts/gen-tool-catalog.ts | 5 +- scripts/type-equiv.manifest.json | 5 + 46 files changed, 2372 insertions(+), 153 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md create mode 100644 packages/skill/skill-local/tests/skill-local-watcher.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml new file mode 100644 index 0000000000..ec6e7fd572 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml @@ -0,0 +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 .agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md +2026-07-27-skill-catalog-hot-refresh.md: f818766eb55f237e21aa3da9586887e493b9de75 +2026-07-27-skill-catalog-hot-refresh.zh.md: 3f0be2e760f4a18504e18803bcb8a8e47acffa5d diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md new file mode 100644 index 0000000000..f818766eb5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md @@ -0,0 +1,46 @@ +# Agent Note: Skill catalog hot refresh + +Status: implemented + +English | [中文](2026-07-27-skill-catalog-hot-refresh.zh.md) + +## Problem + +Skill summaries are model routing input, but local skills can appear, disappear, or be renamed after a session starts. IDEs, Git operations, shell commands, and other processes can all mutate `.agents/skills` without going through the harness filesystem tools. A startup-only catalog leaves the model unaware of new skills and able to call deleted names. Treating every instruction-body edit as a catalog revision would instead couple progressive loading to unnecessary prompt churn. + +Filesystem updates are also non-atomic from the observer's perspective. An editor or Git operation may briefly remove a file, a watched root may not exist at startup, and discovery may fail transiently. Publishing those intermediate observations as authoritative empty catalogs would be worse than retaining the last complete view. + +## Decision + +The skill capability separates catalog membership from instruction-body loading. `ctx.skills.snapshot()` returns summaries plus a completeness bit, while `ctx.skills.invalidateProvider(provider)` dirties only the exact registered provider and discards completed catalog caches. A provider or runtime generation change during discovery retries before returning. Incomplete observations are not cached. A stale provider callback after disposal or replacement is a no-op because invalidation uses object identity. + +`@deepseek-ai/dsh-skill-local` directly depends on Chokidar and observes catalog-relevant host paths. Existing roots watch direct skill bundle directories, flat Markdown entries, and direct `SKILL.md` entry files. Additions, removals, and directory changes invalidate membership; file changes support frontmatter `name` and `description` refresh. Resource files below a bundle are ignored. Events in one microtask batch coalesce to one invalidation. Project watchers use a bounded least-recently-observed set. + +A missing root is followed from its nearest existing ancestor one absent segment at a time with `fs.watchFile`, then handed to Chokidar once the real root exists. Deleting a root re-establishes ancestor observation. Chokidar configuration exposes native-versus-polling mode, write stability, polling interval, symlink following, and project watcher capacity. First-party `write` and `edit` tool observations synchronously invalidate a relevant provider, so the next model step sees its own mutation without waiting for host delivery. Watch startup/runtime failures make discovery incomplete and retry; teardown closes watchers and ignores late callbacks. + +`@deepseek-ai/dsh-tool-skill` keeps the initial complete catalog in `agent/session-prefix`. Before every model step it computes a digest over exact `skill` tool visibility and the ordered rendered names and descriptions. A changed digest appends a durable, complete replacement catalog through `agent.inject()`, including an explicit empty catalog when all skills disappear. The logged message carries `{ kind: 'skill-catalog', version: 1, digest }`, so a still-visible replacement supplies the baseline across replay or plugin reload. If compaction shadows it, the next pre-step falls back to the loop's initial-prefix baseline and re-establishes the current catalog when needed. An incomplete snapshot emits no replacement and preserves the last-good model view. + +The TUI consumes the same invalidation as presentation state, not session history. `skills/change` carries no diff; the TUI refetches `snapshot()` for the active session cwd, applies only the latest complete result, and retains the previous commands across incomplete observations. A complete empty result clears stale completions. Because pi-tui closes autocomplete when its provider is replaced, a catalog that arrives while the user is typing a slash-command name also triggers a suggestion-only re-query of the current draft. + +Instruction bodies keep progressive disclosure. Every `skill(name)` call asks the provider to reread and parse the current file; there is no body cache, hash, revision, or proactive notification. Previously logged tool results remain unchanged. If the loaded frontmatter name no longer matches the selected candidate, the registry rejects the stale name and invalidates that provider so a later catalog observation can publish the new name. + +## Verification + +Registry tests pin exact invalidation, contained observer failures, incomplete snapshots, generation retries, and stale-name rejection. Local-provider tests cover bundle and flat-file creation, removal, rename, root creation/deletion/recreation, description changes, body-only edits, first-party observation, symlinks, polling options, watcher failures, event coalescing, bounded projects, teardown, and transient reads. Tool tests pin full replacement messages, empty tombstones, digest stability for body-only edits, incomplete-state retention, visibility, and resume metadata. TUI tests pin last-complete retention, authoritative empty removal, latest-wins refresh, teardown, and the already-open slash-draft race; a real Loader/PTY smoke adds a local skill after startup and observes its completion without restarting. A keyless assembled agent-spine snapshot creates a project skill through model-facing filesystem tools, observes its replacement catalog on the next request, and loads its current body with the real `skill` tool. + +## Alternatives considered + +- **Put the live catalog in World State** — rejected because catalog replacements are model-visible session inputs and must be reconstructable from the event log. Durable injected history already provides replay, resume, fork, and compaction semantics without another mutable state plane. +- **Rely only on `fs/observed`** — rejected because IDEs, Git, shell commands, and external processes do not cross that seam. The event remains a latency fast path for first-party tools, while host watching supplies coverage. +- **Hash or version every `SKILL.md` body** — rejected because the model initially sees only names and descriptions, and the provider already rereads the body on each tool call. Body revisions would create catalog traffic without changing routing and would not justify rewriting historical tool results. +- **Watch every bundle resource** — rejected because references, scripts, and assets are loaded on demand and do not affect the category list. Broad recursive watching would add invalidations, descriptor pressure, and platform variability without improving routing. +- **Publish partial or failed discovery as the new catalog** — rejected because a transient read failure is not evidence of deletion. The completeness bit lets the model-facing consumer preserve its last-good catalog until a full observation succeeds. + +## Consequences + +- New, deleted, and renamed local skills become visible at model-step boundaries without restarting the agent, including when the skills root did not exist at startup. +- The TUI's `/skill:` completions converge on the same complete catalog without blocking each keystroke on discovery; an open slash-name draft refreshes when the catalog arrives. +- Catalog updates are append-only, logged, and whole-list replacements. They preserve the stable initial prefix and retire stale names explicitly, at token cost proportional to the current catalog on each actual digest change. +- Body-only edits produce no catalog message. A subsequent tool call sees current content, while prior tool results remain an accurate record of what the model previously loaded. +- Missing-root polling and Chokidar add one maintained runtime dependency, host watcher resources, bounded detection latency, and deployment tunables. The bounded project set and teardown contract contain those costs. +- Remote or future mutable providers remain responsible for calling `invalidateProvider()` from their own observation mechanism; the registry does not impose a universal watcher or TTL. diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md new file mode 100644 index 0000000000..3f0be2e760 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md @@ -0,0 +1,46 @@ +# Agent Note: Skill 目录热刷新 + +Status: implemented + +[English](2026-07-27-skill-catalog-hot-refresh.md) | 中文 + +## 问题 + +skill(技能)摘要是模型的路由输入,但本地 skill 可在会话启动后新增、消失或重命名。IDE、Git 操作、shell 命令和其他进程都可以修改 `.agents/skills`,而不经过 harness 文件系统工具。仅在启动时构建目录,会让模型无法获知新 skill,并且仍能调用已删除的名称。反之,如果把每次指令正文编辑都视为目录修订,就会让渐进式加载与不必要的提示词频繁变化耦合。 + +从观察方来看,文件系统更新也不是原子完成的。编辑器或 Git 操作可能会短暂移除文件,受监视的根目录在启动时可能不存在,发现也可能暂时失败。把这些中间观察结果发布为权威空目录,比保留最后一个完整视图更糟。 + +## 决策 + +skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snapshot()` 返回摘要及一个完整性位;`ctx.skills.invalidateProvider(provider)` 只会将精确的已注册提供方标记为脏,并丢弃已经完成的目录缓存。在发现期间,如果提供方或运行时 generation 发生变化,系统会先重试再返回。不完整的观察结果不会缓存。提供方在资源释放或被替换后到达的陈旧回调不会执行任何操作,因为失效操作使用对象身份。 + +`@deepseek-ai/dsh-skill-local` 直接依赖 Chokidar,并观察与目录相关的宿主路径。已有根目录会监视其直属 skill bundle 目录、平铺的 Markdown 条目和直属 `SKILL.md` 条目文件。新增、移除和目录变更会使成员关系失效;文件变更还支持刷新 frontmatter 中的 `name` 和 `description`。bundle 内更深层的资源文件会被忽略。同一微任务批次中的事件会合并为一次失效。项目 watcher 使用有界集合,并按最久未观察顺序淘汰。 + +系统从缺失根目录最近的现有祖先开始,使用 `fs.watchFile` 每次跟进一层缺失路径片段;真实根目录出现后,再交给 Chokidar。删除根目录后,系统会重新建立祖先观察。Chokidar 配置公开原生事件或轮询模式、写入稳定性、轮询间隔、符号链接跟随选项和项目 watcher 容量。第一方 `write` 和 `edit` 工具观察会同步使相关提供方失效,因此下一个模型步骤无需等待宿主事件投递,就能看到自身改动。watcher 启动或运行失败会使发现结果不完整并触发重试;资源销毁会关闭 watcher,并忽略延迟回调。 + +`@deepseek-ai/dsh-tool-skill` 将初始完整目录保存在 `agent/session-prefix` 中。每个模型步骤开始前,它都会针对 `skill` 工具的精确可见性,以及按顺序渲染的名称和描述计算 digest。digest 变化时,插件通过 `agent.inject()` 追加一份持久的完整替换目录;所有 skill 消失时,也会追加显式空目录。记录的消息携带 `{ kind: 'skill-catalog', version: 1, digest }`。恢复后,最新且仍可见的替换是比较基线;如果压缩(compaction)遮蔽了替换消息,模型步骤前的观察会改以 `agent/session-prefix` 为基线,并在必要时重新发布当前完整目录。不完整的快照不会产生替换,并会保留最后一次完整的模型视图。 + +TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills/change` 不携带 diff;TUI 会为活动会话的 cwd 重新获取 `snapshot()`,仅应用最新的完整结果,并在观测不完整时保留先前命令。完整的空结果会清除陈旧补全项。pi-tui 在其提供方被替换时会关闭自动补全,因此如果目录在用户输入斜杠命令名称期间到达,还会触发一次仅用于更新建议的当前草稿重查。 + +指令正文继续采用渐进式披露。每次调用 `skill(name)` 时,系统都会要求提供方重新读取并解析当前文件;不存在正文缓存、哈希、修订或主动通知。先前记录的工具结果保持不变。如果加载后的 frontmatter 名称不再匹配所选候选项,注册表会拒绝这个陈旧名称,并使该提供方失效,以便后续目录观察发布新名称。 + +## 验证 + +注册表测试固定了精确失效、监听器失败隔离、不完整快照、generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、watcher 失败、事件合并、项目 watcher 容量上限、资源销毁和暂时读取。工具测试固定了完整替换消息、空 tombstone、仅修改正文时 digest 稳定、不完整状态保留、可见性和恢复元数据。TUI 测试固定了上一份完整结果保留、权威空结果清除、刷新时以最新结果为准、资源销毁和已打开斜杠草稿的竞态;一项使用真实 Loader/PTY 的 smoke 测试会在启动后添加本地 skill,并观察其补全项出现,而无需重启。一个无密钥、装配完成的 agent-spine 快照测试通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并使用真实 `skill` 工具加载当前正文。 + +## 考虑过的替代方案 + +- **将实时目录放入 World State**:不予采纳,因为目录替换是模型可见的会话输入,必须能够从事件日志重建。持久注入历史已经提供回放、恢复、fork 和压缩语义,无需再引入一套可变状态层。 +- **只依赖 `fs/observed`**:不予采纳,因为 IDE、Git、shell 命令和外部进程都不会经过该 seam。该事件仍作为第一方工具的低延迟快速路径,宿主监视则补齐覆盖。 +- **为每个 `SKILL.md` 正文计算哈希或版本**:不予采纳,因为模型最初只看到名称和描述,提供方已经在每次工具调用时重新读取正文。正文修订会产生目录流量,却不会改变路由,也不足以成为改写历史工具结果的理由。 +- **监视每个 bundle 资源**:不予采纳,因为参考资料、脚本和产物都是按需加载的,不影响类别列表。宽泛的递归监视会增加失效、描述符压力和平台差异,却不能改善路由。 +- **将部分发现或失败发现发布为新目录**:不予采纳,因为暂时读取失败不能证明文件已删除。完整性位让面向模型的消费方保留最后一次完整目录,直到完整观察成功。 + +## 影响 + +- 新增、删除和重命名的本地 skill 会在模型步骤边界变得可见,无需重启 agent(智能体),即使 skill 根目录在启动时不存在也一样。 +- TUI 的 `/skill:` 补全会收敛到同一份完整目录,而不会让每次按键都阻塞于发现;打开的斜杠命令名称草稿会在目录到达时刷新。 +- 目录更新采用仅追加、日志记录和全量列表替换。它们会保持稳定的初始前缀,并显式停用陈旧名称;每次 digest 实际变化时,token 成本与当前目录大小成正比。 +- 仅修改正文不会产生目录消息。后续工具调用会看到当前内容,而先前工具结果仍准确记录模型之前加载的内容。 +- 缺失根目录轮询和 Chokidar 引入一个有人维护的运行时依赖、宿主 watcher 资源、有界检测延迟和部署可调参数。有界项目集合与资源销毁契约会限制这些成本。 +- 远程或未来的可变提供方仍有责任通过自身观察机制调用 `invalidateProvider()`;注册表不会强制采用通用 watcher 或 TTL。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6a31c353fd..6082b41412 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1149,7 +1149,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:113`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:121`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -1164,10 +1164,22 @@ export interface Config { agentsHome?: string /** Additional skill roots scanned after project roots and before user roots. */ customSkillDirs?: string[] + /** Whether host-local skill roots are watched for catalog changes. */ + watch?: boolean + /** Whether Chokidar uses polling instead of native filesystem events. */ + watchUsePolling?: boolean + /** Milliseconds a changed skill entry must remain stable before it is observed. */ + watchStabilityThresholdMs?: number + /** Milliseconds between Chokidar stability or polling probes. */ + watchPollIntervalMs?: number + /** Maximum distinct project roots whose skill directories remain watched. */ + watchMaxProjects?: number + /** Whether watched symbolic links follow their target files. */ + watchFollowSymlinks?: boolean } ``` -Source: [`packages/skill/skill-local/src/index.ts:40`](../packages/skill/skill-local/src/index.ts) +Source: [`packages/skill/skill-local/src/index.ts:45`](../packages/skill/skill-local/src/index.ts) ## `@deepseek-ai/dsh-spill-local` @@ -1567,7 +1579,7 @@ Source: [`packages/session-query/tool-session-query/src/index.ts:29`](../package ## `@deepseek-ai/dsh-tool-skill` -Requires: `tools` · `skills` +Requires: `agents` · `tools` · `skills` ```ts config-catalog /** Model-facing skill catalog configuration. */ @@ -1577,7 +1589,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-skill/src/index.ts:19`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:24`](../packages/skill/tool-skill/src/index.ts) ## `@deepseek-ai/dsh-tool-subagent` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ccae4c9f9b..96c37db8b3 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -708,6 +708,25 @@ Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-stru Source: [`packages/core/session/src/index.ts:111`](../../packages/core/session/src/index.ts) +## `skills/*` + +### `skills/change` — emit + +A skill provider, runtime contribution, or provider-backed catalog may have changed. This is an unfiltered invalidation notification; consumers refetch the catalog for their own lookup options. Listener failures are contained and cannot veto the registry mutation. + +```ts cordis-catalog +/** + * A skill provider, runtime contribution, or provider-backed catalog may + * have changed. This is an unfiltered invalidation notification; consumers + * refetch the catalog for their own lookup options. Listener failures are + * contained and cannot veto the registry mutation. + * @mode emit + */ +'skills/change'(): void +``` + +Source: [`packages/skill/skill/src/index.ts:139`](../../packages/skill/skill/src/index.ts) + ## `slash/*` ### `slash/input-begin-command` — bail diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cc21210f3b..44cc25b433 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1393,6 +1393,14 @@ Registry of skill providers. It merges provider catalogs with stable first-wins */ registerProvider(provider: SkillProvider): () => void +/** + * Invalidate catalogs contributed by one currently registered provider. Exact object identity + * prevents a late callback from an old provider instance from invalidating its replacement. + * Calls for an already-unregistered provider are harmless. + * @param provider - exact provider instance whose external source changed. + */ +invalidateProvider(provider: SkillProvider): void + /** * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and @@ -1411,6 +1419,15 @@ register(skill: SkillRegistration): () => void */ async list(options: SkillLookupOptions = {}): Promise +/** + * Observe the current model-invocable catalog and whether all providers completed discovery. + * Incomplete observations are never cached, allowing consumers to retain last-good state and + * retry on their next request boundary. + * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @returns sorted summaries plus provider-completeness state. + */ +async snapshot(options: SkillLookupOptions = {}): Promise + /** * Load and validate the winning candidate, passing its opaque discovery locator back to the * provider. Cancellation is rechecked after selection, including cache hits, and raced against @@ -1422,9 +1439,9 @@ async list(options: SkillLookupOptions = {}): Promise async get(name: string, options: SkillLookupOptions = {}): Promise ``` -Types: [SkillDefinition](../core-data-structures/skills.md) · [SkillLookupOptions](../core-data-structures/skills.md) · [SkillProvider](../core-data-structures/skills.md) · [SkillRegistration](../core-data-structures/skills.md) · [SkillSummary](../core-data-structures/skills.md) +Types: [SkillCatalogSnapshot](../core-data-structures/skills.md) · [SkillDefinition](../core-data-structures/skills.md) · [SkillLookupOptions](../core-data-structures/skills.md) · [SkillProvider](../core-data-structures/skills.md) · [SkillRegistration](../core-data-structures/skills.md) · [SkillSummary](../core-data-structures/skills.md) -Source: [`packages/skill/skill/src/index.ts:141`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:160`](../../packages/skill/skill/src/index.ts) ## `ctx.spillStore` — `SpillStore` (abstract seam) diff --git a/docs/core-data-structures/skills.i18n.yaml b/docs/core-data-structures/skills.i18n.yaml index 2f67387224..df0b1746fa 100644 --- a/docs/core-data-structures/skills.i18n.yaml +++ b/docs/core-data-structures/skills.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 -skills.md: fc9599713dcfddec9719ed746b66ea0217b86cf5 -skills.zh.md: 0eb4c0aa69ed56117c7508358c0d47e3b3e95fcb +# pnpm run verify-translation-pairing --write docs/core-data-structures/skills.md +skills.md: fca33adb794a0d448249a2e7d518f03d8c36bc8a +skills.zh.md: a4bb20229a2c7e02cb549091ef9023d16e401f48 diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index fc9599713d..fca33adb79 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -2,7 +2,7 @@ English | [中文](skills.zh.md) -The [skill capability family](../../packages/skill) is split across three packages: the registry ([dsh-skill](../../packages/skill/skill), `ctx.skills`) merges provider catalogs; the local provider ([dsh-skill-local](../../packages/skill/skill-local)) scans project/custom/user directories; the consumer ([dsh-tool-skill](../../packages/skill/tool-skill)) owns the session-prefix catalog and model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md). +The [skill capability family](../../packages/skill) is split across three packages: the registry ([dsh-skill](../../packages/skill/skill), `ctx.skills`) merges provider catalogs; the local provider ([dsh-skill-local](../../packages/skill/skill-local)) scans and watches project/custom/user directories; the consumer ([dsh-tool-skill](../../packages/skill/tool-skill)) owns the initial and replacement catalogs plus the model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md). Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts), [`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts), and [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts). @@ -10,7 +10,7 @@ Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/ind `ctx.skills` combines local, embedded, remote, or other providers. Registration is synchronous; remote initialization and discovery belong in awaited `list()`. Provider objects, options, and candidates are borrowed readonly, while semantic fields are validated. -Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and skipped without caching the degraded catalog, while malformed candidates fail fast. +Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and omitted from an incomplete observation without caching it, while malformed candidates fail fast. `invalidateProvider()` clears completed catalogs only for the exact live provider object, and an in-flight discovery retries when its provider generation changes. Provider and runtime membership mutations emit the unfiltered `skills/change` invalidation event; it carries no diff, so consumers refetch `snapshot()` with their own lookup options. ```ts type-equiv /** Provider interface for one source of skills, such as local directories or a remote registry. */ @@ -50,6 +50,8 @@ The shipped local provider scans roots in rank order: The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child. The local provider does not ship built-in system skills; deployments supply built-ins through another provider. +Chokidar watches existing roots for direct bundle/flat-entry additions and removals plus direct skill-entry changes. A missing root is followed one absent path segment at a time from its nearest existing ancestor until Chokidar can attach. Resource files below a bundle are not catalog changes. Model-facing `write` and `edit` observations synchronously invalidate the provider when their target is catalog-relevant, while the host watcher covers IDE, Git, shell, and external-process mutations. Watcher failures make the current observation incomplete; project-scoped watchers use a configured bounded LRU. + ## Skill identity Skill names are kebab-case (`^[a-z0-9]+(?:-[a-z0-9]+)*$`). The local provider accepts directory bundles (`/SKILL.md`) and flat Markdown files (`.md`). Nested recursive `**/SKILL.md` discovery is intentionally outside v1. @@ -83,6 +85,18 @@ interface SkillSummary { } ``` +`SkillCatalogSnapshot` distinguishes authoritative absence from transient provider failure. `skills` contains the sorted summaries collected in that observation; `complete` is true only when every registered provider completed. Incomplete snapshots are not cached, allowing a consumer to retain its last-good model catalog and retry. + +```ts type-equiv +/** One catalog observation plus whether every registered provider completed discovery. */ +interface SkillCatalogSnapshot { + /** Sorted model-invocable summaries from providers that completed. */ + readonly skills: SkillSummary[] + /** Whether every registered provider completed discovery for this observation. */ + readonly complete: boolean +} +``` + `SkillCandidate` is the provider-to-registry shape. `locator` is opaque provider state; the registry only stores it and gives it back to the winning provider's `get()`. ```ts type-equiv @@ -132,6 +146,8 @@ type SkillRegistration = Omit & { readonly provider Skill lookup is cwd-sensitive because providers may expose workspace-local skills, and its optional signal cancels provider work for the caller. Providers receive the same readonly options object used for cache identity and loading. Cancellation is checked before and after catalog selection, including cache hits, and races both discovery and full-definition loading. If no git root is found, the local provider treats the supplied cwd itself as the project root. +Full definitions are not cached by the registry. Each `get()` calls the winning provider with the selected candidate, so the local provider rereads the current body. A definition whose name no longer matches that candidate is rejected and invalidates the exact provider for rediscovery. + ```ts type-equiv /** Caller context used for cwd-sensitive and abortable provider work. */ interface SkillLookupOptions { @@ -142,7 +158,7 @@ interface SkillLookupOptions { } ``` -The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, and `customSkillDirs`). The consumer owns its catalog description bound. +The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, and `customSkillDirs`) plus watcher enablement, polling, stability, symlink, and project-capacity controls. The consumer owns its catalog description bound. Exact defaults and validation are in the generated [config catalog](../config-catalog.md). ```ts type-equiv /** Skill registry configuration. */ @@ -154,6 +170,8 @@ interface Config { ## Session catalog and tool contract -`dsh-tool-skill` contributes a user-role `` through `agent/session-prefix`. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Prefix discovery forwards the caller's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`. Its request-only, header-logged lifecycle is defined by the [session-prefix Agent Note](../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md). +`dsh-tool-skill` contributes the initial user-role `` through `agent/session-prefix`. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Prefix discovery forwards the caller's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`. Its request-only, header-logged lifecycle is defined by the [session-prefix Agent Note](../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md). -The model-facing `skill({ name })` tool validates the kebab-case name, loads the complete definition for the calling agent cwd, reports an unresolved skill as unknown or no longer available, rejects `disableModelInvocation` skills, and returns a tool result containing ``, ``, and ``. `resourceBase` resolves explicitly referenced scripts, references, and assets only as needed; the loaded result does not enumerate a skill directory. The tool result is the model-visible path for complete instructions. +Before each later model step, the consumer digests exact tool visibility plus the rendered names and descriptions from a complete snapshot. A changed digest appends a durable full replacement through `agent.inject()` with `{ kind: 'skill-catalog', version: 1, digest }` metadata; deleting every skill appends an explicit empty replacement. Incomplete snapshots preserve the last-good model view. Visible metadata supplies the replay baseline, while a replacement shadowed by compaction is re-established against the loop's initial-prefix baseline when necessary. These updates are session history, not World State. + +The model-facing `skill({ name })` tool validates the kebab-case name, rereads the complete definition for the calling agent cwd, reports an unresolved skill as unknown or no longer available, rejects `disableModelInvocation` skills, and returns a tool result containing ``, ``, and ``. `resourceBase` resolves explicitly referenced scripts, references, and assets only as needed; the loaded result does not enumerate a skill directory. Body-only edits therefore change later tool calls without producing catalog messages or rewriting earlier tool results. diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index 0eb4c0aa69..a4bb20229a 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -2,7 +2,7 @@ [English](skills.md) | 中文 -[skill(技能)能力族](../../packages/skill)拆分为三个包(package):注册表([dsh-skill](../../packages/skill/skill),`ctx.skills`)合并各提供方的目录;本地提供方([dsh-skill-local](../../packages/skill/skill-local))扫描项目/自定义/用户目录;消费方([dsh-tool-skill](../../packages/skill/tool-skill))拥有会话前缀目录和面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 +[skill(技能)能力族](../../packages/skill)拆分为三个包(package):注册表([dsh-skill](../../packages/skill/skill),`ctx.skills`)合并各提供方的目录;本地提供方([dsh-skill-local](../../packages/skill/skill-local))扫描并监视项目/自定义/用户目录;消费方([dsh-tool-skill](../../packages/skill/tool-skill))拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。 @@ -10,7 +10,7 @@ `ctx.skills` 组合本地、内嵌、远程或其他提供方。注册是同步的;远程初始化与发现属于 `list()` 的 await 阶段。提供方对象、选项与候选项以只读方式借用,语义字段会被校验。 -重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时记录日志并跳过,不缓存降级后的目录;格式错误的候选项快速失败。 +重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时会记录日志并从不完整观测中省略,且该观测不会缓存;格式错误的候选项快速失败。`invalidateProvider()` 只针对传入的活动提供方对象清除已完成目录;若提供方代次在发现进行期间发生变化,该发现会重试。提供方和运行时的成员关系变更会发出不带过滤条件的 `skills/change` 失效事件;该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 `snapshot()`。 ```ts type-equiv /** Provider interface for one source of skills, such as local directories or a remote registry. */ @@ -50,6 +50,8 @@ interface SkillProvider { 项目根目录为包含 `.git` 的最近祖先目录;找不到时使用当前 cwd。当 `ctx.fs` 可用时,git-root 向上查找通过文件系统服务探测 `.git`,使远程或沙箱工作区不会回退到宿主文件系统边界。用户 DSH 根目录会跳过其 `.system` 子目录。本地提供方不附带内置系统 skill;部署方通过另一个提供方提供内置 skill。 +Chokidar 会监视现有根目录中直属 bundle 和平铺条目的添加与移除,以及直属 skill 条目的变更。缺失的根目录会从最近的现有祖先开始,逐个跟踪缺失路径段,直至 Chokidar 可以附加。bundle 下的资源文件变更不属于目录变更。面向模型的 `write` 和 `edit` 观测会在目标路径相关时同步使提供方目录失效,而宿主 watcher 覆盖 IDE、Git、shell 和外部进程产生的变更。watcher 失败会使当前观测不完整;项目作用域 watcher 使用按配置设限的 LRU。 + ## Skill 身份 skill 名称为 kebab-case(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)。本地提供方接受目录包(`/SKILL.md`)和扁平 Markdown 文件(`.md`)。嵌套递归的 `**/SKILL.md` 发现有意不在 v1 范围内。 @@ -83,6 +85,18 @@ interface SkillSummary { } ``` +`SkillCatalogSnapshot` 用于区分已确定的不存在和提供方的瞬时失败。`skills` 包含该次观测中收集并排序的摘要;只有每个已注册提供方都已完成发现,`complete` 才为 true。不完整快照不会缓存,因此消费方可以保留上一份可用模型目录并重试。 + +```ts type-equiv +/** One catalog observation plus whether every registered provider completed discovery. */ +interface SkillCatalogSnapshot { + /** Sorted model-invocable summaries from providers that completed. */ + readonly skills: SkillSummary[] + /** Whether every registered provider completed discovery for this observation. */ + readonly complete: boolean +} +``` + `SkillCandidate` 是提供方到注册表的形状。`locator` 是提供方的不透明状态;注册表只存储它并在调用获胜提供方的 `get()` 时传回。 ```ts type-equiv @@ -132,6 +146,8 @@ type SkillRegistration = Omit & { readonly provider skill 查找对 cwd 敏感,因为提供方可能暴露工作区本地的 skill;可选的 signal 为调用方取消提供方的工作。提供方接收与缓存标识和加载相同的只读选项对象。取消在目录选择前后(包括缓存命中时)都会检查,并与发现和完整定义加载竞争。如果找不到 git root,本地提供方将所提供的 cwd 本身视为项目根目录。 +注册表不缓存完整定义。每次调用 `get()` 都会携所选候选项调用胜出提供方,因此本地提供方会重新读取当前正文。名称与该候选项不再匹配的定义会被拒绝,并使该提供方实例失效以便重新发现。 + ```ts type-equiv /** Caller context used for cwd-sensitive and abortable provider work. */ interface SkillLookupOptions { @@ -142,7 +158,7 @@ interface SkillLookupOptions { } ``` -注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome` 与 `customSkillDirs`)。消费方拥有其目录描述上限。 +注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome` 与 `customSkillDirs`),以及 watcher 启用、轮询、稳定性、符号链接和项目容量控制。消费方拥有其目录描述上限。确切的默认值和校验规则见自动生成的[插件配置目录](../config-catalog.md)。 ```ts type-equiv /** Skill registry configuration. */ @@ -154,6 +170,8 @@ interface Config { ## 会话目录与工具契约 -`dsh-tool-skill` 通过 `agent/session-prefix` 贡献一条 user-role ``。目录只包含已排序的 skill `name` 和规范化、经 XML 转义的 `description`;不包含正文、路径、来源、提供方或路由提示。Prefix 发现通过 `SkillLookupOptions` 转发调用方的 abort signal。`catalogDescriptionMaxLength` 是消费方用于 description 上限的配置,默认值为 `500`,整数最小值为 `3`。其仅用于请求、记录在 header 中的生命周期由 [session-prefix Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md)定义。 +`dsh-tool-skill` 通过 `agent/session-prefix` 贡献初始的 user-role ``。目录只包含已排序的 skill `name` 和规范化、经 XML 转义的 `description`;不包含正文、路径、来源、提供方或路由提示。Prefix 发现通过 `SkillLookupOptions` 转发调用方的 abort signal。`catalogDescriptionMaxLength` 是消费方用于 description 上限的配置,默认值为 `500`,整数最小值为 `3`。其仅用于请求、记录在 header 中的生命周期由 [session-prefix Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md)定义。 -面向模型的 `skill({ name })` 工具校验 kebab-case 名称,为调用方 agent 的 cwd 加载完整定义,将未解析的 skill 报告为 unknown 或 no longer available,拒绝 `disableModelInvocation` 的 skill,并返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。工具结果是模型获取完整指令的可见路径。 +在后续每个模型步骤之前,消费方都会对精确的工具可见性以及完整快照中已渲染的名称和描述计算 digest。digest 发生变化时,会通过 `agent.inject()` 追加一条持久的完整目录替换,并携带 `{ kind: 'skill-catalog', version: 1, digest }` 元数据;删除所有 skill 时会追加一条显式的空替换。不完整快照会保留上一份可用模型视图。可见元数据提供回放基线;如果替换被压缩(compaction)遮蔽,必要时会根据 loop 的初始前缀基线重新建立该替换。这些更新属于会话历史,而非 World State。 + +面向模型的 `skill({ name })` 工具校验 kebab-case 名称,为调用方 agent 的 cwd 重新读取完整定义,将未解析的 skill 报告为 unknown 或 no longer available,拒绝 `disableModelInvocation` 的 skill,并返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。因此,仅修改正文会改变后续工具调用,而不会生成目录消息或改写先前工具结果。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ced431bf51..9254d0d56a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -16,7 +16,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy` | | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:448`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:379`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:379`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval) | | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:395`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:409`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | @@ -30,7 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | @@ -38,6 +38,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:139`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:220`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:234`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:227`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 07956b019b..3ccf014042 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -26,7 +26,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `user/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | -| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | +| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | diff --git a/examples/tui-agent/tests/pty-harness.ts b/examples/tui-agent/tests/pty-harness.ts index 700c67f660..7a4be3a21a 100644 --- a/examples/tui-agent/tests/pty-harness.ts +++ b/examples/tui-agent/tests/pty-harness.ts @@ -1,7 +1,8 @@ import { spawn } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' const POSIX_PTY_DRIVER = String.raw` @@ -36,7 +37,16 @@ while time.monotonic() < deadline: if chunk: output.extend(chunk) while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output: - os.write(fd, actions[action_index]["send"].encode()) + action = actions[action_index] + if "writeFile" in action: + target = os.path.join(cwd, action["writeFile"]["path"]) + os.makedirs(os.path.dirname(target), exist_ok=True) + with open(target, "w", encoding="utf-8") as handle: + handle.write(action["writeFile"]["content"]) + if "send" in action: + os.write(fd, action["send"].encode()) + else: + os.write(fd, action["send"].encode()) action_index += 1 waited, candidate = os.waitpid(pid, os.WNOHANG) if waited == pid: @@ -56,11 +66,14 @@ if actual_exit != int(expected_exit): sys.exit(125) ` -/** One terminal action sent after its marker has rendered. */ -interface TuiPtyAction { - readonly waitFor: string - readonly send: string -} +/** One terminal input or workspace mutation performed after its marker renders. */ +type TuiPtyAction = + | { readonly waitFor: string; readonly send: string } + | { + readonly waitFor: string + readonly writeFile: { readonly path: string; readonly content: string } + readonly send?: string + } /** Inputs for a keyless real-Loader TUI process smoke. */ export interface TuiPtySmokeOptions { @@ -160,7 +173,16 @@ async function runWindowsPtySmoke( terminal.onData((chunk) => { output += chunk while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) { - terminal.write(actions[actionIndex]!.send) + const action = actions[actionIndex]! + if ('writeFile' in action) { + const target = join(cwd, action.writeFile.path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, action.writeFile.content) + const input = action.send + if (input !== undefined) terminal.write(input) + } else { + terminal.write(action.send) + } actionIndex += 1 } }) diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 5967366c12..18d0001926 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -211,6 +211,36 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('adds a watched local skill to live /skill: autocomplete without restarting', async () => { + const skill = [ + '---', + 'name: hot-added-skill', + 'description: HOT_ADDED_COMPLETION_MARKER', + '---', + '', + 'Hot-added body.', + '', + ].join('\n') + const output = await smoke({ + label: 'tui-agent hot-added skill autocomplete', + tempDirPrefix: 'tui-agent-hot-skill-', + configPath: scriptedConfigPath, + actions: [ + { + waitFor: 'scripted TUI ready.', + writeFile: { + path: '.agents/skills/hot-added-skill/SKILL.md', + content: skill, + }, + send: '/skill:hot', + }, + { waitFor: 'HOT_ADDED_COMPLETION_MARKER', send: '\x03/exit\r' }, + ], + }) + expect(output).toContain('HOT_ADDED_COMPLETION_MARKER') + expect(output).toContain('\u001B[?2004l') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('fuzzy-completes an @file path without reading or submitting the file', async () => { const output = await smoke({ label: 'tui-agent file autocomplete', diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 224e8300ab..1b6c903450 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -666,6 +666,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'registerProvider(provider: SkillProvider): () => void', jsDoc: '/**\n * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and\n * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters\n * the provider and invalidates catalog caches.\n * @param provider - the provider to register by `provider.name`.\n * @returns the exact Cordis effect disposer that unregisters this provider;\n * composite effects may yield it directly to preserve teardown ordering.\n */', }, + { + signature: 'invalidateProvider(provider: SkillProvider): void', + jsDoc: '/**\n * Invalidate catalogs contributed by one currently registered provider. Exact object identity\n * prevents a late callback from an old provider instance from invalidating its replacement.\n * Calls for an already-unregistered provider are harmless.\n * @param provider - exact provider instance whose external source changed.\n */', + }, { signature: 'register(skill: SkillRegistration): () => void', jsDoc: '/**\n * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which\n * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and\n * receives a no-op disposer so it cannot remove the winner.\n * @param skill - the complete skill definition to expose for discovery.\n * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.\n */', @@ -674,6 +678,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async list(options: SkillLookupOptions = {}): Promise', jsDoc: '/**\n * List model-invocable skill summaries for a workspace. Lookup options and\n * provider candidates are readonly same-process values borrowed throughout\n * discovery.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns sorted summaries, excluding skills disabled for model invocation.\n */', }, + { + signature: 'async snapshot(options: SkillLookupOptions = {}): Promise', + jsDoc: '/**\n * Observe the current model-invocable catalog and whether all providers completed discovery.\n * Incomplete observations are never cached, allowing consumers to retain last-good state and\n * retry on their next request boundary.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns sorted summaries plus provider-completeness state.\n */', + }, { signature: 'async get(name: string, options: SkillLookupOptions = {}): Promise', jsDoc: '/**\n * Load and validate the winning candidate, passing its opaque discovery locator back to the\n * provider. Cancellation is rechecked after selection, including cache hits, and raced against\n * loading so an uncooperative provider cannot hang the caller.\n * @param name - kebab-case skill name.\n * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.\n * @returns the full skill, including body content, or `undefined`.\n */', @@ -1169,6 +1177,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Dispatch through\n * {@link SessionStore.flush}. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */', summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.', }, + { + name: 'skills/change', + mode: 'emit', + signature: '\'skills/change\'(): void', + jsDoc: '/**\n * A skill provider, runtime contribution, or provider-backed catalog may\n * have changed. This is an unfiltered invalidation notification; consumers\n * refetch the catalog for their own lookup options. Listener failures are\n * contained and cannot veto the registry mutation.\n * @mode emit\n */', + summary: 'A skill provider, runtime contribution, or provider-backed catalog may have changed.', + }, { name: 'slash/input-begin-command', mode: 'bail', @@ -2151,6 +2166,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SkillCandidate', declaration: 'export interface SkillCandidate extends SkillSummary {\n readonly rank: number;\n readonly locator: unknown;\n readonly path?: string;\n readonly metadata?: Readonly>;\n}', }, + { + name: 'SkillCatalogSnapshot', + declaration: 'export interface SkillCatalogSnapshot {\n readonly skills: SkillSummary[];\n readonly complete: boolean;\n}', + }, { name: 'SkillDefinition', declaration: 'export interface SkillDefinition extends SkillSummary {\n readonly content: string;\n readonly path?: string;\n readonly metadata?: Readonly>;\n}', diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 923a9aace6..165c7688e4 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -55,6 +55,7 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index e00bf2016d..04e95120f3 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -8,8 +8,10 @@ import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as agentCore from '../src/index.ts' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' +import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' -import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { CallId, LlmAdapter, LlmError, type GenerateOptions, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import type { ToolExecution } from '@deepseek-ai/dsh-tools' import * as sessionInvariant from '@deepseek-ai/dsh-session/invariant' @@ -399,6 +401,147 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) + it('snapshots a created project skill through catalog refresh and progressive loading', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-refresh-')) + const home = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-skill-refresh-home-')) + try { + await mkdir(join(root, '.git'), { recursive: true }) + const skillPath = '.agents/skills/hot-skill/SKILL.md' + const skillSource = '---\nname: hot-skill\ndescription: Hot-added skill\n---\n\nUse the freshly loaded body.\n' + const adapter = new MockAdapter([ + toolCallResponse('mkdir-skill', 'bash', { + command: 'mkdir -p .agents/skills/hot-skill', + description: 'Create the project skill directory', + }), + toolCallResponse('write-skill', 'write', { + file_path: skillPath, + content: skillSource, + }), + toolCallResponse('load-skill', 'skill', { name: 'hot-skill' }), + textResponse('SKILL_REFRESH_OK'), + ]) + const ctx = await mount({ + workspaceContext: false, + skills: { + local: { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watchStabilityThresholdMs: 20, + watchPollIntervalMs: 10, + }, + }, + }) + await ctx.plugin(LocalBashExecutor, {}) + await ctx.plugin(LocalFileSystem, { cwd: root }) + await ctx.plugin(ToolFs) + ctx.llm.registerAdapter(['mock'], adapter) + const handle = await ctx.agents.create({ + sessionId: SessionId('skill-refresh-session'), + meta: { cwd: root }, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + + handle.agent.followup([{ type: 'text', text: 'Create and load the project skill.' }]) + await waitForIdle(ctx, handle.agent) + + expect(adapter.requests).toHaveLength(4) + expect(adapter.requests.slice(0, 2).map(request => request.messages.map(messageText).join('\n'))) + .toEqual([ + expect.not.stringContaining('hot-skill'), + expect.not.stringContaining('hot-skill'), + ]) + const catalogRequest = adapter.requests[2]?.messages.map(messageText).join('\n') + expect(catalogRequest).toContain('The available skill catalog changed.') + expect(catalogRequest).toContain('- `hot-skill`: Hot-added skill') + const loadedRequest = JSON.stringify(adapter.requests[3]?.messages) + expect(loadedRequest).toContain('') + expect(loadedRequest).toContain('Use the freshly loaded body.') + + const transcript = handle.agent.session.events.flatMap>((event) => { + if (event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'tool-skill') { + return [{ + type: event.type, + source: event.data.source, + meta: { + kind: (event.data.meta as { kind?: unknown } | undefined)?.kind, + version: (event.data.meta as { version?: unknown } | undefined)?.version, + digest: typeof (event.data.meta as { digest?: unknown } | undefined)?.digest, + }, + text: event.data.content.map(block => block.type === 'text' ? block.text : '').join('\n'), + }] + } + if (event.type === 'tool/result' && ['write-skill', 'load-skill'].includes(event.data.callId)) { + return [{ + type: event.type, + callId: event.data.callId, + isError: event.data.isError, + text: event.data.content.map(block => block.type === 'text' ? block.text : '').join('\n') + .replaceAll(root, '{{cwd}}'), + }] + } + return [] + }) + expect(transcript).toMatchInlineSnapshot(` + [ + { + "callId": "write-skill", + "isError": false, + "text": "{{cwd}}/.agents/skills/hot-skill/SKILL.md + file + + Created file + ", + "type": "tool/result", + }, + { + "meta": { + "digest": "string", + "kind": "skill-catalog", + "version": 1, + }, + "source": { + "kind": "plugin", + "plugin": "tool-skill", + }, + "text": " + The available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session: + + + - \`hot-skill\`: Hot-added skill + + + Use only names in this replacement catalog. If the user names a listed skill, or the task clearly matches its description, call the \`skill\` tool with the exact name before acting. + ", + "type": "user/message", + }, + { + "callId": "load-skill", + "isError": false, + "text": " + + Base directory for this skill: {{cwd}}/.agents/skills/hot-skill + Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. + + + + Use the freshly loaded body. + + ", + "type": "tool/result", + }, + ] + `) + + await handle.dispose() + await ctx.fiber.dispose() + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('shares top-level dshHome between local skills and the managed bash environment', async () => { const home = await mkdtemp(join(tmpdir(), 'dsh-agent-core-shared-home-')) const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-core-shared-agents-')) diff --git a/packages/skill/README.i18n.yaml b/packages/skill/README.i18n.yaml index 5843c4fa86..9c4a5fafe3 100644 --- a/packages/skill/README.i18n.yaml +++ b/packages/skill/README.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 -README.md: 5c75661de17826e7ea4763e90b494e9e7a0a7c0f -README.zh.md: d219f710c0185298af89ba2e074d9e3b2e093896 +# pnpm run verify-translation-pairing --write packages/skill/README.md +README.md: 4fb41dda5d9f001f5d7c47a29f7743291c0b0822 +README.zh.md: 6f10c3e907e9cc97bf742fa6155db1442cb44edf diff --git a/packages/skill/README.md b/packages/skill/README.md index 5c75661de1..4fb41dda5d 100644 --- a/packages/skill/README.md +++ b/packages/skill/README.md @@ -6,8 +6,8 @@ The canonical three-package capability seam for reusable agent instructions: a p | Package | Role | ctx key | |---|---|---| -| `skill/` | Provider registry, precedence resolution, stable catalog snapshots, and full-definition lookup | `ctx.skills` | -| `skill-local/` | Project/custom/user filesystem provider | (registers on `ctx.skills`) | -| `tool-skill/` | Session-prefix catalog and model-facing `skill` loader | (registers on `ctx.tools`) | +| `skill/` | Provider registry, precedence resolution, complete/incomplete catalog snapshots, and full-definition lookup | `ctx.skills` | +| `skill-local/` | Project/custom/user filesystem provider with membership watching | (registers on `ctx.skills`) | +| `tool-skill/` | Initial and replacement catalogs plus the model-facing `skill` loader | (registers on `ctx.tools`) | The interface lives at `skill/skill/`. Providers register synchronously and perform asynchronous discovery through `ctx.skills`; `tool-skill` consumes only that interface, so an embedded or remote provider can replace or complement `skill-local` without changing the model-facing contract. `agent-core` loads this family by default, but it remains a capability outside the core control spine, parallel to [`bash/`](../bash/README.md), [`fs/`](../fs/README.md), [`web/`](../web/README.md), and [`subagent/`](../subagent/README.md). diff --git a/packages/skill/README.zh.md b/packages/skill/README.zh.md index d219f710c0..6f10c3e907 100644 --- a/packages/skill/README.zh.md +++ b/packages/skill/README.zh.md @@ -6,8 +6,8 @@ | 包 | 职责 | ctx 键 | |---|---|---| -| `skill/` | 提供方注册表、优先级解析、稳定目录快照和完整定义查找 | `ctx.skills` | -| `skill-local/` | 项目/自定义/用户文件系统提供方 | (注册到 `ctx.skills`) | -| `tool-skill/` | 会话前缀目录和面向模型的 `skill` 加载器 | (注册到 `ctx.tools`) | +| `skill/` | 提供方注册表、优先级解析、完整/不完整目录快照和完整定义查找 | `ctx.skills` | +| `skill-local/` | 带目录成员关系监视的项目/自定义/用户文件系统提供方 | (注册到 `ctx.skills`) | +| `tool-skill/` | 初始目录和替换目录,以及面向模型的 `skill` loader | (注册到 `ctx.tools`) | 接口位于 `skill/skill/`。提供方同步注册,并通过 `ctx.skills` 执行异步发现;`tool-skill` 只消费该接口,因此嵌入式或远程提供方可替换或补充 `skill-local`,无需改变面向模型的契约。`agent-core` 默认加载该家族,但它仍然是核心控制主干之外的功能,与 [`bash/`](../bash/README.md)、[`fs/`](../fs/README.md)、[`web/`](../web/README.md) 和 [`subagent/`](../subagent/README.md) 并列。 diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml index 603c1e0c90..79ff24a116 100644 --- a/packages/skill/skill-local/README.i18n.yaml +++ b/packages/skill/skill-local/README.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 -README.md: c488fdc4b1d97b5aa1113e41a470484063526ded -README.zh.md: 796c814a3c4064d545a966465caf6f99e9dd8601 +# pnpm run verify-translation-pairing --write packages/skill/skill-local/README.md +README.md: 0e25e5a964902d84edbd2ca53f4e63b5f9c21065 +README.zh.md: 19b1091ec08eec9f844f339cdd0226309735ca09 diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index c488fdc4b1..0e25e5a964 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -17,6 +17,12 @@ Requires `ctx.skills` (`inject: ['skills']`). | `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md); scans `skills` under this directory. | | `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. | | `customSkillDirs` | `[]` | Additional local skill roots scanned after project roots and before user roots. | +| `watch` | `true` | Watch host-local roots and invalidate the local provider when catalog membership or frontmatter may have changed. | +| `watchUsePolling` | `false` | Use Chokidar polling instead of native events for existing skill roots. | +| `watchStabilityThresholdMs` | `200` | Stable-write window for Chokidar `add` and `change` events. | +| `watchPollIntervalMs` | `100` | Chokidar polling/stability interval and missing-path probe interval. | +| `watchMaxProjects` | `128` | Maximum distinct project roots retained in the watcher LRU. | +| `watchFollowSymlinks` | `true` | Follow symbolic links while watching existing roots. | ## Discovery @@ -32,23 +38,34 @@ Default roots are resolved in this provider's rank order: The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. This provider supplies project and user skills; another provider may supply built-in system skills. -When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Missing, unreadable, or malformed skill files warn and skip instead of failing the whole request. +When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Confirmed missing paths are valid empty state, malformed or non-text entries warn and skip, and unexpected discovery/read failures make the registry snapshot incomplete rather than replacing a last-good model catalog with a misleading deletion. + +## Catalog Change Detection + +Existing skill roots are watched with Chokidar. The provider observes direct bundle directory additions/removals, flat Markdown additions/removals, and direct `SKILL.md` additions/removals/changes; `change` exists to rediscover catalog frontmatter such as `name` and `description`. Changes below `references`, `scripts`, `assets`, or other bundle resources do not invalidate the catalog. Events delivered in the same microtask batch collapse to one provider invalidation. + +A root that does not exist is followed from the nearest existing ancestor one missing path segment at a time. The next segment is probed with `fs.watchFile`; once `.agents`, `skills`, or the configured root appears, observation advances until Chokidar can attach to the real root. Root deletion reverses this process, so deleting and recreating an entire skills directory remains observable. Project-scoped watchers are bounded by `watchMaxProjects`; revisiting an evicted project reattaches observation during discovery. + +The first-party filesystem `write` and `edit` tools also synchronously invalidate the provider through `fs/observed` when their target could affect a watched skill entry. This fast path makes the next model step observe its own filesystem mutation without waiting for the host watcher. External IDE, Git, shell, and process changes rely on Chokidar or the missing-path probe. Startup/runtime watcher failures are logged, make the current provider observation incomplete, and are retried; effect teardown closes every watcher and contains late callbacks. ## Skill Format Skills can be single-level directory bundles (`/SKILL.md`) or flat Markdown files (`.md`). Nested `**/SKILL.md` discovery is intentionally not part of v1. Frontmatter is parsed as YAML with the `yaml` package; it requires `name` and `description`, while `whenToUse`, `disableModelInvocation`, and `metadata` are optional. Names must be kebab-case. +The catalog and body have separate lifecycles. Discovery parses frontmatter to produce the summary. Every `skill(name)` load rereads and reparses the current file, so body edits need no hash, revision, cache invalidation, or proactive model notification. A frontmatter rename between discovery and loading rejects the stale name and invalidates the provider; the next catalog observation publishes the new name. + ## Model Experience -Indirectly, through `dsh-tool-skill`, which renders this provider's invocable names and capped descriptions into the session-prefix catalog and a selected instruction body plus resource-base guidance into retained tool history while paths, provider ranks, and disabled skills remain hidden. +Indirectly, through `dsh-tool-skill`, which renders this provider's invocable names and capped descriptions into the initial or replacement catalog and a selected current instruction body plus resource-base guidance into retained tool history while paths, provider ranks, and disabled skills remain hidden. #### KV Cache effect -No direct invalidation; the named consumer owns any request-prefix changes. +Watcher invalidation can cause the named consumer to append a replacement catalog after the reusable session prefix. Body-only edits leave the catalog digest unchanged. ## Known Limitations and Deferred Work - **Discovery is one level deep** — only `//SKILL.md` and `/.md` are recognized; nested skill trees and package manifests are ignored. - **Project scope is the nearest `.git` ancestor** — workspaces without that marker fall back to the supplied cwd, with no alternate project-root marker or monorepo subproject selection. -- **Unreadable or malformed entries disappear with a warning** — the model catalog receives no per-skill diagnostic and cannot distinguish an absent skill from a skipped one. -- **No filesystem watching** — edits rely on the registry cache being evicted or invalidated by provider reload before a previously collected cwd is rediscovered. +- **Malformed entries disappear with a warning** — the model catalog receives no per-skill diagnostic and cannot distinguish an absent skill from an invalid one; unexpected I/O failures preserve the last-good catalog instead. +- **Missing-root observation polls one path segment** — roots absent at startup use `fs.watchFile` at `watchPollIntervalMs` until Chokidar can attach, trading bounded detection latency for reliable creation detection across IDE, Git, and shell workflows. +- **No body revision protocol** — a loaded body is ordinary retained tool history; later file edits affect later calls but neither rewrite old results nor announce that the body changed. diff --git a/packages/skill/skill-local/README.zh.md b/packages/skill/skill-local/README.zh.md index 796c814a3c..19b1091ec0 100644 --- a/packages/skill/skill-local/README.zh.md +++ b/packages/skill/skill-local/README.zh.md @@ -17,6 +17,12 @@ | `dshHome` | `$DSH_HOME` or `~/.dsh` | 由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 DeepSeek Harness 配置根;扫描该目录下的 `skills`。 | | `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | 为兼容 skill 扫描的共享 agent 配置根。 | | `customSkillDirs` | `[]` | 在项目根之后、用户根之前扫描的其他本地 skill 根。 | +| `watch` | `true` | 监视宿主本地根,并在目录成员或 frontmatter 可能发生变化时使本地提供方失效。 | +| `watchUsePolling` | `false` | 对现有 skill 根使用 Chokidar 轮询,而不是原生事件。 | +| `watchStabilityThresholdMs` | `200` | Chokidar `add` 和 `change` 事件的稳定写入窗口。 | +| `watchPollIntervalMs` | `100` | Chokidar 轮询/稳定性间隔和缺失路径探测间隔。 | +| `watchMaxProjects` | `128` | watcher LRU 中保留的不同项目根数量上限。 | +| `watchFollowSymlinks` | `true` | 监视现有根时跟随符号链接。 | ## 发现 @@ -32,23 +38,34 @@ 项目根是包含 `.git` 的最近祖先;如果不存在,则使用当前 cwd。用户 DSH 根会跳过其 `.system` 子级,因此系统所有目录不会被当作普通用户 skill。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 -当 `ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O,使最小本地上下文仍能加载 skill。缺失、不可读或格式错误的 skill 文件会警告并跳过,而不会使整个请求失败。 +当 `ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O,使最小本地上下文仍能加载 skill。已确认缺失的路径属于有效空状态;格式错误或非文本条目会警告并跳过;意外的发现或读取失败会使注册表快照不完整,系统不会因此用看似发生删除的结果替换上一份可用模型目录。 + +## 目录变更检测 + +现有 skill 根由 Chokidar 监视。提供方会观察直属 bundle 目录的添加/移除、平铺 Markdown 文件的添加/移除,以及直接 `SKILL.md` 的添加/移除/变更;`change` 事件用于重新发现 `name`、`description` 等目录 frontmatter。`references`、`scripts`、`assets` 或其他 bundle 资源下的变更不会使目录失效。同一微任务批次内送达的事件会合并为一次提供方失效。 + +不存在的根会从最近的现有祖先开始,每次沿一个缺失路径段跟踪。系统使用 `fs.watchFile` 探测下一段;当 `.agents`、`skills` 或已配置的根出现后,观察会逐级推进,直至 Chokidar 可以附加到真实根。根删除时,该过程反向执行,因此删除再重建整个 skills 目录仍可被观察到。按项目划分的 watcher 数量受 `watchMaxProjects` 限制;再次访问已被驱逐的项目时,发现阶段会重新附加观察。 + +如果第一方文件系统 `write` 和 `edit` 工具的目标可能影响受监视的 skill 条目,它们还会通过 `fs/observed` 同步使提供方失效。这条快速路径让模型的下一个步骤无需等待宿主 watcher,即可观察到自身的文件系统变更。外部 IDE、Git、shell 和进程产生的变更依赖 Chokidar 或缺失路径探测。watcher 启动或运行时失败会被记录,使提供方的当前观察不完整,并触发重试;effect 释放会关闭所有 watcher,并收束延迟回调。 ## Skill 格式 Skill 可以是单层目录 bundle(`/SKILL.md`),也可以是平铺 Markdown 文件(`.md`)。v1 刻意不包含嵌套 `**/SKILL.md` 发现。Frontmatter 使用 `yaml` 包解析为 YAML;它要求 `name` 和 `description`,而 `whenToUse`、`disableModelInvocation` 和 `metadata` 可选。名称必须使用 kebab-case。 +目录与正文具有独立的生命周期。发现阶段解析 frontmatter 以生成概述。每次 `skill(name)` 加载都会重新读取并解析当前文件,因此正文编辑不需要 hash、修订号、缓存失效或主动通知模型。若在发现与加载之间重命名 frontmatter,系统会拒绝陈旧名称并使提供方失效;下一次目录观察会发布新名称。 + ## 模型体验 -通过 `dsh-tool-skill` 间接影响模型。它将该提供方的可调用名称和有上限描述渲染到会话前缀目录中,并将所选指令正文与资源基底指引渲染到已保留工具历史中;路径、提供方 rank 和已禁用 skill 仍被隐藏。 +通过 `dsh-tool-skill` 间接影响模型。它将该提供方的可调用名称和有上限描述渲染到初始目录或替换目录中,并将所选当前指令正文与资源基底指引渲染到已保留工具历史中;路径、提供方 rank 和已禁用 skill 仍被隐藏。 #### KV 缓存影响 -不直接导致失效;指定的消费方负责其引起的任何请求前缀变更。 +watcher 触发的失效可促使指定的消费方在可复用会话前缀之后追加替换目录。仅涉及正文的编辑不会改变目录 digest。 ## 已知限制与待完成工作 - **发现深度为一层**:只识别 `//SKILL.md` 和 `/.md`;忽略嵌套 skill 树和包 manifest。 - **项目范围为最近 `.git` 祖先**:没有该标记的工作区回退到提供的 cwd,不支持其他项目根标记或 monorepo 子项目选择。 -- **不可读或格式错误的条目会随警告消失**:模型目录不会收到每个 skill 的诊断,无法区分缺失的 skill 与被跳过的 skill。 -- **无文件系统 watcher**:先前已收集 cwd 重新发现之前,编辑操作依赖注册表缓存被驱逐,或因提供方重新加载而失效。 +- **格式错误的条目会随警告消失**:模型目录不会收到每个 skill 的诊断,无法区分缺失的 skill 与无效的 skill;意外 I/O 失败则会保留最后一份可用目录。 +- **缺失根观察每次轮询一个路径段**:启动时不存在的根会使用 `fs.watchFile` 按 `watchPollIntervalMs` 轮询,直至 Chokidar 可以附加;这以有界检测延迟换取跨 IDE、Git 和 shell 工作流的可靠创建检测。 +- **无正文修订协议**:已加载的正文是普通的已保留工具历史;后续文件编辑会影响后续调用,但既不会改写旧结果,也不会通知正文已发生变化。 diff --git a/packages/skill/skill-local/package.json b/packages/skill/skill-local/package.json index 1bc655fb39..8331a94a97 100644 --- a/packages/skill/skill-local/package.json +++ b/packages/skill/skill-local/package.json @@ -34,6 +34,7 @@ "cordis": "^4.0.0-rc.7" }, "dependencies": { + "chokidar": "^5.0.0", "schemastery": "^3.18.0", "yaml": "^2.4.2" }, diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 2c5e480e2a..a20f42b1b5 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -10,9 +10,11 @@ */ import { access, readdir, readFile, stat } from 'node:fs/promises' -import { dirname, join, resolve } from 'node:path' +import { unwatchFile, watchFile, type Stats } from 'node:fs' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { homedir } from 'node:os' import type { Context } from 'cordis' +import chokidar from 'chokidar' import z from 'schemastery' import type Schema from 'schemastery' import { parse as parseYaml } from 'yaml' @@ -32,6 +34,9 @@ const PROJECT_AGENTS_RANK = 200 const CUSTOM_RANK = 300 const USER_DSH_RANK = 400 const USER_AGENTS_RANK = 500 +const DEFAULT_WATCH_STABILITY_THRESHOLD_MS = 200 +const DEFAULT_WATCH_POLL_INTERVAL_MS = 100 +const DEFAULT_WATCH_MAX_PROJECTS = 128 export const name = 'skill-local' export const inject = ['skills'] @@ -44,12 +49,30 @@ export interface Config { agentsHome?: string /** Additional skill roots scanned after project roots and before user roots. */ customSkillDirs?: string[] + /** Whether host-local skill roots are watched for catalog changes. */ + watch?: boolean + /** Whether Chokidar uses polling instead of native filesystem events. */ + watchUsePolling?: boolean + /** Milliseconds a changed skill entry must remain stable before it is observed. */ + watchStabilityThresholdMs?: number + /** Milliseconds between Chokidar stability or polling probes. */ + watchPollIntervalMs?: number + /** Maximum distinct project roots whose skill directories remain watched. */ + watchMaxProjects?: number + /** Whether watched symbolic links follow their target files. */ + watchFollowSymlinks?: boolean } export const Config: Schema = z.object({ dshHome: z.string(), agentsHome: z.string(), customSkillDirs: z.array(z.string()).default([]), + watch: z.boolean().default(true), + watchUsePolling: z.boolean().default(false), + watchStabilityThresholdMs: z.number().default(DEFAULT_WATCH_STABILITY_THRESHOLD_MS), + watchPollIntervalMs: z.number().default(DEFAULT_WATCH_POLL_INTERVAL_MS), + watchMaxProjects: z.number().default(DEFAULT_WATCH_MAX_PROJECTS), + watchFollowSymlinks: z.boolean().default(true), }) interface SkillRoot { @@ -57,6 +80,7 @@ interface SkillRoot { source: SkillSource rank: number skipSystem?: boolean + projectRoot?: string } interface SkillRootEntry { @@ -79,10 +103,26 @@ interface LocalLocator { directory: string } +interface ResolvedWatchConfig { + enabled: boolean + usePolling: boolean + stabilityThresholdMs: number + pollIntervalMs: number + maxProjects: number + followSymlinks: boolean +} + /** Register the local filesystem skill provider on `ctx.skills`. */ export function apply(ctx: Context, config: Config = {}): void { const provider = new LocalSkillProvider(ctx, config) ctx.skills.registerProvider(provider) + ctx.effect(function* () { + yield async () => { await provider.dispose() } + }, 'skill-local watcher') + ctx.on('fs/observed', (target, _version, actor) => { + if (mutationToolName(actor) === undefined) return + provider.observeHostMutation(target.displayPath) + }) } /** Provider that maps local project/user skill roots into `ctx.skills`. */ @@ -91,11 +131,13 @@ export class LocalSkillProvider implements SkillProvider { private readonly dshHome: string private readonly agentsHome: string private readonly customSkillDirs: string[] + private readonly watchManager: SkillWatchManager constructor(private readonly ctx: Context, config: Config = {}) { this.dshHome = resolveDshHome(config.dshHome) this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents')) this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root)) + this.watchManager = new SkillWatchManager(ctx, this, resolveWatchConfig(config)) } /** @@ -105,6 +147,7 @@ export class LocalSkillProvider implements SkillProvider { */ async list(options: SkillLookupOptions): Promise { const roots = await this.roots(options.cwd) + await this.watchManager.observeRoots(roots) const candidates: SkillCandidate[] = [] for (const root of roots) { for (const skill of await discoverRoot(root, this.ctx)) { @@ -138,13 +181,26 @@ export class LocalSkillProvider implements SkillProvider { } } + /** + * Invalidate this provider synchronously after a first-party filesystem mutation. + * @param path - host display path observed after a model-facing write or edit. + */ + observeHostMutation(path: string): void { + this.watchManager.observeHostMutation(path) + } + + /** Close every host watcher and contain late filesystem callbacks. */ + async dispose(): Promise { + await this.watchManager.dispose() + } + private async roots(cwd: string | undefined): Promise { const roots: SkillRoot[] = [] if (cwd !== undefined) { const projectRoot = await findProjectRoot(resolve(cwd), optionalFileSystem(this.ctx)) roots.push( - { path: join(projectRoot, '.dsh/skills'), source: 'project-dsh', rank: PROJECT_DSH_RANK }, - { path: join(projectRoot, '.agents/skills'), source: 'project-agents', rank: PROJECT_AGENTS_RANK }, + { path: join(projectRoot, '.dsh/skills'), source: 'project-dsh', rank: PROJECT_DSH_RANK, projectRoot }, + { path: join(projectRoot, '.agents/skills'), source: 'project-agents', rank: PROJECT_AGENTS_RANK, projectRoot }, ) } roots.push( @@ -156,6 +212,405 @@ export class LocalSkillProvider implements SkillProvider { } } +type SkillWatchEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir' + +type RootWatchMode = + | { kind: 'root'; anchor: string } + | { kind: 'ancestor'; anchor: string; nextPath: string } + +interface RootWatchState { + root: SkillRoot + owners: Set + watcher: WatchHandle | undefined + opening: Promise | undefined + unhealthy: boolean +} + +interface WatchHandle { + close(): Promise | void +} + +/** Owns bounded host watchers while discovery and reads remain on the filesystem service. */ +class SkillWatchManager { + private readonly roots = new Map() + private readonly projects = new Map>() + private closing = false + private invalidationQueued = false + + constructor( + private readonly ctx: Context, + private readonly provider: SkillProvider, + private readonly config: ResolvedWatchConfig, + ) {} + + async observeRoots(roots: readonly SkillRoot[]): Promise { + if (this.closing) return + const projectRoots = new Map() + const pending: Promise[] = [] + for (const root of roots) { + if (root.projectRoot === undefined) { + pending.push(this.retainRoot(root, `shared:${root.path}`)) + continue + } + const grouped = projectRoots.get(root.projectRoot) ?? [] + grouped.push(root) + projectRoots.set(root.projectRoot, grouped) + } + for (const [projectRoot, grouped] of projectRoots) { + const owner = `project:${projectRoot}` + this.projects.delete(projectRoot) + const paths = new Set(grouped.map(root => root.path)) + this.projects.set(projectRoot, paths) + for (const root of grouped) pending.push(this.retainRoot(root, owner)) + } + let evictedProject = false + while (this.projects.size > this.config.maxProjects) { + const oldest = this.projects.entries().next() + /* v8 ignore next -- the loop condition proves one project exists. */ + if (oldest.done) break + const [projectRoot, paths] = oldest.value + this.projects.delete(projectRoot) + const owner = `project:${projectRoot}` + for (const path of paths) pending.push(this.releaseRoot(path, owner)) + evictedProject = true + } + await Promise.all(pending) + if (evictedProject) this.ctx.skills.invalidateProvider(this.provider) + } + + observeHostMutation(path: string): void { + if (this.closing) return + const normalized = resolve(path) + if (![...this.roots.values()].some(state => isPotentialSkillPath(state.root, normalized))) return + this.ctx.skills.invalidateProvider(this.provider) + } + + async dispose(): Promise { + if (this.closing) return + this.closing = true + const states = [...this.roots.values()] + this.roots.clear() + this.projects.clear() + await Promise.all(states.map(async (state) => { + await settleWatcherOpening(state.opening) + const watcher = state.watcher + state.watcher = undefined + if (watcher !== undefined) await this.closeWatcher(watcher) + })) + } + + private async retainRoot(root: SkillRoot, owner: string): Promise { + let state = this.roots.get(root.path) + if (state === undefined) { + state = { root, owners: new Set(), watcher: undefined, opening: undefined, unhealthy: true } + this.roots.set(root.path, state) + } + state.owners.add(owner) + if (this.config.enabled) await this.ensureWatcher(state) + } + + private async releaseRoot(path: string, owner: string): Promise { + const state = this.roots.get(path) + /* v8 ignore next -- Concurrent cwd observations can evict the same shared root before this release settles. */ + if (state === undefined) return + state.owners.delete(owner) + if (state.owners.size > 0) return + this.roots.delete(path) + await settleWatcherOpening(state.opening) + const watcher = state.watcher + state.watcher = undefined + if (watcher !== undefined) await this.closeWatcher(watcher) + } + + private ensureWatcher(state: RootWatchState): Promise { + if (this.closing || !this.config.enabled) return Promise.resolve() + if (state.watcher !== undefined && !state.unhealthy) return Promise.resolve() + if (state.opening !== undefined) return state.opening + const opening = this.replaceWatcher(state) + state.opening = opening + void opening.then( + () => { + state.opening = undefined + }, + () => { + state.opening = undefined + }, + ) + return opening + } + + private async replaceWatcher(state: RootWatchState): Promise { + const previous = state.watcher + state.watcher = undefined + if (previous !== undefined) await this.closeWatcher(previous) + /* v8 ignore next -- Teardown can win while an unhealthy watcher is still closing. */ + if (this.closing || state.owners.size === 0) return + try { + const watcher = await this.openStableWatcher(state) + /* v8 ignore next -- The loop returns no handle only when teardown wins between awaited probes. */ + if (watcher === undefined) return + /* v8 ignore start -- Post-open teardown is timing-dependent; the disposal race has an explicit integration test. */ + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- teardown can race awaited watcher startup + if (this.closing || state.owners.size === 0) { + await this.closeWatcher(watcher) + return + } + /* v8 ignore stop */ + state.watcher = watcher + state.unhealthy = false + } catch (error) { + state.unhealthy = true + this.ctx.logger.warn(`skill-local: failed to watch ${state.root.path}: ${errorMessage(error)}`) + throw error + } + } + + private async openStableWatcher(state: RootWatchState): Promise { + while (!this.closing && state.owners.size > 0) { + const mode = await resolveRootWatchMode(state.root.path) + const watcher = mode.kind === 'ancestor' + ? this.openAncestorWatcher(state, mode) + : await this.openRootWatcher(state, mode) + const current = await resolveRootWatchMode(state.root.path) + /* v8 ignore else -- A host path transition between the two probes is timing-dependent. */ + if (sameWatchMode(mode, current)) return watcher + /* v8 ignore next -- Covered by the same host path transition guard. */ + await this.closeWatcher(watcher) + } + /* v8 ignore next -- The loop exits only when teardown wins between awaited probes. */ + return undefined + } + + private openAncestorWatcher(state: RootWatchState, mode: Extract): WatchHandle { + const listener = (_current: Stats, _previous: Stats): void => { + this.handleWatchEvent(state, mode, 'change', mode.nextPath) + } + watchFile(mode.nextPath, { + persistent: false, + interval: this.config.pollIntervalMs, + }, listener) + return { + close() { + unwatchFile(mode.nextPath, listener) + }, + } + } + + private async openRootWatcher(state: RootWatchState, mode: Extract): Promise { + const watcher = chokidar.watch(mode.anchor, { + persistent: false, + ignoreInitial: true, + depth: 1, + followSymlinks: this.config.followSymlinks, + atomic: true, + awaitWriteFinish: { + stabilityThreshold: this.config.stabilityThresholdMs, + pollInterval: this.config.pollIntervalMs, + }, + usePolling: this.config.usePolling, + interval: this.config.pollIntervalMs, + }) + let ready = false + const readiness = Promise.withResolvers() + const onError = (error: unknown): void => { + if (!ready) { + readiness.reject(error) + return + } + this.handleWatcherError(state, error) + } + watcher.on('error', onError) + watcher.once('ready', () => { + ready = true + readiness.resolve(undefined) + }) + for (const event of ['add', 'addDir', 'change', 'unlink', 'unlinkDir'] as const) { + watcher.on(event, (path) => { this.handleWatchEvent(state, mode, event, path) }) + } + try { + await readiness.promise + } catch (error) { + await this.closeWatcher(watcher) + throw error + } + return watcher + } + + private handleWatchEvent( + state: RootWatchState, + mode: RootWatchMode, + event: SkillWatchEvent, + path: string, + ): void { + if (this.closing || !isRelevantWatchEvent(state.root, mode, event, resolve(path))) return + this.queueInvalidation() + if (mode.kind === 'ancestor' || (resolve(path) === state.root.path && event === 'unlinkDir')) { + state.unhealthy = true + this.scheduleRewatch(state) + } + } + + private handleWatcherError(state: RootWatchState, error: unknown): void { + if (this.closing) return + this.ctx.logger.warn(`skill-local: watcher for ${state.root.path} failed: ${errorMessage(error)}`) + state.unhealthy = true + this.queueInvalidation() + this.scheduleRewatch(state) + } + + private scheduleRewatch(state: RootWatchState): void { + const currentOpening = state.opening ?? Promise.resolve() + void (async () => { + await settleWatcherOpening(currentOpening) + try { + await this.ensureWatcher(state) + } catch { + // Watch startup logged the retry failure; the next incomplete discovery retries it again. + return + } + this.queueInvalidation() + })() + } + + private queueInvalidation(): void { + if (this.closing || this.invalidationQueued) return + this.invalidationQueued = true + queueMicrotask(() => { + this.invalidationQueued = false + if (this.closing) return + this.ctx.skills.invalidateProvider(this.provider) + }) + } + + private async closeWatcher(watcher: WatchHandle): Promise { + try { + await watcher.close() + } catch (error) { + this.ctx.logger.warn(`skill-local: failed to close watcher: ${errorMessage(error)}`) + } + } +} + +async function settleWatcherOpening(opening: Promise | undefined): Promise { + if (opening === undefined) return + try { + await opening + } catch { + // Watch startup already logged the underlying failure; teardown only contains it. + } +} + +function resolveWatchConfig(config: Config): ResolvedWatchConfig { + const stabilityThresholdMs = config.watchStabilityThresholdMs ?? DEFAULT_WATCH_STABILITY_THRESHOLD_MS + const pollIntervalMs = config.watchPollIntervalMs ?? DEFAULT_WATCH_POLL_INTERVAL_MS + const maxProjects = config.watchMaxProjects ?? DEFAULT_WATCH_MAX_PROJECTS + assertPositiveInteger('watchStabilityThresholdMs', stabilityThresholdMs) + assertPositiveInteger('watchPollIntervalMs', pollIntervalMs) + assertPositiveInteger('watchMaxProjects', maxProjects) + return { + enabled: config.watch ?? true, + usePolling: config.watchUsePolling ?? false, + stabilityThresholdMs, + pollIntervalMs, + maxProjects, + followSymlinks: config.watchFollowSymlinks ?? true, + } +} + +async function resolveRootWatchMode(root: string): Promise { + let candidate = root + while (true) { + try { + const info = await stat(candidate) + if (info.isDirectory()) { + if (candidate === root) return { kind: 'root', anchor: root } + const firstSegment = relative(candidate, root).split(sep)[0] + /* v8 ignore next -- candidate is a strict ancestor of root. */ + if (firstSegment === undefined || firstSegment.length === 0) return { kind: 'root', anchor: root } + return { kind: 'ancestor', anchor: candidate, nextPath: join(candidate, firstSegment) } + } + } catch (error) { + /* v8 ignore next -- Non-absence stat failures are platform/permission-specific and propagate as incomplete discovery. */ + if (!isAbsentPathError(error)) throw error + } + const parent = dirname(candidate) + /* v8 ignore next -- Traversal reaches the existing filesystem root before this fallback. */ + if (parent === candidate) return { kind: 'ancestor', anchor: candidate, nextPath: root } + candidate = parent + } +} + +function sameWatchMode(left: RootWatchMode, right: RootWatchMode): boolean { + return left.kind === right.kind + && left.anchor === right.anchor + && (left.kind === 'root' || (right.kind === 'ancestor' && left.nextPath === right.nextPath)) +} + +function isRelevantWatchEvent( + root: SkillRoot, + mode: RootWatchMode, + event: SkillWatchEvent, + path: string, +): boolean { + if (mode.kind === 'ancestor') { + return path === mode.nextPath + } + const segments = containedSegments(root.path, path) + if (segments === undefined) return false + if (segments.length === 0) return event === 'addDir' || event === 'unlinkDir' + if (root.skipSystem === true && segments[0] === '.system') return false + if (segments.length === 1) { + if (event === 'addDir' || event === 'unlinkDir') return true + return segments[0]?.endsWith('.md') === true + } + return segments.length === 2 + && segments[1] === 'SKILL.md' + && event !== 'addDir' + && event !== 'unlinkDir' +} + +function isPotentialSkillPath(root: SkillRoot, path: string): boolean { + const segments = containedSegments(root.path, path) + if (segments === undefined || segments.length === 0 || segments.length > 2) return false + if (root.skipSystem === true && segments[0] === '.system') return false + return segments.length === 1 + ? segments[0]?.endsWith('.md') === true + : segments[1] === 'SKILL.md' +} + +function containedSegments(root: string, path: string): string[] | undefined { + const child = relative(root, path) + if (child.length === 0) return [] + if (child === '..' || child.startsWith(`..${sep}`) || isAbsolute(child)) return undefined + return child.split(sep) +} + +function mutationToolName(actor: object | undefined): 'edit' | 'write' | undefined { + if (actor === undefined || !('name' in actor)) return undefined + const value = actor.name + return value === 'edit' || value === 'write' ? value : undefined +} + +function assertPositiveInteger(field: string, value: number): void { + if (!Number.isInteger(value) || value < 1) { + throw new TypeError(`skill-local: ${field} must be a positive integer`) + } +} + +function isAbsentPathError(error: unknown): boolean { + return hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR') +} + +function isAbsentSkillPathError(error: unknown): boolean { + return isAbsentPathError(error) + || hasErrorCode(error, 'FS_NOT_FOUND') + || hasErrorCode(error, 'FS_NOT_DIRECTORY') +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} + async function discoverRoot(root: SkillRoot, ctx: Context): Promise { const skills: SkillCandidate[] = [] const entries = await listSkillRootEntries(root, ctx) @@ -193,9 +648,12 @@ async function listSkillRootEntries(root: SkillRoot, ctx: Context): Promise { - // Skill roots are optional; an absent or unlistable root contributes no skills. - const entries = await fsListDir(fs, root.path).catch(() => undefined) - return entries === undefined ? [] : entries.map(entryFromFs) + try { + return (await fsListDir(fs, root.path)).map(entryFromFs) + } catch (error) { + if (isAbsentSkillPathError(error)) return [] + throw error + } } async function fsListDir(fs: FileSystem, path: string): Promise { @@ -211,9 +669,11 @@ async function listSkillRootEntriesFromNode(root: SkillRoot, ctx: Context): Prom let entries try { entries = await readdir(root.path, { withFileTypes: true, encoding: 'utf8' }) - } catch { - // Missing or unreadable local skill roots are expected in most deployments. - return [] + } catch (error) { + /* v8 ignore else -- Native non-absence directory failures are provider-dependent; the ctx.fs path pins incomplete discovery. */ + if (isAbsentSkillPathError(error)) return [] + /* v8 ignore next -- Same native error branch as above. */ + throw error } const result: SkillRootEntry[] = [] @@ -274,31 +734,39 @@ async function readSkillText(ctx: Context, path: string, signal?: AbortSignal): } try { return await readFile(path, { encoding: 'utf8', signal }) - } catch { + } catch (error) { signal?.throwIfAborted() - return undefined + if (isAbsentSkillPathError(error)) return undefined + throw error } } async function readSkillTextFromFileSystem(ctx: Context, fs: FileSystem, path: string, signal?: AbortSignal): Promise { // A missing or temporarily inaccessible skill file is not fatal to discovery. signal?.throwIfAborted() - const target = await fs.resolve(path).catch(() => undefined) + let target + try { + target = await fs.resolve(path) + } catch (error) { + if (isAbsentSkillPathError(error)) return undefined + throw error + } signal?.throwIfAborted() - if (target === undefined) return undefined let info try { info = await fs.stat(target, signal) } catch (error) { signal?.throwIfAborted() - ctx.logger.warn(`skill file ${path} ignored: failed to stat through filesystem service: ${errorMessage(error)}`) - return undefined + if (isAbsentSkillPathError(error)) return undefined + throw error } if (info === undefined || info.type !== 'file') return undefined try { return await fs.readText(target, signal) } catch (error) { signal?.throwIfAborted() + if (isAbsentSkillPathError(error)) return undefined + if (!hasErrorCode(error, 'FS_NOT_TEXT')) throw error ctx.logger.warn(`skill file ${path} ignored: ${fsReadErrorMessage(target, error)}`) return undefined } diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts new file mode 100644 index 0000000000..5cf5de8460 --- /dev/null +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -0,0 +1,220 @@ +import { EventEmitter } from 'node:events' +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import SkillService from '@deepseek-ai/dsh-skill' + +interface FakeWatcherControl { + emitter: EventEmitter + closeCalls: number + options: Record +} + +const watcherHarness = vi.hoisted(() => ({ + watchers: [] as FakeWatcherControl[], + startupErrors: [] as Error[], + closeErrors: 0, + deferredReady: 0, +})) + +vi.mock('chokidar', () => ({ + default: { + watch(_path: unknown, options: Record) { + const emitter = new EventEmitter() as EventEmitter & { close(): Promise } + const control: FakeWatcherControl = { emitter, closeCalls: 0, options } + emitter.close = async () => { + control.closeCalls += 1 + if (watcherHarness.closeErrors > 0) { + watcherHarness.closeErrors -= 1 + throw new Error('close failed') + } + } + watcherHarness.watchers.push(control) + queueMicrotask(() => { + if (watcherHarness.deferredReady > 0) { + watcherHarness.deferredReady -= 1 + return + } + const error = watcherHarness.startupErrors.shift() + if (error === undefined) emitter.emit('ready') + else emitter.emit('error', error) + }) + return emitter + }, + }, +})) + +const SkillLocal = await import('../src/index.ts') + +async function tempDir(name: string): Promise { + return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`))) +} + +async function writeSkill(root: string, name: string): Promise { + const directory = join(root, name) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'SKILL.md'), `---\nname: ${name}\ndescription: ${name}\n---\n\nBody.\n`) +} + +async function settle(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)) +} + +beforeEach(() => { + watcherHarness.watchers.length = 0 + watcherHarness.startupErrors.length = 0 + watcherHarness.closeErrors = 0 + watcherHarness.deferredReady = 0 +}) + +describe('skill-local watcher failures', () => { + it('marks a startup failure incomplete and retries discovery without caching it', async () => { + const home = await tempDir('skill-watch-start-error') + const root = join(home, '.dsh/skills') + await writeSkill(root, 'retry-skill') + watcherHarness.startupErrors.push(new Error('watch failed')) + watcherHarness.closeErrors = 1 + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchUsePolling: true, + watchFollowSymlinks: false, + watchPollIntervalMs: 10, + watchStabilityThresholdMs: 20, + }) + + expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false }) + expect(await ctx.skills.snapshot()).toMatchObject({ + skills: [{ name: 'retry-skill' }], + complete: true, + }) + expect(watcherHarness.watchers).toHaveLength(2) + expect(watcherHarness.watchers[1]?.options).toMatchObject({ + atomic: true, + depth: 1, + followSymlinks: false, + usePolling: true, + interval: 10, + awaitWriteFinish: { + stabilityThreshold: 20, + pollInterval: 10, + }, + }) + + await fiber.dispose() + }) + + it('filters events, coalesces invalidation, recovers runtime errors, and contains late callbacks', async () => { + const home = await tempDir('skill-watch-runtime-error') + const root = join(home, '.dsh/skills') + await writeSkill(root, 'watched-skill') + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchPollIntervalMs: 10, + watchStabilityThresholdMs: 20, + }) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['watched-skill']) + const invalidateProvider = ctx.skills.invalidateProvider.bind(ctx.skills) + let invalidations = 0 + ctx.skills.invalidateProvider = (provider) => { + invalidations += 1 + invalidateProvider(provider) + } + const first = watcherHarness.watchers[0] + if (first === undefined) throw new Error('expected a root watcher') + + first.emitter.emit('change', join(root, 'notes.txt')) + first.emitter.emit('change', join(home, 'outside.md')) + first.emitter.emit('change', join(root, 'watched-skill/references.md')) + first.emitter.emit('change', join(root, '.system/SKILL.md')) + await settle() + expect(invalidations).toBe(0) + + first.emitter.emit('change', join(root, 'watched-skill/SKILL.md')) + first.emitter.emit('change', join(root, 'watched-skill/SKILL.md')) + await settle() + expect(invalidations).toBe(1) + + watcherHarness.closeErrors = 1 + watcherHarness.startupErrors.push(new Error('runtime rewatch failed')) + first.emitter.emit('error', new Error('runtime watch failed')) + await settle() + await settle() + expect(watcherHarness.watchers.length).toBeGreaterThanOrEqual(2) + expect(invalidations).toBeGreaterThanOrEqual(2) + expect(await ctx.skills.snapshot()).toMatchObject({ + skills: [{ name: 'watched-skill' }], + complete: true, + }) + + await fiber.dispose() + first.emitter.emit('change', join(root, 'watched-skill/SKILL.md')) + first.emitter.emit('error', new Error('late error')) + await settle() + }) + + it('settles an opening watcher when plugin disposal races its ready event', async () => { + const home = await tempDir('skill-watch-opening-dispose') + const root = join(home, '.dsh/skills') + await writeSkill(root, 'racing-skill') + watcherHarness.deferredReady = 1 + const ctx = new Context() + await ctx.plugin(SkillService) + const provider = new SkillLocal.LocalSkillProvider(ctx, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchPollIntervalMs: 10, + watchStabilityThresholdMs: 20, + }) + ctx.skills.registerProvider(provider) + + const discovery = provider.list({}) + await settle() + const first = watcherHarness.watchers[0] + if (first === undefined) throw new Error('expected an opening root watcher') + first.emitter.emit('unlinkDir', root) + const disposal = provider.dispose() + first.emitter.emit('ready') + + await Promise.all([discovery, disposal]) + await settle() + expect(first.closeCalls).toBeGreaterThan(0) + }) + + it('contains an opening watcher rejection during provider teardown', async () => { + const home = await tempDir('skill-watch-opening-reject') + const root = join(home, '.dsh/skills') + await writeSkill(root, 'rejected-skill') + watcherHarness.deferredReady = 1 + const ctx = new Context() + await ctx.plugin(SkillService) + const provider = new SkillLocal.LocalSkillProvider(ctx, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchPollIntervalMs: 10, + watchStabilityThresholdMs: 20, + }) + ctx.skills.registerProvider(provider) + + const discovery = provider.list({}) + await settle() + const first = watcherHarness.watchers[0] + if (first === undefined) throw new Error('expected an opening root watcher') + const disposal = provider.dispose() + first.emitter.emit('error', new Error('opening failed during disposal')) + + await expect(discovery).rejects.toThrow('opening failed during disposal') + await disposal + }) +}) diff --git a/packages/skill/skill-local/tests/skill-local.spec.ts b/packages/skill/skill-local/tests/skill-local.spec.ts index 0c7d473b13..470d39b630 100644 --- a/packages/skill/skill-local/tests/skill-local.spec.ts +++ b/packages/skill/skill-local/tests/skill-local.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' -import { mkdir, readdir, readFile, stat, symlink, writeFile } from 'node:fs/promises' +import { mkdir, readdir, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' import SkillService from '@deepseek-ai/dsh-skill' -import { FileSystem, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsPathInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs' +import { FileSystem, FsError, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsPathInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs' import * as SkillLocal from '../src/index.ts' async function tempDir(name: string): Promise { @@ -26,19 +26,26 @@ class TestFileSystem extends FileSystem { listDirCalls = 0 failResolvePaths = new Set() failStatPaths = new Set() + failListDirPaths = new Set() + errorResolvePaths = new Set() + errorStatPaths = new Set() + errorReadPaths = new Set() + missingReadPaths = new Set() statOverrides = new Map() statSignals: Array = [] readTextSignals: Array = [] readTextOverride?: (target: FsTarget, signal?: AbortSignal) => Promise override async resolve(path: string): Promise { - if (this.failResolvePaths.has(path)) throw new Error('resolve failed') + if (this.failResolvePaths.has(path)) throw new FsError('resolve failed', 'FS_NOT_FOUND') + if (this.errorResolvePaths.has(path)) throw new Error('resolve temporarily failed') return { targetKey: path as never, displayPath: path } } override async stat(target: FsTarget, signal?: AbortSignal): Promise { this.statSignals.push(signal) - if (this.failStatPaths.has(target.displayPath)) throw new Error('stat failed') + if (this.failStatPaths.has(target.displayPath)) throw new FsError('stat failed', 'FS_NOT_FOUND') + if (this.errorStatPaths.has(target.displayPath)) throw new Error('stat temporarily failed') if (this.statOverrides.has(target.displayPath)) return this.statOverrides.get(target.displayPath) try { const fs = await import('node:fs/promises') @@ -70,8 +77,10 @@ class TestFileSystem extends FileSystem { override async readText(target: FsTarget, signal?: AbortSignal): Promise { this.readTextSignals.push(signal) if (this.readTextOverride !== undefined) return await this.readTextOverride(target, signal) + if (this.missingReadPaths.has(target.displayPath)) throw new FsError('read failed', 'FS_NOT_FOUND') + if (this.errorReadPaths.has(target.displayPath)) throw new Error('read temporarily failed') const text = await readFile(target.displayPath, 'utf8') - if (text.includes('\uFFFD')) throw new Error('not text') + if (text.includes('\uFFFD')) throw new FsError('not text', 'FS_NOT_TEXT') return text } @@ -81,6 +90,7 @@ class TestFileSystem extends FileSystem { override async listDir(target: FsTarget): Promise { this.listDirCalls += 1 + if (this.failListDirPaths.has(target.displayPath)) throw new Error('list temporarily failed') const entries = await readdir(target.displayPath, { withFileTypes: true, encoding: 'utf8' }) const result: FsDirEntry[] = [] for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { @@ -122,11 +132,22 @@ async function setupLocal(home: string, config: Partial = {}) await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), + watch: false, ...config, }) return ctx } +async function waitFor(read: () => Promise, accept: (value: T) => boolean): Promise { + const deadline = Date.now() + 5000 + while (true) { + const value = await read() + if (accept(value)) return value + if (Date.now() >= deadline) throw new Error('timed out waiting for watcher state') + await new Promise(resolve => setTimeout(resolve, 20)) + } +} + describe('dsh-skill-local plugin exports', () => { it('declares stable plugin metadata', () => { expect(SkillLocal.name).toBe('skill-local') @@ -224,7 +245,7 @@ describe('LocalSkillProvider', () => { const listedBeforeDelete = await ctx.skills.list() const flatSummary = listedBeforeDelete.find(skill => skill.name === 'flat-skill') if (flatSummary === undefined) throw new Error('expected flat-skill') - await writeFile(join(root, 'flat-skill.md'), '') + await rm(join(root, 'flat-skill.md')) expect(listedBeforeDelete.map(skill => skill.name)).toEqual(['flat-skill', 'no-trailing-body', 'rich-skill']) expect(await ctx.skills.get('flat-skill')).toBeUndefined() @@ -327,7 +348,7 @@ describe('LocalSkillProvider', () => { size: 0, }) await ctx.plugin(SkillService) - await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') }) + await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) expect((await ctx.skills.list({ cwd: nestedCwd })).map(skill => [skill.name, skill.source])).toEqual([ ['backend-root', 'project-agents'], @@ -337,6 +358,92 @@ describe('LocalSkillProvider', () => { expect(await ctx.skills.get('binary-skill')).toBeUndefined() }) + it('reports transient root reads as incomplete without caching an empty catalog', async () => { + const home = await tempDir('skill-transient-root') + const root = join(home, '.agents/skills') + await writeSkill(root, 'stable-skill', 'Stable skill') + const ctx = new Context() + await ctx.plugin(TestFileSystem) + const fs = ctx.fs as TestFileSystem + await ctx.plugin(SkillService) + await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: false, + }) + + expect(await ctx.skills.snapshot()).toMatchObject({ + skills: [{ name: 'stable-skill' }], + complete: true, + }) + fs.failListDirPaths.add(root) + const path = join(root, 'stable-skill/SKILL.md') + ctx.emit( + 'fs/observed', + { targetKey: path as never, displayPath: path }, + FsVersion('failed-read'), + { name: 'edit' }, + ) + expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false }) + + fs.failListDirPaths.clear() + expect(await ctx.skills.snapshot()).toMatchObject({ + skills: [{ name: 'stable-skill' }], + complete: true, + }) + }) + + it('distinguishes transient filesystem entry failures from confirmed disappearance', async () => { + const home = await tempDir('skill-transient-entry') + const root = join(home, '.agents/skills') + const path = join(root, 'stable-skill/SKILL.md') + await writeSkill(root, 'stable-skill', 'Stable skill') + const ctx = new Context() + await ctx.plugin(TestFileSystem) + const fs = ctx.fs as TestFileSystem + await ctx.plugin(SkillService) + await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: false, + }) + const invalidate = (): void => { + ctx.emit( + 'fs/observed', + { targetKey: path as never, displayPath: path }, + FsVersion('entry-failure'), + { name: 'write' }, + ) + } + + expect((await ctx.skills.snapshot()).complete).toBe(true) + for (const failures of [fs.errorResolvePaths, fs.errorStatPaths, fs.errorReadPaths]) { + failures.add(path) + invalidate() + expect((await ctx.skills.snapshot()).complete).toBe(false) + failures.clear() + } + + fs.missingReadPaths.add(path) + invalidate() + expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true }) + fs.missingReadPaths.clear() + invalidate() + expect(await ctx.skills.snapshot()).toMatchObject({ + skills: [{ name: 'stable-skill' }], + complete: true, + }) + }) + + it('marks an unexpected native skill-file read failure incomplete', async () => { + const home = await tempDir('skill-native-read-failure') + const root = join(home, '.agents/skills') + await mkdir(join(root, 'broken-skill/SKILL.md'), { recursive: true }) + const ctx = await setupLocal(home) + + expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false }) + }) + it('forwards cancellation to filesystem reads while loading a skill', async () => { const home = await tempDir('skill-read-abort') await writeSkill(join(home, '.dsh/skills'), 'abortable-skill', 'Abortable skill') @@ -345,7 +452,7 @@ describe('LocalSkillProvider', () => { await ctx.plugin(TestFileSystem) const fs = ctx.fs as TestFileSystem await ctx.plugin(SkillService) - await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') }) + await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['abortable-skill']) fs.statSignals = [] @@ -372,6 +479,219 @@ describe('LocalSkillProvider', () => { expect(fs.readTextSignals).toEqual([controller.signal]) }) + it('refreshes additions, metadata changes, deletions, and a recreated missing root', { timeout: 20000 }, async () => { + const home = await tempDir('skill-watch-home') + const agentsRoot = join(home, '.agents/skills') + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchStabilityThresholdMs: 20, + watchPollIntervalMs: 10, + }) + try { + expect(await ctx.skills.list()).toEqual([]) + + await writeSkill(agentsRoot, 'watched-skill', 'First description', 'First body.') + const added = await waitFor( + async () => await ctx.skills.list(), + skills => skills.some(skill => skill.name === 'watched-skill'), + ) + expect(added.find(skill => skill.name === 'watched-skill')?.description).toBe('First description') + + await writeSkill(agentsRoot, 'watched-skill', 'Second description', 'Second body.') + const changed = await waitFor( + async () => await ctx.skills.list(), + skills => skills.find(skill => skill.name === 'watched-skill')?.description === 'Second description', + ) + expect(changed).toHaveLength(1) + expect((await ctx.skills.get('watched-skill'))?.content).toBe('Second body.') + + await writeFlatSkill(agentsRoot, 'flat-added', 'Flat added') + expect(await waitFor( + async () => (await ctx.skills.list()).map(skill => skill.name), + names => names.includes('flat-added'), + )).toEqual(['flat-added', 'watched-skill']) + + await rename(join(agentsRoot, 'watched-skill'), join(agentsRoot, 'renamed-skill')) + await writeSkill(agentsRoot, 'renamed-skill', 'Renamed skill') + expect(await waitFor( + async () => (await ctx.skills.list()).map(skill => skill.name), + names => names.includes('renamed-skill') && !names.includes('watched-skill'), + )).toEqual(['flat-added', 'renamed-skill']) + + await rm(join(agentsRoot, 'renamed-skill'), { recursive: true }) + expect(await waitFor( + async () => (await ctx.skills.list()).map(skill => skill.name), + names => !names.includes('renamed-skill'), + )).toEqual(['flat-added']) + + await rm(join(home, '.agents'), { recursive: true }) + expect(await waitFor( + async () => await ctx.skills.list(), + skills => skills.length === 0, + )).toEqual([]) + + await writeSkill(agentsRoot, 'recreated-skill', 'Recreated') + expect(await waitFor( + async () => (await ctx.skills.list()).map(skill => skill.name), + names => names.includes('recreated-skill'), + )).toEqual(['recreated-skill']) + } finally { + await fiber.dispose() + } + + }) + + it('uses fs/observed as a synchronous first-party invalidation path without a watcher', async () => { + const home = await tempDir('skill-observed-home') + const root = join(home, '.agents/skills') + const ctx = await setupLocal(home) + expect(await ctx.skills.list()).toEqual([]) + const invalidateProvider = ctx.skills.invalidateProvider.bind(ctx.skills) + let invalidations = 0 + ctx.skills.invalidateProvider = (provider) => { + invalidations += 1 + invalidateProvider(provider) + } + + await writeSkill(root, 'observed-skill', 'Observed skill') + const path = join(root, 'observed-skill/SKILL.md') + const emitObserved = (displayPath: string, actor?: object): void => { + ctx.emit( + 'fs/observed', + { targetKey: displayPath as never, displayPath }, + FsVersion('observed'), + actor, + ) + } + emitObserved(path) + emitObserved(path, {}) + emitObserved(path, { name: 'read' }) + emitObserved(join(home, 'outside.md'), { name: 'write' }) + emitObserved(root, { name: 'write' }) + emitObserved(join(root, 'observed-skill/references/notes.md'), { name: 'write' }) + emitObserved(join(home, '.dsh/skills/.system/SKILL.md'), { name: 'write' }) + emitObserved(join(root, 'flat-skill.md'), { name: 'write' }) + ctx.emit( + 'fs/observed', + { targetKey: path as never, displayPath: path }, + FsVersion('observed'), + { name: 'edit' }, + ) + + expect(invalidations).toBe(2) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['observed-skill']) + }) + + it('bounds project watchers and re-observes an evicted project on its next lookup', async () => { + const home = await tempDir('skill-watch-lru-home') + const first = await tempDir('skill-watch-lru-first') + const second = await tempDir('skill-watch-lru-second') + await mkdir(join(first, '.git'), { recursive: true }) + await mkdir(join(second, '.git'), { recursive: true }) + await writeSkill(join(first, '.agents/skills'), 'first-project', 'First project') + await writeSkill(join(second, '.agents/skills'), 'second-project', 'Second project') + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + customSkillDirs: [join(first, '.agents/skills')], + watch: true, + watchMaxProjects: 1, + watchStabilityThresholdMs: 20, + watchPollIntervalMs: 10, + }) + try { + expect((await ctx.skills.list({ cwd: first })).map(skill => skill.name)).toContain('first-project') + expect((await ctx.skills.list({ cwd: second })).map(skill => skill.name)).toContain('second-project') + await writeSkill(join(first, '.agents/skills'), 'first-project', 'First project refreshed') + + expect((await ctx.skills.list({ cwd: first })).find(skill => skill.name === 'first-project')?.description) + .toBe('First project refreshed') + } finally { + await fiber.dispose() + } + + const noWatch = new Context() + await noWatch.plugin(SkillService) + await noWatch.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: false, + watchMaxProjects: 1, + }) + await noWatch.skills.list({ cwd: first }) + await noWatch.skills.list({ cwd: second }) + }) + + it('contains repeated disposal and late first-party observations', async () => { + const home = await tempDir('skill-watch-dispose') + const nonDirectoryRoot = join(home, 'not-a-directory') + await writeFile(nonDirectoryRoot, 'not a skill root') + await writeSkill(join(home, '.agents/skills'), 'disposed-skill', 'Disposed skill') + const ctx = new Context() + await ctx.plugin(SkillService) + const provider = new SkillLocal.LocalSkillProvider(ctx, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + customSkillDirs: [nonDirectoryRoot], + watch: true, + watchStabilityThresholdMs: 20, + watchPollIntervalMs: 10, + }) + ctx.skills.registerProvider(provider) + expect((await provider.list({})).map(skill => skill.name)).toEqual(['disposed-skill']) + + await provider.dispose() + await provider.dispose() + provider.observeHostMutation(join(home, '.agents/skills/disposed-skill/SKILL.md')) + + expect((await provider.list({})).map(skill => skill.name)).toEqual(['disposed-skill']) + }) + + it('refreshes frontmatter through a followed skill symlink', { timeout: 10000 }, async () => { + const home = await tempDir('skill-watch-symlink-home') + const external = await tempDir('skill-watch-symlink-external') + const root = join(home, '.dsh/skills') + await writeSkill(external, 'linked-skill', 'First linked description') + await mkdir(root, { recursive: true }) + await symlink(join(external, 'linked-skill'), join(root, 'linked-skill')) + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchFollowSymlinks: true, + watchStabilityThresholdMs: 20, + watchPollIntervalMs: 10, + }) + try { + expect((await ctx.skills.list())[0]?.description).toBe('First linked description') + await writeSkill(external, 'linked-skill', 'Second linked description') + const refreshed = await waitFor( + async () => await ctx.skills.list(), + skills => skills[0]?.description === 'Second linked description', + ) + expect(refreshed[0]?.name).toBe('linked-skill') + } finally { + await fiber.dispose() + } + }) + + it('validates watcher tunables at plugin load', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + + await expect(ctx.plugin(SkillLocal, { watchMaxProjects: 0 })).rejects.toThrow('watchMaxProjects') + await expect(ctx.plugin(SkillLocal, { watchPollIntervalMs: 1.5 })).rejects.toThrow('watchPollIntervalMs') + await expect(ctx.plugin(SkillLocal, { watchStabilityThresholdMs: 0 })).rejects.toThrow('watchStabilityThresholdMs') + }) + it('uses default home root resolution without exposing builtin skills', async () => { const previousDshHome = process.env.DSH_HOME const previousAgentsHome = process.env.DSH_AGENTS_HOME @@ -382,14 +702,14 @@ describe('LocalSkillProvider', () => { await writeSkill(join(envHome, '.dsh/skills'), 'env-skill', 'Env skill') const ctx = new Context() await ctx.plugin(SkillService) - await ctx.plugin(SkillLocal) + await ctx.plugin(SkillLocal, { watch: false }) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-skill']) process.env.DSH_HOME = join(envHome, 'empty-dsh') process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents') const empty = new Context() await empty.plugin(SkillService) - SkillLocal.apply(empty, {}) + SkillLocal.apply(empty, { watch: false }) expect(await empty.skills.list()).toEqual([]) delete process.env.DSH_AGENTS_HOME diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index d9e7e42df7..f9adb2f558 100644 --- a/packages/skill/skill/README.i18n.yaml +++ b/packages/skill/skill/README.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 -README.md: 639616d0b75f960e9ccd48546d44db841372bbe2 -README.zh.md: 3afdd415397927ebf107d6f862422c711a51888b +# pnpm run verify-translation-pairing --write packages/skill/skill/README.md +README.md: f4f933576c60c8d750e3bd8eb0183ccb4b37e2da +README.zh.md: baa0d0ca43a8da3e3177d5c05437501a1fbe1735 diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 639616d0b7..f4f933576c 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -11,10 +11,16 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ### Public API - `ctx.skills.registerProvider(provider): () => void` Registers a readonly provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry borrows the provider object and invokes its methods directly. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown. +- `ctx.skills.invalidateProvider(provider): void` Marks one exact live provider dirty and clears completed catalog caches. Calls from a disposed or replaced provider instance are no-ops, so late watcher callbacks cannot invalidate its replacement. +- `ctx.skills.snapshot({ cwd?, signal? })` Returns `{ skills, complete }`. `complete` is false when any provider failed transiently; incomplete observations are never cached, so a model-facing consumer can retain its last-good catalog and retry at the next request boundary. - `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns model-invocable summaries for the current workspace, merged across providers and sorted by name. - `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it, including disabled-for-model skills. - `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown. +### Events + +- `skills/change` is an unfiltered invalidation notification emitted after a provider or runtime contribution is registered or disposed and after `invalidateProvider()` accepts an exact live provider. It carries no catalog or diff: each consumer refetches `snapshot()` with its own lookup options. Listener throws and rejected promises are logged and cannot veto the registry mutation or starve later listeners. + ### Config | Field | Default | Meaning | @@ -27,7 +33,9 @@ A provider registers synchronously and performs remote setup, authentication, an The registry validates candidates before caching and definitions before returning them. The winning provider receives the same candidate and opaque `locator` it returned from `list()`, allowing backend-specific file, URL, id, or version handles. Callers and providers must preserve the readonly contract. -Contract violations fail fast. A rejected `list()` is treated as a transient source failure: it is logged, skipped, and not cached. Only completed catalogs are cached; a provider or runtime revision change discards an in-flight result and retries. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name. +Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure: its entries are omitted from that observation, `complete` is false, and the result is not cached. A provider or runtime revision change discards an in-flight result and retries before returning. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name. + +Definitions remain progressively loaded. `get()` asks the winning provider for the body on every call rather than caching it in this registry. If the returned definition has a different name from the selected candidate, the stale selection is rejected and that exact provider is invalidated so the next snapshot rediscovers its catalog. ## Runtime Skills @@ -39,15 +47,15 @@ The registry does not render model guidance or register model-facing tools. [`@d ## Model Experience -Indirectly, through `dsh-tool-skill`, which renders provider summaries into the session prefix and loaded instructions into retained tool results. +Indirectly, through `dsh-tool-skill`, which renders provider summaries into the initial session prefix or durable replacement catalog messages and loaded instructions into retained tool results. #### KV Cache effect -No direct invalidation; the named consumer owns any request-prefix changes. +No direct prompt effect. The named consumer owns initial prefix composition and append-only catalog replacements after invalidation. ## Known Limitations and Deferred Work -- **Completed catalogs have no TTL or watcher invalidation** — a provider's underlying files or remote data can change without a registration revision, so a cached cwd stays stale until eviction or provider/runtime reload. +- **Invalidation is provider-driven** — the registry has no TTL and cannot infer that an arbitrary remote source changed; each mutable provider must call `invalidateProvider()` from its own observation mechanism. - **Providers are queried sequentially** — one slow cooperative provider delays every provider registered after it; cancellation stops the caller's wait but cannot terminate work an uncooperative provider keeps running. -- **A provider-list failure removes that whole source for the request** — the registry logs and skips it, with no model-visible diagnostic or partial-catalog recovery contract. +- **An incomplete snapshot omits the failing provider in that observation** — the registry reports `complete: false`, but it does not own a last-good catalog or a per-provider diagnostic; consumers choose whether to retain earlier state. - **Duplicate resolution is first-wins** — later lower-priority candidates are logged and hidden; there is no API to inspect all shadowed definitions. diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md index 3afdd41539..baa0d0ca43 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -11,10 +11,16 @@ ### 公开 API - `ctx.skills.registerProvider(provider): () => void` 使用唯一 `provider.name` 注册只读提供方。重复提供方名称会抛错,`runtime` 保留给 `ctx.skills.register(...)`。注册表借用提供方对象,并直接调用其方法。注册作用域绑定到 effect,可安全用于 HMR;精确的 Cordis disposer 支持有序组合拆卸。 +- `ctx.skills.invalidateProvider(provider): void` 按实例精确标脏一个活动提供方,并清除已完成目录缓存。已释放或已被替换的提供方实例调用此方法时不执行任何操作,因此延迟到达的 watcher 回调无法使其替代项失效。 +- `ctx.skills.snapshot({ cwd?, signal? })` 返回 `{ skills, complete }`。任一提供方发生瞬时失败时,`complete` 为 false;不完整观测绝不缓存,使面向模型的消费方可以保留上一份可用目录,并在下一个请求边界重试。 - `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中模型可调用的摘要;这些摘要跨提供方合并,并按名称排序。 - `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后将其返回,包括已对模型禁用的 skill。 - `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。 +### 事件 + +- `skills/change` 是一条不带过滤条件的失效通知,在提供方或运行时贡献注册或释放后,以及 `invalidateProvider()` 接受精确活动提供方后发出。它不携带目录或 diff;每个消费方都使用自身的查找选项重新获取 `snapshot()`。监听器抛错或 Promise 拒绝会被记录,既不能否决注册表变更,也不能阻止后续监听器执行。 + ### 配置 | 字段 | 默认值 | 含义 | @@ -27,7 +33,9 @@ 注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读契约。 -契约违反会快速失败。被拒绝的 `list()` 视为瞬时来源失败:系统记录它、跳过它,并且不缓存。只缓存已完成目录;提供方或运行时修订变更会丢弃正在进行的结果并重试。重复名称按 rank、提供方注册顺序,然后按提供方本地顺序解析。摘要按 skill 名称排序。 +契约违反会快速失败。提供方 `list()` 被拒绝会视为瞬时来源失败:该次观测会省略其条目,`complete` 为 false,结果也不会缓存。提供方或运行时修订发生变更时,会丢弃正在进行的结果并重试后再返回。重复名称按 rank、提供方注册顺序,然后按提供方本地顺序解析。摘要按 skill 名称排序。 + +定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并使该提供方实例失效,以便下一次快照重新发现其目录。 ## 运行时 Skill @@ -39,15 +47,15 @@ ## 模型体验 -通过 `dsh-tool-skill` 间接影响模型;该包将提供方摘要渲染到会话前缀中,并将已加载指令渲染到已保留工具结果中。 +通过 `dsh-tool-skill` 间接影响模型;该包将提供方摘要渲染到初始会话前缀或持久的替换目录消息中,并将已加载指令渲染到已保留工具结果中。 #### KV 缓存影响 -不直接导致失效;指定的消费方负责其引起的任何请求前缀变更。 +不直接影响提示词。指定的消费方负责初始前缀组装,以及失效后的仅追加式目录替换。 ## 已知限制与待完成工作 -- **已完成目录没有 TTL 或 watcher 失效机制**:提供方的底层文件或远程数据可在注册修订不变的情况下更改,因此已缓存 cwd 会保持陈旧,直到被驱逐或重新加载提供方/运行时。 +- **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须由自身的观测机制调用 `invalidateProvider()`。 - **提供方依次查询**:一个缓慢的协作提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不协作提供方持续运行的工作。 -- **提供方列表失败会移除该请求的整个来源**:注册表会记录并跳过它,不提供模型可见诊断或部分目录恢复契约。 +- **不完整快照会在该次观测中省略失败的提供方**:注册表会报告 `complete: false`,但不负责上一份可用目录或逐提供方诊断;消费方选择是否保留先前状态。 - **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。 diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index f736fc8d0f..aa078280a8 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -87,6 +87,14 @@ export interface SkillLookupOptions { readonly signal?: AbortSignal | undefined } +/** One catalog observation plus whether every registered provider completed discovery. */ +export interface SkillCatalogSnapshot { + /** Sorted model-invocable summaries from providers that completed. */ + readonly skills: SkillSummary[] + /** Whether every registered provider completed discovery for this observation. */ + readonly complete: boolean +} + /** Provider interface for one source of skills, such as local directories or a remote registry. */ export interface SkillProvider { /** Unique provider name in the `ctx.skills` registry. */ @@ -119,6 +127,17 @@ declare module 'cordis' { interface Context { skills: SkillService } + + interface Events { + /** + * A skill provider, runtime contribution, or provider-backed catalog may + * have changed. This is an unfiltered invalidation notification; consumers + * refetch the catalog for their own lookup options. Listener failures are + * contained and cannot veto the registry mutation. + * @mode emit + */ + 'skills/change'(): void + } } interface IndexedCandidate { @@ -189,6 +208,17 @@ export class SkillService extends Service { return dispose } + /** + * Invalidate catalogs contributed by one currently registered provider. Exact object identity + * prevents a late callback from an old provider instance from invalidating its replacement. + * Calls for an already-unregistered provider are harmless. + * @param provider - exact provider instance whose external source changed. + */ + invalidateProvider(provider: SkillProvider): void { + if (this.providers.get(provider.name)?.provider !== provider) return + this.invalidateCache() + } + /** * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and @@ -228,11 +258,26 @@ export class SkillService extends Service { * @returns sorted summaries, excluding skills disabled for model invocation. */ async list(options: SkillLookupOptions = {}): Promise { - return (await this.collect(options)) - .map(entry => entry.candidate) - .filter(skill => skill.disableModelInvocation !== true) - .map(toSummary) - .sort(compareSkillSummary) + return (await this.snapshot(options)).skills + } + + /** + * Observe the current model-invocable catalog and whether all providers completed discovery. + * Incomplete observations are never cached, allowing consumers to retain last-good state and + * retry on their next request boundary. + * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @returns sorted summaries plus provider-completeness state. + */ + async snapshot(options: SkillLookupOptions = {}): Promise { + const collected = await this.collect(options) + return { + skills: collected.entries + .map(entry => entry.candidate) + .filter(skill => skill.disableModelInvocation !== true) + .map(toSummary) + .sort(compareSkillSummary), + complete: collected.cacheable, + } } /** @@ -247,7 +292,7 @@ export class SkillService extends Service { if (!isSkillName(name)) return undefined const collected = await this.collect(options) throwIfAborted(options.signal) - const match = collected.find(entry => entry.candidate.name === name) + const match = collected.entries.find(entry => entry.candidate.name === name) if (match === undefined) return undefined const definition = await waitWithAbort( match.provider.get(match.candidate, options), @@ -255,17 +300,21 @@ export class SkillService extends Service { ) if (definition === undefined) return undefined validateDefinition(definition) + if (definition.name !== match.candidate.name) { + this.invalidateProvider(match.provider) + return undefined + } return definition } - private async collect(options: SkillLookupOptions): Promise { + private async collect(options: SkillLookupOptions): Promise { throwIfAborted(options.signal) while (true) { const providerRevision = this.providerRevision const runtimeRevision = this.runtimeRevision const key = collectCacheKey(options, providerRevision, runtimeRevision) const cached = this.collectCache.get(key) - if (cached !== undefined) return cached + if (cached !== undefined) return { entries: cached, cacheable: true } const result = await this.collectFresh(options) throwIfAborted(options.signal) @@ -277,7 +326,7 @@ export class SkillService extends Service { this.collectCache.delete(oldest.value) } } - return result.entries + return result } } @@ -339,6 +388,21 @@ export class SkillService extends Service { private invalidateCache(): void { this.providerRevision += 1 this.collectCache.clear() + this.notifyChange() + } + + /** Notify catalog observers without making their refresh work load-bearing. */ + private notifyChange(): void { + for (const callback of this.ctx.events.dispatch('emit', ['skills/change'])) { + try { + const returned: unknown = callback() + void Promise.resolve(returned).catch((error: unknown) => { + this.ctx.logger.warn(`skills/change listener rejected: ${errorMessage(error)}`) + }) + } catch (error: unknown) { + this.ctx.logger.warn(`skills/change listener threw: ${errorMessage(error)}`) + } + } } } diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index 8195d418cb..2f0134eab7 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -555,7 +555,9 @@ describe('SkillService registry', () => { return undefined }, }) - expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill']) + const incomplete = await ctx.skills.snapshot() + expect(incomplete.skills.map(skill => skill.name)).toEqual(['second-skill']) + expect(incomplete.complete).toBe(false) expect(flakyCalls).toBe(1) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill']) expect(flakyCalls).toBe(2) @@ -566,6 +568,156 @@ describe('SkillService registry', () => { expect(flakyCalls).toBe(3) }) + it('invalidates only the exact registered provider and ignores its late callbacks', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)]) + const dispose = ctx.skills.registerProvider(provider) + + expect((await ctx.skills.snapshot()).complete).toBe(true) + provider.replace([memorySkill('second-skill', 'Second', 10)]) + ctx.skills.invalidateProvider(new MemoryProvider([])) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill']) + + ctx.skills.invalidateProvider(provider) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill']) + dispose() + + const replacement = new MemoryProvider([memorySkill('replacement-skill', 'Replacement', 10)]) + ctx.skills.registerProvider(replacement) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill']) + ctx.skills.invalidateProvider(provider) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill']) + expect(replacement.listCalls).toBe(1) + }) + + it('emits catalog invalidations for live provider and runtime mutations', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const provider = new MemoryProvider([memorySkill('provider-skill', 'Provider', 10)]) + let changes = 0 + ctx.on('skills/change', () => { changes += 1 }) + + const disposeProvider = ctx.skills.registerProvider(provider) + expect(changes).toBe(1) + ctx.skills.invalidateProvider(new MemoryProvider([])) + expect(changes).toBe(1) + ctx.skills.invalidateProvider(provider) + expect(changes).toBe(2) + + const disposeRuntime = ctx.skills.register({ + name: 'runtime-skill', + description: 'Runtime', + source: 'runtime', + content: 'Runtime body.', + }) + expect(changes).toBe(3) + disposeRuntime() + expect(changes).toBe(4) + disposeProvider() + expect(changes).toBe(5) + ctx.skills.invalidateProvider(provider) + expect(changes).toBe(5) + }) + + it('contains synchronous and asynchronous catalog observer failures', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const disposeThrowing = ctx.on('skills/change', () => { throw new Error('observer threw') }) + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- deliberate rejection proves notification containment + const disposeRejecting = ctx.on('skills/change', () => Promise.reject(new Error('observer rejected'))) + let observed = 0 + const disposeObserver = ctx.on('skills/change', () => { observed += 1 }) + + const provider = new MemoryProvider([]) + expect(() => ctx.skills.registerProvider(provider)).not.toThrow() + await Promise.resolve() + expect(observed).toBe(1) + expect(warnings).toEqual([ + 'skills/change listener threw: Error: observer threw', + 'skills/change listener rejected: Error: observer rejected', + ]) + + disposeThrowing() + disposeRejecting() + disposeObserver() + }) + + it('retries an in-flight catalog invalidated by its provider', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + let release: (() => void) | undefined + const started = Promise.withResolvers() + const gate = new Promise((resolve) => { release = resolve }) + const provider = new MemoryProvider([memorySkill('stale-skill', 'Stale', 10)]) + const originalList = provider.list.bind(provider) + provider.list = async (options) => { + if (provider.listCalls === 0) { + provider.listCalls += 1 + started.resolve(undefined) + await gate + return [memorySkill('stale-skill', 'Stale', 10)] + } + return await originalList(options) + } + ctx.skills.registerProvider(provider) + + const pending = ctx.skills.list() + await started.promise + provider.replace([memorySkill('fresh-skill', 'Fresh', 10)]) + ctx.skills.invalidateProvider(provider) + release?.() + + expect((await pending).map(skill => skill.name)).toEqual(['fresh-skill']) + expect(provider.listCalls).toBe(2) + }) + + it('invalidates a provider whose loaded definition changed identity', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + let listCalls = 0 + const provider: SkillProvider = { + name: 'renamed', + async list() { + listCalls += 1 + return [{ + name: 'old-name', + description: 'Old name', + provider: 'renamed', + source: 'test', + rank: 1, + locator: 'old-name', + }] + }, + async get(candidate) { + return { ...candidate, name: 'new-name', content: 'Fresh body.' } + }, + } + ctx.skills.registerProvider(provider) + + expect(await ctx.skills.get('old-name')).toBeUndefined() + await ctx.skills.list() + expect(listCalls).toBe(2) + }) + + it('returns undefined when a discovered candidate disappears before loading', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + ctx.skills.registerProvider({ + name: 'vanished-body', + async list() { + return [{ ...memorySkill('vanished-skill', 'Vanished', 10), provider: 'vanished-body' }] + }, + async get() { + return undefined + }, + }) + + await expect(ctx.skills.get('vanished-skill')).resolves.toBeUndefined() + }) + it('contains a provider rejection whose string coercion throws', async () => { const ctx = new Context() await ctx.plugin(SkillService) diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index 868bdd0344..ccfbdc59fe 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/README.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 -README.md: 50a0e06ac06ca8a3b89c3d5ac604dcf2dc423533 -README.zh.md: 56fb2b87adcf072cf2b8b6670864fa274ed5f66f +# pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md +README.md: 7cbb2bef77bd32f188a4d3068a287fbee31b9371 +README.zh.md: fe17e2e7080151e9bd41d4e0b96764719b27baa5 diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index 50a0e06ac0..7cbb2bef77 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -4,13 +4,17 @@ English | [中文](README.zh.md) The model-facing skill catalog and `skill` tool. -Requires `ctx.tools` and `ctx.skills` (`inject: ['tools', 'skills']`). +Requires `ctx.agents`, `ctx.tools`, and `ctx.skills` (`inject: ['agents', 'tools', 'skills']`). -## Session-prefix catalog +## Catalog lifecycle -The plugin contributes one user-role `` catalog through `agent/session-prefix`. It resolves skills for the calling session's cwd, forwards the prefix abort signal to discovery, and lists only sorted `name` and `description` entries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. The catalog is omitted when no model-invocable skills are available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. This exact-definition check keeps prompt guidance, the model-visible schema, and executable dispatch aligned. +The plugin contributes the initial user-role `` catalog through `agent/session-prefix`. Before every later model step it observes `ctx.skills.snapshot()` and computes a digest over exact `skill` tool visibility plus the ordered rendered `name` and `description` entries. It resolves skills for the calling session's cwd and lists only those summaries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. -`catalogDescriptionMaxLength` controls normalized, XML-escaped catalog descriptions. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [session-prefix Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md) defines the request-only, header-logged lifecycle of this message. +When that digest changes, `agent.inject()` records a durable user-role message containing the complete replacement catalog and metadata `{ kind: 'skill-catalog', version: 1, digest }`. An empty replacement explicitly retires names from earlier catalogs. The latest still-visible metadata supplies the comparison baseline across replay or plugin reload. If compaction shadows that replacement, the next pre-step falls back to the loop's initial-prefix baseline and re-establishes the current catalog when needed. An incomplete provider snapshot emits nothing and preserves the last-good model view for retry on the next step. If no prior catalog exists and the current view is empty, no tombstone is necessary. + +The catalog is omitted when no model-invocable skills are initially available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. Visibility changes participate in the digest, keeping prompt guidance, model-visible schema, and executable dispatch aligned. + +`catalogDescriptionMaxLength` controls normalized, XML-escaped catalog descriptions. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [session-prefix Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md) defines the request-only, header-logged lifecycle of the initial message; the [skill catalog hot-refresh Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) owns durable replacements. ## Tool: `skill` @@ -24,7 +28,7 @@ Resource guidance resolves only paths or URLs explicitly referenced by the instr An unresolved name reports that the skill is unknown or no longer available. Invalid names and `disableModelInvocation: true` skills produce distinct error results. -The tool does not call `agent.inject()` in v1. Its result is already recorded as the tool result and becomes available to the next model step without duplicating the content as synthetic context. +Tool execution does not call `agent.inject()`. Its freshly loaded result is already recorded as the tool result and becomes available to the next model step without duplicating the body as synthetic context. Only the catalog projection injects replacement summaries. ## Model Experience @@ -32,7 +36,7 @@ The tool does not call `agent.inject()` in v1. Its result is already recorded as #### What the model sees -If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below, with one data-dependent entry per sorted skill. The catalog is a frozen user-role session prefix. +If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below, with one data-dependent entry per sorted skill. The initial catalog is a user-role session prefix. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. ##### Skill catalog template @@ -50,11 +54,11 @@ If the user names a skill, or the task clearly matches a skill's description, ca #### Token effect -Repeated input cost scales with skill count and `catalogDescriptionMaxLength`; no catalog tokens are sent when the list is empty or the tool is hidden or shadowed. +Repeated input cost scales with skill count and `catalogDescriptionMaxLength`; no initial catalog tokens are sent when the list is empty or the tool is hidden or shadowed. Each actual catalog change adds one retained complete replacement message. #### KV Cache effect -Prefix-stable within a loop instance once the session prefix is composed. A new or resumed instance with different providers, skills, descriptions, visibility, or catalog limits may invalidate reuse from the first changed catalog token. +The initial catalog remains prefix-stable. Dynamic changes are append-only history after that prefix, so existing reusable tokens stay intact while the replacement and later turns form a new suffix. ### Tool schema @@ -146,3 +150,5 @@ Append-only; newly visible content follows the reusable request prefix and does - **Loaded instruction bodies have no size cap** — a provider can return a skill large enough to consume substantial next-step context; only catalog descriptions are truncated. - **Resources are guidance, not attachments** — the tool reports a base directory/URL/opaque hint but neither enumerates nor fetches referenced files for the model. - **Loading is one-shot text** — there is no partial, streaming, or cached-content handle when a remote provider is slow or a skill body is large. +- **Catalog replacement is whole-list** — one changed name or description appends every currently visible summary; this keeps stale-name retirement explicit but costs tokens proportional to the catalog. +- **Bodies are not versioned** — body-only edits do not change the catalog digest or notify the model; a later tool call reads the current provider content while earlier tool results remain historical facts. diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index 56fb2b87ad..fe17e2e708 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -4,13 +4,17 @@ 面向模型的 skill 目录和 `skill` 工具。 -需要 `ctx.tools` 和 `ctx.skills` (`inject: ['tools', 'skills']`)。 +需要 `ctx.agents`、`ctx.tools` 和 `ctx.skills`(`inject: ['agents', 'tools', 'skills']`)。 -## 会话前缀目录 +## 目录生命周期 -该插件贡献一个用户角色 `` 目录,并通过 `agent/session-prefix` 提供它。它为调用会话的 cwd 解析 skill,将前缀中止信号转发到发现,并只列出已排序的 `name` 和 `description` 条目;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。如果没有模型可调用 skill,则省略目录;如果该 agent 的工具视图排除已发布的 `skill` 工具,或解析出一个同名作用域遮蔽,也会省略目录。这项精确定义检查使提示词指引、模型可见 schema 和可执行分派保持对齐。 +该插件通过 `agent/session-prefix` 提供初始的用户角色 `` 目录。之后每个模型步骤开始前,它都会观察 `ctx.skills.snapshot()`,并针对 `skill` 工具的精确可见性,以及按顺序渲染的 `name` 和 `description` 条目计算 digest。它根据调用会话的 cwd 解析 skill,且只列出这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。 -`catalogDescriptionMaxLength` 控制规范化且经 XML 转义的目录描述。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[会话前缀 Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md) 定义了该消息仅存在于请求中、记录于 header 的生命周期。 +该 digest 变化时,`agent.inject()` 会记录一条持久的用户角色消息,其中包含完整替换目录和元数据 `{ kind: 'skill-catalog', version: 1, digest }`。空替换会显式停用较早目录中的名称。恢复后,最新且仍可见的元数据充当比较基线;若压缩(compaction)遮蔽了替换消息,模型步骤前的观察会改以会话前缀为基线,并在必要时重新发布当前完整目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。 + +如果最初没有模型可调用 skill,则省略目录;如果该 agent 的工具视图排除已发布的 `skill` 工具,或解析出一个同名作用域遮蔽,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。 + +`catalogDescriptionMaxLength` 控制规范化且经 XML 转义的目录描述。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[会话前缀 Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-session-prefix.md) 定义了初始消息仅存在于请求中、记录于 header 的生命周期;[skill 目录热刷新 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久替换。 ## 工具:`skill` @@ -24,7 +28,7 @@ 无法解析的名称会报告 skill 未知或已不可用。无效名称和 `disableModelInvocation: true` skill 产生不同的错误结果。 -该工具在 v1 中不调用 `agent.inject()`。其结果已作为工具结果记录,并在下一个模型步骤可用,无需将内容重复为合成上下文。 +工具执行不调用 `agent.inject()`。新加载的结果已作为工具结果记录,并在下一个模型步骤可用,无需将正文重复为合成上下文。只有目录投影会注入替换摘要。 ## 模型体验 @@ -32,7 +36,7 @@ #### 模型所见 -如果存在模型可调用 skill,且该精确 `skill` 工具可见,agent 会收到下方目录模板,其中包含每个已排序 skill 的一条数据依赖条目。该目录是冻结的用户角色会话前缀。 +如果存在模型可调用 skill,且该精确 `skill` 工具可见,agent 会收到下方目录模板,其中包含每个已排序 skill 的一条数据依赖条目。初始目录是用户角色会话前缀。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。 ##### Skill 目录模板 @@ -50,11 +54,11 @@ If the user names a skill, or the task clearly matches a skill's description, ca #### Token 影响 -重复输入成本随 skill 数量和 `catalogDescriptionMaxLength` 增长;当列表为空或工具被隐藏或遮蔽时,不会发送目录 token。 +重复输入成本随 skill 数量和 `catalogDescriptionMaxLength` 增长;当列表为空或工具被隐藏或遮蔽时,不会发送初始目录 token。每次实际目录变更都会添加一条保留的完整替换消息。 #### KV 缓存影响 -会话前缀组合完成后,在一个循环实例内前缀稳定。如果新建或恢复的实例具有不同提供方、skill、描述、可见性或目录上限,则可能从第一个变更目录 token 起使重用失效。 +初始目录保持前缀稳定。动态变更作为该前缀之后的仅追加历史,因此现有可重用 token 保持不变,替换消息和后续轮次则形成新的后缀。 ### 工具 schema @@ -146,3 +150,5 @@ Load referenced resources only as needed. - **已加载指令正文没有大小上限**:提供方可返回足以占用大量下一步上下文的 skill;只有目录描述会被截断。 - **资源是指引,而非附件**:工具报告基础目录/URL/不透明提示,但既不列举也不为模型获取引用文件。 - **加载是一次性文本**:远程提供方缓慢或 skill 正文很大时,不提供部分、流式或缓存内容句柄。 +- **目录替换采用全量列表**:一个名称或描述发生变化,就会追加当前所有可见摘要;这样能显式停用陈旧名称,但 token 成本与目录大小成正比。 +- **正文不做版本化**:仅修改正文不会改变目录 digest,也不会通知模型;后续工具调用会读取提供方的当前内容,而先前工具结果仍是历史事实。 diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 47421bf19c..9d86da58dd 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -42,6 +42,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index f5fbd1dff5..1fe078d75f 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -4,16 +4,21 @@ * @module @deepseek-ai/dsh-tool-skill */ +import { createHash } from 'node:crypto' import type { Context } from 'cordis' import z from 'schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import { assertNever, type Message } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' import { isSkillName, type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill' export const name = 'tool-skill' -export const inject = ['tools', 'skills'] +export const inject = ['agents', 'tools', 'skills'] const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500 +const CATALOG_META_KIND = 'skill-catalog' +const CATALOG_META_VERSION = 1 +const PLUGIN_SOURCE = { kind: 'plugin', plugin: name } as const /** Model-facing skill catalog configuration. */ export interface Config { @@ -35,6 +40,7 @@ export const Config: z = z.object({ export function apply(ctx: Context, config: Config = {}): void { const catalogDescriptionMaxLength = config.catalogDescriptionMaxLength ?? DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH assertPositiveInteger('catalogDescriptionMaxLength', catalogDescriptionMaxLength, 3) + const baselineBySession = new WeakMap() const skillTool = defineTool({ name: 'skill', @@ -116,11 +122,40 @@ export function apply(ctx: Context, config: Config = {}): void { // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. ctx.on('agent/session-prefix', async (agent, _prefix, signal, next): Promise => { - if (ctx.tools.get(skillTool.name, agent) !== registeredSkillTool) return await next() - const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal }) + const toolVisible = ctx.tools.get(skillTool.name, agent) === registeredSkillTool + const snapshot = toolVisible + ? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal }) + : { skills: [], complete: true } const rest = await next() - if (skills.length === 0) return rest - return [renderCatalogMessage(skills, catalogDescriptionMaxLength), ...rest] + signal.throwIfAborted() + if (!snapshot.complete) return rest + const digest = catalogDigest(toolVisible, snapshot.skills, catalogDescriptionMaxLength) + baselineBySession.set(agent.session, digest) + if (!toolVisible || snapshot.skills.length === 0) return rest + return [renderCatalogMessage(snapshot.skills, catalogDescriptionMaxLength), ...rest] + }) + + ctx.on('agent/pre-step', async (agent, _turn, _step, signal) => { + const toolVisible = ctx.tools.get(skillTool.name, agent) === registeredSkillTool + const snapshot = toolVisible + ? await ctx.skills.snapshot({ cwd: agent.session.header.cwd, signal }) + : { skills: [], complete: true } + signal.throwIfAborted() + if (!snapshot.complete) return + const digest = catalogDigest(toolVisible, snapshot.skills, catalogDescriptionMaxLength) + const effective = latestVisibleCatalogDigest(agent) ?? baselineBySession.get(agent.session) + if (effective === digest) return + if (effective === undefined && snapshot.skills.length === 0) { + baselineBySession.set(agent.session, digest) + return + } + agent.inject( + renderCatalogUpdate(snapshot.skills, catalogDescriptionMaxLength).content, + { + source: PLUGIN_SOURCE, + meta: { kind: CATALOG_META_KIND, version: CATALOG_META_VERSION, digest }, + }, + ) }) } @@ -171,7 +206,7 @@ function renderResourceHint(skill: Pick `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`) + const entries = renderCatalogEntries(skills, descriptionMaxLength) return { role: 'user', content: [{ @@ -191,6 +226,68 @@ function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: numb } } +function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: number): Message { + const entries = renderCatalogEntries(skills, descriptionMaxLength) + const availability = skills.length === 0 + ? [ + 'No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.', + ] + : [ + 'Use only names in this replacement catalog. If the user names a listed skill, or the task clearly matches its description, call the `skill` tool with the exact name before acting.', + ] + return { + role: 'user', + content: [{ + type: 'text', + text: [ + '', + 'The available skill catalog changed. This complete catalog replaces every earlier available-skills list in this session:', + '', + '', + ...entries, + '', + '', + ...availability, + '', + ].join('\n'), + }], + } +} + +function renderCatalogEntries(skills: SkillSummary[], descriptionMaxLength: number): string[] { + return skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`) +} + +function catalogDigest(toolVisible: boolean, skills: SkillSummary[], descriptionMaxLength: number): string { + return createHash('sha256') + .update(JSON.stringify({ + toolVisible, + entries: renderCatalogEntries(skills, descriptionMaxLength), + })) + .digest('hex') +} + +function latestVisibleCatalogDigest(agent: Agent): string | undefined { + const visible = new Set(agent.session.surface.nodes) + for (const event of [...agent.session.events].reverse()) { + if (!visible.has(event.seq) + || event.type !== 'user/message' + || event.data.source.kind !== 'plugin' + || event.data.source.plugin !== name) continue + const meta = event.data.meta + if (!isRecord(meta) + || meta.kind !== CATALOG_META_KIND + || meta.version !== CATALOG_META_VERSION + || typeof meta.digest !== 'string') continue + return meta.digest + } + return undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + function catalogDescription(value: string, maxLength: number): string { const normalized = value.replaceAll(/\s+/g, ' ').trim() const truncated = normalized.length <= maxLength diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 7fef1cbf22..3ef11a5e30 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -5,9 +5,10 @@ import { tmpdir } from 'node:os' import { Context } from 'cordis' import { CallId, type Message } from '@deepseek-ai/dsh-llm' import { createScope, type Scope } from '@deepseek-ai/dsh-scope' +import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' -import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent' import SkillService from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' @@ -28,8 +29,9 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise AgentMessageId('stub'), + queue: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), + inject(content, options) { + session.append('user/message', { + content, + source: options?.source ?? { kind: 'user' }, + ...(options?.meta === undefined ? {} : { meta: options.meta }), + }, { surfaceOp: 'append' }) + return AgentMessageId('stub') + }, + send: () => AgentMessageId('stub'), + cancel() {}, + whenIdle: () => Promise.resolve(), + } +} + +function openMessageTurn(session: Session, turn = 1): void { + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { + content: [{ type: 'text', text: `turn ${turn}` }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) +} + +async function firePreStep(ctx: Context, agent: Agent, turn: number, step: number): Promise { + await agentEvents(ctx, agent).serial('agent/pre-step', turn, step, new AbortController().signal) +} + +function catalogUpdates(session: Session): Extract[] { + return session.events.filter((event): event is Extract => event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'tool-skill') +} + async function composePrefix(ctx: Context, cwd: string, signal = new AbortController().signal): Promise { return await composePrefixForAgent(ctx, agentForCwd(cwd), signal) } @@ -50,8 +94,8 @@ async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new Ab ) } -async function mintAgentScope(ctx: Context, cwd: string): Promise<{ agent: Agent; scope: Scope }> { - const agent = agentForCwd(cwd) +async function mintAgentScope(ctx: Context, subject: string | Agent): Promise<{ agent: Agent; scope: Scope }> { + const agent = typeof subject === 'string' ? agentForCwd(subject) : subject let scope!: Scope await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['tools'], @@ -64,9 +108,10 @@ describe('dsh-tool-skill', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) const home = await tempDir('tool-schema') await ctx.plugin(SkillService) - await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') }) + await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) ctx.skills.register({ name: 'lifecycle-skill', description: 'Lifecycle', source: 'runtime', content: 'body' }) const fiber = await ctx.plugin(toolSkill) @@ -169,15 +214,227 @@ describe('dsh-tool-skill', () => { expect(await composePrefix(ctx, '/workspace')).toEqual([]) }) + it('omits an incomplete initial catalog and retries on a later request boundary', async () => { + const home = await tempDir('tool-incomplete-prefix') + const ctx = await setup(home) + let failing = true + const provider = { + name: 'recovering', + async list() { + if (failing) throw new Error('temporarily unavailable') + return [] + }, + async get() { + return undefined + }, + } + ctx.skills.registerProvider(provider) + const session = new Session(SessionId('incomplete-prefix')) + const agent = sessionAgent(session) + openMessageTurn(session) + + expect(await composePrefixForAgent(ctx, agent)).toEqual([]) + failing = false + ctx.skills.invalidateProvider(provider) + await firePreStep(ctx, agent, 1, 1) + + expect(catalogUpdates(session)).toEqual([]) + }) + + it('records an empty baseline when pre-step runs before prefix composition', async () => { + const home = await tempDir('tool-empty-pre-step') + const ctx = await setup(home) + const session = new Session(SessionId('empty-pre-step')) + const agent = sessionAgent(session) + openMessageTurn(session) + + await firePreStep(ctx, agent, 1, 1) + await firePreStep(ctx, agent, 1, 2) + + expect(catalogUpdates(session)).toEqual([]) + }) + + it('injects complete replacement catalogs for additions and an empty tombstone for removals', async () => { + const home = await tempDir('tool-dynamic-catalog') + const ctx = await setup(home) + const disposeFirst = ctx.skills.register({ + name: 'first-skill', + description: 'First skill', + source: 'runtime', + content: 'First body.', + }) + const session = new Session(SessionId('dynamic-catalog')) + const agent = sessionAgent(session) + openMessageTurn(session) + + expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('first-skill') + await firePreStep(ctx, agent, 1, 1) + expect(catalogUpdates(session)).toEqual([]) + + const disposeSecond = ctx.skills.register({ + name: 'second-skill', + description: 'Second skill', + source: 'runtime', + content: 'Second body.', + }) + await firePreStep(ctx, agent, 1, 2) + + const addition = catalogUpdates(session)[0] + if (addition?.type !== 'user/message') throw new Error('expected catalog addition') + expect(addition.data.meta).toMatchObject({ kind: 'skill-catalog', version: 1 }) + expect(JSON.stringify(addition.data.content)).toContain('first-skill') + expect(JSON.stringify(addition.data.content)).toContain('second-skill') + + disposeSecond() + disposeFirst() + await firePreStep(ctx, agent, 1, 3) + + const removal = catalogUpdates(session)[1] + if (removal?.type !== 'user/message') throw new Error('expected catalog removal') + expect(JSON.stringify(removal.data.content)).toContain('No skills are currently available') + expect(JSON.stringify(removal.data.content)).not.toContain('first-skill') + expect(JSON.stringify(removal.data.content)).not.toContain('second-skill') + }) + + it('resumes from the latest valid visible catalog metadata', async () => { + const home = await tempDir('tool-catalog-resume') + const ctx = await setup(home) + ctx.skills.register({ + name: 'resumed-skill', + description: 'Resumed skill', + source: 'runtime', + content: 'Resumed body.', + }) + const session = new Session(SessionId('catalog-resume')) + const agent = sessionAgent(session) + openMessageTurn(session) + session.append('user/message', { + content: [{ type: 'text', text: 'old catalog' }], + source: { kind: 'plugin', plugin: 'tool-skill' }, + meta: { kind: 'skill-catalog', version: 1, digest: 'old-digest' }, + }, { surfaceOp: 'append' }) + session.append('user/message', { + content: [{ type: 'text', text: 'malformed metadata' }], + source: { kind: 'plugin', plugin: 'tool-skill' }, + meta: { kind: 'skill-catalog', version: 1, digest: 42 }, + }, { surfaceOp: 'append' }) + session.append('user/message', { + content: [{ type: 'text', text: 'non-record metadata' }], + source: { kind: 'plugin', plugin: 'tool-skill' }, + meta: [], + }, { surfaceOp: 'append' }) + + await firePreStep(ctx, agent, 1, 1) + + expect(catalogUpdates(session)).toHaveLength(4) + expect(JSON.stringify(catalogUpdates(session).at(-1)?.data.content)).toContain('resumed-skill') + }) + + it('re-establishes a replacement catalog after compaction shadows its metadata', async () => { + const home = await tempDir('tool-catalog-compaction') + const ctx = await setup(home) + ctx.skills.register({ + name: 'first-skill', + description: 'First skill', + source: 'runtime', + content: 'First body.', + }) + const session = new Session(SessionId('catalog-compaction')) + const agent = sessionAgent(session) + openMessageTurn(session) + expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('first-skill') + ctx.skills.register({ + name: 'second-skill', + description: 'Second skill', + source: 'runtime', + content: 'Second body.', + }) + await firePreStep(ctx, agent, 1, 1) + const replacement = catalogUpdates(session)[0] + if (replacement === undefined) throw new Error('expected replacement catalog') + session.append('user/message', { + content: [{ type: 'text', text: 'compacted history' }], + source: { kind: 'plugin', plugin: 'compact' }, + }, { + surfaceOp: { op: 'replace', start: replacement.seq, end: replacement.seq }, + sourceEventSeqs: [replacement.seq], + }) + + await firePreStep(ctx, agent, 1, 2) + + expect(catalogUpdates(session)).toHaveLength(2) + expect(JSON.stringify(catalogUpdates(session).at(-1)?.data.content)).toContain('second-skill') + }) + + it('keeps body-only edits out of the catalog and loads the latest body on demand', async () => { + const home = await tempDir('tool-body-refresh') + const root = join(home, '.dsh/skills') + await writeSkill(root, 'body-skill', 'Stable description', 'First body.') + const ctx = await setup(home) + const session = new Session(SessionId('body-refresh')) + const agent = sessionAgent(session) + openMessageTurn(session) + + expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('Stable description') + await writeSkill(root, 'body-skill', 'Stable description', 'Second body.') + await firePreStep(ctx, agent, 1, 1) + expect(catalogUpdates(session)).toEqual([]) + + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('body-refresh'), + name: 'skill', + arguments: { name: 'body-skill' }, + agent, + }) + expect(result.isError).toBe(false) + expect(JSON.stringify(result.content)).toContain('Second body.') + expect(JSON.stringify(result.content)).not.toContain('First body.') + }) + + it('retains the last-good catalog while any provider discovery is incomplete', async () => { + const home = await tempDir('tool-incomplete-catalog') + const ctx = await setup(home) + const disposeStable = ctx.skills.register({ + name: 'stable-skill', + description: 'Stable skill', + source: 'runtime', + content: 'Stable body.', + }) + const session = new Session(SessionId('incomplete-catalog')) + const agent = sessionAgent(session) + openMessageTurn(session) + expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('stable-skill') + + ctx.skills.registerProvider({ + name: 'failing', + async list() { + throw new Error('temporarily unavailable') + }, + async get() { + return undefined + }, + }) + disposeStable() + await firePreStep(ctx, agent, 1, 1) + + expect(catalogUpdates(session)).toEqual([]) + }) + it('omits catalog guidance when the calling agent restricts away the shipped skill tool', async () => { const home = await tempDir('tool-restricted-catalog') const ctx = await setup(home) ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' }) - const { agent, scope } = await mintAgentScope(ctx, '/workspace') + const session = new Session(SessionId('restricted-catalog')) + const agent = sessionAgent(session) + openMessageTurn(session) + const { scope } = await mintAgentScope(ctx, agent) scope.ctx.tools.restrict({ deny: ['skill'] }) expect(ctx.tools.get('skill', agent)).toBeUndefined() expect(await composePrefixForAgent(ctx, agent)).toEqual([]) + await firePreStep(ctx, agent, 1, 1) + expect(catalogUpdates(session)).toEqual([]) expect(await composePrefix(ctx, '/workspace')).toHaveLength(1) await scope.dispose() }) @@ -207,8 +464,9 @@ describe('dsh-tool-skill', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(SkillService) - await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') }) + await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) await expect(ctx.plugin(toolSkill, { catalogDescriptionMaxLength: 2 })).rejects.toThrow('greater than or equal to 3') }) diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index a2a17d022d..8ba46590bc 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.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 -README.md: 3d64de9f0703838cad10f8e04665ec4b985d00dc -README.zh.md: 5cf41dd76c7c28cd2d605466c7c10cfe1c1dd958 +# pnpm run verify-translation-pairing --write packages/ui/tui/README.md +README.md: 632a2fd2c94b4d49c4a479b3c4f5477fe9246c8d +README.zh.md: 75c941b36033048f4122bf5bb1fc4ff26c76620e diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 3d64de9f07..632a2fd2c9 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -130,7 +130,7 @@ Changing provider or model enters that target's cache domain; no cache reuse acr #### What the model sees -A `/skill: [instructions]` submission loads the named skill and delivers one text block: a `` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name. +A `/skill: [instructions]` submission loads the named skill and delivers one text block: a `` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name. Autocomplete retains its last complete skill snapshot and refetches after `skills/change`; an incomplete observation preserves the prior menu, a complete empty observation clears it, and a catalog arriving while a slash-name draft is open immediately re-queries that draft. #### Token effect diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 5cf41dd76c..75c941b360 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -130,7 +130,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read #### 模型看到的内容 -提交 `/skill: [instructions]` 会加载具名 skill,并交付一个文本块:用 `` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型;模型禁用的 skill 不出现在自动补全中,但仍可按精确名称加载。 +提交 `/skill: [instructions]` 会加载具名 skill,并交付一个文本块:用 `` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型;模型禁用的 skill 不出现在自动补全中,但仍可按精确名称加载。自动补全会保留最后一份完整 skill 快照,并在 `skills/change` 后重新获取。观测不完整时保留先前菜单,完整的空观测会将其清空;如果目录在斜杠命令名称草稿打开期间到达,则会立即根据该草稿重新查询。 #### Token 影响 diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index de0ef02f7e..0c34ec5382 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -2668,10 +2668,12 @@ export function createTuiChat( } // Skill listing is async while `createTuiChat` is synchronous, so the - // completions rebuild once the catalog resolves. Disabled-for-model skills - // are absent from `list()`, so they never appear as completions; a user can + // TUI retains the last complete catalog for synchronous editor completion + // and refreshes it after registry invalidation. Disabled-for-model skills are + // absent from snapshots, so they never appear as completions; a user can // still invoke one by typing its exact name. let skillCommands: SlashCommand[] = [] + let skillCommandScan = 0 const refreshCommandAutocomplete = (): void => { const base = new CombinedAutocompleteProvider( [ @@ -2692,19 +2694,31 @@ export function createTuiChat( agent, )) } + const refreshVisibleSlashAutocomplete = (): void => { + const cursor = editor.getCursor() + const textBeforeCursor = editor.getLines().slice(cursor.line, cursor.line + 1).join('').slice(0, cursor.col) + if (cursor.line === 0 && textBeforeCursor.startsWith('/') && !textBeforeCursor.includes(' ')) { + // pi-tui's provider setter closes an existing menu but does not query + // the replacement for the current draft. Tab in a slash-name context + // only requests suggestions, so it refreshes without editing the text. + editor.handleInput('\t') + } + } const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete) refreshCommandAutocomplete() - const loadSkillCommands = (service: SkillService): void => { - service.list({ cwd, signal: skillAbort.signal }).then( - (summaries) => { - if (disposed || summaries.length === 0) return - skillCommands = summaries.map(skill => ({ + const refreshSkillCommands = (service: SkillService): void => { + const scan = ++skillCommandScan + service.snapshot({ cwd, signal: skillAbort.signal }).then( + (snapshot) => { + if (disposed || scan !== skillCommandScan || !snapshot.complete) return + skillCommands = snapshot.skills.map(skill => ({ name: `skill:${skill.name}`, description: skill.description, argumentHint: '[instructions]', })) refreshCommandAutocomplete() + refreshVisibleSlashAutocomplete() requestRender() }, () => { @@ -2713,7 +2727,10 @@ export function createTuiChat( }, ) } - if (skills !== undefined) loadSkillCommands(skills) + const disposeSkillChanges = skills === undefined + ? () => {} + : ctx.on('skills/change', () => { refreshSkillCommands(skills) }) + if (skills !== undefined) refreshSkillCommands(skills) // The agent scope is minted by agent-loop and intentionally inherits only // that core plugin's dependencies. A child command producer declares its own @@ -3202,6 +3219,7 @@ export function createTuiChat( fileSearch.dispose() removeInputListener() disposeCommandChanges() + disposeSkillChanges() stopBannerReveal() disposeSessionEvents() disposeQueued() diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index c7af95061a..59da1d37d4 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -10,7 +10,7 @@ import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeM import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { SessionRecord } from '@deepseek-ai/dsh-session-query' -import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill' +import SkillService, { type SkillCatalogSnapshot, type SkillDefinition, type SkillProvider } from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-session-title' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -2674,6 +2674,129 @@ describe('skill slash command', () => { await dispose(result) }) + it('refreshes slash completions after runtime skill additions and complete removals', async () => { + let skills: SkillService | undefined + const result = await setup({ + configureContext: async (ctx) => { + ctx.provide('tools', { get() { return undefined } } as never) + await ctx.plugin(SkillService) + skills = ctx.get('skills') + }, + }) + if (skills === undefined) throw new Error('skills service not mounted') + + result.terminal.send('/skill:dynamic') + await tick() + result.terminal.output = '' + const disposeSkill = skills.register({ + name: 'dynamic-skill', + description: 'DYNAMIC_COMPLETION_MARKER', + source: 'runtime', + content: 'Dynamic body.', + }) + await tick() + expect(result.terminal.output).toContain('DYNAMIC_COMPLETION_MARKER') + + result.terminal.send('\x03') + disposeSkill() + await tick() + result.terminal.output = '' + result.terminal.send('/skill:dynamic') + await tick() + expect(result.terminal.output).not.toContain('DYNAMIC_COMPLETION_MARKER') + await dispose(result) + }) + + it('retains last-good slash completions across incomplete snapshots', async () => { + let skills: SkillService | undefined + let provider: SkillProvider | undefined + let fail = false + const result = await setup({ + configureContext: async (ctx) => { + ctx.provide('tools', { get() { return undefined } } as never) + await ctx.plugin(SkillService) + skills = ctx.get('skills') + provider = { + name: 'flaky-completion', + async list() { + if (fail) throw new Error('transient completion failure') + return [{ + name: 'stable-skill', + description: 'STABLE_COMPLETION_MARKER', + source: 'test', + provider: 'flaky-completion', + rank: 1, + locator: 'stable', + }] + }, + async get() { + return undefined + }, + } + skills?.registerProvider(provider) + }, + }) + if (skills === undefined || provider === undefined) throw new Error('skills provider not mounted') + + fail = true + skills.invalidateProvider(provider) + await tick() + result.terminal.output = '' + result.terminal.send('/skill:stable') + await tick() + expect(result.terminal.output).toContain('STABLE_COMPLETION_MARKER') + await dispose(result) + }) + + it('keeps the latest slash catalog when asynchronous refreshes settle out of order', async () => { + const pendingSnapshots: Array> = [] + const result = await setup({ + configureContext: async (ctx) => { + ctx.provide('tools', { get() { return undefined } } as never) + ctx.provide('skills', { + snapshot: () => { + const pending = Promise.withResolvers() + pendingSnapshots.push(pending) + return pending.promise + }, + get: () => Promise.resolve(undefined), + } as never) + }, + }) + expect(pendingSnapshots).toHaveLength(1) + + result.ctx.emit('skills/change') + result.ctx.emit('skills/change') + expect(pendingSnapshots).toHaveLength(3) + pendingSnapshots[2]?.resolve({ + skills: [{ + name: 'latest-skill', + description: 'LATEST_COMPLETION_MARKER', + source: 'runtime', + provider: 'runtime', + }], + complete: true, + }) + await tick() + pendingSnapshots[0]?.resolve({ + skills: [{ name: 'stale-first', description: 'STALE_FIRST', source: 'runtime', provider: 'runtime' }], + complete: true, + }) + pendingSnapshots[1]?.resolve({ + skills: [{ name: 'stale-second', description: 'STALE_SECOND', source: 'runtime', provider: 'runtime' }], + complete: true, + }) + await tick() + + result.terminal.output = '' + result.terminal.send('/skill:latest') + await tick() + expect(result.terminal.output).toContain('LATEST_COMPLETION_MARKER') + expect(result.terminal.output).not.toContain('STALE_FIRST') + expect(result.terminal.output).not.toContain('STALE_SECOND') + await dispose(result) + }) + it('loads a skill as a user turn, appending typed instructions', async () => { const result = await setup({ configureContext: withSkills }) result.terminal.send('/skill:demo-skill') @@ -2732,7 +2855,7 @@ describe('skill slash command', () => { configureContext: async (ctx) => { ctx.provide('tools', { get() { return undefined } } as never) ctx.provide('skills', { - list: () => Promise.reject(new Error('list boom')), + snapshot: () => Promise.reject(new Error('list boom')), get: () => Promise.reject(new Error('get boom')), } as never) }, @@ -2746,13 +2869,13 @@ describe('skill slash command', () => { }) it('drops skill list and lookup results that settle after disposal', async () => { - const pendingList: Array<(value: SkillSummary[]) => void> = [] + const pendingSnapshots: Array<(value: SkillCatalogSnapshot) => void> = [] const pendingGet: Array<{ resolve: (value: SkillDefinition | undefined) => void; reject: (error: unknown) => void }> = [] const result = await setup({ configureContext: async (ctx) => { ctx.provide('tools', { get() { return undefined } } as never) ctx.provide('skills', { - list: () => new Promise((resolve) => { pendingList.push(resolve) }), + snapshot: () => new Promise((resolve) => { pendingSnapshots.push(resolve) }), get: () => new Promise((resolve, reject) => { pendingGet.push({ resolve, reject }) }), } as never) }, @@ -2765,7 +2888,14 @@ describe('skill slash command', () => { await tick() await dispose(result) - for (const resolve of pendingList) resolve([{ name: 'late', description: 'late', source: 'runtime', provider: 'runtime' }]) + result.ctx.emit('skills/change') + expect(pendingSnapshots).toHaveLength(1) + for (const resolve of pendingSnapshots) { + resolve({ + skills: [{ name: 'late', description: 'late', source: 'runtime', provider: 'runtime' }], + complete: true, + }) + } pendingGet[0]?.resolve({ name: 'demo-skill', description: 'late', source: 'runtime', provider: 'runtime', content: 'late body' }) pendingGet[1]?.reject(new Error('late failure')) await tick() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2e7b31fe0..21f46c1cbb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1881,6 +1881,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-bash-local': + specifier: workspace:^ + version: link:../../bash/bash-local '@deepseek-ai/dsh-bash-sandbox': specifier: workspace:^ version: link:../../bash/bash-sandbox @@ -3491,6 +3494,9 @@ importers: packages/skill/skill-local: dependencies: + chokidar: + specifier: ^5.0.0 + version: 5.0.0 schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -3532,6 +3538,9 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill @@ -7609,6 +7618,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -9304,6 +9317,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + refa@0.12.1: resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -12428,6 +12445,10 @@ snapshots: dependencies: readdirp: 4.1.2 + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + clsx@2.1.1: {} color-convert@2.0.1: @@ -14470,6 +14491,8 @@ snapshots: readdirp@4.1.2: {} + readdirp@5.0.0: {} + refa@0.12.1: dependencies: '@eslint-community/regexpp': 4.12.2 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 693f0aab60..81be7a9b3a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -144,6 +144,7 @@ export const LINK_MAP: Record = { SessionTitleObservationResult: 'session-query.md', SessionTitleProvider: 'session-title.md', SessionTitleSnapshot: 'session-title.md', + SkillCatalogSnapshot: 'skills.md', SkillDefinition: 'skills.md', SkillLookupOptions: 'skills.md', SkillProvider: 'skills.md', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 8f7902dc9b..dcf3cb1b98 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -308,9 +308,10 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-skill', dir: 'tool-skill', source: 'packages/skill/tool-skill/src/index.ts', - requires: ['ctx.tools', 'ctx.skills'], - writes: ['tool/call', 'tool/result'], + requires: ['ctx.tools', 'ctx.agents', 'ctx.skills'], + writes: ['tool/call', 'tool/result', 'user/message replacement catalogs via agent.inject()'], async mount(ctx) { + await ctx.plugin(AgentRegistry) await ctx.plugin(SkillService) await ctx.plugin(SkillLocal, { dshHome: resolve(root, '.tmp/tool-catalog/.dsh'), diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 02665b6dce..2668ac7154 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -989,6 +989,11 @@ "symbol": "SkillSummary", "source": "packages/skill/skill/src/index.ts" }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillCatalogSnapshot", + "source": "packages/skill/skill/src/index.ts" + }, { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillCandidate", From fcbf0f0952d61634e0931fccba155463602e22ea Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 27 Jul 2026 17:12:08 +0800 Subject: [PATCH 02/32] perf(skill): avoid catalog event copy --- .../feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml | 4 ++-- .../feature/2026-07-27-skill-catalog-hot-refresh.md | 2 +- .../feature/2026-07-27-skill-catalog-hot-refresh.zh.md | 2 +- packages/skill/tool-skill/src/index.ts | 6 +++++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml index ec6e7fd572..3530f2c4f7 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.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-27-skill-catalog-hot-refresh.md -2026-07-27-skill-catalog-hot-refresh.md: f818766eb55f237e21aa3da9586887e493b9de75 -2026-07-27-skill-catalog-hot-refresh.zh.md: 3f0be2e760f4a18504e18803bcb8a8e47acffa5d +2026-07-27-skill-catalog-hot-refresh.md: 7a63574a7a260489760f5ec376a6c1fdcd71e681 +2026-07-27-skill-catalog-hot-refresh.zh.md: 7cf6dcdbfe6e3540779bbe4cf48bdef037edf070 diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md index f818766eb5..7a63574a7a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md @@ -18,7 +18,7 @@ The skill capability separates catalog membership from instruction-body loading. A missing root is followed from its nearest existing ancestor one absent segment at a time with `fs.watchFile`, then handed to Chokidar once the real root exists. Deleting a root re-establishes ancestor observation. Chokidar configuration exposes native-versus-polling mode, write stability, polling interval, symlink following, and project watcher capacity. First-party `write` and `edit` tool observations synchronously invalidate a relevant provider, so the next model step sees its own mutation without waiting for host delivery. Watch startup/runtime failures make discovery incomplete and retry; teardown closes watchers and ignores late callbacks. -`@deepseek-ai/dsh-tool-skill` keeps the initial complete catalog in `agent/session-prefix`. Before every model step it computes a digest over exact `skill` tool visibility and the ordered rendered names and descriptions. A changed digest appends a durable, complete replacement catalog through `agent.inject()`, including an explicit empty catalog when all skills disappear. The logged message carries `{ kind: 'skill-catalog', version: 1, digest }`, so a still-visible replacement supplies the baseline across replay or plugin reload. If compaction shadows it, the next pre-step falls back to the loop's initial-prefix baseline and re-establishes the current catalog when needed. An incomplete snapshot emits no replacement and preserves the last-good model view. +`@deepseek-ai/dsh-tool-skill` keeps the initial complete catalog in `agent/session-prefix`. Before every model step it computes a digest over exact `skill` tool visibility and the ordered rendered names and descriptions. A changed digest appends a durable, complete replacement catalog through `agent.inject()`, including an explicit empty catalog when all skills disappear. The logged message carries `{ kind: 'skill-catalog', version: 1, digest }`, so a still-visible replacement supplies the baseline across replay or plugin reload. The lookup scans the read-only event view by descending index and stops at the newest visible replacement, avoiding a full event-array copy on every model step. If compaction shadows it, the next pre-step falls back to the loop's initial-prefix baseline and re-establishes the current catalog when needed. An incomplete snapshot emits no replacement and preserves the last-good model view. The TUI consumes the same invalidation as presentation state, not session history. `skills/change` carries no diff; the TUI refetches `snapshot()` for the active session cwd, applies only the latest complete result, and retains the previous commands across incomplete observations. A complete empty result clears stale completions. Because pi-tui closes autocomplete when its provider is replaced, a catalog that arrives while the user is typing a slash-command name also triggers a suggestion-only re-query of the current draft. diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md index 3f0be2e760..7cf6dcdbfe 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md @@ -18,7 +18,7 @@ skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snaps 系统从缺失根目录最近的现有祖先开始,使用 `fs.watchFile` 每次跟进一层缺失路径片段;真实根目录出现后,再交给 Chokidar。删除根目录后,系统会重新建立祖先观察。Chokidar 配置公开原生事件或轮询模式、写入稳定性、轮询间隔、符号链接跟随选项和项目 watcher 容量。第一方 `write` 和 `edit` 工具观察会同步使相关提供方失效,因此下一个模型步骤无需等待宿主事件投递,就能看到自身改动。watcher 启动或运行失败会使发现结果不完整并触发重试;资源销毁会关闭 watcher,并忽略延迟回调。 -`@deepseek-ai/dsh-tool-skill` 将初始完整目录保存在 `agent/session-prefix` 中。每个模型步骤开始前,它都会针对 `skill` 工具的精确可见性,以及按顺序渲染的名称和描述计算 digest。digest 变化时,插件通过 `agent.inject()` 追加一份持久的完整替换目录;所有 skill 消失时,也会追加显式空目录。记录的消息携带 `{ kind: 'skill-catalog', version: 1, digest }`。恢复后,最新且仍可见的替换是比较基线;如果压缩(compaction)遮蔽了替换消息,模型步骤前的观察会改以 `agent/session-prefix` 为基线,并在必要时重新发布当前完整目录。不完整的快照不会产生替换,并会保留最后一次完整的模型视图。 +`@deepseek-ai/dsh-tool-skill` 将初始完整目录保存在 `agent/session-prefix` 中。每个模型步骤开始前,它都会针对 `skill` 工具的精确可见性,以及按顺序渲染的名称和描述计算 digest。digest 变化时,插件通过 `agent.inject()` 追加一份持久的完整替换目录;所有 skill 消失时,也会追加显式空目录。记录的消息携带 `{ kind: 'skill-catalog', version: 1, digest }`。恢复后,最新且仍可见的替换是比较基线。查找会按索引降序扫描只读事件视图,在找到最新且仍可见的替换时停止,从而避免在每个模型步骤复制整个事件数组。如果压缩(compaction)遮蔽了替换消息,模型步骤前的观察会改以 `agent/session-prefix` 为基线,并在必要时重新发布当前完整目录。不完整的快照不会产生替换,并会保留最后一次完整的模型视图。 TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills/change` 不携带 diff;TUI 会为活动会话的 cwd 重新获取 `snapshot()`,仅应用最新的完整结果,并在观测不完整时保留先前命令。完整的空结果会清除陈旧补全项。pi-tui 在其提供方被替换时会关闭自动补全,因此如果目录在用户输入斜杠命令名称期间到达,还会触发一次仅用于更新建议的当前草稿重查。 diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 1fe078d75f..56292a80a0 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -269,7 +269,11 @@ function catalogDigest(toolVisible: boolean, skills: SkillSummary[], description function latestVisibleCatalogDigest(agent: Agent): string | undefined { const visible = new Set(agent.session.surface.nodes) - for (const event of [...agent.session.events].reverse()) { + const events = agent.session.events + for (let index = events.length - 1; index >= 0; index -= 1) { + // The loop bounds prove the read-only event view contains this index. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const event = events[index]! if (!visible.has(event.seq) || event.type !== 'user/message' || event.data.source.kind !== 'plugin' From 979fa8ab33dac1f54ab6ac96c572e0f548ec0eab Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:06:07 +0800 Subject: [PATCH 03/32] refactor(skill): scope provider invalidation --- ...-07-27-skill-catalog-hot-refresh.i18n.yaml | 4 +- .../2026-07-27-skill-catalog-hot-refresh.md | 8 +- ...2026-07-27-skill-catalog-hot-refresh.zh.md | 8 +- docs/config-catalog.md | 4 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 16 +-- docs/core-data-structures/skills.i18n.yaml | 4 +- docs/core-data-structures/skills.md | 12 +- docs/core-data-structures/skills.zh.md | 12 +- docs/event-producer-consumer.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 12 +- .../apiproxy/tests/api-proxy-commands.spec.ts | 8 +- packages/skill/skill-local/src/index.ts | 39 ++++-- .../tests/skill-local-watcher.spec.ts | 42 +++--- .../skill-local/tests/skill-local.spec.ts | 31 ++-- packages/skill/skill/README.i18n.yaml | 4 +- packages/skill/skill/README.md | 11 +- packages/skill/skill/README.zh.md | 11 +- packages/skill/skill/src/index.ts | 84 ++++++----- packages/skill/skill/tests/skill.spec.ts | 132 ++++++++++++------ .../skill/tool-skill/tests/tool-skill.spec.ts | 16 ++- packages/ui/tui/tests/tui.spec.ts | 8 +- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 5 + 24 files changed, 295 insertions(+), 181 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml index a1a9780a3f..ba65c7d05d 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.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-27-skill-catalog-hot-refresh.md -2026-07-27-skill-catalog-hot-refresh.md: 61f6690cb00546664e36d3916c98caf2dcc1b79f -2026-07-27-skill-catalog-hot-refresh.zh.md: f174d27f88c861cd3d951c1379f7ebaf72888f27 +2026-07-27-skill-catalog-hot-refresh.md: 8a53195dd8c4880c5cfa758ccf666ae88b2e1030 +2026-07-27-skill-catalog-hot-refresh.zh.md: 8cf55534460d2926be706353afd2019fad1bb45d diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md index 61f6690cb0..8a53195dd8 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md @@ -12,7 +12,7 @@ Filesystem updates are also non-atomic from the observer's perspective. An edito ## Decision -The skill capability separates catalog membership from instruction-body loading. `ctx.skills.snapshot()` returns summaries plus a completeness bit, while `ctx.skills.invalidateProvider(provider)` dirties only the exact registered provider and discards completed catalog caches. A provider or runtime generation change during discovery retries before returning. Incomplete observations are not cached. A stale provider callback after disposal or replacement is a no-op because invalidation uses object identity. +The skill capability separates catalog membership from instruction-body loading. `ctx.skills.snapshot()` returns summaries plus a completeness bit. `ctx.skills.registerProvider(factory)` gives the synchronous factory one registration-scoped `{ signal, invalidate }` control: `invalidate()` dirties only that exact active registration and discards completed catalog caches, while the signal aborts when registration fails or is disposed. A provider or runtime generation change during discovery retries before returning. Incomplete observations are not cached. A late invalidation after disposal or replacement is a no-op because the capability has been revoked. `@deepseek-ai/dsh-skill-local` directly depends on Chokidar and observes catalog-relevant host paths. Existing roots watch direct skill bundle directories, flat Markdown entries, and direct `SKILL.md` entry files. Additions, removals, and directory changes invalidate membership; file changes support frontmatter `name` and `description` refresh. Resource files below a bundle are ignored. Events in one microtask batch coalesce to one invalidation. Project watchers use a bounded least-recently-observed set. @@ -26,7 +26,7 @@ Instruction bodies keep progressive disclosure. Every `skill(name)` call asks th ## Verification -Registry tests pin exact invalidation, contained observer failures, incomplete snapshots, generation retries, and stale-name rejection. Local-provider tests cover bundle and flat-file creation, removal, rename, root creation/deletion/recreation, description changes, body-only edits, first-party observation, symlinks, polling options, watcher failures, event coalescing, bounded projects, teardown, and transient reads. Tool tests pin full replacement messages, empty tombstones, digest stability for body-only edits, incomplete-state retention, visibility, and resume metadata. TUI tests pin last-complete retention, authoritative empty removal, latest-wins refresh, teardown, and the already-open slash-draft race; a real Loader/PTY smoke adds a local skill after startup and observes its completion without restarting. A keyless assembled agent-spine snapshot creates a project skill through model-facing filesystem tools, observes its replacement catalog on the next request, and loads its current body with the real `skill` tool. +Registry tests pin registration-scoped invalidation, revocation, signal abort, contained observer failures, incomplete snapshots, generation retries, and stale-name rejection. Local-provider tests cover bundle and flat-file creation, removal, rename, root creation/deletion/recreation, description changes, body-only edits, first-party observation, symlinks, polling options, watcher failures, event coalescing, bounded projects, teardown, and transient reads. Tool tests pin full replacement messages, empty tombstones, digest stability for body-only edits, incomplete-state retention, visibility, and resume metadata. TUI tests pin last-complete retention, authoritative empty removal, latest-wins refresh, teardown, and the already-open slash-draft race; a real Loader/PTY smoke adds a local skill after startup and observes its completion without restarting. A keyless assembled agent-spine snapshot creates a project skill through model-facing filesystem tools, observes its replacement catalog on the next request, and loads its current body with the real `skill` tool. ## Alternatives considered @@ -35,6 +35,8 @@ Registry tests pin exact invalidation, contained observer failures, incomplete s - **Hash or version every `SKILL.md` body** — rejected because the model initially sees only names and descriptions, and the provider already rereads the body on each tool call. Body revisions would create catalog traffic without changing routing and would not justify rewriting historical tool results. - **Watch every bundle resource** — rejected because references, scripts, and assets are loaded on demand and do not affect the category list. Broad recursive watching would add invalidations, descriptor pressure, and platform variability without improving routing. - **Publish partial or failed discovery as the new catalog** — rejected because a transient read failure is not evidence of deletion. The completeness bit lets the model-facing consumer preserve its last-good catalog until a full observation succeeds. +- **Keep `invalidateProvider(provider)` public** — rejected because it exposes a registry mutation method and makes callers resupply an identity the registry already owns. The factory-issued closure binds invalidation to one registration and becomes inert on disposal, so observers need neither registry access nor provider identity. +- **Extract a generic Cordis file-watching service now** — deferred until another consumer establishes the reusable service contract. The local provider marks its Chokidar and missing-root observation boundary for that extraction; skill-path filtering and the call to the provider's invalidation closure remain skill-specific. ## Consequences @@ -43,4 +45,4 @@ Registry tests pin exact invalidation, contained observer failures, incomplete s - Catalog messages are append-only, logged, whole-list snapshots. They preserve earlier reusable tokens; replacements retire stale names explicitly, at token cost proportional to the current catalog on each actual digest change. - Body-only edits produce no catalog message. A subsequent tool call sees current content, while prior tool results remain an accurate record of what the model previously loaded. - Missing-root polling and Chokidar add one maintained runtime dependency, host watcher resources, bounded detection latency, and deployment tunables. The bounded project set and teardown contract contain those costs. -- Remote or future mutable providers remain responsible for calling `invalidateProvider()` from their own observation mechanism; the registry does not impose a universal watcher or TTL. +- Remote or future mutable providers retain their own registration-scoped invalidation closure and call it from their observation mechanism; the registry does not impose a universal watcher or TTL. diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md index f174d27f88..8cf5553446 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md @@ -12,7 +12,7 @@ skill(技能)摘要是模型的路由输入,但本地 skill 可在会话 ## 决策 -skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snapshot()` 返回摘要及一个完整性位;`ctx.skills.invalidateProvider(provider)` 只会将精确的已注册提供方标记为脏,并丢弃已经完成的目录缓存。在发现期间,如果提供方或运行时 generation 发生变化,系统会先重试再返回。不完整的观察结果不会缓存。提供方在资源释放或被替换后到达的陈旧回调不会执行任何操作,因为失效操作使用对象身份。 +skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snapshot()` 返回摘要及一个完整性位。`ctx.skills.registerProvider(factory)` 会向同步工厂提供一项注册作用域内的 `{ signal, invalidate }` 控制能力:`invalidate()` 只会将该精确活动注册标记为脏,并丢弃已完成目录缓存;注册失败或释放时,信号会中止。在发现期间,如果提供方或运行时 generation 发生变化,系统会先重试再返回。不完整的观察结果不会缓存。资源释放或替换后的延迟失效操作不会执行任何操作,因为该能力已被撤销。 `@deepseek-ai/dsh-skill-local` 直接依赖 Chokidar,并观察与目录相关的宿主路径。已有根目录会监视其直属 skill bundle 目录、平铺的 Markdown 条目和直属 `SKILL.md` 条目文件。新增、移除和目录变更会使成员关系失效;文件变更还支持刷新 frontmatter 中的 `name` 和 `description`。bundle 内更深层的资源文件会被忽略。同一微任务批次中的事件会合并为一次失效。项目 watcher 使用有界集合,并按最久未观察顺序淘汰。 @@ -26,7 +26,7 @@ TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills ## 验证 -注册表测试固定了精确失效、监听器失败隔离、不完整快照、generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、watcher 失败、事件合并、项目 watcher 容量上限、资源销毁和暂时读取。工具测试固定了完整替换消息、空 tombstone、仅修改正文时 digest 稳定、不完整状态保留、可见性和恢复元数据。TUI 测试固定了上一份完整结果保留、权威空结果清除、刷新时以最新结果为准、资源销毁和已打开斜杠草稿的竞态;一项使用真实 Loader/PTY 的 smoke 测试会在启动后添加本地 skill,并观察其补全项出现,而无需重启。一个无密钥、装配完成的 agent-spine 快照测试通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并使用真实 `skill` 工具加载当前正文。 +注册表测试固定了注册作用域内的失效、能力撤销、信号中止、监听器失败隔离、不完整快照、generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、watcher 失败、事件合并、项目 watcher 容量上限、资源销毁和暂时读取。工具测试固定了完整替换消息、空 tombstone、仅修改正文时 digest 稳定、不完整状态保留、可见性和恢复元数据。TUI 测试固定了上一份完整结果保留、权威空结果清除、刷新时以最新结果为准、资源销毁和已打开斜杠草稿的竞态;一项使用真实 Loader/PTY 的 smoke 测试会在启动后添加本地 skill,并观察其补全项出现,而无需重启。一个无密钥、装配完成的 agent-spine 快照测试通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并使用真实 `skill` 工具加载当前正文。 ## 考虑过的替代方案 @@ -35,6 +35,8 @@ TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills - **为每个 `SKILL.md` 正文计算哈希或版本**:不予采纳,因为模型最初只看到名称和描述,提供方已经在每次工具调用时重新读取正文。正文修订会产生目录流量,却不会改变路由,也不足以成为改写历史工具结果的理由。 - **监视每个 bundle 资源**:不予采纳,因为参考资料、脚本和产物都是按需加载的,不影响类别列表。宽泛的递归监视会增加失效、描述符压力和平台差异,却不能改善路由。 - **将部分发现或失败发现发布为新目录**:不予采纳,因为暂时读取失败不能证明文件已删除。完整性位让面向模型的消费方保留最后一次完整目录,直到完整观察成功。 +- **保留公开的 `invalidateProvider(provider)`**:不予采纳,因为这会公开一项注册表变更方法,并要求调用方重复提供注册表已经持有的身份。发给工厂的闭包会将失效绑定到单个注册,并在释放后失去作用,因此观察方既不需要访问注册表,也不需要提供方身份。 +- **现在提取通用 Cordis 文件监视服务**:暂缓,直到另一个消费方确立可复用的服务契约。本地提供方标出了其 Chokidar 和缺失根目录观测边界,以便后续提取;skill 路径过滤以及对提供方失效闭包的调用仍属于 skill 专用逻辑。 ## 影响 @@ -43,4 +45,4 @@ TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills - 目录消息采用仅追加、日志记录和全量列表快照。它们会保留较早的可重用 token;替换目录会显式停用陈旧名称,每次 digest 实际变化时,token 成本与当前目录大小成正比。 - 仅修改正文不会产生目录消息。后续工具调用会看到当前内容,而先前工具结果仍准确记录模型之前加载的内容。 - 缺失根目录轮询和 Chokidar 引入一个有人维护的运行时依赖、宿主 watcher 资源、有界检测延迟和部署可调参数。有界项目集合与资源销毁契约会限制这些成本。 -- 远程或未来的可变提供方仍有责任通过自身观察机制调用 `invalidateProvider()`;注册表不会强制采用通用 watcher 或 TTL。 +- 远程或未来的可变提供方会保留各自注册作用域内的失效闭包,并通过自身观察机制调用它;注册表不会强制采用通用 watcher 或 TTL。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b4ac98544a..5e4d5414f5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1187,7 +1187,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:121`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:129`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -1219,7 +1219,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill-local/src/index.ts:46`](../packages/skill/skill-local/src/index.ts) +Source: [`packages/skill/skill-local/src/index.ts:47`](../packages/skill/skill-local/src/index.ts) ## `@deepseek-ai/dsh-spill-local` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 885c98b806..3c20bb6ffc 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -657,7 +657,7 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:139`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:147`](../../packages/skill/skill/src/index.ts) ## `slash/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f87767af64..9a8196f06a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1397,19 +1397,11 @@ Registry of skill providers. It merges provider catalogs with stable first-wins * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters * the provider and invalidates catalog caches. - * @param provider - the provider to register by `provider.name`. + * @param create - synchronous factory receiving this registration's lifecycle and invalidation control. * @returns the exact Cordis effect disposer that unregisters this provider; * composite effects may yield it directly to preserve teardown ordering. */ -registerProvider(provider: SkillProvider): () => void - -/** - * Invalidate catalogs contributed by one currently registered provider. Exact object identity - * prevents a late callback from an old provider instance from invalidating its replacement. - * Calls for an already-unregistered provider are harmless. - * @param provider - exact provider instance whose external source changed. - */ -invalidateProvider(provider: SkillProvider): void +registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void /** * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which @@ -1449,9 +1441,9 @@ async snapshot(options: SkillLookupOptions = {}): Promise async get(name: string, options: SkillLookupOptions = {}): Promise ``` -Types: [SkillCatalogSnapshot](../core-data-structures/skills.md) · [SkillDefinition](../core-data-structures/skills.md) · [SkillLookupOptions](../core-data-structures/skills.md) · [SkillProvider](../core-data-structures/skills.md) · [SkillRegistration](../core-data-structures/skills.md) · [SkillSummary](../core-data-structures/skills.md) +Types: [SkillCatalogSnapshot](../core-data-structures/skills.md) · [SkillDefinition](../core-data-structures/skills.md) · [SkillLookupOptions](../core-data-structures/skills.md) · [SkillProvider](../core-data-structures/skills.md) · [SkillProviderControl](../core-data-structures/skills.md) · [SkillRegistration](../core-data-structures/skills.md) · [SkillSummary](../core-data-structures/skills.md) -Source: [`packages/skill/skill/src/index.ts:160`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:168`](../../packages/skill/skill/src/index.ts) ## `ctx.spillStore` — `SpillStore` (abstract seam) diff --git a/docs/core-data-structures/skills.i18n.yaml b/docs/core-data-structures/skills.i18n.yaml index a9040ffece..a89d2150a5 100644 --- a/docs/core-data-structures/skills.i18n.yaml +++ b/docs/core-data-structures/skills.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/skills.md -skills.md: e1b2bfd5336c3cbce7f3c85bf5e440519efef36b -skills.zh.md: 3a80b6a93d37e945b7a2ad7ed4bccb45724d6b15 +skills.md: 247d8d71890a1624225091a7d53dd6cee977134a +skills.zh.md: 7d5c61dcf3efc3cc90cae2d5bf9f16e4df74bd79 diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index e1b2bfd533..247d8d7189 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -10,7 +10,7 @@ Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/ind `ctx.skills` combines local, embedded, remote, or other providers. Registration is synchronous; remote initialization and discovery belong in awaited `list()`. Provider objects, options, and candidates are borrowed readonly, while semantic fields are validated. -Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and omitted from an incomplete observation without caching it, while malformed candidates fail fast. `invalidateProvider()` clears completed catalogs only for the exact live provider object, and an in-flight discovery retries when its provider generation changes. Provider and runtime membership mutations emit the unfiltered `skills/change` invalidation event; it carries no diff, so consumers refetch `snapshot()` with their own lookup options. +Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and omitted from an incomplete observation without caching it, while malformed candidates fail fast. Each provider factory receives a registration-scoped control whose `invalidate()` clears completed catalogs only while that exact registration remains active and whose signal aborts on failed registration or disposal. An in-flight discovery retries when its provider generation changes. Provider and runtime mutations emit the unfiltered `skills/change` invalidation event; it carries no diff, so consumers refetch `snapshot()` with their own lookup options. ```ts type-equiv /** Provider interface for one source of skills, such as local directories or a remote registry. */ @@ -36,6 +36,16 @@ interface SkillProvider { } ``` +```ts type-equiv +/** Registration-scoped lifecycle and invalidation capability borrowed by one provider. */ +interface SkillProviderControl { + /** Aborts if registration fails or when the exact provider registration is disposed. */ + readonly signal: AbortSignal + /** Invalidate completed catalogs and notify consumers only while the exact registration remains active. */ + readonly invalidate: () => void +} +``` + ## Local discovery priority The shipped local provider scans roots in rank order: diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index 3a80b6a93d..7d5c61dcf3 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -10,7 +10,7 @@ `ctx.skills` 组合本地、内嵌、远程或其他提供方。注册是同步的;远程初始化与发现属于 `list()` 的 await 阶段。提供方对象、选项与候选项以只读方式借用,语义字段会被校验。 -重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时会记录日志并从不完整观测中省略,且该观测不会缓存;格式错误的候选项快速失败。`invalidateProvider()` 只针对传入的活动提供方对象清除已完成目录;若提供方代次在发现进行期间发生变化,该发现会重试。提供方和运行时的成员关系变更会发出不带过滤条件的 `skills/change` 失效事件;该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 `snapshot()`。 +重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时会记录日志并从不完整观测中省略,且该观测不会缓存;格式错误的候选项快速失败。每个提供方工厂都会接收一项注册作用域内的控制能力;仅当该精确注册仍处于活动状态时,其 `invalidate()` 才会清除已完成目录;注册失败或释放时,其信号会中止。若提供方代次在发现进行期间发生变化,该发现会重试。提供方和运行时变更会发出不带过滤条件的 `skills/change` 失效事件;该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 `snapshot()`。 ```ts type-equiv /** Provider interface for one source of skills, such as local directories or a remote registry. */ @@ -36,6 +36,16 @@ interface SkillProvider { } ``` +```ts type-equiv +/** Registration-scoped lifecycle and invalidation capability borrowed by one provider. */ +interface SkillProviderControl { + /** Aborts if registration fails or when the exact provider registration is disposed. */ + readonly signal: AbortSignal + /** Invalidate completed catalogs and notify consumers only while the exact registration remains active. */ + readonly invalidate: () => void +} +``` + ## 本地发现优先级 内置的本地提供方按 rank 顺序扫描各根目录: diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e6ea309d10..1942291a03 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -35,7 +35,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:139`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:147`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:237`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 923c7c8747..12efd2e644 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -671,12 +671,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Registry of skill providers.', methods: [ { - signature: 'registerProvider(provider: SkillProvider): () => void', - jsDoc: '/**\n * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and\n * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters\n * the provider and invalidates catalog caches.\n * @param provider - the provider to register by `provider.name`.\n * @returns the exact Cordis effect disposer that unregisters this provider;\n * composite effects may yield it directly to preserve teardown ordering.\n */', - }, - { - signature: 'invalidateProvider(provider: SkillProvider): void', - jsDoc: '/**\n * Invalidate catalogs contributed by one currently registered provider. Exact object identity\n * prevents a late callback from an old provider instance from invalidating its replacement.\n * Calls for an already-unregistered provider are harmless.\n * @param provider - exact provider instance whose external source changed.\n */', + signature: 'registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void', + jsDoc: '/**\n * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and\n * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters\n * the provider and invalidates catalog caches.\n * @param create - synchronous factory receiving this registration\'s lifecycle and invalidation control.\n * @returns the exact Cordis effect disposer that unregisters this provider;\n * composite effects may yield it directly to preserve teardown ordering.\n */', }, { signature: 'register(skill: SkillRegistration): () => void', @@ -2216,6 +2212,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SkillProvider', declaration: 'export interface SkillProvider {\n readonly name: string;\n readonly list: (options: SkillLookupOptions) => Promise;\n readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise;\n}', }, + { + name: 'SkillProviderControl', + declaration: 'export interface SkillProviderControl {\n readonly signal: AbortSignal;\n readonly invalidate: () => void;\n}', + }, { name: 'SkillRegistration', declaration: 'export type SkillRegistration = Omit & {\n readonly provider?: string;\n};', diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 480f821b95..5df3e316cc 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -171,7 +171,7 @@ describe('skill.list', () => { it('lists skills for the session cwd taken from the header', async () => { const ctx = await harness() const seenCwds: (string | undefined)[] = [] - ctx.skills.registerProvider({ + ctx.skills.registerProvider(() => ({ name: 'probe', list: (options) => { seenCwds.push(options.cwd) @@ -181,7 +181,7 @@ describe('skill.list', () => { }]) }, get: () => Promise.resolve(undefined), - }) + })) const api = createApiProxy(ctx, DEFAULTS) // No agent is registered for this session: header resolution must not // touch (or resume through) the Agent registry. @@ -210,11 +210,11 @@ describe('skill.list', () => { it('folds a provider failure into internal', async () => { const ctx = await harness() - ctx.skills.registerProvider({ + ctx.skills.registerProvider(() => ({ name: 'broken', list: () => Promise.reject(new Error('directory exploded')), get: () => Promise.resolve(undefined), - }) + })) const api = createApiProxy(ctx, DEFAULTS) const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) const response = await api.skills.list(request({ sessionId: session.id })) diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 228aa700b7..dbf3a3c0b6 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -26,6 +26,7 @@ import { type SkillDefinition, type SkillLookupOptions, type SkillProvider, + type SkillProviderControl, type SkillSource, } from '@deepseek-ai/dsh-skill' @@ -119,8 +120,11 @@ interface ResolvedWatchConfig { /** Register the local filesystem skill provider on `ctx.skills`. */ export function apply(ctx: Context, config: Config = {}): void { - const provider = new LocalSkillProvider(ctx, config) - ctx.skills.registerProvider(provider) + let provider!: LocalSkillProvider + ctx.skills.registerProvider((control) => { + provider = new LocalSkillProvider(ctx, control, config) + return provider + }) ctx.effect(function* () { yield async () => { await provider.dispose() } }, 'skill-local watcher') @@ -138,12 +142,18 @@ export class LocalSkillProvider implements SkillProvider { private readonly customSkillDirs: string[] private readonly watchManager: SkillWatchManager private readonly bundledSkillDir: string | undefined + private disposal: Promise | undefined - constructor(private readonly ctx: Context, config: Config = {}) { + constructor( + private readonly ctx: Context, + control: SkillProviderControl, + config: Config = {}, + ) { this.dshHome = resolveDshHome(config.dshHome) this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents')) this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root)) - this.watchManager = new SkillWatchManager(ctx, this, resolveWatchConfig(config)) + this.watchManager = new SkillWatchManager(ctx, control.invalidate, resolveWatchConfig(config)) + control.signal.addEventListener('abort', () => { void this.dispose() }, { once: true }) const bundledSkillDir = config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir) } @@ -197,9 +207,13 @@ export class LocalSkillProvider implements SkillProvider { this.watchManager.observeHostMutation(path) } - /** Close every host watcher and contain late filesystem callbacks. */ - async dispose(): Promise { - await this.watchManager.dispose() + /** + * Close every host watcher and contain late filesystem callbacks. + * @returns a shared promise that settles when every watcher reaches quiescence. + */ + dispose(): Promise { + this.disposal ??= this.watchManager.dispose() + return this.disposal } private async roots(cwd: string | undefined): Promise { @@ -250,7 +264,7 @@ class SkillWatchManager { constructor( private readonly ctx: Context, - private readonly provider: SkillProvider, + private readonly invalidate: () => void, private readonly config: ResolvedWatchConfig, ) {} @@ -286,18 +300,17 @@ class SkillWatchManager { evictedProject = true } await Promise.all(pending) - if (evictedProject) this.ctx.skills.invalidateProvider(this.provider) + if (evictedProject) this.invalidate() } observeHostMutation(path: string): void { if (this.closing) return const normalized = resolve(path) if (![...this.roots.values()].some(state => isPotentialSkillPath(state.root, normalized))) return - this.ctx.skills.invalidateProvider(this.provider) + this.invalidate() } async dispose(): Promise { - if (this.closing) return this.closing = true const states = [...this.roots.values()] this.roots.clear() @@ -376,6 +389,8 @@ class SkillWatchManager { } } + // FIXME(file-watch-service): Extract Chokidar and missing-root observation below into a Cordis + // service; keep skill filtering and invalidation here. private async openStableWatcher(state: RootWatchState): Promise { while (!this.closing && state.owners.size > 0) { const mode = await resolveRootWatchMode(state.root.path) @@ -489,7 +504,7 @@ class SkillWatchManager { queueMicrotask(() => { this.invalidationQueued = false if (this.closing) return - this.ctx.skills.invalidateProvider(this.provider) + this.invalidate() }) } diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts index 5cf5de8460..c13f4f1542 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -123,12 +123,8 @@ describe('skill-local watcher failures', () => { watchStabilityThresholdMs: 20, }) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['watched-skill']) - const invalidateProvider = ctx.skills.invalidateProvider.bind(ctx.skills) let invalidations = 0 - ctx.skills.invalidateProvider = (provider) => { - invalidations += 1 - invalidateProvider(provider) - } + ctx.on('skills/change', () => { invalidations += 1 }) const first = watcherHarness.watchers[0] if (first === undefined) throw new Error('expected a root watcher') @@ -169,14 +165,17 @@ describe('skill-local watcher failures', () => { watcherHarness.deferredReady = 1 const ctx = new Context() await ctx.plugin(SkillService) - const provider = new SkillLocal.LocalSkillProvider(ctx, { - dshHome: join(home, '.dsh'), - agentsHome: join(home, '.agents'), - watch: true, - watchPollIntervalMs: 10, - watchStabilityThresholdMs: 20, + let provider!: InstanceType + const disposeProvider = ctx.skills.registerProvider((control) => { + provider = new SkillLocal.LocalSkillProvider(ctx, control, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchPollIntervalMs: 10, + watchStabilityThresholdMs: 20, + }) + return provider }) - ctx.skills.registerProvider(provider) const discovery = provider.list({}) await settle() @@ -187,6 +186,7 @@ describe('skill-local watcher failures', () => { first.emitter.emit('ready') await Promise.all([discovery, disposal]) + disposeProvider() await settle() expect(first.closeCalls).toBeGreaterThan(0) }) @@ -198,14 +198,17 @@ describe('skill-local watcher failures', () => { watcherHarness.deferredReady = 1 const ctx = new Context() await ctx.plugin(SkillService) - const provider = new SkillLocal.LocalSkillProvider(ctx, { - dshHome: join(home, '.dsh'), - agentsHome: join(home, '.agents'), - watch: true, - watchPollIntervalMs: 10, - watchStabilityThresholdMs: 20, + let provider!: InstanceType + const disposeProvider = ctx.skills.registerProvider((control) => { + provider = new SkillLocal.LocalSkillProvider(ctx, control, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchPollIntervalMs: 10, + watchStabilityThresholdMs: 20, + }) + return provider }) - ctx.skills.registerProvider(provider) const discovery = provider.list({}) await settle() @@ -216,5 +219,6 @@ describe('skill-local watcher failures', () => { await expect(discovery).rejects.toThrow('opening failed during disposal') await disposal + disposeProvider() }) }) diff --git a/packages/skill/skill-local/tests/skill-local.spec.ts b/packages/skill/skill-local/tests/skill-local.spec.ts index 6c218f3f61..27bd0904b1 100644 --- a/packages/skill/skill-local/tests/skill-local.spec.ts +++ b/packages/skill/skill-local/tests/skill-local.spec.ts @@ -572,12 +572,8 @@ describe('LocalSkillProvider', () => { const root = join(home, '.agents/skills') const ctx = await setupLocal(home) expect(await ctx.skills.list()).toEqual([]) - const invalidateProvider = ctx.skills.invalidateProvider.bind(ctx.skills) let invalidations = 0 - ctx.skills.invalidateProvider = (provider) => { - invalidations += 1 - invalidateProvider(provider) - } + ctx.on('skills/change', () => { invalidations += 1 }) await writeSkill(root, 'observed-skill', 'Observed skill') const path = join(root, 'observed-skill/SKILL.md') @@ -657,15 +653,18 @@ describe('LocalSkillProvider', () => { await writeSkill(join(home, '.agents/skills'), 'disposed-skill', 'Disposed skill') const ctx = new Context() await ctx.plugin(SkillService) - const provider = new SkillLocal.LocalSkillProvider(ctx, { - dshHome: join(home, '.dsh'), - agentsHome: join(home, '.agents'), - customSkillDirs: [nonDirectoryRoot], - watch: true, - watchStabilityThresholdMs: 20, - watchPollIntervalMs: 10, + let provider!: SkillLocal.LocalSkillProvider + const disposeProvider = ctx.skills.registerProvider((control) => { + provider = new SkillLocal.LocalSkillProvider(ctx, control, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + customSkillDirs: [nonDirectoryRoot], + watch: true, + watchStabilityThresholdMs: 20, + watchPollIntervalMs: 10, + }) + return provider }) - ctx.skills.registerProvider(provider) expect((await provider.list({})).map(skill => skill.name)).toEqual(['disposed-skill']) await provider.dispose() @@ -673,6 +672,7 @@ describe('LocalSkillProvider', () => { provider.observeHostMutation(join(home, '.agents/skills/disposed-skill/SKILL.md')) expect((await provider.list({})).map(skill => skill.name)).toEqual(['disposed-skill']) + disposeProvider() }) it('refreshes frontmatter through a followed skill symlink', { timeout: 10000 }, async () => { @@ -740,7 +740,10 @@ describe('LocalSkillProvider', () => { expect(await empty.skills.list()).toEqual([]) delete process.env.DSH_AGENTS_HOME - expect(new SkillLocal.LocalSkillProvider(empty, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local') + expect(new SkillLocal.LocalSkillProvider(empty, { + signal: new AbortController().signal, + invalidate() {}, + }, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local') } finally { if (previousDshHome === undefined) { delete process.env.DSH_HOME diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index d38296e354..09a8788486 100644 --- a/packages/skill/skill/README.i18n.yaml +++ b/packages/skill/skill/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/skill/skill/README.md -README.md: 54362bd3a0b8bcbf8161ce45f13535b49eab18a1 -README.zh.md: 8f15a44c815ffa687d01f8fc8f6070a8f1d28195 +README.md: 66b240c3a67941b2e617986bd43e6b0060b49f56 +README.zh.md: 0ebbab089999cbca07018724c05e40dd6b100200 diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 54362bd3a0..66b240c3a6 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -10,8 +10,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ### Public API -- `ctx.skills.registerProvider(provider): () => void` Registers a readonly provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry borrows the provider object and invokes its methods directly. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown. -- `ctx.skills.invalidateProvider(provider): void` Marks one exact live provider dirty and clears completed catalog caches. Calls from a disposed or replaced provider instance are no-ops, so late watcher callbacks cannot invalidate its replacement. +- `ctx.skills.registerProvider(create): () => void` Calls a synchronous provider factory with `{ signal, invalidate }`, then registers its readonly result by unique `provider.name`. Duplicate names throw, `runtime` is reserved, and failed registration aborts the signal. The exact Cordis disposer unregisters the provider, aborts the signal, and preserves ordered composite teardown. - `ctx.skills.snapshot({ cwd?, signal? })` Returns `{ skills, complete }`. `complete` is false when any provider failed transiently; incomplete observations are never cached, so a model-facing consumer can retain its last-good catalog and retry at the next request boundary. - `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns model-invocable summaries for the current workspace, merged across providers and sorted by name. - `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it, including disabled-for-model skills. @@ -19,7 +18,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ### Events -- `skills/change` is an unfiltered invalidation notification emitted after a provider or runtime contribution is registered or disposed and after `invalidateProvider()` accepts an exact live provider. It carries no catalog or diff: each consumer refetches `snapshot()` with its own lookup options. Listener throws and rejected promises are logged and cannot veto the registry mutation or starve later listeners. +- `skills/change` is an unfiltered invalidation notification emitted after a provider or runtime contribution is registered or disposed and after an active provider's registration control invalidates. It carries no catalog or diff: each consumer refetches `snapshot()` with its own lookup options. Listener throws and rejected promises are logged and cannot veto the registry mutation or starve later listeners. ### Config @@ -29,13 +28,13 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ## Provider Contract -A provider registers synchronously and performs remote setup, authentication, and discovery in its awaited `list(options)` call. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation. +A provider factory runs synchronously and receives one registration-scoped control. `control.signal` aborts when registration fails or is disposed; `control.invalidate()` clears completed catalogs only while that exact registration remains active, so late callbacks cannot affect a replacement with the same name. Immutable providers may ignore the control. Remote setup, authentication, and discovery belong in the provider's awaited `list(options)` call. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation. The registry validates candidates before caching and definitions before returning them. The winning provider receives the same candidate and opaque `locator` it returned from `list()`, allowing backend-specific file, URL, id, or version handles. Callers and providers must preserve the readonly contract. Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure: its entries are omitted from that observation, `complete` is false, and the result is not cached. A provider or runtime revision change discards an in-flight result and retries before returning. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name. -Definitions remain progressively loaded. `get()` asks the winning provider for the body on every call rather than caching it in this registry. If the returned definition has a different name from the selected candidate, the stale selection is rejected and that exact provider is invalidated so the next snapshot rediscovers its catalog. +Definitions remain progressively loaded. `get()` asks the winning provider for the body on every call rather than caching it in this registry. If the returned definition has a different name from the selected candidate, the stale selection is rejected and the registry internally invalidates that exact provider so the next snapshot rediscovers its catalog. ## Runtime Skills @@ -55,7 +54,7 @@ No direct prompt effect. The named consumer owns the durable initial catalog and ## Known Limitations and Deferred Work -- **Invalidation is provider-driven** — the registry has no TTL and cannot infer that an arbitrary remote source changed; each mutable provider must call `invalidateProvider()` from its own observation mechanism. +- **Invalidation is provider-driven** — the registry has no TTL and cannot infer that an arbitrary remote source changed; each mutable provider must retain and call its registration-scoped `invalidate()` capability from its own observation mechanism. - **Providers are queried sequentially** — one slow cooperative provider delays every provider registered after it; cancellation stops the caller's wait but cannot terminate work an uncooperative provider keeps running. - **An incomplete snapshot omits the failing provider in that observation** — the registry reports `complete: false`, but it does not own a last-good catalog or a per-provider diagnostic; consumers choose whether to retain earlier state. - **Duplicate resolution is first-wins** — later lower-priority candidates are logged and hidden; there is no API to inspect all shadowed definitions. diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md index 8f15a44c81..0ebbab0899 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -10,8 +10,7 @@ ### 公开 API -- `ctx.skills.registerProvider(provider): () => void` 使用唯一 `provider.name` 注册只读提供方。重复提供方名称会抛错,`runtime` 保留给 `ctx.skills.register(...)`。注册表借用提供方对象,并直接调用其方法。注册作用域绑定到 effect,可安全用于 HMR;精确的 Cordis disposer 支持有序组合拆卸。 -- `ctx.skills.invalidateProvider(provider): void` 按实例精确标脏一个活动提供方,并清除已完成目录缓存。已释放或已被替换的提供方实例调用此方法时不执行任何操作,因此延迟到达的 watcher 回调无法使其替代项失效。 +- `ctx.skills.registerProvider(create): () => void` 调用同步提供方工厂并向其传入 `{ signal, invalidate }`,随后使用唯一 `provider.name` 注册其只读结果。重复提供方名称会抛错,`runtime` 为保留名称;注册失败会中止信号。精确的 Cordis disposer 会注销提供方、中止信号,并保持有序组合拆卸。 - `ctx.skills.snapshot({ cwd?, signal? })` 返回 `{ skills, complete }`。任一提供方发生瞬时失败时,`complete` 为 false;不完整观测绝不缓存,使面向模型的消费方可以保留上一份可用目录,并在下一个请求边界重试。 - `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中模型可调用的摘要;这些摘要跨提供方合并,并按名称排序。 - `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后将其返回,包括已对模型禁用的 skill。 @@ -19,7 +18,7 @@ ### 事件 -- `skills/change` 是一条不带过滤条件的失效通知,在提供方或运行时贡献注册或释放后,以及 `invalidateProvider()` 接受精确活动提供方后发出。它不携带目录或 diff;每个消费方都使用自身的查找选项重新获取 `snapshot()`。监听器抛错或 Promise 拒绝会被记录,既不能否决注册表变更,也不能阻止后续监听器执行。 +- `skills/change` 是一条不带过滤条件的失效通知,在提供方或运行时贡献注册或释放后,以及活动提供方的注册控制触发失效后发出。它不携带目录或 diff;每个消费方都使用自身的查找选项重新获取 `snapshot()`。监听器抛错或 Promise 拒绝会被记录,既不能否决注册表变更,也不能阻止后续监听器执行。 ### 配置 @@ -29,13 +28,13 @@ ## 提供方契约 -提供方同步注册,并在已等待的 `list(options)` 调用中执行远程设置、身份验证和发现。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。 +提供方工厂同步运行,并接收一项注册作用域内的控制能力。注册失败或释放时,`control.signal` 会中止;仅当该精确注册仍处于活动状态时,`control.invalidate()` 才会清除已完成目录,因此延迟回调无法影响同名替代项。不可变提供方可以忽略该控制能力。远程设置、身份验证和发现属于提供方需等待的 `list(options)` 调用。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。 注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读契约。 契约违反会快速失败。提供方 `list()` 被拒绝会视为瞬时来源失败:该次观测会省略其条目,`complete` 为 false,结果也不会缓存。提供方或运行时修订发生变更时,会丢弃正在进行的结果并重试后再返回。重复名称按 rank、提供方注册顺序,然后按提供方本地顺序解析。摘要按 skill 名称排序。 -定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并使该提供方实例失效,以便下一次快照重新发现其目录。 +定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并由注册表在内部使该精确提供方失效,以便下一次快照重新发现其目录。 ## 运行时 Skill @@ -55,7 +54,7 @@ ## 已知限制与待完成工作 -- **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须由自身的观测机制调用 `invalidateProvider()`。 +- **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须保留其注册作用域内的 `invalidate()` 能力,并由自身的观测机制调用它。 - **提供方依次查询**:一个缓慢的协作提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不协作提供方持续运行的工作。 - **不完整快照会在该次观测中省略失败的提供方**:注册表会报告 `complete: false`,但不负责上一份可用目录或逐提供方诊断;消费方选择是否保留先前状态。 - **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。 diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index e3a041eb7d..f103f828a0 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -117,6 +117,14 @@ export interface SkillProvider { readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise } +/** Registration-scoped lifecycle and invalidation capability borrowed by one provider. */ +export interface SkillProviderControl { + /** Aborts if registration fails or when the exact provider registration is disposed. */ + readonly signal: AbortSignal + /** Invalidate completed catalogs and notify consumers only while the exact registration remains active. */ + readonly invalidate: () => void +} + /** Skill registry configuration. */ export interface Config { /** Maximum number of completed cwd/provider catalogs kept in memory. */ @@ -180,43 +188,50 @@ export class SkillService extends Service { * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters * the provider and invalidates catalog caches. - * @param provider - the provider to register by `provider.name`. + * @param create - synchronous factory receiving this registration's lifecycle and invalidation control. * @returns the exact Cordis effect disposer that unregisters this provider; * composite effects may yield it directly to preserve teardown ordering. */ - registerProvider(provider: SkillProvider): () => void { - const name = provider.name - if (name === RUNTIME_PROVIDER) { - throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`) + registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void { + const lifecycle = new AbortController() + let active = false + let provider: SkillProvider + const control: SkillProviderControl = { + signal: lifecycle.signal, + invalidate: () => { + if (active) this.invalidateProvider(provider) + }, } - if (this.providers.has(name)) { - throw new Error(`a skill provider named "${name}" is already registered`) - } - const providers = this.providers - const order = this.nextProviderOrder - const invalidateCache = (): void => { this.invalidateCache() } - this.nextProviderOrder += 1 - const dispose = this.ctx.effect(function* () { - providers.set(name, { provider, order }) - invalidateCache() - yield () => { - providers.delete(name) - invalidateCache() + try { + provider = create(control) + const name = provider.name + if (name === RUNTIME_PROVIDER) { + throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`) } - }, 'skills.registerProvider()') - // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity - return dispose - } - - /** - * Invalidate catalogs contributed by one currently registered provider. Exact object identity - * prevents a late callback from an old provider instance from invalidating its replacement. - * Calls for an already-unregistered provider are harmless. - * @param provider - exact provider instance whose external source changed. - */ - invalidateProvider(provider: SkillProvider): void { - if (this.providers.get(provider.name)?.provider !== provider) return - this.invalidateCache() + if (this.providers.has(name)) { + throw new Error(`a skill provider named "${name}" is already registered`) + } + const providers = this.providers + const order = this.nextProviderOrder + const invalidateCache = (): void => { this.invalidateCache() } + this.nextProviderOrder += 1 + const dispose = this.ctx.effect(function* () { + active = true + providers.set(name, { provider, order }) + invalidateCache() + yield () => { + active = false + providers.delete(name) + lifecycle.abort(new Error(`skill provider "${name}" disposed`)) + invalidateCache() + } + }, 'skills.registerProvider()') + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; preserve exact disposer identity + return dispose + } catch (error) { + lifecycle.abort(error) + throw error + } } /** @@ -391,6 +406,11 @@ export class SkillService extends Service { this.notifyChange() } + private invalidateProvider(provider: SkillProvider): void { + /* v8 ignore else -- A definition load can outlive the exact provider registration it selected. */ + if (this.providers.get(provider.name)?.provider === provider) this.invalidateCache() + } + /** Notify catalog observers without making their refresh work load-bearing. */ private notifyChange(): void { for (const callback of this.ctx.events.dispatch('emit', ['skills/change'])) { diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index 2f0134eab7..fdf9c40abc 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -34,6 +34,10 @@ class MemoryProvider implements SkillProvider { } } +function registerProvider(ctx: Context, provider: SkillProvider): () => void { + return ctx.skills.registerProvider(() => provider) +} + describe('SkillService registry', () => { it('registers providers, resolves duplicates first-wins, and disposes providers', async () => { const ctx = new Context() @@ -59,8 +63,8 @@ describe('SkillService registry', () => { return { ...candidate, content: (candidate.locator as { content: string }).content } }, } - const disposeMemory = ctx.skills.registerProvider(provider) - ctx.skills.registerProvider(overrideProvider) + const disposeMemory = registerProvider(ctx, provider) + registerProvider(ctx, overrideProvider) expect((await ctx.skills.list()).map(skill => [skill.name, skill.description, skill.provider])).toEqual([ ['a-skill', 'A skill', 'memory'], @@ -84,24 +88,52 @@ describe('SkillService registry', () => { return { ...candidate, content: (candidate.locator as { content: string }).content } }, } - ctx.skills.registerProvider(sameRankProvider) + registerProvider(ctx, sameRankProvider) expect((await ctx.skills.list()).find(skill => skill.name === 'same-rank-skill')?.provider).toBe('same-rank') await expect(ctx.plugin({ name: 'duplicate-memory', inject: ['skills'], apply(pluginCtx: Context) { - pluginCtx.skills.registerProvider(new MemoryProvider([])) + registerProvider(pluginCtx, new MemoryProvider([])) }, })).rejects.toThrow('already registered') - expect(() => ctx.skills.registerProvider({ - name: 'runtime', - async list() { - return [] - }, - async get() { - return undefined - }, + let rejectedSignal: AbortSignal | undefined + expect(() => ctx.skills.registerProvider((control) => { + rejectedSignal = control.signal + return { + name: 'runtime', + async list() { + return [] + }, + async get() { + return undefined + }, + } })).toThrow('reserved') + expect(rejectedSignal?.aborted).toBe(true) + + const factoryFailure = new Error('factory failed') + let failedSignal: AbortSignal | undefined + expect(() => ctx.skills.registerProvider((control) => { + failedSignal = control.signal + throw factoryFailure + })).toThrow(factoryFailure) + expect(failedSignal?.reason).toBe(factoryFailure) + + const effectContext = new Context() + const effectService = new SkillService(effectContext) + const effectFailure = new Error('effect registration failed') + vi.spyOn(effectContext, 'effect').mockImplementation(() => { throw effectFailure }) + let effectSignal: AbortSignal | undefined + expect(() => effectService.registerProvider((control) => { + effectSignal = control.signal + return { + name: 'effect-provider', + list: () => Promise.resolve([]), + get: () => Promise.resolve(undefined), + } + })).toThrow(effectFailure) + expect(effectSignal?.reason).toBe(effectFailure) disposeMemory() expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed']) @@ -111,7 +143,7 @@ describe('SkillService registry', () => { const ctx = new Context() await ctx.plugin(SkillService) const badDescription = { value: 'object-description' } - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'bad-candidate', list: () => Promise.resolve([{ ...memorySkill('bad-candidate', 'placeholder', 1), @@ -125,7 +157,7 @@ describe('SkillService registry', () => { const badBoolean = new Context() await badBoolean.plugin(SkillService) - badBoolean.skills.registerProvider({ + registerProvider(badBoolean, { name: 'bad-boolean', list: () => Promise.resolve([{ ...memorySkill('bad-boolean', 'Bad boolean', 1), @@ -140,7 +172,7 @@ describe('SkillService registry', () => { it('rejects non-array provider results and every malformed candidate scalar', async () => { const badList = new Context() await badList.plugin(SkillService) - badList.skills.registerProvider({ + registerProvider(badList, { name: 'non-array-list', list: () => Promise.resolve({} as unknown as SkillCandidate[]), get: () => Promise.resolve(undefined), @@ -171,7 +203,7 @@ describe('SkillService registry', () => { path: '/skills/candidate/SKILL.md', ...patch, } as SkillCandidate - ctx.skills.registerProvider({ + registerProvider(ctx, { name: providerName, list: () => Promise.resolve([candidate]), get: () => Promise.resolve(undefined), @@ -195,7 +227,7 @@ describe('SkillService registry', () => { rank: 1, locator: 'skill-a', } - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'contextual', async list(received) { listedWith = received @@ -218,7 +250,7 @@ describe('SkillService registry', () => { const ctx = new Context() await ctx.plugin(SkillService) let getCalls = 0 - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'cached', async list() { return [{ @@ -267,7 +299,7 @@ describe('SkillService registry', () => { }) } }) - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'held', async list() { return [{ @@ -347,7 +379,7 @@ describe('SkillService registry', () => { } let listCalls = 0 let received: SkillCandidate | undefined - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'detached', async list() { listCalls += 1 @@ -422,7 +454,7 @@ describe('SkillService registry', () => { await ctx.plugin(SkillService) const providerName = `definition-provider-${index}` const skillName = `definition-${index}` - ctx.skills.registerProvider({ + registerProvider(ctx, { name: providerName, list: () => Promise.resolve([{ name: skillName, @@ -455,7 +487,7 @@ describe('SkillService registry', () => { const ctx = new Context() await ctx.plugin(SkillService) - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'bad', async list() { return [memorySkill('Bad_Name', 'bad', 1)] @@ -474,7 +506,7 @@ describe('SkillService registry', () => { for (const candidate of invalidCandidates) { const invalid = new Context() await invalid.plugin(SkillService) - invalid.skills.registerProvider({ + registerProvider(invalid, { name: candidate.name, async list() { return [candidate] @@ -492,7 +524,7 @@ describe('SkillService registry', () => { it('sorts model-visible summaries without locale-sensitive collation', async () => { const ctx = new Context() await ctx.plugin(SkillService) - ctx.skills.registerProvider(new MemoryProvider([ + registerProvider(ctx, new MemoryProvider([ memorySkill('z-skill', 'Z skill', 10), memorySkill('a-skill', 'A skill', 10), ])) @@ -517,7 +549,7 @@ describe('SkillService registry', () => { const ctx = new Context() await ctx.plugin(SkillService, { collectCacheMaxEntries: 1 }) const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)]) - ctx.skills.registerProvider(provider) + registerProvider(ctx, provider) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill']) provider.replace([memorySkill('second-skill', 'Second', 10)]) @@ -544,7 +576,7 @@ describe('SkillService registry', () => { let fail = true let flakyCalls = 0 - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'flaky', async list() { flakyCalls += 1 @@ -572,21 +604,27 @@ describe('SkillService registry', () => { const ctx = new Context() await ctx.plugin(SkillService) const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)]) - const dispose = ctx.skills.registerProvider(provider) + let invalidate = (): void => {} + let signal: AbortSignal | undefined + const dispose = ctx.skills.registerProvider((control) => { + invalidate = control.invalidate + signal = control.signal + return provider + }) expect((await ctx.skills.snapshot()).complete).toBe(true) provider.replace([memorySkill('second-skill', 'Second', 10)]) - ctx.skills.invalidateProvider(new MemoryProvider([])) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill']) - ctx.skills.invalidateProvider(provider) + invalidate() expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill']) dispose() + expect(signal?.aborted).toBe(true) const replacement = new MemoryProvider([memorySkill('replacement-skill', 'Replacement', 10)]) - ctx.skills.registerProvider(replacement) + registerProvider(ctx, replacement) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill']) - ctx.skills.invalidateProvider(provider) + invalidate() expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill']) expect(replacement.listCalls).toBe(1) }) @@ -598,11 +636,13 @@ describe('SkillService registry', () => { let changes = 0 ctx.on('skills/change', () => { changes += 1 }) - const disposeProvider = ctx.skills.registerProvider(provider) + let invalidate = (): void => {} + const disposeProvider = ctx.skills.registerProvider((control) => { + invalidate = control.invalidate + return provider + }) expect(changes).toBe(1) - ctx.skills.invalidateProvider(new MemoryProvider([])) - expect(changes).toBe(1) - ctx.skills.invalidateProvider(provider) + invalidate() expect(changes).toBe(2) const disposeRuntime = ctx.skills.register({ @@ -616,7 +656,7 @@ describe('SkillService registry', () => { expect(changes).toBe(4) disposeProvider() expect(changes).toBe(5) - ctx.skills.invalidateProvider(provider) + invalidate() expect(changes).toBe(5) }) @@ -632,7 +672,7 @@ describe('SkillService registry', () => { const disposeObserver = ctx.on('skills/change', () => { observed += 1 }) const provider = new MemoryProvider([]) - expect(() => ctx.skills.registerProvider(provider)).not.toThrow() + expect(() => registerProvider(ctx, provider)).not.toThrow() await Promise.resolve() expect(observed).toBe(1) expect(warnings).toEqual([ @@ -662,12 +702,16 @@ describe('SkillService registry', () => { } return await originalList(options) } - ctx.skills.registerProvider(provider) + let invalidate = (): void => {} + ctx.skills.registerProvider((control) => { + invalidate = control.invalidate + return provider + }) const pending = ctx.skills.list() await started.promise provider.replace([memorySkill('fresh-skill', 'Fresh', 10)]) - ctx.skills.invalidateProvider(provider) + invalidate() release?.() expect((await pending).map(skill => skill.name)).toEqual(['fresh-skill']) @@ -695,7 +739,7 @@ describe('SkillService registry', () => { return { ...candidate, name: 'new-name', content: 'Fresh body.' } }, } - ctx.skills.registerProvider(provider) + registerProvider(ctx, provider) expect(await ctx.skills.get('old-name')).toBeUndefined() await ctx.skills.list() @@ -705,7 +749,7 @@ describe('SkillService registry', () => { it('returns undefined when a discovered candidate disappears before loading', async () => { const ctx = new Context() await ctx.plugin(SkillService) - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'vanished-body', async list() { return [{ ...memorySkill('vanished-skill', 'Vanished', 10), provider: 'vanished-body' }] @@ -728,7 +772,7 @@ describe('SkillService registry', () => { throw new Error('provider failure coercion failed') }, } - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'hostile-failure', list() { // Deliberately violate the provider contract to prove containment is total. @@ -753,7 +797,7 @@ describe('SkillService registry', () => { let release: (() => void) | undefined const started = new Promise((resolve) => { markStarted = resolve }) const gate = new Promise((resolve) => { release = resolve }) - const dispose = ctx.skills.registerProvider({ + const dispose = registerProvider(ctx, { name: 'delayed', async list() { markStarted?.() @@ -783,7 +827,7 @@ describe('SkillService registry', () => { const held = new Promise((resolve) => { release = () => { resolve([]) } }) - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'uncooperative', list(options) { seenSignal = options.signal diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index c08df2f984..028f0f7869 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -153,7 +153,7 @@ describe('dsh-tool-skill', () => { const home = await tempDir('tool-prefix-signal') const ctx = await setup(home) let seenSignal: AbortSignal | undefined - ctx.skills.registerProvider({ + ctx.skills.registerProvider(() => ({ name: 'signal-probe', async list(options) { seenSignal = options.signal @@ -162,7 +162,7 @@ describe('dsh-tool-skill', () => { async get() { return undefined }, - }) + })) const controller = new AbortController() await composePrefix(ctx, '/workspace', controller.signal) @@ -245,7 +245,11 @@ describe('dsh-tool-skill', () => { return undefined }, } - ctx.skills.registerProvider(provider) + let invalidate = (): void => {} + ctx.skills.registerProvider((control) => { + invalidate = control.invalidate + return provider + }) const session = new Session(SessionId('incomplete-prefix')) const agent = sessionAgent(session) openMessageTurn(session) @@ -253,7 +257,7 @@ describe('dsh-tool-skill', () => { await composePrefixForAgent(ctx, agent) expect(catalogMessages(session)).toEqual([]) failing = false - ctx.skills.invalidateProvider(provider) + invalidate() await fireStep(ctx, agent, 1, 1) expect(catalogMessages(session)).toEqual([]) @@ -421,7 +425,7 @@ describe('dsh-tool-skill', () => { openMessageTurn(session) expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('stable-skill') - ctx.skills.registerProvider({ + ctx.skills.registerProvider(() => ({ name: 'failing', async list() { throw new Error('temporarily unavailable') @@ -429,7 +433,7 @@ describe('dsh-tool-skill', () => { async get() { return undefined }, - }) + })) disposeStable() await fireStep(ctx, agent, 1, 1) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 501aa6c5c5..bc11ff68b8 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -3596,6 +3596,7 @@ describe('skill slash command', () => { it('retains last-good slash completions across incomplete snapshots', async () => { let skills: SkillService | undefined let provider: SkillProvider | undefined + let invalidate = (): void => {} let fail = false const result = await setup({ configureContext: async (ctx) => { @@ -3619,13 +3620,16 @@ describe('skill slash command', () => { return undefined }, } - skills?.registerProvider(provider) + skills?.registerProvider((control) => { + invalidate = control.invalidate + return provider as SkillProvider + }) }, }) if (skills === undefined || provider === undefined) throw new Error('skills provider not mounted') fail = true - skills.invalidateProvider(provider) + invalidate() await tick() result.terminal.output = '' result.terminal.send('/skill:stable') diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 23b114276d..f16711ab1a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -105,6 +105,7 @@ export const LINK_MAP: Record = { PreparedLlmCall: 'llm-streaming.md', LlmService: 'llm-streaming.md', StreamChunk: 'llm-streaming.md', + SkillProviderControl: 'skills.md', CreateSessionOptions: 'persistence.md', SessionHeader: 'persistence.md', SessionLocation: 'persistence.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 160f3c33c2..53fb6b4a33 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1009,6 +1009,11 @@ "symbol": "SkillProvider", "source": "packages/skill/skill/src/index.ts" }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillProviderControl", + "source": "packages/skill/skill/src/index.ts" + }, { "doc": "docs/core-data-structures/skills.md", "symbol": "Config", From 709d545e7c60185f1084a95835a5a3e4076bf928 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:53:47 +0800 Subject: [PATCH 04/32] fix(skill): re-probe retained root watchers --- ...-07-27-skill-catalog-hot-refresh.i18n.yaml | 4 +- .../2026-07-27-skill-catalog-hot-refresh.md | 4 +- ...2026-07-27-skill-catalog-hot-refresh.zh.md | 4 +- packages/skill/skill-local/src/index.ts | 27 ++++++-- .../tests/skill-local-watcher.spec.ts | 62 ++++++++++++++++++- 5 files changed, 87 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml index ba65c7d05d..c89c716b13 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.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-27-skill-catalog-hot-refresh.md -2026-07-27-skill-catalog-hot-refresh.md: 8a53195dd8c4880c5cfa758ccf666ae88b2e1030 -2026-07-27-skill-catalog-hot-refresh.zh.md: 8cf55534460d2926be706353afd2019fad1bb45d +2026-07-27-skill-catalog-hot-refresh.md: e7cff2cb53a044ed0c4789cef3550652903f3586 +2026-07-27-skill-catalog-hot-refresh.zh.md: 86519c93880f94b1b9d3bdc011aa09b04ca10686 diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md index 8a53195dd8..e7cff2cb53 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md @@ -16,7 +16,7 @@ The skill capability separates catalog membership from instruction-body loading. `@deepseek-ai/dsh-skill-local` directly depends on Chokidar and observes catalog-relevant host paths. Existing roots watch direct skill bundle directories, flat Markdown entries, and direct `SKILL.md` entry files. Additions, removals, and directory changes invalidate membership; file changes support frontmatter `name` and `description` refresh. Resource files below a bundle are ignored. Events in one microtask batch coalesce to one invalidation. Project watchers use a bounded least-recently-observed set. -A missing root is followed from its nearest existing ancestor one absent segment at a time with `fs.watchFile`, then handed to Chokidar once the real root exists. Deleting a root re-establishes ancestor observation. Chokidar configuration exposes native-versus-polling mode, write stability, polling interval, symlink following, and project watcher capacity. First-party `write` and `edit` tool observations synchronously invalidate a relevant provider, so the next model step sees its own mutation without waiting for host delivery. Watch startup/runtime failures make discovery incomplete and retry; teardown closes watchers and ignores late callbacks. +A missing root is followed from its nearest existing ancestor one absent segment at a time with `fs.watchFile`, then handed to Chokidar once the real root exists. Before scanning, each discovery re-probes the retained root/ancestor mode. That independent probe re-establishes ancestor observation after deletion even when child removals invalidate and publish an authoritative empty catalog before, or without, a root `unlinkDir` event. Chokidar configuration exposes native-versus-polling mode, write stability, polling interval, symlink following, and project watcher capacity. First-party `write` and `edit` tool observations synchronously invalidate a relevant provider, so the next model step sees its own mutation without waiting for host delivery. Watch startup/runtime failures make discovery incomplete and retry; teardown closes watchers and ignores late callbacks. `@deepseek-ai/dsh-tool-skill` injects the first non-empty complete catalog as a durable sourced `user/message` on the first complete `agent/step` that observes one. At every `agent/step` it applies exact `skill` tool visibility, hashes the exact rendered text between the `` tags, and scans the read-only session events backwards without copying them for the newest recognizable visible catalog from this plugin. A changed digest appends a durable, complete replacement through `agent.inject()`, including an explicit empty catalog when all skills disappear. If no catalog remains visible but a recognizable one exists in historical events, compaction hid it and the next complete observation re-establishes the current catalog, including an empty tombstone. A current empty catalog with no historical publication emits nothing, while an incomplete snapshot preserves the last-good model view. The backward scan normally stops at the newest visible catalog; when compaction hides every catalog it pays an O(session-events) scan to recover that fact. @@ -26,7 +26,7 @@ Instruction bodies keep progressive disclosure. Every `skill(name)` call asks th ## Verification -Registry tests pin registration-scoped invalidation, revocation, signal abort, contained observer failures, incomplete snapshots, generation retries, and stale-name rejection. Local-provider tests cover bundle and flat-file creation, removal, rename, root creation/deletion/recreation, description changes, body-only edits, first-party observation, symlinks, polling options, watcher failures, event coalescing, bounded projects, teardown, and transient reads. Tool tests pin full replacement messages, empty tombstones, digest stability for body-only edits, incomplete-state retention, visibility, and resume metadata. TUI tests pin last-complete retention, authoritative empty removal, latest-wins refresh, teardown, and the already-open slash-draft race; a real Loader/PTY smoke adds a local skill after startup and observes its completion without restarting. A keyless assembled agent-spine snapshot creates a project skill through model-facing filesystem tools, observes its replacement catalog on the next request, and loads its current body with the real `skill` tool. +Registry tests pin registration-scoped invalidation, revocation, signal abort, contained observer failures, incomplete snapshots, generation retries, and stale-name rejection. Local-provider tests cover bundle and flat-file creation, removal, rename, root creation/deletion/recreation including an unobserved root `unlinkDir`, description changes, body-only edits, first-party observation, symlinks, polling options, watcher failures, event coalescing, bounded projects, teardown, and transient reads. Tool tests pin full replacement messages, empty tombstones, digest stability for body-only edits, incomplete-state retention, visibility, and resume metadata. TUI tests pin last-complete retention, authoritative empty removal, latest-wins refresh, teardown, and the already-open slash-draft race; a real Loader/PTY smoke adds a local skill after startup and observes its completion without restarting. A keyless assembled agent-spine snapshot creates a project skill through model-facing filesystem tools, observes its replacement catalog on the next request, and loads its current body with the real `skill` tool. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md index 8cf5553446..86519c9388 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md @@ -16,7 +16,7 @@ skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snaps `@deepseek-ai/dsh-skill-local` 直接依赖 Chokidar,并观察与目录相关的宿主路径。已有根目录会监视其直属 skill bundle 目录、平铺的 Markdown 条目和直属 `SKILL.md` 条目文件。新增、移除和目录变更会使成员关系失效;文件变更还支持刷新 frontmatter 中的 `name` 和 `description`。bundle 内更深层的资源文件会被忽略。同一微任务批次中的事件会合并为一次失效。项目 watcher 使用有界集合,并按最久未观察顺序淘汰。 -系统从缺失根目录最近的现有祖先开始,使用 `fs.watchFile` 每次跟进一层缺失路径片段;真实根目录出现后,再交给 Chokidar。删除根目录后,系统会重新建立祖先观察。Chokidar 配置公开原生事件或轮询模式、写入稳定性、轮询间隔、符号链接跟随选项和项目 watcher 容量。第一方 `write` 和 `edit` 工具观察会同步使相关提供方失效,因此下一个模型步骤无需等待宿主事件投递,就能看到自身改动。watcher 启动或运行失败会使发现结果不完整并触发重试;资源销毁会关闭 watcher,并忽略延迟回调。 +系统从缺失根目录最近的现有祖先开始,使用 `fs.watchFile` 每次跟进一层缺失路径片段;真实根目录出现后,再交给 Chokidar。每次发现操作都会在扫描前重新探测所保留的根目录/祖先模式。即使子项移除在根目录 `unlinkDir` 事件之前就触发失效并发布权威空目录,或者该事件根本没有到达,这项独立探测也会在删除后重新建立祖先观察。Chokidar 配置公开原生事件或轮询模式、写入稳定性、轮询间隔、符号链接跟随选项和项目 watcher 容量。第一方 `write` 和 `edit` 工具观察会同步使相关提供方失效,因此下一个模型步骤无需等待宿主事件投递,就能看到自身改动。watcher 启动或运行失败会使发现结果不完整并触发重试;资源销毁会关闭 watcher,并忽略延迟回调。 `@deepseek-ai/dsh-tool-skill` 在 `agent/step` 首次观察到非空完整目录时,将该目录注入为一条持久且带来源的 `user/message`。每次 `agent/step`,它都会应用 `skill` 工具的精确可见性,对 `` 标签之间精确渲染的文本计算哈希,并从后向前扫描只读会话事件且不复制,以查找该插件发布的最新一条可识别且仍可见的目录。digest 变化时,插件通过 `agent.inject()` 追加一份持久的完整替换目录;所有 skill 消失时,也会追加显式空目录。如果没有目录仍然可见,但历史事件中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录,包括空 tombstone。如果当前目录为空且历史上从未发布目录,则不发送任何内容;不完整快照则保留最后一次完整的模型视图。反向扫描通常在最新且仍可见的目录处停止;当压缩遮蔽所有目录时,它会以一次 O(session-events) 扫描的成本确认这一事实。 @@ -26,7 +26,7 @@ TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills ## 验证 -注册表测试固定了注册作用域内的失效、能力撤销、信号中止、监听器失败隔离、不完整快照、generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、watcher 失败、事件合并、项目 watcher 容量上限、资源销毁和暂时读取。工具测试固定了完整替换消息、空 tombstone、仅修改正文时 digest 稳定、不完整状态保留、可见性和恢复元数据。TUI 测试固定了上一份完整结果保留、权威空结果清除、刷新时以最新结果为准、资源销毁和已打开斜杠草稿的竞态;一项使用真实 Loader/PTY 的 smoke 测试会在启动后添加本地 skill,并观察其补全项出现,而无需重启。一个无密钥、装配完成的 agent-spine 快照测试通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并使用真实 `skill` 工具加载当前正文。 +注册表测试固定了注册作用域内的失效、能力撤销、信号中止、监听器失败隔离、不完整快照、generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建(包括未观测到根目录 `unlinkDir` 事件的情形)、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、watcher 失败、事件合并、项目 watcher 容量上限、资源销毁和暂时读取。工具测试固定了完整替换消息、空 tombstone、仅修改正文时 digest 稳定、不完整状态保留、可见性和恢复元数据。TUI 测试固定了上一份完整结果保留、权威空结果清除、刷新时以最新结果为准、资源销毁和已打开斜杠草稿的竞态;一项使用真实 Loader/PTY 的 smoke 测试会在启动后添加本地 skill,并观察其补全项出现,而无需重启。一个无密钥、装配完成的 agent-spine 快照测试通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并使用真实 `skill` 工具加载当前正文。 ## 考虑过的替代方案 diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index dbf3a3c0b6..4ac5f101cd 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -252,6 +252,7 @@ interface RootWatchState { } interface WatchHandle { + mode: RootWatchMode close(): Promise | void } @@ -348,9 +349,8 @@ class SkillWatchManager { private ensureWatcher(state: RootWatchState): Promise { if (this.closing || !this.config.enabled) return Promise.resolve() - if (state.watcher !== undefined && !state.unhealthy) return Promise.resolve() if (state.opening !== undefined) return state.opening - const opening = this.replaceWatcher(state) + const opening = this.ensureCurrentWatcher(state) state.opening = opening void opening.then( () => { @@ -363,6 +363,18 @@ class SkillWatchManager { return opening } + private async ensureCurrentWatcher(state: RootWatchState): Promise { + const watcher = state.watcher + if (watcher !== undefined && !state.unhealthy) { + const current = await resolveRootWatchMode(state.root.path) + // A child unlink can publish an empty catalog before root unlinkDir arrives. + // Discovery therefore revalidates the retained handle independently. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- watcher callbacks can mark unhealthy while the probe awaits + if (!state.unhealthy && sameWatchMode(watcher.mode, current)) return + } + await this.replaceWatcher(state) + } + private async replaceWatcher(state: RootWatchState): Promise { const previous = state.watcher state.watcher = undefined @@ -389,7 +401,7 @@ class SkillWatchManager { } } - // FIXME(file-watch-service): Extract Chokidar and missing-root observation below into a Cordis + // TODO(file-watch-service): Extract Chokidar and missing-root observation below into a Cordis // service; keep skill filtering and invalidation here. private async openStableWatcher(state: RootWatchState): Promise { while (!this.closing && state.owners.size > 0) { @@ -416,6 +428,7 @@ class SkillWatchManager { interval: this.config.pollIntervalMs, }, listener) return { + mode, close() { unwatchFile(mode.nextPath, listener) }, @@ -436,6 +449,10 @@ class SkillWatchManager { usePolling: this.config.usePolling, interval: this.config.pollIntervalMs, }) + const handle: WatchHandle = { + mode, + close: () => watcher.close(), + } let ready = false const readiness = Promise.withResolvers() const onError = (error: unknown): void => { @@ -456,10 +473,10 @@ class SkillWatchManager { try { await readiness.promise } catch (error) { - await this.closeWatcher(watcher) + await this.closeWatcher(handle) throw error } - return watcher + return handle } private handleWatchEvent( diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts index c13f4f1542..97ab8c9ee7 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'node:events' -import { mkdir, writeFile } from 'node:fs/promises' +import type { Stats } from 'node:fs' +import { mkdir, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -12,13 +13,33 @@ interface FakeWatcherControl { options: Record } +interface FakeWatchFileControl { + path: string + listener(current: Stats, previous: Stats): void +} + const watcherHarness = vi.hoisted(() => ({ watchers: [] as FakeWatcherControl[], startupErrors: [] as Error[], closeErrors: 0, deferredReady: 0, + watchFiles: [] as FakeWatchFileControl[], })) +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + watchFile(path: string, _options: unknown, listener: FakeWatchFileControl['listener']) { + watcherHarness.watchFiles.push({ path, listener }) + }, + unwatchFile(path: string, listener: FakeWatchFileControl['listener']) { + const index = watcherHarness.watchFiles.findIndex(control => control.path === path && control.listener === listener) + if (index !== -1) watcherHarness.watchFiles.splice(index, 1) + }, + } +}) + vi.mock('chokidar', () => ({ default: { watch(_path: unknown, options: Record) { @@ -67,6 +88,7 @@ beforeEach(() => { watcherHarness.startupErrors.length = 0 watcherHarness.closeErrors = 0 watcherHarness.deferredReady = 0 + watcherHarness.watchFiles.length = 0 }) describe('skill-local watcher failures', () => { @@ -158,6 +180,40 @@ describe('skill-local watcher failures', () => { await settle() }) + it('re-probes a retained root after child unlink and observes immediate recreation', async () => { + const home = await tempDir('skill-watch-root-reprobe') + const root = join(home, '.dsh/skills') + await writeSkill(root, 'old-skill') + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchPollIntervalMs: 10, + watchStabilityThresholdMs: 20, + }) + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['old-skill']) + const original = watcherHarness.watchers[0] + if (original === undefined) throw new Error('expected a root watcher') + + await rm(root, { recursive: true }) + original.emitter.emit('unlink', join(root, 'old-skill/SKILL.md')) + await settle() + expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true }) + + const missingRoot = watcherHarness.watchFiles.find(control => control.path === root) + expect(missingRoot).toBeDefined() + await writeSkill(root, 'recreated-skill') + missingRoot!.listener({} as Stats, {} as Stats) + await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(2) }) + await settle() + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['recreated-skill']) + await fiber.dispose() + }) + it('settles an opening watcher when plugin disposal races its ready event', async () => { const home = await tempDir('skill-watch-opening-dispose') const root = join(home, '.dsh/skills') @@ -178,7 +234,7 @@ describe('skill-local watcher failures', () => { }) const discovery = provider.list({}) - await settle() + await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(1) }) const first = watcherHarness.watchers[0] if (first === undefined) throw new Error('expected an opening root watcher') first.emitter.emit('unlinkDir', root) @@ -211,7 +267,7 @@ describe('skill-local watcher failures', () => { }) const discovery = provider.list({}) - await settle() + await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(1) }) const first = watcherHarness.watchers[0] if (first === undefined) throw new Error('expected an opening root watcher') const disposal = provider.dispose() 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 05/32] 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 06/32] 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 07/32] 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 08/32] 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 09/32] 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 10/32] 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 45c9205cafa5f37b3f7ee9a21db04d9ee2505297 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Wed, 29 Jul 2026 19:46:48 +0800 Subject: [PATCH 11/32] feat(client): localize composer hints and rework input command interaction Unify the /plan claimed hint with the plan placeholder through a locale namespace, localize slash menu group titles, replace the PermissionSelect native select with the Menu primitive, add a goal pause verb chain, clamp anchored popups to the viewport with scroll-into-view and outside-dismiss, and fix onPasteUpgrade insertedRange to account for the chip trailing gap. --- .../src/client/PopupSelectView.module.css | 30 +++-- .../ui-command/src/client/PopupSelectView.tsx | 34 ++++-- .../ui-command/tests/popup-view.spec.tsx | 40 ++++++- packages/client/ui-conversation/package.json | 5 +- .../ui-conversation/src/client/apply.ts | 32 ++++- .../src/client/contract/slots.ts | 2 + .../src/client/input/machine.ts | 21 +++- .../src/client/skeleton/InputBar.module.css | 28 ++--- .../src/client/skeleton/InputBar.tsx | 17 ++- .../skeleton/PermissionSelect.module.css | 70 +++++------ .../src/client/skeleton/PermissionSelect.tsx | 88 +++++++------- .../tests/apply-inject.spec.tsx | 2 + .../ui-conversation/tests/chat-apply.spec.tsx | 2 + .../tests/chat-code-subcalls.spec.tsx | 3 +- .../tests/chat-toolview-slot.spec.tsx | 3 + .../ui-conversation/tests/input-bar.spec.tsx | 43 +++++-- .../tests/input-machine.spec.ts | 22 ++-- .../tests/input-matrix.spec.tsx | 1 + .../tests/input-scenarios.spec.tsx | 1 + .../ui-conversation/tests/skeleton.spec.tsx | 1 + packages/client/ui-conversation/tsconfig.json | 3 + .../ui-goal/src/client/GoalBar.module.css | 2 +- .../client/ui-goal/src/client/GoalBar.tsx | 12 +- packages/client/ui-goal/src/client/index.ts | 5 + packages/client/ui-goal/src/client/slots.ts | 2 + .../ui-goal/tests/browser-plugin.spec.tsx | 8 +- .../client/ui-goal/tests/goalbar.spec.tsx | 1 + .../client/ui-permission/src/client/index.ts | 13 ++- .../src/client/PlanModeControl.module.css | 3 - .../client/ui-primitives/src/icons/index.tsx | 12 ++ packages/client/ui-primitives/src/index.ts | 1 + .../ui-primitives/src/useAnchoredMaxHeight.ts | 38 ++++++ .../client/ui-primitives/tests/icons.spec.tsx | 4 +- packages/client/ui-skill/src/client/index.ts | 1 + packages/client/ui-slash/package.json | 7 +- .../ui-slash/src/client/MenuView.module.css | 20 +++- .../client/ui-slash/src/client/MenuView.tsx | 110 ++++++++++++------ .../client/ui-slash/src/client/controller.ts | 7 ++ packages/client/ui-slash/src/client/index.ts | 18 ++- .../client/ui-slash/src/client/service.ts | 2 +- packages/client/ui-slash/src/client/slots.ts | 9 ++ packages/client/ui-slash/src/types.ts | 2 + packages/client/ui-slash/tests/apply.spec.ts | 22 +++- .../client/ui-slash/tests/menu-view.spec.tsx | 83 +++++++++++-- packages/client/ui-slash/tsconfig.json | 6 + pnpm-lock.yaml | 12 ++ 46 files changed, 623 insertions(+), 225 deletions(-) create mode 100644 packages/client/ui-primitives/src/useAnchoredMaxHeight.ts diff --git a/packages/client/ui-command/src/client/PopupSelectView.module.css b/packages/client/ui-command/src/client/PopupSelectView.module.css index 14cf581e13..16e1e5c11d 100644 --- a/packages/client/ui-command/src/client/PopupSelectView.module.css +++ b/packages/client/ui-command/src/client/PopupSelectView.module.css @@ -13,8 +13,10 @@ display: flex; flex-direction: column; min-width: 220px; + /* Height cap: the 320px design maximum, clamped at runtime to the space + * above the composer (inline max-height set in PopupSelectView.tsx). */ max-height: 320px; - overflow-y: auto; + overflow: hidden; /* Elevated surface: the scrollbar thumb takes the l2 elevation tokens (see ui-theme styles/scrollbar.css for the rebinding contract). */ --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); @@ -26,6 +28,13 @@ outline: none; } +.viewport { + display: flex; + flex-direction: column; + min-height: 0; + overflow-y: auto; +} + .row { display: flex; align-items: center; @@ -34,11 +43,11 @@ border-radius: 8px; cursor: pointer; font-size: 13px; - color: var(--dsw-alias-text-primary); + color: var(--dsw-alias-label-primary); } .rowActive { - background: var(--dsw-alias-fill-hover); + background: var(--dsw-alias-interactive-bg-hover); } .label { @@ -50,19 +59,20 @@ .detail { font-size: 12px; - color: var(--dsw-alias-text-tertiary); + color: var(--dsw-alias-label-tertiary); white-space: nowrap; } .check { display: inline-flex; - color: var(--dsw-alias-text-secondary); + flex: none; + color: var(--dsw-alias-label-primary); } .status { - padding: 8px; - font-size: 12px; - color: var(--dsw-alias-text-tertiary); + padding: 8px 10px; + font-size: 13px; + color: var(--dsw-alias-label-tertiary); } .search { @@ -72,7 +82,7 @@ border-radius: 8px; background: transparent; font-size: 13px; - color: var(--dsw-alias-text-primary); + color: var(--dsw-alias-label-primary); outline: none; } @@ -97,6 +107,6 @@ border-radius: 6px; background: transparent; font-size: 12px; - color: var(--dsw-alias-text-primary); + color: var(--dsw-alias-label-primary); cursor: pointer; } diff --git a/packages/client/ui-command/src/client/PopupSelectView.tsx b/packages/client/ui-command/src/client/PopupSelectView.tsx index 9d2807ded1..ec0bbdd2bb 100644 --- a/packages/client/ui-command/src/client/PopupSelectView.tsx +++ b/packages/client/ui-command/src/client/PopupSelectView.tsx @@ -3,19 +3,23 @@ * store into the conversation.input.overlay anchor. Unlike the slash menu * (combobox — textarea keeps focus), this shell HOLDS focus while open: the * inner search input takes focus, plain typing filters the loaded options - * locally, Enter/↑↓ drive the filtered highlight, Escape dismisses back to - * the composer, and ←→ keep the search input's native caret. Any pointer - * interaction outside the box dismisses (the click's own target takes - * focus). Closed state renders null; the overlay slot stays mounted. + * locally, Enter/↑↓ drive the filtered highlight (scrolled into view), Escape + * dismisses back to the composer, and ←→ keep the search input's native + * caret. Any pointer interaction outside the box dismisses (the click's own + * target takes focus). Closed state renders null; the overlay slot stays + * mounted. The card height clamps to the space above the composer. */ import { useEffect, useRef } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' -import { IconCheckOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' import { filterOptions } from './popup.ts' import type { PopupSelectController } from './popup.ts' import css from './PopupSelectView.module.css' +/** Design cap on the card height (same MenuDropdown family as the slash menu). */ +const MAX_HEIGHT = 320 + /** Injected business face of the popupSelect overlay entry. */ export interface PopupSelectInjected { /** The session's shell controller (state store + verbs; the view never touches the open-context type). */ @@ -34,6 +38,17 @@ export function PopupSelectView({ popup }: PopupSelectInjected) { ) const cardRef = useRef(null) const searchRef = useRef(null) + // The card is bottom-anchored above the composer; clamp the design cap to + // the space above it, re-measured on every store update. + const maxHeight = useAnchoredMaxHeight(cardRef, MAX_HEIGHT, state) + const active = state.open ? state.active : null + + // The search input keeps focus while arrows move a virtual highlight, so + // the browser never scrolls the active row into view — do it here. + useEffect(() => { + if (active === null) return + cardRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }) + }, [active]) // Focus ownership: the search input grabs on open (the design's // transient-layer rule), and ANY outside pointer interaction dismisses — @@ -42,7 +57,6 @@ export function PopupSelectView({ popup }: PopupSelectInjected) { // takes focus naturally, so no focusComposer here. useEffect(() => { if (!state.open) return - searchRef.current?.focus() const onPointerDown = (ev: PointerEvent): void => { if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return popup.dismiss() @@ -51,6 +65,11 @@ export function PopupSelectView({ popup }: PopupSelectInjected) { return () => { document.removeEventListener('pointerdown', onPointerDown, true) } }, [state.open, popup]) + // Focus the search input after it mounts (separate effect so the ref is populated). + useEffect(() => { + if (state.open) searchRef.current?.focus() + }, [state.open]) + if (!state.open) return null const rows = filterOptions(state.options, state.search) @@ -83,6 +102,7 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
@@ -108,7 +128,7 @@ export function PopupSelectView({ popup }: PopupSelectInjected) { {state.submitting &&
Applying…
} {state.status === 'ready' && rows.length === 0 &&
No options
} {state.status === 'ready' && ( -
+
{rows.map((option, index) => (
{ + Element.prototype.scrollIntoView = scrollIntoView + scrollIntoView.mockClear() +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) const OPTIONS: SelectOption[] = [ { id: 'dark', label: 'Dark' }, @@ -87,6 +98,27 @@ describe('PopupSelectView', () => { expect(fireEvent.keyDown(search, { key: 'ArrowRight' })).toBe(true) }) + it('scrolls the highlighted row into view when the highlight moves', async () => { + const { search } = await mountOpen() + scrollIntoView.mockClear() + act(() => { fireEvent.keyDown(search, { key: 'ArrowDown' }) }) + const options = screen.getAllByRole('option') + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' }) + expect(scrollIntoView.mock.instances.at(-1)).toBe(options[1]) + }) + + it('caps the card height at the design maximum when the composer sits low enough', async () => { + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect) + await mountOpen() + expect(screen.getByLabelText('/theme options').style.maxHeight).toBe('320px') + }) + + it('clamps the card height to the space above the composer minus the safe margin', async () => { + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect) + await mountOpen() + expect(screen.getByLabelText('/theme options').style.maxHeight).toBe('188px') + }) + it('Enter selects the highlighted row: onSelect, consume, close, focusComposer', async () => { const seen: Array<{ option: SelectOption; context: string }> = [] const { view, search, consume, focusComposer } = await mountOpen({ diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 42812dce24..8a9173982b 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -39,6 +39,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", @@ -48,10 +49,10 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 2b845e6c83..f5aa3caa23 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -3,6 +3,8 @@ import type { Context } from 'cordis' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' import type { ViewTab } from './contract/views.ts' import type { ApprovalWait, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected, @@ -25,7 +27,7 @@ import { ConversationSession } from './skeleton/ConversationSession.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx' /** Services required by the conversation plugin. */ -export const inject = ['slots', 'layout', 'sessions', 'workspaces'] +export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale'] /** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */ function scopedConversation(sessions: ISessions, id: SessionId): IConversation { @@ -50,6 +52,33 @@ export function apply(ctx: Context): void { const layout = ctx.layout const slots = ctx.slots + // Command hint locale: friendly placeholder text for claimed commands. The + // claimed /plan hint and the plan-mode textarea placeholder share one + // string: both describe the same next action. + const HINT_NS = 'command.hint' + const PLAN_HINT_ZH = '描述你的任务以生成计划' + const PLAN_HINT_EN = 'describe your task to generate plan' + ctx.effect(() => { + const disposers = [ + ctx.locale.register(HINT_NS, 'zh', { + plan: PLAN_HINT_ZH, + goal: '输入目标,智能体将持续执行', + 'goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除', + 'placeholder.plan': PLAN_HINT_ZH, + 'placeholder.default': '给智能体发消息', + }), + ctx.locale.register(HINT_NS, 'en', { + plan: PLAN_HINT_EN, + goal: 'describe the objective for a long-running task', + 'goal.active': 'goal active — edit / pause / resume / clear', + 'placeholder.plan': PLAN_HINT_EN, + 'placeholder.default': 'Message the agent', + }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-conversation: command hint dictionaries') + const translateHint = ctx.locale.bind(HINT_NS) + // Apply-time construction keeps store identity bound to this fiber. const chatStore = createChatStore() @@ -159,6 +188,7 @@ export function apply(ctx: Context): void { const result = await session.command(line) return result.ok && result.value.matched }, + translateHint, hooks: { notices: shell.notices, lexicon: shell.lexicon }, } }, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 9f3f3edf02..6b3d5c2fae 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -257,6 +257,8 @@ export interface ComposerBarInjected { * Resolves admission: false = rejected/unmatched/transport failure. */ command: (line: string) => Promise + /** Locale-aware hint translator for claimed command placeholders. */ + translateHint: (key: string) => string /** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */ hooks: { /** Latest surfaced notice (null after none; seq keys re-render of repeats). */ diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index f9c5a479a4..48f6ddd872 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -297,14 +297,23 @@ export class InputMachine { return [] } - /** Shared chip-insertion transaction: replace [span) with one placeholder occurrence (insert-ref and paste-upgrade both land here). */ - private replaceSpanWithChip(reference: ReferenceInsert, span: TokenSpan): void { + /** + * Shared chip-insertion transaction: replace [span) with one placeholder + * occurrence (insert-ref and paste-upgrade both land here). A separating + * space follows the chip unless one is already next. + * @returns the inserted length (placeholder plus optional gap). + */ + private replaceSpanWithChip(reference: ReferenceInsert, span: TokenSpan): number { this.pushTxn() this.typingRun = undefined - this.reconcile({ start: span.start, end: span.end, insertedLength: 1 }) + const tail = this.draft.slice(span.end) + const gap = tail.length === 0 || tail[0] !== ' ' ? ' ' : '' + const inserted = PLACEHOLDER + gap + this.reconcile({ start: span.start, end: span.end, insertedLength: inserted.length }) this.withMinted([this.mint(reference, span.start)]) - this.adopt(this.draft.slice(0, span.start) + PLACEHOLDER + this.draft.slice(span.end)) + this.adopt(this.draft.slice(0, span.start) + inserted + tail) this.watchClaim() + return inserted.length } /** @@ -442,10 +451,10 @@ export class InputMachine { if (attempt === undefined || attempt.attemptId !== attemptId) return [] if (this.phase !== 'plain' && this.phase !== 'claimed') return [] if (!this.casOk(span) || span.start === span.end) return [] - this.replaceSpanWithChip(reference, span) + const insertedLength = this.replaceSpanWithChip(reference, span) this.paste = { ...attempt, - insertedRange: { start: attempt.insertedRange.start, end: attempt.insertedRange.end + 1 - (span.end - span.start) }, + insertedRange: { start: attempt.insertedRange.start, end: attempt.insertedRange.end + insertedLength - (span.end - span.start) }, } return [] } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 1bada4391d..8837752830 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -125,20 +125,18 @@ position: absolute; inset: 0; overflow: hidden; - color: transparent; + color: var(--dsw-alias-label-primary); pointer-events: none; } .hlToken { - border-radius: 4px; - /* GOAL 稿 amber token emphasis (state warn pair; glyphs stay the textarea's). */ - background: var(--dsw-alias-state-warn-tertiary); - color: transparent; + background-color: transparent; + color: var(--dsw-alias-state-warn-label); } .hlSegment { border-radius: 4px; - background: var(--dsw-alias-interactive-bg-hover); + background-color: transparent; color: transparent; } @@ -170,7 +168,7 @@ border: none; outline: none; background: transparent; - color: var(--dsw-alias-label-primary); + color: transparent; /* Business blue, not brand-primary: that token resolves to ink in this sheet. */ caret-color: var(--dsw-alias-state-business-primary); } @@ -348,25 +346,13 @@ draft's own glyphs — advance untouched, so the two layers cannot drift. Chip family colors; clone keeps rounded ends on soft-wrap fragments. */ .textRef { - color: transparent; background-color: transparent; + color: var(--dsw-alias-state-business-primary); box-decoration-break: clone; -webkit-box-decoration-break: clone; - position: relative; } .textRef:after { - content: ""; - position: absolute; - left: 0; - top: 0; - - width: 100%; - height: 100%; - - border-radius: 6px; - background: rgba(97, 135, 216, 0.22); - transform: translate(-2px, -1px); - padding: 2px 4px; + display: none; } /* Reference chip: rendered in the backdrop at the placeholder offset. Hard diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index a98ee09114..331e600bff 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -13,6 +13,8 @@ import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: the `plan` projection key merge (the TodoDock posture — the // composer reads a host-computed value; the domain owns the key). import type {} from '@deepseek-ai/dsh-plan-mode/client' +// Type-only: the `goal` projection key merge (hint disambiguation). +import type {} from '@deepseek-ai/dsh-goal/client' import type { ComposerBarProps } from '../contract/slots.ts' import { deriveDecorations } from '../input/decorations.ts' import { PermissionSelect } from './PermissionSelect.tsx' @@ -27,7 +29,7 @@ export interface InputBarError { export type InputBarProps = ComposerBarProps export function InputBar({ - useSession, useInput, inputActions, keyboard, stop, command, renderSlot, useNotices, useLexicon, useProjection, + useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon, useProjection, variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment', }: InputBarProps) { const input = useInput(s => s) @@ -39,6 +41,8 @@ export function InputBar({ // Plan mode swaps the textarea placeholder (the projection is the folded // host value; owner-prop placeholders — hero, session-unavailable — win). const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active)) + // Absent (undefined: no frame yet) and cleared (null) both mean no goal. + const hasGoal = useProjection('goal', goal => goal != null) // Prompt failures are ordinary failures (no create/attach transaction // exists anymore): the strip renders promptError, the draft stays in the // machine, and the user resubmits. @@ -296,7 +300,12 @@ export function InputBar({ } pushPlain(draft.length) if (deco.hint !== null) { - backdrop.push({deco.hint}) + // Claim tokens are shaped `/name ` (trailing space); trim to the bare name. + const commandName = input.claim?.token.slice(1).trim() ?? '' + const hintKey = commandName === 'goal' && hasGoal ? 'goal.active' : commandName + const translated = translateHint(hintKey) + const displayHint = translated !== hintKey ? translated : deco.hint + backdrop.push({displayHint}) } } @@ -312,7 +321,7 @@ export function InputBar({ {notice.text}
)} -
+
{overlay !== undefined &&
{overlay}
} {accessory !== undefined &&
{accessory}
} {/* Mirror-div auto-grow: the hidden mirror renders draft+'\n' and stretches the wrapper @@ -329,7 +338,7 @@ export function InputBar({ data-phase={input.phase} placeholder={placeholder ?? (disabled ? 'Session unavailable' - : planActive ? 'describe your task to generate plan' : 'Message the agent')} + : planActive ? translateHint('placeholder.plan') : translateHint('placeholder.default'))} rows={2} onChange={onChange} onKeyDown={onKeyDown} diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css index dd5986992c..50dce3913f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css @@ -1,49 +1,43 @@ -/* Composer bottom-row permission chip (draft start.jpeg `Read-only ∨`): a - quiet text chip with a chevron; hover paints the standard interactive pill. - The native select is stretched invisibly over the chip so the platform - dropdown does the menu work — keyboard/AT semantics come free. */ - -.root { - position: relative; - display: inline-flex; - align-items: center; -} - -.chip { +.trigger { display: inline-flex; align-items: center; gap: 4px; - padding: 6px 8px; - border-radius: 8px; - color: var(--dsw-alias-label-secondary); - font-size: 14px; - line-height: 20px; - pointer-events: none; /* the overlaid select owns the interaction */ -} - -.root:hover .chip { - background: var(--dsw-alias-interactive-bg-hover); -} - -.chevron { - color: var(--dsw-alias-label-caption); -} - -/* Invisible native select stretched over the chip: real menu, zero drawing. */ -.select { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - opacity: 0; + min-width: 0; + max-width: 220px; + height: 28px; + padding: 0 4px 0 8px; border: none; + border-radius: 8px; + outline: none; + background: transparent; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 20px; + font-weight: 500; cursor: pointer; } -.select:disabled { +.trigger:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.trigger:focus-visible { + box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); +} + +.trigger:disabled { + color: var(--dsw-alias-label-dimmed); cursor: default; } -.root:has(.select:disabled) .chip { - opacity: 0.5; +.triggerLabel { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chevron { + flex: 0 0 auto; + color: var(--dsw-alias-label-caption); } diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx index 0622e64500..873c8c11c4 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -1,27 +1,14 @@ -// PermissionSelect: the composer bottom-row permission chip (draft -// start.jpeg's `Read-only ∨` control), the Access seat's wired occupant. -// Options and the current value read from the host-computed `permissions` -// projection (baseline block + push frames — no fetch, no mount timing); -// key absence (a permission-less composition, or a Draft with no host -// session yet) renders nothing. The visible chip is presentation only — an -// invisible native select stretched over it owns the menu and interaction. -// A switch submits the `/permission ` command line (the one write -// path); the control shows the picked value optimistically and disables -// until the admission result, then re-follows the projection — the pushed -// frame confirms the switch, and a failed/unmatched submit falls back to -// the still-authoritative projection value (`custom` is shown as the -// current value but never offered as a target — the host omits it from -// switchable options). - import { useState } from 'react' import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client' +import { Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' import css from './PermissionSelect.module.css' /** * Display transform: kebab-case machine names render as title-case labels - * (`workspace-write` → `Workspace Write`). Presentation-only — the wire - * vocabulary and the host's advertised names are untouched; a host-configured - * name that is not kebab-case (contains spaces or uppercase) passes through. + * (`workspace-write` → `Workspace Write`); non-kebab host-configured names + * pass through. Twin of the /permission popup's (client ui-permission) — the + * two permission surfaces must show the same text. */ function displayName(name: string): string { if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name @@ -29,52 +16,57 @@ function displayName(name: string): string { } export interface PermissionSelectProps { - /** The host-computed select, or undefined while the capability is absent. */ value: PermissionSelectValue | undefined - /** Session-removed lock (the bar's chrome disable state). */ locked: boolean - /** Submit one slash-command line; resolves admission (false = rejected/unmatched). */ command: (line: string) => Promise } export function PermissionSelect({ value, locked, command }: PermissionSelectProps) { - // Optimistic pick, shown while the admission round-trip runs; null follows - // the projection (the pushed frame lands the confirmed value there). const [pick, setPick] = useState(null) + const [open, setOpen] = useState(false) + if (value === undefined) return null const currentValue = pick ?? value.currentValue const current = value.options.find(option => option.value === currentValue) + const busy = pick !== null - const onChange = (next: string): void => { - if (next === value.currentValue) return - setPick(next) - void command(`/permission ${next}`) + const items: MenuEntry[] = value.options + .filter(o => o.value !== 'custom') + .map(option => ({ id: option.value, label: displayName(option.name) })) + + const choose = (id: string): void => { + setOpen(false) + if (id === value.currentValue) return + setPick(id) + void command(`/permission ${id}`) .catch(() => false) .then(() => { setPick(null) }) } return ( - + { setOpen(false) }} + side="top" + anchor={ + + } + /> ) } diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 416f3fa4ef..e73f959341 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -17,6 +17,7 @@ import { describe, expect, it, vi } from 'vitest' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { @@ -49,6 +50,7 @@ async function bench() { }) const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() } runtime.provide('layout', layoutFake) + runtime.provide('locale', new LocaleService(runtime.ctx)) // The AppFrame role: the conversation-package slots must be declared by a // live entry before apply can contribute into them. diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 4d6dc99f4a..bc10aedb05 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -10,6 +10,7 @@ import { describe, expect, it, vi } from 'vitest' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -22,6 +23,7 @@ async function bench() { await runtime.sessions.add( { id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false }) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.provide('locale', new LocaleService(runtime.ctx)) // Declared by ui-layout's root entry in production; the test root declares // them here so the contributions land. diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 1b4d1ee158..6b75f940d4 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -17,6 +17,7 @@ import type { ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -125,7 +126,7 @@ async function bench(snapshot: ConversationSnapshot) { } ctx.provide('workspaces', workspaces) ctx.provide('layout', layout) - ctx.provide('i18n', { bind: () => (key: string) => key }) + ctx.provide('locale', new LocaleService(ctx)) slots.install(createSlotRenderer()) slots.register({ diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 3d2e9ea0e6..02bb6b92dc 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -15,6 +15,7 @@ import { cleanup, fireEvent } from '@testing-library/react' import type { ISession, SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -53,6 +54,7 @@ async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } runtime.provide('layout', layout) + runtime.provide('locale', new LocaleService(runtime.ctx)) await runtime.sessions.add({ id: SID, summary: { title: 'S', displayTitle: 'S' }, @@ -180,6 +182,7 @@ describe('registrant load-order seam', () => { it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => { const runtime = await SlotTestRuntime.create() runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) + runtime.provide('locale', new LocaleService(runtime.ctx)) await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) // Third-party posture, mounted BEFORE ui-conversation: real fiber inject diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 23d853dd1a..495258f7ab 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -42,6 +42,7 @@ interface BenchOptions { promptError?: ConversationSnapshot['promptError'] variant?: 'hero' | 'composer' placeholder?: string + translateHint?: (key: string) => string accessory?: React.ReactNode overlay?: React.ReactNode leftItems?: React.ReactNode @@ -100,6 +101,11 @@ function bench(over?: BenchOptions) { useLexicon: bindSnapshotSelector(shell.lexicon), stop, command: () => Promise.resolve(true), + // Mirrors the en 'command.hint' locale entries the production apply wires in. + translateHint: over?.translateHint ?? ((key: string) => ({ + 'placeholder.default': 'Message the agent', + 'placeholder.plan': 'describe your task to generate plan', + } as Record)[key] ?? key), renderSlot, variant: over?.variant ?? 'composer', ...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}), @@ -292,6 +298,19 @@ describe('decorations', () => { expect(view.container.querySelector('[data-decoration="token"]')).not.toBeNull() }) + it('a locale entry for the claimed command overrides the raw claim hint (trailing-space token)', () => { + const dict: Record = { goal: '输入目标,智能体将持续执行' } + const { view, shell } = bench({ translateHint: key => dict[key] ?? key }) + act(() => { + shell.setDraft('/goal ') + shell.beginCommand( + { token: '/goal ', hint: '[|clear|edit |pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) }, + { start: 0, end: 6, draftRev: shell.snapshot.draftRev }, + ) + }) + expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('输入目标,智能体将持续执行') + }) + it('an inserted reference renders as a chip at its placeholder offset', () => { const { view, shell } = bench() act(() => { @@ -374,7 +393,7 @@ describe('placeholder chrome and control seats', () => { const { view, slotCalls } = bench() expect(view.getByLabelText('Add attachment')).toBeTruthy() // Capability absent (no projection value): the chip renders nothing. - expect(view.queryByLabelText('Access mode')).toBeNull() + expect(view.queryByLabelText(/^Access mode/)).toBeNull() // Both seats dispatched, nothing rendered. expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model']) expect(view.queryByLabelText('Plan mode')).toBeNull() @@ -390,15 +409,19 @@ describe('placeholder chrome and control seats', () => { currentValue: 'workspace-write', } const { view } = bench({ permissions }) - const select = view.getByLabelText('Access mode') as HTMLSelectElement - expect(select.value).toBe('workspace-write') - // Title-case display is presentation only; the option values stay machine names. - expect([...select.options].map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access']) - fireEvent.change(select, { target: { value: 'danger-full-access' } }) + const trigger = view.getByLabelText(/^Access mode/) as HTMLButtonElement + // Title-case display is presentation only; the menu ids stay machine names. + expect(trigger.textContent).toBe('Workspace Write') + fireEvent.click(trigger) + const items = view.getAllByRole('menuitem') + expect(items.map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access']) + fireEvent.click(items[1]!) // Optimistic pick + disable until admission resolves (command stub resolves true). - expect(select.disabled).toBe(true) + const busy = view.getByLabelText(/^Access mode/) as HTMLButtonElement + expect(busy.textContent).toBe('Danger Full Access') + expect(busy.disabled).toBe(true) await act(async () => {}) - expect(select.disabled).toBe(false) + expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false) }) it('a registered entry fills its seat and receives the locked owner prop', () => { @@ -420,9 +443,9 @@ describe('placeholder chrome and control seats', () => { const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' } const { view } = bench({ disabled: true, permissions }) expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true) - expect((view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(true) + expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(true) cleanup() const live = bench({ running: true, permissions }) - expect((live.view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(false) + expect((live.view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false) }) }) diff --git a/packages/client/ui-conversation/tests/input-machine.spec.ts b/packages/client/ui-conversation/tests/input-machine.spec.ts index 206a66e4c6..9ce23c4a10 100644 --- a/packages/client/ui-conversation/tests/input-machine.spec.ts +++ b/packages/client/ui-conversation/tests/input-machine.spec.ts @@ -260,10 +260,10 @@ describe('input-machine: insert-ref and the occurrence table', () => { m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) }) m.dispatch({ type: 'draft-changed', draft: `${P} and /alp`, editRange: { start: 1, end: 1, insertedLength: 9 } }) m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 6, 10) }) - expect(m.state.draft).toBe(`${P} and ${P}`) + expect(m.state.draft).toBe(`${P} and ${P} `) expect(m.state.occurrences.map(o => o.occurrenceId)).toEqual([1, 2]) // Delete the first chip whole; the second survives with its own identity. - m.dispatch({ type: 'draft-changed', draft: ` and ${P}`, editRange: { start: 0, end: 1, insertedLength: 0 } }) + m.dispatch({ type: 'draft-changed', draft: ` and ${P} `, editRange: { start: 0, end: 1, insertedLength: 0 } }) expect(m.state.occurrences).toEqual([expect.objectContaining({ occurrenceId: 2, offset: 5 })]) }) @@ -273,7 +273,7 @@ describe('input-machine: insert-ref and the occurrence table', () => { m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) }) m.dispatch({ type: 'draft-changed', draft: '/goal ask @wor' }) m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 10, 14) }) - expect(m.state.draft).toBe(`/goal ask ${P}`) + expect(m.state.draft).toBe(`/goal ask ${P} `) expect(m.state.phase).toBe('claimed') expect(m.state.occurrences).toHaveLength(1) }) @@ -350,10 +350,10 @@ describe('input-machine: newline transaction (F1)', () => { m.dispatch({ type: 'draft-changed', draft: 'ab @wor' }) m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 3, 7) }) m.dispatch({ type: 'newline', selection: { start: 2, end: 2 } }) - expect(m.state.draft).toBe(`ab\n ${P}`) + expect(m.state.draft).toBe(`ab\n ${P} `) expect(m.state.occurrences[0]?.offset).toBe(4) m.dispatch({ type: 'undo' }) - expect(m.state.draft).toBe(`ab ${P}`) + expect(m.state.draft).toBe(`ab ${P} `) }) it('replaces a selection, breaks the claim prefix when leading, and rejects out-of-bounds', () => { @@ -410,7 +410,7 @@ describe('input-machine: consume-token guards', () => { m.dispatch({ type: 'draft-changed', draft: '/model @wor' }) m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 7, 11) }) m.dispatch({ type: 'consume-token', guard: { kind: 'span', span: spanOf(m, 0, 7) } }) - expect(m.state.draft).toBe(P) + expect(m.state.draft).toBe(`${P} `) expect(m.state.occurrences[0]?.offset).toBe(0) }) }) @@ -490,7 +490,7 @@ describe('input-machine: undo / redo', () => { m.dispatch({ type: 'draft-changed', draft: '', editRange: { start: 0, end: 1, insertedLength: 0 } }) expect(m.state.occurrences).toEqual([]) m.dispatch({ type: 'undo' }) - expect(m.state.draft).toBe(P) + expect(m.state.draft).toBe(`${P} `) expect(m.state.occurrences).toHaveLength(1) }) @@ -555,9 +555,9 @@ describe('input-machine: paste plane', () => { m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') }) expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 7 }) m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 2, 7), reference: refOf('beta') }) - expect(m.state.draft).toBe(`${P} ${P}`) + expect(m.state.draft).toBe(`${P} ${P} `) expect(m.state.occurrences.map(o => o.ref)).toEqual(['alpha', 'beta']) - expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 3 }) + expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 4 }) }) it('a stale span CAS drops one upgrade without ending the attempt', () => { @@ -634,8 +634,8 @@ describe('input-machine: projectClipboard', () => { m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 4, 8) }) m.dispatch({ type: 'draft-changed', draft: `use ${P} then /bet`, editRange: { start: 5, end: 5, insertedLength: 10 } }) m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, 11, 15) }) - expect(m.state.draft).toBe(`use ${P} then ${P}`) - expect(projectClipboard(m.state)).toBe('use /alpha then /beta') + expect(m.state.draft).toBe(`use ${P} then ${P} `) + expect(projectClipboard(m.state)).toBe('use /alpha then /beta ') }) it('is the identity on a chip-free draft', () => { diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index f6694f8cb4..a9c00b0748 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -48,6 +48,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), command: () => Promise.resolve(true), + translateHint: (key: string) => key, variant: 'composer', } return render() diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 9d9ace032c..1c7bbe50ec 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -134,6 +134,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), command: () => Promise.resolve(true), + translateHint: (key: string) => key, variant: 'composer', } const view = render() diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 0d32e2edea..870d5c113b 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -124,6 +124,7 @@ function mount( useLexicon={bindSnapshotSelector(wiring.lexicon)} stop={stop} command={() => Promise.resolve(true)} + translateHint={(key: string) => key} renderSlot={(() => null) as InputBarProps['renderSlot']} {...bar} /> diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 14ae91598a..1aa28c9c62 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../plan/plan-mode" }, + { + "path": "../../goal/goal" + }, { "path": "../../todo/tool-todo" }, diff --git a/packages/client/ui-goal/src/client/GoalBar.module.css b/packages/client/ui-goal/src/client/GoalBar.module.css index fe07bace1b..80c87be57b 100644 --- a/packages/client/ui-goal/src/client/GoalBar.module.css +++ b/packages/client/ui-goal/src/client/GoalBar.module.css @@ -91,7 +91,7 @@ .actions { display: flex; align-items: center; - gap: 2px; + gap: 8px; flex: none; } diff --git a/packages/client/ui-goal/src/client/GoalBar.tsx b/packages/client/ui-goal/src/client/GoalBar.tsx index 76308734fc..b1b0fd7398 100644 --- a/packages/client/ui-goal/src/client/GoalBar.tsx +++ b/packages/client/ui-goal/src/client/GoalBar.tsx @@ -11,7 +11,7 @@ import { useCallback, useEffect, useState } from 'react' import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client' import { - IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconPlayOutline16, IconSparkle16, IconTrashOutline16, + IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconPauseOutline16, IconPlayOutline16, IconSparkle16, IconTrashOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { GoalActionResult, GoalBarActions } from './slots.ts' import css from './GoalBar.module.css' @@ -28,7 +28,7 @@ const PHASE_LABELS = { blocked: 'Blocked Goal', } as const -export function GoalBar({ goal, onEdit, onResume, onClear }: GoalBarProps) { +export function GoalBar({ goal, onEdit, onPause, onResume, onClear }: GoalBarProps) { const [editing, setEditing] = useState(false) const [draft, setDraft] = useState('') const [pending, setPending] = useState(false) @@ -120,6 +120,11 @@ export function GoalBar({ goal, onEdit, onResume, onClear }: GoalBarProps) { {goal.objective} {actionError !== null && {actionError}}
+ {goal.phase === 'active' && ( + + )} {goal.phase === 'paused' && ( - ) - }))} +
+ {state.groups.map(group => (group.status === 'ready' && group.items.length === 0) + ? null + : ( + +
{t(group.source)}
+ {group.status === 'pending' + ?
{t('loading')}
+ : group.items.map((item, index) => { + const active = highlight !== null && highlight.source === group.source && highlight.index === index + return ( + + ) + })} +
+ ))} +
) } diff --git a/packages/client/ui-slash/src/client/controller.ts b/packages/client/ui-slash/src/client/controller.ts index ab0b26da54..3a8e85afc3 100644 --- a/packages/client/ui-slash/src/client/controller.ts +++ b/packages/client/ui-slash/src/client/controller.ts @@ -256,6 +256,13 @@ export class SlashController { this.refreshLexicon() } + /** External dismiss (e.g. pointer outside the composer area). */ + dismiss(): void { + if (this.disposed) return + this.stopFetch() + this.reduce({ type: 'close' }) + } + /** Scope teardown: close and abort (the service deletes the map entry). */ dispose(): void { this.disposed = true diff --git a/packages/client/ui-slash/src/client/index.ts b/packages/client/ui-slash/src/client/index.ts index f1c1d1953e..0ef751462a 100644 --- a/packages/client/ui-slash/src/client/index.ts +++ b/packages/client/ui-slash/src/client/index.ts @@ -4,6 +4,8 @@ * self-registers into the conversation.input.overlay slot. Frozen pipeline * contract in ./contract.ts; sources register through ctx.slash alone. */ +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { SlashService } from './service.ts' import type { MenuViewInjected } from './slots.ts' @@ -29,8 +31,11 @@ declare module 'cordis' { } } -/** Required services: controller resolution reads the session scope tree. */ -export const inject = ['sessions'] +/** Namespace owning the candidate-menu copy: group titles keyed by source name plus the pending row. */ +const MENU_NS = 'slash.menu' + +/** Required services: controller resolution reads the session scope tree; the menu copy is localized. */ +export const inject = ['sessions', 'locale'] /** * Client plugin body: mount the service, then register MenuView into the @@ -39,6 +44,13 @@ export const inject = ['sessions'] */ export function apply(ctx: ClientContext): void { ctx.plugin(SlashService) + ctx.effect(() => { + const disposers = [ + ctx.locale.register(MENU_NS, 'zh', { command: '命令', skill: '技能', subagent: '子智能体', loading: '正在加载…' }), + ctx.locale.register(MENU_NS, 'en', { command: 'Commands', skill: 'Skills', subagent: 'Subagents', loading: 'Loading…' }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-slash: menu dictionaries') // Conditional mount: 'conversation.input.overlay' is declared by the // conversation composer entry, and the conversation service is mounted // after that declaration lands on the ledger — its presence is the @@ -59,6 +71,8 @@ export function apply(ctx: ClientContext): void { return { menu: controller.menu, onPick: (source, index) => { controller.pick(source, index) }, + onDismiss: () => { controller.dismiss() }, + t: scope.locale.bind(MENU_NS), } }, }, MenuView), 'ui-slash: MenuView overlay registration') diff --git a/packages/client/ui-slash/src/client/service.ts b/packages/client/ui-slash/src/client/service.ts index c5448a3d50..d9af10e887 100644 --- a/packages/client/ui-slash/src/client/service.ts +++ b/packages/client/ui-slash/src/client/service.ts @@ -87,7 +87,7 @@ export class SlashService extends Service implements SlashServiceContract { actx, sessionId: id, roster: { - sources: trigger => live.sources.filter(s => s.trigger === trigger), + sources: trigger => live.sources.filter(s => s.trigger === trigger).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)), all: () => live.sources, }, }) diff --git a/packages/client/ui-slash/src/client/slots.ts b/packages/client/ui-slash/src/client/slots.ts index f74ec29457..f69be9f28a 100644 --- a/packages/client/ui-slash/src/client/slots.ts +++ b/packages/client/ui-slash/src/client/slots.ts @@ -9,6 +9,7 @@ */ // Type-only edge: the SlotMap augmentation below merges into this package's interface. import type {} from '@deepseek-ai/dsh-client-ui-slots' +import type { Translate } from '@deepseek-ai/dsh-client-locale/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { MenuState } from '../core/contract.ts' @@ -35,4 +36,12 @@ export interface MenuViewInjected { * @param index - candidate index within the group. */ onPick: (source: string, index: number) => void + /** Dismiss the menu (external pointer outside the composer area). */ + onDismiss: () => void + /** + * Bound translator for the menu namespace: group titles keyed by source + * name (the locale fallback chain returns the key itself, so an unknown + * source shows its raw name) plus the pending-row text. + */ + t: Translate } diff --git a/packages/client/ui-slash/src/types.ts b/packages/client/ui-slash/src/types.ts index 4b9bd64408..2b63381efb 100644 --- a/packages/client/ui-slash/src/types.ts +++ b/packages/client/ui-slash/src/types.ts @@ -138,6 +138,8 @@ export interface SlashSource { readonly trigger: TriggerChar /** Menu group label; unique per trigger — duplicate registration throws. */ readonly name: string + /** Menu group display order (lower = higher in the list; default 0). */ + readonly order?: number candidates(session: ClientSessionContext, req: CandidateRequest): Promise /** Every pick lands here; claim/insert outcomes are executed by the pipeline via the scoped input events. */ onPick(pick: SlashPick): PickOutcome diff --git a/packages/client/ui-slash/tests/apply.spec.ts b/packages/client/ui-slash/tests/apply.spec.ts index 637f18f102..5a8b2b6c37 100644 --- a/packages/client/ui-slash/tests/apply.spec.ts +++ b/packages/client/ui-slash/tests/apply.spec.ts @@ -7,6 +7,7 @@ */ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { createScope, scopeOf, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject, SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' @@ -31,12 +32,25 @@ async function bench() { scope: (id: SessionId) => (id === sid('a') ? scope.ctx : undefined), scopeOf: (c: Context) => scopeOf(c), }) - return { ctx, slots } + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + return { ctx, slots, locale } } describe('apply', () => { - it('declares the sessions dependency (controller resolution reads the scope tree)', () => { - expect(inject).toEqual(['sessions']) + it('declares the sessions and locale dependencies (scope tree + localized menu copy)', () => { + expect(inject).toEqual(['sessions', 'locale']) + }) + + it('registers the bilingual menu dictionaries (group titles by source name + the pending row)', async () => { + const { ctx, locale } = await bench() + await ctx.plugin({ inject: [...inject], apply }).await() + const t = locale.bind('slash.menu') + expect(t('command')).toBe('命令') + locale.setLocale('en') + expect(t('skill')).toBe('Skills') + expect(t('subagent')).toBe('Subagents') + expect(t('loading')).toBe('Loading…') }) it('mounts ctx.slash once sessions is up, before any conversation service exists', async () => { @@ -65,6 +79,8 @@ describe('apply', () => { (ctx.get('sessions') as { scope(id: SessionId): Context }).scope(sid('a')), ) expect(injected.menu).toBe(controller.menu) + // The injected translator is the menu-namespace binding. + expect(injected.t('command')).toBe('命令') // The pick face routes into the controller pipeline (closed menu → no-op). injected.onPick('command', 0) expect(controller.menu.getSnapshot().open).toBe(false) diff --git a/packages/client/ui-slash/tests/menu-view.spec.tsx b/packages/client/ui-slash/tests/menu-view.spec.tsx index d9b6a08a88..b18bbd6b30 100644 --- a/packages/client/ui-slash/tests/menu-view.spec.tsx +++ b/packages/client/ui-slash/tests/menu-view.spec.tsx @@ -1,11 +1,13 @@ // @vitest-environment jsdom /** * MenuView rendering spec, props-direct (slot-parity doctrine): closed store - * renders null, groups render in roster order with pending rows as loading, - * pointer picks route (source, index) back without stealing focus, and the - * highlight is exposed through aria-activedescendant + aria-selected. + * renders null, groups render in roster order under localized title rows + * (unknown sources fall back to the raw name) with pending rows as loading, + * pointer picks route (source, index) back without stealing focus, the + * highlight is exposed through aria-activedescendant + aria-selected, and + * the list height clamps to the space above the composer. */ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { MenuState, TriggerHit } from '@deepseek-ai/dsh-client-ui-slash/client' @@ -34,13 +36,35 @@ function openState(partial?: Partial): MenuState { } } -afterEach(cleanup) +// jsdom has no scrollIntoView; the view calls it on the highlighted option. +const scrollIntoView = vi.fn() +beforeEach(() => { + Element.prototype.scrollIntoView = scrollIntoView + scrollIntoView.mockClear() +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +// Dictionary-backed fake mirroring the LocaleService key fallback (an +// unknown key comes back verbatim, so unknown sources show their raw name). +const DICT: Record = { command: 'Commands', skill: 'Skills', loading: 'Loading…' } +const t = (key: string) => DICT[key] ?? key function mount(state: MenuState) { const menu = createSnapshotStore(state) const onPick = vi.fn() - const view = render() - return { menu, onPick, view } + const onDismiss = vi.fn() + const view = render() + return { menu, onPick, onDismiss, view } +} + +/** The non-interactive group title rows (role=presentation), in document order. */ +function titles(container: HTMLElement): string[] { + return [...container.querySelectorAll('div[role="presentation"][data-source]')] + .map(el => el.textContent ?? '') } describe('MenuView', () => { @@ -57,7 +81,19 @@ describe('MenuView', () => { mount(openState()) const options = screen.getAllByRole('option') expect(options.map(o => o.textContent)).toEqual(['⚑goalSet up a goal', 'plan']) - expect(screen.queryByText('Loading skill…')).not.toBeNull() + expect(screen.queryByText('Loading…')).not.toBeNull() + }) + + it('titles each group with the localized source name, raw name for unknown sources, none for empty ready groups', () => { + const { view } = mount(openState({ + groups: [ + { source: 'command', status: 'ready', items: [{ name: 'goal' }] }, + { source: 'hollow', status: 'ready', items: [] }, + { source: 'mystery', status: 'ready', items: [{ name: 'x' }] }, + { source: 'skill', status: 'pending', items: [] }, + ], + })) + expect(titles(view.container)).toEqual(['Commands', 'mystery', 'Skills']) }) it('exposes the highlight via aria-activedescendant and aria-selected', () => { @@ -75,6 +111,37 @@ describe('MenuView', () => { expect(screen.getByRole('listbox').getAttribute('aria-activedescendant')).toBeNull() }) + it('scrolls the highlighted option into view when the highlight moves', () => { + const { menu } = mount(openState()) + scrollIntoView.mockClear() + act(() => { menu.set(openState({ highlight: { source: 'command', index: 1 } })) }) + const options = screen.getAllByRole('option') + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' }) + expect(scrollIntoView.mock.instances.at(-1)).toBe(options[1]) + }) + + it('caps the list height at the design maximum when the composer sits low enough', () => { + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect) + mount(openState()) + expect(screen.getByRole('listbox').style.maxHeight).toBe('320px') + }) + + it('clamps the list height to the space above the composer minus the safe margin', () => { + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect) + mount(openState()) + expect(screen.getByRole('listbox').style.maxHeight).toBe('188px') + }) + + it('re-fits the height when the window resizes', () => { + const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect') + rect.mockReturnValue({ bottom: 800 } as DOMRect) + mount(openState()) + expect(screen.getByRole('listbox').style.maxHeight).toBe('320px') + rect.mockReturnValue({ bottom: 100 } as DOMRect) + act(() => { window.dispatchEvent(new Event('resize')) }) + expect(screen.getByRole('listbox').style.maxHeight).toBe('88px') + }) + it('mousedown on a row picks (source, index) and prevents the focus steal', () => { const { onPick } = mount(openState()) const options = screen.getAllByRole('option') diff --git a/packages/client/ui-slash/tsconfig.json b/packages/client/ui-slash/tsconfig.json index a3002d4981..deca328a0a 100644 --- a/packages/client/ui-slash/tsconfig.json +++ b/packages/client/ui-slash/tsconfig.json @@ -11,9 +11,15 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../locale" + }, { "path": "../runtime" }, + { + "path": "../ui-primitives" + }, { "path": "../ui-slots" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66ea5bea26..998ad4cc4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1049,6 +1049,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -1064,6 +1067,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1483,9 +1489,15 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots From 82a43a72404a04deb6bfdd15d3346e6717a20b2b Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Wed, 29 Jul 2026 19:59:42 +0800 Subject: [PATCH 12/32] test(client): cover menu dismiss, goal pause, and permission label paths Close the per-file coverage gaps the new UI behavior introduced: MenuView pointer-outside dismiss (all guard branches), the GoalBar pause action, the ui-slash injected onDismiss face, and the non-kebab permission name passthrough. --- .../client/ui-goal/tests/goalbar.spec.tsx | 7 ++++ .../tests/browser-plugin.spec.ts | 5 +++ packages/client/ui-slash/tests/apply.spec.ts | 3 ++ .../client/ui-slash/tests/menu-view.spec.tsx | 42 +++++++++++++++++++ 4 files changed, 57 insertions(+) diff --git a/packages/client/ui-goal/tests/goalbar.spec.tsx b/packages/client/ui-goal/tests/goalbar.spec.tsx index ce622bccd4..38a943d457 100644 --- a/packages/client/ui-goal/tests/goalbar.spec.tsx +++ b/packages/client/ui-goal/tests/goalbar.spec.tsx @@ -104,6 +104,13 @@ describe('GoalBar', () => { expect(screen.getByRole('textbox', { name: 'Goal objective' })).toBeTruthy() }) + it('active goal: the pause action pauses', () => { + const actions = makeActions() + render() + fireEvent.click(screen.getByRole('button', { name: 'Pause goal' })) + expect(actions.onPause).toHaveBeenCalledTimes(1) + }) + it('paused goal: "Paused Goal" with a resume action before edit', () => { const actions = makeActions() render() diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts index 167cf17362..5f9125db53 100644 --- a/packages/client/ui-permission/tests/browser-plugin.spec.ts +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -85,6 +85,11 @@ describe('ui-permission browser plugin', () => { const again = await c.ui.options(proj, new AbortController().signal) expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true) expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.') + // Kebab-case names title-case; non-kebab host-configured names pass through. + expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Danger Full Access']) + b.values.set(sid('s1'), { ...SELECT, options: [{ value: 'plain', name: 'Ask Every Time' }] }) + const passthrough = await c.ui.options(proj, new AbortController().signal) + expect(passthrough[0]?.label).toBe('Ask Every Time') // A projection that vanished between availability and open throws. expect(() => c.ui.options({ sessionId: sid('ghost') }, new AbortController().signal)) .toThrow(/not available on this host/) diff --git a/packages/client/ui-slash/tests/apply.spec.ts b/packages/client/ui-slash/tests/apply.spec.ts index 5a8b2b6c37..2e20dc96f8 100644 --- a/packages/client/ui-slash/tests/apply.spec.ts +++ b/packages/client/ui-slash/tests/apply.spec.ts @@ -84,6 +84,9 @@ describe('apply', () => { // The pick face routes into the controller pipeline (closed menu → no-op). injected.onPick('command', 0) expect(controller.menu.getSnapshot().open).toBe(false) + // The dismiss face routes into the controller too (closed menu → no-op). + injected.onDismiss() + expect(controller.menu.getSnapshot().open).toBe(false) // An unknown session id fails loud (no silent scope miss). expect(() => injectEntry(sid('ghost'))).toThrow(/resolved no scope/) }) diff --git a/packages/client/ui-slash/tests/menu-view.spec.tsx b/packages/client/ui-slash/tests/menu-view.spec.tsx index b18bbd6b30..1c74340575 100644 --- a/packages/client/ui-slash/tests/menu-view.spec.tsx +++ b/packages/client/ui-slash/tests/menu-view.spec.tsx @@ -142,6 +142,48 @@ describe('MenuView', () => { expect(screen.getByRole('listbox').style.maxHeight).toBe('88px') }) + it('pointerdown outside the menu (no composer card ancestor) dismisses', () => { + const { onDismiss } = mount(openState()) + fireEvent.pointerDown(document.body) + expect(onDismiss).toHaveBeenCalledTimes(1) + }) + + it('pointerdown inside the list does not dismiss', () => { + const { onDismiss } = mount(openState()) + fireEvent.pointerDown(screen.getAllByRole('option')[0]!) + expect(onDismiss).not.toHaveBeenCalled() + }) + + it('pointerdown inside the surrounding composer card does not dismiss; outside it does', () => { + const menu = createSnapshotStore(openState()) + const onDismiss = vi.fn() + render( +
+ +
, + ) + fireEvent.pointerDown(screen.getByTestId('composer-button')) + expect(onDismiss).not.toHaveBeenCalled() + fireEvent.pointerDown(document.body) + expect(onDismiss).toHaveBeenCalledTimes(1) + }) + + it('ignores a pointerdown whose target is not a DOM node', () => { + const { onDismiss } = mount(openState()) + const ev = new Event('pointerdown', { bubbles: true }) + Object.defineProperty(ev, 'target', { value: {} }) + document.dispatchEvent(ev) + expect(onDismiss).not.toHaveBeenCalled() + }) + + it('closing the menu removes the dismiss listener', () => { + const { menu, onDismiss } = mount(openState()) + act(() => { menu.set(CLOSED) }) + fireEvent.pointerDown(document.body) + expect(onDismiss).not.toHaveBeenCalled() + }) + it('mousedown on a row picks (source, index) and prevents the focus steal', () => { const { onPick } = mount(openState()) const options = screen.getAllByRole('option') 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 13/32] 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 14/32] 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 15/32] 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 16/32] 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', From a4602b959e2e601f20b1f32ba060a8c36b33dc03 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 29 Jul 2026 20:25:22 +0800 Subject: [PATCH 17/32] feat: optimize chat page scroll area --- ...cky-composer-conversation-scroll.i18n.yaml | 6 ++ ...-29-sticky-composer-conversation-scroll.md | 29 +++++ ...-sticky-composer-conversation-scroll.zh.md | 29 +++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- .../ui-conversation/src/client/apply.ts | 4 + .../src/client/chat/ChatView.module.css | 47 ++++++-- .../src/client/chat/ChatView.tsx | 100 ++++++++++++------ .../src/client/chat/StatsLine.tsx | 4 +- .../src/client/contract/slots.ts | 9 ++ .../skeleton/ConversationRoot.module.css | 34 ++++++ .../src/client/skeleton/ConversationRoot.tsx | 34 ++++-- .../client/skeleton/ConversationSession.tsx | 26 +++-- .../src/client/skeleton/InputBar.tsx | 16 +++ .../ui-conversation/tests/chat-apply.spec.tsx | 2 + .../tests/chat-stats-bash-sample.spec.tsx | 2 +- .../ui-conversation/tests/chat-view.spec.tsx | 22 ++++ .../ui-conversation/tests/input-bar.spec.tsx | 16 +++ .../ui-conversation/tests/skeleton.spec.tsx | 29 +++-- .../src/markdown/CodeBlock.module.css | 4 + .../ui-trajectory/src/client/views.module.css | 9 +- 22 files changed, 358 insertions(+), 76 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml new file mode 100644 index 0000000000..39556c379a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml @@ -0,0 +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 .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md +2026-07-29-sticky-composer-conversation-scroll.md: f245e7ca0404df4f644504ac9e1b101e659b6b68 +2026-07-29-sticky-composer-conversation-scroll.zh.md: 7e4d5fb523f33f450c343e12e486e1d33784ee6c diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md new file mode 100644 index 0000000000..f245e7ca04 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md @@ -0,0 +1,29 @@ +# Agent Note: Fixed header, sticky composer inside the transcript scrollport + +Status: implemented + +English | [中文](2026-07-29-sticky-composer-conversation-scroll.zh.md) + +## Problem + +The active conversation column split scrolling: the chat (and trajectory) view owned `overflow-y: auto`, while the composer stack sat as a sibling below that scrollport. A wheel gesture over the stats line or input therefore hit a non-scrolling region and did nothing — the transcript only moved when the pointer was over the message list. Long drafts made it worse: the textarea is itself a scrollport, so wheel over the composer could be trapped there. The session header must occupy the top of the column as ordinary chrome (not `position: sticky` inside the scrollport), while the composer must stick to the bottom of the same scrollport as the transcript so wheel over the footer moves the flow. + +## Decision + +Active phase keeps the session header as `flex: none` column chrome above the scrollport. `ConversationRoot` supplies a `wrapActiveBody` owner callback that wraps the view ring in a `data-conversation-scroll` body and places the composer stack inside that body with `position: sticky; bottom: 0`. Hero and settling keep the composer as a Root child (centered hero card). ChatView and Trajectory/Waterfall keep a local scroller only when mounted outside that host (unit tests); under the host they set `overflow: visible` and resolve bottom-follow / prepend anchoring through `closest('[data-conversation-scroll]')`. + +Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, listens for `wheel` with `{ passive: false }`, calls `preventDefault`, and applies `deltaY` to the host — hero mounts have no host and keep native textarea wheel behavior. Moving the composer into the Session scrollport on the hero → active flip may remount the textarea; the InputHub draft is the durable carrier across that flip. + +## Alternatives considered + +**Sticky header and sticky composer inside one column scrollport.** Rejected for the header: it must occupy the top as fixed layout chrome, not participate in the scrollport's sticky layer. + +**Fixed flex-none composer below the scrollport with wheel forwarding.** Rejected: the product requires the composer to stick inside the transcript scrollport so the footer is part of that scroll hit-testing surface, not a sibling that only forwards deltas. + +**Portal the composer into ChatView's scroller.** Rejected: the composer is shared across view tabs; the wrap target is the Session body owned by the resident shell. + +**Keep StatsLine inside ChatView below the message column.** Rejected: outside the sticky composer it would scroll away while the input stayed pinned. + +## Consequences + +Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Hero → active asserts draft survival through the InputHub, not textarea DOM identity. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md new file mode 100644 index 0000000000..7e4d5fb523 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 固定标题栏,sticky 编辑器位于 transcript 滚动容器内 + +Status: implemented + +[English](2026-07-29-sticky-composer-conversation-scroll.md) | 中文 + +## Problem + +活跃会话列把滚动拆成两段:聊天(以及 trajectory)视图自有 `overflow-y: auto`,编辑器栈则作为该滚动容器的兄弟节点坐在下方。指针落在统计行或输入区上时,滚轮打在不可滚动区域上因而毫无效果——只有指针在消息列表上时 transcript 才会移动。草稿变长时更糟:textarea 本身也是滚动容器,编辑器上的滚轮可能被截在那里。会话标题栏必须以普通 chrome 占据列顶(不能在滚动容器内 `position: sticky`),而编辑器必须与 transcript 贴在同一滚动容器底部,使页脚上的滚轮能带动内容流动。 + +## Decision + +活跃阶段会话标题栏保持为滚动容器之上的 `flex: none` 列 chrome。`ConversationRoot` 提供 `wrapActiveBody` owner 回调,将视图环包进 `data-conversation-scroll` 主体,并把编辑器栈以 `position: sticky; bottom: 0` 放进该主体。Hero/settling 仍把编辑器作为 Root 子节点(居中 hero 卡片)。ChatView 与 Trajectory/Waterfall 仅在宿主之外挂载时(单元测试)保留本地 scroller;位于宿主下时设为 `overflow: visible`,并通过 `closest('[data-conversation-scroll]')` 解析贴底跟随与前置锚定。 + +会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 监听 `wheel`,调用 `preventDefault`,并将 `deltaY` 施加到宿主——hero 挂载没有宿主,保留 textarea 原生滚轮行为。hero → active 翻转时编辑器进入 Session 滚动容器可能重挂载 textarea;跨该翻转的耐久载体是 InputHub 草稿。 + +## Alternatives considered + +**标题栏与编辑器都在同一列滚动容器内 sticky。** 标题栏否决:它必须作为固定布局 chrome 占据顶部,而不是参与滚动容器的 sticky 层。 + +**滚动容器下方 flex-none 固定编辑器并转发滚轮。** 否决:产品要求编辑器 sticky 在 transcript 滚动容器内,使页脚成为该滚动命中面的一部分,而不是仅转发增量的兄弟节点。 + +**把编辑器 portal 进 ChatView 的 scroller。** 否决:编辑器跨视图标签共享;包装目标是常驻壳拥有的 Session 主体。 + +**把 StatsLine 留在 ChatView 消息列下方。** 否决:落在 sticky 编辑器之外会随内容滚走,而输入区仍钉在底部。 + +## Consequences + +在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。hero → active 断言经 InputHub 的草稿存续,而非 textarea DOM 身份。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 680faa2c6e..fa6b3c6b30 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 5a1f9f1ad5cac6601e8686af7206bb436e40e91f -README.zh.md: ccbf1918ae3d40fd42ff7454f7f983d6261ba28f +README.md: 1d4dad5c342b9d83275eb2c1ef5a1b4b667def6b +README.zh.md: f881d4dbeb12d7e58ccc5be5caeaed0ad1b89882 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 5a1f9f1ad5..1d4dad5c34 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant and the todo row), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). +Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). -The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. +The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea forwards to that host so nested textarea scrolling never traps the gesture. The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index ccbf1918ae..f881d4dbeb 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、统计行、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 +会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会转交给该宿主,避免嵌套 textarea 滚动截获手势。 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 2b845e6c83..26e644e809 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -15,6 +15,7 @@ import type { IConversation } from './service.ts' import { InputHub } from './input/hub.ts' import { InputBar } from './skeleton/InputBar.tsx' import { ChatView } from './chat/ChatView.tsx' +import { StatsLine } from './chat/StatsLine.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx' import { todoToolview } from './toolviews/todo-row.tsx' @@ -208,6 +209,9 @@ export function apply(ctx: Context): void { }, }, ChatView) + // Session stats stick with the composer (composer.dock = stats-line family). + slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0 }, StatsLine) + // Class-plugin mount (packages/AGENTS.md service form): the service // registers itself as `conversation` and lives on its own child fiber. // Mounted AFTER the chat entry register above — construction guarantee for diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index fae96e5f6c..80e461b518 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -1,6 +1,8 @@ /* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool runs) via the column gap and between consecutive tool rows via the group - gap. Input padding cap rides the skeleton. */ + gap. Input padding cap rides the skeleton. Under + `[data-conversation-scroll]` the column host owns overflow and this view + is ordinary flow (see ConversationRoot active-phase rules). */ .root { position: relative; @@ -17,6 +19,18 @@ padding: 16px 24px; } +:global([data-conversation-scroll]) .root { + flex: 0 0 auto; + min-height: auto; + height: auto; +} + +:global([data-conversation-scroll]) .scroll { + overflow: visible; + flex: 0 0 auto; + min-height: auto; +} + /* Message column: 736px fixed width, centered on the same axis as the input box; the scroller itself stays full-bleed. */ .column { @@ -113,16 +127,34 @@ opacity: 0.6; } -/* Back-to-bottom: 34px circular icon button at the column's right edge. */ -.toBottom { - position: absolute; - right: max(24px, calc((100% - 736px) / 2)); +/* Back-to-bottom: zero-height sticky slot so the control does not extend + scrollHeight; the button translates up into the viewport. Under the + conversation host, clearance sits above the sticky composer stack. */ +.toBottomSlot { + position: sticky; bottom: 16px; - width: 34px; - height: 34px; + /* Above the sticky composer (z-index 7) so the control stays clickable and + visible over the input card. */ + z-index: 8; + height: 0; + display: flex; + justify-content: flex-end; + padding-right: max(0px, calc((100% - 736px) / 2)); + pointer-events: none; +} + +:global([data-conversation-scroll]) .toBottomSlot { + /* Clears the sticky composer stack (stats + docks + input card). */ + bottom: 168px; +} + +.toBottom { display: flex; align-items: center; justify-content: center; + width: 34px; + height: 34px; + margin-top: -34px; padding: 0; border: 1px solid var(--dsw-alias-border-l2); border-radius: 100px; @@ -130,6 +162,7 @@ background: var(--dsw-alias-button-floating-fill); box-shadow: var(--dsw-shadow-lv2); cursor: pointer; + pointer-events: auto; } .toBottom:hover { diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 0f6b3dc5ae..e281c0e11d 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -1,11 +1,16 @@ // ChatView: the default conversation view — message flow with user bubbles, // assistant narration, tool summary rows grouped into step runs, pending -// cards, paging, bottom-follow, and the session stats line under the flow -// (chrome dissolved into the view: the footer is part of what a chat view -// IS, not registration metadata). Pure component registered directly; its -// registration declares the keyed 'conversation.chat.toolview' hole, so tool -// rows render through the props renderSlot share (entryKey = tool name, -// GenericToolCard as the render-site fallback). +// cards, paging, and bottom-follow. Session stats live on +// 'conversation.composer.dock' (sticky with the composer). Pure component +// registered directly; its registration declares the keyed +// 'conversation.chat.toolview' hole, so tool rows render through the props +// renderSlot share (entryKey = tool name, GenericToolCard as the render-site +// fallback). +// +// Scroll: when nested under `[data-conversation-scroll]` (active conversation +// column), that host is the scrollport and this view is flow content; when +// mounted alone (unit tests), `.scroll` owns overflow. Bottom-follow and +// prepend anchoring always target the resolved scrollport. // // Render economics (architecture RFC performance model): the list parent // subscribes to snapshot segments that do NOT change per streaming chunk @@ -17,7 +22,7 @@ // memoized rows never churns them. import { - memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode, + memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, } from 'react' import type { CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode, @@ -30,11 +35,15 @@ import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' import { MessageItem } from './MessageItem.tsx' -import { StatsLine } from './StatsLine.tsx' import css from './ChatView.module.css' const FOLLOW_THRESHOLD = 24 +/** Active column host when present; otherwise the view-local scroller. */ +function scrollerOf(from: HTMLElement): HTMLElement { + return (from.closest('[data-conversation-scroll]')) ?? from +} + type OpenFile = (path: string) => void /** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */ @@ -248,16 +257,17 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio const firstSeq = nodes[0]?.seq ?? null const lastItem = items[items.length - 1] - const toBottom = (el: HTMLDivElement): void => { + const toBottom = (el: HTMLElement): void => { el.scrollTop = el.scrollHeight atBottomRef.current = true setAtBottom(true) } useLayoutEffect(() => { - const el = listRef.current + const local = listRef.current /* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */ - if (el === null) return + if (local === null) return + const el = scrollerOf(local) // Open completed: jump to the bottom once. if (openState === 'open' && !openedRef.current) { openedRef.current = true @@ -285,29 +295,48 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio if (appendedUser || atBottomRef.current) toBottom(el) }) - const onScroll = (): void => { - const el = listRef.current - /* v8 ignore next -- ref-null guard: the handler only fires on the mounted element. */ - if (el === null) return + const onScrollRef = useRef(() => {}) + onScrollRef.current = () => { + const local = listRef.current + /* v8 ignore next -- ref-null guard: the handler only fires while mounted. */ + if (local === null) return + const el = scrollerOf(local) const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1 atBottomRef.current = isAtBottom setAtBottom(isAtBottom) } + // Bind scroll to the resolved scrollport (host or local) once per mount. + useEffect(() => { + const local = listRef.current + /* v8 ignore next -- ref-null guard: effect runs after the list node commits. */ + if (local === null) return + const el = scrollerOf(local) + const onScroll = (): void => { onScrollRef.current() } + el.addEventListener('scroll', onScroll, { passive: true }) + return () => { el.removeEventListener('scroll', onScroll) } + }, []) + // Follow streaming growth the parent never re-renders for (stable ref). // The ref starts null and is assigned every render, so the placeholder // initializer a function initial value would need never exists. const followRef = useRef<(() => void) | null>(null) followRef.current = () => { - const el = listRef.current - if (el !== null && atBottomRef.current) el.scrollTop = el.scrollHeight + const local = listRef.current + if (local !== null && atBottomRef.current) { + const el = scrollerOf(local) + el.scrollTop = el.scrollHeight + } } const onGrow = useRef(() => followRef.current?.()).current const loadOlderAnchored = (): void => { - const el = listRef.current + const local = listRef.current /* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */ - if (el !== null) anchorRef.current = { h: el.scrollHeight, t: el.scrollTop } + if (local !== null) { + const el = scrollerOf(local) + anchorRef.current = { h: el.scrollHeight, t: el.scrollTop } + } loadOlder() } @@ -350,7 +379,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio return (
-
+
{openState === 'loading' &&
载入历史…
} {openState === 'error' &&
历史加载失败:{openErrorMessage}
} @@ -388,22 +417,23 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio wait, tool execution, streaming) so it never flickers per step. */} {running && }
+ {!atBottom && ( +
+ +
+ )}
- - {!atBottom && ( - - )}
) } diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 45b783f19b..7db5695030 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -1,4 +1,6 @@ // Settled-node identity prevents stream-delta updates from rerendering this row. +// Mounted on 'conversation.composer.dock' so it sticks with the composer in the +// active conversation scrollport (see ConversationRoot data-conversation-scroll). import { memo, useMemo } from 'react' import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' @@ -49,7 +51,7 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals { } } -/** Props: the conversation-snapshot selector hook (handed down by ChatView). */ +/** Props: the conversation-snapshot selector (dock registration or unit mount). */ export interface StatsLineProps { useSession: SnapshotSelectorHook } export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) { diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 9f3f3edf02..5781d2dc15 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -117,6 +117,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Owner share of the strict session content seat. */ export interface ConversationSessionOwnerProps { + /** + * Active phase only: wrap the view ring in the transcript scrollport that + * also hosts the sticky composer. The header stays outside that wrapper as + * ordinary column chrome (`flex: none`), while the composer sticks to the + * bottom of the same scrollport so wheel over the footer scrolls the flow. + * @param view - the session view-ring content. + * @returns the scrollport containing `view` and the sticky composer. + */ + wrapActiveBody?: (view: ReactNode) => ReactNode } /** diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index de974da86c..18278e34a0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -127,6 +127,40 @@ flex-direction: column; } +/* Active phase: header is ordinary column chrome above the scrollport (not + sticky). The scroll body holds the transcript and the sticky composer so + wheel over the footer moves the flow. */ +.root[data-phase='active'] { + overflow: hidden; +} + +.root[data-phase='active'] .header { + flex: none; +} + +.scrollBody { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow-y: auto; +} + +.root[data-phase='active'] .viewArea { + flex: 1 0 auto; + min-height: auto; +} + +.root[data-phase='active'] .composerStack { + position: sticky; + bottom: 0; + /* Above markdown CodeBlock sticky banners (z-index 6) so the footer never + paints under a sticking code header while scrolling. */ + z-index: 7; + flex: none; + background: var(--dsw-alias-bg-base); +} + /* Hero phase: the composer stack (hero chrome + workspace row + card) is flex-centered in the column; composer phase docks it at the bottom. Flex, NOT absolute+transform: a transform would make this box the containing diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index b110860685..7f62598a49 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -2,7 +2,7 @@ // chain stay mounted across no-session/session transitions. Only the inert // input body swaps for the strict session InputBar. -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, type ReactNode } from 'react' import clsx from 'clsx' import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSlotProps, InputZone } from '../contract/slots.ts' @@ -113,24 +113,42 @@ export function ConversationRoot({ {hero && } {hero && } {hero && heroWorkspaceRow} - {!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)} + {/* Stats band above the input-dock strips so the prior ChatView footer + order (stats → todo/queue → card) is preserved under the sticky stack. */} {!hero && zone !== undefined && renderSlot('conversation.composer.dock', zone)} + {!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)} {inputBar}
) + const phase = settling ? 'settling' : hero ? 'hero' : 'active' + const composer = renderSlotChain( + 'conversation.composer', + { interactions: pending }, + { fallback: composerBar, overlay: true }, + ) + + // Active: header is column chrome above the scrollport; the sticky composer + // lives inside the same scrollport as the transcript (wheel over the footer + // scrolls the flow). Hero/settling keep the composer as a Root child. + const wrapActiveBody = (view: ReactNode): ReactNode => ( +
+ {view} + {composer} +
+ ) + return ( -
+
{/* Mounted for every real session, hero included: ConversationSession renders no chrome while blank but owns the draft-persistence mirror bind — unmounting it in the hero would lose pre-first-send text on a refresh or scope rebuild. */} - {sessionId !== undefined && renderSlot('conversation.session', {})} - {renderSlotChain( - 'conversation.composer', - { interactions: pending }, - { fallback: composerBar, overlay: true }, + {sessionId !== undefined && renderSlot( + 'conversation.session', + phase === 'active' ? { wrapActiveBody } : {}, )} + {phase !== 'active' ? composer : null}
) } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 0c1addcba7..f36874cfc2 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -1,6 +1,6 @@ /** Strict per-session conversation content: header, view ring, and chat store bindings. */ -import { useEffect, useSyncExternalStore } from 'react' +import { useEffect, useSyncExternalStore, type ReactNode } from 'react' import clsx from 'clsx' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' @@ -24,7 +24,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session export function ConversationSession({ sessionId, useSession, useSessions, useInput, inputActions, useStore, actions, - renderSlot, views, bindDraftMirror, open, + renderSlot, views, bindDraftMirror, open, wrapActiveBody, }: ConversationSessionProps) { useSyncExternalStore(views.subscribe, views.version) const tabs = views.list() @@ -46,6 +46,12 @@ export function ConversationSession({ if (blank && composerPhase === 'blank') return null + const view: ReactNode = ( +
+ {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })} +
+ ) + return ( <>
@@ -72,24 +78,22 @@ export function ConversationSession({
{tabs.length > 1 && (
- {tabs.map(view => ( + {tabs.map(viewTab => ( ))}
)} -
- {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })} -
+ {wrapActiveBody !== undefined ? wrapActiveBody(view) : view} ) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index a98ee09114..9586a04cdc 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -75,6 +75,22 @@ export function InputBar({ if (!locked) inputRef.current?.focus() }, [locked]) + // Active conversation scrollport: never let the textarea become a nested + // wheel target; forward delta to `[data-conversation-scroll]` instead. + // Hero mounts have no host, so the textarea keeps native wheel scrolling. + useEffect(() => { + const el = inputRef.current + if (el === null) return + const onWheel = (e: WheelEvent): void => { + const host = el.closest('[data-conversation-scroll]') + if (!(host instanceof HTMLElement)) return + e.preventDefault() + host.scrollTop += e.deltaY + } + el.addEventListener('wheel', onWheel, { passive: false }) + return () => { el.removeEventListener('wheel', onWheel) } + }, []) + const onKeyDown = (e: KeyboardEvent): void => { // Shift+Enter is the native newline UNCONDITIONALLY — decided before the // IME guard so a composition-closing Shift+Enter still breaks the line. diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 4d6dc99f4a..b66c45aa8a 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -84,6 +84,8 @@ describe('apply wiring', () => { // service being present implies the chat entry declared the hole first. const entries = b.slots.entries('conversation.chat.toolview') expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write']) + // Stats stick with the composer (not inside ChatView). + expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats']) await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 6985991074..38edc48cca 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -// StatsLine (rendered inside the chat view body): totals derivation + the RFC +// StatsLine (composer.dock entry): totals derivation + the RFC // hard acceptance — zero renders during streaming. Bash sample row: the // canonical sub-agent differential decided INSIDE the component off the // standard useSessions kit (no registry predicates — tool ring dissolved). diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 328ba38340..7254add4ca 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -371,6 +371,28 @@ describe('ChatView', () => { expect(view.queryByLabelText('回到底部')).toBeNull() }) + it('under data-conversation-scroll, bottom-follow targets the host scrollport', () => { + const host = document.createElement('div') + host.setAttribute('data-conversation-scroll', '') + Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true }) + Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true }) + Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true }) + document.body.appendChild(host) + try { + const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) + const view = render(, { container: host }) + // Open jump uses the host, not the local .scroll node. + expect(host.scrollTop).toBe(2000) + host.scrollTop = 100 + fireEvent.scroll(host) + expect(view.getByLabelText('回到底部')).toBeTruthy() + fireEvent.click(view.getByLabelText('回到底部')) + expect(host.scrollTop).toBe(2000) + } finally { + host.remove() + } + }) + it('paging button loads older and shows its busy label', () => { const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true }) const view = render() diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 23d853dd1a..08d99ed260 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -227,6 +227,22 @@ describe('running and lock semantics (queue cut 1)', () => { expect((textarea).value).toBe('typed') }) + it('wheel over the textarea scrolls the conversation host, not a nested textarea port', () => { + const host = document.createElement('div') + host.setAttribute('data-conversation-scroll', '') + Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true }) + const { view, textarea } = bench() + host.appendChild(view.container) + document.body.appendChild(host) + try { + const wheeled = fireEvent.wheel(textarea, { deltaY: 30 }) + expect(wheeled).toBe(false) // preventDefault + expect(host.scrollTop).toBe(70) + } finally { + host.remove() + } + }) + it('disabled state shows the unavailable placeholder; custom placeholder wins', () => { const { textarea } = bench({ disabled: true }) expect(textarea.placeholder).toBe('Session unavailable') diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 0d32e2edea..8e9377e73f 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -102,6 +102,7 @@ function mount( views={{ list: () => [{ id: 'chat', label: 'Chat' }], subscribe: () => () => {}, version: () => 1 }} bindDraftMirror={write => wiring.bindMirror(write)} open={open} + {...owner} /> ) } @@ -166,6 +167,18 @@ describe('ConversationRoot resident composer', () => { expect(b.open).toHaveBeenCalledWith(sid('root')) }) + it('active phase: fixed header outside the scrollport; sticky composer inside it', () => { + const b = mount(conversationSnapshot()) + const host = b.view.container.querySelector('[data-conversation-scroll]') + const header = b.view.container.querySelector('header') + const textarea = b.view.container.querySelector('textarea') + expect(host).not.toBeNull() + expect(header).not.toBeNull() + // Header is column chrome above the scrollport; composer sticks inside it. + expect(host?.contains(header)).toBe(false) + expect(host?.contains(textarea)).toBe(true) + }) + it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => { const b = mount( conversationSnapshot({ composerPhase: 'blank', blank: true }), @@ -174,7 +187,8 @@ describe('ConversationRoot resident composer', () => { { ...workspace('second'), title: 'Selected Folder' }, ], ) - // Hero chrome present, view ring absent. + // Hero chrome present, view ring absent; scroll host is active-phase only. + expect(b.view.container.querySelector('[data-conversation-scroll]')).toBeNull() expect(b.view.getByText("Let's start building")).toBeTruthy() expect(b.view.queryByTestId('view-chat')).toBeNull() // The same machine-backed textarea is live in the hero, and the @@ -193,16 +207,19 @@ describe('ConversationRoot resident composer', () => { expect(b.view.getByText('Selected Folder')).toBeTruthy() }) - it('textarea DOM identity survives the hero → active flip', () => { + it('machine draft survives the hero → active flip into the sticky scrollport composer', () => { const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true })) const before = b.view.getByRole('textbox') fireEvent.change(before, { target: { value: 'kept across flip' } }) - // First message landed: content exists, phase leaves blank. + // First message landed: content exists, phase leaves blank. The active + // composer lives inside the Session scrollport (sticky footer), so the + // textarea may remount; the InputHub draft is the durable carrier. b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false })) b.rerender() - const after = b.view.getByRole('textbox') - expect(after).toBe(before) - expect((after as HTMLTextAreaElement).value).toBe('kept across flip') + const after = b.view.getByRole('textbox') as HTMLTextAreaElement + expect(after.value).toBe('kept across flip') + expect(b.chat.store.getSnapshot().draft).toBe('kept across flip') + expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true) expect(b.view.queryByText("Let's start building")).toBeNull() expect(b.view.getByTestId('view-chat')).toBeTruthy() }) diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css index 7222c3df44..4f78a49935 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css @@ -74,6 +74,10 @@ white-space: pre-wrap; word-break: break-all; background: var(--dsw-alias-markdown-code-block); + /* Bottom radii live on
: overflow:hidden on .block would kill the
+     sticky banner, and this opaque fill otherwise squares off the wrapper. */
+  border-bottom-left-radius: var(--dsl-code-block-border-radius);
+  border-bottom-right-radius: var(--dsl-code-block-border-radius);
 }
 
 /* Shiki inlines its theme background var; route it to the repo token. */
diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css
index 16a853c441..6873315cd7 100644
--- a/packages/client/ui-trajectory/src/client/views.module.css
+++ b/packages/client/ui-trajectory/src/client/views.module.css
@@ -1,5 +1,7 @@
 /* Full-bleed scroll host so Turn sticky bars can paint edge-to-edge;
- * cell content width is capped on the turn body (max 880). */
+ * cell content width is capped on the turn body (max 880). Under the
+ * active conversation column (`[data-conversation-scroll]`) the parent
+ * owns overflow so the sticky composer stays in the same scrollport. */
 .root {
   overflow-y: auto;
   height: 100%;
@@ -10,6 +12,11 @@
   background: var(--dsw-specific-sidebar-fill);
 }
 
+:global([data-conversation-scroll]) .root {
+  overflow: visible;
+  height: auto;
+}
+
 .empty {
   padding: 16px;
   color: var(--dsw-alias-label-tertiary);

From 382884e2378987e0500f915c59682372aba72d0d Mon Sep 17 00:00:00 2001
From: 07akioni <07akioni2@gmail.com>
Date: Wed, 29 Jul 2026 20:39:45 +0800
Subject: [PATCH 18/32] fix: scroll to bottom

---
 .../src/client/chat/ChatView.tsx              | 19 +++++++++++++++----
 .../ui-conversation/tests/chat-view.spec.tsx  | 14 ++++++++++++++
 2 files changed, 29 insertions(+), 4 deletions(-)

diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx
index e281c0e11d..b9e1e3351f 100644
--- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx
@@ -253,9 +253,15 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
   const firstSeqRef = useRef(null)
   const openedRef = useRef(false)
   const lastKeyRef = useRef(null)
+  /** Flow tip signature — follow-scroll only when this moves, never on a
+   *  scroll-driven at-bottom chrome re-render (that was snapping inertial
+   *  scrolls the rest of the way to the floor). */
+  const followSigRef = useRef(null)
 
   const firstSeq = nodes[0]?.seq ?? null
   const lastItem = items[items.length - 1]
+  const lastKey = lastItem?.key ?? null
+  const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}`
 
   const toBottom = (el: HTMLElement): void => {
     el.scrollTop = el.scrollHeight
@@ -273,7 +279,8 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
       openedRef.current = true
       toBottom(el)
       firstSeqRef.current = firstSeq
-      lastKeyRef.current = lastItem?.key ?? null
+      lastKeyRef.current = lastKey
+      followSigRef.current = followSig
       return
     }
     // Prepend (head seq decreased): compensate by the height delta.
@@ -282,17 +289,21 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
       anchorRef.current = null
       firstSeqRef.current = firstSeq
       /* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
-      lastKeyRef.current = lastItem?.key ?? null
+      lastKeyRef.current = lastKey
+      followSigRef.current = followSig
       return
     }
     firstSeqRef.current = firstSeq
     // Own words must be visible: a new trailing user node force-scrolls
     // (send lives in the composer, so arrival is detected here, not armed there).
-    const lastKey = lastItem?.key ?? null
     const appendedUser = lastKey !== lastKeyRef.current
       && lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
+    const tipMoved = followSigRef.current !== followSig
     lastKeyRef.current = lastKey
-    if (appendedUser || atBottomRef.current) toBottom(el)
+    followSigRef.current = followSig
+    // Follow new flow content while pinned; do NOT re-pin on every render
+    // merely because atBottomRef is true (scroll threshold → setState → snap).
+    if (appendedUser || (tipMoved && atBottomRef.current)) toBottom(el)
   })
 
   const onScrollRef = useRef(() => {})
diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx
index 7254add4ca..c0cb4dcb78 100644
--- a/packages/client/ui-conversation/tests/chat-view.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx
@@ -371,6 +371,20 @@ describe('ChatView', () => {
     expect(view.queryByLabelText('回到底部')).toBeNull()
   })
 
+  it('entering the at-bottom threshold does not snap the remaining scroll distance', () => {
+    const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
+    const view = render()
+    const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+    Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
+    Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
+    // Inside FOLLOW_THRESHOLD (24) but not flush with the floor — the chrome
+    // re-render from setAtBottom must not force scrollTop to scrollHeight.
+    scroller.scrollTop = 690 // distance-to-bottom = 10
+    fireEvent.scroll(scroller)
+    expect(view.queryByLabelText('回到底部')).toBeNull()
+    expect(scroller.scrollTop).toBe(690)
+  })
+
   it('under data-conversation-scroll, bottom-follow targets the host scrollport', () => {
     const host = document.createElement('div')
     host.setAttribute('data-conversation-scroll', '')

From cf390cc6fd62c5e007b2cb31ab9f3dd695716938 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Wed, 29 Jul 2026 20:44:06 +0800
Subject: [PATCH 19/32] fix(skill): recognize empty catalog tombstones

---
 packages/skill/tool-skill/src/index.ts             | 6 ++++--
 packages/skill/tool-skill/tests/tool-skill.spec.ts | 3 +++
 2 files changed, 7 insertions(+), 2 deletions(-)

diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts
index bd635c9893..f6f45b0316 100644
--- a/packages/skill/tool-skill/src/index.ts
+++ b/packages/skill/tool-skill/src/index.ts
@@ -18,7 +18,7 @@ export const inject = ['agents', 'tools', 'skills']
 
 const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500
 const CATALOG_ENTRIES_START = '\n'
-const CATALOG_ENTRIES_END = '\n'
+const CATALOG_ENTRIES_END = ''
 const PLUGIN_SOURCE = { kind: 'plugin', plugin: 'dsh-tool-skill' } as const
 
 /** Model-facing skill catalog configuration. */
@@ -275,7 +275,9 @@ function catalogContentDigest(content: UserMessage['content']): string | undefin
   const entriesStart = start + CATALOG_ENTRIES_START.length
   const end = text.indexOf(CATALOG_ENTRIES_END, entriesStart)
   if (end === -1) return undefined
-  return digestCatalogEntries(text.slice(entriesStart, end))
+  const renderedEntries = text.slice(entriesStart, end)
+  const entries = renderedEntries.endsWith('\n') ? renderedEntries.slice(0, -1) : renderedEntries
+  return digestCatalogEntries(entries)
 }
 
 function catalogDescription(value: string, maxLength: number): string {
diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts
index 0390504565..3962b98f28 100644
--- a/packages/skill/tool-skill/tests/tool-skill.spec.ts
+++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts
@@ -320,6 +320,9 @@ describe('dsh-tool-skill', () => {
     expect(JSON.stringify(removal.data.content)).toContain('No skills are currently available')
     expect(JSON.stringify(removal.data.content)).not.toContain('first-skill')
     expect(JSON.stringify(removal.data.content)).not.toContain('second-skill')
+
+    await fireStep(ctx, agent, 1, 4)
+    expect(catalogMessages(session)).toHaveLength(3)
   })
 
   it('resumes from the latest valid visible catalog content', async () => {

From cf7f14948c7ec525d0fa59489a5355cf7c3cc1a8 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Wed, 29 Jul 2026 20:45:00 +0800
Subject: [PATCH 20/32] test(tui): match durable skill catalog source

---
 examples/tui-agent/tests/tui-keyless-smoke.e2e.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts
index 40076c97e3..139c8a1900 100644
--- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts
+++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts
@@ -105,7 +105,7 @@ async function readLoggedRequestContext(cwd: string): Promise
Date: Wed, 29 Jul 2026 20:46:29 +0800
Subject: [PATCH 21/32] docs(client): sync package READMEs and agent notes with
 the input interaction rework

Update the six touched client package README pairs (slash menu ordering,
localized group titles and dismiss, permission label twin, goal pause,
plan hint localization, useAnchoredMaxHeight) and keep the owning agent
notes current: SlashSource.order and the MenuView dismiss/localize/clamp
face in the slash-pipeline note, the pause verb in the goal bar note.
---
 ...25-web-input-machine-and-slash-pipeline.i18n.yaml |  4 ++--
 ...026-07-25-web-input-machine-and-slash-pipeline.md |  4 ++--
 ...-07-25-web-input-machine-and-slash-pipeline.zh.md |  4 ++--
 .../feature/2026-07-22-docked-web-goal-bar.i18n.yaml |  4 ++--
 .../feature/2026-07-22-docked-web-goal-bar.md        | 12 ++++++------
 .../feature/2026-07-22-docked-web-goal-bar.zh.md     | 12 ++++++------
 packages/client/ui-conversation/README.i18n.yaml     |  4 ++--
 packages/client/ui-conversation/README.md            |  4 ++--
 packages/client/ui-conversation/README.zh.md         |  4 ++--
 packages/client/ui-goal/README.i18n.yaml             |  4 ++--
 packages/client/ui-goal/README.md                    |  4 ++--
 packages/client/ui-goal/README.zh.md                 |  4 ++--
 packages/client/ui-permission/README.i18n.yaml       |  4 ++--
 packages/client/ui-permission/README.md              |  2 +-
 packages/client/ui-permission/README.zh.md           |  2 +-
 packages/client/ui-plan/README.i18n.yaml             |  4 ++--
 packages/client/ui-plan/README.md                    |  2 +-
 packages/client/ui-plan/README.zh.md                 |  2 +-
 packages/client/ui-primitives/README.i18n.yaml       |  6 +++---
 packages/client/ui-primitives/README.md              |  2 +-
 packages/client/ui-primitives/README.zh.md           |  2 +-
 packages/client/ui-slash/README.i18n.yaml            |  4 ++--
 packages/client/ui-slash/README.md                   |  3 +--
 packages/client/ui-slash/README.zh.md                |  3 +--
 24 files changed, 49 insertions(+), 51 deletions(-)

diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
index 121879629c..7abc19ba70 100644
--- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.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/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
-2026-07-25-web-input-machine-and-slash-pipeline.md: 8cf3be7b3b7579d0c37898a58fb0ab4990fd71bf
-2026-07-25-web-input-machine-and-slash-pipeline.zh.md: b1488893558d8b2bf9c104faca968435d46a9640
+2026-07-25-web-input-machine-and-slash-pipeline.md: f446f42c9e202afcb404c7a551a4f715228bb8e5
+2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 8f5e449bb878811b70bc5bc29e4a09bbc1a33bfa
diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
index 8cf3be7b3b..f446f42c9e 100644
--- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
+++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
@@ -62,8 +62,8 @@ Calls that stay un-evented (registry registration → explicit call → await):
 
 A trigger/menu/pick pipeline with zero knowledge of "commands":
 
-- The service holds only the source registry (`SlashSource{trigger: '/'|'@', name, candidates, onPick, matchSpace?, matchEnter?}`; (trigger,name) unique, registration order = group order = polling order) and `sessionOf(sctx)`. Implementing a match hook IS the declaration of participation in space/enter adjudication; the pipeline polls in registration order, the first non-undefined answer wins, and no claimant means the default sink. matchSpace is synchronous (space fires mid-keystroke; hot cache only); matchEnter is asynchronous (it may await the source's own warmup, and a warmup failure rejects).
-- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller.
+- The service holds only the source registry (`SlashSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`; (trigger,name) unique; the optional `order` sorts the roster — lower first, default 0, ties keep registration order — and that sorted roster is both group order and polling order) and `sessionOf(sctx)`. Implementing a match hook IS the declaration of participation in space/enter adjudication; the pipeline polls in roster order, the first non-undefined answer wins, and no claimant means the default sink. matchSpace is synchronous (space fires mid-keystroke; hot cache only); matchEnter is asynchronous (it may await the source's own warmup, and a warmup failure rejects).
+- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events); a `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller.
 - Trigger-detection word boundaries (`user@host` and URL `/` never trigger) and the guard tiers (plain: `/` everywhere + `@` inline / claimed: `/` suppressed, `@` live / frozen: none) are the frozen pure core.
 
 ### hub / facade: the resident shell and the strict-session input body
diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
index b148889355..8f5e449bb8 100644
--- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
@@ -62,8 +62,8 @@ occurrence 表与 chip 三投影:
 
 对"命令"零知识的触发/菜单/pick 管线:
 
-- service 只有 source 注册表(`SlashSource{trigger: '/'|'@', name, candidates, onPick, matchSpace?, matchEnter?}`;(trigger,name) 唯一、注册序 = 组序 = 轮询序)与 `sessionOf(sctx)`。实现 match 钩子即参与空格/回车裁决的声明;管线按注册序轮询,首个非 undefined 应答胜出,无人认领落 default sink。matchSpace 同步(空格在击键中触发,只许热缓存);matchEnter 异步(可 await 源自身预热,预热失败即 reject)。
-- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、per-session menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在 textarea,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行)、pick 编排(outcome → 自派 bail 事件);每个 session scope 出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。
+- service 只有 source 注册表(`SlashSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`;(trigger,name) 唯一;可选 `order` 对 roster 排序——越小越靠前、默认 0、同值保持注册序——排序后的 roster 同时是组序与轮询序)与 `sessionOf(sctx)`。实现 match 钩子即参与空格/回车裁决的声明;管线按 roster 序轮询,首个非 undefined 应答胜出,无人认领落 default sink。matchSpace 同步(空格在击键中触发,只许热缓存);matchEnter 异步(可 await 源自身预热,预热失败即 reject)。
+- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、per-session menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在 textarea,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行)、pick 编排(outcome → 自派 bail 事件);`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个 session scope 出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。
 - 触发检测词边界(`user@host`、URL `/` 永不触发)、守卫分档(plain:`/` 到处 + `@` 行内 / claimed:`/` 抑制、`@` 活 / frozen:全无)为冻结纯核。
 
 ### hub / facade:常驻外壳与严格 session 输入体
diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml
index 48a75f3449..187c4dfe94 100644
--- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.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-22-docked-web-goal-bar.md
-2026-07-22-docked-web-goal-bar.md: 52a7d223ce3522c5ba977b1126dcd63bd2f6366f
-2026-07-22-docked-web-goal-bar.zh.md: e4842a03ccb8a29b35c7af0c03c51b1324b6ab36
+2026-07-22-docked-web-goal-bar.md: 110aea299a260896b0098f10b337734e0c0aebcf
+2026-07-22-docked-web-goal-bar.zh.md: cc0a5eda6815e6c02e97fd02197659764d4f2d69
diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md
index 52a7d223ce..110aea299a 100644
--- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md
+++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md
@@ -12,9 +12,9 @@ The web UI had no goal surface at all: the goal stack shipped with model tools,
 
 `GoalBar` (`packages/client/ui-goal/src/client/GoalBar.tsx`) is a new props-driven, self-contained component; `ConversationRoot` mounts it immediately before the composer `InputBar`. The strip's CSS mirrors the composer's horizontal geometry (32px side padding, 776px centered cap) plus the mock's 12px inset, and a -10px bottom margin eats InputBar's 8px top padding and tucks its square bottom edge 2px under the composer card's top edge. All strip states share one fixed 38px height so switching between them never resizes it. Loading (`goal === undefined`), absent (`goal === null`), and `phase === 'complete'` render nothing — a completed goal is history, not chrome.
 
-Visibility drives the label and actions: active shows "Ongoing Goal" with edit/clear; paused shows "Paused Goal" and adds a resume icon button; blocked shows "Blocked Goal" and carries `blockedReason.message` as the strip's `title` tooltip. Goal creation lives on the `/goal` command, not in the bar. The pencil swaps the strip for an inline edit form prefilled with the current objective: Enter or the check button saves through `GoalBarActions.onEdit(objective)`, Esc cancels, and an all-whitespace objective keeps save disabled. The form closes only when the edit succeeds; a failure preserves the draft and displays the error in the bar. Resume and clear failures are displayed there as well. Clear otherwise calls `onClear` directly with no confirmation — a clear keeps a durable tombstone, so nothing is unrecoverable. An effect keyed on the goal's id drops the edit form when the goal's identity changes, so a surviving draft can never be written over the goal that replaced it.
+Visibility drives the label and actions: active shows "Ongoing Goal" with pause/edit/clear; paused shows "Paused Goal" and swaps pause for a resume icon button; blocked shows "Blocked Goal" and carries `blockedReason.message` as the strip's `title` tooltip. Goal creation lives on the `/goal` command, not in the bar. The pencil swaps the strip for an inline edit form prefilled with the current objective: Enter or the check button saves through `GoalBarActions.onEdit(objective)`, Esc cancels, and an all-whitespace objective keeps save disabled. The form closes only when the edit succeeds; a failure preserves the draft and displays the error in the bar. Resume and clear failures are displayed there as well. Clear otherwise calls `onClear` directly with no confirmation — a clear keeps a durable tombstone, so nothing is unrecoverable. An effect keyed on the goal's id drops the edit form when the goal's identity changes, so a surviving draft can never be written over the goal that replaced it.
 
-`GoalBarActions` lives in the contract layer (`contract/slots.ts`, next to the `ConversationInjected.goalActions` slot it feeds) and carries exactly the rendered verbs: `onEdit`/`onResume`/`onClear`. Each callback asynchronously returns an explicit success/failure result so `GoalBar` owns its transitions and error display. `apply.ts` wires them to the runtime session methods; the runtime session resolves the current goal's compare-and-set ref internally, so the UI passes no ref.
+`GoalBarActions` lives in ui-goal's slot contract (`packages/client/ui-goal/src/client/slots.ts`) and carries exactly the rendered verbs: `onEdit`/`onPause`/`onResume`/`onClear`. Each callback asynchronously returns an explicit success/failure result so `GoalBar` owns its transitions and error display. `apply.ts` wires them to the runtime session methods; the runtime session resolves the current goal's compare-and-set ref internally, so the UI passes no ref.
 
 The runtime session gains the goal surface the strip (and future UI) needs: `fetchGoal` populates the snapshot on open, and a live `context/message` carrying `goal/change` meta triggers a coalesced refetch — concurrent triggers share the in-flight `goal.get`, while a trigger received during that read schedules one coalesced trailing read so independently ordered notifications and GET responses cannot leave stale state. Window replays never refetch, and matching the meta kind (rather than a goal key) also catches clear tombstones written by other clients. The six mutation verbs fold transport failures into `{ ok: false }` results like every sibling session method, and a get result older than a mutation response that landed mid-flight is dropped.
 
@@ -22,18 +22,18 @@ The strip's background is `--dsw-alias-interactive-bg-hover` rather than the moc
 
 ## Testing
 
-`packages/client/ui-goal/tests/goalbar.spec.tsx` pins the behavior through props alone: loading/absent/complete render nothing, the active strip renders label/objective and fires clear, the edit form prefills, rejects empty, saves on Enter, cancels on Esc, and resets when the goal's identity changes, the paused strip fires resume, and the blocked strip exposes the reason tooltip. Component failure-path cases prove that a failed edit preserves its draft and that edit/resume/clear errors remain visible in the bar. The skeleton specs mount `ConversationRoot` with and without `goalActions`; the undefined case is seeded with an active goal, so the missing gate — not the missing goal — is what hides the strip. Runtime session specs pin the folded-error results, the live-only in-flight-plus-trailing refetch, and the stale-read guard. A keyless real-browser smoke boots the assembled application through `boot → RPC → runtime → GoalBar` and records an inline snapshot of the rendered label, objective, and actions.
+`packages/client/ui-goal/tests/goalbar.spec.tsx` pins the behavior through props alone: loading/absent/complete render nothing, the active strip renders label/objective and fires clear, the edit form prefills, rejects empty, saves on Enter, cancels on Esc, and resets when the goal's identity changes, the active strip fires pause, the paused strip fires resume, and the blocked strip exposes the reason tooltip. Component failure-path cases prove that a failed edit preserves its draft and that edit/resume/clear errors remain visible in the bar. The skeleton specs mount `ConversationRoot` with and without `goalActions`; the undefined case is seeded with an active goal, so the missing gate — not the missing goal — is what hides the strip. Runtime session specs pin the folded-error results, the live-only in-flight-plus-trailing refetch, and the stale-read guard. A keyless real-browser smoke boots the assembled application through `boot → RPC → runtime → GoalBar` and records an inline snapshot of the rendered label, objective, and actions.
 
 ## Alternatives considered
 
 - **Put the strip in the session header** — rejected because the redesign's premise is that goal presence belongs to the composer's context; a header strip cannot dock into the composer card.
 - **Render a "Loading goal…" placeholder for `undefined`** — rejected: the strip would flash and collapse on every session open, chrome noise for a sub-second state.
 - **Include an inline create affordance when no goal is set** — rejected after implementation review: goal creation lives on the `/goal` command, matching the pattern where the model creates goals on request; the bar is a status indicator, not a creation surface.
-- **Carry the full verb set (`onPause`/`onComplete`) in `GoalBarActions`** — rejected as speculative generality: no consumer calls them, so the interface carries only the rendered verbs.
+- **Carry the full verb set (`onComplete` included) in `GoalBarActions`** — rejected as speculative generality: the interface carries only the rendered verbs (`onPause` joined it when the active strip gained its pause action).
 
 ## Consequences
 
-- Goal presence in the web UI is a composer-docked strip: sparkle, phase label, truncated objective, and edit/clear (plus resume when paused) — the browser client's first goal surface.
+- Goal presence in the web UI is a composer-docked strip: sparkle, phase label, truncated objective, and pause/edit/clear (resume replacing pause when paused) — the browser client's first goal surface.
 - The runtime session exposes the goal verbs over RPC with folded transport errors, and refreshes the snapshot's goal on open and on live goal-change meta (coalesced, guarded against stale reads).
-- Objective editing is reachable from the UI for the first time, through `goal.edit` with the runtime-owned ref; pause/complete remain available to other surfaces (`/goal`, model tools).
+- Objective editing is reachable from the UI for the first time, through `goal.edit` with the runtime-owned ref; complete remains available to other surfaces (`/goal`, model tools).
 - `goal === null` renders nothing; the composer carries no persistent create affordance — creation is the `/goal` command's job.
diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md
index e4842a03cc..cc0a5eda68 100644
--- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md
@@ -12,9 +12,9 @@ Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、T
 
 `GoalBar`(`packages/client/ui-goal/src/client/GoalBar.tsx`)是一个新的、由 props 驱动的自包含组件;`ConversationRoot` 将它挂载在输入框 `InputBar` 紧上方。横条的 CSS 对齐输入框的水平几何(两侧 32px 内边距、776px 居中上限),再加上设计稿的 12px 内缩,并用 -10px 的下外边距吃掉 InputBar 的 8px 上内边距,使它方形的底边收进输入框卡片顶边之下 2px。横条的所有状态共享固定的 38px 高度,状态切换不会引起尺寸变化。加载中(`goal === undefined`)、无目标(`goal === null`)和 `phase === 'complete'` 时不渲染任何内容:已完成的目标是历史记录,不是常驻界面元素。
 
-可见性决定标签和操作:active 状态显示 "Ongoing Goal" 并提供编辑/清除;paused 状态显示 "Paused Goal",并增加一个恢复图标按钮;blocked 状态显示 "Blocked Goal",并把 `blockedReason.message` 作为横条的 `title` 悬浮提示。创建目标的入口在 `/goal` 命令上,不在横条里。铅笔图标把横条切换为内联编辑表单,预填当前目标内容:Enter 或勾选按钮通过 `GoalBarActions.onEdit(objective)` 保存,Esc 取消,目标内容全为空白字符时保存按钮保持禁用。编辑成功后表单才会关闭;编辑失败时保留草稿,并在横条中显示错误。恢复和清除失败也显示在横条中。除此之外,清除直接调用 `onClear`,不做确认——清除会保留 durable 墓碑,没有不可恢复的损失。一个以目标 id 为键的 effect 会在目标身份变化时丢弃编辑表单,因此存留的草稿绝不可能覆盖掉替换它的新目标。
+可见性决定标签和操作:active 状态显示 "Ongoing Goal" 并提供暂停/编辑/清除;paused 状态显示 "Paused Goal",把暂停换成一个恢复图标按钮;blocked 状态显示 "Blocked Goal",并把 `blockedReason.message` 作为横条的 `title` 悬浮提示。创建目标的入口在 `/goal` 命令上,不在横条里。铅笔图标把横条切换为内联编辑表单,预填当前目标内容:Enter 或勾选按钮通过 `GoalBarActions.onEdit(objective)` 保存,Esc 取消,目标内容全为空白字符时保存按钮保持禁用。编辑成功后表单才会关闭;编辑失败时保留草稿,并在横条中显示错误。恢复和清除失败也显示在横条中。除此之外,清除直接调用 `onClear`,不做确认——清除会保留 durable 墓碑,没有不可恢复的损失。一个以目标 id 为键的 effect 会在目标身份变化时丢弃编辑表单,因此存留的草稿绝不可能覆盖掉替换它的新目标。
 
-`GoalBarActions` 位于 contract 层(`contract/slots.ts`,紧挨它所喂给的 `ConversationInjected.goalActions` 槽位),只携带实际渲染的动词:`onEdit`/`onResume`/`onClear`。每个回调都会异步返回显式成功/失败结果,因此 `GoalBar` 自行负责界面转换和错误显示。`apply.ts` 把它们接到运行时会话方法上;运行时会话在内部解析当前目标的 compare-and-set ref,因此 UI 不传 ref。
+`GoalBarActions` 位于 ui-goal 的槽位契约(`packages/client/ui-goal/src/client/slots.ts`),只携带实际渲染的动词:`onEdit`/`onPause`/`onResume`/`onClear`。每个回调都会异步返回显式成功/失败结果,因此 `GoalBar` 自行负责界面转换和错误显示。`apply.ts` 把它们接到运行时会话方法上;运行时会话在内部解析当前目标的 compare-and-set ref,因此 UI 不传 ref。
 
 运行时会话获得了横条(以及未来 UI)所需的目标表面:`fetchGoal` 在打开时填充快照;携带 `goal/change` 元数据的 live `context/message` 触发合并重新拉取——并发触发器共享正在执行的 `goal.get`,读取期间收到的触发器会安排一次合并后的尾随读取,避免彼此独立排序的通知和 GET 响应留下陈旧状态。窗口重放绝不触发重新拉取,且匹配元数据 kind(而不是 goal 键)还能捕获其他客户端写入的清除墓碑。六个变更动词与所有同类会话方法一样,把传输层失败折叠为 `{ ok: false }` 结果;比在拉取途中落地的变更响应更旧的 get 结果会被丢弃。
 
@@ -22,18 +22,18 @@ Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、T
 
 ## 测试
 
-`packages/client/ui-goal/tests/goalbar.spec.tsx` 仅通过 props 固定这些行为:加载中/无目标/已完成时不渲染;active 横条渲染标签和目标内容并触发清除;编辑表单预填内容、拒绝空值、按 Enter 保存、按 Esc 取消,并在目标身份变化时重置;paused 横条触发恢复;blocked 横条暴露原因悬浮提示。组件失败路径用例证明编辑失败时保留草稿,并且编辑/恢复/清除错误持续显示在横条中。skeleton 规格测试分别挂载带与不带 `goalActions` 的 `ConversationRoot`;未定义的情形预置了一个 active 目标,因此隐藏横条的是缺失的挂载门,而不是缺失的目标。运行时会话规格测试固定了折叠错误结果、仅 live 的执行中读取加尾随读取,以及陈旧读取守卫。一个无密钥真实浏览器冒烟测试通过 `boot → RPC → runtime → GoalBar` 启动组装后的应用,并以内联快照记录渲染出的标签、目标内容和操作。
+`packages/client/ui-goal/tests/goalbar.spec.tsx` 仅通过 props 固定这些行为:加载中/无目标/已完成时不渲染;active 横条渲染标签和目标内容并触发清除;编辑表单预填内容、拒绝空值、按 Enter 保存、按 Esc 取消,并在目标身份变化时重置;active 横条触发暂停;paused 横条触发恢复;blocked 横条暴露原因悬浮提示。组件失败路径用例证明编辑失败时保留草稿,并且编辑/恢复/清除错误持续显示在横条中。skeleton 规格测试分别挂载带与不带 `goalActions` 的 `ConversationRoot`;未定义的情形预置了一个 active 目标,因此隐藏横条的是缺失的挂载门,而不是缺失的目标。运行时会话规格测试固定了折叠错误结果、仅 live 的执行中读取加尾随读取,以及陈旧读取守卫。一个无密钥真实浏览器冒烟测试通过 `boot → RPC → runtime → GoalBar` 启动组装后的应用,并以内联快照记录渲染出的标签、目标内容和操作。
 
 ## 考虑过的替代方案
 
 - **把横条放在会话头部**:不予采纳,因为重新设计的前提是目标的存在感属于输入框的上下文;放在头部的横条无法停靠进输入框卡片。
 - **为 `undefined` 渲染 "Loading goal…" 占位**:不予采纳,每次打开会话横条都会闪现再坍缩,对一个不到一秒的状态来说只是界面噪音。
 - **未设置目标时在横条内提供内联创建入口**:实现评审后不予采纳,创建目标的职责在 `/goal` 命令上,与模型按请求创建目标的模式一致;横条是状态指示器,不是创建入口。
-- **在 `GoalBarActions` 中携带完整动词集合(`onPause`/`onComplete`)**:作为投机性泛化不予采纳,没有消费方调用它们,接口只携带实际渲染的动词。
+- **在 `GoalBarActions` 中携带完整动词集合(含 `onComplete`)**:作为投机性泛化不予采纳,接口只携带实际渲染的动词(active 横条获得暂停操作后,`onPause` 随之加入)。
 
 ## 后果
 
-- Web UI 中目标的存在形式是停靠在输入框上方的横条:闪光图标、阶段标签、截断的目标内容,以及编辑/清除(暂停时另有恢复)——这是浏览器客户端的第一个目标界面。
+- Web UI 中目标的存在形式是停靠在输入框上方的横条:闪光图标、阶段标签、截断的目标内容,以及暂停/编辑/清除(暂停时恢复取代暂停)——这是浏览器客户端的第一个目标界面。
 - 运行时会话通过 RPC 暴露目标动词并折叠传输层错误,且在打开时和 live 目标变更元数据到达时刷新快照中的目标(合并拉取,带陈旧读取守卫)。
-- 目标内容首次可以从 UI 编辑,经由 `goal.edit`,ref 由运行时持有;暂停/完成对其他界面(`/goal`、模型工具)照常可用。
+- 目标内容首次可以从 UI 编辑,经由 `goal.edit`,ref 由运行时持有;完成对其他界面(`/goal`、模型工具)照常可用。
 - `goal === null` 时不渲染任何内容;输入框不提供常驻的创建入口,创建是 `/goal` 命令的职责。
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index 045488f917..c540537827 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/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-conversation/README.md
-README.md: b68b2ee0b816d0a4ffb440f05592a21772392b77
-README.zh.md: 06a324830e1900b02765853f4c31c53b44657dca
+README.md: eba2b83815522e4ceef92dbb254bd43f5f95605f
+README.zh.md: 456000a02ebb7f797e53b9fcaeb07f7cb589f0e7
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index b68b2ee0b8..eba2b83815 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -8,7 +8,7 @@ The resident conversation shell survives no-session and session transitions. Wit
 
 The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
 
-Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); a pick submits the `/permission ` command line through the bar's injected `command` callback.
+Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels (the `/permission` popup's display transform twin), and a pick submits the `/permission ` command line through the bar's injected `command` callback.
 
 Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
 
@@ -18,7 +18,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p
 
 Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
 
-The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
+The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
 
 `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
 
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index 06a324830e..456000a02e 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -12,13 +12,13 @@
 
 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
 
-审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);选中会经由输入栏注入的 `command` 回调提交 `/permission ` 命令行。
+审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission ` 命令行。
 
 todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks ·  in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
 
 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
 
-输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
+输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
 
 `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
 
diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml
index 9cda25e2e2..666a6e472e 100644
--- a/packages/client/ui-goal/README.i18n.yaml
+++ b/packages/client/ui-goal/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-goal/README.md
-README.md: 476096a43532a0bf514cd191585872ef17f65c50
-README.zh.md: 27bd9a2e735cb4895d30eaf3b08dd939a00436fc
+README.md: fed4870f73277b22760417297d668853b8afb2db
+README.zh.md: cc607edc856e04c6ee42cc8f596aa658679a02ca
diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md
index 476096a435..fed4870f73 100644
--- a/packages/client/ui-goal/README.md
+++ b/packages/client/ui-goal/README.md
@@ -2,13 +2,13 @@
 
 English | [中文](README.zh.md)
 
-Goal surface plugin, browser half: the `GoalBar` strip in the `conversation.input.dock` list (order 1, tucked against the composer). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the three mutation verbs (edit / resume / clear over the `goal.*` wire domain); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing.
+Goal surface plugin, browser half: the `GoalBar` strip in the `conversation.input.dock` list (order 1, tucked against the composer). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing.
 
 The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
 
 ## Model Experience
 
-Indirectly, through the `goal.edit`/`goal.resume`/`goal.clear` RPCs the strip's verbs submit: each accepted mutation appends a model-visible `goal/change` context message to the session (the same durable event the projection folds), so the model sees the updated goal state on its next turn. The strip itself adds no prompt content.
+Indirectly, through the `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPCs the strip's verbs submit: each accepted mutation appends a model-visible `goal/change` context message to the session (the same durable event the projection folds), so the model sees the updated goal state on its next turn. The strip itself adds no prompt content.
 
 #### KV Cache effect
 
diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md
index 27bd9a2e73..cc607edc85 100644
--- a/packages/client/ui-goal/README.zh.md
+++ b/packages/client/ui-goal/README.zh.md
@@ -2,13 +2,13 @@
 
 [English](README.md) | 中文
 
-Goal 表面插件(浏览器半件):`conversation.input.dock` 列表中的 `GoalBar` 条带(order 1,紧贴 composer)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带三个变更动词(edit / resume / clear,走 `goal.*` 协议域);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现(RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏)。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。
+Goal 表面插件(浏览器半件):`conversation.input.dock` 列表中的 `GoalBar` 条带(order 1,紧贴 composer)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现(RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏)。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。
 
 `/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
 
 ## Model Experience
 
-间接影响:条带动词提交的 `goal.edit`/`goal.resume`/`goal.clear` RPC 每次被接受后,会向会话追加一条模型可见的 `goal/change` 上下文消息(与投影折叠的正是同一条持久事件),模型在下一轮即可看到更新后的 goal 状态。条带自身不添加任何提示词内容。
+间接影响:条带动词提交的 `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPC 每次被接受后,会向会话追加一条模型可见的 `goal/change` 上下文消息(与投影折叠的正是同一条持久事件),模型在下一轮即可看到更新后的 goal 状态。条带自身不添加任何提示词内容。
 
 #### KV Cache effect
 
diff --git a/packages/client/ui-permission/README.i18n.yaml b/packages/client/ui-permission/README.i18n.yaml
index f963bb9dda..12fef93f39 100644
--- a/packages/client/ui-permission/README.i18n.yaml
+++ b/packages/client/ui-permission/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-permission/README.md
-README.md: 0cd8e7f878a151ffacd749eb625afcb20d44ad93
-README.zh.md: 6bc299529c9795ef44cbe5429e78d6355c02a6ca
+README.md: 3377a1c5907b67b065879b012923427685c106d6
+README.zh.md: 34cf6f72394632968ded1671a5ac0377e5c78cc6
diff --git a/packages/client/ui-permission/README.md b/packages/client/ui-permission/README.md
index 0cd8e7f878..3377a1c590 100644
--- a/packages/client/ui-permission/README.md
+++ b/packages/client/ui-permission/README.md
@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-Permission preset selection plugin, browser half: a popupSelect DECORATION hung on the host `/permission` command (`ctx.command.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission ` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active, where a pick submits the `/permission ` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows no picker (a decoration never manufactures a catalog row).
+Permission preset selection plugin, browser half: a popupSelect DECORATION hung on the host `/permission` command (`ctx.command.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission ` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active and kebab-case preset names rendered as title-case labels (`workspace-write` → `Workspace Write`, the composer chip's display transform twin), where a pick submits the `/permission ` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows no picker (a decoration never manufactures a catalog row).
 
 The `/client` export surface is the plugin body (`apply`/`inject`).
 
diff --git a/packages/client/ui-permission/README.zh.md b/packages/client/ui-permission/README.zh.md
index 6bc299529c..34cf6f7239 100644
--- a/packages/client/ui-permission/README.zh.md
+++ b/packages/client/ui-permission/README.zh.md
@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-权限预设选择插件(浏览器半侧):挂在 host `/permission` 命令上的 popupSelect **装饰**(`ctx.command.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission ` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,选中即提交 `/permission ` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合不显示选择框(装饰绝不无中生有目录行)。
+权限预设选择插件(浏览器半侧):挂在 host `/permission` 命令上的 popupSelect **装饰**(`ctx.command.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission ` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,kebab-case 预设名渲染为 Title Case 标签(`workspace-write` → `Workspace Write`,与 composer chip 的显示变换孪生),选中即提交 `/permission ` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合不显示选择框(装饰绝不无中生有目录行)。
 
 `/client` 导出面为插件本体(`apply`/`inject`)。
 
diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml
index f7572c9bfa..199210a863 100644
--- a/packages/client/ui-plan/README.i18n.yaml
+++ b/packages/client/ui-plan/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-plan/README.md
-README.md: de43ce66d17498d31e05f8c64092ea0843103054
-README.zh.md: b4d2f4fd1a6d45f814d4a20195434f34d207e9c8
+README.md: 1d22c057b439ff337bf9daadcdba96dd4cca4540
+README.zh.md: 183b8ef7776b60c1f0afa630e04627a474d40391
diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md
index de43ce66d1..1d22c057b4 100644
--- a/packages/client/ui-plan/README.md
+++ b/packages/client/ui-plan/README.md
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 Plan-mode status chip, a pure browser surface plugin. The browser half occupies the conversation-declared `conversation.input.plan` single seat (to the right of the access-mode control); the node half is an empty apply (the roster row). Plan behavior itself — the `/plan` command, the boundary-or-idle-committed `plan/mode` state, the `plan` projection unit, and the policy section — is owned by [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md), composed independently on the host roster.
 
-Plan mode is entered through the `/plan` command only; there is no UI control that turns it on. While the host-computed `plan` projection's effective target is plan mode (`pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way), the seat renders a read-only "Plan" chip whose hover × executes `/plan off` through `command.execute`; otherwise the seat stays empty — a host without plan-mode (or a Draft with no session) shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to "describe your task to generate plan" (rendered by the composer from the same projection; owner-supplied placeholders win).
+Plan mode is entered through the `/plan` command only; there is no UI control that turns it on. While the host-computed `plan` projection's effective target is plan mode (`pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way), the seat renders a read-only "Plan" chip whose hover × executes `/plan off` through `command.execute`; otherwise the seat stays empty — a host without plan-mode (or a Draft with no session) shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to the plan-task hint — "describe your task to generate plan", localized through ui-conversation's `command.hint` locale namespace and shared verbatim with the claimed `/plan` command hint (rendered by the composer from the same projection; owner-supplied placeholders win).
 
 The chip carries the accessible description "Plan mode on, press to turn off". Admission failures (`matched: false`, business errors, transport faults) surface as an inline error and the chip stays until the projection confirms the exit.
 
diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md
index b4d2f4fd1a..183b8ef777 100644
--- a/packages/client/ui-plan/README.zh.md
+++ b/packages/client/ui-plan/README.zh.md
@@ -4,7 +4,7 @@
 
 Plan mode 状态徽章,纯浏览器 surface 插件。浏览器侧占据会话声明的 `conversation.input.plan` 单座(位于 access 模式控件右侧);node 侧是空 apply(roster 行)。plan 行为本身——`/plan` 命令、边界或空闲即时提交的 `plan/mode` 状态、`plan` 投影单元与 policy 段——归 [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md) 所有,由 host roster 独立组合。
 
-plan mode 只经 `/plan` 命令进入;UI 上没有打开它的控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染一个只读 "Plan" chip,hover 出现的 × 经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host(或尚无会话的 Draft)不显示任何内容。plan mode 为有效目标期间,composer 文本框的 placeholder 切换为 "describe your task to generate plan"(由 composer 从同一投影渲染;owner 提供的 placeholder 优先)。
+plan mode 只经 `/plan` 命令进入;UI 上没有打开它的控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染一个只读 "Plan" chip,hover 出现的 × 经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host(或尚无会话的 Draft)不显示任何内容。plan mode 为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染;owner 提供的 placeholder 优先)。
 
 chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,chip 保持显示直至投影确认退出。
 
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index 6162494def..b75882b790 100644
--- a/packages/client/ui-primitives/README.i18n.yaml
+++ b/packages/client/ui-primitives/README.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
-README.md: 58e450451ab64f69762817dfb277b8a888e2177f
-README.zh.md: 6824f3efe4981adf9549941afa7e2f5db2ac005d
+#   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
+README.md: 0d22412c9e99184b009f585aca446bf9429192ad
+README.zh.md: a845fedb565ae91ccd8333e64e87184b70664d9f
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index 58e450451a..0d22412c9e 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8.
+Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock), plus the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency). Contract: api-contracts v3 §8.
 
 ## Markdown rendering
 
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
index 6824f3efe4..a845fedb56 100644
--- a/packages/client/ui-primitives/README.zh.md
+++ b/packages/client/ui-primitives/README.zh.md
@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input,以及 markdown 家族(MessageText/MarkdownText/JsonBlock)。契约:api-contracts v3 §8。
+纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock),以及 `useAnchoredMaxHeight` hook——把底部锚定的浮层高度收敛到锚点上方的视口空间(在 resize、scroll 与调用方提供的依赖变化时重新测量)。契约:api-contracts v3 §8。
 
 ## Markdown 渲染
 
diff --git a/packages/client/ui-slash/README.i18n.yaml b/packages/client/ui-slash/README.i18n.yaml
index 1053e205b0..97a136e2c3 100644
--- a/packages/client/ui-slash/README.i18n.yaml
+++ b/packages/client/ui-slash/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-slash/README.md
-README.md: 4e363c2682bf91862ec40f3f2174831451fb9b0d
-README.zh.md: 76d39673cb853d1889ee84cb9f3595708eae2db3
+README.md: 29f1a71ce20f898ffe2ab3a1c6f3d73a7aecfe38
+README.zh.md: 804413c2f595ca5dd6b39b4c6664f58dabc842c3
diff --git a/packages/client/ui-slash/README.md b/packages/client/ui-slash/README.md
index 4e363c2682..29f1a71ce2 100644
--- a/packages/client/ui-slash/README.md
+++ b/packages/client/ui-slash/README.md
@@ -6,7 +6,7 @@ Input trigger pipeline plugin: `/` and `@` detection under the caret (word-bound
 
 Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration.
 
-MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`.
+MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. Groups sort by the optional `SlashSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `slash.menu` locale namespace (an unknown source shows its raw name); the list height clamps to the space above the composer, and a pointer down outside both the menu and the surrounding composer card dismisses it. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`.
 
 The `/client` export surface is the plugin body (`apply`/`inject`), `SlashService`, `MenuViewInjected`, and the contract types. MenuView itself is internal — the slot registration closes over it.
 
@@ -23,4 +23,3 @@ None; this package neither assembles nor sends a provider request.
 - **Global source layer only** — session-scope source registration (per-session shadowing, ScopedLayers-alike) is designed but not enabled; the ledger tracks the trigger condition (a real per-session source need).
 - **`SlashCandidate.icon` renders as text** — MenuView drops the string into the icon slot verbatim; wiring to the design-system icon enum (iconFile five-variant family) lands when that enum ships.
 - **Overlay SlotMap merge home is split from slot ownership** — the `conversation.input.overlay` merge lives here (sole copy) while the slot's owner semantics (anchor, children declaration, lifecycle) stay with ui-conversation; the dependency direction (ui-conversation → ui-slash) forces the split, so a future dependency reshuffle should revisit it.
-- **Menu group order is registration order** — no explicit ordering seam across sources; acceptable while the roster is command/skill/subagent, revisit if business sources join.
diff --git a/packages/client/ui-slash/README.zh.md b/packages/client/ui-slash/README.zh.md
index 76d39673cb..804413c2f5 100644
--- a/packages/client/ui-slash/README.zh.md
+++ b/packages/client/ui-slash/README.zh.md
@@ -6,7 +6,7 @@
 
 分层:`src/core/`(T2)是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。
 
-MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。该 slot 由 ui-conversation 的编辑器配置项拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-slash)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。
+MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。分组按可选的 `SlashSource.order` 排序(越小越靠前,默认 0,同值保持注册序),组标题行经 `slash.menu` locale 命名空间本地化(未知 source 显示其原名);列表高度收敛到 composer 上方的可用空间,指针落在菜单与所在 composer 卡片之外即关闭菜单。该 slot 由 ui-conversation 的编辑器配置项拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-slash)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。
 
 `/client` 导出表层是插件主体(`apply`/`inject`)、`SlashService`、`MenuViewInjected` 与契约类型。MenuView 本身是内部实现——slot 注册以闭包持有它。
 
@@ -23,4 +23,3 @@ MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类
 - **只有全局 source 层**:会话 scope 的 source 注册(逐会话遮蔽、类 ScopedLayers 机制)已有设计但未启用;台账记录着触发条件(出现真实的逐会话 source 需求)。
 - **`SlashCandidate.icon` 以文本渲染**:MenuView 把该字符串原样放进图标位;接到设计系统图标枚举(iconFile 五变体家族)的接线等该枚举交付后落地。
 - **overlay 的 SlotMap 合并归属与 slot 所有权分离**:`conversation.input.overlay` 的合并放在本包(唯一副本),而该 slot 的 owner 语义(锚点、children 声明、生命周期)留在 ui-conversation;依赖方向(ui-conversation → ui-slash)迫使这一拆分,未来依赖关系调整时应重新审视。
-- **菜单组顺序即注册顺序**:source 之间没有显式排序 seam;roster 还是 command/skill/subagent 时可以接受,业务 source 加入后需重新审视。

From e8c265a337f3527a8fa78b70d37c3006e475a528 Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Wed, 29 Jul 2026 20:46:30 +0800
Subject: [PATCH 22/32] docs: regenerate cordis catalog and doc graphs for
 shifted source lines

---
 docs/cordis-catalog/events.md   | 8 ++++----
 docs/event-producer-consumer.md | 8 ++++----
 2 files changed, 8 insertions(+), 8 deletions(-)

diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md
index 967241fbf6..3a60f441e0 100644
--- a/docs/cordis-catalog/events.md
+++ b/docs/cordis-catalog/events.md
@@ -660,7 +660,7 @@ Applies one command claim to the scoped Input. Dispatched with the session's sco
 'slash/input-begin-command'(request: BeginCommandRequest): true | undefined
 ```
 
-Source: [`packages/client/ui-slash/src/types.ts:230`](../../packages/client/ui-slash/src/types.ts)
+Source: [`packages/client/ui-slash/src/types.ts:232`](../../packages/client/ui-slash/src/types.ts)
 
 ### `slash/input-consume-token` — bail
 
@@ -676,7 +676,7 @@ Consumes one command token after business success (popup settle / menu-pick exec
 'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined
 ```
 
-Source: [`packages/client/ui-slash/src/types.ts:244`](../../packages/client/ui-slash/src/types.ts)
+Source: [`packages/client/ui-slash/src/types.ts:246`](../../packages/client/ui-slash/src/types.ts)
 
 ### `slash/input-insert-reference` — bail
 
@@ -692,7 +692,7 @@ Inserts one reference into the scoped Input (same carrier routing and applied-tr
 'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined
 ```
 
-Source: [`packages/client/ui-slash/src/types.ts:237`](../../packages/client/ui-slash/src/types.ts)
+Source: [`packages/client/ui-slash/src/types.ts:239`](../../packages/client/ui-slash/src/types.ts)
 
 ### `slash/input-insert-text` — bail
 
@@ -709,7 +709,7 @@ Replaces the trigger token span with literal text — the plain-text reference p
 'slash/input-insert-text'(request: InsertTextRequest): true | undefined
 ```
 
-Source: [`packages/client/ui-slash/src/types.ts:252`](../../packages/client/ui-slash/src/types.ts)
+Source: [`packages/client/ui-slash/src/types.ts:254`](../../packages/client/ui-slash/src/types.ts)
 
 ## `subagent/*`
 
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 71238305e5..41a5420d7c 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -35,10 +35,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
 | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
-| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
-| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
-| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:237`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
-| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:252`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
+| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:232`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
+| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:246`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
+| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:239`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
+| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:254`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
 | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
 | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:120`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |

From 2a12057345cc39e5092a389dd733fe2a54eb6d73 Mon Sep 17 00:00:00 2001
From: 07akioni <07akioni2@gmail.com>
Date: Wed, 29 Jul 2026 20:56:56 +0800
Subject: [PATCH 23/32] fix: ci

---
 ...cky-composer-conversation-scroll.i18n.yaml |  4 +-
 ...-29-sticky-composer-conversation-scroll.md |  6 +-
 ...-sticky-composer-conversation-scroll.zh.md |  6 +-
 .../src/client/contract/slots.ts              | 12 +--
 .../skeleton/ConversationRoot.module.css      | 11 ++-
 .../src/client/skeleton/ConversationRoot.tsx  | 17 ++--
 .../client/skeleton/ConversationSession.tsx   | 81 +++++++++++--------
 .../src/client/skeleton/EmptyHero.tsx         |  6 +-
 .../ui-conversation/tests/skeleton.spec.tsx   | 20 +++--
 9 files changed, 96 insertions(+), 67 deletions(-)

diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml
index 39556c379a..718e1dde26 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml
+++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.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/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md
-2026-07-29-sticky-composer-conversation-scroll.md: f245e7ca0404df4f644504ac9e1b101e659b6b68
-2026-07-29-sticky-composer-conversation-scroll.zh.md: 7e4d5fb523f33f450c343e12e486e1d33784ee6c
+2026-07-29-sticky-composer-conversation-scroll.md: 803033613c715a1b5c8d299ab0df985e6a02fa6b
+2026-07-29-sticky-composer-conversation-scroll.zh.md: 0c3e83ce13f70e0e425e89f20ca133a55d9a663d
diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md
index f245e7ca04..803033613c 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md
+++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md
@@ -10,9 +10,9 @@ The active conversation column split scrolling: the chat (and trajectory) view o
 
 ## Decision
 
-Active phase keeps the session header as `flex: none` column chrome above the scrollport. `ConversationRoot` supplies a `wrapActiveBody` owner callback that wraps the view ring in a `data-conversation-scroll` body and places the composer stack inside that body with `position: sticky; bottom: 0`. Hero and settling keep the composer as a Root child (centered hero card). ChatView and Trajectory/Waterfall keep a local scroller only when mounted outside that host (unit tests); under the host they set `overflow: visible` and resolve bottom-follow / prepend anchoring through `closest('[data-conversation-scroll]')`.
+While a session exists, `ConversationRoot` always supplies a `wrapActiveBody` owner callback that wraps the view ring in a `data-conversation-scroll` body and places the composer stack inside that body. Active CSS sticks the composer with `position: sticky; bottom: 0`; hero CSS centers the same stack inside the scroll body. `ConversationSession` keeps a chrome-hidden header + body shell while blank so that tree seat does not change on the first send. The session header remains `flex: none` column chrome above the scrollport when visible. ChatView and Trajectory/Waterfall keep a local scroller only when mounted outside that host (unit tests); under the host they set `overflow: visible` and resolve bottom-follow / prepend anchoring through `closest('[data-conversation-scroll]')`.
 
-Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, listens for `wheel` with `{ passive: false }`, calls `preventDefault`, and applies `deltaY` to the host — hero mounts have no host and keep native textarea wheel behavior. Moving the composer into the Session scrollport on the hero → active flip may remount the textarea; the InputHub draft is the durable carrier across that flip.
+Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, listens for `wheel` with `{ passive: false }`, calls `preventDefault`, and applies `deltaY` to the host.
 
 ## Alternatives considered
 
@@ -26,4 +26,4 @@ Session stats live on `'conversation.composer.dock'` (above `'conversation.input
 
 ## Consequences
 
-Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Hero → active asserts draft survival through the InputHub, not textarea DOM identity.
+Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Hero → active keeps the same textarea DOM node (assembled slash-flow snapshot) and the InputHub draft.
diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md
index 7e4d5fb523..0c3e83ce13 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md
+++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md
@@ -10,9 +10,9 @@ Status: implemented
 
 ## Decision
 
-活跃阶段会话标题栏保持为滚动容器之上的 `flex: none` 列 chrome。`ConversationRoot` 提供 `wrapActiveBody` owner 回调,将视图环包进 `data-conversation-scroll` 主体,并把编辑器栈以 `position: sticky; bottom: 0` 放进该主体。Hero/settling 仍把编辑器作为 Root 子节点(居中 hero 卡片)。ChatView 与 Trajectory/Waterfall 仅在宿主之外挂载时(单元测试)保留本地 scroller;位于宿主下时设为 `overflow: visible`,并通过 `closest('[data-conversation-scroll]')` 解析贴底跟随与前置锚定。
+只要存在会话,`ConversationRoot` 就会始终提供 `wrapActiveBody` owner 回调,将视图环包进 `data-conversation-scroll` 主体,并把编辑器栈放进该主体。活跃阶段 CSS 以 `position: sticky; bottom: 0` 钉住编辑器;hero CSS 在同一滚动主体内居中同一栈。`ConversationSession` 在 blank 时保留隐藏 chrome 的 header + body 壳,使首次发送时树座位不变。可见时会话标题栏仍是滚动容器之上的 `flex: none` 列 chrome。ChatView 与 Trajectory/Waterfall 仅在宿主之外挂载时(单元测试)保留本地 scroller;位于宿主下时设为 `overflow: visible`,并通过 `closest('[data-conversation-scroll]')` 解析贴底跟随与前置锚定。
 
-会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 监听 `wheel`,调用 `preventDefault`,并将 `deltaY` 施加到宿主——hero 挂载没有宿主,保留 textarea 原生滚轮行为。hero → active 翻转时编辑器进入 Session 滚动容器可能重挂载 textarea;跨该翻转的耐久载体是 InputHub 草稿。
+会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 监听 `wheel`,调用 `preventDefault`,并将 `deltaY` 施加到宿主。
 
 ## Alternatives considered
 
@@ -26,4 +26,4 @@ Status: implemented
 
 ## Consequences
 
-在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。hero → active 断言经 InputHub 的草稿存续,而非 textarea DOM 身份。
+在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。hero → active 保持同一 textarea DOM 节点(assembled slash-flow 快照)以及 InputHub 草稿。
diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts
index 5781d2dc15..372effcea6 100644
--- a/packages/client/ui-conversation/src/client/contract/slots.ts
+++ b/packages/client/ui-conversation/src/client/contract/slots.ts
@@ -118,11 +118,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 /** Owner share of the strict session content seat. */
 export interface ConversationSessionOwnerProps {
   /**
-   * Active phase only: wrap the view ring in the transcript scrollport that
-   * also hosts the sticky composer. The header stays outside that wrapper as
-   * ordinary column chrome (`flex: none`), while the composer sticks to the
-   * bottom of the same scrollport so wheel over the footer scrolls the flow.
-   * @param view - the session view-ring content.
+   * Wrap the view ring in the transcript scrollport that also hosts the
+   * sticky composer. Supplied for every real session (hero/settling/active)
+   * so the composer keeps one tree seat across the blank → active flip; the
+   * header stays outside that wrapper as ordinary column chrome
+   * (`flex: none`), while active CSS sticks the composer to the bottom of
+   * the same scrollport so wheel over the footer scrolls the flow.
+   * @param view - the session view-ring content (null while blank chrome is hidden).
    * @returns the scrollport containing `view` and the sticky composer.
    */
   wrapActiveBody?: (view: ReactNode) => ReactNode
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css
index 18278e34a0..4984de4048 100644
--- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css
+++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css
@@ -17,6 +17,12 @@
   border-bottom: 1px solid var(--dsw-alias-border-l2);
 }
 
+/* Blank hero/settling: keep the header node mounted (stable Session tree for
+   the wrapActiveBody composer) without taking column space. */
+.headerHidden {
+  display: none;
+}
+
 .crumbRow {
   display: flex;
   align-items: center;
@@ -199,8 +205,11 @@
   padding-left: 8px;
 }
 
-.root[data-phase='hero'] {
+/* Hero: the composer sits inside the session scroll body; center there so
+   the tree seat matches active (sticky footer) without a Root remount. */
+.root[data-phase='hero'] .scrollBody {
   justify-content: center;
+  overflow-y: auto;
 }
 
 /* Settling (session replaying, hero/docked unknown): keep the composer
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
index 7f62598a49..41e88bf7a0 100644
--- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
@@ -128,9 +128,10 @@ export function ConversationRoot({
     { fallback: composerBar, overlay: true },
   )
 
-  // Active: header is column chrome above the scrollport; the sticky composer
-  // lives inside the same scrollport as the transcript (wheel over the footer
-  // scrolls the flow). Hero/settling keep the composer as a Root child.
+  // Header stays column chrome above this scrollport; the sticky composer
+  // lives inside it with the transcript. Always wrap while a session exists
+  // (hero/settling/active) so the composer keeps one tree seat across the
+  // blank → active flip — relocating it only in active remounted the textarea.
   const wrapActiveBody = (view: ReactNode): ReactNode => (
     
{view} @@ -141,14 +142,14 @@ export function ConversationRoot({ return (
{/* Mounted for every real session, hero included: ConversationSession - renders no chrome while blank but owns the draft-persistence mirror - bind — unmounting it in the hero would lose pre-first-send text on - a refresh or scope rebuild. */} + keeps a chrome-hidden shell while blank and owns the draft- + persistence mirror bind — unmounting it in the hero would lose + pre-first-send text on a refresh or scope rebuild. */} {sessionId !== undefined && renderSlot( 'conversation.session', - phase === 'active' ? { wrapActiveBody } : {}, + { wrapActiveBody }, )} - {phase !== 'active' ? composer : null} + {sessionId === undefined ? composer : null}
) } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index f36874cfc2..33b24f5245 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -44,9 +44,13 @@ export function ConversationSession({ // the machine mirror, not this seed effect. }, [inputActions]) - if (blank && composerPhase === 'blank') return null + // Blank hero/settling: keep the same header + body tree shape so a + // wrapActiveBody-hosted composer keeps its DOM identity across the first + // send (hero → active). Chrome is hidden; the draft-persistence mirror + // still runs because this component stays mounted. + const hideChrome = blank && composerPhase === 'blank' - const view: ReactNode = ( + const view: ReactNode = hideChrome ? null : (
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
@@ -54,43 +58,50 @@ export function ConversationSession({ return ( <> -
-
- -
- {tabs.length > 1 && ( -
- {tabs.map(viewTab => ( - - ))} -
+ ))} +
+ )} + )} {wrapActiveBody !== undefined ? wrapActiveBody(view) : view} diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index 7fbed0ee25..3b2d60417a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -115,9 +115,9 @@ export function HeroShell({ children }: HeroShellProps) { Let's start building
- {/* The resident composer (rendered by ConversationRoot at its stable - tree position; the workspace row rides its accessory hole) is - CSS-positioned into this gap during the hero phase — see + {/* The resident composer (ConversationRoot wrapActiveBody seat; the + workspace row rides the stack above the card) is CSS-centered in + the session scroll body during hero — see ConversationRoot.module.css [data-phase='hero']. */}
diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 8e9377e73f..ac81848fb9 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -187,14 +187,19 @@ describe('ConversationRoot resident composer', () => { { ...workspace('second'), title: 'Selected Folder' }, ], ) - // Hero chrome present, view ring absent; scroll host is active-phase only. - expect(b.view.container.querySelector('[data-conversation-scroll]')).toBeNull() + // Hero chrome present, view ring absent; scroll host already wraps the + // resident composer so the blank → active flip does not remount it. + const host = b.view.container.querySelector('[data-conversation-scroll]') + const header = b.view.container.querySelector('header') + expect(host).not.toBeNull() + expect(header?.getAttribute('aria-hidden')).toBe('true') expect(b.view.getByText("Let's start building")).toBeTruthy() expect(b.view.queryByTestId('view-chat')).toBeNull() // The same machine-backed textarea is live in the hero, and the - // persistence mirror stays bound (ConversationSession mounts chrome-less + // persistence mirror stays bound (ConversationSession mounts chrome-hidden // for blank sessions): hero typing reaches the chat store. const box = b.view.getByRole('textbox') + expect(host?.contains(box)).toBe(true) fireEvent.change(box, { target: { value: 'draft in hero' } }) expect(b.chat.store.getSnapshot().draft).toBe('draft in hero') // Picker: open through the chip; a pick switches to the other @@ -207,16 +212,17 @@ describe('ConversationRoot resident composer', () => { expect(b.view.getByText('Selected Folder')).toBeTruthy() }) - it('machine draft survives the hero → active flip into the sticky scrollport composer', () => { + it('same textarea DOM node survives the hero → active flip into the sticky scrollport', () => { const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true })) const before = b.view.getByRole('textbox') fireEvent.change(before, { target: { value: 'kept across flip' } }) - // First message landed: content exists, phase leaves blank. The active - // composer lives inside the Session scrollport (sticky footer), so the - // textarea may remount; the InputHub draft is the durable carrier. + // First message landed: content exists, phase leaves blank. Composer + // already sat in the Session scrollport during hero, so the textarea + // node and InputHub draft both survive. b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false })) b.rerender() const after = b.view.getByRole('textbox') as HTMLTextAreaElement + expect(after).toBe(before) expect(after.value).toBe('kept across flip') expect(b.chat.store.getSnapshot().draft).toBe('kept across flip') expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true) From 08d097077ea5eda53de7a8766e35c04572ffbb3c Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 29 Jul 2026 21:03:40 +0800 Subject: [PATCH 24/32] fix: cr --- ...cky-composer-conversation-scroll.i18n.yaml | 4 +-- ...-29-sticky-composer-conversation-scroll.md | 4 +-- ...-sticky-composer-conversation-scroll.zh.md | 4 +-- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/contract/slots.ts | 13 +++---- .../skeleton/ConversationRoot.module.css | 18 ++++++---- .../src/client/skeleton/ConversationRoot.tsx | 20 ++++++++--- .../src/client/skeleton/InputBar.tsx | 13 ++++--- .../ui-conversation/tests/input-bar.spec.tsx | 36 ++++++++++++++++++- .../ui-conversation/tests/skeleton.spec.tsx | 33 ++++++++++++++--- 12 files changed, 117 insertions(+), 36 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml index 718e1dde26..5689b73bf8 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.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/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md -2026-07-29-sticky-composer-conversation-scroll.md: 803033613c715a1b5c8d299ab0df985e6a02fa6b -2026-07-29-sticky-composer-conversation-scroll.zh.md: 0c3e83ce13f70e0e425e89f20ca133a55d9a663d +2026-07-29-sticky-composer-conversation-scroll.md: 7ceae95dafffdb756ef49bb5612cd4e711eb59ca +2026-07-29-sticky-composer-conversation-scroll.zh.md: d925d82f94635b5fe67b0be119c041d003def393 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md index 803033613c..7ceae95daf 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md @@ -10,9 +10,9 @@ The active conversation column split scrolling: the chat (and trajectory) view o ## Decision -While a session exists, `ConversationRoot` always supplies a `wrapActiveBody` owner callback that wraps the view ring in a `data-conversation-scroll` body and places the composer stack inside that body. Active CSS sticks the composer with `position: sticky; bottom: 0`; hero CSS centers the same stack inside the scroll body. `ConversationSession` keeps a chrome-hidden header + body shell while blank so that tree seat does not change on the first send. The session header remains `flex: none` column chrome above the scrollport when visible. ChatView and Trajectory/Waterfall keep a local scroller only when mounted outside that host (unit tests); under the host they set `overflow: visible` and resolve bottom-follow / prepend anchoring through `closest('[data-conversation-scroll]')`. +While a session exists, `ConversationRoot` always supplies a `wrapActiveBody` owner callback that wraps the view ring in a `data-conversation-scroll` body and places a `data-composer-seat` around the whole `'conversation.composer'` chain output (fallback + elected overlay siblings from `overlay: true`). Active CSS sticks that seat with `position: sticky; bottom: 0` so Question/Approval takeovers stay visible when the user is not pinned to the floor; hero CSS centers the fallback stack inside the scroll body. `ConversationSession` keeps a chrome-hidden header + body shell while blank so that tree seat does not change on the first send. The session header remains `flex: none` column chrome above the scrollport when visible. ChatView and Trajectory/Waterfall keep a local scroller only when mounted outside that host (unit tests); under the host they set `overflow: visible` and resolve bottom-follow / prepend anchoring through `closest('[data-conversation-scroll]')`. -Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, listens for `wheel` with `{ passive: false }`, calls `preventDefault`, and applies `deltaY` to the host. +Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, chains `wheel` with `{ passive: false }`: while the capped textarea can still scroll in that direction it keeps the native gesture; only at its own edge does it `preventDefault` and apply `deltaY` to the host. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md index 0c3e83ce13..d925d82f94 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md @@ -10,9 +10,9 @@ Status: implemented ## Decision -只要存在会话,`ConversationRoot` 就会始终提供 `wrapActiveBody` owner 回调,将视图环包进 `data-conversation-scroll` 主体,并把编辑器栈放进该主体。活跃阶段 CSS 以 `position: sticky; bottom: 0` 钉住编辑器;hero CSS 在同一滚动主体内居中同一栈。`ConversationSession` 在 blank 时保留隐藏 chrome 的 header + body 壳,使首次发送时树座位不变。可见时会话标题栏仍是滚动容器之上的 `flex: none` 列 chrome。ChatView 与 Trajectory/Waterfall 仅在宿主之外挂载时(单元测试)保留本地 scroller;位于宿主下时设为 `overflow: visible`,并通过 `closest('[data-conversation-scroll]')` 解析贴底跟随与前置锚定。 +只要存在会话,`ConversationRoot` 就会始终提供 `wrapActiveBody` owner 回调,将视图环包进 `data-conversation-scroll` 主体,并用 `data-composer-seat` 包住整条 `'conversation.composer'` chain 输出(`overlay: true` 下的 fallback 与选举出的 overlay 兄弟节点)。活跃阶段 CSS 以 `position: sticky; bottom: 0` 钉住该 seat,使用户未贴底时 Question/Approval 接管仍可见;hero CSS 在滚动主体内居中 fallback 栈。`ConversationSession` 在 blank 时保留隐藏 chrome 的 header + body 壳,使首次发送时树座位不变。可见时会话标题栏仍是滚动容器之上的 `flex: none` 列 chrome。ChatView 与 Trajectory/Waterfall 仅在宿主之外挂载时(单元测试)保留本地 scroller;位于宿主下时设为 `overflow: visible`,并通过 `closest('[data-conversation-scroll]')` 解析贴底跟随与前置锚定。 -会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 监听 `wheel`,调用 `preventDefault`,并将 `deltaY` 施加到宿主。 +会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 链式处理 `wheel`:在限高 textarea 仍能沿该方向滚动时保留原生手势;仅在自身边缘才 `preventDefault` 并将 `deltaY` 施加到宿主。 ## Alternatives considered diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index fa6b3c6b30..920ba52b8d 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 1d4dad5c342b9d83275eb2c1ef5a1b4b667def6b -README.zh.md: f881d4dbeb12d7e58ccc5be5caeaed0ad1b89882 +README.md: 4d4bdcf9dac4e49de5a1f7f977c5701b3182e83e +README.zh.md: 0cc98158e2941a59ee9e218cbd785343d515482a diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 1d4dad5c34..4d4bdcf9da 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). -The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea forwards to that host so nested textarea scrolling never traps the gesture. +The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f881d4dbeb..0cc98158e2 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -4,7 +4,7 @@ 会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会转交给该宿主,避免嵌套 textarea 滚动截获手势。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 372effcea6..41791fc8eb 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -119,13 +119,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { export interface ConversationSessionOwnerProps { /** * Wrap the view ring in the transcript scrollport that also hosts the - * sticky composer. Supplied for every real session (hero/settling/active) - * so the composer keeps one tree seat across the blank → active flip; the - * header stays outside that wrapper as ordinary column chrome - * (`flex: none`), while active CSS sticks the composer to the bottom of - * the same scrollport so wheel over the footer scrolls the flow. + * sticky composer seat (whole `'conversation.composer'` chain output). + * Supplied for every real session (hero/settling/active) so the composer + * keeps one tree seat across the blank → active flip; the header stays + * outside that wrapper as ordinary column chrome (`flex: none`), while + * active CSS sticks the seat to the bottom of the same scrollport so wheel + * over the footer scrolls the flow. * @param view - the session view-ring content (null while blank chrome is hidden). - * @returns the scrollport containing `view` and the sticky composer. + * @returns the scrollport containing `view` and the sticky composer seat. */ wrapActiveBody?: (view: ReactNode) => ReactNode } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 4984de4048..272bbae873 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -133,9 +133,16 @@ flex-direction: column; } +/* Common seat for the composer chain (fallback + elected overlay siblings). */ +.composerSeat { + display: flex; + flex: none; + flex-direction: column; +} + /* Active phase: header is ordinary column chrome above the scrollport (not - sticky). The scroll body holds the transcript and the sticky composer so - wheel over the footer moves the flow. */ + sticky). The scroll body holds the transcript and the sticky composer seat + so wheel over the footer moves the flow. */ .root[data-phase='active'] { overflow: hidden; } @@ -157,13 +164,12 @@ min-height: auto; } -.root[data-phase='active'] .composerStack { +.root[data-phase='active'] .composerSeat { position: sticky; bottom: 0; /* Above markdown CodeBlock sticky banners (z-index 6) so the footer never paints under a sticking code header while scrolling. */ z-index: 7; - flex: none; background: var(--dsw-alias-bg-base); } @@ -212,8 +218,8 @@ overflow-y: auto; } -/* Settling (session replaying, hero/docked unknown): keep the composer +/* Settling (session replaying, hero/docked unknown): keep the composer seat mounted but invisible so no wrong layout flashes before the phase lands. */ -.root[data-phase='settling'] .composerStack { +.root[data-phase='settling'] .composerSeat { visibility: hidden; } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 41e88bf7a0..3afae25de9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -128,14 +128,24 @@ export function ConversationRoot({ { fallback: composerBar, overlay: true }, ) + // Sticky wraps the whole chain output (fallback + elected overlay), not + // only `.composerStack`: overlay:true renders those as siblings, and sticky + // on the fallback alone would leave Question/Approval panels at the content + // end off-screen when the user is not pinned to the floor. + const composerSeat = ( +
+ {composer} +
+ ) + // Header stays column chrome above this scrollport; the sticky composer - // lives inside it with the transcript. Always wrap while a session exists - // (hero/settling/active) so the composer keeps one tree seat across the - // blank → active flip — relocating it only in active remounted the textarea. + // seat lives inside it with the transcript. Always wrap while a session + // exists (hero/settling/active) so the composer keeps one tree seat across + // the blank → active flip — relocating it only in active remounted the textarea. const wrapActiveBody = (view: ReactNode): ReactNode => (
{view} - {composer} + {composerSeat}
) @@ -149,7 +159,7 @@ export function ConversationRoot({ 'conversation.session', { wrapActiveBody }, )} - {sessionId === undefined ? composer : null} + {sessionId === undefined ? composerSeat : null}
) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 9586a04cdc..23a5c701f4 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -75,15 +75,20 @@ export function InputBar({ if (!locked) inputRef.current?.focus() }, [locked]) - // Active conversation scrollport: never let the textarea become a nested - // wheel target; forward delta to `[data-conversation-scroll]` instead. - // Hero mounts have no host, so the textarea keeps native wheel scrolling. + // Active conversation scrollport: chain the wheel. While the textarea (capped + // at 14 lines with overflow-y:auto) can still move in this direction, keep + // the native scroll; only at its own edge forward delta to the host so a + // short draft never traps the gesture and a long draft stays scrollable. + // Hero mounts have no host and keep native wheel scrolling. useEffect(() => { const el = inputRef.current if (el === null) return const onWheel = (e: WheelEvent): void => { const host = el.closest('[data-conversation-scroll]') - if (!(host instanceof HTMLElement)) return + if (!(host instanceof HTMLElement) || e.deltaY === 0) return + const atTop = el.scrollTop <= 0 + const atEnd = el.scrollTop + el.clientHeight >= el.scrollHeight - 1 + if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atEnd)) return e.preventDefault() host.scrollTop += e.deltaY } diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 08d99ed260..1ec9a6eeeb 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -227,7 +227,7 @@ describe('running and lock semantics (queue cut 1)', () => { expect((textarea).value).toBe('typed') }) - it('wheel over the textarea scrolls the conversation host, not a nested textarea port', () => { + it('wheel over a non-overflowing textarea forwards to the conversation host', () => { const host = document.createElement('div') host.setAttribute('data-conversation-scroll', '') Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true }) @@ -243,6 +243,40 @@ describe('running and lock semantics (queue cut 1)', () => { } }) + it('wheel chains: long drafts scroll inside the textarea until each edge, then the host', () => { + const host = document.createElement('div') + host.setAttribute('data-conversation-scroll', '') + Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true }) + const { view, textarea } = bench() + host.appendChild(view.container) + document.body.appendChild(host) + Object.defineProperty(textarea, 'clientHeight', { value: 100, configurable: true }) + Object.defineProperty(textarea, 'scrollHeight', { value: 400, configurable: true }) + let scrollTop = 150 + Object.defineProperty(textarea, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { scrollTop = value }, + }) + try { + // Mid-draft: both directions stay local — host must not move. + expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(true) + expect(fireEvent.wheel(textarea, { deltaY: -30 })).toBe(true) + expect(host.scrollTop).toBe(40) + // At the bottom edge, further down-scroll forwards to the host. + scrollTop = 300 + expect(fireEvent.wheel(textarea, { deltaY: 30 })).toBe(false) + expect(host.scrollTop).toBe(70) + // At the top edge, further up-scroll forwards to the host. + scrollTop = 0 + host.scrollTop = 70 + expect(fireEvent.wheel(textarea, { deltaY: -20 })).toBe(false) + expect(host.scrollTop).toBe(50) + } finally { + host.remove() + } + }) + it('disabled state shows the unavailable placeholder; custom placeholder wins', () => { const { textarea } = bench({ disabled: true }) expect(textarea.placeholder).toBe('Session unavailable') diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index ac81848fb9..d8984e2f2b 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -59,6 +59,8 @@ function mount( snapshot: ConversationSnapshot, workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }], retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}), + /** When true, mimic overlay:true chain siblings (hidden fallback + takeover). */ + overlayTakeover = false, ) { const root = sid('root') const sessions = createSnapshotStore({ @@ -132,7 +134,18 @@ function mount( } return
}) as ConversationRootProps['renderSlot'] - const renderSlotChain = ((_key, _owner, opts) => opts?.fallback ?? null) as ConversationRootProps['renderSlotChain'] + const renderSlotChain = ((_key, _owner, opts) => ( + overlayTakeover + ? ( + <> +
+ {opts?.fallback ?? null} +
+
TAKEOVER
+ + ) + : (opts?.fallback ?? null) + )) as ConversationRootProps['renderSlotChain'] const props: ConversationRootProps = { sessionId: SID, SessionProvider: ({ children }) => children(SID), @@ -167,16 +180,28 @@ describe('ConversationRoot resident composer', () => { expect(b.open).toHaveBeenCalledWith(sid('root')) }) - it('active phase: fixed header outside the scrollport; sticky composer inside it', () => { + it('active phase: fixed header outside the scrollport; sticky composer seat inside it', () => { const b = mount(conversationSnapshot()) const host = b.view.container.querySelector('[data-conversation-scroll]') + const seat = b.view.container.querySelector('[data-composer-seat]') const header = b.view.container.querySelector('header') const textarea = b.view.container.querySelector('textarea') expect(host).not.toBeNull() + expect(seat).not.toBeNull() expect(header).not.toBeNull() - // Header is column chrome above the scrollport; composer sticks inside it. + // Header is column chrome above the scrollport; the seat sticks inside it. expect(host?.contains(header)).toBe(false) - expect(host?.contains(textarea)).toBe(true) + expect(host?.contains(seat)).toBe(true) + expect(seat?.contains(textarea)).toBe(true) + }) + + it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => { + const b = mount(conversationSnapshot(), undefined, undefined, true) + const seat = b.view.container.querySelector('[data-composer-seat]') + const takeover = b.view.getByTestId('composer-takeover') + const fallback = b.view.container.querySelector('[data-chain-overlay-fallback="conversation.composer"]') + expect(seat?.contains(takeover)).toBe(true) + expect(seat?.contains(fallback)).toBe(true) }) it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => { From e9233123ebf195a65f352983fc8b2116b40d14ec Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:12:01 +0800 Subject: [PATCH 25/32] fix(skill): ignore unchanged missing-root probes --- packages/skill/skill-local/src/index.ts | 30 ++++++++++++++----- .../tests/skill-local-watcher.spec.ts | 25 ++++++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 4ac5f101cd..ed68e84bcb 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -421,7 +421,7 @@ class SkillWatchManager { private openAncestorWatcher(state: RootWatchState, mode: Extract): WatchHandle { const listener = (_current: Stats, _previous: Stats): void => { - this.handleWatchEvent(state, mode, 'change', mode.nextPath) + void this.handleAncestorWatchEvent(state, mode) } watchFile(mode.nextPath, { persistent: false, @@ -435,6 +435,25 @@ class SkillWatchManager { } } + private async handleAncestorWatchEvent( + state: RootWatchState, + mode: Extract, + ): Promise { + let current: RootWatchMode + try { + current = await resolveRootWatchMode(state.root.path) + } catch (error) { + /* v8 ignore start -- Non-absence stat failures need a platform permission or I/O fault. */ + if (!this.closing && state.owners.size > 0) this.handleWatcherError(state, error) + return + /* v8 ignore stop */ + } + if (this.closing || state.owners.size === 0 || sameWatchMode(mode, current)) return + this.queueInvalidation() + state.unhealthy = true + this.scheduleRewatch(state) + } + private async openRootWatcher(state: RootWatchState, mode: Extract): Promise { const watcher = chokidar.watch(mode.anchor, { persistent: false, @@ -481,13 +500,13 @@ class SkillWatchManager { private handleWatchEvent( state: RootWatchState, - mode: RootWatchMode, + mode: Extract, event: SkillWatchEvent, path: string, ): void { if (this.closing || !isRelevantWatchEvent(state.root, mode, event, resolve(path))) return this.queueInvalidation() - if (mode.kind === 'ancestor' || (resolve(path) === state.root.path && event === 'unlinkDir')) { + if (resolve(path) === state.root.path && event === 'unlinkDir') { state.unhealthy = true this.scheduleRewatch(state) } @@ -591,13 +610,10 @@ function sameWatchMode(left: RootWatchMode, right: RootWatchMode): boolean { function isRelevantWatchEvent( root: SkillRoot, - mode: RootWatchMode, + mode: Extract, event: SkillWatchEvent, path: string, ): boolean { - if (mode.kind === 'ancestor') { - return path === mode.nextPath - } const segments = containedSegments(root.path, path) if (segments === undefined) return false if (segments.length === 0) return event === 'addDir' || event === 'unlinkDir' diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts index 97ab8c9ee7..2a7a3e5f86 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -92,6 +92,31 @@ beforeEach(() => { }) describe('skill-local watcher failures', () => { + it('ignores missing-path probes until the observed path actually changes', async () => { + const home = await tempDir('skill-watch-missing-stable') + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchPollIntervalMs: 10, + }) + expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true }) + expect(watcherHarness.watchFiles).toHaveLength(2) + let invalidations = 0 + ctx.on('skills/change', () => { invalidations += 1 }) + + for (const control of watcherHarness.watchFiles) { + control.listener({} as Stats, {} as Stats) + } + await settle() + + expect(invalidations).toBe(0) + expect(watcherHarness.watchFiles).toHaveLength(2) + await fiber.dispose() + }) + it('marks a startup failure incomplete and retries discovery without caching it', async () => { const home = await tempDir('skill-watch-start-error') const root = join(home, '.dsh/skills') From 48cf50fc558a92bbc7a73eb7e39c2844b3b8ac63 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:17:04 +0800 Subject: [PATCH 26/32] fix(skill): cancel opening watchers on dispose --- packages/skill/skill-local/src/index.ts | 16 ++++++++++++++-- .../tests/skill-local-watcher.spec.ts | 7 +++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index ed68e84bcb..907c15a8ab 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -260,6 +260,7 @@ interface WatchHandle { class SkillWatchManager { private readonly roots = new Map() private readonly projects = new Map>() + private readonly lifecycle = new AbortController() private closing = false private invalidationQueued = false @@ -313,6 +314,7 @@ class SkillWatchManager { async dispose(): Promise { this.closing = true + this.lifecycle.abort(new Error('skill-local watcher disposed')) const states = [...this.roots.values()] this.roots.clear() this.projects.clear() @@ -348,6 +350,7 @@ class SkillWatchManager { } private ensureWatcher(state: RootWatchState): Promise { + /* v8 ignore next -- A scheduled rewatch can reach this guard only when teardown wins its await. */ if (this.closing || !this.config.enabled) return Promise.resolve() if (state.opening !== undefined) return state.opening const opening = this.ensureCurrentWatcher(state) @@ -395,8 +398,11 @@ class SkillWatchManager { state.watcher = watcher state.unhealthy = false } catch (error) { - state.unhealthy = true - this.ctx.logger.warn(`skill-local: failed to watch ${state.root.path}: ${errorMessage(error)}`) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- teardown can race awaited watcher startup + if (!this.closing) { + state.unhealthy = true + this.ctx.logger.warn(`skill-local: failed to watch ${state.root.path}: ${errorMessage(error)}`) + } throw error } } @@ -474,6 +480,9 @@ class SkillWatchManager { } let ready = false const readiness = Promise.withResolvers() + const signal = this.lifecycle.signal + const onAbort = (): void => { readiness.reject(signal.reason) } + signal.addEventListener('abort', onAbort, { once: true }) const onError = (error: unknown): void => { if (!ready) { readiness.reject(error) @@ -494,6 +503,8 @@ class SkillWatchManager { } catch (error) { await this.closeWatcher(handle) throw error + } finally { + signal.removeEventListener('abort', onAbort) } return handle } @@ -539,6 +550,7 @@ class SkillWatchManager { this.invalidationQueued = true queueMicrotask(() => { this.invalidationQueued = false + /* v8 ignore next -- Effect teardown can win this queued microtask before provider disposal emits. */ if (this.closing) return this.invalidate() }) diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts index 2a7a3e5f86..a1df287fbe 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -262,11 +262,10 @@ describe('skill-local watcher failures', () => { await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(1) }) const first = watcherHarness.watchers[0] if (first === undefined) throw new Error('expected an opening root watcher') - first.emitter.emit('unlinkDir', root) const disposal = provider.dispose() - first.emitter.emit('ready') - await Promise.all([discovery, disposal]) + await expect(discovery).rejects.toThrow('skill-local watcher disposed') + await disposal disposeProvider() await settle() expect(first.closeCalls).toBeGreaterThan(0) @@ -295,8 +294,8 @@ describe('skill-local watcher failures', () => { await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(1) }) const first = watcherHarness.watchers[0] if (first === undefined) throw new Error('expected an opening root watcher') - const disposal = provider.dispose() first.emitter.emit('error', new Error('opening failed during disposal')) + const disposal = provider.dispose() await expect(discovery).rejects.toThrow('opening failed during disposal') await disposal From 52d68a538316241d9f91668de224b02911f66d29 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:08:10 +0800 Subject: [PATCH 27/32] fix(skill): retain candidates across watcher failures --- ...-07-27-skill-catalog-hot-refresh.i18n.yaml | 4 +- .../2026-07-27-skill-catalog-hot-refresh.md | 6 +-- ...2026-07-27-skill-catalog-hot-refresh.zh.md | 6 +-- docs/config-catalog.md | 4 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/skills.i18n.yaml | 4 +- docs/core-data-structures/skills.md | 23 +++++++-- docs/core-data-structures/skills.zh.md | 23 +++++++-- docs/event-producer-consumer.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 6 ++- packages/skill/skill-local/README.i18n.yaml | 4 +- packages/skill/skill-local/README.md | 2 +- packages/skill/skill-local/README.zh.md | 2 +- packages/skill/skill-local/src/index.ts | 16 ++++-- .../tests/skill-local-watcher.spec.ts | 20 +++++--- packages/skill/skill/README.i18n.yaml | 4 +- packages/skill/skill/README.md | 8 +-- packages/skill/skill/README.zh.md | 8 +-- packages/skill/skill/src/index.ts | 41 ++++++++++++--- packages/skill/skill/tests/skill.spec.ts | 50 +++++++++++++++---- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 5 ++ 23 files changed, 175 insertions(+), 68 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml index c89c716b13..d3ecfe624c 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.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-27-skill-catalog-hot-refresh.md -2026-07-27-skill-catalog-hot-refresh.md: e7cff2cb53a044ed0c4789cef3550652903f3586 -2026-07-27-skill-catalog-hot-refresh.zh.md: 86519c93880f94b1b9d3bdc011aa09b04ca10686 +2026-07-27-skill-catalog-hot-refresh.md: 7c81b287cecde60c42f7e1e3ec171244ffc18aa6 +2026-07-27-skill-catalog-hot-refresh.zh.md: ca570a3c6a0402e6764824e3101e15054769b85d diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md index e7cff2cb53..7c81b287ce 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md @@ -12,11 +12,11 @@ Filesystem updates are also non-atomic from the observer's perspective. An edito ## Decision -The skill capability separates catalog membership from instruction-body loading. `ctx.skills.snapshot()` returns summaries plus a completeness bit. `ctx.skills.registerProvider(factory)` gives the synchronous factory one registration-scoped `{ signal, invalidate }` control: `invalidate()` dirties only that exact active registration and discards completed catalog caches, while the signal aborts when registration fails or is disposed. A provider or runtime generation change during discovery retries before returning. Incomplete observations are not cached. A late invalidation after disposal or replacement is a no-op because the capability has been revoked. +The skill capability separates catalog membership from instruction-body loading. `ctx.skills.snapshot()` returns summaries plus a completeness bit. `ctx.skills.registerProvider(factory)` gives the synchronous factory one registration-scoped `{ signal, invalidate }` control: `invalidate()` dirties only that exact active registration and discards completed catalog caches, while the signal aborts when registration fails or is disposed. Provider arrays are complete-discovery shorthand; an explicit incomplete observation can retain readable candidates for direct loads without becoming cacheable or authoritative for model-facing consumers. A provider or runtime generation change during discovery retries before returning. A late invalidation after disposal or replacement is a no-op because the capability has been revoked. `@deepseek-ai/dsh-skill-local` directly depends on Chokidar and observes catalog-relevant host paths. Existing roots watch direct skill bundle directories, flat Markdown entries, and direct `SKILL.md` entry files. Additions, removals, and directory changes invalidate membership; file changes support frontmatter `name` and `description` refresh. Resource files below a bundle are ignored. Events in one microtask batch coalesce to one invalidation. Project watchers use a bounded least-recently-observed set. -A missing root is followed from its nearest existing ancestor one absent segment at a time with `fs.watchFile`, then handed to Chokidar once the real root exists. Before scanning, each discovery re-probes the retained root/ancestor mode. That independent probe re-establishes ancestor observation after deletion even when child removals invalidate and publish an authoritative empty catalog before, or without, a root `unlinkDir` event. Chokidar configuration exposes native-versus-polling mode, write stability, polling interval, symlink following, and project watcher capacity. First-party `write` and `edit` tool observations synchronously invalidate a relevant provider, so the next model step sees its own mutation without waiting for host delivery. Watch startup/runtime failures make discovery incomplete and retry; teardown closes watchers and ignores late callbacks. +A missing root is followed from its nearest existing ancestor one absent segment at a time with `fs.watchFile`, then handed to Chokidar once the real root exists. Before scanning, each discovery re-probes the retained root/ancestor mode. That independent probe re-establishes ancestor observation after deletion even when child removals invalidate and publish an authoritative empty catalog before, or without, a root `unlinkDir` event. Chokidar configuration exposes native-versus-polling mode, write stability, polling interval, symlink following, and project watcher capacity. First-party `write` and `edit` tool observations synchronously invalidate a relevant provider, so the next model step sees its own mutation without waiting for host delivery. Watch startup/runtime failures are logged and retried; discovery still returns readable candidates for direct loads but reports an incomplete observation. Teardown closes watchers and ignores late callbacks. `@deepseek-ai/dsh-tool-skill` injects the first non-empty complete catalog as a durable sourced `user/message` on the first complete `agent/step` that observes one. At every `agent/step` it applies exact `skill` tool visibility, hashes the exact rendered text between the `` tags, and scans the read-only session events backwards without copying them for the newest recognizable visible catalog from this plugin. A changed digest appends a durable, complete replacement through `agent.inject()`, including an explicit empty catalog when all skills disappear. If no catalog remains visible but a recognizable one exists in historical events, compaction hid it and the next complete observation re-establishes the current catalog, including an empty tombstone. A current empty catalog with no historical publication emits nothing, while an incomplete snapshot preserves the last-good model view. The backward scan normally stops at the newest visible catalog; when compaction hides every catalog it pays an O(session-events) scan to recover that fact. @@ -26,7 +26,7 @@ Instruction bodies keep progressive disclosure. Every `skill(name)` call asks th ## Verification -Registry tests pin registration-scoped invalidation, revocation, signal abort, contained observer failures, incomplete snapshots, generation retries, and stale-name rejection. Local-provider tests cover bundle and flat-file creation, removal, rename, root creation/deletion/recreation including an unobserved root `unlinkDir`, description changes, body-only edits, first-party observation, symlinks, polling options, watcher failures, event coalescing, bounded projects, teardown, and transient reads. Tool tests pin full replacement messages, empty tombstones, digest stability for body-only edits, incomplete-state retention, visibility, and resume metadata. TUI tests pin last-complete retention, authoritative empty removal, latest-wins refresh, teardown, and the already-open slash-draft race; a real Loader/PTY smoke adds a local skill after startup and observes its completion without restarting. A keyless assembled agent-spine snapshot creates a project skill through model-facing filesystem tools, observes its replacement catalog on the next request, and loads its current body with the real `skill` tool. +Registry tests pin registration-scoped invalidation, revocation, signal abort, contained observer failures, incomplete candidates, generation retries, and stale-name rejection. Local-provider tests cover bundle and flat-file creation, removal, rename, root creation/deletion/recreation including an unobserved root `unlinkDir`, description changes, body-only edits, first-party observation, symlinks, polling options, persistent watcher failures with loadable candidates, event coalescing, bounded projects, teardown, and transient reads. Tool tests pin full replacement messages, empty tombstones, digest stability for body-only edits, incomplete-state retention, visibility, and resume metadata. TUI tests pin last-complete retention, authoritative empty removal, latest-wins refresh, teardown, and the already-open slash-draft race; a real Loader/PTY smoke adds a local skill after startup and observes its completion without restarting. A keyless assembled agent-spine snapshot creates a project skill through model-facing filesystem tools, observes its replacement catalog on the next request, and loads its current body with the real `skill` tool. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md index 86519c9388..ca570a3c6a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md @@ -12,11 +12,11 @@ skill(技能)摘要是模型的路由输入,但本地 skill 可在会话 ## 决策 -skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snapshot()` 返回摘要及一个完整性位。`ctx.skills.registerProvider(factory)` 会向同步工厂提供一项注册作用域内的 `{ signal, invalidate }` 控制能力:`invalidate()` 只会将该精确活动注册标记为脏,并丢弃已完成目录缓存;注册失败或释放时,信号会中止。在发现期间,如果提供方或运行时 generation 发生变化,系统会先重试再返回。不完整的观察结果不会缓存。资源释放或替换后的延迟失效操作不会执行任何操作,因为该能力已被撤销。 +skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snapshot()` 返回摘要及一个完整性位。`ctx.skills.registerProvider(factory)` 会向同步工厂提供一项注册作用域内的 `{ signal, invalidate }` 控制能力:`invalidate()` 只会将该精确活动注册标记为脏,并丢弃已完成目录缓存;注册失败或释放时,信号会中止。提供方返回的数组是完整发现的简写形式;显式的不完整观测可以保留可读候选项供直接加载,但不能缓存,也不能作为面向模型消费方的权威结果。在发现期间,如果提供方或运行时 generation 发生变化,系统会先重试再返回。资源释放或替换后的延迟失效操作不会执行任何操作,因为该能力已被撤销。 `@deepseek-ai/dsh-skill-local` 直接依赖 Chokidar,并观察与目录相关的宿主路径。已有根目录会监视其直属 skill bundle 目录、平铺的 Markdown 条目和直属 `SKILL.md` 条目文件。新增、移除和目录变更会使成员关系失效;文件变更还支持刷新 frontmatter 中的 `name` 和 `description`。bundle 内更深层的资源文件会被忽略。同一微任务批次中的事件会合并为一次失效。项目 watcher 使用有界集合,并按最久未观察顺序淘汰。 -系统从缺失根目录最近的现有祖先开始,使用 `fs.watchFile` 每次跟进一层缺失路径片段;真实根目录出现后,再交给 Chokidar。每次发现操作都会在扫描前重新探测所保留的根目录/祖先模式。即使子项移除在根目录 `unlinkDir` 事件之前就触发失效并发布权威空目录,或者该事件根本没有到达,这项独立探测也会在删除后重新建立祖先观察。Chokidar 配置公开原生事件或轮询模式、写入稳定性、轮询间隔、符号链接跟随选项和项目 watcher 容量。第一方 `write` 和 `edit` 工具观察会同步使相关提供方失效,因此下一个模型步骤无需等待宿主事件投递,就能看到自身改动。watcher 启动或运行失败会使发现结果不完整并触发重试;资源销毁会关闭 watcher,并忽略延迟回调。 +系统从缺失根目录最近的现有祖先开始,使用 `fs.watchFile` 每次跟进一层缺失路径片段;真实根目录出现后,再交给 Chokidar。每次发现操作都会在扫描前重新探测所保留的根目录/祖先模式。即使子项移除在根目录 `unlinkDir` 事件之前就触发失效并发布权威空目录,或者该事件根本没有到达,这项独立探测也会在删除后重新建立祖先观察。Chokidar 配置公开原生事件或轮询模式、写入稳定性、轮询间隔、符号链接跟随选项和项目 watcher 容量。第一方 `write` 和 `edit` 工具观察会同步使相关提供方失效,因此下一个模型步骤无需等待宿主事件投递,就能看到自身改动。watcher 启动或运行失败会被记录并触发重试;发现过程仍会返回可读候选项供直接加载,但会报告不完整观测。资源销毁会关闭 watcher,并忽略延迟回调。 `@deepseek-ai/dsh-tool-skill` 在 `agent/step` 首次观察到非空完整目录时,将该目录注入为一条持久且带来源的 `user/message`。每次 `agent/step`,它都会应用 `skill` 工具的精确可见性,对 `` 标签之间精确渲染的文本计算哈希,并从后向前扫描只读会话事件且不复制,以查找该插件发布的最新一条可识别且仍可见的目录。digest 变化时,插件通过 `agent.inject()` 追加一份持久的完整替换目录;所有 skill 消失时,也会追加显式空目录。如果没有目录仍然可见,但历史事件中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录,包括空 tombstone。如果当前目录为空且历史上从未发布目录,则不发送任何内容;不完整快照则保留最后一次完整的模型视图。反向扫描通常在最新且仍可见的目录处停止;当压缩遮蔽所有目录时,它会以一次 O(session-events) 扫描的成本确认这一事实。 @@ -26,7 +26,7 @@ TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills ## 验证 -注册表测试固定了注册作用域内的失效、能力撤销、信号中止、监听器失败隔离、不完整快照、generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建(包括未观测到根目录 `unlinkDir` 事件的情形)、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、watcher 失败、事件合并、项目 watcher 容量上限、资源销毁和暂时读取。工具测试固定了完整替换消息、空 tombstone、仅修改正文时 digest 稳定、不完整状态保留、可见性和恢复元数据。TUI 测试固定了上一份完整结果保留、权威空结果清除、刷新时以最新结果为准、资源销毁和已打开斜杠草稿的竞态;一项使用真实 Loader/PTY 的 smoke 测试会在启动后添加本地 skill,并观察其补全项出现,而无需重启。一个无密钥、装配完成的 agent-spine 快照测试通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并使用真实 `skill` 工具加载当前正文。 +注册表测试固定了注册作用域内的失效、能力撤销、信号中止、监听器失败隔离、不完整候选项、generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建(包括未观测到根目录 `unlinkDir` 事件的情形)、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、候选项仍可加载的持续 watcher 失败、事件合并、项目 watcher 容量上限、资源销毁和暂时读取。工具测试固定了完整替换消息、空 tombstone、仅修改正文时 digest 稳定、不完整状态保留、可见性和恢复元数据。TUI 测试固定了上一份完整结果保留、权威空结果清除、刷新时以最新结果为准、资源销毁和已打开斜杠草稿的竞态;一项使用真实 Loader/PTY 的 smoke 测试会在启动后添加本地 skill,并观察其补全项出现,而无需重启。一个无密钥、装配完成的 agent-spine 快照测试通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并使用真实 `skill` 工具加载当前正文。 ## 考虑过的替代方案 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0f297397f5..0e4cdd1b5f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1241,7 +1241,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:129`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:138`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -1273,7 +1273,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill-local/src/index.ts:47`](../packages/skill/skill-local/src/index.ts) +Source: [`packages/skill/skill-local/src/index.ts:48`](../packages/skill/skill-local/src/index.ts) ## `@deepseek-ai/dsh-spill-local` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 470a331db1..f5b1f3d70d 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -659,7 +659,7 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:147`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:156`](../../packages/skill/skill/src/index.ts) ## `slash/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 048385a013..50b0fd5768 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1681,7 +1681,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promise Promise + readonly list: (options: SkillLookupOptions) => Promise /** * Load a complete skill body for a previously listed candidate. * @param candidate - the winning candidate originally returned by this provider. @@ -61,7 +74,7 @@ The shipped local provider scans roots in rank order: The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child. The local provider does not ship built-in system skills; deployments supply built-ins through another provider. -Chokidar watches existing roots for direct bundle/flat-entry additions and removals plus direct skill-entry changes. A missing root is followed one absent path segment at a time from its nearest existing ancestor until Chokidar can attach. Resource files below a bundle are not catalog changes. Model-facing `write` and `edit` observations synchronously invalidate the provider when their target is catalog-relevant, while the host watcher covers IDE, Git, shell, and external-process mutations. Watcher failures make the current observation incomplete; project-scoped watchers use a configured bounded LRU. +Chokidar watches existing roots for direct bundle/flat-entry additions and removals plus direct skill-entry changes. A missing root is followed one absent path segment at a time from its nearest existing ancestor until Chokidar can attach. Resource files below a bundle are not catalog changes. Model-facing `write` and `edit` observations synchronously invalidate the provider when their target is catalog-relevant, while the host watcher covers IDE, Git, shell, and external-process mutations. Watcher failures make the current observation incomplete without hiding readable candidates from direct loads; project-scoped watchers use a configured bounded LRU. ## Skill identity @@ -101,7 +114,7 @@ interface SkillSummary { ```ts type-equiv /** One catalog observation plus whether every registered provider completed discovery. */ interface SkillCatalogSnapshot { - /** Sorted model-invocable summaries from providers that completed. */ + /** Sorted model-invocable summaries collected in this observation. */ readonly skills: SkillSummary[] /** Whether every registered provider completed discovery for this observation. */ readonly complete: boolean diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index 7d5c61dcf3..2fd2f73f04 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -10,7 +10,19 @@ `ctx.skills` 组合本地、内嵌、远程或其他提供方。注册是同步的;远程初始化与发现属于 `list()` 的 await 阶段。提供方对象、选项与候选项以只读方式借用,语义字段会被校验。 -重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时会记录日志并从不完整观测中省略,且该观测不会缓存;格式错误的候选项快速失败。每个提供方工厂都会接收一项注册作用域内的控制能力;仅当该精确注册仍处于活动状态时,其 `invalidate()` 才会清除已完成目录;注册失败或释放时,其信号会中止。若提供方代次在发现进行期间发生变化,该发现会重试。提供方和运行时变更会发出不带过滤条件的 `skills/change` 失效事件;该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 `snapshot()`。 +重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时会记录日志并从不完整观测中省略;显式的不完整观测会提供可用候选项,但不会使结果变得可缓存;格式错误的候选项快速失败。每个提供方工厂都会接收一项注册作用域内的控制能力;仅当该精确注册仍处于活动状态时,其 `invalidate()` 才会清除已完成目录;注册失败或释放时,其信号会中止。若提供方代次在发现进行期间发生变化,该发现会重试。提供方和运行时变更会发出不带过滤条件的 `skills/change` 失效事件;该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 `snapshot()`。 + +`SkillProvider.list()` 返回的数组是完整发现的简写形式。`SkillProviderObservation` 允许提供方公开仍可直接加载的候选项,同时报告该观测不具权威性。 + +```ts type-equiv +/** Provider candidates plus whether the current discovery is authoritative. */ +interface SkillProviderObservation { + /** Candidates available from the current provider discovery. */ + readonly candidates: readonly SkillCandidate[] + /** Whether discovery completed and these candidates may be cached. */ + readonly complete: boolean +} +``` ```ts type-equiv /** Provider interface for one source of skills, such as local directories or a remote registry. */ @@ -23,9 +35,10 @@ interface SkillProvider { * authentication, and discovery are awaited inside this method. Implementations * should settle promptly when `options.signal` aborts. * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. - * @returns provider candidates with precedence ranks and opaque locators. + * @returns provider candidates as a complete-array shorthand, or an explicit + * observation when usable candidates came from incomplete discovery. */ - readonly list: (options: SkillLookupOptions) => Promise + readonly list: (options: SkillLookupOptions) => Promise /** * Load a complete skill body for a previously listed candidate. * @param candidate - the winning candidate originally returned by this provider. @@ -61,7 +74,7 @@ interface SkillProviderControl { 项目根目录为包含 `.git` 的最近祖先目录;找不到时使用当前 cwd。当 `ctx.fs` 可用时,git-root 向上查找通过文件系统服务探测 `.git`,使远程或沙箱工作区不会回退到宿主文件系统边界。用户 DSH 根目录会跳过其 `.system` 子目录。本地提供方不附带内置系统 skill;部署方通过另一个提供方提供内置 skill。 -Chokidar 会监视现有根目录中直属 bundle 和平铺条目的添加与移除,以及直属 skill 条目的变更。缺失的根目录会从最近的现有祖先开始,逐个跟踪缺失路径段,直至 Chokidar 可以附加。bundle 下的资源文件变更不属于目录变更。面向模型的 `write` 和 `edit` 观测会在目标路径相关时同步使提供方目录失效,而宿主 watcher 覆盖 IDE、Git、shell 和外部进程产生的变更。watcher 失败会使当前观测不完整;项目作用域 watcher 使用按配置设限的 LRU。 +Chokidar 会监视现有根目录中直属 bundle 和平铺条目的添加与移除,以及直属 skill 条目的变更。缺失的根目录会从最近的现有祖先开始,逐个跟踪缺失路径段,直至 Chokidar 可以附加。bundle 下的资源文件变更不属于目录变更。面向模型的 `write` 和 `edit` 观测会在目标路径相关时同步使提供方目录失效,而宿主 watcher 覆盖 IDE、Git、shell 和外部进程产生的变更。watcher 失败会使当前观测不完整,但不会在直接加载时隐藏可读候选项;项目作用域 watcher 使用按配置设限的 LRU。 ## Skill 身份 @@ -101,7 +114,7 @@ interface SkillSummary { ```ts type-equiv /** One catalog observation plus whether every registered provider completed discovery. */ interface SkillCatalogSnapshot { - /** Sorted model-invocable summaries from providers that completed. */ + /** Sorted model-invocable summaries collected in this observation. */ readonly skills: SkillSummary[] /** Whether every registered provider completed discovery for this observation. */ readonly complete: boolean diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f63d34ea1d..8f46e59f05 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -35,7 +35,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:147`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:156`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:237`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index eb497f5074..2cfc926ef1 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2360,12 +2360,16 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SkillProvider', - declaration: 'export interface SkillProvider {\n readonly name: string;\n readonly list: (options: SkillLookupOptions) => Promise;\n readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise;\n}', + declaration: 'export interface SkillProvider {\n readonly name: string;\n readonly list: (options: SkillLookupOptions) => Promise;\n readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise;\n}', }, { name: 'SkillProviderControl', declaration: 'export interface SkillProviderControl {\n readonly signal: AbortSignal;\n readonly invalidate: () => void;\n}', }, + { + name: 'SkillProviderObservation', + declaration: 'export interface SkillProviderObservation {\n readonly candidates: readonly SkillCandidate[];\n readonly complete: boolean;\n}', + }, { name: 'SkillRegistration', declaration: 'export type SkillRegistration = Omit & {\n readonly provider?: string;\n};', diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml index 633ae1260c..56835ba955 100644 --- a/packages/skill/skill-local/README.i18n.yaml +++ b/packages/skill/skill-local/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/skill/skill-local/README.md -README.md: 1fe545f15c12a6b540b1feaa7b612ae27c1f4f56 -README.zh.md: 15911b45c229f41ed2ebca4db22a2050b2d71431 +README.md: d4b6253c6667f4786d53ad6c291f9e96f82546c3 +README.zh.md: 7cede0ea64064ca4f170d81303085b40b5751f15 diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index 1fe545f15c..d4b6253c66 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -46,7 +46,7 @@ Existing skill roots are watched with Chokidar. The provider observes direct bun A root that does not exist is followed from the nearest existing ancestor one missing path segment at a time. The next segment is probed with `fs.watchFile`; once `.agents`, `skills`, or the configured root appears, observation advances until Chokidar can attach to the real root. Root deletion reverses this process, so deleting and recreating an entire skills directory remains observable. Project-scoped watchers are bounded by `watchMaxProjects`; revisiting an evicted project reattaches observation during discovery. -The first-party filesystem `write` and `edit` tools also synchronously invalidate the provider through `fs/observed` when their target could affect a watched skill entry. This fast path makes the next model step observe its own filesystem mutation without waiting for the host watcher. External IDE, Git, shell, and process changes rely on Chokidar or the missing-path probe. Startup/runtime watcher failures are logged, make the current provider observation incomplete, and are retried; effect teardown closes every watcher and contains late callbacks. +The first-party filesystem `write` and `edit` tools also synchronously invalidate the provider through `fs/observed` when their target could affect a watched skill entry. This fast path makes the next model step observe its own filesystem mutation without waiting for the host watcher. External IDE, Git, shell, and process changes rely on Chokidar or the missing-path probe. Startup/runtime watcher failures are logged and retried. Discovery still scans readable roots and returns their candidates for direct loading, but marks the observation incomplete so it is not cached or published as an authoritative model catalog. Effect teardown closes every watcher and contains late callbacks. ## Skill Format diff --git a/packages/skill/skill-local/README.zh.md b/packages/skill/skill-local/README.zh.md index 15911b45c2..7cede0ea64 100644 --- a/packages/skill/skill-local/README.zh.md +++ b/packages/skill/skill-local/README.zh.md @@ -46,7 +46,7 @@ 不存在的根会从最近的现有祖先开始,每次沿一个缺失路径段跟踪。系统使用 `fs.watchFile` 探测下一段;当 `.agents`、`skills` 或已配置的根出现后,观察会逐级推进,直至 Chokidar 可以附加到真实根。根删除时,该过程反向执行,因此删除再重建整个 skills 目录仍可被观察到。按项目划分的 watcher 数量受 `watchMaxProjects` 限制;再次访问已被驱逐的项目时,发现阶段会重新附加观察。 -如果第一方文件系统 `write` 和 `edit` 工具的目标可能影响受监视的 skill 条目,它们还会通过 `fs/observed` 同步使提供方失效。这条快速路径让模型的下一个步骤无需等待宿主 watcher,即可观察到自身的文件系统变更。外部 IDE、Git、shell 和进程产生的变更依赖 Chokidar 或缺失路径探测。watcher 启动或运行时失败会被记录,使提供方的当前观察不完整,并触发重试;effect 释放会关闭所有 watcher,并收束延迟回调。 +如果第一方文件系统 `write` 和 `edit` 工具的目标可能影响受监视的 skill 条目,它们还会通过 `fs/observed` 同步使提供方失效。这条快速路径让模型的下一个步骤无需等待宿主 watcher,即可观察到自身的文件系统变更。外部 IDE、Git、shell 和进程产生的变更依赖 Chokidar 或缺失路径探测。watcher 启动或运行时失败会被记录并触发重试。发现过程仍会扫描可读根目录,并返回其候选项供直接加载,但会将观测标记为不完整,因此不会缓存,也不会作为权威模型目录发布。effect 释放会关闭所有 watcher,并收束延迟回调。 ## Skill 格式 diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 907c15a8ab..8bc02dbb31 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -27,6 +27,7 @@ import { type SkillLookupOptions, type SkillProvider, type SkillProviderControl, + type SkillProviderObservation, type SkillSource, } from '@deepseek-ai/dsh-skill' @@ -161,18 +162,25 @@ export class LocalSkillProvider implements SkillProvider { /** * Discover local skill summaries for a cwd-sensitive workspace. * @param options - lookup options; `cwd` selects the project roots to scan. - * @returns local provider candidates with stable root ranks. + * @returns local provider candidates with stable root ranks; watcher startup + * failure returns readable candidates as an incomplete observation. */ - async list(options: SkillLookupOptions): Promise { + async list(options: SkillLookupOptions): Promise { const roots = await this.roots(options.cwd) - await this.watchManager.observeRoots(roots) + let complete = true + try { + await this.watchManager.observeRoots(roots) + } catch (error) { + if (this.disposal !== undefined) throw error + complete = false + } const candidates: SkillCandidate[] = [] for (const root of roots) { for (const skill of await discoverRoot(root, this.ctx)) { candidates.push(skill) } } - return candidates + return complete ? candidates : { candidates, complete } } /** diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts index a1df287fbe..4f9cfc59ef 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -117,11 +117,15 @@ describe('skill-local watcher failures', () => { await fiber.dispose() }) - it('marks a startup failure incomplete and retries discovery without caching it', async () => { + it('keeps skills loadable across persistent watcher startup failures without caching them', async () => { const home = await tempDir('skill-watch-start-error') const root = join(home, '.dsh/skills') await writeSkill(root, 'retry-skill') - watcherHarness.startupErrors.push(new Error('watch failed')) + watcherHarness.startupErrors.push( + new Error('watch failed once'), + new Error('watch failed twice'), + new Error('watch failed three times'), + ) watcherHarness.closeErrors = 1 const ctx = new Context() await ctx.plugin(SkillService) @@ -135,13 +139,17 @@ describe('skill-local watcher failures', () => { watchStabilityThresholdMs: 20, }) - expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false }) expect(await ctx.skills.snapshot()).toMatchObject({ skills: [{ name: 'retry-skill' }], - complete: true, + complete: false, }) - expect(watcherHarness.watchers).toHaveLength(2) - expect(watcherHarness.watchers[1]?.options).toMatchObject({ + expect((await ctx.skills.get('retry-skill'))?.content).toBe('Body.') + expect(await ctx.skills.snapshot()).toMatchObject({ + skills: [{ name: 'retry-skill' }], + complete: false, + }) + expect(watcherHarness.watchers).toHaveLength(3) + expect(watcherHarness.watchers[0]?.options).toMatchObject({ atomic: true, depth: 1, followSymlinks: false, diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index 09a8788486..df1e9b8e29 100644 --- a/packages/skill/skill/README.i18n.yaml +++ b/packages/skill/skill/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/skill/skill/README.md -README.md: 66b240c3a67941b2e617986bd43e6b0060b49f56 -README.zh.md: 0ebbab089999cbca07018724c05e40dd6b100200 +README.md: 9813338dcec82fc2db7e149be1bd80ec5239684d +README.zh.md: abc637c1793b31158d015468941fd38ae06254c6 diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 66b240c3a6..9813338dce 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -11,7 +11,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ### Public API - `ctx.skills.registerProvider(create): () => void` Calls a synchronous provider factory with `{ signal, invalidate }`, then registers its readonly result by unique `provider.name`. Duplicate names throw, `runtime` is reserved, and failed registration aborts the signal. The exact Cordis disposer unregisters the provider, aborts the signal, and preserves ordered composite teardown. -- `ctx.skills.snapshot({ cwd?, signal? })` Returns `{ skills, complete }`. `complete` is false when any provider failed transiently; incomplete observations are never cached, so a model-facing consumer can retain its last-good catalog and retry at the next request boundary. +- `ctx.skills.snapshot({ cwd?, signal? })` Returns `{ skills, complete }`. `complete` is false when any provider rejects or explicitly reports incomplete discovery; candidates supplied with an incomplete observation remain in this result, which is never cached. - `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns model-invocable summaries for the current workspace, merged across providers and sorted by name. - `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it, including disabled-for-model skills. - `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown. @@ -28,11 +28,11 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ## Provider Contract -A provider factory runs synchronously and receives one registration-scoped control. `control.signal` aborts when registration fails or is disposed; `control.invalidate()` clears completed catalogs only while that exact registration remains active, so late callbacks cannot affect a replacement with the same name. Immutable providers may ignore the control. Remote setup, authentication, and discovery belong in the provider's awaited `list(options)` call. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation. +A provider factory runs synchronously and receives one registration-scoped control. `control.signal` aborts when registration fails or is disposed; `control.invalidate()` clears completed catalogs only while that exact registration remains active, so late callbacks cannot affect a replacement with the same name. Immutable providers may ignore the control. Remote setup, authentication, and discovery belong in the provider's awaited `list(options)` call. An array return is shorthand for complete discovery; a provider that collected usable candidates but could not establish an authoritative observation returns `{ candidates, complete: false }`. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation. The registry validates candidates before caching and definitions before returning them. The winning provider receives the same candidate and opaque `locator` it returned from `list()`, allowing backend-specific file, URL, id, or version handles. Callers and providers must preserve the readonly contract. -Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure: its entries are omitted from that observation, `complete` is false, and the result is not cached. A provider or runtime revision change discards an in-flight result and retries before returning. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name. +Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure and omitted. An explicit incomplete observation still contributes its candidates for `list()` and `get()`, but makes the aggregate snapshot incomplete and uncacheable. A provider or runtime revision change discards an in-flight result and retries before returning. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name. Definitions remain progressively loaded. `get()` asks the winning provider for the body on every call rather than caching it in this registry. If the returned definition has a different name from the selected candidate, the stale selection is rejected and the registry internally invalidates that exact provider so the next snapshot rediscovers its catalog. @@ -56,5 +56,5 @@ No direct prompt effect. The named consumer owns the durable initial catalog and - **Invalidation is provider-driven** — the registry has no TTL and cannot infer that an arbitrary remote source changed; each mutable provider must retain and call its registration-scoped `invalidate()` capability from its own observation mechanism. - **Providers are queried sequentially** — one slow cooperative provider delays every provider registered after it; cancellation stops the caller's wait but cannot terminate work an uncooperative provider keeps running. -- **An incomplete snapshot omits the failing provider in that observation** — the registry reports `complete: false`, but it does not own a last-good catalog or a per-provider diagnostic; consumers choose whether to retain earlier state. +- **Incomplete observations are not retained** — rejected providers are omitted and explicitly supplied candidates remain available only to the current lookup; the registry owns neither a last-good catalog nor per-provider diagnostics. - **Duplicate resolution is first-wins** — later lower-priority candidates are logged and hidden; there is no API to inspect all shadowed definitions. diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md index 0ebbab0899..abc637c179 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -11,7 +11,7 @@ ### 公开 API - `ctx.skills.registerProvider(create): () => void` 调用同步提供方工厂并向其传入 `{ signal, invalidate }`,随后使用唯一 `provider.name` 注册其只读结果。重复提供方名称会抛错,`runtime` 为保留名称;注册失败会中止信号。精确的 Cordis disposer 会注销提供方、中止信号,并保持有序组合拆卸。 -- `ctx.skills.snapshot({ cwd?, signal? })` 返回 `{ skills, complete }`。任一提供方发生瞬时失败时,`complete` 为 false;不完整观测绝不缓存,使面向模型的消费方可以保留上一份可用目录,并在下一个请求边界重试。 +- `ctx.skills.snapshot({ cwd?, signal? })` 返回 `{ skills, complete }`。任一提供方调用被拒绝或显式报告发现不完整时,`complete` 为 false;不完整观测提供的候选项仍保留在该结果中,但该结果绝不缓存。 - `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中模型可调用的摘要;这些摘要跨提供方合并,并按名称排序。 - `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后将其返回,包括已对模型禁用的 skill。 - `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。 @@ -28,11 +28,11 @@ ## 提供方契约 -提供方工厂同步运行,并接收一项注册作用域内的控制能力。注册失败或释放时,`control.signal` 会中止;仅当该精确注册仍处于活动状态时,`control.invalidate()` 才会清除已完成目录,因此延迟回调无法影响同名替代项。不可变提供方可以忽略该控制能力。远程设置、身份验证和发现属于提供方需等待的 `list(options)` 调用。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。 +提供方工厂同步运行,并接收一项注册作用域内的控制能力。注册失败或释放时,`control.signal` 会中止;仅当该精确注册仍处于活动状态时,`control.invalidate()` 才会清除已完成目录,因此延迟回调无法影响同名替代项。不可变提供方可以忽略该控制能力。远程设置、身份验证和发现属于提供方需等待的 `list(options)` 调用。返回数组是完整发现的简写形式;若提供方已收集到可用候选项,却无法建立权威观测,则返回 `{ candidates, complete: false }`。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。 注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读契约。 -契约违反会快速失败。提供方 `list()` 被拒绝会视为瞬时来源失败:该次观测会省略其条目,`complete` 为 false,结果也不会缓存。提供方或运行时修订发生变更时,会丢弃正在进行的结果并重试后再返回。重复名称按 rank、提供方注册顺序,然后按提供方本地顺序解析。摘要按 skill 名称排序。 +契约违反会快速失败。提供方 `list()` 被拒绝会视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()` 和 `get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变更时,会丢弃正在进行的结果并重试后再返回。重复名称按 rank、提供方注册顺序,然后按提供方本地顺序解析。摘要按 skill 名称排序。 定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并由注册表在内部使该精确提供方失效,以便下一次快照重新发现其目录。 @@ -56,5 +56,5 @@ - **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须保留其注册作用域内的 `invalidate()` 能力,并由自身的观测机制调用它。 - **提供方依次查询**:一个缓慢的协作提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不协作提供方持续运行的工作。 -- **不完整快照会在该次观测中省略失败的提供方**:注册表会报告 `complete: false`,但不负责上一份可用目录或逐提供方诊断;消费方选择是否保留先前状态。 +- **不保留不完整观测**:被拒绝的提供方会被省略,显式提供的候选项也仅在当前查找中可用;注册表既不负责上一份可用目录,也不负责逐提供方诊断。 - **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。 diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index f103f828a0..6c12503bd9 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -89,12 +89,20 @@ export interface SkillLookupOptions { /** One catalog observation plus whether every registered provider completed discovery. */ export interface SkillCatalogSnapshot { - /** Sorted model-invocable summaries from providers that completed. */ + /** Sorted model-invocable summaries collected in this observation. */ readonly skills: SkillSummary[] /** Whether every registered provider completed discovery for this observation. */ readonly complete: boolean } +/** Provider candidates plus whether the current discovery is authoritative. */ +export interface SkillProviderObservation { + /** Candidates available from the current provider discovery. */ + readonly candidates: readonly SkillCandidate[] + /** Whether discovery completed and these candidates may be cached. */ + readonly complete: boolean +} + /** Provider interface for one source of skills, such as local directories or a remote registry. */ export interface SkillProvider { /** Unique provider name in the `ctx.skills` registry. */ @@ -105,9 +113,10 @@ export interface SkillProvider { * authentication, and discovery are awaited inside this method. Implementations * should settle promptly when `options.signal` aborts. * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. - * @returns provider candidates with precedence ranks and opaque locators. + * @returns provider candidates as a complete-array shorthand, or an explicit + * observation when usable candidates came from incomplete discovery. */ - readonly list: (options: SkillLookupOptions) => Promise + readonly list: (options: SkillLookupOptions) => Promise /** * Load a complete skill body for a previously listed candidate. * @param candidate - the winning candidate originally returned by this provider. @@ -387,11 +396,9 @@ export class SkillService extends Service { this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`) } if (output === undefined) continue - if (!Array.isArray(output)) { - throw new TypeError(`skill provider "${provider.name}" list() must return an array`) - } - const listed = output as readonly SkillCandidate[] - for (const candidate of listed) { + const observation = normalizeProviderObservation(output, provider.name) + if (!observation.complete) cacheable = false + for (const candidate of observation.candidates) { validateCandidate(candidate, provider.name) candidates.push({ candidate, provider, providerOrder: order, localOrder }) localOrder += 1 @@ -426,6 +433,24 @@ export class SkillService extends Service { } } +function normalizeProviderObservation(output: unknown, providerName: string): SkillProviderObservation { + if (Array.isArray(output)) { + return { candidates: output as readonly SkillCandidate[], complete: true } + } + if (output === null || typeof output !== 'object') { + throw invalidProviderObservation(providerName) + } + const observation = output as Partial + if (!Array.isArray(observation.candidates) || typeof observation.complete !== 'boolean') { + throw invalidProviderObservation(providerName) + } + return observation as SkillProviderObservation +} + +function invalidProviderObservation(providerName: string): TypeError { + return new TypeError(`skill provider "${providerName}" list() must return an array or { candidates, complete } observation`) +} + const RUNTIME_SKILL_PROVIDER: SkillProvider = { name: RUNTIME_PROVIDER, /* v8 ignore next -- Runtime skills are injected directly by the registry; this provider only owns `get()`. */ diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index fdf9c40abc..cb9fc8e0c9 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import SkillService, { type SkillCandidate, type SkillDefinition, type SkillLookupOptions, type SkillProvider } from '@deepseek-ai/dsh-skill' +import SkillService, { type SkillCandidate, type SkillDefinition, type SkillLookupOptions, type SkillProvider, type SkillProviderObservation } from '@deepseek-ai/dsh-skill' function memorySkill(name: string, description: string, rank: number, body = `${name} body.`): SkillCandidate { return { @@ -169,15 +169,18 @@ describe('SkillService registry', () => { await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean disableModelInvocation') }) - it('rejects non-array provider results and every malformed candidate scalar', async () => { - const badList = new Context() - await badList.plugin(SkillService) - registerProvider(badList, { - name: 'non-array-list', - list: () => Promise.resolve({} as unknown as SkillCandidate[]), - get: () => Promise.resolve(undefined), - }) - await expect(badList.skills.list()).rejects.toThrow('list() must return an array') + it('rejects malformed provider results and every malformed candidate scalar', async () => { + const malformedOutputs: unknown[] = [null, 1, {}, { candidates: [], complete: 'yes' }] + for (const [index, output] of malformedOutputs.entries()) { + const badList = new Context() + await badList.plugin(SkillService) + registerProvider(badList, { + name: `malformed-list-${index}`, + list: () => Promise.resolve(output as readonly SkillCandidate[] | SkillProviderObservation), + get: () => Promise.resolve(undefined), + }) + await expect(badList.skills.list()).rejects.toThrow('list() must return an array or { candidates, complete } observation') + } const cases: { patch: Partial; expected: string }[] = [ { patch: { name: { value: 'candidate' } as unknown as string }, expected: 'non-string skill name' }, @@ -600,6 +603,33 @@ describe('SkillService registry', () => { expect(flakyCalls).toBe(3) }) + it('keeps candidates from incomplete provider observations loadable without caching them', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + let listCalls = 0 + registerProvider(ctx, { + name: 'incomplete-candidates', + async list() { + listCalls += 1 + return { + candidates: [{ ...memorySkill('available-skill', 'Available', 10), provider: 'incomplete-candidates' }], + complete: false, + } + }, + async get(candidate) { + return { ...candidate, content: (candidate.locator as { content: string }).content } + }, + }) + + expect(await ctx.skills.snapshot()).toMatchObject({ + skills: [{ name: 'available-skill' }], + complete: false, + }) + expect((await ctx.skills.get('available-skill'))?.content).toBe('available-skill body.') + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['available-skill']) + expect(listCalls).toBe(3) + }) + it('invalidates only the exact registered provider and ignores its late callbacks', async () => { const ctx = new Context() await ctx.plugin(SkillService) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index c5a92ed3cd..fa2fbd4533 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -159,6 +159,7 @@ export const LINK_MAP: Record = { SkillDefinition: 'skills.md', SkillLookupOptions: 'skills.md', SkillProvider: 'skills.md', + SkillProviderObservation: 'skills.md', SkillRegistration: 'skills.md', SkillSummary: 'skills.md', SaveTextSpill: 'spill.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 8fd9c127af..6ac7cd8863 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -994,6 +994,11 @@ "symbol": "SkillLookupOptions", "source": "packages/skill/skill/src/index.ts" }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillProviderObservation", + "source": "packages/skill/skill/src/index.ts" + }, { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillProvider", From 354aeae35a97a59e1be064d8194276728f284864 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:40:45 +0800 Subject: [PATCH 28/32] fix: docs --- docs/module-graph.md | 79 +++++++++++++++++++++++--------------------- 1 file changed, 41 insertions(+), 38 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 233c7b49fc..b757a0c9c7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -301,9 +301,6 @@ flowchart TD pkg_client_ui_sidebar --> pkg_client_ui_primitives pkg_client_ui_sidebar --> pkg_client_ui_slots pkg_client_ui_sidebar --> pkg_invariants - pkg_client_ui_slash --> pkg_client_runtime - pkg_client_ui_slash --> pkg_client_ui_slots - pkg_client_ui_slash --> pkg_invariants pkg_client_ui_trajectory --> pkg_client_ui_primitives pkg_client_ui_trajectory --> pkg_invariants pkg_client_ui_workspace --> pkg_client_runtime @@ -339,26 +336,17 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slash - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_settings_general --> pkg_client_locale pkg_client_ui_settings_general --> pkg_client_runtime pkg_client_ui_settings_general --> pkg_client_ui_primitives pkg_client_ui_settings_general --> pkg_client_ui_settings pkg_client_ui_settings_general --> pkg_client_ui_slots pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_slash - pkg_client_ui_skill --> pkg_client_ui_slots - pkg_client_ui_skill --> pkg_invariants - pkg_client_ui_subagent --> pkg_client_runtime - pkg_client_ui_subagent --> pkg_client_ui_slash - pkg_client_ui_subagent --> pkg_client_ui_slots - pkg_client_ui_subagent --> pkg_invariants + pkg_client_ui_slash --> pkg_client_locale + pkg_client_ui_slash --> pkg_client_runtime + pkg_client_ui_slash --> pkg_client_ui_primitives + pkg_client_ui_slash --> pkg_client_ui_slots + pkg_client_ui_slash --> pkg_invariants pkg_client_ui_theme --> pkg_client_locale pkg_client_ui_theme --> pkg_client_runtime pkg_client_ui_theme --> pkg_client_ui_primitives @@ -423,17 +411,25 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt - pkg_client_ui_command --> pkg_client_connection - pkg_client_ui_command --> pkg_client_runtime - pkg_client_ui_command --> pkg_client_ui_conversation - pkg_client_ui_command --> pkg_client_ui_primitives - pkg_client_ui_command --> pkg_client_ui_slash - pkg_client_ui_command --> pkg_client_ui_slots - pkg_client_ui_command --> pkg_invariants + pkg_client_ui_conversation --> pkg_client_locale + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_slash + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_layout --> pkg_client_runtime pkg_client_ui_layout --> pkg_client_ui_slots pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_invariants + pkg_client_ui_subagent --> pkg_client_runtime + pkg_client_ui_subagent --> pkg_client_ui_slash + pkg_client_ui_subagent --> pkg_client_ui_slots + pkg_client_ui_subagent --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session @@ -514,14 +510,13 @@ flowchart TD pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm - pkg_client_ui_model --> pkg_client_connection - pkg_client_ui_model --> pkg_client_runtime - pkg_client_ui_model --> pkg_client_ui_command - pkg_client_ui_model --> pkg_client_ui_conversation - pkg_client_ui_model --> pkg_client_ui_primitives - pkg_client_ui_model --> pkg_client_ui_slash - pkg_client_ui_model --> pkg_client_ui_slots - pkg_client_ui_model --> pkg_invariants + pkg_client_ui_command --> pkg_client_connection + pkg_client_ui_command --> pkg_client_runtime + pkg_client_ui_command --> pkg_client_ui_conversation + pkg_client_ui_command --> pkg_client_ui_primitives + pkg_client_ui_command --> pkg_client_ui_slash + pkg_client_ui_command --> pkg_client_ui_slots + pkg_client_ui_command --> pkg_invariants pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -613,6 +608,14 @@ flowchart TD pkg_client_ui_goal --> pkg_client_ui_slots pkg_client_ui_goal --> pkg_goal pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_model --> pkg_client_connection + pkg_client_ui_model --> pkg_client_runtime + pkg_client_ui_model --> pkg_client_ui_command + pkg_client_ui_model --> pkg_client_ui_conversation + pkg_client_ui_model --> pkg_client_ui_primitives + pkg_client_ui_model --> pkg_client_ui_slash + pkg_client_ui_model --> pkg_client_ui_slots + pkg_client_ui_model --> pkg_invariants pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -1007,7 +1010,6 @@ flowchart TD | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | @@ -1021,10 +1023,8 @@ flowchart TD | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | @@ -1044,8 +1044,10 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | @@ -1066,7 +1068,7 @@ flowchart TD | [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | @@ -1086,6 +1088,7 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | From e0dd4028d06c8c22e0f50e7b686fc7d855106b0d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:08:16 +0800 Subject: [PATCH 29/32] test(web): refresh affected snapshot fixtures --- apps/web/tests/session-title.snapshot.ts | 2 +- apps/web/tests/slash-flow.snapshot.ts | 5 +++++ apps/web/tests/todo-display.snapshot.ts | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index cc08da6cc0..4667b85079 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -143,7 +143,7 @@ it('projects titles and routes the next turn through the selected model in the b // allowed for the next turn; stop the fixture's resident run before sending // the route-report prompt. fireEvent.click(screen.getByRole('button', { name: 'Stop generating' })) - const composer = await screen.findByPlaceholderText('Message the agent') + const composer = await screen.findByPlaceholderText('给智能体发消息') fireEvent.change(composer, { target: { value: 'report model' } }) fireEvent.keyDown(composer, { key: 'Enter' }) await screen.findByText('当前模型:openai/gpt-5 · 推理等级:max', {}, { timeout: 10_000 }) diff --git a/apps/web/tests/slash-flow.snapshot.ts b/apps/web/tests/slash-flow.snapshot.ts index ac47ca4cd0..c650d8e6de 100644 --- a/apps/web/tests/slash-flow.snapshot.ts +++ b/apps/web/tests/slash-flow.snapshot.ts @@ -57,12 +57,16 @@ class ResizeObserverStub { unobserve(): void {} } +// jsdom has no scrollIntoView; the slash menu follows its highlighted option. +const scrollIntoView = vi.fn() const win = window as FixtureWindow let unmount: (() => void) | undefined beforeEach(() => { localStorage.clear() document.title = 'DeepSeek Harness' + Element.prototype.scrollIntoView = scrollIntoView + scrollIntoView.mockClear() vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => { callback(0) }, 0) as unknown as number) @@ -80,6 +84,7 @@ afterEach(() => { document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) document.title = '' history.replaceState(null, '', '/') + Reflect.deleteProperty(Element.prototype, 'scrollIntoView') vi.unstubAllGlobals() }) diff --git a/apps/web/tests/todo-display.snapshot.ts b/apps/web/tests/todo-display.snapshot.ts index 2ae5f0e402..7d08d587e9 100644 --- a/apps/web/tests/todo-display.snapshot.ts +++ b/apps/web/tests/todo-display.snapshot.ts @@ -196,7 +196,7 @@ it('hides the plan strip when the next turn starts', async () => { await openFixtureSession() expect(document.querySelector('[data-testid="todo-panel"]')).not.toBeNull() - const composer = await screen.findByPlaceholderText('Message the agent', {}, { timeout: 10_000 }) + const composer = await screen.findByPlaceholderText('给智能体发消息', {}, { timeout: 10_000 }) fireEvent.change(composer, { target: { value: '下一轮清空计划' } }) fireEvent.keyDown(composer, { key: 'Enter' }) From 1a21fc3a06f816ba5d1722154ea41c6907114111 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:10:26 +0800 Subject: [PATCH 30/32] fix(agent-loop): quiesce scheduler failures --- ...-10-parallel-tool-call-execution.i18n.yaml | 6 +- ...2026-07-10-parallel-tool-call-execution.md | 10 ++- ...6-07-10-parallel-tool-call-execution.zh.md | 10 ++- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 8 +- docs/architecture.zh.md | 8 +- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/tool-calls.ts | 73 +++++++++++++------ .../core/agent-loop/tests/tool-calls.spec.ts | 65 ++++++++++++++++- 11 files changed, 145 insertions(+), 47 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml index 0c7363827e..28ca5e0ae4 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.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-10-parallel-tool-call-execution.md: c67ae61939a3e7974f9bf729058a57f5576308a1 -2026-07-10-parallel-tool-call-execution.zh.md: a80317aa951cbf3a9cae0651348c99712a4193d5 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md +2026-07-10-parallel-tool-call-execution.md: 19f5dc189821433052edfa72613980a2e94e2cae +2026-07-10-parallel-tool-call-execution.zh.md: e90e357180ae3684500adf7cba41c5fc1dac5743 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md index c67ae61939..19f5dc1898 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md @@ -10,7 +10,7 @@ An assistant message may contain several sibling `tool-call` blocks. Running the Concurrency is a host scheduling concern, not model-facing tool metadata. The loop needs to decide which calls may overlap without hardcoding tool names or exposing scheduler policy in the JSON schema. -The session log remains authoritative: every started call has an audit event, every started call receives a result, and model history observes results in the original call order regardless of completion order. +The session log remains authoritative: every started call has an audit event, ordinary completion and cancellation pair calls with results, and model history observes committed results in the original call order regardless of completion order. ## Decision @@ -46,7 +46,7 @@ Only dispatch and the tool body overlap. `tools/pre-execute` and `tools/post-exe Each started call appends `tool/call` immediately before its pre-execute gate. Completed dispatches occupy model-order slots, and a commit cursor appends `tool/result` and collects `additionalContexts` only when the next slot is ready. Live surfaces may show several pending calls, but results and post-tool context remain model-ordered. -An abort before a group starts records no calls from that group. An abort during a group stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and then ends the step through the existing abort path. Calls that never start have no audit event. +An abort before a group starts records no calls from that group. An abort during a group stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and then ends the step through the existing abort path. Calls that never start have no audit event. An unexpected scheduler failure stops new dispatches, waits for every already-started dispatch to settle, and rethrows the first failure. Because that failure is terminal internal state rather than a tool outcome, the loop does not invent tool results for rejected or uncommitted calls. Code Mode remains outside this scheduler because the model emits one native `run_code` call. `run_code` and its internal dispatch queue remain serial; native sibling calls in `mode: 'both'` use the normal scheduler. @@ -66,7 +66,7 @@ Filesystem read relies on a narrow recorder exception: its synchronous observati ## Verification -Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, live reclassification after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, and abort draining. First-party tests pin each parallel declaration. +Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, live reclassification after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, abort draining, and scheduler-failure quiescence. First-party tests pin each parallel declaration. Snapshot coverage pins the visible multi-call transcript: pending calls may overlap while completed results remain model-ordered. Code Mode coverage pins its serial boundary. No provider-backed e2e is required because scheduling is deterministic loop behavior. @@ -84,6 +84,8 @@ Snapshot coverage pins the visible multi-call transcript: pending calls may over **Expose staged methods or a scheduling waterfall.** Public `prepare` / `dispatch` / `finalize` methods or a `tools/execution-mode` event add extension surface before another consumer needs it. The loop uses an internal scheduler view, while `executionMode(exec)` leaves an insertion point for a policy seam. +**Convert scheduler failures into tool results.** AgentLoop cannot determine whether a rejected dispatch invoked the tool body; ToolRegistry owns body-invocation state and typed tool outcomes. Internal scheduler failures therefore remain terminal instead of being reclassified as `ABORTED` results. + **Start calls while the model streams.** This may reduce latency further but changes assistant-message authority, replay, and call/result pairing. The scheduler starts only after the assistant message is complete. **Use fixed-size windows.** Waiting for every call in one window before starting the next leaves capacity idle behind a slow call. The rolling pool preserves the cap without that delay. @@ -101,3 +103,5 @@ Ordered commits may hold a fast result behind a slow earlier sibling. This prese Concurrent external calls can compete for quota or process capacity. Providers own their capacity controls; the loop cap only limits calls from one agent step. Tool registration is a scheduling boundary. Registry mutations affect not-yet-started calls because the scheduler reclassifies after each barrier and before every pool replenishment. Already-started calls retain the scheduling decision under which they entered the pool. + +A terminal scheduler failure may leave recorded calls without results before the failed step closes. Waiting for live dispatches preserves quiescence without misreporting those internal failures as tool outcomes. diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md index a80317aa95..e90e357180 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md @@ -10,7 +10,7 @@ Status: implemented 并发属于宿主调度范畴,不是面向模型的工具元数据。循环需要在不硬编码工具名称、不向 JSON Schema 暴露调度策略的前提下,判断哪些调用可以重叠执行。 -会话日志仍是权威记录:每个已启动的调用都有审计事件,都会获得结果;无论完成顺序如何,模型历史都按原始调用顺序观察结果。 +会话日志仍是权威记录:每个已启动的调用都有审计事件,正常完成和取消都会使调用与结果配对;无论完成顺序如何,模型历史都按原始调用顺序观察已提交的结果。 ## 决策 @@ -46,7 +46,7 @@ Status: implemented 每个已启动的调用都会在进入 pre-execute 门禁之前立即追加 `tool/call`。已完成的派发占据模型顺序的槽位;提交游标只有在下一个槽位就绪时,才会追加 `tool/result` 并收集 `additionalContexts`。实时界面可以显示多个待处理调用,但结果和工具执行后的上下文仍按模型顺序排列。 -如果在一组启动前中止,系统不会记录该组的任何调用。如果在一组执行期间中止,系统会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,然后通过现有中止路径结束该步骤。从未启动的调用没有审计事件。 +如果在一组启动前中止,系统不会记录该组的任何调用。如果在一组执行期间中止,系统会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,然后通过现有中止路径结束该步骤。从未启动的调用没有审计事件。调度器发生意外故障时,会停止新的派发,等待每项已启动的派发结算,并重新抛出第一个故障。由于该故障是内部终态,而非工具结果,循环不会为被拒绝或未提交的调用虚构工具结果。 Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_code` 调用。`run_code` 及其内部派发队列仍按串行方式执行;`mode: 'both'` 中的原生并列调用使用常规调度器。 @@ -66,7 +66,7 @@ Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_c ## 验证 -单元测试覆盖固定了安全退化的分类、类型化参数验证、分组、屏障、替换注册表后的实时重新分类、滚动上限、独立执行对象、中间件顺序、有序结果与上下文,以及中止排空。第一方测试固定每项并行声明。 +单元测试覆盖固定了安全退化的分类、类型化参数验证、分组、屏障、替换注册表后的实时重新分类、滚动上限、独立执行对象、中间件顺序、有序结果与上下文、中止排空,以及调度器故障后的完全停稳。第一方测试固定每项并行声明。 快照覆盖固定了可见的多调用 transcript(文本记录):待处理调用可以重叠执行,已完成结果仍按模型顺序排列。Code Mode 覆盖固定其串行边界。此调度属于确定性循环行为,因此无需依赖提供方的 e2e 测试。 @@ -84,6 +84,8 @@ Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_c **公开分阶段方法或调度 waterfall。** 公开的 `prepare` / `dispatch` / `finalize` 方法或 `tools/execution-mode` 事件,会在出现另一个消费方之前扩大扩展接口。循环使用内部调度器视图,而 `executionMode(exec)` 为策略 seam 保留了插入点。 +**将调度器故障转换为工具结果。** AgentLoop 无法判断被拒绝的派发是否已调用工具主体;ToolRegistry 负责工具主体调用状态和类型化工具结果。因此,内部调度器故障保持为终态,而不会被重新分类为 `ABORTED` 结果。 + **在模型流式输出时启动调用。** 这可能进一步降低延迟,但会改变 assistant 消息的权威性、回放以及调用/结果配对。调度器只在 assistant 消息完成后才启动。 **使用固定大小的窗口。** 如果在启动下一个窗口前等待当前窗口的每个调用,一个缓慢调用就会使容量闲置。滚动池在保持上限的同时避免了这项延迟。 @@ -101,3 +103,5 @@ Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_c 并发外部调用可能会争用配额或进程容量。提供方负责自身容量控制;循环上限只限制一个 agent 步骤中的调用数量。 工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。已启动的调用保留它们进入池时所依据的调度决策。 + +终态调度器故障可能会在故障步骤关闭前留下已记录但没有结果的调用。等待仍在运行的派发可确保完全停稳,而不会将这些内部故障误报为工具结果。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index c296e10fb1..4bc87d1ee1 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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/architecture.md -architecture.md: 2ae982eba49b6dbd2365496915f9917071167813 -architecture.zh.md: abaef961504ff64dbcd1e8e8ba9bd002406fa7f4 +architecture.md: bb5414d6bb108056bf2ff25366e5afe261e1803a +architecture.zh.md: 6d39a320019a1bf87141be0874a5a20a51fc3fbb diff --git a/docs/architecture.md b/docs/architecture.md index 2ae982eba4..bb5414d6bb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -97,10 +97,10 @@ forever: 'assistant/chunk' 'assistant/message' schedule tool calls by ctx.tools.executionMode: - exclusive -> one-call barrier - parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start - each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute - each model-order result -> ordered tools/post-execute -> 'tool/result' + exclusive -> barrier + parallel -> rolling pool, <= maxParallelToolCalls; reclassify-at-start; scheduler failure -> stop starts, drain dispatches + start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute + model-order result -> ordered tools/post-execute -> 'tool/result' drain accepted tool context and steering 'step/end' continue for tools or steering unless a result concluded the turn diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index abaef96150..6d39a32001 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -97,10 +97,10 @@ forever: 'assistant/chunk' 'assistant/message' schedule tool calls by ctx.tools.executionMode: - exclusive -> one-call barrier - parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start - each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute - each model-order result -> ordered tools/post-execute -> 'tool/result' + exclusive -> barrier + parallel -> rolling pool, <= maxParallelToolCalls; reclassify-at-start; scheduler failure -> stop starts, drain dispatches + start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute + model-order result -> ordered tools/post-execute -> 'tool/result' drain accepted tool context and steering 'step/end' continue for tools or steering unless a result concluded the turn diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 938f374862..339c91a2ff 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/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/core/agent-loop/README.md -README.md: 6bb8b12af69f54c2a75cd672e4d3802887808c76 -README.zh.md: 80a01f3e3fdddba8c243cad28c43072148af1dd9 +README.md: 16d70cc06498fec1221b7872f988a0126f69f39f +README.zh.md: ce68595072766ebbf1e4cbd9f7c262cee36c5eff diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 6bb8b12af6..16d70cc064 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -67,7 +67,7 @@ After `agent/request` returns a provider/model call config, the loop asks `ctx.l Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and other extension failures close directly. Recovery receives the exact live error, immutable provider facts, immutable prior failures, the immutable retry policy of the adapter registration that served the request, and the turn signal after the failed step closes; the policy is absent if no final adapter served it. A handling listener returns `{ kind: 'retry' }`; the loop closes the failed turn with its error and opens one numbered retry turn without an intervening idle notification. Success clears the consecutive history, and an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. -Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, and retains their finalized result context without distinguishing the cancellation cause. +Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, and retains their finalized result context without distinguishing the cancellation cause. An internal scheduler failure stops new dispatches, waits for already-started dispatches, and reaches the turn error boundary without fabricating tool results. ### What belongs to plugins diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 80a01f3e3f..ce68595072 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -67,7 +67,7 @@ interface Config { 插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败以及带内的终止错误或中止结束才进入 `agent/request-error`;中间件、结果处理、工具及其他扩展失败会直接关闭轮次。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实、不可变的先前失败、为请求提供服务的适配器注册所对应的不可变重试策略,以及轮次信号;如果没有最终适配器为其提供服务,则该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;循环用其错误关闭失败轮次,并在不插入空闲通知的情况下开启一个编号重试轮次。成功会清除连续失败历史;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。 -在步骤内,独占调用形成屏障;并行安全调用使用有界滚动池,并在启动前重新分类。只有分发/主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用,drain 已启动的结果,并保留其已定案的结果上下文,不区分取消原因。 +在步骤内,独占调用形成屏障;并行安全调用使用有界滚动池,并在启动前重新分类。只有分发/主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用,drain 已启动的结果,并保留其已定案的结果上下文,不区分取消原因。内部调度器故障会停止新的分发,等待已启动的分发,然后在不虚构工具结果的情况下到达轮次错误边界。 ### 插件负责的内容 diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index b7ffdaf041..cd57a28f44 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -2,10 +2,12 @@ * Schedules one assistant step's tool calls. Exclusive calls form barriers; * parallel calls use a bounded rolling pool and are reclassified before start. * Dispatch may overlap, while policy, results, and result context remain - * model-ordered. Abort stops replenishment and drains started calls. + * model-ordered. Abort or an internal scheduler failure stops replenishment + * and drains started calls. * - * Each advertised call records a balanced `tool/call`/`tool/result` pair. Calls - * skipped after abort receive synthetic error results so replay stays valid. + * Abort records synthetic error results for skipped calls so replay stays + * valid. A terminal scheduler failure preserves already-recorded `tool/call` + * events without fabricating results. * @module dsh-agent-loop/tool-calls */ @@ -37,10 +39,13 @@ interface GroupOutcome { /** * Schedule one assistant step's tool calls by their live concurrency mode. - * Started calls receive ordered results. Abort drains them, records synthetic - * results for unstarted calls, and returns with the signal still aborted after - * accepting started-call context through the caller-supplied acceptor (the - * machine stages it on its outbox for the next step boundary). + * Ordinary completion and abort commit started-call results in order. Abort + * drains them, records synthetic results for unstarted calls, and returns with + * the signal still aborted after accepting started-call context through the + * caller-supplied acceptor (the machine stages it on its outbox for the next + * step boundary). An internal scheduler failure stops new dispatches, drains + * already-started dispatches, and rejects with the first failure without + * fabricating tool results. * The committed step's AgentLoop driver boundary supplies the initiating Agent * that becomes each explicit {@link ToolExecutionInput.agent}. * @@ -110,7 +115,8 @@ function parseArguments(raw: string): unknown { * drain and remains for the caller's next barrier. Results and contexts commit * in model order. Abort stops starts, drains and commits started calls, accepts * their contexts into the owning batch, records results for skipped calls, and - * returns an aborted outcome. + * returns an aborted outcome. Scheduler failure drains dispatches without + * committing synthetic recovery results. */ async function runGroup( ctx: Context, @@ -131,6 +137,10 @@ async function runGroup( let started = 0 let aborted: boolean = signal.aborted let concluded = false + let schedulerFailure: { error: unknown } | undefined + const throwSchedulerFailure = (): void => { + if (schedulerFailure !== undefined) throw schedulerFailure.error + } // `committed` advances only across contiguous model-order slots. const commitReady = async (): Promise => { @@ -157,12 +167,19 @@ async function runGroup( callSeqs[index] = appendToolCall(session, turn, step, call.block) started++ const prepared = await ctx.tools[TOOL_REGISTRY_SCHEDULER].prepare(call.exec) + throwSchedulerFailure() switch (prepared.kind) { case 'dispatch': { - const promise = ctx.tools[TOOL_REGISTRY_SCHEDULER].dispatch(prepared.exec).then((outcome) => { - slots[index] = { exec: prepared.exec, result: outcome.result, needsPost: outcome.kind === 'post-result' } - return index - }) + const promise = ctx.tools[TOOL_REGISTRY_SCHEDULER].dispatch(prepared.exec).then( + (outcome) => { + slots[index] = { exec: prepared.exec, result: outcome.result, needsPost: outcome.kind === 'post-result' } + return index + }, + (error: unknown) => { + schedulerFailure ??= { error } + return index + }, + ) inFlight.set(index, promise) break } @@ -187,24 +204,34 @@ async function runGroup( && ctx.tools.executionMode(nextCall.exec).kind !== 'parallel') break await startCall(nextToStart) nextToStart++ + throwSchedulerFailure() await commitReady() + throwSchedulerFailure() // Abort may arrive while pre-execute awaits. if (signal.aborted) aborted = true } } - // Ordered pre-execute may await; only dispatch/body overlaps. - // TODO: Drain every started call before rethrowing a scheduler error; tool - // bodies must not outlive the failed turn. - await fillPool() - while (inFlight.size > 0) { - const settledIndex = await Promise.race(inFlight.values()) - inFlight.delete(settledIndex) - await commitReady() - // Abort may arrive while a tool or ordered commit awaits. - - if (signal.aborted) aborted = true + // Ordered pre-execute may await; only dispatch/body overlaps. A scheduler + // failure stops new dispatches and reaches the turn boundary after every + // already-started dispatch settles. + try { await fillPool() + while (inFlight.size > 0) { + const settledIndex = await Promise.race(inFlight.values()) + inFlight.delete(settledIndex) + throwSchedulerFailure() + await commitReady() + throwSchedulerFailure() + // Abort may arrive while a tool or ordered commit awaits. + + if (signal.aborted) aborted = true + await fillPool() + } + } catch (error: unknown) { + schedulerFailure ??= { error } + await Promise.allSettled(inFlight.values()) + throw schedulerFailure.error } if (aborted) { diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index a0266fd3f8..dd086d8e7f 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -9,7 +9,7 @@ import { createUserMessage, CallId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import LlmService from '@deepseek-ai/dsh-llm' -import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH, TOOL_REGISTRY_SCHEDULER, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -613,3 +613,66 @@ describe('tool-call scheduler: abort handling', () => { }) }) }) + +describe('tool-call scheduler: failure quiescence', () => { + it('stops new dispatches and drains started bodies before surfacing the first failure', async () => { + const adapter = new MockAdapter([ + multiCall([ + { id: 'c1', name: 'p', args: { id: '1' } }, + { id: 'c2', name: 'p', args: { id: '2' } }, + { id: 'c3', name: 'p', args: { id: '3' } }, + ]), + ]) + const ctx = await harness(adapter, 3) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + // The registry contains expected failures as results; replace its internal + // view only to inject the invariant violation this boundary must contain. + const scheduler = ctx.tools[TOOL_REGISTRY_SCHEDULER] + const prepare = scheduler.prepare.bind(scheduler) + const dispatch = scheduler.dispatch.bind(scheduler) + const prepareGate = Promise.withResolvers() + let thirdPrepareEntered = false + scheduler.prepare = async (exec) => { + const prepared = await prepare(exec) + if (exec.callId === CallId('c3')) { + thirdPrepareEntered = true + await prepareGate.promise + } + return prepared + } + const schedulerError = new Error('scheduler exploded') + const drainedError = new Error('sibling failed while draining') + let rejectFirst: ((error: Error) => void) | undefined + scheduler.dispatch = exec => exec.callId === CallId('c1') + ? new Promise((_resolve, reject) => { rejectFirst = reject }) + : dispatch(exec).then(() => { throw drainedError }) + const agent = ctx.agentLoop.create(SessionId('scheduler-failure'), { provider: 'mock', model: 'mock' }) + const errors: unknown[] = [] + ctx.on('agent/error', (subject, _turn, _step, error) => { + if (subject === agent) errors.push(error) + }) + let idle = false + const idlePromise = waitForIdle(ctx, agent).then(() => { idle = true }) + + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) + await until(() => gated.started.includes('2') && thirdPrepareEntered && rejectFirst !== undefined) + rejectFirst?.(schedulerError) + await new Promise(resolve => setImmediate(resolve)) + prepareGate.resolve(undefined) + await new Promise(resolve => setImmediate(resolve)) + + const startedBeforeDrain = [...gated.started] + const idleBeforeDrain = idle + const errorsBeforeDrain = [...errors] + for (const id of gated.pending()) gated.release(id) + await idlePromise + + expect(startedBeforeDrain).toEqual(['2']) + expect(idleBeforeDrain).toBe(false) + expect(errorsBeforeDrain).toEqual([]) + expect(gated.pending()).toEqual([]) + expect(errors).toEqual([schedulerError]) + expect(errors[0]).toBe(schedulerError) + }) +}) From c10c72837b0d50446ac72b4a2f5df3883a21169a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:13:57 +0800 Subject: [PATCH 31/32] fix(skill): close watchers after probe disposal --- packages/skill/skill-local/src/index.ts | 4 ++ .../tests/skill-local-watcher.spec.ts | 58 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index f124a10f62..add2f8792c 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -489,6 +489,10 @@ class SkillWatchManager { let ready = false const readiness = Promise.withResolvers() const signal = this.lifecycle.signal + if (signal.aborted) { + await this.closeWatcher(handle) + signal.throwIfAborted() + } const onAbort = (): void => { readiness.reject(signal.reason) } signal.addEventListener('abort', onAbort, { once: true }) const onError = (error: unknown): void => { diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts index 4f9cfc59ef..14e3c07e41 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -18,12 +18,18 @@ interface FakeWatchFileControl { listener(current: Stats, previous: Stats): void } +interface FakeStatGate { + started: PromiseWithResolvers + release: PromiseWithResolvers +} + const watcherHarness = vi.hoisted(() => ({ watchers: [] as FakeWatcherControl[], startupErrors: [] as Error[], closeErrors: 0, deferredReady: 0, watchFiles: [] as FakeWatchFileControl[], + statGates: [] as FakeStatGate[], })) vi.mock('node:fs', async (importOriginal) => { @@ -40,6 +46,21 @@ vi.mock('node:fs', async (importOriginal) => { } }) +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async stat(...args: Parameters) { + const gate = watcherHarness.statGates.shift() + if (gate !== undefined) { + gate.started.resolve(undefined) + await gate.release.promise + } + return await actual.stat(...args) + }, + } +}) + vi.mock('chokidar', () => ({ default: { watch(_path: unknown, options: Record) { @@ -89,6 +110,7 @@ beforeEach(() => { watcherHarness.closeErrors = 0 watcherHarness.deferredReady = 0 watcherHarness.watchFiles.length = 0 + watcherHarness.statGates.length = 0 }) describe('skill-local watcher failures', () => { @@ -279,6 +301,42 @@ describe('skill-local watcher failures', () => { expect(first.closeCalls).toBeGreaterThan(0) }) + it('closes an opening watcher when disposal wins the mode probe', async () => { + const home = await tempDir('skill-watch-probe-dispose') + const root = join(home, '.dsh/skills') + await writeSkill(root, 'racing-skill') + watcherHarness.deferredReady = 1 + const statGate: FakeStatGate = { + started: Promise.withResolvers(), + release: Promise.withResolvers(), + } + watcherHarness.statGates.push(statGate) + const ctx = new Context() + await ctx.plugin(SkillService) + let provider!: InstanceType + const disposeProvider = ctx.skills.registerProvider((control) => { + provider = new SkillLocal.LocalSkillProvider(ctx, control, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchPollIntervalMs: 10, + watchStabilityThresholdMs: 20, + }) + return provider + }) + + const discovery = provider.list({}) + await statGate.started.promise + const disposal = provider.dispose() + statGate.release.resolve(undefined) + + await expect(discovery).rejects.toThrow('skill-local watcher disposed') + await disposal + expect(watcherHarness.watchers).toHaveLength(1) + expect(watcherHarness.watchers[0]?.closeCalls).toBeGreaterThan(0) + disposeProvider() + }) + it('contains an opening watcher rejection during provider teardown', async () => { const home = await tempDir('skill-watch-opening-reject') const root = join(home, '.dsh/skills') From db543ffa859ec6cf3b3fdfde1809b3bb49232bb3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:27:49 +0800 Subject: [PATCH 32/32] fix(skill): bound catalog discovery retries --- ...-07-27-skill-catalog-hot-refresh.i18n.yaml | 4 +-- .../2026-07-27-skill-catalog-hot-refresh.md | 4 +-- ...2026-07-27-skill-catalog-hot-refresh.zh.md | 4 +-- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 6 ++-- docs/core-data-structures/skills.i18n.yaml | 4 +-- docs/core-data-structures/skills.md | 8 ++--- docs/core-data-structures/skills.zh.md | 8 ++--- docs/event-producer-consumer.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/skill/skill/README.i18n.yaml | 4 +-- packages/skill/skill/README.md | 4 +-- packages/skill/skill/README.zh.md | 4 +-- packages/skill/skill/src/index.ts | 18 +++++++--- packages/skill/skill/tests/skill.spec.ts | 34 +++++++++++++++++++ 16 files changed, 76 insertions(+), 34 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml index d3ecfe624c..9a3df405be 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.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-27-skill-catalog-hot-refresh.md -2026-07-27-skill-catalog-hot-refresh.md: 7c81b287cecde60c42f7e1e3ec171244ffc18aa6 -2026-07-27-skill-catalog-hot-refresh.zh.md: ca570a3c6a0402e6764824e3101e15054769b85d +2026-07-27-skill-catalog-hot-refresh.md: 8e70fb10e7da4292325b72f3a0392bef2271738c +2026-07-27-skill-catalog-hot-refresh.zh.md: 9a6b6d944baa4a9cc4dddb5158fdcbac2b05f5db diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md index 7c81b287ce..8e70fb10e7 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md @@ -12,7 +12,7 @@ Filesystem updates are also non-atomic from the observer's perspective. An edito ## Decision -The skill capability separates catalog membership from instruction-body loading. `ctx.skills.snapshot()` returns summaries plus a completeness bit. `ctx.skills.registerProvider(factory)` gives the synchronous factory one registration-scoped `{ signal, invalidate }` control: `invalidate()` dirties only that exact active registration and discards completed catalog caches, while the signal aborts when registration fails or is disposed. Provider arrays are complete-discovery shorthand; an explicit incomplete observation can retain readable candidates for direct loads without becoming cacheable or authoritative for model-facing consumers. A provider or runtime generation change during discovery retries before returning. A late invalidation after disposal or replacement is a no-op because the capability has been revoked. +The skill capability separates catalog membership from instruction-body loading. `ctx.skills.snapshot()` returns summaries plus a completeness bit. `ctx.skills.registerProvider(factory)` gives the synchronous factory one registration-scoped `{ signal, invalidate }` control: `invalidate()` dirties only that exact active registration and discards completed catalog caches, while the signal aborts when registration fails or is disposed. Provider arrays are complete-discovery shorthand; an explicit incomplete observation can retain readable candidates for direct loads without becoming cacheable or authoritative for model-facing consumers. A provider or runtime generation change during discovery retries once; if the retry is also superseded, the latest candidates return as an incomplete, uncached observation. A late invalidation after disposal or replacement is a no-op because the capability has been revoked. `@deepseek-ai/dsh-skill-local` directly depends on Chokidar and observes catalog-relevant host paths. Existing roots watch direct skill bundle directories, flat Markdown entries, and direct `SKILL.md` entry files. Additions, removals, and directory changes invalidate membership; file changes support frontmatter `name` and `description` refresh. Resource files below a bundle are ignored. Events in one microtask batch coalesce to one invalidation. Project watchers use a bounded least-recently-observed set. @@ -26,7 +26,7 @@ Instruction bodies keep progressive disclosure. Every `skill(name)` call asks th ## Verification -Registry tests pin registration-scoped invalidation, revocation, signal abort, contained observer failures, incomplete candidates, generation retries, and stale-name rejection. Local-provider tests cover bundle and flat-file creation, removal, rename, root creation/deletion/recreation including an unobserved root `unlinkDir`, description changes, body-only edits, first-party observation, symlinks, polling options, persistent watcher failures with loadable candidates, event coalescing, bounded projects, teardown, and transient reads. Tool tests pin full replacement messages, empty tombstones, digest stability for body-only edits, incomplete-state retention, visibility, and resume metadata. TUI tests pin last-complete retention, authoritative empty removal, latest-wins refresh, teardown, and the already-open slash-draft race; a real Loader/PTY smoke adds a local skill after startup and observes its completion without restarting. A keyless assembled agent-spine snapshot creates a project skill through model-facing filesystem tools, observes its replacement catalog on the next request, and loads its current body with the real `skill` tool. +Registry tests pin registration-scoped invalidation, revocation, signal abort, contained observer failures, incomplete candidates, bounded generation retries, and stale-name rejection. Local-provider tests cover bundle and flat-file creation, removal, rename, root creation/deletion/recreation including an unobserved root `unlinkDir`, description changes, body-only edits, first-party observation, symlinks, polling options, persistent watcher failures with loadable candidates, event coalescing, bounded projects, teardown, and transient reads. Tool tests pin full replacement messages, empty tombstones, digest stability for body-only edits, incomplete-state retention, visibility, and resume metadata. TUI tests pin last-complete retention, authoritative empty removal, latest-wins refresh, teardown, and the already-open slash-draft race; a real Loader/PTY smoke adds a local skill after startup and observes its completion without restarting. A keyless assembled agent-spine snapshot creates a project skill through model-facing filesystem tools, observes its replacement catalog on the next request, and loads its current body with the real `skill` tool. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md index ca570a3c6a..9a6b6d944b 100644 --- a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md @@ -12,7 +12,7 @@ skill(技能)摘要是模型的路由输入,但本地 skill 可在会话 ## 决策 -skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snapshot()` 返回摘要及一个完整性位。`ctx.skills.registerProvider(factory)` 会向同步工厂提供一项注册作用域内的 `{ signal, invalidate }` 控制能力:`invalidate()` 只会将该精确活动注册标记为脏,并丢弃已完成目录缓存;注册失败或释放时,信号会中止。提供方返回的数组是完整发现的简写形式;显式的不完整观测可以保留可读候选项供直接加载,但不能缓存,也不能作为面向模型消费方的权威结果。在发现期间,如果提供方或运行时 generation 发生变化,系统会先重试再返回。资源释放或替换后的延迟失效操作不会执行任何操作,因为该能力已被撤销。 +skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snapshot()` 返回摘要及一个完整性位。`ctx.skills.registerProvider(factory)` 会向同步工厂提供一项注册作用域内的 `{ signal, invalidate }` 控制能力:`invalidate()` 只会将该精确活动注册标记为脏,并丢弃已完成目录缓存;注册失败或释放时,信号会中止。提供方返回的数组是完整发现的简写形式;显式的不完整观测可以保留可读候选项供直接加载,但不能缓存,也不能作为面向模型消费方的权威结果。在发现期间,如果提供方或运行时 generation 发生变化,系统会重试一次;如果这次重试也被后续修订取代,则最新候选项会作为不完整且不缓存的观测返回。资源释放或替换后的延迟失效操作不会执行任何操作,因为该能力已被撤销。 `@deepseek-ai/dsh-skill-local` 直接依赖 Chokidar,并观察与目录相关的宿主路径。已有根目录会监视其直属 skill bundle 目录、平铺的 Markdown 条目和直属 `SKILL.md` 条目文件。新增、移除和目录变更会使成员关系失效;文件变更还支持刷新 frontmatter 中的 `name` 和 `description`。bundle 内更深层的资源文件会被忽略。同一微任务批次中的事件会合并为一次失效。项目 watcher 使用有界集合,并按最久未观察顺序淘汰。 @@ -26,7 +26,7 @@ TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills ## 验证 -注册表测试固定了注册作用域内的失效、能力撤销、信号中止、监听器失败隔离、不完整候选项、generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建(包括未观测到根目录 `unlinkDir` 事件的情形)、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、候选项仍可加载的持续 watcher 失败、事件合并、项目 watcher 容量上限、资源销毁和暂时读取。工具测试固定了完整替换消息、空 tombstone、仅修改正文时 digest 稳定、不完整状态保留、可见性和恢复元数据。TUI 测试固定了上一份完整结果保留、权威空结果清除、刷新时以最新结果为准、资源销毁和已打开斜杠草稿的竞态;一项使用真实 Loader/PTY 的 smoke 测试会在启动后添加本地 skill,并观察其补全项出现,而无需重启。一个无密钥、装配完成的 agent-spine 快照测试通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并使用真实 `skill` 工具加载当前正文。 +注册表测试固定了注册作用域内的失效、能力撤销、信号中止、监听器失败隔离、不完整候选项、有界 generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建(包括未观测到根目录 `unlinkDir` 事件的情形)、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、候选项仍可加载的持续 watcher 失败、事件合并、项目 watcher 容量上限、资源销毁和暂时读取。工具测试固定了完整替换消息、空 tombstone、仅修改正文时 digest 稳定、不完整状态保留、可见性和恢复元数据。TUI 测试固定了上一份完整结果保留、权威空结果清除、刷新时以最新结果为准、资源销毁和已打开斜杠草稿的竞态;一项使用真实 Loader/PTY 的 smoke 测试会在启动后添加本地 skill,并观察其补全项出现,而无需重启。一个无密钥、装配完成的 agent-spine 快照测试通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并使用真实 `skill` 工具加载当前正文。 ## 考虑过的替代方案 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 74c0a43a7b..3ff1e3c028 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1241,7 +1241,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:138`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:139`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index f5b1f3d70d..07d4d52ddb 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -659,7 +659,7 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:156`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:157`](../../packages/skill/skill/src/index.ts) ## `slash/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 50b0fd5768..d6166f1c8d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1660,11 +1660,11 @@ register(skill: SkillRegistration): () => void async list(options: SkillLookupOptions = {}): Promise /** - * Observe the current model-invocable catalog and whether all providers completed discovery. + * Observe the current model-invocable catalog and whether discovery completed within a stable revision. * Incomplete observations are never cached, allowing consumers to retain last-good state and * retry on their next request boundary. * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. - * @returns sorted summaries plus provider-completeness state. + * @returns sorted summaries plus discovery-completeness state. */ async snapshot(options: SkillLookupOptions = {}): Promise @@ -1681,7 +1681,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promise', - jsDoc: '/**\n * Observe the current model-invocable catalog and whether all providers completed discovery.\n * Incomplete observations are never cached, allowing consumers to retain last-good state and\n * retry on their next request boundary.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns sorted summaries plus provider-completeness state.\n */', + jsDoc: '/**\n * Observe the current model-invocable catalog and whether discovery completed within a stable revision.\n * Incomplete observations are never cached, allowing consumers to retain last-good state and\n * retry on their next request boundary.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns sorted summaries plus discovery-completeness state.\n */', }, { signature: 'async get(name: string, options: SkillLookupOptions = {}): Promise', diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index f5af86ea54..d39e7a1505 100644 --- a/packages/skill/skill/README.i18n.yaml +++ b/packages/skill/skill/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/skill/skill/README.md -README.md: 9813338dcec82fc2db7e149be1bd80ec5239684d -README.zh.md: 70558875a2d223367e2b35f326fba4b5d966fae3 +README.md: 65ff110999ea416648f3d676eb813dd4ceb194f8 +README.zh.md: 79e77d2a2846489125ba0ecaafc138172ba2fd86 diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 9813338dce..65ff110999 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -11,7 +11,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ### Public API - `ctx.skills.registerProvider(create): () => void` Calls a synchronous provider factory with `{ signal, invalidate }`, then registers its readonly result by unique `provider.name`. Duplicate names throw, `runtime` is reserved, and failed registration aborts the signal. The exact Cordis disposer unregisters the provider, aborts the signal, and preserves ordered composite teardown. -- `ctx.skills.snapshot({ cwd?, signal? })` Returns `{ skills, complete }`. `complete` is false when any provider rejects or explicitly reports incomplete discovery; candidates supplied with an incomplete observation remain in this result, which is never cached. +- `ctx.skills.snapshot({ cwd?, signal? })` Returns `{ skills, complete }`. `complete` is false when any provider rejects or explicitly reports incomplete discovery, or when a second catalog revision races the bounded retry; candidates supplied by that observation remain in this result, which is never cached. - `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns model-invocable summaries for the current workspace, merged across providers and sorted by name. - `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it, including disabled-for-model skills. - `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown. @@ -32,7 +32,7 @@ A provider factory runs synchronously and receives one registration-scoped contr The registry validates candidates before caching and definitions before returning them. The winning provider receives the same candidate and opaque `locator` it returned from `list()`, allowing backend-specific file, URL, id, or version handles. Callers and providers must preserve the readonly contract. -Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure and omitted. An explicit incomplete observation still contributes its candidates for `list()` and `get()`, but makes the aggregate snapshot incomplete and uncacheable. A provider or runtime revision change discards an in-flight result and retries before returning. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name. +Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure and omitted. An explicit incomplete observation still contributes its candidates for `list()` and `get()`, but makes the aggregate snapshot incomplete and uncacheable. A provider or runtime revision change discards an in-flight result and retries once. If the retry is also superseded, its candidates are returned incomplete and uncached so a continuously invalidating provider cannot monopolize the caller. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name. Definitions remain progressively loaded. `get()` asks the winning provider for the body on every call rather than caching it in this registry. If the returned definition has a different name from the selected candidate, the stale selection is rejected and the registry internally invalidates that exact provider so the next snapshot rediscovers its catalog. diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md index 70558875a2..79e77d2a28 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -11,7 +11,7 @@ ### 公开 API - `ctx.skills.registerProvider(create): () => void` 调用同步提供方工厂并向其传入 `{ signal, invalidate }`,随后使用唯一 `provider.name` 注册其只读结果。重复提供方名称会抛错,`runtime` 为保留名称;注册失败会中止信号。精确的 Cordis disposer 会注销提供方、中止信号,并保持有序组合拆卸。 -- `ctx.skills.snapshot({ cwd?, signal? })` 返回 `{ skills, complete }`。任一提供方调用被拒绝或显式报告发现不完整时,`complete` 为 false;不完整观测提供的候选项仍保留在该结果中,但该结果绝不缓存。 +- `ctx.skills.snapshot({ cwd?, signal? })` 返回 `{ skills, complete }`。任一提供方调用被拒绝或显式报告发现不完整,或有界重试期间又发生目录修订时,`complete` 为 false;该次观测提供的候选项仍保留在此结果中,但该结果绝不缓存。 - `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中模型可调用的摘要;这些摘要跨提供方合并,并按名称排序。 - `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后将其返回,包括已对模型禁用的 skill。 - `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。 @@ -32,7 +32,7 @@ 注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读契约。 -违反契约时会快速失败。`list()` 返回的 Promise 被拒绝会被视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()` 和 `get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变化时,会丢弃正在进行的结果并在返回前重试。重复名称依次按 rank、提供方注册顺序和提供方本地顺序解决冲突。摘要按 skill 名称排序。 +违反契约时会快速失败。`list()` 返回的 Promise 被拒绝会被视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()` 和 `get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变化时,会丢弃正在进行的结果并重试一次。如果这次重试也被后续修订取代,则返回其候选项,并将结果标为不完整且不予缓存,以免持续触发失效的提供方一直占用调用方。重复名称依次按 rank、提供方注册顺序和提供方本地顺序解决冲突。摘要按 skill 名称排序。 定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并由注册表在内部使该精确提供方失效,以便下一次快照重新发现其目录。 diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index 6c12503bd9..2cc37a6683 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -15,6 +15,7 @@ import type Schema from 'schemastery' const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ const DEFAULT_COLLECT_CACHE_ENTRIES = 128 +const MAX_COLLECT_ATTEMPTS = 2 const RUNTIME_PROVIDER = 'runtime' const RUNTIME_RANK = 250 @@ -87,11 +88,11 @@ export interface SkillLookupOptions { readonly signal?: AbortSignal | undefined } -/** One catalog observation plus whether every registered provider completed discovery. */ +/** One catalog observation plus whether discovery completed within a stable catalog revision. */ export interface SkillCatalogSnapshot { /** Sorted model-invocable summaries collected in this observation. */ readonly skills: SkillSummary[] - /** Whether every registered provider completed discovery for this observation. */ + /** Whether every registered provider completed without a concurrent catalog revision. */ readonly complete: boolean } @@ -286,11 +287,11 @@ export class SkillService extends Service { } /** - * Observe the current model-invocable catalog and whether all providers completed discovery. + * Observe the current model-invocable catalog and whether discovery completed within a stable revision. * Incomplete observations are never cached, allowing consumers to retain last-good state and * retry on their next request boundary. * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. - * @returns sorted summaries plus provider-completeness state. + * @returns sorted summaries plus discovery-completeness state. */ async snapshot(options: SkillLookupOptions = {}): Promise { const collected = await this.collect(options) @@ -333,6 +334,7 @@ export class SkillService extends Service { private async collect(options: SkillLookupOptions): Promise { throwIfAborted(options.signal) + let attempt = 1 while (true) { const providerRevision = this.providerRevision const runtimeRevision = this.runtimeRevision @@ -342,7 +344,13 @@ export class SkillService extends Service { const result = await this.collectFresh(options) throwIfAborted(options.signal) - if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) continue + if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) { + if (attempt < MAX_COLLECT_ATTEMPTS) { + attempt += 1 + continue + } + return { entries: result.entries, cacheable: false } + } if (result.cacheable) { this.collectCache.set(key, result.entries) if (this.collectCache.size > this.collectCacheMaxEntries) { diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index cb9fc8e0c9..ac3df3aa35 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -748,6 +748,40 @@ describe('SkillService registry', () => { expect(provider.listCalls).toBe(2) }) + it('bounds repeated in-flight invalidation and leaves the result uncached', async () => { + const ctx = new Context() + await ctx.plugin(SkillService) + let listCalls = 0 + ctx.skills.registerProvider(control => ({ + name: 'self-invalidating', + async list() { + listCalls += 1 + control.invalidate() + return [{ + ...memorySkill('bounded-skill', `Attempt ${listCalls}`, 10), + provider: 'self-invalidating', + }] + }, + async get() { + return undefined + }, + })) + + expect(await ctx.skills.snapshot()).toEqual({ + skills: [{ + name: 'bounded-skill', + description: 'Attempt 2', + provider: 'self-invalidating', + source: 'memory', + }], + complete: false, + }) + expect(listCalls).toBe(2) + + expect((await ctx.skills.snapshot()).skills[0]?.description).toBe('Attempt 4') + expect(listCalls).toBe(4) + }) + it('invalidates a provider whose loaded definition changed identity', async () => { const ctx = new Context() await ctx.plugin(SkillService)