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/.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/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..5689b73bf8 --- /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: 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 new file mode 100644 index 0000000000..7ceae95daf --- /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 + +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, 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 + +**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 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 new file mode 100644 index 0000000000..d925d82f94 --- /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 + +只要存在会话,`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`:在限高 textarea 仍能沿该方向滚动时保留原生手势;仅在自身边缘才 `preventDefault` 并将 `deltaY` 施加到宿主。 + +## 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 保持同一 textarea DOM 节点(assembled slash-flow 快照)以及 InputHub 草稿。 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/.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..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: 1bd58e35ec625fb0b04c0c119ce425ff30a64881 -2026-07-21-log-backed-session-titles.zh.md: 37ec95efbca334f71d19d2bc3e18c22d50d9b5fb +2026-07-21-log-backed-session-titles.md: 81ac687c6f55dd0ca1eaeb9d84c811edcfe17b5c +2026-07-21-log-backed-session-titles.zh.md: b0c7e9d76a1b9365fa16dcb223b390b5aec3e174 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..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 @@ -36,9 +36,13 @@ Model providers require explicit word, CJK-character, input-byte, output-token, Automatic provider failures are nonfatal warnings and retain the latest title. Explicit refresh failures reject to the caller. Output must be non-empty text with unique ordered seqs drawn from the fixed request; the service normalizes and byte-limits it before log acceptance. +### Explicit rename + +`rename(session, title)` accepts a user title synchronously: it normalizes the text under the accepted-title byte limit, supersedes in-flight automatic work, and appends a `session/title` event with the third source kind, `user`. A user-sourced latest title pins the session: `onUserMessage` schedules no automatic revision while it stands, under either cadence. An explicit `refresh()` remains the deliberate unpin — it appends a provider or fallback event over the pinned one whenever a replacement title is derivable (an underivable fallback, e.g. under a tiny byte cap, leaves the pin standing). The Web host exposes this as the `session.rename` unary method (resuming cold sessions first) and returns the normalized title plus its event seq so the client settles its `title` projection cell before the push frame arrives. + ### Forks and consumers -A fork inherits seed title events unchanged, like the rest of its source log. The first-message provider does not automatically retitle a fork. The all-messages provider may append a child-owned revision after a later child prompt, using inherited and new eligible messages. +A fork inherits seed title events unchanged, like the rest of its source log — a pinned (user-sourced) title stays pinned in the child until an explicit refresh. The first-message provider does not automatically retitle a fork. The all-messages provider may append a child-owned revision after a later child prompt, using inherited and new eligible messages. `ctx.sessionQuery.readTitle()` folds one live-preferred or persisted log without loading titles during `listSessions()`. The TUI uses the latest title as its header subtitle and sets the terminal window title to `` 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..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 @@ -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/.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/.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..9a3df405be --- /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: 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 new file mode 100644 index 0000000000..8e70fb10e7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md @@ -0,0 +1,48 @@ +# 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. `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. + +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. + +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 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 + +- **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. +- **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 + +- 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 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 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 new file mode 100644 index 0000000000..9a6b6d944b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md @@ -0,0 +1,48 @@ +# 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.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,并忽略延迟回调。 + +`@deepseek-ai/dsh-tool-skill` 在 `agent/step` 首次观察到非空完整目录时,将该目录注入为一条持久且带来源的 `user/message`。每次 `agent/step`,它都会应用 `skill` 工具的精确可见性,对 `` 标签之间精确渲染的文本计算哈希,并从后向前扫描只读会话事件且不复制,以查找该插件发布的最新一条可识别且仍可见的目录。digest 变化时,插件通过 `agent.inject()` 追加一份持久的完整替换目录;所有 skill 消失时,也会追加显式空目录。如果没有目录仍然可见,但历史事件中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录,包括空 tombstone。如果当前目录为空且历史上从未发布目录,则不发送任何内容;不完整快照则保留最后一次完整的模型视图。反向扫描通常在最新且仍可见的目录处停止;当压缩遮蔽所有目录时,它会以一次 O(session-events) 扫描的成本确认这一事实。 + +TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills/change` 不携带 diff;TUI 会为活动会话的 cwd 重新获取 `snapshot()`,仅应用最新的完整结果,并在观测不完整时保留先前命令。完整的空结果会清除陈旧补全项。pi-tui 在其提供方被替换时会关闭自动补全,因此如果目录在用户输入斜杠命令名称期间到达,还会触发一次仅用于更新建议的当前草稿重查。 + +指令正文继续采用渐进式披露。每次调用 `skill(name)` 时,系统都会要求提供方重新读取并解析当前文件;不存在正文缓存、哈希、修订或主动通知。先前记录的工具结果保持不变。如果加载后的 frontmatter 名称不再匹配所选候选项,注册表会拒绝这个陈旧名称,并使该提供方失效,以便后续目录观察发布新名称。 + +## 验证 + +注册表测试固定了注册作用域内的失效、能力撤销、信号中止、监听器失败隔离、不完整候选项、有界 generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建(包括未观测到根目录 `unlinkDir` 事件的情形)、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、候选项仍可加载的持续 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 资源**:不予采纳,因为参考资料、脚本和产物都是按需加载的,不影响类别列表。宽泛的递归监视会增加失效、描述符压力和平台差异,却不能改善路由。 +- **将部分发现或失败发现发布为新目录**:不予采纳,因为暂时读取失败不能证明文件已删除。完整性位让面向模型的消费方保留最后一次完整目录,直到完整观察成功。 +- **保留公开的 `invalidateProvider(provider)`**:不予采纳,因为这会公开一项注册表变更方法,并要求调用方重复提供注册表已经持有的身份。发给工厂的闭包会将失效绑定到单个注册,并在释放后失去作用,因此观察方既不需要访问注册表,也不需要提供方身份。 +- **现在提取通用 Cordis 文件监视服务**:暂缓,直到另一个消费方确立可复用的服务契约。本地提供方标出了其 Chokidar 和缺失根目录观测边界,以便后续提取;skill 路径过滤以及对提供方失效闭包的调用仍属于 skill 专用逻辑。 + +## 影响 + +- 新增、删除和重命名的本地 skill 会在模型步骤边界变得可见,无需重启 agent(智能体),即使 skill 根目录在启动时不存在也一样。 +- TUI 的 `/skill:` 补全会收敛到同一份完整目录,而不会让每次按键都阻塞于发现;打开的斜杠命令名称草稿会在目录到达时刷新。 +- 目录消息采用仅追加、日志记录和全量列表快照。它们会保留较早的可重用 token;替换目录会显式停用陈旧名称,每次 digest 实际变化时,token 成本与当前目录大小成正比。 +- 仅修改正文不会产生目录消息。后续工具调用会看到当前内容,而先前工具结果仍准确记录模型之前加载的内容。 +- 缺失根目录轮询和 Chokidar 引入一个有人维护的运行时依赖、宿主 watcher 资源、有界检测延迟和部署可调参数。有界项目集合与资源销毁契约会限制这些成本。 +- 远程或未来的可变提供方会保留各自注册作用域内的失效闭包,并通过自身观察机制调用它;注册表不会强制采用通用 watcher 或 TTL。 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/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 b4a7624e73..5ef759a7d0 100644 --- a/apps/web/tests/slash-flow.snapshot.ts +++ b/apps/web/tests/slash-flow.snapshot.ts @@ -58,12 +58,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) @@ -81,6 +85,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/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/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' }) 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/docs/config-catalog.md b/docs/config-catalog.md index ca9126cfa2..3783bfd284 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` @@ -1241,7 +1241,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:144`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:170`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -1256,12 +1256,24 @@ 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 /** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */ bundledSkillDir?: string } ``` -Source: [`packages/skill/skill-local/src/index.ts:42`](../packages/skill/skill-local/src/index.ts) +Source: [`packages/skill/skill-local/src/index.ts:49`](../packages/skill/skill-local/src/index.ts) ## `@deepseek-ai/dsh-spill-local` @@ -1726,7 +1738,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. */ @@ -1736,7 +1748,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-skill/src/index.ts:26`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:30`](../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 967241fbf6..6ed425e1d9 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -642,6 +642,25 @@ Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-stru Source: [`packages/core/session/src/index.ts:103`](../../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:188`](../../packages/skill/skill/src/index.ts) + ## `slash/*` ### `slash/input-begin-command` — bail @@ -660,7 +679,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 +695,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 +711,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 +728,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/cordis-catalog/services.md b/docs/cordis-catalog/services.md index bf7961151d..bc084f7956 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1604,6 +1604,19 @@ Log-backed title fold plus asynchronous fallback generation. */ get(session: Session): SessionTitleSnapshot | undefined +/** + * Accept an explicit user title. Appends a `session/title` event with the + * `user` source, which pins the title: in-flight automatic generation is + * superseded and later user messages schedule none (an explicit + * {@link SessionTitleService.refresh} remains the deliberate unpin). + * @param session - exact live session to rename. + * @param title - raw user input; normalized before acceptance. + * @returns the accepted title snapshot. + * @throws {SessionTitleInvalidError} when the title normalizes to empty. + * @throws {Error} when the session is not live or the service is disposed. + */ +rename(session: Session, title: string): SessionTitleSnapshot + /** * Explicitly retry the registered provider, or materialize the built-in * fallback when no provider is registered. @@ -1624,22 +1637,22 @@ 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:261`](../../packages/session-title/session-title/src/index.ts) ## `ctx.skills` — `SkillService` -Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted model-visible summaries, and loads full skill bodies on demand. +Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted invocation-neutral summaries, and loads full skill bodies on demand. ```ts cordis-catalog /** * 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 +registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void /** * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which @@ -1660,6 +1673,15 @@ register(skill: SkillRegistration): () => void */ async list(options: SkillLookupOptions = {}): Promise +/** + * Observe the current invocation-neutral 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 discovery-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 @@ -1671,9 +1693,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) · [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:172`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:209`](../../packages/skill/skill/src/index.ts) ## `ctx.spillStore` — `SpillStore` (abstract seam) @@ -2175,7 +2197,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:251`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:247`](../../packages/ui/tui/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/session-title.i18n.yaml b/docs/core-data-structures/session-title.i18n.yaml index 64b3d8f342..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: 33efc911c0ca1ae94dc4ded74676e5c32a73bdd5 -session-title.zh.md: a4b95a726d2bc89a13d14f1daa2f825cd5aa91b1 +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 33efc911c0..fff1aa1f6b 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 @@ -41,9 +45,9 @@ type SessionTitleSource = interface SessionTitleEventData { /** Normalized non-empty title text. */ readonly title: string - /** Exact human `user/message` seqs used to derive this title. */ + /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */ readonly messageSeqs: number[] - /** Built-in fallback or registered-provider provenance. */ + /** Built-in fallback, registered-provider, or explicit-user provenance. */ readonly source: SessionTitleSource } ``` diff --git a/docs/core-data-structures/session-title.zh.md b/docs/core-data-structures/session-title.zh.md index a4b95a726d..73821b07c6 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 @@ -41,9 +45,9 @@ type SessionTitleSource = interface SessionTitleEventData { /** Normalized non-empty title text. */ readonly title: string - /** Exact human `user/message` seqs used to derive this title. */ + /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */ readonly messageSeqs: number[] - /** Built-in fallback or registered-provider provenance. */ + /** Built-in fallback, registered-provider, or explicit-user provenance. */ readonly source: SessionTitleSource } ``` diff --git a/docs/core-data-structures/skills.i18n.yaml b/docs/core-data-structures/skills.i18n.yaml index 7614fcbdd8..f5c134815e 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: 2524606ad058d5cbf81e287a7001777b4a125aaf -skills.zh.md: 7bc2d2165172da46bde0cfe0b9a1be279ea8af32 +skills.md: d4b41845bea009444653739abad712e9ce3afb13 +skills.zh.md: 8d6793129080487836b2e2471b8659df5a402974 diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index 2524606ad0..d4b41845be 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,19 @@ 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, while an explicit incomplete observation contributes usable candidates without making the result cacheable; 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 once when its provider generation changes; a second change returns the latest candidates incomplete and uncached. Provider and runtime mutations emit the unfiltered `skills/change` invalidation event; it carries no diff, so consumers refetch `snapshot()` with their own lookup options. + +An array returned by `SkillProvider.list()` is complete-discovery shorthand. `SkillProviderObservation` lets a provider expose candidates that remain directly loadable while reporting that the observation is not authoritative. + +```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. @@ -36,6 +49,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: @@ -51,6 +74,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 without hiding readable candidates from direct loads; 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. @@ -96,6 +121,18 @@ interface SkillSummary { `ctx.skills.list()` preserves all four policy combinations. `isModelInvocable(skill)` and `isUserInvocable(skill)` read the corresponding required field. A model-only skill sets `{ modelInvocable: true, userInvocable: false }`, a user-only skill sets `{ modelInvocable: false, userInvocable: true }`, and setting both fields to `false` keeps the skill available only through trusted `ctx.skills.get()` callers. The local provider reads the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`, defaults omitted fields to `true`, and projects every parsed skill into this normalized policy. +`SkillCatalogSnapshot` distinguishes authoritative absence from transient provider failure or a catalog that kept changing during discovery. `skills` contains the sorted invocation-neutral summaries collected in that observation; `complete` is true only when every registered provider completed without a concurrent catalog revision. Incomplete snapshots are not cached, allowing each consumer to retain its last-good filtered catalog and retry. + +```ts type-equiv +/** One catalog observation plus whether discovery completed within a stable catalog revision. */ +interface SkillCatalogSnapshot { + /** Sorted invocation-neutral summaries collected in this observation. */ + readonly skills: SkillSummary[] + /** Whether every registered provider completed without a concurrent catalog revision. */ + 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 @@ -150,6 +187,8 @@ type SkillRegistration = Omit & { 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 { @@ -160,7 +199,7 @@ interface SkillLookupOptions { } ``` -The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, `customSkillDirs`, and optional `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`). The consumer owns its catalog description bound. +The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, `customSkillDirs`, and optional `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`) 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. */ @@ -172,6 +211,8 @@ interface Config { ## Session catalog and tool contract -`dsh-tool-skill` injects a durable user-role `` at the first `agent/step` of a live session. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Discovery forwards the step's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`. +`dsh-tool-skill` injects the initial durable user-role `` at the first `agent/step` of a live session that observes a non-empty complete view. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Discovery forwards the step's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`. -The model-facing `skill({ name })` tool validates the kebab-case name, filters its catalog with `isModelInvocable`, loads the complete definition for the calling agent cwd, reports an unresolved skill as unknown or no longer available, rechecks model invocation policy before returning content, 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 applies exact tool visibility and digests the exact rendered entries between the `` tags from a complete snapshot. It derives the comparison baseline from the same entries in the newest recognizable visible catalog message sourced by the plugin. A changed digest appends a durable full replacement through `agent.inject()`; deleting every skill appends an explicit empty replacement. Incomplete snapshots preserve the last-good model view. If compaction hides every historical catalog message, the next complete snapshot re-establishes the current catalog; an empty view with no prior catalog emits nothing. These catalog messages are session history, not World State. + +The model-facing `skill({ name })` tool validates the kebab-case name, finds the summary in the invocation-neutral catalog, rejects it before loading unless `isModelInvocable` permits access, then rereads the complete definition for the calling agent cwd and rechecks the policy before returning content. It reports an unresolved skill as unknown or no longer available 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 7bc2d21651..8d67931290 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,19 @@ `ctx.skills` 组合本地、内嵌、远程或其他提供方。注册是同步的;远程初始化与发现属于 `list()` 的 await 阶段。提供方对象、选项与候选项以只读方式借用,语义字段会被校验。 -重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时记录日志并跳过,不缓存降级后的目录;格式错误的候选项快速失败。 +重名按 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. @@ -36,6 +49,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 顺序扫描各根目录: @@ -51,6 +74,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 范围内。 @@ -96,6 +121,18 @@ interface SkillSummary { `ctx.skills.list()` 保留全部四种策略组合。`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别读取对应的必填字段。仅供模型调用的 skill 设置 `{ modelInvocable: true, userInvocable: false }`,仅供用户调用的 skill 设置 `{ modelInvocable: false, userInvocable: true }`,两个字段均设为 `false` 后,该 skill 只能由受信的 `ctx.skills.get()` 调用方获取。本地提供方读取名称完全匹配的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`,将省略的字段默认为 `true`,并为每个解析出的 skill 生成这个规范化策略。 +`SkillCatalogSnapshot` 用于区分已确定的不存在与提供方的瞬时失败或发现期间持续变化的目录。`skills` 包含该次观测中收集、排序且与调用策略无关的摘要;只有每个已注册提供方都在没有并发目录修订时完成发现,`complete` 才为 true。不完整快照不会缓存,因此每个消费方可以保留上一份经过自身过滤的可用目录并重试。 + +```ts type-equiv +/** One catalog observation plus whether discovery completed within a stable catalog revision. */ +interface SkillCatalogSnapshot { + /** Sorted invocation-neutral summaries collected in this observation. */ + readonly skills: SkillSummary[] + /** Whether every registered provider completed without a concurrent catalog revision. */ + readonly complete: boolean +} +``` + `SkillCandidate` 是提供方到注册表的形状。`locator` 是提供方的不透明状态;注册表只存储它并在调用获胜提供方的 `get()` 时传回。 ```ts type-equiv @@ -150,6 +187,8 @@ type SkillRegistration = Omit & { skill 查找对 cwd 敏感,因为提供方可能暴露工作区本地的 skill;可选的 signal 为调用方取消提供方的工作。提供方接收与缓存标识和加载相同的只读选项对象。取消在目录选择前后(包括缓存命中时)都会检查,并与发现和完整定义加载竞争。如果找不到 git root,本地提供方将所提供的 cwd 本身视为项目根目录。 +注册表不缓存完整定义。每次调用 `get()` 都会携所选候选项调用胜出提供方,因此本地提供方会重新读取当前正文。名称与该候选项不再匹配的定义会被拒绝,并使该提供方实例失效以便重新发现。 + ```ts type-equiv /** Caller context used for cwd-sensitive and abortable provider work. */ interface SkillLookupOptions { @@ -160,7 +199,7 @@ interface SkillLookupOptions { } ``` -注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome`、`customSkillDirs`,以及可选的 `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`)。消费方拥有其目录描述上限。 +注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome`、`customSkillDirs`,以及可选的 `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`),以及 watcher 启用、轮询、稳定性、符号链接和项目容量控制。消费方拥有其目录描述上限。确切的默认值和校验规则见自动生成的[插件配置目录](../config-catalog.md)。 ```ts type-equiv /** Skill registry configuration. */ @@ -172,6 +211,8 @@ interface Config { ## 会话目录与工具契约 -`dsh-tool-skill` 在存活会话的第一个 `agent/step` 注入一条持久的 user-role ``。目录只包含已排序的 skill `name` 和规范化、经 XML 转义的 `description`;不包含正文、路径、来源、提供方或路由提示。发现通过 `SkillLookupOptions` 转发该步骤的 abort signal。`catalogDescriptionMaxLength` 是消费方用于 description 上限的配置,默认值为 `500`,整数最小值为 `3`。 +`dsh-tool-skill` 在存活会话中第一个观察到非空完整视图的 `agent/step` 注入初始的持久 user-role ``。目录只包含已排序的 skill `name` 和规范化、经 XML 转义的 `description`;不包含正文、路径、来源、提供方或路由提示。发现通过 `SkillLookupOptions` 转发该步骤的 abort signal。`catalogDescriptionMaxLength` 是消费方用于 description 上限的配置,默认值为 `500`,整数最小值为 `3`。 -面向模型的 `skill({ name })` 工具校验 kebab-case 名称,使用 `isModelInvocable` 过滤目录,为调用方 agent 的 cwd 加载完整定义,将未解析的 skill 报告为 unknown 或 no longer available,在返回内容前重新检查模型调用策略,并返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。工具结果是模型获取完整指令的可见路径。 +在后续每个模型步骤之前,消费方都会应用精确的工具可见性,并对完整快照中 `` 标签之间精确渲染的条目计算 digest。它以该插件所发布、最新一条可识别且仍可见的目录消息中的相同条目作为比较基线。digest 发生变化时,会通过 `agent.inject()` 追加一条持久的完整目录替换;删除所有 skill 时会追加一条显式的空替换。不完整快照会保留上一份可用模型视图。如果压缩(compaction)隐藏了所有历史目录消息,下一份完整快照会重新建立当前目录;如果视图为空且从未发布目录,则不发送任何内容。这些目录消息属于会话历史,而非 World State。 + +面向模型的 `skill({ name })` 工具校验 kebab-case 名称,在与调用策略无关的目录中查找摘要,并在加载前通过 `isModelInvocable` 拒绝无权访问的 skill;随后它为调用方 agent 的 cwd 重新读取完整定义,并在返回内容前再次检查策略。该工具将未解析的 skill 报告为 unknown 或 no longer available,并返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。因此,仅修改正文会改变后续工具调用,而不会生成目录消息或改写先前工具结果。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 6e2c6e68a0..16d3faff9c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../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/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../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) | @@ -35,10 +35,11 @@ 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` | +| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../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: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) | @@ -65,7 +66,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/docs/module-graph.md b/docs/module-graph.md index 98902f621f..2b873c1ed3 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 @@ -1008,7 +1011,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) | @@ -1022,10 +1024,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) | @@ -1045,8 +1045,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) | @@ -1067,7 +1069,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) | @@ -1087,6 +1089,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) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 0c76a37188..dea3c4816b 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -416,7 +416,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s Types: [SessionTitleEventData](core-data-structures/session-title.md) -Source: [`packages/session-title/session-title/src/index.ts:96`](../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:100`](../packages/session-title/session-title/src/index.ts) #### `session/title-llm-request` — log-only diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 995a8c669d..368f92ae70 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 ea088a20f9..a6c33160fa 100644 --- a/examples/tui-agent/tests/pty-harness.ts +++ b/examples/tui-agent/tests/pty-harness.ts @@ -1,6 +1,7 @@ +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 { execa } from 'execa' import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' @@ -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 { @@ -157,7 +170,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 ea41f5c73a..cd5f83e4e3 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -252,6 +252,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/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 5b10207da5..95b960e3b6 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -336,8 +336,9 @@ async function runScenario(scenario: Scenario): Promise { terminal.send('\x03') await settleTerminal(terminal) + const skillContext = ctx const skillTurnEnded = new Promise((resolve) => { - const detach = ctx.on('session/event', (session, event) => { + const detach = skillContext.on('session/event', (session, event) => { if (session !== agent.session || event.type !== 'turn/end') return detach() resolve() diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 68d3374291..3253efbb06 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1021,6 +1021,27 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication') return ok(request, { sessionId: created.sessionId }) }, + rename: (request) => { + const missing = requireSession(request) + if (missing !== undefined) return missing + const { sessionId, title } = request.payload + const normalized = title.trim().replace(/\s+/g, ' ') + if (normalized.length === 0) { + return err(request, { + code: 'title-invalid', + message: 'session title must contain visible characters', + details: { sessionId }, + }) + } + // The append emits the session/event and its session/projection frame + // (host parallel); the unary response settles the caller first. + append(sessionId, { + type: 'session/title', + data: { title: normalized, messageSeqs: [], source: { kind: 'user' } }, + }) + const appended = logOf(sessionId).at(-1) as SessionEvent + return ok(request, { title: normalized, seq: appended.seq }) + }, history: async (request) => { const log = logs.get(request.payload.sessionId) ?? [] // Snapshot at request time, deliver after the transit delay (mirrors a real host under latency). @@ -1564,6 +1585,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.history': return this.api.sessions.history(request) case 'session.models': return this.api.sessions.models(request) case 'session.selectModel': return this.api.sessions.selectModel(request) + case 'session.rename': return this.api.sessions.rename(request) case 'session.prompt': return this.api.sessions.prompt(request) case 'session.cancel': return this.api.sessions.cancel(request) case 'host.describe': return this.api.host.describe(request) 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/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 0d6676ec97..157083018a 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: d283cf19572f4888d17884472ea0d2272109de7f -README.zh.md: b2d479e1ba277738390de2122c295ce44c77b1e0 +README.md: b51cc0276d8635ea9faa506e30246a107c1c1418 +README.zh.md: 4b2248d875ae37f1b848c51a0009d2497c6b3e61 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index d283cf1957..b51cc0276d 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 b2d479e1ba..4b2248d875 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/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 3f6388b9eb..c80f046ceb 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 408badeb42..59dd7d9186 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -108,6 +108,13 @@ export class FixtureSession implements SessionFace { throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`) } + /** + * Fail-loud stub; supply `rename` on the fixture's session face to exercise it. + * @returns never — always throws. + */ + rename(): never { + throw new Error(`test session "${this.sessionId}": rename is not stubbed — supply it on the fixture's session face`) + } } /** One live test session: fixture-derived stores plus its minted scope state. */ 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() }) 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/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 6ab2d800bc..8fe70e0e7e 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: 2f7ca50fe241ddc7927b9cddf9496d1b990d372d -README.zh.md: 8a27ccb1ab59e98ca893190887a81fc0c9f4b910 +README.md: 8a8002ef75299579d7df1eb562a7e8bfaf2aeb2a +README.zh.md: 27e6ab94d4d1cee7fcb7943e0f07c4de2a2f7503 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 2f7ca50fe2..8a8002ef75 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,13 +2,13 @@ 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 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. -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. @@ -20,7 +20,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). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. 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). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. 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 8a27ccb1ab..27e6ab94d4 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 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 @@ -14,13 +14,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 提供其余状态和回调。 -输入栏声明两个会话作用域的单实例 seat:`'conversation.input.plan'` 位于本地 access 模式控件右侧,而 `'conversation.input.model'` 紧接在 pending 指示器与发送/停止按钮之前;它还为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 会将文本框的 placeholder 切换为 plan 任务文案(它通过标准工具包的 `useProjection` 读取由 host 折叠的值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。常驻无会话壳使用 `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 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。常驻无会话壳使用 `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-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..7f3aeb38cc 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, @@ -15,6 +17,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' @@ -25,7 +28,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 +53,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 +189,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 }, } }, @@ -208,6 +239,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..b9e1e3351f 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). */ @@ -244,26 +253,34 @@ 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: 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 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. @@ -272,42 +289,65 @@ 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 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 +390,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio return (
-
+
{openState === 'loading' &&
载入历史…
} {openState === 'error' &&
历史加载失败:{openErrorMessage}
} @@ -388,22 +428,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..c468c418a4 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -117,6 +117,18 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Owner share of the strict session content seat. */ export interface ConversationSessionOwnerProps { + /** + * Wrap the view ring in the transcript scrollport that also hosts the + * 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 seat. + */ + wrapActiveBody?: (view: ReactNode) => ReactNode } /** @@ -257,6 +269,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/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index de974da86c..272bbae873 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; @@ -127,6 +133,46 @@ 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 seat + 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'] .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; + 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 @@ -165,12 +211,15 @@ 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 +/* 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 b110860685..3afae25de9 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,53 @@ 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 }, + ) + + // 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 + // 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} + {composerSeat} +
+ ) + 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 }, + 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', + { wrapActiveBody }, )} + {sessionId === undefined ? composerSeat : 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..33b24f5245 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() @@ -44,52 +44,67 @@ 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 = hideChrome ? null : ( +
+ {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })} +
+ ) return ( <> -
-
- -
- {tabs.length > 1 && ( -
- {tabs.map(view => ( - - ))} -
+ ))} +
+ )} + )} -
- {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })} -
+ {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/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..24359965f5 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. @@ -75,6 +79,27 @@ export function InputBar({ if (!locked) inputRef.current?.focus() }, [locked]) + // 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) || 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 + } + 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. @@ -296,7 +321,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 +342,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 +359,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..d7b9125b34 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. @@ -84,6 +86,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-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-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-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/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 328ba38340..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,42 @@ 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', '') + 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..c0bfda7f4a 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 } : {}), @@ -227,6 +233,56 @@ describe('running and lock semantics (queue cut 1)', () => { expect((textarea).value).toBe('typed') }) + 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 }) + 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('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') @@ -292,6 +348,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 +443,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 +459,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 +493,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 12123d5e6d..b174bace53 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -61,6 +61,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({ @@ -111,6 +113,7 @@ function mount( }} bindDraftMirror={write => wiring.bindMirror(write)} open={open} + {...owner} /> ) } @@ -133,6 +136,7 @@ function mount( useLexicon={bindSnapshotSelector(wiring.lexicon)} stop={stop} command={() => Promise.resolve(true)} + translateHint={(key: string) => key} renderSlot={(() => null) as InputBarProps['renderSlot']} {...bar} /> @@ -140,7 +144,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), @@ -175,6 +190,30 @@ describe('ConversationRoot resident composer', () => { expect(b.open).toHaveBeenCalledWith(sid('root')) }) + 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; the seat sticks inside it. + expect(host?.contains(header)).toBe(false) + 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', () => { const b = mount( conversationSnapshot({ composerPhase: 'blank', blank: true }), @@ -183,13 +222,19 @@ describe('ConversationRoot resident composer', () => { { ...workspace('second'), title: 'Selected Folder' }, ], ) - // Hero chrome present, view ring absent. + // 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 @@ -202,16 +247,20 @@ describe('ConversationRoot resident composer', () => { expect(b.view.getByText('Selected Folder')).toBeTruthy() }) - it('textarea DOM identity survives the hero → active flip', () => { + 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. + // 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') + const after = b.view.getByRole('textbox') as HTMLTextAreaElement expect(after).toBe(before) - expect((after as HTMLTextAreaElement).value).toBe('kept across flip') + 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-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/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-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..2e20dc96f8 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,9 +79,14 @@ 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) + // 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 d9b6a08a88..1c74340575 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,79 @@ 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('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') 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/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 1980ac2dbf..14b4ce6ac2 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -13,6 +13,13 @@ background: var(--dsw-alias-bg-layer-1); } +/* Under the active conversation column (`[data-conversation-scroll]`) the + * parent owns overflow so the sticky composer stays in the same scrollport. */ +:global([data-conversation-scroll]) .root { + overflow: visible; + height: auto; +} + .ledger { display: flex; flex: 1; 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 7effff36b5..a1fa0683ac 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,39 @@ export function WorkspaceBrowser({ }) } + // Session rename dialog (same browser-owned pattern as workspace rename; + // sessions have no client-side name-conflict rule — the host normalizes). + // Unlike workspace rename, an unchanged title is NOT blocked: confirming + // the current automatic title is the gesture that pins it. + const [sessionRenameTarget, setSessionRenameTarget] = useState<{ sessionId: SessionNode['id']; currentTitle: string } | null>(null) + const [sessionRenameDraft, setSessionRenameDraft] = useState('') + const [sessionRenaming, setSessionRenaming] = useState(false) + const [sessionRenameError, setSessionRenameError] = useState(null) + const sessionRenameTrimmed = sessionRenameDraft.trim() + const sessionRenameBlocked = sessionRenaming || sessionRenameTrimmed === '' || sessionRenameTarget === null + const closeSessionRename = () => { + if (sessionRenaming) return + setSessionRenameTarget(null) + setSessionRenameError(null) + } + const confirmSessionRename = () => { + if (sessionRenameBlocked) return + setSessionRenaming(true) + setSessionRenameError(null) + renameSession(sessionRenameTarget.sessionId, sessionRenameTrimmed).then(() => { + setSessionRenaming(false) + setSessionRenameTarget(null) + }).catch((reason: unknown) => { + setSessionRenaming(false) + setSessionRenameError(reason instanceof Error ? reason.message : String(reason)) + }) + } + const onSessionRename = (sessionId: SessionNode['id'], currentTitle: string) => { + setSessionRenameTarget({ sessionId, currentTitle }) + setSessionRenameDraft(currentTitle) + setSessionRenameError(null) + } + // Delete dialog is separate from the row so a successful removal can // unmount that row without tearing down the in-flight confirmation state. const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null) @@ -424,10 +462,11 @@ export function WorkspaceBrowser({ itself is wide-only. */}
{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 () => {}), diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 635ccc28e2..bfcd90b445 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 {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', 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 */', @@ -749,8 +753,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: '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', @@ -760,6 +764,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async list(options: SkillLookupOptions = {}): Promise', jsDoc: '/**\n * List invocation-neutral skill summaries for a workspace. Consumers apply\n * model or user invocation policy at their operational boundary. Lookup\n * options and provider candidates are readonly same-process values borrowed\n * throughout discovery.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns all sorted winning summaries.\n */', }, + { + signature: 'async snapshot(options: SkillLookupOptions = {}): Promise', + jsDoc: '/**\n * Observe the current invocation-neutral 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', 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 */', @@ -1266,6 +1274,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', @@ -2325,7 +2340,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', @@ -2335,6 +2350,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}', @@ -2349,7 +2368,15 @@ 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', 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) + }) +}) diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 930d75b0ac..82178c48de 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 5a6f89525c..16538297df 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -8,9 +8,12 @@ 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 { createUserMessage, +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { + createUserMessage, CallId, LlmAdapter, LlmError, @@ -404,6 +407,142 @@ 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(createUserMessage({ + content: [{ type: 'text', text: 'Create and load the project skill.' }], + source: { kind: 'user' }, + })) + 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 following skills are available in this session:') + 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 === 'dsh-tool-skill') { + return [{ + type: event.type, + source: event.data.source, + 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.message.source.callId)) { + const result = event.data.message.content[0] + return [{ + type: event.type, + callId: event.data.message.source.callId, + isError: result.isError, + text: result.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", + }, + { + "source": { + "kind": "plugin", + "plugin": "dsh-tool-skill", + }, + "text": " + A skill is a reusable set of task-specific instructions. The following skills are available in this session: + + + - \`hot-skill\`: Hot-added skill + + + If the user names a skill, or the task clearly matches a skill's description, call the \`skill\` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded. + ", + "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/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 9fd16af6b6..18202ed036 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: 931fa13b02ce3116e6ef002bb6fba1ea1ec97321 -README.zh.md: f6a474af10277aeb8c9cf13dd9d4b32f2834b2eb +README.md: 1f0daedc54888a1951bc83c474f83287aaf42307 +README.zh.md: abf5417cdbe93f1199c621ac101249986969da93 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 931fa13b02..1f0daedc54 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 f6a474af10..abf5417cdb 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 4cd55a398a..81d16c8919 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -38,6 +38,8 @@ import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal' // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`. import type {} from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-skill' +// Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`. +import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' import type { CallId } from '@deepseek-ai/dsh-llm/brand' import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' // Side-effect type import: resolves the `approval/request` waterfall and @@ -1049,6 +1051,36 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, + async rename(request) { + const { sessionId, title } = request.payload + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + const titles = ctx.get('sessionTitle') + if (titles === undefined) { + return err(request, { code: 'internal', message: 'renaming is unavailable: this deployment mounts no session-title service', details: {} }) + } + try { + const accepted = titles.rename(found.agent.session, title) + return ok(request, { title: accepted.title, seq: accepted.eventSeq }) + } catch (error: unknown) { + // Only the input's fault maps to title-invalid (the message is + // product-user-visible in the rename dialog); liveness and disposal + // races are deployment trouble, not a bad title. + if (error instanceof SessionTitleInvalidError) { + return err(request, { + code: 'title-invalid', + message: error.message, + details: { sessionId }, + }) + } + return err(request, { + code: 'internal', + message: `failed to rename session "${sessionId}": ${String(error)}`, + details: {}, + }) + } + }, + async prompt(request) { const { sessionId, mode, content } = request.payload const found = await agentFor(sessionId) 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..5b81d1c4ea 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -24,6 +24,7 @@ import { sessionListValueSchema, sessionModelsValueSchema, sessionPromptValueSchema, + sessionRenameValueSchema, sessionSelectModelValueSchema, } from '../api/sessions.schema.ts' import { @@ -66,6 +67,7 @@ export interface IApiClient { history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise>> 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..46e3703f3a 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -21,6 +21,7 @@ import { sessionListRequestSchema, sessionModelsRequestSchema, sessionPromptRequestSchema, + sessionRenameRequestSchema, sessionSelectModelRequestSchema, } from '../api/sessions.schema.ts' import { @@ -68,6 +69,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) }, 'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) }, 'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) }, + 'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) }, 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index db4e125bd5..ad70e6b26a 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -180,7 +180,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) @@ -208,7 +208,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. @@ -237,11 +237,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/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts new file mode 100644 index 0000000000..0e3f0ffe18 --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts @@ -0,0 +1,129 @@ +/** + * sessions.rename delegation through the composed SessionTitleService. The + * agent factory is a structural stub whose createAgent forwards seed/meta into + * the real SessionStore, and whose resume never runs (every source here is + * already attached). Cold-session resolution is the shared `agentFor` path — + * api-proxy-cold.spec.ts owns the resume evidence for every unary that rides + * it, rename included. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionTitleService from '@deepseek-ai/dsh-session-title' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import type { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' + +const sid = (id: string): SessionId => id as SessionId + +let nextRpc = 1 +function request

(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 only an empty-normalizing title to title-invalid, with a presentable message', async () => { + const ctx = await composed() + const source = liveAgent(ctx, 'session-rename-bad', 1) + + // U+200B passes a client-side trim gate but normalizes to empty host-side. + const response = await api(ctx).sessions.rename(request({ sessionId: source.id, title: ' ​ ' })) + expect(response.result.ok).toBe(false) + if (!response.result.ok) { + expect(response.result.error).toMatchObject({ + code: 'title-invalid', + details: { sessionId: source.id }, + }) + // The message renders verbatim in the rename dialog's alert. + expect(response.result.error.message).toBe('session title must contain visible characters') + } + }) + + it('maps a non-validation rename failure (stale session object) to internal, not title-invalid', async () => { + const ctx = await composed() + // The registered agent holds a session object from another store: the + // title service's liveness check throws a plain Error, which must not + // read as the user's fault. + const foreign = await composed(false) + const stale = liveAgent(foreign, 'session-rename-stale', 1) + ctx.agents.register({ id: stale.id, session: stale, status: 'idle', ctx } as Agent) + + const response = await api(ctx).sessions.rename(request({ sessionId: stale.id, title: 'name' })) + expect(response.result.ok).toBe(false) + if (!response.result.ok) expect(response.result.error.code).toBe('internal') + }) + + it('answers internal when the composition mounts no session-title service', async () => { + const ctx = await composed(false) + const source = liveAgent(ctx, 'session-no-titles', 1) + + const response = await api(ctx).sessions.rename(request({ sessionId: source.id, title: 'name' })) + expect(response.result.ok).toBe(false) + if (!response.result.ok) { + expect(response.result.error.code).toBe('internal') + expect(response.result.error.message).toMatch(/mounts no session-title service/) + } + }) +}) 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/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 7e1e83e39b..5185081283 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -41,6 +41,9 @@ { "path": "../../session-projection/session-projection-cache" }, + { + "path": "../../session-title/session-title" + }, { "path": "../../skill/skill" }, diff --git a/packages/session-title/session-title/README.i18n.yaml b/packages/session-title/session-title/README.i18n.yaml index 6f5a315dc5..5760b076a4 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: f842db9ca5d0215ecf6589978983952678a19897 +README.md: 9a5ec27c36f3411add37ebe231262eb5d205bc9e +README.zh.md: 38fc9f82e3fcd94233cad31e291b8258c97d0975 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 f842db9ca5..38fc9f82e3 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?)` 在需要时物化回退,然后显式运行已注册提供方,处理当前符合条件的消息。提供方错误或调用方取消都会导致返回的 Promise 被拒绝;取消不会回滚已接受的回退事件。 +- `rename(session, title)` 同步接受用户显式标题:规范化文本、取代在途自动工作,并追加一条 `user` 来源的 `session/title` 事件。最新标题来源为 user 即钉住该会话——后续用户消息不再安排自动 revision;显式 `refresh` 仍是有意的解钉手段。 - `register(provider)` 安装唯一可选提供方,并返回可等待的 Cordis effect disposer。第二次注册会立即抛出;对提供方执行 dispose(资源释放)会中止待处理和活跃调用,等待其结算,之后才允许注册另一个提供方。 自动工作绝不会延迟主 agent(智能体)响应。只有当带标记、由循环构建的请求,其确切路由与当前已记录的 `request/header` 匹配时,提供方才会启动;即使请求头未变而无需新快照,也适用此规则。延迟完成会直接通过 `Session` 追加一个独立的纯日志事件,而不打开轮次。持久化会立即观察到该事件,并在常规生命周期检查点完成刷写;标题发布本身不会强制刷写。自动失败会发出警告并保留最新标题。新的全消息修订、提供方 dispose、会话 dispose 和显式刷新都会中止旧工作,陈旧的完成结果无法追加。并发显式刷新会在提供方工作之前预留修订号;重叠的自动/显式回退请求共享一个会话本地正在进行的追加操作。服务与内置模型提供方各自追加自己的字面事件类型,因此不需要通用标题写入标记、类型断言或结算队列。服务拆卸会取消排队工作,并在卸载完成前等待不响应取消的调用结算完成。 @@ -50,5 +51,5 @@ Fork 出的会话会原样继承种子中的标题事件。首消息节奏不会 ## 已知限制与暂缓事项 -- 手动重命名、删除标题、生成标题与用户标题的优先级、搜索和列表索引都不属于此服务。 +- 删除标题(不经显式 `refresh` 就解钉回自动标题)、搜索和列表索引不属于此服务。 - 提供方注册表有意最多接受一个实现,因此部署若要组合相互竞争的标题策略,必须编写一个自行负责优先级的提供方。 diff --git a/packages/session-title/session-title/src/index.ts b/packages/session-title/session-title/src/index.ts index 995b216086..12da431c68 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,14 +52,18 @@ export type SessionTitleSource = readonly provider: SessionTitleProviderId readonly model?: SessionTitleModelProvenance } + | { + /** Explicit user rename: pins the title — automatic generation stops scheduling. */ + readonly kind: 'user' + } /** Payload of the log-only `session/title` event. */ export interface SessionTitleEventData { /** Normalized non-empty title text. */ readonly title: string - /** Exact human `user/message` seqs used to derive this title. */ + /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */ readonly messageSeqs: number[] - /** Built-in fallback or registered-provider provenance. */ + /** Built-in fallback, registered-provider, or explicit-user provenance. */ readonly source: SessionTitleSource } @@ -97,6 +101,16 @@ declare module '@deepseek-ai/dsh-session' { } } +/** + * Rejection of an explicit user title whose text normalizes to empty — the + * one {@link SessionTitleService.rename} failure that blames the input. + * Callers translating rename failures onto a wire (`title-invalid`) narrow on + * this class; liveness and disposal failures stay plain `Error`s. + */ +export class SessionTitleInvalidError extends Error { + override readonly name = 'SessionTitleInvalidError' +} + /** One eligible human text message exposed to title providers. */ export interface SessionTitleUserMessage { /** Source `user/message` event seq. */ @@ -180,20 +194,27 @@ export function foldSessionTitle(events: readonly SessionEvent[]): SessionTitleS return deepFreeze({ title: event.data.title, messageSeqs: [...event.data.messageSeqs], - source: event.data.source.kind === 'fallback' - ? { kind: 'fallback' } - : { - kind: 'provider', - provider: event.data.source.provider, - ...(event.data.source.model === undefined - ? {} - : { model: { ...event.data.source.model } }), - }, + source: copySessionTitleSource(event.data.source), eventSeq: event.seq, updatedAt: event.time, }) } +/** Defensive copy of a logged title source (the snapshot must not alias log-owned objects). */ +function copySessionTitleSource(source: SessionTitleSource): SessionTitleSource { + switch (source.kind) { + case 'fallback': return { kind: 'fallback' } + case 'provider': return { + kind: 'provider', + provider: source.provider, + ...(source.model === undefined ? {} : { model: { ...source.model } }), + } + case 'user': return { kind: 'user' } + /* v8 ignore next -- closed-union exhaustiveness guard */ + default: return assertNever(source, 'SessionTitleSource') + } +} + /** Service-owned resolved limits. */ interface ResolvedConfig { readonly fallbackMaxWords: number @@ -328,6 +349,39 @@ export class SessionTitleService extends Service { return foldSessionTitle(session.events) } + /** + * Accept an explicit user title. Appends a `session/title` event with the + * `user` source, which pins the title: in-flight automatic generation is + * superseded and later user messages schedule none (an explicit + * {@link SessionTitleService.refresh} remains the deliberate unpin). + * @param session - exact live session to rename. + * @param title - raw user input; normalized before acceptance. + * @returns the accepted title snapshot. + * @throws {SessionTitleInvalidError} when the title normalizes to empty. + * @throws {Error} when the session is not live or the service is disposed. + */ + rename(session: Session, title: string): SessionTitleSnapshot { + this.assertServiceActive() + if (this.ctx.sessions.get(session.id) !== session) { + throw new Error(`session "${session.id}" is not live in this store`) + } + const normalized = normalizeSessionTitle(title, this.config.maxTitleBytes) + if (normalized.length === 0) { + throw new SessionTitleInvalidError('session title must contain visible characters') + } + const state = this.stateFor(session) + this.supersede(state, 'user rename superseded automatic title generation') + session.append('session/title', { + title: normalized, + messageSeqs: [], + source: { kind: 'user' }, + }) + const snapshot = this.get(session) + /* v8 ignore next -- unreachable: the append above just committed a session/title event. */ + if (snapshot === undefined) throw new Error('renamed title failed to fold') + return snapshot + } + /** * Explicitly retry the registered provider, or materialize the built-in * fallback when no provider is registered. @@ -345,6 +399,16 @@ export class SessionTitleService extends Service { const messages = collectSessionTitleMessages(session.events) const latest = messages.at(-1) if (registration === undefined || registration.closing || latest === undefined) { + // Explicit refresh is the unpin even without a provider: a standing + // user title must not short-circuit ensureFallback into a no-op, so + // re-derive and append the fallback over it when one is derivable. + const current = this.get(session) + const [first] = messages + if (current?.source.kind === 'user' && first !== undefined) { + this.appendFallback(session, first) + signal?.throwIfAborted() + return this.get(session) + } const fallback = await this.ensureFallback(session) signal?.throwIfAborted() return fallback @@ -398,6 +462,8 @@ export class SessionTitleService extends Service { private onUserMessage(session: Session, event: Extract): void { if (!this.serviceActive()) return if (event.data.source.kind !== 'user' || collectSessionTitleMessages([event]).length === 0) return + // A user rename pins the title: no automatic revision may override it. + if (this.get(session)?.source.kind === 'user') return const registration = this.registration if (registration !== undefined && !registration.closing) { const messages = collectSessionTitleMessages(session.events, event.seq) @@ -668,6 +734,23 @@ export class SessionTitleService extends Service { } } + /** + * Derive and append the deterministic fallback title over whatever stands + * (the refresh unpin path: overwriting a pinned user title is the point). + * Synchronous on purpose — no await may separate derivation from append, so + * it needs neither ensureFallback's in-flight dedup nor its liveness + * re-check. An underivable fallback (empty after the caps) appends nothing. + */ + private appendFallback(session: Session, first: SessionTitleUserMessage): void { + const title = fallbackSessionTitle(first.text, this.config.fallbackMaxWords, this.config.fallbackMaxBytes) + if (title.length === 0) return + session.append('session/title', { + title, + messageSeqs: [first.seq], + source: { kind: 'fallback' }, + }) + } + /** Create the first deterministic fallback if the session still lacks a title. */ private async ensureFallback(session: Session): Promise { 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 new file mode 100644 index 0000000000..01d613ee3e --- /dev/null +++ b/packages/session-title/session-title/tests/rename.spec.ts @@ -0,0 +1,181 @@ +// SessionTitleService.rename: user-source acceptance, normalization/rejection +// boundaries, and the pin (a user-sourced latest title schedules no automatic +// revision; explicit refresh stays the unpin). +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionTitleService, { + SessionTitleProviderId, + foldSessionTitle, + type SessionTitleProviderRequest, +} from '@deepseek-ai/dsh-session-title' + +const CONFIG = { + fallbackMaxWords: 5, + fallbackMaxBytes: 40, + maxTitleBytes: 40, +} as const + +async function settle(): Promise { + 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', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionTitleService, CONFIG) + const session = ctx.sessions.create(SessionId('rename-accept')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + appendHumanPrompt(session, 'Original prompt text') + await settle() + + const accepted = ctx.sessionTitle.rename(session, ' Hand\tpicked name ') + expect(accepted).toMatchObject({ + title: 'Hand picked name', + messageSeqs: [], + source: { kind: 'user' }, + }) + const event = session.events.findLast(item => item.type === 'session/title') + expect(event?.data).toEqual({ + title: 'Hand picked name', + messageSeqs: [], + source: { kind: 'user' }, + }) + // foldSessionTitle round-trips the third source kind. + expect(foldSessionTitle(session.events)?.source).toEqual({ kind: 'user' }) + }) + + it('rejects titles that normalize to empty and dead sessions', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionTitleService, CONFIG) + const session = ctx.sessions.create(SessionId('rename-reject')) + expect(() => ctx.sessionTitle.rename(session, '  ')).toThrow(/visible characters/) + + expect(() => ctx.sessionTitle.rename(new Session(SessionId('detached')), 'name')) + .toThrow(/not live in this store/) + }) + + it('pins the title: later user messages schedule no automatic revision; refresh unpins', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionTitleService, CONFIG) + const generate = vi.fn(async (request: SessionTitleProviderRequest) => ({ + title: 'Provider title', + messageSeqs: request.messages.map(message => message.seq), + })) + ctx.sessionTitle.register({ + id: SessionTitleProviderId('pin-provider'), + automatic: 'all-user-messages', + generate, + }) + const session = ctx.sessions.create(SessionId('rename-pin')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + appendHumanPrompt(session, 'First prompt') + await settle() + ctx.sessionTitle.rename(session, 'Pinned by hand') + + // A later eligible prompt must schedule nothing while the pin stands. + appendHumanPrompt(session, 'Second prompt after the pin') + await settle() + session.append('request/header', { + header: { config: { provider: 'main-route', model: 'chat-model' } }, + reason: 'change', + }) + await settle() + expect(generate).not.toHaveBeenCalled() + expect(ctx.sessionTitle.get(session)?.title).toBe('Pinned by hand') + + // Explicit refresh remains the deliberate unpin. + const refreshed = await ctx.sessionTitle.refresh(session) + expect(generate).toHaveBeenCalledOnce() + expect(refreshed?.title).toBe('Provider title') + expect(ctx.sessionTitle.get(session)?.source.kind).toBe('provider') + }) + + it('fallback-only refresh also unpins: the user title yields to a re-derived fallback', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionTitleService, CONFIG) + const session = ctx.sessions.create(SessionId('rename-unpin-fallback')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + appendHumanPrompt(session, 'Derivable prompt words') + await settle() + ctx.sessionTitle.rename(session, 'Pinned without provider') + expect(ctx.sessionTitle.get(session)?.source.kind).toBe('user') + + const refreshed = await ctx.sessionTitle.refresh(session) + expect(refreshed).toMatchObject({ + title: 'Derivable prompt words', + source: { kind: 'fallback' }, + }) + // The pin is gone: the latest title is fallback-sourced, so the + // onUserMessage pin check no longer skips scheduling. + expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback') + }) + + it('supersedes in-flight automatic generation: a late provider result cannot override the user title', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionTitleService, CONFIG) + // The provider parks on a test-held deferred so rename lands while its + // generation is ACTIVE (not merely scheduled). + let releaseProvider: (() => void) | undefined + const gate = new Promise((resolve) => { releaseProvider = resolve }) + let aborted = false + const generate = vi.fn(async (request: SessionTitleProviderRequest) => { + request.signal.addEventListener('abort', () => { aborted = true }) + await gate + return { title: 'Late provider title', messageSeqs: request.messages.map(message => message.seq) } + }) + ctx.sessionTitle.register({ + id: SessionTitleProviderId('deferred-provider'), + automatic: 'all-user-messages', + generate, + }) + const session = ctx.sessions.create(SessionId('rename-supersede')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + appendHumanPrompt(session, 'Prompt that triggers generation') + session.append('request/header', { + header: { config: { provider: 'main-route', model: 'chat-model' } }, + reason: 'change', + }) + await settle() + expect(generate).toHaveBeenCalledOnce() + + ctx.sessionTitle.rename(session, 'User wins') + expect(aborted).toBe(true) + releaseProvider?.() + await settle() + // The released provider result must not append over the user title, and + // the swallowed abort must not surface as an unhandled rejection. + const latest = session.events.findLast(item => item.type === 'session/title') + expect(latest?.data).toMatchObject({ title: 'User wins', source: { kind: 'user' } }) + }) + + it('fallback-only refresh keeps the user title when no fallback is derivable', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + // A 3-byte fallback cap cannot hold the 4-byte emoji prompt: the + // re-derived fallback is empty, so the pinned title survives the refresh. + await ctx.plugin(SessionTitleService, { ...CONFIG, fallbackMaxBytes: 3 }) + const session = ctx.sessions.create(SessionId('rename-unpin-empty')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + appendHumanPrompt(session, '😀😀') + await settle() + ctx.sessionTitle.rename(session, 'Sticky emoji pin') + + const refreshed = await ctx.sessionTitle.refresh(session) + expect(refreshed?.title).toBe('Sticky emoji pin') + expect(ctx.sessionTitle.get(session)?.source.kind).toBe('user') + }) +}) diff --git a/packages/skill/README.i18n.yaml b/packages/skill/README.i18n.yaml index ead6d96d42..c17e3e6452 100644 --- a/packages/skill/README.i18n.yaml +++ b/packages/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/README.md -README.md: 5c75661de17826e7ea4763e90b494e9e7a0a7c0f -README.zh.md: 0db8933ecff2a8ab4767b29bc5319453ddfb87fa +README.md: 4fb41dda5d9f001f5d7c47a29f7743291c0b0822 +README.zh.md: 5981173d05e74e0576e83f4f0ec42894050675cf 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 0db8933ecf..5981173d05 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 460ec107ad..d1fa4602be 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: 40666f8e56878260eeef13d378450273b950048d -README.zh.md: edf3ef59539d85c903be788d063cfde74a5e16b2 +README.md: 2077cf852fe90f7a0fec4e9bda1e9ff68fc56453 +README.zh.md: ba1c71f1bc1916daad82d872ae6658bb203133c9 diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index 40666f8e56..2077cf852f 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Local filesystem provider for the `ctx.skills` registry. -This package implements one skill source. It scans local project, custom, and user skill roots, parses `SKILL.md` or flat Markdown skill files, and registers the provider on `ctx.skills`. The registry remains in `@deepseek-ai/dsh-skill`; the session-prefix catalog and model-facing loader tool remain in `@deepseek-ai/dsh-tool-skill`. +This package implements one skill source. It scans local project, custom, and user skill roots, parses `SKILL.md` or flat Markdown skill files, and registers the provider on `ctx.skills`. The registry remains in `@deepseek-ai/dsh-skill`; the durable session catalogs and model-facing loader tool remain in `@deepseek-ai/dsh-tool-skill`. ## Plugin @@ -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,7 +38,15 @@ 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 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 @@ -40,17 +54,20 @@ Skills can be single-level directory bundles (`/SKILL.md`) or flat Markdow The two invocation fields accept YAML booleans and the case-insensitive forms `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`. `disable-model-invocation: true` excludes the skill from model-facing catalogs and loaders; `user-invocable: false` excludes it from human-facing commands. Each omitted field defaults to permitting its surface, and the provider always emits both positive internal policy values, including when both keys are absent. A rejected camel-case spelling or a non-boolean invocation value drops the entire skill from discovery with a warning instead of discarding only that field or falling back to a permissive default. Invocation policy fails closed because ignoring invalid data could expose a skill on a disabled surface; wrong-typed optional `whenToUse` and `metadata` values are omitted because neither currently grants invocation. +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 to the existing request history. 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 edf3ef5953..ba1c71f1bc 100644 --- a/packages/skill/skill-local/README.zh.md +++ b/packages/skill/skill-local/README.zh.md @@ -4,7 +4,7 @@ `ctx.skills` 注册表的本地文件系统提供方。 -该包(package)实现一个 skill(技能)来源。它扫描本地项目、自定义和用户 skill 根目录,解析 `SKILL.md` 或平铺 Markdown skill 文件,并将提供方注册到 `ctx.skills`。注册表仍位于 `@deepseek-ai/dsh-skill`;会话前缀目录和面向模型的 loader 工具仍位于 `@deepseek-ai/dsh-tool-skill`。 +该包(package)实现一个 skill(技能)来源。它扫描本地项目、自定义和用户 skill 根目录,解析 `SKILL.md` 或平铺 Markdown skill 文件,并将提供方注册到 `ctx.skills`。注册表仍位于 `@deepseek-ai/dsh-skill`;持久会话目录和面向模型的 loader 工具仍位于 `@deepseek-ai/dsh-tool-skill`。 ## 插件 @@ -17,6 +17,12 @@ | `dshHome` | `$DSH_HOME` 或 `~/.dsh` | 由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 DeepSeek Harness 配置根目录;扫描该目录下的 `skills`。 | | `agentsHome` | `$DSH_AGENTS_HOME` 或 `~/.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,7 +38,15 @@ 项目根目录是包含 `.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 格式 @@ -40,17 +54,20 @@ Skill 可以是单层目录 bundle(`/SKILL.md`),也可以是平铺 M 这两个调用字段接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`。`disable-model-invocation: true` 会从面向模型的目录和 loader 中排除该 skill;`user-invocable: false` 会从面向用户的命令中排除该 skill。每个省略的字段都默认为允许对应接口调用;提供方始终输出两个正向内部策略值,即使两个键都不存在也不例外。若使用驼峰拼写或提供非布尔调用值,系统会记录警告并从发现结果中排除整个 skill,而不是只丢弃该字段或回退到宽松的默认值。调用策略校验遵循失败时默认拒绝原则,因为忽略无效数据可能会在已禁用的接口上暴露 skill;类型错误的可选 `whenToUse` 和 `metadata` 值则会被省略,因为这两个字段目前都不授予调用权限。 +目录与正文具有独立的生命周期。发现阶段解析 frontmatter 以生成概述。每次 `skill(name)` 加载都会重新读取并解析当前文件,因此正文编辑不需要 hash、修订号、缓存失效或主动通知模型。若在发现与加载之间重命名 frontmatter,系统会拒绝陈旧名称并使提供方失效;下一次目录观察会发布新名称。 + ## 模型体验 -通过 `dsh-tool-skill` 间接影响模型。它将该提供方的可调用名称和有长度上限的描述渲染到会话前缀目录中,并将所选指令正文与资源基底指引渲染到保留的工具历史中;路径、提供方 rank 和已禁用 skill 仍被隐藏。 +通过 `dsh-tool-skill` 间接影响模型。它将该提供方的可调用名称和有长度上限的描述渲染到初始目录或替换目录中,并将所选的当前指令正文与资源基底指引渲染到保留的工具历史中;路径、提供方 rank 和已禁用 skill 仍被隐藏。 #### KV Cache 影响 -不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。 +watcher 触发的失效可促使指定的消费方在现有请求历史中追加替换目录。仅涉及正文的编辑不会改变目录 digest。 ## 已知限制与暂缓事项 - **发现深度为一层**:只识别 `//SKILL.md` 和 `/.md`;忽略嵌套 skill 树和包 manifest(元数据清单)。 - **项目范围为最近 `.git` 祖先**:没有该标记的工作区回退到提供的 cwd,不支持其他项目根标记或 monorepo 子项目选择。 -- **不可读或格式错误的条目会随警告消失**:模型目录不会收到每个 skill 的诊断,无法区分缺失的 skill 与被跳过的 skill。 -- **无文件系统监听**:在重新发现先前已收集的 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 d382f3a781..1ffc54ceb7 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' @@ -25,6 +27,8 @@ import { type SkillInvocationPolicy, type SkillLookupOptions, type SkillProvider, + type SkillProviderControl, + type SkillProviderObservation, type SkillSource, } from '@deepseek-ai/dsh-skill' @@ -33,6 +37,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 const BUNDLED_RANK = 600 export const name = 'skill-local' @@ -46,6 +53,18 @@ 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 /** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */ bundledSkillDir?: string } @@ -54,6 +73,12 @@ 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), bundledSkillDir: z.string(), }) @@ -62,6 +87,7 @@ interface SkillRoot { source: SkillSource rank: number skipSystem?: boolean + projectRoot?: string trustedHost?: boolean } @@ -85,10 +111,29 @@ 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) + 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') + 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`. */ @@ -97,12 +142,20 @@ export class LocalSkillProvider implements SkillProvider { private readonly dshHome: string private readonly agentsHome: string 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, 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) } @@ -110,17 +163,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) + 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 } } /** @@ -147,13 +208,30 @@ 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. + * @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 { 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( @@ -168,6 +246,453 @@ 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 { + mode: RootWatchMode + 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 readonly lifecycle = new AbortController() + private closing = false + private invalidationQueued = false + + constructor( + private readonly ctx: Context, + private readonly invalidate: () => void, + 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.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.invalidate() + } + + 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() + 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 { + /* 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) + state.opening = opening + void opening.then( + () => { + state.opening = undefined + }, + () => { + state.opening = undefined + }, + ) + 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 + 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) { + // 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 + } + } + + // 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) { + 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 => { + void this.handleAncestorWatchEvent(state, mode) + } + watchFile(mode.nextPath, { + persistent: false, + interval: this.config.pollIntervalMs, + }, listener) + return { + mode, + close() { + unwatchFile(mode.nextPath, listener) + }, + } + } + + 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, + 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, + }) + const handle: WatchHandle = { + mode, + close: () => watcher.close(), + } + 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 => { + 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, event, path) }) + } + try { + await readiness.promise + } catch (error) { + await this.closeWatcher(handle) + throw error + } finally { + signal.removeEventListener('abort', onAbort) + } + return handle + } + + private handleWatchEvent( + state: RootWatchState, + event: SkillWatchEvent, + path: string, + ): void { + if (this.closing || !isRelevantWatchEvent(state.root, event, resolve(path))) return + this.queueInvalidation() + if (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 + /* v8 ignore next -- Effect teardown can win this queued microtask before provider disposal emits. */ + if (this.closing) return + this.invalidate() + }) + } + + 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, + event: SkillWatchEvent, + path: string, +): boolean { + 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) @@ -205,9 +730,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 { @@ -223,9 +751,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[] = [] @@ -293,31 +823,39 @@ async function readSkillText(ctx: Context, path: string, signal?: AbortSignal, t } 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..14e3c07e41 --- /dev/null +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -0,0 +1,370 @@ +import { EventEmitter } from 'node:events' +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' +import { Context } from 'cordis' +import SkillService from '@deepseek-ai/dsh-skill' + +interface FakeWatcherControl { + emitter: EventEmitter + closeCalls: number + options: Record +} + +interface FakeWatchFileControl { + path: string + 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) => { + 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('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) { + 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 + watcherHarness.watchFiles.length = 0 + watcherHarness.statGates.length = 0 +}) + +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('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 once'), + new Error('watch failed twice'), + new Error('watch failed three times'), + ) + 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()).toMatchObject({ + skills: [{ name: 'retry-skill' }], + complete: false, + }) + 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, + 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']) + let invalidations = 0 + ctx.on('skills/change', () => { invalidations += 1 }) + 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('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') + await writeSkill(root, 'racing-skill') + watcherHarness.deferredReady = 1 + 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 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() + + await expect(discovery).rejects.toThrow('skill-local watcher disposed') + await disposal + disposeProvider() + await settle() + 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') + await writeSkill(root, 'rejected-skill') + watcherHarness.deferredReady = 1 + 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 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('error', new Error('opening failed during disposal')) + const disposal = provider.dispose() + + 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 4b7f0671d6..ae57df3bca 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') @@ -235,7 +256,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', @@ -420,7 +441,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'], @@ -444,6 +465,92 @@ describe('LocalSkillProvider', () => { expect((await bundledCtx.skills.get('bundled-host'))?.source).toBe('bundled') }) + 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') @@ -452,7 +559,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 = [] @@ -479,6 +586,223 @@ 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([]) + let invalidations = 0 + ctx.on('skills/change', () => { invalidations += 1 }) + + 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) + 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 + }) + const beforeDisposal = await provider.list({}) + expect((Array.isArray(beforeDisposal) ? beforeDisposal : beforeDisposal.candidates).map(skill => skill.name)) + .toEqual(['disposed-skill']) + + await provider.dispose() + await provider.dispose() + provider.observeHostMutation(join(home, '.agents/skills/disposed-skill/SKILL.md')) + + const afterDisposal = await provider.list({}) + expect((Array.isArray(afterDisposal) ? afterDisposal : afterDisposal.candidates).map(skill => skill.name)) + .toEqual(['disposed-skill']) + disposeProvider() + }) + + 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 @@ -493,7 +817,7 @@ describe('LocalSkillProvider', () => { await writeSkill(bundled, 'env-bundled-skill', 'Env bundled 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-bundled-skill', 'env-skill']) process.env.DSH_HOME = join(envHome, 'empty-dsh') @@ -501,11 +825,14 @@ describe('LocalSkillProvider', () => { 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 - 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 d477da8ab8..dbb25eb911 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: d2b75ff73e97089ed3d8da4192e71898e6b7eac6 -README.zh.md: 1b559e7584fc1646e868d933d13469dfb5d3cef4 +README.md: f538ae668ccff291be86348627d5547150f460df +README.zh.md: 8a44f684ea4d9519a0af7866d272a8e7834aeda6 diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index d2b75ff73e..f538ae668c 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -10,11 +10,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.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns every winning summary for the current workspace, merged across providers and sorted by name. The result is invocation-neutral; consumers apply `isModelInvocable(skill)` or `isUserInvocable(skill)` at their own boundary. +- `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 the invocation-neutral `{ skills, complete }` observation. `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 every winning summary for the current workspace, merged across providers and sorted by name. Consumers apply `isModelInvocable(skill)` or `isUserInvocable(skill)` at their own boundary. - `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 regardless of invocation policy. - `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding the all-invocable policy and `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 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 | Field | Default | Meaning | @@ -36,11 +41,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. 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 `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 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. ## Runtime Skills @@ -48,19 +55,19 @@ Contract violations fail fast. A rejected `list()` is treated as a transient sou ## Consumer boundary -The registry does not render model guidance or register model-facing tools. [`@deepseek-ai/dsh-tool-skill`](../tool-skill) consumes `ctx.skills` to provide the session-prefix catalog and `skill` tool, so providers remain independent of the model surface. +The registry does not render model guidance or register model-facing tools. [`@deepseek-ai/dsh-tool-skill`](../tool-skill) consumes `ctx.skills` to provide durable session catalogs and the `skill` tool, so providers remain independent of the model surface. ## 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 durable initial or 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 the durable initial catalog and append-only 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 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. -- **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. +- **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 1b559e7584..8a44f684ea 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -10,11 +10,16 @@ ### 公开 API -- `ctx.skills.registerProvider(provider): () => void` 使用唯一 `provider.name` 注册只读提供方。重复提供方名称会抛错,`runtime` 保留给 `ctx.skills.register(...)`。注册表借用提供方对象,并直接调用其方法。注册作用域绑定到 effect,可安全用于 HMR(热模块替换);Cordis 返回的原始 disposer 支持有序组合拆卸。 -- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中的全部胜出摘要;这些摘要跨提供方合并,并按名称排序。结果与调用策略无关;消费方在自身边界调用 `isModelInvocable(skill)` 或 `isUserInvocable(skill)`。 +- `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? })` 借用只读查找选项,然后返回当前工作区中的全部胜出摘要;这些摘要跨提供方合并,并按名称排序。消费方在自身边界调用 `isModelInvocable(skill)` 或 `isUserInvocable(skill)`。 - `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后无论调用策略如何都将其返回。 - `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加允许模型和用户调用的策略以及 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。 +### 事件 + +- `skills/change` 是一条不带过滤条件的失效通知,在提供方或运行时贡献注册或释放后,以及活动提供方的注册控制触发失效后发出。它不携带目录或 diff;每个消费方都使用自身的查找选项重新获取 `snapshot()`。监听器抛错或 Promise 拒绝会被记录,既不能否决注册表变更,也不能阻止后续监听器执行。 + ### 配置 | 字段 | 默认值 | 含义 | @@ -36,11 +41,13 @@ ## 提供方契约 -提供方同步注册,并在可等待的 `list(options)` 调用中执行远程设置、身份验证和发现。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。 +提供方工厂同步运行,并接收一项注册作用域内的控制能力。注册失败或释放时,`control.signal` 会中止;仅当该精确注册仍处于活动状态时,`control.invalidate()` 才会清除已完成目录,因此延迟回调无法影响同名替代项。不可变提供方可以忽略该控制能力。远程设置、身份验证和发现由提供方可等待的 `list(options)` 调用执行。返回数组是完整发现的简写形式;若提供方已收集到可用候选项,却无法建立权威观测,则返回 `{ candidates, complete: false }`。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。 注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读契约。 -违反契约时会快速失败。`list()` 返回的 Promise 被拒绝会被视为瞬时来源失败:系统记录并跳过该失败,且不缓存结果。只缓存已完成的目录;提供方或运行时修订发生变化时,会丢弃正在进行的结果并重试。重复名称依次按 rank、提供方注册顺序和提供方本地顺序解决冲突。摘要按 skill 名称排序。 +违反契约时会快速失败。`list()` 返回的 Promise 被拒绝会被视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()` 和 `get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变化时,会丢弃正在进行的结果并重试一次。如果这次重试也被后续修订取代,则返回其候选项,并将结果标为不完整且不予缓存,以免持续触发失效的提供方一直占用调用方。重复名称依次按 rank、提供方注册顺序和提供方本地顺序解决冲突。摘要按 skill 名称排序。 + +定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并由注册表在内部使该精确提供方失效,以便下一次快照重新发现其目录。 ## 运行时 skill @@ -48,19 +55,19 @@ ## 消费方边界 -注册表不渲染模型指引,也不注册面向模型的工具。[`@deepseek-ai/dsh-tool-skill`](../tool-skill) 消费 `ctx.skills` 以提供会话前缀目录和 `skill` 工具,因此提供方仍与模型接口独立。 +注册表不渲染模型指引,也不注册面向模型的工具。[`@deepseek-ai/dsh-tool-skill`](../tool-skill) 消费 `ctx.skills` 以提供持久会话目录和 `skill` 工具,因此提供方仍与模型接口独立。 ## 模型体验 -通过 `dsh-tool-skill` 间接影响模型;该包将提供方摘要渲染到会话前缀中,并将已加载指令渲染到已保留工具结果中。 +通过 `dsh-tool-skill` 间接影响模型;该包将提供方摘要渲染到持久的初始目录或替换目录消息中,并将已加载指令渲染到已保留工具结果中。 #### KV Cache 影响 -不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。 +不直接影响提示词。指定的消费方负责持久初始目录,以及失效后的仅追加式目录替换。 ## 已知限制与暂缓事项 -- **已完成的目录没有 TTL 或监听失效机制**:提供方的底层文件或远程数据可在注册修订不变的情况下更改,因此已缓存的 cwd 会保持陈旧,直到缓存条目被淘汰或提供方/运行时重新加载。 +- **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须保留其注册作用域内的 `invalidate()` 能力,并由自身的观测机制调用它。 - **提供方依次查询**:一个响应取消但速度缓慢的提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不响应取消的提供方持续运行的工作。 -- **提供方列表失败会使该请求无法使用整个来源**:注册表会记录并跳过该来源,不提供模型可见诊断或部分目录恢复契约。 +- **不保留不完整观测**:被拒绝的提供方会被省略,显式提供的候选项也仅在当前查找中可用;注册表既不负责上一份可用目录,也不负责逐提供方诊断。 - **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。 diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index 3af58804b1..c79d7861e1 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 @@ -118,6 +119,22 @@ export function isUserInvocable(skill: Pick): boolea return skill.invocation.userInvocable } +/** One catalog observation plus whether discovery completed within a stable catalog revision. */ +export interface SkillCatalogSnapshot { + /** Sorted invocation-neutral summaries collected in this observation. */ + readonly skills: SkillSummary[] + /** Whether every registered provider completed without a concurrent catalog revision. */ + 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. */ @@ -128,9 +145,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. @@ -140,6 +158,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. */ @@ -150,6 +176,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 { @@ -166,7 +203,7 @@ interface CollectResult { /** * Registry of skill providers. It merges provider catalogs with stable - * first-wins duplicate handling, exposes sorted model-visible summaries, and + * first-wins duplicate handling, exposes sorted invocation-neutral summaries, and * loads full skill bodies on demand. */ export class SkillService extends Service { @@ -192,32 +229,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 + 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 + } } /** @@ -265,10 +320,25 @@ export class SkillService extends Service { * @returns all sorted winning summaries. */ async list(options: SkillLookupOptions = {}): Promise { - return (await this.collect(options)) - .map(entry => entry.candidate) - .map(toSummary) - .sort(compareSkillSummary) + return (await this.snapshot(options)).skills + } + + /** + * Observe the current invocation-neutral 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 discovery-completeness state. + */ + async snapshot(options: SkillLookupOptions = {}): Promise { + const collected = await this.collect(options) + return { + skills: collected.entries + .map(entry => entry.candidate) + .map(toSummary) + .sort(compareSkillSummary), + complete: collected.cacheable, + } } /** @@ -283,7 +353,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), @@ -291,21 +361,32 @@ 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) + let attempt = 1 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) - 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) { @@ -313,7 +394,7 @@ export class SkillService extends Service { this.collectCache.delete(oldest.value) } } - return result.entries + return result } } @@ -359,11 +440,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 @@ -375,7 +454,45 @@ export class SkillService extends Service { private invalidateCache(): void { this.providerRevision += 1 this.collectCache.clear() + 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'])) { + 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)}`) + } + } + } +} + +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 = { diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index 77a5db20ce..7bb51915f1 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -8,6 +8,7 @@ import SkillService, { type SkillInvocationPolicy, type SkillLookupOptions, type SkillProvider, + type SkillProviderObservation, } from '@deepseek-ai/dsh-skill' function memorySkill(name: string, description: string, rank: number, body = `${name} body.`): SkillCandidate { @@ -43,6 +44,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() @@ -69,8 +74,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'], @@ -95,24 +100,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']) @@ -150,7 +183,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), @@ -164,7 +197,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), @@ -176,15 +209,18 @@ describe('SkillService registry', () => { await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean invocation.modelInvocable') }) - it('rejects non-array provider results and every malformed candidate scalar', async () => { - const badList = new Context() - await badList.plugin(SkillService) - badList.skills.registerProvider({ - 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' }, @@ -210,7 +246,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), @@ -235,7 +271,7 @@ describe('SkillService registry', () => { rank: 1, locator: 'skill-a', } - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'contextual', async list(received) { listedWith = received @@ -258,7 +294,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 [{ @@ -309,7 +345,7 @@ describe('SkillService registry', () => { }) } }) - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'held', async list() { return [{ @@ -391,7 +427,7 @@ describe('SkillService registry', () => { } let listCalls = 0 let received: SkillCandidate | undefined - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'detached', async list() { listCalls += 1 @@ -485,7 +521,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, @@ -519,7 +555,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)] @@ -538,7 +574,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] @@ -556,7 +592,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), ])) @@ -581,7 +617,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)]) @@ -608,7 +644,7 @@ describe('SkillService registry', () => { let fail = true let flakyCalls = 0 - ctx.skills.registerProvider({ + registerProvider(ctx, { name: 'flaky', async list() { flakyCalls += 1 @@ -619,7 +655,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) @@ -630,6 +668,231 @@ 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) + const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)]) + 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)]) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill']) + + 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)]) + registerProvider(ctx, replacement) + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill']) + invalidate() + 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 }) + + let invalidate = (): void => {} + const disposeProvider = ctx.skills.registerProvider((control) => { + invalidate = control.invalidate + return provider + }) + expect(changes).toBe(1) + invalidate() + 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) + invalidate() + 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(() => registerProvider(ctx, 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) + } + 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)]) + invalidate() + release?.() + + expect((await pending).map(skill => skill.name)).toEqual(['fresh-skill']) + 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', + invocation: { modelInvocable: true, userInvocable: true }, + 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) + let listCalls = 0 + const provider: SkillProvider = { + name: 'renamed', + async list() { + listCalls += 1 + return [{ + name: 'old-name', + description: 'Old name', + invocation: { modelInvocable: true, userInvocable: true }, + provider: 'renamed', + source: 'test', + rank: 1, + locator: 'old-name', + }] + }, + async get(candidate) { + return { ...candidate, name: 'new-name', content: 'Fresh body.' } + }, + } + registerProvider(ctx, 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) + registerProvider(ctx, { + 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) @@ -640,7 +903,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. @@ -665,7 +928,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?.() @@ -695,7 +958,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/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index 05b93b2673..9d706de673 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-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/tool-skill/README.md -README.md: ddf2b7503e8dad47677aa5260b0aa3349f4c4dd1 -README.zh.md: 0d06210efae4060aa591aff39d9bd27c8c59d341 +README.md: d8e00bc839358f58cd83bfa9b28eed09dd407bce +README.zh.md: 6c0df1d6e38c99ce64cadeb668bbf0ad7b3029e3 diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index ddf2b7503e..d8e00bc839 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 catalog +## Catalog lifecycle -The plugin injects one durable user-role `` catalog at the first `agent/step` of a live session. It resolves skills for the calling session's cwd, forwards the step 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. +At every `agent/step`, the plugin calls `ctx.skills.snapshot()` for the calling session's cwd, forwards the step abort signal to discovery, applies exact `skill` tool visibility, and renders the ordered `name` and `description` entries. When no prior catalog exists and that view is non-empty, it injects an initial durable user-role `` before the request. Catalog messages contain 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 catalog is a sourced `user/message` injected before the first request and retained in ordinary session history. +The digest covers the exact rendered text between the `` tags. The plugin scans durable session events backwards without copying them and derives the comparison baseline from the newest recognizable visible catalog message it sourced. When the digest changes, `agent.inject()` records a durable user-role message containing the complete replacement catalog; an empty replacement explicitly retires earlier names. If no catalog remains visible but a recognizable historical catalog exists, compaction hid it and the next complete observation re-establishes the current catalog. 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 [skill catalog hot-refresh Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) owns the durable initial catalog and replacement lifecycle. ## 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 skills whose `invocation.modelInvocable` is `false` produce distinct error results. `invocation.userInvocable` does not restrict this model-facing surface. -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 one durable user-role message. +If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. 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 -Append-only after the existing reusable prefix. A new or resumed instance with different providers, skills, descriptions, visibility, or catalog limits may affect cache reuse from the newly appended catalog position. +The initial durable catalog is appended after the existing reusable prefix. Dynamic changes are append-only history after that catalog, so earlier reusable tokens stay intact while each newly appended catalog and later turns form a new suffix. A new or resumed instance with a changed digest may affect cache reuse from the newly appended catalog position. ### 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 0d06210efa..6c0df1d6e3 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/step` 注入一条持久的用户角色 `` 目录。它为调用会话的 cwd 解析 skill,将步骤中止信号转发到发现流程,并只列出已排序的 `name` 和 `description` 条目;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。如果没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。这项对工具定义的精确匹配检查使提示词指引、模型可见 schema 和可执行分派保持对齐。 +每次 `agent/step`,该插件都会使用调用会话的 cwd 调用 `ctx.skills.snapshot()`,将步骤中止信号转发到发现流程,应用 `skill` 工具的精确可见性,并按顺序渲染 `name` 和 `description` 条目。如果先前不存在目录且该视图非空,插件会在请求之前注入初始的持久用户角色 ``。目录消息只包含这些摘要;skill 正文、路径、来源、提供方和 `whenToUse` 提示仍位于目录之外。 -`catalogDescriptionMaxLength` 控制规范化且经 XML 转义的目录描述。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。目录是一条带来源的 `user/message`,在第一个请求前注入,并保留在普通会话历史中。 +该 digest 覆盖 `` 标签之间精确渲染的文本。插件从后向前扫描持久会话事件且不复制,并以自身发布的最新一条可识别且仍可见的目录消息作为比较基线。digest 变化时,`agent.inject()` 会记录一条包含完整替换目录的持久用户角色消息;空替换会显式停用较早的名称。如果没有目录仍然可见,但历史中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录。提供方快照不完整时,插件不会发送任何内容,并会保留最后一次完整的模型视图,以便在下一步骤重试。若不存在先前目录且当前视图为空,则不需要 tombstone。 + +如果最初没有模型可调用 skill,则省略目录;如果该 agent(智能体)的工具视图排除了随附的 `skill` 工具,或解析出同名的作用域内遮蔽项,也会省略目录。可见性变更参与 digest 计算,使提示词指引、模型可见 schema 和可执行分派保持对齐。 + +`catalogDescriptionMaxLength` 控制规范化且经 XML 转义的目录描述。其默认值是 `500`,且必须是不小于 `3` 的整数,以便为截断省略号保留空间。[skill 目录热刷新 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md) 负责定义持久初始目录和替换目录的生命周期。 ## 工具:`skill` @@ -24,7 +28,7 @@ 无法解析的名称会报告 skill 未知或已不可用。无效名称和 `invocation.modelInvocable` 为 `false` 的 skill 会产生不同的错误结果。`invocation.userInvocable` 不限制这个面向模型的接口。 -该工具在 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 Cache 影响 -仅追加,位于现有可重用前缀之后。如果新建或恢复的实例具有不同提供方、skill、描述、可见性或目录上限,则可能从新追加的目录位置起影响缓存重用。 +初始持久目录追加在现有可重用前缀之后。动态变更作为该目录之后的仅追加历史,因此较早的可重用 token 保持不变,每条新追加的目录和后续轮次都会形成新的后缀。新建或恢复的实例如果 digest 发生变化,可能会从新追加的目录位置起影响缓存重用。 ### 工具 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 9c19aac4de..2398fe02cb 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -4,6 +4,7 @@ * @module @deepseek-ai/dsh-tool-skill */ +import { createHash } from 'node:crypto' import type { Context } from 'cordis' import z from 'schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -18,9 +19,12 @@ import { } 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_ENTRIES_START = '\n' +const CATALOG_ENTRIES_END = '' +const PLUGIN_SOURCE = { kind: 'plugin', plugin: 'dsh-tool-skill' } as const /** Model-facing skill catalog configuration. */ export interface Config { @@ -130,20 +134,22 @@ 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. - const catalogLoaded = new WeakSet() ctx.on('agent/step', async (agent: Agent, _turn, _step, signal): Promise => { - if (catalogLoaded.has(agent.session)) return - if (ctx.tools.get(skillTool.name, agent) !== registeredSkillTool) { - catalogLoaded.add(agent.session) - return - } - const skills = (await ctx.skills.list({ cwd: agent.session.header.cwd, signal })) - .filter(isModelInvocable) - if (skills.length > 0) { - const catalog = renderCatalogMessage(skills, catalogDescriptionMaxLength) - agent.inject(catalog) - } - catalogLoaded.add(agent.session) + 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 skills = snapshot.skills.filter(isModelInvocable) + const digest = catalogDigest(skills, catalogDescriptionMaxLength) + const history = catalogHistory(agent) + if (history.visibleDigest === digest) return + if (!history.published && skills.length === 0) return + const catalog = history.published + ? renderCatalogUpdate(skills, catalogDescriptionMaxLength) + : renderCatalogMessage(skills, catalogDescriptionMaxLength) + agent.inject(catalog) }) } @@ -194,7 +200,7 @@ function renderResourceHint(skill: Pick `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`) + const entries = renderCatalogEntries(skills, descriptionMaxLength) return createUserMessage({ content: [{ type: 'text', @@ -210,10 +216,84 @@ function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: numb '', ].join('\n'), }], - source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, + source: PLUGIN_SOURCE, }) } +function renderCatalogUpdate(skills: SkillSummary[], descriptionMaxLength: number): UserMessage { + 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 createUserMessage({ + 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'), + }], + source: PLUGIN_SOURCE, + }) +} + +function renderCatalogEntries(skills: SkillSummary[], descriptionMaxLength: number): string[] { + return skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`) +} + +function catalogDigest(skills: SkillSummary[], descriptionMaxLength: number): string { + return digestCatalogEntries(renderCatalogEntries(skills, descriptionMaxLength).join('\n')) +} + +function digestCatalogEntries(entries: string): string { + return createHash('sha256') + .update(entries) + .digest('hex') +} + +function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } { + const visible = new Set(agent.session.surface.nodes) + const events = agent.session.events + let published = false + 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 (event.type !== 'user/message' + || event.data.source.kind !== 'plugin' + || event.data.source.plugin !== PLUGIN_SOURCE.plugin) continue + const digest = catalogContentDigest(event.data.content) + if (digest === undefined) continue + published = true + if (visible.has(event.seq)) return { visibleDigest: digest, published } + } + return { published } +} + +function catalogContentDigest(content: UserMessage['content']): string | undefined { + if (content.length !== 1 || content[0]?.type !== 'text') return undefined + const text = content[0].text + const start = text.indexOf(CATALOG_ENTRIES_START) + if (start === -1) return undefined + const entriesStart = start + CATALOG_ENTRIES_START.length + const end = text.indexOf(CATALOG_ENTRIES_END, entriesStart) + if (end === -1) return undefined + 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 { 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 5b52db0842..48e7b942b1 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -4,12 +4,11 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' import { createUserMessage, CallId, type Message } from '@deepseek-ai/dsh-llm' -import {} from '@deepseek-ai/dsh-agent' -import { Session, SessionId } from '@deepseek-ai/dsh-session' 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, 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' @@ -30,8 +29,9 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise {}, + followup: () => {}, + steer: () => {}, + inject(input) { + session.append('user/message', input, { surfaceOp: 'append' }) + }, + 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', createUserMessage({ + content: [{ type: 'text', text: `turn ${turn}` }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) +} + +async function fireStep(ctx: Context, agent: Agent, turn: number, step: number): Promise { + await agentEvents(ctx, agent).serial('agent/step', turn, step, new AbortController().signal) +} + +function catalogMessages(session: Session): Extract[] { + return session.events.filter((event): event is Extract => event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'dsh-tool-skill') +} + +function catalogContent(entries: string[]): Message['content'] { + return [{ + type: 'text', + text: ['', '', ...entries, '', ''].join('\n'), + }] +} + async function composePrefix(ctx: Context, cwd: string, signal = new AbortController().signal): Promise { return await composePrefixForAgent(ctx, agentForCwd(cwd), signal) } @@ -66,8 +110,8 @@ async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new Ab return agent.session.deriveMessages() } -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'], @@ -80,9 +124,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) @@ -106,7 +151,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 @@ -115,7 +160,7 @@ describe('dsh-tool-skill', () => { async get() { return undefined }, - }) + })) const controller = new AbortController() await composePrefix(ctx, '/workspace', controller.signal) @@ -216,15 +261,233 @@ describe('dsh-tool-skill', () => { expect(await composePrefixForAgent(ctx, agent)).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 + }, + } + 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) + + await composePrefixForAgent(ctx, agent) + expect(catalogMessages(session)).toEqual([]) + failing = false + invalidate() + await fireStep(ctx, agent, 1, 1) + + expect(catalogMessages(session)).toEqual([]) + }) + + it('records an empty baseline across repeated step observations', async () => { + const home = await tempDir('tool-empty-step') + const ctx = await setup(home) + const session = new Session(SessionId('empty-step')) + const agent = sessionAgent(session) + openMessageTurn(session) + + await fireStep(ctx, agent, 1, 1) + await fireStep(ctx, agent, 1, 2) + + expect(catalogMessages(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 fireStep(ctx, agent, 1, 1) + expect(catalogMessages(session)).toHaveLength(1) + + const disposeSecond = ctx.skills.register({ + name: 'second-skill', + description: 'Second skill', + source: 'runtime', + content: 'Second body.', + }) + await fireStep(ctx, agent, 1, 2) + + const addition = catalogMessages(session)[1] + if (addition?.type !== 'user/message') throw new Error('expected catalog addition') + expect(JSON.stringify(addition.data.content)).toContain('first-skill') + expect(JSON.stringify(addition.data.content)).toContain('second-skill') + + disposeSecond() + disposeFirst() + await fireStep(ctx, agent, 1, 3) + + const removal = catalogMessages(session)[2] + 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') + + await fireStep(ctx, agent, 1, 4) + expect(catalogMessages(session)).toHaveLength(3) + }) + + it('resumes from the latest valid visible catalog content', 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', createUserMessage({ + content: catalogContent(['- `old-skill`: Old skill']), + source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, + }), { surfaceOp: 'append' }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'missing catalog markers' }], + source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, + }), { surfaceOp: 'append' }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: '\nmissing closing marker' }], + source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, + }), { surfaceOp: 'append' }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'first block' }, { type: 'text', text: 'second block' }], + source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, + }), { surfaceOp: 'append' }) + session.append('user/message', createUserMessage({ + content: [{ type: 'reasoning', text: 'not a user-role catalog block' }], + source: { kind: 'plugin', plugin: 'dsh-tool-skill' }, + }), { surfaceOp: 'append' }) + + await fireStep(ctx, agent, 1, 1) + + expect(catalogMessages(session)).toHaveLength(6) + expect(JSON.stringify(catalogMessages(session).at(-1)?.data.content)).toContain('resumed-skill') + }) + + it('re-establishes the current catalog after compaction hides its durable message', 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') + const initial = catalogMessages(session)[0] + if (initial === undefined) throw new Error('expected initial catalog') + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'compacted history' }], + source: { kind: 'plugin', plugin: 'compact' }, + }), { + surfaceOp: { op: 'replace', start: initial.seq, end: initial.seq }, + sourceEventSeqs: [initial.seq], + }) + + await fireStep(ctx, agent, 1, 1) + + expect(catalogMessages(session)).toHaveLength(2) + expect(JSON.stringify(catalogMessages(session).at(-1)?.data.content)).toContain('first-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 fireStep(ctx, agent, 1, 1) + expect(catalogMessages(session)).toHaveLength(1) + + 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 fireStep(ctx, agent, 1, 1) + + expect(catalogMessages(session)).toHaveLength(1) + }) + 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 composePrefixForAgent(ctx, agent) + expect(catalogMessages(session)).toEqual([]) + await fireStep(ctx, agent, 1, 1) + expect(catalogMessages(session)).toEqual([]) expect(await composePrefix(ctx, '/workspace')).toHaveLength(1) await scope.dispose() }) @@ -254,8 +517,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') }) @@ -392,7 +656,7 @@ describe('dsh-tool-skill', () => { const home = await tempDir('tool-policy-before-load') const ctx = await setup(home) const getCalls: string[] = [] - ctx.skills.registerProvider({ + ctx.skills.registerProvider(() => ({ name: 'policy-probe', async list() { return [ @@ -434,7 +698,7 @@ describe('dsh-tool-skill', () => { content: 'Instructions must not be disclosed.', } }, - }) + })) const denied = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c6'), name: 'skill', arguments: { name: 'denied-skill' } }) const raced = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c7'), name: 'skill', arguments: { name: 'policy-race-skill' } }) diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index be6f9afdb7..864f4ff0bf 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/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/ui/tui/README.md -README.md: dead972f93d5e72fc6153bacc0ecd9cd80f725ca -README.zh.md: 879bb0951df061d88151aa43544baf3e6c4b02e8 +README.md: 88c4501d87b7f24de1f5cc0d67f4c0e03ec49aa4 +README.zh.md: f03120e5a7820e2bcb572ab31b535211cf859c82 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index dead972f93..88c4501d87 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -139,7 +139,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: autocomplete and exact invocation apply `invocation.userInvocable`, while `invocation.modelInvocable` does not restrict this surface. A user-disabled skill produces a warning and no user turn. The skill service is an optional peer; this policy check uses its type contract without introducing a runtime package dependency. +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: autocomplete and exact invocation apply `invocation.userInvocable`, while `invocation.modelInvocable` does not restrict this surface. User-disabled skills are omitted from autocomplete and rejected before exact-name loading; the loaded definition is rechecked for a policy race. 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. The skill service is an optional peer; this policy check uses its type contract without introducing a runtime package dependency. #### Token effect diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 879bb0951d..f03120e5a7 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -139,7 +139,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read #### 模型看到的内容 -提交 `/skill: [instructions]` 会加载具名 skill,并交付一个文本块:用 `` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型:自动补全和按精确名称调用都应用 `invocation.userInvocable`,`invocation.modelInvocable` 不限制这个接口。用户禁用的 skill 会产生一条警告,不会产生用户轮次。skill 服务是可选 peer;这项策略检查仅使用其类型契约,不引入运行时包依赖。 +提交 `/skill: [instructions]` 会加载具名 skill,并交付一个文本块:用 `` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型:自动补全和按精确名称调用都应用 `invocation.userInvocable`,`invocation.modelInvocable` 不限制这个接口。用户禁用的 skill 不出现在自动补全中,按精确名称调用时也会在加载前被拒绝;为防止策略竞态,加载后的定义还会再次接受检查。自动补全会保留最后一份完整 skill 快照,并在 `skills/change` 后重新获取。观测不完整时保留先前菜单,完整的空观测会将其清空;如果目录在斜杠命令名称草稿打开期间到达,则会立即根据该草稿重新查询。skill 服务是可选 peer;这项策略检查仅使用其类型契约,不引入运行时包依赖。 #### Token 影响 diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index b5e632a5bd..639191aca4 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1043,9 +1043,12 @@ export function createTuiChat( requestRender() } - // Skill listing is async while `createTuiChat` is synchronous, so the - // completions rebuild once the invocation-neutral catalog resolves. + // Skill listing is async while `createTuiChat` is synchronous, so the TUI + // retains the last complete invocation-neutral catalog for synchronous + // editor completion, filters it for user invocation, and refreshes it after + // registry invalidation. let skillCommands: SlashCommand[] = [] + let skillCommandScan = 0 const refreshCommandAutocomplete = (): void => { const base = new CombinedAutocompleteProvider( [ @@ -1066,14 +1069,25 @@ 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) => { - const invocable = summaries.filter(skill => skill.invocation.userInvocable) - if (disposed || invocable.length === 0) return + const refreshSkillCommands = (service: SkillService): void => { + const scan = ++skillCommandScan + service.snapshot({ cwd, signal: skillAbort.signal }).then( + (snapshot) => { + if (disposed || scan !== skillCommandScan || !snapshot.complete) return + const invocable = snapshot.skills.filter(skill => skill.invocation.userInvocable) // The argument-hint slot shows in the menu but is never inserted on // selection, so it carries the skill's scope instead of an // instructions placeholder. `SkillSource` is open-ended; every @@ -1085,6 +1099,7 @@ export function createTuiChat( argumentHint: skill.source.startsWith('project-') ? '(project)' : '(user)', })) refreshCommandAutocomplete() + refreshVisibleSlashAutocomplete() requestRender() }, () => { @@ -1093,7 +1108,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 @@ -1508,6 +1526,7 @@ export function createTuiChat( fileSearch.dispose() removeInputListener() disposeCommandChanges() + disposeSkillChanges() disposePromptChanges() for (const value of promptValues) value.dispose() stopBannerReveal() diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 8cf5d29647..7d18540c2b 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -18,7 +18,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, type SkillSummary } 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' @@ -3866,6 +3866,135 @@ 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 invalidate = (): void => {} + 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', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'test', + provider: 'flaky-completion', + rank: 1, + locator: 'stable', + }] + }, + async get() { + return undefined + }, + } + skills?.registerProvider((control) => { + invalidate = control.invalidate + return provider as SkillProvider + }) + }, + }) + if (skills === undefined || provider === undefined) throw new Error('skills provider not mounted') + + fail = true + invalidate() + 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', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'runtime', + provider: 'runtime', + }], + complete: true, + }) + await tick() + pendingSnapshots[0]?.resolve({ + skills: [{ name: 'stale-first', description: 'STALE_FIRST', invocation: { modelInvocable: true, userInvocable: true }, source: 'runtime', provider: 'runtime' }], + complete: true, + }) + pendingSnapshots[1]?.resolve({ + skills: [{ name: 'stale-second', description: 'STALE_SECOND', invocation: { modelInvocable: true, userInvocable: true }, 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') @@ -3891,6 +4020,22 @@ describe('skill slash command', () => { }) it('checks user policy before loading and rechecks the loaded definition', async () => { + const summaries: SkillSummary[] = [ + { + name: 'model-only-skill', + description: 'Model-only skill', + invocation: { modelInvocable: true, userInvocable: false }, + source: 'runtime', + provider: 'runtime', + }, + { + name: 'policy-race-skill', + description: 'Policy race skill', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'runtime', + provider: 'runtime', + }, + ] const get = vi.fn((name: string) => Promise.resolve({ name, description: 'Policy race skill', @@ -3903,22 +4048,8 @@ describe('skill slash command', () => { configureContext: async (ctx) => { ctx.provide('tools', { get() { return undefined } } as never) ctx.provide('skills', { - list: () => Promise.resolve([ - { - name: 'model-only-skill', - description: 'Model-only skill', - invocation: { modelInvocable: true, userInvocable: false }, - source: 'runtime', - provider: 'runtime', - }, - { - name: 'policy-race-skill', - description: 'Policy race skill', - invocation: { modelInvocable: true, userInvocable: true }, - source: 'runtime', - provider: 'runtime', - }, - ]), + snapshot: () => Promise.resolve({ skills: summaries, complete: true }), + list: () => Promise.resolve(summaries), get, } as never) }, @@ -3989,6 +4120,7 @@ describe('skill slash command', () => { configureContext: async (ctx) => { ctx.provide('tools', { get() { return undefined } } as never) ctx.provide('skills', { + snapshot: () => Promise.reject(new Error('list boom')), list: () => Promise.reject(new Error('list boom')), get: () => Promise.reject(new Error('get boom')), } as never) @@ -4005,16 +4137,17 @@ describe('skill slash command', () => { it('drops skill list and lookup results that settle after disposal', async () => { let listCalls = 0 let resolvePendingList: ((value: SkillSummary[]) => void) | undefined + 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', { + snapshot: () => new Promise((resolve) => { pendingSnapshots.push(resolve) }), list: () => { listCalls += 1 - if (listCalls === 1) return Promise.resolve([]) - if (listCalls === 2 || listCalls === 3) { - const name = listCalls === 2 ? 'demo-skill' : 'error-skill' + if (listCalls === 1 || listCalls === 2) { + const name = listCalls === 1 ? 'demo-skill' : 'error-skill' return Promise.resolve([{ name, description: 'demo', @@ -4041,6 +4174,20 @@ describe('skill slash command', () => { await tick() await dispose(result) + result.ctx.emit('skills/change') + expect(pendingSnapshots).toHaveLength(1) + for (const resolve of pendingSnapshots) { + resolve({ + skills: [{ + name: 'late', + description: 'late', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'runtime', + provider: 'runtime', + }], + complete: true, + }) + } resolvePendingList?.([{ name: 'other-skill', description: 'late', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6bcfa8fc8..362e622aab 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 @@ -1495,9 +1501,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 @@ -2226,6 +2238,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 @@ -2937,6 +2952,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 @@ -4067,6 +4085,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 @@ -4108,6 +4129,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 @@ -8469,6 +8493,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'} @@ -10204,6 +10232,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} @@ -13437,6 +13469,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: @@ -15523,6 +15559,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 0f3270f8ee..fa2fbd4533 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', @@ -154,9 +155,11 @@ 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', + SkillProviderObservation: 'skills.md', SkillRegistration: 'skills.md', SkillSummary: 'skills.md', SaveTextSpill: 'spill.md', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 7bdc8b68a8..a9bbb4c8c1 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -310,9 +310,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 c668f40279..680a462d36 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -974,6 +974,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", @@ -994,11 +999,21 @@ "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", "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", diff --git a/tsconfig.base.json b/tsconfig.base.json index ca4fc68b58..fc2f2c1ee4 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",